diff --git a/ccmsi.lua b/ccmsi.lua index cc4a5d0..86f2094 100644 --- a/ccmsi.lua +++ b/ccmsi.lua @@ -138,6 +138,7 @@ local function gen_tree(manifest) for i = 1, #list do local split = {} +---@diagnostic disable-next-line: discard-returns string.gsub(list[i], "([^/]+)", function(c) split[#split + 1] = c end) if #split == 1 then table.insert(tree, list[i]) else table.insert(tree, _tree_add(tree, split)) end diff --git a/coordinator/configure.lua b/coordinator/configure.lua index 54fb854..f57f11e 100644 --- a/coordinator/configure.lua +++ b/coordinator/configure.lua @@ -7,6 +7,7 @@ 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 types = require("scada-common.types") local util = require("scada-common.util") local themes = require("graphics.themes") @@ -756,7 +757,7 @@ local function config_view(display) local clock_fmt = RadioButton{parent=crd_c_1,x=1,y=5,default=util.trinary(ini_cfg.Time24Hour,1,2),options={"24-Hour","12-Hour"},callback=function()end,radio_colors=cpair(colors.lightGray,colors.black),select_color=colors.lime} TextBox{parent=crd_c_1,x=1,y=8,height=1,text="Temperature Scale"} - local temp_scale = RadioButton{parent=crd_c_1,x=1,y=9,default=ini_cfg.TempScale,options={"Kelvin","Celsius","Fahrenheit","Rankine"},callback=function()end,radio_colors=cpair(colors.lightGray,colors.black),select_color=colors.lime} + local temp_scale = RadioButton{parent=crd_c_1,x=1,y=9,default=ini_cfg.TempScale,options=types.TEMP_SCALE_NAMES,callback=function()end,radio_colors=cpair(colors.lightGray,colors.black),select_color=colors.lime} local function submit_ui_opts() tmp_cfg.Time24Hour = clock_fmt.get_value() == 1 @@ -1356,7 +1357,7 @@ local function config_view(display) if f[1] == "AuthKey" then val = string.rep("*", string.len(val)) elseif f[1] == "LogMode" then val = util.trinary(raw == log.MODE.APPEND, "append", "replace") elseif f[1] == "TempScale" then - if raw == 1 then val = "Kelvin" elseif raw == 2 then val = "Celsius" elseif raw == 3 then val = "Fahrenheit" elseif raw == 4 then val = "Rankine" end + val = types.TEMP_SCALE_NAMES[raw] elseif f[1] == "MainTheme" then val = util.strval(themes.ui_theme_name(raw)) elseif f[1] == "FrontPanelTheme" then @@ -1449,9 +1450,11 @@ function configurator.configure(start_code, message) elseif event == "paste" then display.handle_paste(param1) elseif event == "peripheral_detach" then +---@diagnostic disable-next-line: discard-returns ppm.handle_unmount(param1) tool_ctl.gen_mon_list() elseif event == "peripheral" then +---@diagnostic disable-next-line: discard-returns ppm.mount(param1) tool_ctl.gen_mon_list() elseif event == "monitor_resize" then diff --git a/coordinator/coordinator.lua b/coordinator/coordinator.lua index f6a2018..27543ec 100644 --- a/coordinator/coordinator.lua +++ b/coordinator/coordinator.lua @@ -232,8 +232,8 @@ function coordinator.comms(version, nic, sv_watchdog) local self = { sv_linked = false, sv_addr = comms.BROADCAST, - sv_seq_num = 0, - sv_r_seq_num = nil, + sv_seq_num = util.time_ms() * 10, -- unique per peer, restarting will not re-use seq nums due to message rate + sv_r_seq_num = nil, ---@type nil|integer sv_config_err = false, last_est_ack = ESTABLISH_ACK.ALLOW, last_api_est_acks = {}, @@ -492,7 +492,7 @@ function coordinator.comms(version, nic, sv_watchdog) _send_api_establish_ack(packet.scada_frame, ESTABLISH_ACK.BAD_API_VERSION) elseif dev_type == DEVICE_TYPE.PKT then -- pocket linking request - local id = apisessions.establish_session(src_addr, firmware_v) + local id = apisessions.establish_session(src_addr, packet.scada_frame.seq_num(), firmware_v) coordinator.log_comms(util.c("API_ESTABLISH: pocket (", firmware_v, ") [@", src_addr, "] connected with session ID ", id)) local conf = iocontrol.get_db().facility.conf @@ -515,15 +515,15 @@ function coordinator.comms(version, nic, sv_watchdog) elseif r_chan == config.SVR_Channel then -- check sequence number if self.sv_r_seq_num == nil then - self.sv_r_seq_num = packet.scada_frame.seq_num() - elseif self.sv_linked and ((self.sv_r_seq_num + 1) ~= packet.scada_frame.seq_num()) then + self.sv_r_seq_num = packet.scada_frame.seq_num() + 1 + elseif self.sv_r_seq_num ~= packet.scada_frame.seq_num() then log.warning("sequence out-of-order: last = " .. self.sv_r_seq_num .. ", new = " .. packet.scada_frame.seq_num()) return false elseif self.sv_linked and src_addr ~= self.sv_addr then log.debug("received packet from unknown computer " .. src_addr .. " while linked; channel in use by another system?") return false else - self.sv_r_seq_num = packet.scada_frame.seq_num() + self.sv_r_seq_num = packet.scada_frame.seq_num() + 1 end -- feed watchdog on valid sequence number @@ -706,7 +706,6 @@ function coordinator.comms(version, nic, sv_watchdog) self.sv_addr = src_addr self.sv_linked = true - self.sv_r_seq_num = nil self.sv_config_err = false iocontrol.fp_link_state(types.PANEL_LINK_STATE.LINKED) diff --git a/coordinator/iocontrol.lua b/coordinator/iocontrol.lua index 1de337f..5d8428f 100644 --- a/coordinator/iocontrol.lua +++ b/coordinator/iocontrol.lua @@ -14,6 +14,8 @@ local pgi = require("coordinator.ui.pgi") local ALARM_STATE = types.ALARM_STATE local PROCESS = types.PROCESS +local TEMP_SCALE = types.TEMP_SCALE +local TEMP_UNITS = types.TEMP_SCALE_UNITS -- nominal RTT is ping (0ms to 10ms usually) + 500ms for CRD main loop tick local WARN_RTT = 1000 -- 2x as long as expected w/ 0 ping @@ -47,17 +49,16 @@ end -- initialize the coordinator IO controller ---@param conf facility_conf configuration ---@param comms coord_comms comms reference ----@param temp_scale integer temperature unit (1 = K, 2 = C, 3 = F, 4 = R) +---@param temp_scale TEMP_SCALE temperature unit function iocontrol.init(conf, comms, temp_scale) + io.temp_label = TEMP_UNITS[temp_scale] + -- temperature unit label and conversion function (from Kelvin) - if temp_scale == 2 then - io.temp_label = "\xb0C" + if temp_scale == TEMP_SCALE.CELSIUS then io.temp_convert = function (t) return t - 273.15 end - elseif temp_scale == 3 then - io.temp_label = "\xb0F" + elseif temp_scale == TEMP_SCALE.FAHRENHEIT then io.temp_convert = function (t) return (1.8 * (t - 273.15)) + 32 end - elseif temp_scale == 4 then - io.temp_label = "\xb0R" + elseif temp_scale == TEMP_SCALE.RANKINE then io.temp_convert = function (t) return 1.8 * t end else io.temp_label = "K" @@ -92,6 +93,7 @@ function iocontrol.init(conf, comms, temp_scale) ---@type WASTE_PRODUCT auto_current_waste_product = types.WASTE_PRODUCT.PLUTONIUM, auto_pu_fallback_active = false, + auto_sps_disabled = false, radiation = types.new_zero_radiation_reading(), @@ -227,6 +229,8 @@ function iocontrol.init(conf, comms, temp_scale) ---@class ioctl_unit local entry = { unit_id = i, + connected = false, + rtu_hw = { boilers = {}, turbines = {} }, num_boilers = 0, num_turbines = 0, @@ -244,6 +248,9 @@ function iocontrol.init(conf, comms, temp_scale) waste_mode = types.WASTE_MODE.MANUAL_PLUTONIUM, waste_product = types.WASTE_PRODUCT.PLUTONIUM, + last_rate_change_ms = 0, + turbine_flow_stable = false, + -- auto control group a_group = 0, @@ -318,12 +325,14 @@ function iocontrol.init(conf, comms, temp_scale) for _ = 1, conf.cooling.r_cool[i].BoilerCount do table.insert(entry.boiler_ps_tbl, psil.create()) table.insert(entry.boiler_data_tbl, {}) + table.insert(entry.rtu_hw.boilers, { connected = false, faulted = false }) end -- create turbine tables for _ = 1, conf.cooling.r_cool[i].TurbineCount do table.insert(entry.turbine_ps_tbl, psil.create()) table.insert(entry.turbine_data_tbl, {}) + table.insert(entry.rtu_hw.turbines, { connected = false, faulted = false }) end -- create tank tables @@ -593,7 +602,7 @@ function iocontrol.update_facility_status(status) local ctl_status = status[1] - if type(ctl_status) == "table" and #ctl_status == 16 then + if type(ctl_status) == "table" and #ctl_status == 17 then fac.all_sys_ok = ctl_status[1] fac.auto_ready = ctl_status[2] @@ -644,9 +653,11 @@ function iocontrol.update_facility_status(status) fac.auto_current_waste_product = ctl_status[15] fac.auto_pu_fallback_active = ctl_status[16] + fac.auto_sps_disabled = ctl_status[17] fac.ps.publish("current_waste_product", fac.auto_current_waste_product) fac.ps.publish("pu_fallback_active", fac.auto_pu_fallback_active) + fac.ps.publish("sps_disabled_low_power", fac.auto_sps_disabled) else log.debug(log_header .. "control status not a table or length mismatch") valid = false @@ -663,10 +674,27 @@ function iocontrol.update_facility_status(status) fac.rtu_count = rtu_statuses.count -- power statistics - if type(rtu_statuses.power) == "table" then - fac.induction_ps_tbl[1].publish("avg_charge", rtu_statuses.power[1]) - fac.induction_ps_tbl[1].publish("avg_inflow", rtu_statuses.power[2]) - fac.induction_ps_tbl[1].publish("avg_outflow", rtu_statuses.power[3]) + if type(rtu_statuses.power) == "table" and #rtu_statuses.power == 4 then + local data = fac.induction_data_tbl[1] ---@type imatrix_session_db + local ps = fac.induction_ps_tbl[1] ---@type psil + + local chg = tonumber(rtu_statuses.power[1]) + local in_f = tonumber(rtu_statuses.power[2]) + local out_f = tonumber(rtu_statuses.power[3]) + local eta = tonumber(rtu_statuses.power[4]) + + ps.publish("avg_charge", chg) + ps.publish("avg_inflow", in_f) + ps.publish("avg_outflow", out_f) + ps.publish("eta_ms", eta) + + ps.publish("is_charging", in_f > out_f) + ps.publish("is_discharging", out_f > in_f) + + if data and data.build then + local cap = util.joules_to_fe(data.build.transfer_cap) + ps.publish("at_max_io", in_f >= cap or out_f >= cap) + end else log.debug(log_header .. "power statistics list not a table") valid = false @@ -877,6 +905,7 @@ function iocontrol.update_unit_statuses(statuses) end if #reactor_status == 0 then + unit.connected = false unit.unit_ps.publish("computed_status", 1) -- disconnected elseif #reactor_status == 3 then local mek_status = reactor_status[1] @@ -936,6 +965,8 @@ function iocontrol.update_unit_statuses(statuses) unit.unit_ps.publish(key, val) end end + + unit.connected = true else log.debug(log_header .. "reactor status length mismatch") valid = false @@ -950,7 +981,10 @@ function iocontrol.update_unit_statuses(statuses) local boil_sum = 0 for id = 1, #unit.boiler_ps_tbl do - if rtu_statuses.boilers[id] == nil then + local connected = rtu_statuses.boilers[id] ~= nil + unit.rtu_hw.boilers[id].connected = connected + + if not connected then -- disconnected unit.boiler_ps_tbl[id].publish("computed_status", 1) end @@ -962,6 +996,7 @@ function iocontrol.update_unit_statuses(statuses) local ps = unit.boiler_ps_tbl[id] ---@type psil local rtu_faulted = _record_multiblock_status(boiler, data, ps) + unit.rtu_hw.boilers[id].faulted = rtu_faulted if rtu_faulted then ps.publish("computed_status", 3) -- faulted @@ -993,7 +1028,10 @@ function iocontrol.update_unit_statuses(statuses) local flow_sum = 0 for id = 1, #unit.turbine_ps_tbl do - if rtu_statuses.turbines[id] == nil then + local connected = rtu_statuses.turbines[id] ~= nil + unit.rtu_hw.turbines[id].connected = connected + + if not connected then -- disconnected unit.turbine_ps_tbl[id].publish("computed_status", 1) end @@ -1005,6 +1043,7 @@ function iocontrol.update_unit_statuses(statuses) local ps = unit.turbine_ps_tbl[id] ---@type psil local rtu_faulted = _record_multiblock_status(turbine, data, ps) + unit.rtu_hw.turbines[id].faulted = rtu_faulted if rtu_faulted then ps.publish("computed_status", 3) -- faulted @@ -1179,9 +1218,11 @@ function iocontrol.update_unit_statuses(statuses) local unit_state = status[5] if type(unit_state) == "table" then - if #unit_state == 6 then + if #unit_state == 8 then unit.waste_mode = unit_state[5] unit.waste_product = unit_state[6] + unit.last_rate_change_ms = unit_state[7] + unit.turbine_flow_stable = unit_state[8] unit.unit_ps.publish("U_StatusLine1", unit_state[1]) unit.unit_ps.publish("U_StatusLine2", unit_state[2]) diff --git a/coordinator/process.lua b/coordinator/process.lua index 581fcd9..1016e62 100644 --- a/coordinator/process.lua +++ b/coordinator/process.lua @@ -29,7 +29,8 @@ local self = { gen_target = 0.0, limits = {}, waste_product = PRODUCT.PLUTONIUM, - pu_fallback = false + pu_fallback = false, + sps_low_power = false }, waste_modes = {}, priority_groups = {} @@ -65,6 +66,7 @@ function process.init(iocontrol, coord_comms) ctl_proc.limits = config.limits ctl_proc.waste_product = config.waste_product ctl_proc.pu_fallback = config.pu_fallback + ctl_proc.sps_low_power = config.sps_low_power self.io.facility.ps.publish("process_mode", ctl_proc.mode) self.io.facility.ps.publish("process_burn_target", ctl_proc.burn_target) @@ -72,6 +74,7 @@ function process.init(iocontrol, coord_comms) self.io.facility.ps.publish("process_gen_target", ctl_proc.gen_target) self.io.facility.ps.publish("process_waste_product", ctl_proc.waste_product) self.io.facility.ps.publish("process_pu_fallback", ctl_proc.pu_fallback) + self.io.facility.ps.publish("process_sps_low_power", ctl_proc.sps_low_power) for id = 1, math.min(#ctl_proc.limits, self.io.facility.num_units) do local unit = self.io.units[id] ---@type ioctl_unit @@ -83,6 +86,7 @@ function process.init(iocontrol, coord_comms) -- notify supervisor of auto waste config self.comms.send_fac_command(FAC_COMMAND.SET_WASTE_MODE, ctl_proc.waste_product) self.comms.send_fac_command(FAC_COMMAND.SET_PU_FB, ctl_proc.pu_fallback) + self.comms.send_fac_command(FAC_COMMAND.SET_SPS_LP, ctl_proc.sps_low_power) end -- unit waste states @@ -259,6 +263,18 @@ function process.set_pu_fallback(enabled) _write_auto_config() end +-- set automatic process control SPS usage at low power +---@param enabled boolean whether to enable SPS usage at low power +function process.set_sps_low_power(enabled) + self.comms.send_fac_command(FAC_COMMAND.SET_SPS_LP, enabled) + + log.debug(util.c("PROCESS: SET SPS LOW POWER ", enabled)) + + -- update config table and save + self.control_states.process.sps_low_power = enabled + _write_auto_config() +end + -- save process control settings ---@param mode PROCESS control mode ---@param burn_target number burn rate target diff --git a/coordinator/session/apisessions.lua b/coordinator/session/apisessions.lua index 516b91b..5c4a38a 100644 --- a/coordinator/session/apisessions.lua +++ b/coordinator/session/apisessions.lua @@ -89,10 +89,11 @@ end -- establish a new API session ---@nodiscard ----@param source_addr integer ----@param version string +---@param source_addr integer pocket computer ID +---@param i_seq_num integer initial (most recent) sequence number +---@param version string pocket version ---@return integer session_id -function apisessions.establish_session(source_addr, version) +function apisessions.establish_session(source_addr, i_seq_num, version) ---@class pkt_session_struct local pkt_s = { open = true, @@ -105,7 +106,7 @@ function apisessions.establish_session(source_addr, version) local id = self.next_id - pkt_s.instance = pocket.new_session(id, source_addr, pkt_s.in_queue, pkt_s.out_queue, self.config.API_Timeout) + pkt_s.instance = pocket.new_session(id, source_addr, i_seq_num, pkt_s.in_queue, pkt_s.out_queue, self.config.API_Timeout) table.insert(self.sessions, pkt_s) local mt = { diff --git a/coordinator/session/pocket.lua b/coordinator/session/pocket.lua index 7101297..c554c64 100644 --- a/coordinator/session/pocket.lua +++ b/coordinator/session/pocket.lua @@ -32,16 +32,17 @@ local PERIODICS = { ---@nodiscard ---@param id integer session ID ---@param s_addr integer device source address +---@param i_seq_num integer initial sequence number ---@param in_queue mqueue in message queue ---@param out_queue mqueue out message queue ---@param timeout number communications timeout -function pocket.new_session(id, s_addr, in_queue, out_queue, timeout) +function pocket.new_session(id, s_addr, i_seq_num, in_queue, out_queue, timeout) local log_header = "pkt_session(" .. id .. "): " local self = { -- connection properties - seq_num = 0, - r_seq_num = nil, + seq_num = i_seq_num + 2, -- next after the establish approval was sent + r_seq_num = i_seq_num + 1, connected = true, conn_watchdog = util.new_watchdog(timeout), last_rtt = 0, @@ -104,13 +105,11 @@ function pocket.new_session(id, s_addr, in_queue, out_queue, timeout) ---@param pkt mgmt_frame|crdn_frame local function _handle_packet(pkt) -- check sequence number - if self.r_seq_num == nil then - self.r_seq_num = pkt.scada_frame.seq_num() - elseif (self.r_seq_num + 1) ~= pkt.scada_frame.seq_num() then + if self.r_seq_num ~= pkt.scada_frame.seq_num() then log.warning(log_header .. "sequence out-of-order: last = " .. self.r_seq_num .. ", new = " .. pkt.scada_frame.seq_num()) return else - self.r_seq_num = pkt.scada_frame.seq_num() + self.r_seq_num = pkt.scada_frame.seq_num() + 1 end -- feed watchdog @@ -138,21 +137,28 @@ function pocket.new_session(id, s_addr, in_queue, out_queue, timeout) } _send(CRDN_TYPE.API_GET_FAC, data) - elseif pkt.type == CRDN_TYPE.API_GET_UNITS then - local data = {} + elseif pkt.type == CRDN_TYPE.API_GET_UNIT then + if pkt.length == 1 and type(pkt.data[1]) == "number" then + local u = db.units[pkt.data[1]] ---@type ioctl_unit - for i = 1, #db.units do - local u = db.units[i] ---@type ioctl_unit - table.insert(data, { - u.unit_id, - u.num_boilers, - u.num_turbines, - u.num_snas, - u.has_tank - }) + if u then + local data = { + u.unit_id, + u.connected, + u.rtu_hw, + u.alarms, + u.annunciator, + u.reactor_data, + u.boiler_data_tbl, + u.turbine_data_tbl, + u.tank_data_tbl, + u.last_rate_change_ms, + u.turbine_flow_stable + } + + _send(CRDN_TYPE.API_GET_UNIT, data) + end end - - _send(CRDN_TYPE.API_GET_UNITS, data) else log.debug(log_header .. "handler received unsupported CRDN packet type " .. pkt.type) end diff --git a/coordinator/startup.lua b/coordinator/startup.lua index a50da5b..1960e58 100644 --- a/coordinator/startup.lua +++ b/coordinator/startup.lua @@ -19,7 +19,7 @@ local renderer = require("coordinator.renderer") local sounder = require("coordinator.sounder") local threads = require("coordinator.threads") -local COORDINATOR_VERSION = "v1.4.3" +local COORDINATOR_VERSION = "v1.5.1" local CHUNK_LOAD_DELAY_S = 30.0 @@ -151,8 +151,8 @@ local function main() -- core coordinator devices crd_dev = { - speaker = ppm.get_device("speaker"), - modem = ppm.get_wireless_modem() + modem = ppm.get_wireless_modem(), + speaker = ppm.get_device("speaker") }, -- system objects diff --git a/coordinator/threads.lua b/coordinator/threads.lua index e7c2d8c..20ba7ae 100644 --- a/coordinator/threads.lua +++ b/coordinator/threads.lua @@ -244,7 +244,7 @@ function threads.thread__main(smem) return public end --- coordinator renderer thread, tasked with long duration re-draws +-- coordinator renderer thread, tasked with long duration draws ---@nodiscard ---@param smem crd_shared_memory function threads.thread__render(smem) diff --git a/coordinator/ui/components/imatrix.lua b/coordinator/ui/components/imatrix.lua index 2b80350..b116e3d 100644 --- a/coordinator/ui/components/imatrix.lua +++ b/coordinator/ui/components/imatrix.lua @@ -9,6 +9,7 @@ local Rectangle = require("graphics.elements.rectangle") local TextBox = require("graphics.elements.textbox") local DataIndicator = require("graphics.elements.indicators.data") +local IndicatorLight = require("graphics.elements.indicators.light") local PowerIndicator = require("graphics.elements.indicators.power") local StateIndicator = require("graphics.elements.indicators.state") local VerticalBar = require("graphics.elements.indicators.vbar") @@ -26,9 +27,13 @@ local ALIGN = core.ALIGN ---@param ps psil ps interface ---@param id number? matrix ID local function new_view(root, x, y, data, ps, id) + local label_fg = style.theme.label_fg local text_fg = style.theme.text_fg local lu_col = style.lu_colors + local ind_yel = style.ind_yel + local ind_wht = style.ind_wht + local title = "INDUCTION MATRIX" if type(id) == "number" then title = title .. id end @@ -42,45 +47,47 @@ local function new_view(root, x, y, data, ps, id) local rect = Rectangle{parent=matrix,border=border(1,colors.gray,true),width=33,height=22,x=1,y=3} - local status = StateIndicator{parent=rect,x=10,y=1,states=style.imatrix.states,value=1,min_width=14} - local energy = PowerIndicator{parent=rect,x=7,y=3,lu_colors=lu_col,label="Energy: ",format="%8.2f",value=0,width=26,fg_bg=text_fg} - local capacity = PowerIndicator{parent=rect,x=7,y=4,lu_colors=lu_col,label="Capacity:",format="%8.2f",value=0,width=26,fg_bg=text_fg} - local input = PowerIndicator{parent=rect,x=7,y=5,lu_colors=lu_col,label="Input: ",format="%8.2f",rate=true,value=0,width=26,fg_bg=text_fg} - local output = PowerIndicator{parent=rect,x=7,y=6,lu_colors=lu_col,label="Output: ",format="%8.2f",rate=true,value=0,width=26,fg_bg=text_fg} - - local avg_chg = PowerIndicator{parent=rect,x=7,y=8,lu_colors=lu_col,label="Avg. Chg:",format="%8.2f",value=0,width=26,fg_bg=text_fg} - local avg_in = PowerIndicator{parent=rect,x=7,y=9,lu_colors=lu_col,label="Avg. In: ",format="%8.2f",rate=true,value=0,width=26,fg_bg=text_fg} - local avg_out = PowerIndicator{parent=rect,x=7,y=10,lu_colors=lu_col,label="Avg. Out:",format="%8.2f",rate=true,value=0,width=26,fg_bg=text_fg} + local status = StateIndicator{parent=rect,x=10,y=1,states=style.imatrix.states,value=1,min_width=14} + local capacity = PowerIndicator{parent=rect,x=7,y=3,lu_colors=lu_col,label="Capacity:",format="%8.2f",value=0,width=26,fg_bg=text_fg} + local energy = PowerIndicator{parent=rect,x=7,y=4,lu_colors=lu_col,label="Energy: ",format="%8.2f",value=0,width=26,fg_bg=text_fg} + local avg_chg = PowerIndicator{parent=rect,x=7,y=5,lu_colors=lu_col,label="\xb7Average:",format="%8.2f",value=0,width=26,fg_bg=text_fg} + local input = PowerIndicator{parent=rect,x=7,y=6,lu_colors=lu_col,label="Input: ",format="%8.2f",rate=true,value=0,width=26,fg_bg=text_fg} + local avg_in = PowerIndicator{parent=rect,x=7,y=7,lu_colors=lu_col,label="\xb7Average:",format="%8.2f",rate=true,value=0,width=26,fg_bg=text_fg} + local output = PowerIndicator{parent=rect,x=7,y=8,lu_colors=lu_col,label="Output: ",format="%8.2f",rate=true,value=0,width=26,fg_bg=text_fg} + local avg_out = PowerIndicator{parent=rect,x=7,y=9,lu_colors=lu_col,label="\xb7Average:",format="%8.2f",rate=true,value=0,width=26,fg_bg=text_fg} + local trans_cap = PowerIndicator{parent=rect,x=7,y=10,lu_colors=lu_col,label="Max I/O: ",format="%8.2f",rate=true,value=0,width=26,fg_bg=text_fg} status.register(ps, "computed_status", status.update) - energy.register(ps, "energy", function (val) energy.update(util.joules_to_fe(val)) end) capacity.register(ps, "max_energy", function (val) capacity.update(util.joules_to_fe(val)) end) - input.register(ps, "last_input", function (val) input.update(util.joules_to_fe(val)) end) - output.register(ps, "last_output", function (val) output.update(util.joules_to_fe(val)) end) - + energy.register(ps, "energy", function (val) energy.update(util.joules_to_fe(val)) end) avg_chg.register(ps, "avg_charge", avg_chg.update) + input.register(ps, "last_input", function (val) input.update(util.joules_to_fe(val)) end) avg_in.register(ps, "avg_inflow", avg_in.update) + output.register(ps, "last_output", function (val) output.update(util.joules_to_fe(val)) end) avg_out.register(ps, "avg_outflow", avg_out.update) + trans_cap.register(ps, "transfer_cap", function (val) trans_cap.update(util.joules_to_fe(val)) end) - local fill = DataIndicator{parent=rect,x=11,y=12,lu_colors=lu_col,label="Fill:",unit="%",format="%8.2f",value=0,width=18,fg_bg=text_fg} - - local cells = DataIndicator{parent=rect,x=11,y=14,lu_colors=lu_col,label="Cells: ",format="%7d",value=0,width=18,fg_bg=text_fg} - local providers = DataIndicator{parent=rect,x=11,y=15,lu_colors=lu_col,label="Providers:",format="%7d",value=0,width=18,fg_bg=text_fg} - - TextBox{parent=rect,text="Transfer Capacity",x=11,y=17,height=1,width=17,fg_bg=style.theme.label_fg} - local trans_cap = PowerIndicator{parent=rect,x=19,y=18,lu_colors=lu_col,label="",format="%5.2f",rate=true,value=0,width=12,fg_bg=text_fg} + local fill = DataIndicator{parent=rect,x=11,y=12,lu_colors=lu_col,label="Fill: ",format="%7.2f",unit="%",value=0,width=20,fg_bg=text_fg} + local cells = DataIndicator{parent=rect,x=11,y=13,lu_colors=lu_col,label="Cells: ",format="%7d",value=0,width=18,fg_bg=text_fg} + local providers = DataIndicator{parent=rect,x=11,y=14,lu_colors=lu_col,label="Providers:",format="%7d",value=0,width=18,fg_bg=text_fg} + fill.register(ps, "energy_fill", function (val) fill.update(val * 100) end) cells.register(ps, "cells", cells.update) providers.register(ps, "providers", providers.update) - fill.register(ps, "energy_fill", function (val) fill.update(val * 100) end) - trans_cap.register(ps, "transfer_cap", function (val) trans_cap.update(util.joules_to_fe(val)) end) + + local chging = IndicatorLight{parent=rect,x=11,y=16,label="Charging",colors=ind_wht} + local dischg = IndicatorLight{parent=rect,x=11,y=17,label="Discharging",colors=ind_wht} + local max_io = IndicatorLight{parent=rect,x=11,y=18,label="Max I/O Rate",colors=ind_yel} + + chging.register(ps, "is_charging", chging.update) + dischg.register(ps, "is_discharging", dischg.update) + max_io.register(ps, "at_max_io", max_io.update) local charge = VerticalBar{parent=rect,x=2,y=2,fg_bg=cpair(colors.green,colors.gray),height=17,width=4} local in_cap = VerticalBar{parent=rect,x=7,y=12,fg_bg=cpair(colors.red,colors.gray),height=7,width=1} local out_cap = VerticalBar{parent=rect,x=9,y=12,fg_bg=cpair(colors.blue,colors.gray),height=7,width=1} - TextBox{parent=rect,text="FILL",x=2,y=20,height=1,width=4,fg_bg=text_fg} - TextBox{parent=rect,text="I/O",x=7,y=20,height=1,width=3,fg_bg=text_fg} + TextBox{parent=rect,text="FILL I/O",x=2,y=20,height=1,width=8,fg_bg=label_fg} local function calc_saturation(val) if (type(data.build) == "table") and (type(data.build.transfer_cap) == "number") and (data.build.transfer_cap > 0) then @@ -91,6 +98,49 @@ local function new_view(root, x, y, data, ps, id) charge.register(ps, "energy_fill", charge.update) in_cap.register(ps, "last_input", function (val) in_cap.update(calc_saturation(val)) end) out_cap.register(ps, "last_output", function (val) out_cap.update(calc_saturation(val)) end) + + local eta = TextBox{parent=rect,x=11,y=20,width=20,height=1,text="ETA Unknown",alignment=ALIGN.CENTER,fg_bg=style.theme.field_box} + + eta.register(ps, "eta_ms", function (eta_ms) + local str, pre = "", util.trinary(eta_ms >= 0, "Full in ", "Empty in ") + + local seconds = math.abs(eta_ms) / 1000 + local minutes = seconds / 60 + local hours = minutes / 60 + local days = hours / 24 + + if math.abs(eta_ms) < 1000 or (eta_ms ~= eta_ms) then + -- really small or NaN + str = "No ETA" + elseif days < 1000 then + days = math.floor(days) + hours = math.floor(hours % 24) + minutes = math.floor(minutes % 60) + seconds = math.floor(seconds % 60) + + if days > 0 then + str = days .. "d" + elseif hours > 0 then + str = hours .. "h " .. minutes .. "m" + elseif minutes > 0 then + str = minutes .. "m " .. seconds .. "s" + elseif seconds > 0 then + str = seconds .. "s" + end + + str = pre .. str + else + local years = math.floor(days / 365.25) + + if years <= 99999999 then + str = pre .. years .. "y" + else + str = pre .. "eras" + end + end + + eta.set_value(str) + end) end return new_view diff --git a/coordinator/ui/components/process_ctl.lua b/coordinator/ui/components/process_ctl.lua index 430409b..6435de7 100644 --- a/coordinator/ui/components/process_ctl.lua +++ b/coordinator/ui/components/process_ctl.lua @@ -341,31 +341,25 @@ local function new_view(root, x, y) status.register(facility.ps, "current_waste_product", status.update) local waste_prod = RadioButton{parent=rect,x=2,y=3,options=style.waste.options,callback=process.set_process_waste,radio_colors=cpair(style.theme.accent_dark,style.theme.accent_light),select_color=colors.brown} - local pu_fallback = Checkbox{parent=rect,x=2,y=7,label="Pu Fallback",callback=process.set_pu_fallback,box_fg_bg=cpair(colors.green,style.theme.checkbox_bg)} 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=ind_wht} + local fb_active = IndicatorLight{parent=rect,x=2,y=7,label="Fallback Active",colors=ind_wht} + local sps_disabled = IndicatorLight{parent=rect,x=2,y=8,label="SPS Disabled LC",colors=ind_yel} fb_active.register(facility.ps, "pu_fallback_active", fb_active.update) + sps_disabled.register(facility.ps, "sps_disabled_low_power", sps_disabled.update) - TextBox{parent=rect,x=2,y=11,text="Plutonium Rate",height=1,width=17,fg_bg=style.label} - local pu_rate = DataIndicator{parent=rect,x=2,label="",unit="mB/t",format="%12.2f",value=0,lu_colors=lu_cpair,fg_bg=s_field,width=17} + local pu_fallback = Checkbox{parent=rect,x=2,y=10,label="Pu Fallback",callback=process.set_pu_fallback,box_fg_bg=cpair(colors.brown,style.theme.checkbox_bg)} - TextBox{parent=rect,x=2,y=14,text="Polonium Rate",height=1,width=17,fg_bg=style.label} - local po_rate = DataIndicator{parent=rect,x=2,label="",unit="mB/t",format="%12.2f",value=0,lu_colors=lu_cpair,fg_bg=s_field,width=17} + TextBox{parent=rect,x=2,y=12,height=3,text="Switch to Pu when SNAs cannot keep up with waste.",fg_bg=style.label} - 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="%12d",value=0,lu_colors=lu_cpair,fg_bg=s_field,width=17} + local lc_sps = Checkbox{parent=rect,x=2,y=16,label="Low Charge SPS",callback=process.set_sps_low_power,box_fg_bg=cpair(colors.brown,style.theme.checkbox_bg)} - pu_rate.register(facility.ps, "pu_rate", pu_rate.update) - po_rate.register(facility.ps, "po_rate", po_rate.update) - am_rate.register(facility.ps, "am_rate", am_rate.update) + TextBox{parent=rect,x=2,y=18,height=3,text="Use SPS at low charge, otherwise switches to Po.",fg_bg=style.label} - local sna_count = DataIndicator{parent=rect,x=2,y=20,label="Linked SNAs:",format="%4d",value=0,lu_colors=lu_cpair,width=17} - - sna_count.register(facility.ps, "sna_count", sna_count.update) + pu_fallback.register(facility.ps, "process_pu_fallback", pu_fallback.set_value) + lc_sps.register(facility.ps, "process_sps_low_power", lc_sps.set_value) end return new_view diff --git a/coordinator/ui/components/unit_detail.lua b/coordinator/ui/components/unit_detail.lua index fe36a85..2c26b98 100644 --- a/coordinator/ui/components/unit_detail.lua +++ b/coordinator/ui/components/unit_detail.lua @@ -183,7 +183,7 @@ local function init(parent, id) 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_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} diff --git a/graphics/core.lua b/graphics/core.lua index b4e1d94..f6a647a 100644 --- a/graphics/core.lua +++ b/graphics/core.lua @@ -7,7 +7,7 @@ local flasher = require("graphics.flasher") local core = {} -core.version = "2.2.3" +core.version = "2.3.0" core.flasher = flasher core.events = events diff --git a/graphics/element.lua b/graphics/element.lua index c7cc056..7475dc1 100644 --- a/graphics/element.lua +++ b/graphics/element.lua @@ -82,9 +82,10 @@ end -- a base graphics element, should not be created on its own ---@nodiscard ---@param args graphics_args arguments +---@param constraint? function apply a dimensional constraint based on proposed dimensions function(frame) -> width, height ---@param child_offset_x? integer mouse event offset x ---@param child_offset_y? integer mouse event offset y -function element.new(args, child_offset_x, child_offset_y) +function element.new(args, constraint, child_offset_x, child_offset_y) local self = { id = nil, ---@type element_id|nil is_root = args.parent == nil, @@ -198,6 +199,9 @@ function element.new(args, child_offset_x, child_offset_y) ---@param offset_y integer y offset for mouse events ---@param next_y integer next line if no y was provided function protected.prepare_template(offset_x, offset_y, next_y) + -- don't auto incrememnt y if inheriting height, that would cause an assertion + next_y = util.trinary(args.height == nil and constraint == nil, 1, next_y) + -- record offsets in case there is a reposition self.offset_x = offset_x self.offset_y = offset_y @@ -223,6 +227,13 @@ function element.new(args, child_offset_x, child_offset_y) local w, h = self.p_window.getSize() f.w = math.min(f.w, w - (f.x - 1)) f.h = math.min(f.h, h - (f.y - 1)) + + if type(constraint) == "function" then + -- constrain per provided constraint function (can only get smaller than available space) + w, h = constraint(f) + f.w = math.min(f.w, w) + f.h = math.min(f.h, h) + end end -- check frame diff --git a/graphics/elements/controls/app.lua b/graphics/elements/controls/app.lua index f610393..4ac936d 100644 --- a/graphics/elements/controls/app.lua +++ b/graphics/elements/controls/app.lua @@ -30,7 +30,7 @@ local function app_button(args) element.assert(type(args.app_fg_bg) == "table", "app_fg_bg is a required field") args.height = 4 - args.width = 5 + args.width = 7 -- create new graphics element base object local e = element.new(args) @@ -46,7 +46,7 @@ local function app_button(args) end -- draw icon - e.w_set_cur(1, 1) + e.w_set_cur(2, 1) e.w_set_fgd(fgd) e.w_set_bkg(bkg) e.w_write("\x9f\x83\x83\x83") @@ -55,16 +55,16 @@ local function app_button(args) e.w_write("\x90") e.w_set_fgd(fgd) e.w_set_bkg(bkg) - e.w_set_cur(1, 2) + e.w_set_cur(2, 2) e.w_write("\x95 ") e.w_set_fgd(bkg) e.w_set_bkg(fgd) e.w_write("\x95") - e.w_set_cur(1, 3) + e.w_set_cur(2, 3) e.w_write("\x82\x8f\x8f\x8f\x81") -- write the icon text - e.w_set_cur(3, 2) + e.w_set_cur(4, 2) e.w_set_fgd(fgd) e.w_set_bkg(bkg) e.w_write(args.text) diff --git a/graphics/elements/controls/push_button.lua b/graphics/elements/controls/push_button.lua index 88c8a5d..f060901 100644 --- a/graphics/elements/controls/push_button.lua +++ b/graphics/elements/controls/push_button.lua @@ -1,6 +1,7 @@ -- Button Graphics Element local tcd = require("scada-common.tcd") +local util = require("scada-common.util") local core = require("graphics.core") local element = require("graphics.element") @@ -21,7 +22,6 @@ local KEY_CLICK = core.events.KEY_CLICK ---@field id? string element id ---@field x? integer 1 if omitted ---@field y? integer auto incremented if omitted ----@field height? integer parent height if omitted ---@field fg_bg? cpair foreground/background colors ---@field hidden? boolean true to hide on initial draw @@ -38,29 +38,40 @@ local function push_button(args) -- set automatic settings args.can_focus = true - args.height = 1 args.min_width = args.min_width or 0 args.width = math.max(text_width, args.min_width) - -- create new graphics element base object - local e = element.new(args) - - local h_pad = 1 - local v_pad = math.floor(e.frame.h / 2) + 1 - - if alignment == ALIGN.CENTER then - h_pad = math.floor((e.frame.w - text_width) / 2) + 1 - elseif alignment == ALIGN.RIGHT then - h_pad = (e.frame.w - text_width) + 1 + -- provide a constraint condition to element creation to prefer a single line button + ---@param frame graphics_frame + local function constrain(frame) + return frame.w, math.max(1, #util.strwrap(args.text, frame.w)) end + -- create new graphics element base object + local e = element.new(args, constrain) + + local text_lines = util.strwrap(args.text, e.frame.w) + -- draw the button function e.redraw() e.window.clear() - -- write the button text - e.w_set_cur(h_pad, v_pad) - e.w_write(args.text) + for i = 1, #text_lines do + if i > e.frame.h then break end + + local len = string.len(text_lines[i]) + + -- use cursor position to align this line + if alignment == ALIGN.CENTER then + e.w_set_cur(math.floor((e.frame.w - len) / 2) + 1, i) + elseif alignment == ALIGN.RIGHT then + e.w_set_cur((e.frame.w - len) + 1, i) + else + e.w_set_cur(1, i) + end + + e.w_write(text_lines[i]) + end end -- draw the button as pressed (if active_fg_bg set) @@ -109,7 +120,9 @@ local function push_button(args) if event.type == KEY_CLICK.DOWN then if event.key == keys.space or event.key == keys.enter or event.key == keys.numPadEnter then args.callback() - e.defocus() + -- visualize click without unfocusing + show_unpressed() + if args.active_fg_bg ~= nil then tcd.dispatch(0.25, show_pressed) end end end end diff --git a/graphics/elements/controls/sidebar.lua b/graphics/elements/controls/sidebar.lua index fef8a8a..58e8b13 100644 --- a/graphics/elements/controls/sidebar.lua +++ b/graphics/elements/controls/sidebar.lua @@ -8,13 +8,7 @@ local element = require("graphics.element") local MOUSE_CLICK = core.events.MOUSE_CLICK ----@class sidebar_tab ----@field char string character identifier ----@field color cpair tab colors (fg/bg) - ---@class sidebar_args ----@field tabs table sidebar tab options ----@field callback function function to call on tab change ---@field parent graphics_element ---@field id? string element id ---@field x? integer 1 if omitted @@ -27,21 +21,16 @@ local MOUSE_CLICK = core.events.MOUSE_CLICK ---@param args sidebar_args ---@return graphics_element element, element_id id local function sidebar(args) - element.assert(type(args.tabs) == "table", "tabs is a required field") - element.assert(#args.tabs > 0, "at least one tab is required") - element.assert(type(args.callback) == "function", "callback is a required field") - args.width = 3 -- create new graphics element base object local e = element.new(args) - element.assert(e.frame.h >= (#args.tabs * 3), "height insufficent to display all tabs") - -- default to 1st tab e.value = 1 local was_pressed = false + local tabs = {} -- show the button state ---@param pressed? boolean if the currently selected tab should appear as actively pressed @@ -51,10 +40,18 @@ local function sidebar(args) was_pressed = pressed pressed_idx = pressed_idx or e.value - for i = 1, #args.tabs do - local tab = args.tabs[i] ---@type sidebar_tab + -- clear + e.w_set_fgd(e.fg_bg.fgd) + e.w_set_bkg(e.fg_bg.bkg) + for y = 1, e.frame.h do + e.w_set_cur(1, y) + e.w_write(" ") + end - local y = ((i - 1) * 3) + 1 + -- draw tabs + for i = 1, #tabs do + local tab = tabs[i] ---@type sidebar_tab + local y = tab.y_start e.w_set_cur(1, y) @@ -66,13 +63,29 @@ local function sidebar(args) e.w_set_bkg(tab.color.bkg) end - e.w_write(" ") - e.w_set_cur(1, y + 1) - if e.value == i then - e.w_write(" " .. tab.char .. "\x10") - else e.w_write(" " .. tab.char .. " ") end - e.w_set_cur(1, y + 2) - e.w_write(" ") + if tab.tall then + e.w_write(" ") + e.w_set_cur(1, y + 1) + end + + e.w_write(tab.label) + + if tab.tall then + e.w_set_cur(1, y + 2) + e.w_write(" ") + end + end + end + + -- determine which tab was pressed + ---@param y integer y coordinate + local function find_tab(y) + for i = 1, #tabs do + local tab = tabs[i] ---@type sidebar_tab + + if y >= tab.y_start and y <= tab.y_end then + return i + end end end @@ -81,23 +94,25 @@ local function sidebar(args) function e.handle_mouse(event) -- determine what was pressed if e.enabled then - local cur_idx = math.ceil(event.current.y / 3) - local ini_idx = math.ceil(event.initial.y / 3) + local cur_idx = find_tab(event.current.y) + local ini_idx = find_tab(event.initial.y) + local tab = tabs[cur_idx] - if args.tabs[cur_idx] ~= nil then + -- handle press if a callback was provided + if tab ~= nil and type(tab.callback) == "function" then if event.type == MOUSE_CLICK.TAP then e.value = cur_idx draw(true) -- show as unpressed in 0.25 seconds tcd.dispatch(0.25, function () draw(false) end) - args.callback(e.value) + tab.callback() elseif event.type == MOUSE_CLICK.DOWN then draw(true, cur_idx) elseif event.type == MOUSE_CLICK.UP then if cur_idx == ini_idx and e.in_frame_bounds(event.current.x, event.current.y) then e.value = cur_idx draw(false) - args.callback(e.value) + tab.callback() else draw(false) end end elseif event.type == MOUSE_CLICK.UP then @@ -113,6 +128,35 @@ local function sidebar(args) draw(false) end + -- update the sidebar navigation options + ---@param items table sidebar entries + function e.on_update(items) + local next_y = 1 + + tabs = {} + + for i = 1, #items do + local item = items[i] + local height = util.trinary(item.tall, 3, 1) + + ---@class sidebar_tab + local entry = { + y_start = next_y, ---@type integer + y_end = next_y + height - 1, ---@type integer + tall = item.tall, ---@type boolean + label = item.label, ---@type string + color = item.color, ---@type cpair + callback = item.callback ---@type function|nil + } + + next_y = next_y + height + + tabs[i] = entry + end + + draw() + end + -- element redraw e.redraw = draw diff --git a/graphics/elements/indicators/icon.lua b/graphics/elements/indicators/icon.lua index 5c2fc4e..15aef3a 100644 --- a/graphics/elements/indicators/icon.lua +++ b/graphics/elements/indicators/icon.lua @@ -9,7 +9,7 @@ local element = require("graphics.element") ---@class icon_indicator_args ---@field label string indicator label ---@field states table state color and symbol table ----@field value? integer default state, defaults to 1 +---@field value? integer|boolean default state, defaults to 1 (true = 2, false = 1) ---@field min_label_width? integer label length if omitted ---@field parent graphics_element ---@field id? string element id @@ -33,6 +33,7 @@ local function icon(args) local e = element.new(args) e.value = args.value or 1 + if e.value == true then e.value = 2 end -- state blit strings local state_blit_cmds = {} @@ -47,8 +48,11 @@ local function icon(args) end -- on state change - ---@param new_state integer indicator state + ---@param new_state integer|boolean indicator state function e.on_update(new_state) + new_state = new_state or 1 + if new_state == true then new_state = 2 end + local blit_cmd = state_blit_cmds[new_state] e.value = new_state e.w_set_cur(1, 1) @@ -56,7 +60,7 @@ local function icon(args) end -- set indicator state - ---@param val integer indicator state + ---@param val integer|boolean indicator state function e.set_value(val) e.on_update(val) end -- element redraw diff --git a/graphics/elements/rectangle.lua b/graphics/elements/rectangle.lua index 252790b..eceb9bd 100644 --- a/graphics/elements/rectangle.lua +++ b/graphics/elements/rectangle.lua @@ -45,7 +45,7 @@ local function rectangle(args) end -- create new graphics element base object - local e = element.new(args, offset_x, offset_y) + local e = element.new(args, nil, offset_x, offset_y) -- create content window for child elements e.content_window = window.create(e.window, 1 + offset_x, 1 + offset_y, e.frame.w - (2 * offset_x), e.frame.h - (2 * offset_y)) diff --git a/graphics/elements/textbox.lua b/graphics/elements/textbox.lua index 07a8736..2a61860 100644 --- a/graphics/elements/textbox.lua +++ b/graphics/elements/textbox.lua @@ -10,12 +10,13 @@ local ALIGN = core.ALIGN ---@class textbox_args ---@field text string text to show ---@field alignment? ALIGN text alignment, left by default +---@field anchor? boolean true to use this as an anchor, making it focusable ---@field parent graphics_element ---@field id? string element id ---@field x? integer 1 if omitted ---@field y? integer auto incremented if omitted ---@field width? integer parent width if omitted ----@field height? integer parent height if omitted +---@field height? integer minimum necessary height for wrapped text if omitted ---@field gframe? graphics_frame frame instead of x/y/width/height ---@field fg_bg? cpair foreground/background colors ---@field hidden? boolean true to hide on initial draw @@ -26,8 +27,22 @@ local ALIGN = core.ALIGN local function textbox(args) element.assert(type(args.text) == "string", "text is a required field") + if args.anchor == true then args.can_focus = true end + + -- provide a constraint condition to element creation to prevent an pointlessly tall text box + ---@param frame graphics_frame + local function constrain(frame) + local new_height = math.max(1, #util.strwrap(args.text, frame.w)) + + if args.height then + new_height = math.max(frame.h, new_height) + end + + return frame.w, new_height + end + -- create new graphics element base object - local e = element.new(args) + local e = element.new(args, constrain) e.value = args.text diff --git a/pocket/configure.lua b/pocket/configure.lua index 80f91bc..d16309c 100644 --- a/pocket/configure.lua +++ b/pocket/configure.lua @@ -3,7 +3,7 @@ -- local log = require("scada-common.log") -local tcd = require("scada-common.tcd") +local types = require("scada-common.types") local util = require("scada-common.util") local core = require("graphics.core") @@ -32,7 +32,9 @@ local CENTER = core.ALIGN.CENTER local RIGHT = core.ALIGN.RIGHT -- changes to the config data/format to let the user know -local changes = {} +local changes = { + { "v0.9.2", { "Added temperature scale options" } } +} ---@class pkt_configurator local configurator = {} @@ -73,6 +75,7 @@ local tool_ctl = { ---@class pkt_config local tmp_cfg = { + TempScale = 1, SVR_Channel = nil, ---@type integer CRD_Channel = nil, ---@type integer PKT_Channel = nil, ---@type integer @@ -91,6 +94,7 @@ local settings_cfg = {} -- all settings fields, their nice names, and their default values local fields = { + { "TempScale", "Temperature Scale", 1 }, { "SVR_Channel", "SVR Channel", 16240 }, { "CRD_Channel", "CRD Channel", 16243 }, { "PKT_Channel", "PKT Channel", 16244 }, @@ -126,12 +130,13 @@ local function config_view(display) local root_pane_div = Div{parent=display,x=1,y=2} local main_page = Div{parent=root_pane_div,x=1,y=1} + local ui_cfg = Div{parent=root_pane_div,x=1,y=1} local net_cfg = Div{parent=root_pane_div,x=1,y=1} local log_cfg = Div{parent=root_pane_div,x=1,y=1} local summary = Div{parent=root_pane_div,x=1,y=1} local changelog = Div{parent=root_pane_div,x=1,y=1} - local main_pane = MultiPane{parent=root_pane_div,x=1,y=1,panes={main_page,net_cfg,log_cfg,summary,changelog}} + local main_pane = MultiPane{parent=root_pane_div,x=1,y=1,panes={main_page,ui_cfg,net_cfg,log_cfg,summary,changelog}} -- Main Page @@ -148,7 +153,7 @@ local function config_view(display) tool_ctl.viewing_config = true tool_ctl.gen_summary(settings_cfg) tool_ctl.settings_apply.hide(true) - main_pane.set_value(4) + main_pane.set_value(5) end if fs.exists("/pocket/config.lua") then @@ -162,7 +167,28 @@ local function config_view(display) if not tool_ctl.has_config then tool_ctl.view_cfg.disable() end PushButton{parent=main_page,x=2,y=18,min_width=6,text="Exit",callback=exit,fg_bg=cpair(colors.black,colors.red),active_fg_bg=btn_act_fg_bg} - PushButton{parent=main_page,x=14,y=18,min_width=12,text="Change Log",callback=function()main_pane.set_value(5)end,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} + PushButton{parent=main_page,x=14,y=18,min_width=12,text="Change Log",callback=function()main_pane.set_value(6)end,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} + + --#region Pocket UI + + local ui_c_1 = Div{parent=ui_cfg,x=2,y=4,width=24} + + TextBox{parent=ui_cfg,x=1,y=2,height=1,text=" Pocket UI",fg_bg=cpair(colors.black,colors.lime)} + + TextBox{parent=ui_c_1,x=1,y=1,height=3,text="You may use the options below to customize formats."} + + TextBox{parent=ui_c_1,x=1,y=5,height=1,text="Temperature Scale"} + local temp_scale = RadioButton{parent=ui_c_1,x=1,y=6,default=ini_cfg.TempScale,options=types.TEMP_SCALE_NAMES,callback=function()end,radio_colors=cpair(colors.lightGray,colors.black),select_color=colors.lime} + + local function submit_ui_opts() + tmp_cfg.TempScale = temp_scale.get_value() + main_pane.set_value(3) + end + + PushButton{parent=ui_c_1,x=1,y=15,text="\x1b Back",callback=function()main_pane.set_value(1)end,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} + PushButton{parent=ui_c_1,x=19,y=15,text="Next \x1a",callback=submit_ui_opts,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} + + --#endregion --#region Network @@ -201,7 +227,7 @@ local function config_view(display) else chan_err.show() end end - PushButton{parent=net_c_1,x=1,y=15,text="\x1b Back",callback=function()main_pane.set_value(1)end,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} + PushButton{parent=net_c_1,x=1,y=15,text="\x1b Back",callback=function()main_pane.set_value(2)end,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} PushButton{parent=net_c_1,x=19,y=15,text="Next \x1a",callback=submit_channels,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} TextBox{parent=net_c_2,x=1,y=1,height=1,text="Set connection timeout."} @@ -268,7 +294,7 @@ local function config_view(display) local v = key.get_value() if string.len(v) == 0 or string.len(v) >= 8 then tmp_cfg.AuthKey = key.get_value() - main_pane.set_value(3) + main_pane.set_value(4) key_err.hide(true) else key_err.show() end end @@ -306,11 +332,11 @@ local function config_view(display) tool_ctl.viewing_config = false tool_ctl.importing_legacy = false tool_ctl.settings_apply.show() - main_pane.set_value(4) + main_pane.set_value(5) else path_err.show() end end - PushButton{parent=log_c_1,x=1,y=15,text="\x1b Back",callback=function()main_pane.set_value(2)end,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} + PushButton{parent=log_c_1,x=1,y=15,text="\x1b Back",callback=function()main_pane.set_value(3)end,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} PushButton{parent=log_c_1,x=19,y=15,text="Next \x1a",callback=submit_log,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} --#endregion @@ -335,7 +361,7 @@ local function config_view(display) tool_ctl.importing_legacy = false tool_ctl.settings_apply.show() else - main_pane.set_value(3) + main_pane.set_value(4) end end @@ -444,7 +470,7 @@ local function config_view(display) tool_ctl.gen_summary(tmp_cfg) sum_pane.set_value(1) - main_pane.set_value(4) + main_pane.set_value(5) tool_ctl.importing_legacy = true end @@ -473,8 +499,13 @@ local function config_view(display) local raw = cfg[f[1]] local val = util.strval(raw) - if f[1] == "AuthKey" then val = string.rep("*", string.len(val)) - elseif f[1] == "LogMode" then val = util.trinary(raw == log.MODE.APPEND, "append", "replace") end + if f[1] == "AuthKey" then + val = string.rep("*", string.len(val)) + elseif f[1] == "LogMode" then + val = util.trinary(raw == log.MODE.APPEND, "append", "replace") + elseif f[1] == "TempScale" then + val = types.TEMP_SCALE_NAMES[raw] + end if val == "nil" then val = "" end @@ -532,9 +563,7 @@ function configurator.configure(ask_config) local event, param1, param2, param3 = util.pull_event() -- handle event - if event == "timer" then - tcd.handle(param1) - elseif event == "mouse_click" or event == "mouse_up" or event == "mouse_drag" or event == "mouse_scroll" or event == "double_click" then + if event == "mouse_click" or event == "mouse_up" or event == "mouse_drag" or event == "mouse_scroll" or event == "double_click" then local m_e = core.events.new_mouse_event(event, param1, param2, param3) if m_e then display.handle_mouse(m_e) end elseif event == "char" or event == "key" or event == "key_up" then diff --git a/pocket/iocontrol.lua b/pocket/iocontrol.lua index 30f4879..de69dd3 100644 --- a/pocket/iocontrol.lua +++ b/pocket/iocontrol.lua @@ -2,11 +2,16 @@ -- I/O Control for Pocket Integration with Supervisor & Coordinator -- -local log = require("scada-common.log") +local const = require("scada-common.constants") +-- local log = require("scada-common.log") local psil = require("scada-common.psil") local types = require("scada-common.types") +local util = require("scada-common.util") local ALARM = types.ALARM +local ALARM_STATE = types.ALARM_STATE +local TEMP_SCALE = types.TEMP_SCALE +local TEMP_UNITS = types.TEMP_SCALE_UNITS ---@todo nominal trip time is ping (0ms to 10ms usually) local WARN_TT = 40 @@ -24,172 +29,17 @@ local LINK_STATE = { iocontrol.LINK_STATE = LINK_STATE ----@enum POCKET_APP_ID -local APP_ID = { - ROOT = 1, - -- main app page - UNITS = 2, - ABOUT = 3, - -- diag app page - ALARMS = 4, - -- other - DUMMY = 5, - NUM_APPS = 5 -} - -iocontrol.APP_ID = APP_ID - ---@class pocket_ioctl local io = { version = "unknown", ps = psil.create() } ----@class nav_tree_page ----@field _p nav_tree_page|nil page's parent ----@field _c table page's children ----@field nav_to function function to navigate to this page ----@field switcher function|nil function to switch between children ----@field tasks table tasks to run while viewing this page - --- allocate the page navigation system -function iocontrol.alloc_nav() - local self = { - pane = nil, ---@type graphics_element - apps = {}, - containers = {}, - cur_app = APP_ID.ROOT - } - - self.cur_page = self.root - - ---@class pocket_nav - io.nav = {} - - -- set the root pane element to switch between apps with - ---@param root_pane graphics_element - function io.nav.set_pane(root_pane) - self.pane = root_pane - end - - -- register an app - ---@param app_id POCKET_APP_ID app ID - ---@param container graphics_element element that contains this app (usually a Div) - ---@param pane graphics_element? multipane if this is a simple paned app, then nav_to must be a number - function io.nav.register_app(app_id, container, pane) - ---@class pocket_app - local app = { - root = { _p = nil, _c = {}, nav_to = function () end, tasks = {} }, ---@type nav_tree_page - cur_page = nil, ---@type nav_tree_page - pane = pane, - paned_pages = {} - } - - -- delayed set of the pane if it wasn't ready at the start - ---@param root_pane graphics_element multipane - function app.set_root_pane(root_pane) - app.pane = root_pane - end - - -- if a pane was provided, this will switch between numbered pages - ---@param idx integer page index - function app.switcher(idx) - if app.paned_pages[idx] then - app.paned_pages[idx].nav_to() - end - end - - -- create a new page entry in the app's page navigation tree - ---@param parent nav_tree_page? a parent page or nil to set this as the root - ---@param nav_to function|integer function to navigate to this page or pane index - ---@return nav_tree_page new_page this new page - function app.new_page(parent, nav_to) - ---@type nav_tree_page - local page = { _p = parent, _c = {}, nav_to = function () end, switcher = function () end, tasks = {} } - - if parent == nil then - app.root = page - if app.cur_page == nil then app.cur_page = page end - end - - if type(nav_to) == "number" then - app.paned_pages[nav_to] = page - - function page.nav_to() - app.cur_page = page - if app.pane then app.pane.set_value(nav_to) end - end - else - function page.nav_to() - app.cur_page = page - nav_to() - end - end - - -- switch between children - ---@param id integer child ID - function page.switcher(id) if page._c[id] then page._c[id].nav_to() end end - - if parent ~= nil then - table.insert(page._p._c, page) - end - - return page - end - - -- get the currently active page - function app.get_current_page() return app.cur_page end - - -- attempt to navigate up the tree - ---@return boolean success true if successfully navigated up - function app.nav_up() - local parent = app.cur_page._p - if parent then parent.nav_to() end - return parent ~= nil - end - - self.apps[app_id] = app - self.containers[app_id] = container - - return app - end - - -- get a list of the app containers (usually Div elements) - function io.nav.get_containers() return self.containers end - - -- open a given app - ---@param app_id POCKET_APP_ID - function io.nav.open_app(app_id) - if self.apps[app_id] then - self.cur_app = app_id - self.pane.set_value(app_id) - else - log.debug("tried to open unknown app") - end - end - - -- get the currently active page - ---@return nav_tree_page - function io.nav.get_current_page() - return self.apps[self.cur_app].get_current_page() - end - - -- attempt to navigate up - function io.nav.nav_up() - local app = self.apps[self.cur_app] ---@type pocket_app - log.debug("attempting app nav up for app " .. self.cur_app) - - if not app.nav_up() then - log.debug("internal app nav up failed, going to home screen") - io.nav.open_app(APP_ID.ROOT) - end - end -end - -- initialize facility-independent components of pocket iocontrol ---@param comms pocket_comms -function iocontrol.init_core(comms) - iocontrol.alloc_nav() +---@param nav pocket_nav +function iocontrol.init_core(comms, nav) + io.nav = nav ---@class pocket_ioctl_diag io.diag = {} @@ -227,21 +77,26 @@ function iocontrol.init_core(comms) alarm_buttons = {}, tone_indicators = {} -- indicators to update from supervisor tone states } + + -- API access + ---@class pocket_ioctl_api + io.api = { + get_unit = function (unit) comms.api__get_unit(unit) end + } end -- initialize facility-dependent components of pocket iocontrol ---@param conf facility_conf configuration ----@param temp_scale 1|2|3|4 temperature unit (1 = K, 2 = C, 3 = F, 4 = R) +---@param temp_scale TEMP_SCALE temperature unit function iocontrol.init_fac(conf, temp_scale) + io.temp_label = TEMP_UNITS[temp_scale] + -- temperature unit label and conversion function (from Kelvin) - if temp_scale == 2 then - io.temp_label = "\xb0C" + if temp_scale == TEMP_SCALE.CELSIUS then io.temp_convert = function (t) return t - 273.15 end - elseif temp_scale == 3 then - io.temp_label = "\xb0F" + elseif temp_scale == TEMP_SCALE.FAHRENHEIT then io.temp_convert = function (t) return (1.8 * (t - 273.15)) + 32 end - elseif temp_scale == 4 then - io.temp_label = "\xb0R" + elseif temp_scale == TEMP_SCALE.RANKINE then io.temp_convert = function (t) return 1.8 * t end else io.temp_label = "K" @@ -262,6 +117,16 @@ function iocontrol.init_fac(conf, temp_scale) auto_ramping = false, auto_saturated = false, + auto_scram = false, + ---@type ascram_status + ascram_status = { + matrix_dc = false, + matrix_fill = false, + crit_alarm = false, + radiation = false, + gen_fault = false + }, + ---@type WASTE_PRODUCT auto_current_waste_product = types.WASTE_PRODUCT.PLUTONIUM, auto_pu_fallback_active = false, @@ -282,11 +147,191 @@ function iocontrol.init_fac(conf, temp_scale) env_d_ps = psil.create(), env_d_data = {} } + + -- create induction and SPS tables (currently only 1 of each is supported) + table.insert(io.facility.induction_ps_tbl, psil.create()) + table.insert(io.facility.induction_data_tbl, {}) + 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].TankConnection, 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 + ---@class pioctl_unit + local entry = { + unit_id = i, + connected = false, + rtu_hw = {}, + + num_boilers = 0, + num_turbines = 0, + num_snas = 0, + has_tank = conf.cooling.r_cool[i].TankConnection, + + control_state = false, + burn_rate_cmd = 0.0, + radiation = types.new_zero_radiation_reading(), + + sna_peak_rate = 0.0, + sna_max_rate = 0.0, + sna_out_rate = 0.0, + + waste_mode = types.WASTE_MODE.MANUAL_PLUTONIUM, + waste_product = types.WASTE_PRODUCT.PLUTONIUM, + + last_rate_change_ms = 0, + turbine_flow_stable = false, + + -- auto control group + a_group = 0, + + ---@type alarms + alarms = { ALARM_STATE.INACTIVE, ALARM_STATE.INACTIVE, ALARM_STATE.INACTIVE, ALARM_STATE.INACTIVE, ALARM_STATE.INACTIVE, ALARM_STATE.INACTIVE, ALARM_STATE.INACTIVE, ALARM_STATE.INACTIVE, ALARM_STATE.INACTIVE, ALARM_STATE.INACTIVE, ALARM_STATE.INACTIVE, ALARM_STATE.INACTIVE }, + + annunciator = {}, ---@type annunciator + + unit_ps = psil.create(), + reactor_data = {}, ---@type reactor_db + + boiler_ps_tbl = {}, + boiler_data_tbl = {}, + + turbine_ps_tbl = {}, + turbine_data_tbl = {}, + + tank_ps_tbl = {}, + 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.cooling.r_cool[i].BoilerCount do + table.insert(entry.boiler_ps_tbl, psil.create()) + table.insert(entry.boiler_data_tbl, {}) + end + + -- create turbine tables + for _ = 1, conf.cooling.r_cool[i].TurbineCount do + table.insert(entry.turbine_ps_tbl, psil.create()) + 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 + entry.num_turbines = #entry.turbine_data_tbl + + table.insert(io.units, entry) + end end -- set network link state ---@param state POCKET_LINK_STATE -function iocontrol.report_link_state(state) +---@param sv_addr integer? supervisor address if linked +---@param api_addr integer? coordinator address if linked +function iocontrol.report_link_state(state, sv_addr, api_addr) io.ps.publish("link_state", state) if state == LINK_STATE.API_LINK_ONLY or state == LINK_STATE.UNLINKED then @@ -296,6 +341,9 @@ function iocontrol.report_link_state(state) if state == LINK_STATE.SV_LINK_ONLY or state == LINK_STATE.UNLINKED then io.ps.publish("crd_conn_quality", 0) end + + if sv_addr then io.ps.publish("sv_addr", sv_addr) end + if api_addr then io.ps.publish("api_addr", api_addr) end end -- determine supervisor connection quality (trip time) @@ -357,6 +405,458 @@ function iocontrol.record_facility_data(data) return valid end +local function tripped(state) return state == ALARM_STATE.TRIPPED or state == ALARM_STATE.ACKED end + +local function _record_multiblock_status(faulted, data, ps) + ps.publish("formed", data.formed) + ps.publish("faulted", faulted) + + for key, val in pairs(data.state) do ps.publish(key, val) end + for key, val in pairs(data.tanks) do ps.publish(key, val) end +end + +-- update unit status data from API_GET_UNIT +---@param data table +function iocontrol.record_unit_data(data) + local unit = io.units[data[1]] ---@type pioctl_unit + + unit.connected = data[2] + unit.rtu_hw = data[3] + unit.alarms = data[4] + + --#region Annunciator + + unit.annunciator = data[5] + + local rcs_disconn, rcs_warn, rcs_hazard = false, false, false + + for key, val in pairs(unit.annunciator) do + if key == "BoilerOnline" or key == "TurbineOnline" then + local every = true + + -- split up online arrays + for id = 1, #val do + every = every and val[id] + + if key == "BoilerOnline" then + unit.boiler_ps_tbl[id].publish(key, val[id]) + else + unit.turbine_ps_tbl[id].publish(key, val[id]) + end + end + + if not every then rcs_disconn = true end + + unit.unit_ps.publish("U_" .. key, every) + elseif key == "HeatingRateLow" or key == "WaterLevelLow" then + -- split up array for all boilers + local any = false + for id = 1, #val do + any = any or val[id] + unit.boiler_ps_tbl[id].publish(key, val[id]) + end + + if key == "HeatingRateLow" and any then + rcs_warn = true + elseif key == "WaterLevelLow" and any then + rcs_hazard = true + end + + unit.unit_ps.publish("U_" .. key, any) + elseif key == "SteamDumpOpen" or key == "TurbineOverSpeed" or key == "GeneratorTrip" or key == "TurbineTrip" then + -- split up array for all turbines + local any = false + for id = 1, #val do + any = any or val[id] + unit.turbine_ps_tbl[id].publish(key, val[id]) + end + + if key == "GeneratorTrip" and any then + rcs_warn = true + elseif (key == "TurbineOverSpeed" or key == "TurbineTrip") and any then + rcs_hazard = true + end + + unit.unit_ps.publish("U_" .. key, any) + else + -- non-table fields + unit.unit_ps.publish(key, val) + end + end + + local anc = unit.annunciator + rcs_hazard = rcs_hazard or anc.RCPTrip + rcs_warn = rcs_warn or anc.RCSFlowLow or anc.CoolantLevelLow or anc.RCSFault or anc.MaxWaterReturnFeed or + anc.CoolantFeedMismatch or anc.BoilRateMismatch or anc.SteamFeedMismatch + + local rcs_status = 4 + if rcs_hazard then + rcs_status = 2 + elseif rcs_warn then + rcs_status = 3 + elseif rcs_disconn then + rcs_status = 1 + end + + unit.unit_ps.publish("U_RCS", rcs_status) + + --#endregion + + --#region Reactor Data + + unit.reactor_data = data[6] + + local control_status = 1 + local reactor_status = 1 + local reactor_state = 1 + local rps_status = 1 + + if unit.connected then + -- update RPS status + if unit.reactor_data.rps_tripped then + control_status = 2 + + if unit.reactor_data.rps_trip_cause == "manual" then + reactor_state = 4 -- disabled + rps_status = 3 + else + reactor_state = 6 -- SCRAM + rps_status = 2 + end + else + rps_status = 4 + reactor_state = 4 + end + + -- update reactor/control status + if unit.reactor_data.mek_status.status then + reactor_status = 4 + reactor_state = 5 -- running + control_status = util.trinary(unit.annunciator.AutoControl, 4, 3) + else + if unit.reactor_data.no_reactor then + reactor_status = 2 + reactor_state = 3 -- faulted + elseif not unit.reactor_data.formed then + reactor_status = 3 + reactor_state = 2 -- not formed + elseif unit.reactor_data.rps_status.force_dis then + reactor_status = 3 + reactor_state = 7 -- force disabled + else + reactor_status = 4 + end + end + + for key, val in pairs(unit.reactor_data) do + if key ~= "rps_status" and key ~= "mek_struct" and key ~= "mek_status" then + unit.unit_ps.publish(key, val) + end + end + + if type(unit.reactor_data.rps_status) == "table" then + for key, val in pairs(unit.reactor_data.rps_status) do + unit.unit_ps.publish(key, val) + end + end + + if type(unit.reactor_data.mek_status) == "table" then + for key, val in pairs(unit.reactor_data.mek_status) do + unit.unit_ps.publish(key, val) + end + end + end + + unit.unit_ps.publish("U_ControlStatus", control_status) + unit.unit_ps.publish("U_ReactorStatus", reactor_status) + unit.unit_ps.publish("U_ReactorStateStatus", reactor_state) + unit.unit_ps.publish("U_RPS", rps_status) + + --#endregion + + --#region RTU Devices + + unit.boiler_data_tbl = data[7] + + for id = 1, #unit.boiler_data_tbl do + local boiler = unit.boiler_data_tbl[id] ---@type boilerv_session_db + local ps = unit.boiler_ps_tbl[id] ---@type psil + + local boiler_status = 1 + local computed_status = 1 + + if unit.rtu_hw.boilers[id].connected then + if unit.rtu_hw.boilers[id].faulted then + boiler_status = 3 + computed_status = 3 + elseif boiler.formed then + boiler_status = 4 + + if boiler.state.boil_rate > 0 then + computed_status = 5 + else + computed_status = 4 + end + else + boiler_status = 2 + computed_status = 2 + end + + _record_multiblock_status(unit.rtu_hw.boilers[id].faulted, boiler, ps) + end + + ps.publish("BoilerStatus", boiler_status) + ps.publish("BoilerStateStatus", computed_status) + end + + unit.turbine_data_tbl = data[8] + + for id = 1, #unit.turbine_data_tbl do + local turbine = unit.turbine_data_tbl[id] ---@type turbinev_session_db + local ps = unit.turbine_ps_tbl[id] ---@type psil + + local turbine_status = 1 + local computed_status = 1 + + if unit.rtu_hw.turbines[id].connected then + if unit.rtu_hw.turbines[id].faulted then + turbine_status = 3 + computed_status = 3 + elseif turbine.formed then + turbine_status = 4 + + if turbine.tanks.energy_fill >= 0.99 then + computed_status = 6 + elseif turbine.state.flow_rate < 100 then + computed_status = 4 + else + computed_status = 5 + end + else + turbine_status = 2 + computed_status = 2 + end + + _record_multiblock_status(unit.rtu_hw.turbines[id].faulted, turbine, ps) + end + + ps.publish("TurbineStatus", turbine_status) + ps.publish("TurbineStateStatus", computed_status) + end + + unit.tank_data_tbl = data[9] + + unit.last_rate_change_ms = data[10] + unit.turbine_flow_stable = data[11] + + --#endregion + + --#region Status Information Display + + local ecam = {} -- aviation reference :) back to VATSIM I go... + + -- local function red(text) return { text = text, color = colors.red } end + local function white(text) return { text = text, color = colors.white } end + local function blue(text) return { text = text, color = colors.blue } end + + -- unit.reactor_data.rps_status = { + -- high_dmg = false, + -- high_temp = false, + -- low_cool = false, + -- ex_waste = false, + -- ex_hcool = false, + -- no_fuel = false, + -- fault = false, + -- timeout = false, + -- manual = false, + -- automatic = false, + -- sys_fail = false, + -- force_dis = false + -- } + + -- if unit.reactor_data.rps_status then + -- for k, v in pairs(unit.alarms) do + -- unit.alarms[k] = ALARM_STATE.TRIPPED + -- end + -- end + + if tripped(unit.alarms[ALARM.ContainmentBreach]) then + local items = { white("REACTOR MELTDOWN"), blue("DON HAZMAT SUIT") } + table.insert(ecam, { color = colors.red, text = "CONTAINMENT BREACH", help = "ContainmentBreach", items = items }) + end + + if tripped(unit.alarms[ALARM.ContainmentRadiation]) then + local items = { + white("RADIATION DETECTED"), + blue("DON HAZMAT SUIT"), + blue("RESOLVE LEAK"), + blue("AWAIT SAFE LEVELS") + } + + table.insert(ecam, { color = colors.red, text = "RADIATION LEAK", help = "ContainmentRadiation", items = items }) + end + + if tripped(unit.alarms[ALARM.CriticalDamage]) then + local items = { white("MELTDOWN IMMINENT"), blue("EVACUATE") } + table.insert(ecam, { color = colors.red, text = "RCT DAMAGE CRITICAL", help = "CriticalDamage", items = items }) + end + + if tripped(unit.alarms[ALARM.ReactorLost]) then + local items = { white("REACTOR OFF-LINE"), blue("CHECK PLC") } + table.insert(ecam, { color = colors.red, text = "REACTOR CONN LOST", help = "ReactorLost", items = items }) + end + + if tripped(unit.alarms[ALARM.ReactorDamage]) then + local items = { white("REACTOR DAMAGED"), blue("CHECK RCS"), blue("AWAIT DMG REDUCED") } + table.insert(ecam, { color = colors.red, text = "REACTOR DAMAGE", help = "ReactorDamage", items = items }) + end + + if tripped(unit.alarms[ALARM.ReactorOverTemp]) then + local items = { white("DAMAGING TEMP"), blue("CHECK RCS"), blue("AWAIT COOLDOWN") } + table.insert(ecam, { color = colors.red, text = "REACTOR OVER TEMP", help = "ReactorOverTemp", items = items }) + end + + if tripped(unit.alarms[ALARM.ReactorHighTemp]) then + local items = { white("OVER EXPECTED TEMP"), blue("CHECK RCS") } + table.insert(ecam, { color = colors.yellow, text = "REACTOR HIGH TEMP", help = "ReactorHighTemp", items = items}) + end + + if tripped(unit.alarms[ALARM.ReactorWasteLeak]) then + local items = { white("AT WASTE CAPACITY"), blue("CHECK WASTE OUTPUT"), blue("KEEP RCT DISABLED") } + table.insert(ecam, { color = colors.red, text = "REACTOR WASTE LEAK", help = "ReactorWasteLeak", items = items}) + end + + if tripped(unit.alarms[ALARM.ReactorHighWaste]) then + local items = { blue("CHECK WASTE OUTPUT") } + table.insert(ecam, { color = colors.yellow, text = "REACTOR WASTE HIGH", help = "ReactorHighWaste", items = items}) + end + + if tripped(unit.alarms[ALARM.RPSTransient]) then + local items = {} + local stat = unit.reactor_data.rps_status + + -- for k, _ in pairs(stat) do stat[k] = true end + + local function insert(cond, key, text, color) if cond[key] then table.insert(items, { text = text, help = key, color = color }) end end + + table.insert(items, white("REACTOR SCRAMMED")) + insert(stat, "high_dmg", "HIGH DAMAGE", colors.red) + insert(stat, "high_temp", "HIGH TEMPERATURE", colors.red) + insert(stat, "low_cool", "CRIT LOW COOLANT") + insert(stat, "ex_waste", "EXCESS WASTE") + insert(stat, "ex_hcool", "EXCESS HEATED COOL") + insert(stat, "no_fuel", "NO FUEL") + insert(stat, "fault", "HARDWARE FAULT") + insert(stat, "timeout", "SUPERVISOR DISCONN") + insert(stat, "manual", "MANUAL SCRAM", colors.white) + insert(stat, "automatic", "AUTOMATIC SCRAM") + insert(stat, "sys_fail", "NOT FORMED", colors.red) + insert(stat, "force_dis", "FORCE DISABLED", colors.red) + table.insert(items, blue("RESOLVE PROBLEM")) + table.insert(items, blue("RESET RPS")) + + table.insert(ecam, { color = colors.yellow, text = "RPS TRANSIENT", help = "RPSTransient", items = items}) + end + + if tripped(unit.alarms[ALARM.RCSTransient]) then + local items = {} + local annunc = unit.annunciator + + -- for k, v in pairs(annunc) do + -- if type(v) == "boolean" then annunc[k] = true end + -- if type(v) == "table" then + -- for a, _ in pairs(v) do + -- v[a] = true + -- end + -- end + -- end + + local function insert(cond, key, text, color) + if cond == true or (type(cond) == "table" and cond[key]) then table.insert(items, { text = text, help = key, color = color }) end + end + + table.insert(items, white("COOLANT PROBLEM")) + + insert(annunc, "RCPTrip", "RCP TRIP", colors.red) + insert(annunc, "CoolantLevelLow", "LOW COOLANT") + + if unit.num_boilers == 0 then + if (util.time_ms() - unit.last_rate_change_ms) > const.FLOW_STABILITY_DELAY_MS then + insert(annunc, "BoilRateMismatch", "BOIL RATE MISMATCH") + end + + if unit.turbine_flow_stable then + insert(annunc, "RCSFlowLow", "RCS FLOW LOW") + insert(annunc, "CoolantFeedMismatch", "COOL FEED MISMATCH") + insert(annunc, "SteamFeedMismatch", "STM FEED MISMATCH") + end + else + if (util.time_ms() - unit.last_rate_change_ms) > const.FLOW_STABILITY_DELAY_MS then + insert(annunc, "RCSFlowLow", "RCS FLOW LOW") + insert(annunc, "BoilRateMismatch", "BOIL RATE MISMATCH") + insert(annunc, "CoolantFeedMismatch", "COOL FEED MISMATCH") + end + + if unit.turbine_flow_stable then + insert(annunc, "SteamFeedMismatch", "STM FEED MISMATCH") + end + end + + insert(annunc, "MaxWaterReturnFeed", "MAX WTR RTRN FEED") + + for k, v in ipairs(annunc.WaterLevelLow) do insert(v, "WaterLevelLow", "BOILER " .. k .. " WTR LOW", colors.red) end + for k, v in ipairs(annunc.HeatingRateLow) do insert(v, "HeatingRateLow", "BOILER " .. k .. " HEAT RATE") end + for k, v in ipairs(annunc.TurbineOverSpeed) do insert(v, "TurbineOverSpeed", "TURBINE " .. k .. " OVERSPD", colors.red) end + for k, v in ipairs(annunc.GeneratorTrip) do insert(v, "GeneratorTrip", "TURBINE " .. k .. " GEN TRIP") end + + table.insert(items, blue("CHECK COOLING SYS")) + + table.insert(ecam, { color = colors.yellow, text = "RCS TRANSIENT", help = "RCSTransient", items = items}) + end + + if tripped(unit.alarms[ALARM.TurbineTrip]) then + local items = {} + + for k, v in ipairs(unit.annunciator.TurbineTrip) do + if v then table.insert(items, { text = "TURBINE " .. k .. " TRIP", help = "TurbineTrip" }) end + end + + table.insert(items, blue("CHECK ENERGY OUT")) + table.insert(ecam, { color = colors.red, text = "TURBINE TRIP", help = "TurbineTripAlarm", items = items}) + end + + if not (tripped(unit.alarms[ALARM.ReactorLost]) or unit.connected) then + local items = { blue("CHECK PLC") } + table.insert(ecam, { color = colors.yellow, text = "REACTOR OFF-LINE", items = items }) + end + + for k, v in ipairs(unit.annunciator.BoilerOnline) do + if not v then + local items = { blue("CHECK RTU") } + table.insert(ecam, { color = colors.yellow, text = "BOILER " .. k .. " OFF-LINE", items = items}) + end + end + + for k, v in ipairs(unit.annunciator.TurbineOnline) do + if not v then + local items = { blue("CHECK RTU") } + table.insert(ecam, { color = colors.yellow, text = "TURBINE " .. k .. " OFF-LINE", items = items}) + end + end + + -- if no alarms, put some basic status messages in + if #ecam == 0 then + table.insert(ecam, { color = colors.green, text = "REACTOR " .. util.trinary(unit.reactor_data.mek_status.status, "NOMINAL", "IDLE"), items = {}}) + + local plural = util.trinary(unit.num_turbines > 1, "S", "") + table.insert(ecam, { color = colors.green, text = "TURBINE" .. plural .. util.trinary(unit.turbine_flow_stable, " STABLE", " STABILIZING"), items = {}}) + end + + unit.unit_ps.publish("U_ECAM", textutils.serialize(ecam)) + + --#endregion +end + -- get the IO controller database function iocontrol.get_db() return io end diff --git a/pocket/pocket.lua b/pocket/pocket.lua index 63149e8..fab0827 100644 --- a/pocket/pocket.lua +++ b/pocket/pocket.lua @@ -14,6 +14,18 @@ local LINK_STATE = iocontrol.LINK_STATE local pocket = {} +local MQ__RENDER_CMD = { + UNLOAD_SV_APPS = 1, + UNLOAD_API_APPS = 2 +} + +local MQ__RENDER_DATA = { + LOAD_APP = 1 +} + +pocket.MQ__RENDER_CMD = MQ__RENDER_CMD +pocket.MQ__RENDER_DATA = MQ__RENDER_DATA + ---@type pkt_config local config = {} @@ -23,6 +35,8 @@ pocket.config = config function pocket.load_config() if not settings.load("/pocket.settings") then return false end + config.TempScale = settings.get("TempScale") + config.SVR_Channel = settings.get("SVR_Channel") config.CRD_Channel = settings.get("CRD_Channel") config.PKT_Channel = settings.get("PKT_Channel") @@ -36,6 +50,9 @@ function pocket.load_config() local cfv = util.new_validator() + cfv.assert_type_int(config.TempScale) + cfv.assert_range(config.TempScale, 1, 4) + cfv.assert_channel(config.SVR_Channel) cfv.assert_channel(config.CRD_Channel) cfv.assert_channel(config.PKT_Channel) @@ -58,26 +75,310 @@ function pocket.load_config() return cfv.valid() end +---@enum POCKET_APP_ID +local APP_ID = { + ROOT = 1, + LOADER = 2, + -- main app pages + UNITS = 3, + GUIDE = 4, + ABOUT = 5, + -- diag app page + ALARMS = 6, + -- other + DUMMY = 7, + NUM_APPS = 7 +} + +pocket.APP_ID = APP_ID + +---@class nav_tree_page +---@field _p nav_tree_page|nil page's parent +---@field _c table page's children +---@field nav_to function function to navigate to this page +---@field switcher function|nil function to switch between children +---@field tasks table tasks to run while viewing this page + +-- initialize the page navigation system +---@param smem pkt_shared_memory +function pocket.init_nav(smem) + local self = { + pane = nil, ---@type graphics_element + sidebar = nil, ---@type graphics_element + apps = {}, + containers = {}, + help_map = {}, + help_return = nil, + loader_return = nil, + cur_app = APP_ID.ROOT + } + + self.cur_page = self.root + + ---@class pocket_nav + local nav = {} + + -- set the root pane element to switch between apps with + ---@param root_pane graphics_element + function nav.set_pane(root_pane) self.pane = root_pane end + + -- link sidebar element + ---@param sidebar graphics_element + function nav.set_sidebar(sidebar) self.sidebar = sidebar end + + -- register an app + ---@param app_id POCKET_APP_ID app ID + ---@param container graphics_element element that contains this app (usually a Div) + ---@param pane? graphics_element multipane if this is a simple paned app, then nav_to must be a number + ---@param require_sv? boolean true to specifiy if this app should be unloaded when the supervisor connection is lost + ---@param require_api? boolean true to specifiy if this app should be unloaded when the api connection is lost + function nav.register_app(app_id, container, pane, require_sv, require_api) + ---@class pocket_app + local app = { + loaded = false, + cur_page = nil, ---@type nav_tree_page + pane = pane, + paned_pages = {}, + sidebar_items = {} + } + + app.load = function () app.loaded = true end + app.unload = function () app.loaded = false end + + -- check which connections this requires (for unload) + ---@return boolean requires_sv, boolean requires_api + function app.check_requires() return require_sv or false, require_api or false end + + -- check if any connection is required (for load) + function app.requires_conn() return require_sv or require_api or false end + + -- delayed set of the pane if it wasn't ready at the start + ---@param root_pane graphics_element multipane + function app.set_root_pane(root_pane) + app.pane = root_pane + end + + -- configure the sidebar + ---@param items table + function app.set_sidebar(items) + app.sidebar_items = items + if self.sidebar then self.sidebar.update(items) end + end + + -- function to run on initial load into memory + ---@param on_load function callback + function app.set_load(on_load) + app.load = function () + on_load() + app.loaded = true + end + end + + -- function to run to close out the app + ---@param on_unload function callback + function app.set_unload(on_unload) + app.unload = function () + on_unload() + app.loaded = false + end + end + + -- if a pane was provided, this will switch between numbered pages + ---@param idx integer page index + function app.switcher(idx) + if app.paned_pages[idx] then + app.paned_pages[idx].nav_to() + end + end + + -- create a new page entry in the app's page navigation tree + ---@param parent nav_tree_page|nil a parent page or nil to set this as the root + ---@param nav_to function|integer function to navigate to this page or pane index + ---@return nav_tree_page new_page this new page + function app.new_page(parent, nav_to) + ---@type nav_tree_page + local page = { _p = parent, _c = {}, nav_to = function () end, switcher = function () end, tasks = {} } + + if parent == nil and app.cur_page == nil then + app.cur_page = page + end + + if type(nav_to) == "number" then + app.paned_pages[nav_to] = page + + function page.nav_to() + app.cur_page = page + if app.pane then app.pane.set_value(nav_to) end + end + else + function page.nav_to() + app.cur_page = page + nav_to() + end + end + + -- switch between children + ---@param id integer child ID + function page.switcher(id) if page._c[id] then page._c[id].nav_to() end end + + if parent ~= nil then + table.insert(page._p._c, page) + end + + return page + end + + -- delete paned pages and clear the current page + function app.delete_pages() + app.paned_pages = {} + app.cur_page = nil + end + + -- get the currently active page + function app.get_current_page() return app.cur_page end + + -- attempt to navigate up the tree + ---@return boolean success true if successfully navigated up + function app.nav_up() + local parent = app.cur_page._p + if parent then parent.nav_to() end + return parent ~= nil + end + + self.apps[app_id] = app + self.containers[app_id] = container + + return app + end + + -- open an app + ---@param app_id POCKET_APP_ID + function nav.open_app(app_id) + -- reset help return on navigating out of an app + if app_id == APP_ID.ROOT then self.help_return = nil end + + local app = self.apps[app_id] ---@type pocket_app + if app then + if app.requires_conn() and not smem.pkt_sys.pocket_comms.is_linked() then + -- bring up the app loader + self.loader_return = app_id + app_id = APP_ID.LOADER + app = self.apps[app_id] + else self.loader_return = nil end + + if not app.loaded then smem.q.mq_render.push_data(MQ__RENDER_DATA.LOAD_APP, app_id) end + + self.cur_app = app_id + self.pane.set_value(app_id) + + if #app.sidebar_items > 0 then + self.sidebar.update(app.sidebar_items) + end + else + log.debug("tried to open unknown app") + end + end + + -- open the app that was blocked on connecting + function nav.on_loader_connected() + if self.loader_return then + nav.open_app(self.loader_return) + end + end + + -- load a given app + ---@param app_id POCKET_APP_ID + function nav.load_app(app_id) + self.apps[app_id].load() + end + + -- unload api-dependent apps + function nav.unload_api() + for id, app in pairs(self.apps) do + local _, api = app.check_requires() + if app.loaded and api then + if id == self.cur_app then nav.open_app(APP_ID.ROOT) end + app.unload() + end + end + end + + -- unload supervisor-dependent apps + function nav.unload_sv() + for id, app in pairs(self.apps) do + local sv, _ = app.check_requires() + if app.loaded and sv then + if id == self.cur_app then nav.open_app(APP_ID.ROOT) end + app.unload() + end + end + end + + -- get a list of the app containers (usually Div elements) + function nav.get_containers() return self.containers end + + -- get the currently active page + ---@return nav_tree_page + function nav.get_current_page() + return self.apps[self.cur_app].get_current_page() + end + + -- attempt to navigate up within the active app, otherwise open home page
+ -- except, this will go back to a prior app if leaving the help app after open_help was used + function nav.nav_up() + -- return out of help if opened with open_help + if self.help_return then + nav.open_app(self.help_return) + self.help_return = nil + return + end + + local app = self.apps[self.cur_app] ---@type pocket_app + log.debug("attempting app nav up for app " .. self.cur_app) + + if not app.nav_up() then + log.debug("internal app nav up failed, going to home screen") + nav.open_app(APP_ID.ROOT) + end + end + + -- open the help app, to show the reference for a key + function nav.open_help(key) + self.help_return = self.cur_app + + nav.open_app(APP_ID.GUIDE) + + local load = self.help_map[key] + if load then load() end + end + + -- link the help map from the guide app + function nav.link_help(map) self.help_map = map end + + return nav +end + -- pocket coordinator + supervisor communications ---@nodiscard ---@param version string pocket version ---@param nic nic network interface device ---@param sv_watchdog watchdog ---@param api_watchdog watchdog -function pocket.comms(version, nic, sv_watchdog, api_watchdog) +---@param nav pocket_nav +function pocket.comms(version, nic, sv_watchdog, api_watchdog, nav) local self = { sv = { linked = false, addr = comms.BROADCAST, - seq_num = 0, - r_seq_num = nil, ---@type nil|integer + seq_num = util.time_ms() * 10, -- unique per peer, restarting will not re-use seq nums due to message rate + r_seq_num = nil, ---@type nil|integer last_est_ack = ESTABLISH_ACK.ALLOW }, api = { linked = false, addr = comms.BROADCAST, - seq_num = 0, - r_seq_num = nil, ---@type nil|integer + seq_num = util.time_ms() * 10, -- unique per peer, restarting will not re-use seq nums due to message rate + r_seq_num = nil, ---@type nil|integer last_est_ack = ESTABLISH_ACK.ALLOW }, establish_delay_counter = 0 @@ -119,6 +420,20 @@ function pocket.comms(version, nic, sv_watchdog, api_watchdog) self.api.seq_num = self.api.seq_num + 1 end + -- send an API packet to the coordinator + ---@param msg_type CRDN_TYPE + ---@param msg table + local function _send_api(msg_type, msg) + local s_pkt = comms.scada_packet() + local pkt = comms.crdn_packet() + + pkt.make(msg_type, msg) + s_pkt.make(self.api.addr, self.api.seq_num, PROTOCOL.SCADA_CRDN, pkt.raw_sendable()) + + nic.transmit(config.CRD_Channel, config.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(MGMT_TYPE.ESTABLISH, { comms.version, version, DEVICE_TYPE.PKT }) @@ -149,6 +464,7 @@ function pocket.comms(version, nic, sv_watchdog, api_watchdog) -- close connection to the supervisor function public.close_sv() sv_watchdog.cancel() + nav.unload_sv() self.sv.linked = false self.sv.r_seq_num = nil self.sv.addr = comms.BROADCAST @@ -158,6 +474,7 @@ function pocket.comms(version, nic, sv_watchdog, api_watchdog) -- close connection to coordinator API server function public.close_api() api_watchdog.cancel() + nav.unload_api() self.api.linked = false self.api.r_seq_num = nil self.api.addr = comms.BROADCAST @@ -192,7 +509,7 @@ function pocket.comms(version, nic, sv_watchdog, api_watchdog) end else -- linked, all good! - iocontrol.report_link_state(LINK_STATE.LINKED) + iocontrol.report_link_state(LINK_STATE.LINKED, self.sv.addr, self.api.addr) end end @@ -215,6 +532,11 @@ function pocket.comms(version, nic, sv_watchdog, api_watchdog) if self.sv.linked then _send_sv(MGMT_TYPE.DIAG_ALARM_SET, { id, state }) end end + -- coordinator get unit data + function public.api__get_unit(unit) + if self.api.linked then _send_api(CRDN_TYPE.API_GET_UNIT, { unit }) end + end + -- parse a packet ---@param side string ---@param sender integer @@ -255,7 +577,7 @@ function pocket.comms(version, nic, sv_watchdog, api_watchdog) local ok = util.trinary(max == nil, packet.length == length, packet.length >= length and packet.length <= (max or 0)) if not ok then local fmt = "[comms] RX_PACKET{r_chan=%d,proto=%d,type=%d}: packet length mismatch -> expect %d != actual %d" - log.debug(util.sprintf(fmt, packet.scada_frame.remote_channel(), packet.scada_frame.protocol(), packet.type)) + log.debug(util.sprintf(fmt, packet.scada_frame.remote_channel(), packet.scada_frame.protocol(), packet.type, length, packet.scada_frame.length())) end return ok end @@ -282,8 +604,8 @@ function pocket.comms(version, nic, sv_watchdog, api_watchdog) elseif r_chan == config.CRD_Channel then -- check sequence number if self.api.r_seq_num == nil then - self.api.r_seq_num = packet.scada_frame.seq_num() - elseif self.connected and ((self.api.r_seq_num + 1) ~= packet.scada_frame.seq_num()) then + self.api.r_seq_num = packet.scada_frame.seq_num() + 1 + elseif self.api.r_seq_num ~= packet.scada_frame.seq_num() then log.warning("sequence out-of-order (API): last = " .. self.api.r_seq_num .. ", new = " .. packet.scada_frame.seq_num()) return elseif self.api.linked and (src_addr ~= self.api.addr) then @@ -291,7 +613,7 @@ function pocket.comms(version, nic, sv_watchdog, api_watchdog) "); channel in use by another system?") return else - self.api.r_seq_num = packet.scada_frame.seq_num() + self.api.r_seq_num = packet.scada_frame.seq_num() + 1 end -- feed watchdog on valid sequence number @@ -304,7 +626,10 @@ function pocket.comms(version, nic, sv_watchdog, api_watchdog) if _check_length(packet, 11) then iocontrol.record_facility_data(packet.data) end - elseif packet.type == CRDN_TYPE.API_GET_UNITS then + elseif packet.type == CRDN_TYPE.API_GET_UNIT then + if _check_length(packet, 11) and type(packet.data[1]) == "number" and iocontrol.get_db().units[packet.data[1]] then + iocontrol.record_unit_data(packet.data) + end else _fail_type(packet) end else log.debug("discarding coordinator SCADA_CRDN packet before linked") @@ -331,6 +656,7 @@ function pocket.comms(version, nic, sv_watchdog, api_watchdog) elseif packet.type == MGMT_TYPE.CLOSE then -- handle session close api_watchdog.cancel() + nav.unload_api() self.api.linked = false self.api.r_seq_num = nil self.api.addr = comms.BROADCAST @@ -349,8 +675,7 @@ function pocket.comms(version, nic, sv_watchdog, api_watchdog) -- get configuration local conf = { num_units = fac_config[1], cooling = fac_config[2] } - ---@todo unit options - iocontrol.init_fac(conf, 1) + iocontrol.init_fac(conf, config.TempScale) log.info("coordinator connection established") self.establish_delay_counter = 0 @@ -358,7 +683,7 @@ function pocket.comms(version, nic, sv_watchdog, api_watchdog) self.api.addr = src_addr if self.sv.linked then - iocontrol.report_link_state(LINK_STATE.LINKED) + iocontrol.report_link_state(LINK_STATE.LINKED, self.sv.addr, self.api.addr) else iocontrol.report_link_state(LINK_STATE.API_LINK_ONLY) end @@ -399,8 +724,8 @@ function pocket.comms(version, nic, sv_watchdog, api_watchdog) elseif r_chan == config.SVR_Channel then -- check sequence number if self.sv.r_seq_num == nil then - self.sv.r_seq_num = packet.scada_frame.seq_num() - elseif self.connected and ((self.sv.r_seq_num + 1) ~= packet.scada_frame.seq_num()) then + self.sv.r_seq_num = packet.scada_frame.seq_num() + 1 + elseif self.sv.r_seq_num ~= packet.scada_frame.seq_num() then log.warning("sequence out-of-order (SVR): last = " .. self.sv.r_seq_num .. ", new = " .. packet.scada_frame.seq_num()) return elseif self.sv.linked and (src_addr ~= self.sv.addr) then @@ -408,7 +733,7 @@ function pocket.comms(version, nic, sv_watchdog, api_watchdog) "); channel in use by another system?") return else - self.sv.r_seq_num = packet.scada_frame.seq_num() + self.sv.r_seq_num = packet.scada_frame.seq_num() + 1 end -- feed watchdog on valid sequence number @@ -437,6 +762,7 @@ function pocket.comms(version, nic, sv_watchdog, api_watchdog) elseif packet.type == MGMT_TYPE.CLOSE then -- handle session close sv_watchdog.cancel() + nav.unload_sv() self.sv.linked = false self.sv.r_seq_num = nil self.sv.addr = comms.BROADCAST @@ -497,7 +823,7 @@ function pocket.comms(version, nic, sv_watchdog, api_watchdog) self.sv.addr = src_addr if self.api.linked then - iocontrol.report_link_state(LINK_STATE.LINKED) + iocontrol.report_link_state(LINK_STATE.LINKED, self.sv.addr, self.api.addr) else iocontrol.report_link_state(LINK_STATE.SV_LINK_ONLY) end @@ -537,6 +863,10 @@ function pocket.comms(version, nic, sv_watchdog, api_watchdog) ---@nodiscard function public.is_api_linked() return self.api.linked end + -- check if we are still linked with the supervisor and coordinator + ---@nodiscard + function public.is_linked() return self.sv.linked and self.api.linked end + return public end diff --git a/pocket/renderer.lua b/pocket/renderer.lua index 892fc92..bc16037 100644 --- a/pocket/renderer.lua +++ b/pocket/renderer.lua @@ -92,4 +92,20 @@ function renderer.handle_mouse(event) end end +-- handle a keyboard event +---@param event key_interaction|nil +function renderer.handle_key(event) + if ui.display ~= nil and event ~= nil then + ui.display.handle_key(event) + end +end + +-- handle a paste event +---@param text string +function renderer.handle_paste(text) + if ui.display ~= nil then + ui.display.handle_paste(text) + end +end + return renderer diff --git a/pocket/startup.lua b/pocket/startup.lua index 67e2f79..2701efb 100644 --- a/pocket/startup.lua +++ b/pocket/startup.lua @@ -2,27 +2,35 @@ -- SCADA System Access on a Pocket Computer -- +---@diagnostic disable-next-line: undefined-global +local _is_pocket_env = pocket or periphemu -- luacheck: ignore pocket + require("/initenv").init_env() local crash = require("scada-common.crash") local log = require("scada-common.log") +local mqueue = require("scada-common.mqueue") 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 configure = require("pocket.configure") local iocontrol = require("pocket.iocontrol") local pocket = require("pocket.pocket") local renderer = require("pocket.renderer") +local threads = require("pocket.threads") -local POCKET_VERSION = "v0.8.0-alpha" +local POCKET_VERSION = "v0.11.0-alpha" local println = util.println local println_ts = util.println_ts +-- check environment (allows Pocket or CraftOS-PC) +if not _is_pocket_env then + println("You can only use this application on a pocket computer.") + return +end + ---------------------------------------- -- get configuration ---------------------------------------- @@ -72,9 +80,51 @@ local function main() iocontrol.get_db().version = POCKET_VERSION ---------------------------------------- - -- setup communications & clocks + -- memory allocation ---------------------------------------- + -- shared memory across threads + ---@class pkt_shared_memory + local __shared_memory = { + -- pocket system state flags + ---@class pkt_state + pkt_state = { + ui_ok = false, + ui_error = nil, + shutdown = false + }, + + -- core pocket devices + pkt_dev = { + modem = ppm.get_wireless_modem() + }, + + -- system objects + pkt_sys = { + nic = nil, ---@type nic + pocket_comms = nil, ---@type pocket_comms + sv_wd = nil, ---@type watchdog + api_wd = nil, ---@type watchdog + nav = nil ---@type pocket_nav + }, + + -- message queues + q = { + mq_render = mqueue.new() + } + } + + local smem_dev = __shared_memory.pkt_dev + local smem_sys = __shared_memory.pkt_sys + + local pkt_state = __shared_memory.pkt_state + + ---------------------------------------- + -- setup system + ---------------------------------------- + + smem_sys.nav = pocket.init_nav(__shared_memory) + -- message authentication init if type(config.AuthKey) == "string" and string.len(config.AuthKey) > 0 then network.init_mac(config.AuthKey) @@ -83,112 +133,59 @@ local function main() iocontrol.report_link_state(iocontrol.LINK_STATE.UNLINKED) -- get the communications modem - local modem = ppm.get_wireless_modem() - if modem == nil then + if smem_dev.modem == nil then println("startup> wireless modem not found: please craft the pocket computer with a wireless modem") log.fatal("startup> no wireless modem on startup") return end -- create connection watchdogs - local conn_wd = { - sv = util.new_watchdog(config.ConnTimeout), - api = util.new_watchdog(config.ConnTimeout) - } - - conn_wd.sv.cancel() - conn_wd.api.cancel() - + smem_sys.sv_wd = util.new_watchdog(config.ConnTimeout) + smem_sys.sv_wd.cancel() + smem_sys.api_wd = util.new_watchdog(config.ConnTimeout) + smem_sys.api_wd.cancel() log.debug("startup> conn watchdogs created") -- create network interface then setup comms - local nic = network.nic(modem) - local pocket_comms = pocket.comms(POCKET_VERSION, nic, conn_wd.sv, conn_wd.api) + smem_sys.nic = network.nic(smem_dev.modem) + smem_sys.pocket_comms = pocket.comms(POCKET_VERSION, smem_sys.nic, smem_sys.sv_wd, smem_sys.api_wd, smem_sys.nav) log.debug("startup> comms init") - -- base loop clock (2Hz, 10 ticks) - local MAIN_CLOCK = 0.5 - local loop_clock = util.new_clock(MAIN_CLOCK) - -- init I/O control - iocontrol.init_core(pocket_comms) + iocontrol.init_core(smem_sys.pocket_comms, smem_sys.nav) ---------------------------------------- -- start the UI ---------------------------------------- - local ui_ok, message = renderer.try_start_ui() - if not ui_ok then - println(util.c("UI error: ", message)) - log.error(util.c("startup> GUI render failed with error ", message)) - else - -- start clock - loop_clock.start() + local ui_message + pkt_state.ui_ok, ui_message = renderer.try_start_ui() + if not pkt_state.ui_ok then + println(util.c("UI error: ", ui_message)) + log.error(util.c("startup> GUI render failed with error ", ui_message)) end ---------------------------------------- - -- main event loop + -- start system ---------------------------------------- - if ui_ok then - -- start connection watchdogs - conn_wd.sv.feed() - conn_wd.api.feed() - log.debug("startup> conn watchdogs started") + if pkt_state.ui_ok then + -- init threads + local main_thread = threads.thread__main(__shared_memory) + local render_thread = threads.thread__render(__shared_memory) - local io_db = iocontrol.get_db() - local nav = io_db.nav + log.info("startup> completed") - -- main event loop - while true do - local event, param1, param2, param3, param4, param5 = util.pull_event() - - -- handle event - if event == "timer" then - if loop_clock.is_clock(param1) then - -- main loop tick - - -- relink if necessary - pocket_comms.link_update() - - -- update any tasks for the active page - local page_tasks = nav.get_current_page().tasks - for i = 1, #page_tasks do page_tasks[i]() end - - loop_clock.start() - elseif conn_wd.sv.is_timer(param1) then - -- supervisor watchdog timeout - log.info("supervisor server timeout") - pocket_comms.close_sv() - elseif conn_wd.api.is_timer(param1) then - -- coordinator watchdog timeout - log.info("coordinator api server timeout") - pocket_comms.close_api() - else - -- a non-clock/main watchdog timer event - -- notify timer callback dispatcher - tcd.handle(param1) - end - elseif event == "modem_message" then - -- got a packet - local packet = pocket_comms.parse_packet(param1, param2, param3, param4, param5) - pocket_comms.handle_packet(packet) - elseif event == "mouse_click" or event == "mouse_up" or event == "mouse_drag" or event == "mouse_scroll" or - event == "double_click" then - -- handle a monitor touch event - renderer.handle_mouse(core.events.new_mouse_event(event, param1, param2, param3)) - end - - -- check for termination request - if event == "terminate" or ppm.should_terminate() then - log.info("terminate requested, closing server connections...") - pocket_comms.close() - log.info("connections closed") - break - end - end + -- run threads + parallel.waitForAll(main_thread.p_exec, render_thread.p_exec) renderer.close_ui() + + if not pkt_state.ui_ok then + println(util.c("UI crashed with error: ", pkt_state.ui_error)) + end + else + println_ts("UI creation failed") end println_ts("exited") diff --git a/pocket/threads.lua b/pocket/threads.lua new file mode 100644 index 0000000..32120b2 --- /dev/null +++ b/pocket/threads.lua @@ -0,0 +1,219 @@ +local log = require("scada-common.log") +local mqueue = require("scada-common.mqueue") +local ppm = require("scada-common.ppm") +local tcd = require("scada-common.tcd") +local util = require("scada-common.util") + +local pocket = require("pocket.pocket") +local renderer = require("pocket.renderer") + +local core = require("graphics.core") + +local threads = {} + +local MAIN_CLOCK = 0.5 -- (2Hz, 10 ticks) +local RENDER_SLEEP = 100 -- (100ms, 2 ticks) + +local MQ__RENDER_CMD = pocket.MQ__RENDER_CMD +local MQ__RENDER_DATA = pocket.MQ__RENDER_DATA + +-- main thread +---@nodiscard +---@param smem pkt_shared_memory +function threads.thread__main(smem) + ---@class parallel_thread + local public = {} + + -- execute thread + function public.exec() + log.debug("main thread start") + + local loop_clock = util.new_clock(MAIN_CLOCK) + + -- start clock + loop_clock.start() + + -- load in from shared memory + local pkt_state = smem.pkt_state + local pocket_comms = smem.pkt_sys.pocket_comms + local sv_wd = smem.pkt_sys.sv_wd + local api_wd = smem.pkt_sys.api_wd + local nav = smem.pkt_sys.nav + + -- start connection watchdogs + sv_wd.feed() + api_wd.feed() + log.debug("startup> conn watchdogs started") + + -- event loop + while true do + local event, param1, param2, param3, param4, param5 = util.pull_event() + + -- handle event + if event == "timer" then + if loop_clock.is_clock(param1) then + -- main loop tick + + -- relink if necessary + pocket_comms.link_update() + + -- update any tasks for the active page + local page_tasks = nav.get_current_page().tasks + for i = 1, #page_tasks do page_tasks[i]() end + + loop_clock.start() + elseif sv_wd.is_timer(param1) then + -- supervisor watchdog timeout + log.info("supervisor server timeout") + pocket_comms.close_sv() + elseif api_wd.is_timer(param1) then + -- coordinator watchdog timeout + log.info("coordinator api server timeout") + pocket_comms.close_api() + else + -- a non-clock/main watchdog timer event + -- notify timer callback dispatcher + tcd.handle(param1) + end + elseif event == "modem_message" then + -- got a packet + local packet = pocket_comms.parse_packet(param1, param2, param3, param4, param5) + pocket_comms.handle_packet(packet) + elseif event == "mouse_click" or event == "mouse_up" or event == "mouse_drag" or event == "mouse_scroll" or + event == "double_click" then + -- handle a mouse event + renderer.handle_mouse(core.events.new_mouse_event(event, param1, param2, param3)) + elseif event == "char" or event == "key" or event == "key_up" then + -- handle a keyboard event + renderer.handle_key(core.events.new_key_event(event, param1, param2)) + elseif event == "paste" then + -- handle a paste event + renderer.handle_paste(param1) + end + + -- check for termination request or UI crash + if event == "terminate" or ppm.should_terminate() then + log.info("terminate requested, main thread exiting") + pkt_state.shutdown = true + elseif not pkt_state.ui_ok then + pkt_state.shutdown = true + log.info("terminating due to fatal UI error") + end + + if pkt_state.shutdown then + log.info("closing server connections...") + pocket_comms.close() + log.info("connections closed") + break + end + end + end + + -- execute the thread in a protected mode, retrying it on return if not shutting down + function public.p_exec() + local pkt_state = smem.pkt_state + + while not pkt_state.shutdown do + local status, result = pcall(public.exec) + if status == false then + log.fatal(util.strval(result)) + end + + -- if status is true, then we are probably exiting, so this won't matter + -- this thread cannot be slept because it will miss events (namely "terminate") + if not pkt_state.shutdown then + log.info("main thread restarting now...") + end + end + end + + return public +end + +-- pocket renderer thread, tasked with long duration draws +---@nodiscard +---@param smem pkt_shared_memory +function threads.thread__render(smem) + ---@class parallel_thread + local public = {} + + -- execute thread + function public.exec() + log.debug("render thread start") + + -- load in from shared memory + local pkt_state = smem.pkt_state + local nav = smem.pkt_sys.nav + local render_queue = smem.q.mq_render + + local last_update = util.time() + + -- thread loop + while true do + -- check for messages in the message queue + while render_queue.ready() and not pkt_state.shutdown do + local msg = render_queue.pop() + + if msg ~= nil then + if msg.qtype == mqueue.TYPE.COMMAND then + -- received a command + if msg.message == MQ__RENDER_CMD.UNLOAD_SV_APPS then + elseif msg.message == MQ__RENDER_CMD.UNLOAD_API_APPS then + end + elseif msg.qtype == mqueue.TYPE.DATA then + -- received data + local cmd = msg.message ---@type queue_data + + if cmd.key == MQ__RENDER_DATA.LOAD_APP then + log.debug("RENDER: load app " .. cmd.val) + + local draw_start = util.time_ms() + + pkt_state.ui_ok, pkt_state.ui_error = pcall(function () nav.load_app(cmd.val) end) + if not pkt_state.ui_ok then + log.fatal(util.c("RENDER: app load failed with error ", pkt_state.ui_error)) + else + log.debug("RENDER: app loaded in " .. (util.time_ms() - draw_start) .. "ms") + end + end + elseif msg.qtype == mqueue.TYPE.PACKET then + -- received a packet + end + end + + -- quick yield + util.nop() + end + + -- check for termination request + if pkt_state.shutdown then + log.info("render thread exiting") + break + end + + -- delay before next check + last_update = util.adaptive_delay(RENDER_SLEEP, last_update) + end + end + + -- execute the thread in a protected mode, retrying it on return if not shutting down + function public.p_exec() + local pkt_state = smem.pkt_state + + while not pkt_state.shutdown do + local status, result = pcall(public.exec) + if status == false then + log.fatal(util.strval(result)) + end + + if not pkt_state.shutdown then + log.info("render thread restarting in 5 seconds...") + util.psleep(5) + end + end + end + + return public +end + +return threads diff --git a/pocket/ui/apps/diag_apps.lua b/pocket/ui/apps/diag_apps.lua index ec8b5e6..27ef837 100644 --- a/pocket/ui/apps/diag_apps.lua +++ b/pocket/ui/apps/diag_apps.lua @@ -3,6 +3,7 @@ -- local iocontrol = require("pocket.iocontrol") +local pocket = require("pocket.pocket") local core = require("graphics.core") @@ -15,9 +16,10 @@ local Checkbox = require("graphics.elements.controls.checkbox") local PushButton = require("graphics.elements.controls.push_button") local SwitchButton = require("graphics.elements.controls.switch_button") +local ALIGN = core.ALIGN local cpair = core.cpair -local ALIGN = core.ALIGN +local APP_ID = pocket.APP_ID -- create diagnostic app pages ---@param root graphics_element parent @@ -30,7 +32,7 @@ local function create_pages(root) local alarm_test = Div{parent=root,x=1,y=1} - local alarm_app = db.nav.register_app(iocontrol.APP_ID.ALARMS, alarm_test) + local alarm_app = db.nav.register_app(APP_ID.ALARMS, alarm_test, nil, true) local page = alarm_app.new_page(nil, function () end) page.tasks = { db.diag.tone_test.get_tone_states } diff --git a/pocket/ui/apps/dummy_app.lua b/pocket/ui/apps/dummy_app.lua index fe21db3..6e92493 100644 --- a/pocket/ui/apps/dummy_app.lua +++ b/pocket/ui/apps/dummy_app.lua @@ -3,12 +3,15 @@ -- local iocontrol = require("pocket.iocontrol") +local pocket = require("pocket.pocket") local core = require("graphics.core") local Div = require("graphics.elements.div") local TextBox = require("graphics.elements.textbox") +local APP_ID = pocket.APP_ID + -- create placeholder app page ---@param root graphics_element parent local function create_pages(root) @@ -16,9 +19,11 @@ local function create_pages(root) local main = Div{parent=root,x=1,y=1} - db.nav.register_app(iocontrol.APP_ID.DUMMY, main).new_page(nil, function () end) + db.nav.register_app(APP_ID.DUMMY, main).new_page(nil, function () end) TextBox{parent=main,text="This app is not implemented yet.",x=1,y=2,alignment=core.ALIGN.CENTER} + + TextBox{parent=main,text=" pretend something cool is here \x03",x=1,y=10,alignment=core.ALIGN.CENTER,fg_bg=core.cpair(colors.gray,colors.black)} end return create_pages diff --git a/pocket/ui/apps/guide.lua b/pocket/ui/apps/guide.lua new file mode 100644 index 0000000..b174805 --- /dev/null +++ b/pocket/ui/apps/guide.lua @@ -0,0 +1,247 @@ +-- +-- System Guide +-- + +local util = require("scada-common.util") + +local iocontrol = require("pocket.iocontrol") +local pocket = require("pocket.pocket") + +local docs = require("pocket.ui.docs") +-- local style = require("pocket.ui.style") + +local guide_section = require("pocket.ui.pages.guide_section") + +local core = require("graphics.core") + +local Div = require("graphics.elements.div") +local ListBox = require("graphics.elements.listbox") +local MultiPane = require("graphics.elements.multipane") +local TextBox = require("graphics.elements.textbox") + +local WaitingAnim = require("graphics.elements.animations.waiting") + +local PushButton = require("graphics.elements.controls.push_button") + +local TextField = require("graphics.elements.form.text_field") + +local ALIGN = core.ALIGN +local cpair = core.cpair + +local APP_ID = pocket.APP_ID + +-- local label = style.label +-- local lu_col = style.label_unit_pair +-- local text_fg = style.text_fg + +-- new system guide view +---@param root graphics_element parent +local function new_view(root) + local db = iocontrol.get_db() + + local frame = Div{parent=root,x=1,y=1} + + local app = db.nav.register_app(APP_ID.GUIDE, frame) + + local load_div = Div{parent=frame,x=1,y=1} + local main = Div{parent=frame,x=1,y=1} + + TextBox{parent=load_div,y=12,text="Loading...",height=1,alignment=ALIGN.CENTER} + WaitingAnim{parent=load_div,x=math.floor(main.get_width()/2)-1,y=8,fg_bg=cpair(colors.cyan,colors._INHERIT)} + + local load_pane = MultiPane{parent=main,x=1,y=1,panes={load_div,main}} + + local btn_fg_bg = cpair(colors.cyan, colors.black) + local btn_active = cpair(colors.white, colors.black) + local btn_disable = cpair(colors.gray, colors.black) + + app.set_sidebar({{ label = " # ", tall = true, color = core.cpair(colors.black, colors.green), callback = function () db.nav.open_app(APP_ID.ROOT) end }}) + + local page_div = nil ---@type nil|graphics_element + + -- load the app (create the elements) + local function load() + local list = { + { label = " # ", tall = true, color = core.cpair(colors.black, colors.green), callback = function () db.nav.open_app(APP_ID.ROOT) end }, + { label = " \x14 ", color = core.cpair(colors.black, colors.cyan), callback = function () app.switcher(1) end }, + { label = "__?", color = core.cpair(colors.black, colors.lightGray), callback = function () app.switcher(2) end } + } + + app.set_sidebar(list) + + page_div = Div{parent=main,y=2} + local p_width = page_div.get_width() - 2 + + local main_page = app.new_page(nil, 1) + local search_page = app.new_page(main_page, 2) + local use_page = app.new_page(main_page, 3) + local uis_page = app.new_page(main_page, 4) + local fps_page = app.new_page(main_page, 5) + local gls_page = app.new_page(main_page, 6) + + local home = Div{parent=page_div,x=2} + local search = Div{parent=page_div,x=2} + local use = Div{parent=page_div,x=2,width=p_width} + local uis = Div{parent=page_div,x=2,width=p_width} + local fps = Div{parent=page_div,x=2,width=p_width} + local gls = Div{parent=page_div,x=2,width=p_width} + local panes = { home, search, use, uis, fps, gls } + + local doc_map = {} + local search_db = {} + + ---@class _guide_section_constructor_data + local sect_construct_data = { app, page_div, panes, doc_map, search_db, btn_fg_bg, btn_active } + + TextBox{parent=home,y=1,text="cc-mek-scada Guide",height=1,alignment=ALIGN.CENTER} + + PushButton{parent=home,y=3,text="Search >",fg_bg=btn_fg_bg,active_fg_bg=btn_active,callback=search_page.nav_to} + PushButton{parent=home,y=5,text="System Usage >",fg_bg=btn_fg_bg,active_fg_bg=btn_active,callback=use_page.nav_to} + PushButton{parent=home,text="Operator UIs >",fg_bg=btn_fg_bg,active_fg_bg=btn_active,callback=uis_page.nav_to} + PushButton{parent=home,text="Front Panels >",fg_bg=btn_fg_bg,active_fg_bg=btn_active,callback=fps_page.nav_to} + PushButton{parent=home,text="Glossary >",fg_bg=btn_fg_bg,active_fg_bg=btn_active,callback=gls_page.nav_to} + + TextBox{parent=search,y=1,text="Search",height=1,alignment=ALIGN.CENTER} + + local query_field = TextField{parent=search,x=1,y=3,width=18,fg_bg=cpair(colors.white,colors.gray)} + + local func_ref = {} + + PushButton{parent=search,x=20,y=3,text="GO",fg_bg=btn_fg_bg,active_fg_bg=btn_active,callback=function()func_ref.run_search()end} + + local search_results = ListBox{parent=search,x=1,y=5,scroll_height=200,nav_fg_bg=cpair(colors.lightGray,colors.gray),nav_active=cpair(colors.white,colors.gray)} + + function func_ref.run_search() + local query = string.lower(query_field.get_value()) + local s_results = { {}, {}, {} } + + search_results.remove_all() + + if string.len(query) < 3 then + TextBox{parent=search_results,text="Search requires at least 3 characters."} + return + end + + for _, entry in ipairs(search_db) do + local s_start, _ = string.find(entry[1], query, 1, true) + + if s_start == nil then + elseif s_start == 1 then + -- best match, start of key + table.insert(s_results[1], entry) + elseif string.sub(query, s_start - 1, s_start) == " " then + -- start of word, good match + table.insert(s_results[2], entry) + else + -- basic match in content + table.insert(s_results[3], entry) + end + end + + local empty = true + + for tier = 1, 3 do + for idx = 1, #s_results[tier] do + local entry = s_results[tier][idx] + TextBox{parent=search_results,text=entry[3].." >",fg_bg=cpair(colors.gray,colors.black)} + PushButton{parent=search_results,text=entry[2],alignment=ALIGN.LEFT,fg_bg=btn_fg_bg,active_fg_bg=btn_active,callback=entry[4]} + + empty = false + end + end + + if empty then + TextBox{parent=search_results,text="No results found."} + end + end + + TextBox{parent=search_results,text="Click 'GO' to search..."} + + util.nop() + + TextBox{parent=use,y=1,text="System Usage",height=1,alignment=ALIGN.CENTER} + PushButton{parent=use,x=2,y=1,text="<",fg_bg=btn_fg_bg,active_fg_bg=btn_active,callback=main_page.nav_to} + + PushButton{parent=use,y=3,text="Configuring Devices >",fg_bg=btn_fg_bg,active_fg_bg=btn_active,dis_fg_bg=btn_disable,callback=function()end}.disable() + PushButton{parent=use,text="Connecting Devices >",fg_bg=btn_fg_bg,active_fg_bg=btn_active,dis_fg_bg=btn_disable,callback=function()end}.disable() + PushButton{parent=use,text="Manual Control >",fg_bg=btn_fg_bg,active_fg_bg=btn_active,dis_fg_bg=btn_disable,callback=function()end}.disable() + PushButton{parent=use,text="Automatic Control >",fg_bg=btn_fg_bg,active_fg_bg=btn_active,dis_fg_bg=btn_disable,callback=function()end}.disable() + PushButton{parent=use,text="Waste Control >",fg_bg=btn_fg_bg,active_fg_bg=btn_active,dis_fg_bg=btn_disable,callback=function()end}.disable() + + TextBox{parent=uis,y=1,text="Operator UIs",height=1,alignment=ALIGN.CENTER} + PushButton{parent=uis,x=2,y=1,text="<",fg_bg=btn_fg_bg,active_fg_bg=btn_active,callback=main_page.nav_to} + + local annunc_page = app.new_page(uis_page, #panes + 1) + local annunc_div = Div{parent=page_div,x=2} + table.insert(panes, annunc_div) + + local alarms_page = guide_section(sect_construct_data, uis_page, "Alarms", docs.alarms, 100) + + PushButton{parent=uis,y=3,text="Alarms >",fg_bg=btn_fg_bg,active_fg_bg=btn_active,callback=alarms_page.nav_to} + PushButton{parent=uis,text="Annunciators >",fg_bg=btn_fg_bg,active_fg_bg=btn_active,callback=annunc_page.nav_to} + PushButton{parent=uis,text="Pocket UI >",fg_bg=btn_fg_bg,active_fg_bg=btn_active,dis_fg_bg=btn_disable,callback=function()end}.disable() + PushButton{parent=uis,text="Coordinator UI >",fg_bg=btn_fg_bg,active_fg_bg=btn_active,dis_fg_bg=btn_disable,callback=function()end}.disable() + + TextBox{parent=annunc_div,y=1,text="Annunciators",height=1,alignment=ALIGN.CENTER} + PushButton{parent=annunc_div,x=2,y=1,text="<",fg_bg=btn_fg_bg,active_fg_bg=btn_active,callback=uis_page.nav_to} + + local unit_gen_page = guide_section(sect_construct_data, annunc_page, "Unit General", docs.annunc.unit.main_section, 170) + local unit_rps_page = guide_section(sect_construct_data, annunc_page, "Unit RPS", docs.annunc.unit.rps_section, 100) + local unit_rcs_page = guide_section(sect_construct_data, annunc_page, "Unit RCS", docs.annunc.unit.rcs_section, 170) + + PushButton{parent=annunc_div,y=3,text="Unit General >",fg_bg=btn_fg_bg,active_fg_bg=btn_active,callback=unit_gen_page.nav_to} + PushButton{parent=annunc_div,text="Unit RPS >",fg_bg=btn_fg_bg,active_fg_bg=btn_active,callback=unit_rps_page.nav_to} + PushButton{parent=annunc_div,text="Unit RCS >",fg_bg=btn_fg_bg,active_fg_bg=btn_active,callback=unit_rcs_page.nav_to} + PushButton{parent=annunc_div,text="Facility General >",fg_bg=btn_fg_bg,active_fg_bg=btn_active,dis_fg_bg=btn_disable,callback=function()end}.disable() + PushButton{parent=annunc_div,text="Waste & Valves >",fg_bg=btn_fg_bg,active_fg_bg=btn_active,dis_fg_bg=btn_disable,callback=function()end}.disable() + + TextBox{parent=fps,y=1,text="Front Panels",height=1,alignment=ALIGN.CENTER} + PushButton{parent=fps,x=2,y=1,text="<",fg_bg=btn_fg_bg,active_fg_bg=btn_active,callback=main_page.nav_to} + + PushButton{parent=fps,y=3,text="Common Items >",fg_bg=btn_fg_bg,active_fg_bg=btn_active,dis_fg_bg=btn_disable,callback=function()end}.disable() + PushButton{parent=fps,text="Reactor PLC >",fg_bg=btn_fg_bg,active_fg_bg=btn_active,dis_fg_bg=btn_disable,callback=function()end}.disable() + PushButton{parent=fps,text="RTU Gateway >",fg_bg=btn_fg_bg,active_fg_bg=btn_active,dis_fg_bg=btn_disable,callback=function()end}.disable() + PushButton{parent=fps,text="Supervisor >",fg_bg=btn_fg_bg,active_fg_bg=btn_active,dis_fg_bg=btn_disable,callback=function()end}.disable() + PushButton{parent=fps,text="Coordinator >",fg_bg=btn_fg_bg,active_fg_bg=btn_active,dis_fg_bg=btn_disable,callback=function()end}.disable() + + TextBox{parent=gls,y=1,text="Glossary",height=1,alignment=ALIGN.CENTER} + PushButton{parent=gls,x=3,y=1,text="<",fg_bg=btn_fg_bg,active_fg_bg=btn_active,callback=main_page.nav_to} + + local gls_abbv_page = guide_section(sect_construct_data, gls_page, "Abbreviations", docs.glossary.abbvs, 120) + local gls_term_page = guide_section(sect_construct_data, gls_page, "Terminology", docs.glossary.terms, 100) + + PushButton{parent=gls,y=3,text="Abbreviations >",fg_bg=btn_fg_bg,active_fg_bg=btn_active,callback=gls_abbv_page.nav_to} + PushButton{parent=gls,text="Terminology >",fg_bg=btn_fg_bg,active_fg_bg=btn_active,callback=gls_term_page.nav_to} + + -- setup multipane + local u_pane = MultiPane{parent=page_div,x=1,y=1,panes=panes} + app.set_root_pane(u_pane) + + -- link help resources + db.nav.link_help(doc_map) + + -- done, show the app + load_pane.set_value(2) + end + + -- delete the elements and switch back to the loading screen + local function unload() + if page_div then + page_div.delete() + page_div = nil + end + + app.set_sidebar({ { label = " # ", tall = true, color = core.cpair(colors.black, colors.green), callback = function () db.nav.open_app(APP_ID.ROOT) end } }) + app.delete_pages() + + -- show loading screen + load_pane.set_value(1) + end + + app.set_load(load) + app.set_unload(unload) + + return main +end + +return new_view diff --git a/pocket/ui/apps/loader.lua b/pocket/ui/apps/loader.lua new file mode 100644 index 0000000..8ea72f0 --- /dev/null +++ b/pocket/ui/apps/loader.lua @@ -0,0 +1,49 @@ +-- +-- Loading Screen App +-- + +local iocontrol = require("pocket.iocontrol") +local pocket = require("pocket.pocket") + +local conn_waiting = require("pocket.ui.components.conn_waiting") + +local core = require("graphics.core") + +local Div = require("graphics.elements.div") +local MultiPane = require("graphics.elements.multipane") +local TextBox = require("graphics.elements.textbox") + +local APP_ID = pocket.APP_ID + +local LINK_STATE = iocontrol.LINK_STATE + +-- create the connecting to SV & API page +---@param root graphics_element parent +local function create_pages(root) + local db = iocontrol.get_db() + + local main = Div{parent=root,x=1,y=1} + + db.nav.register_app(APP_ID.LOADER, main).new_page(nil, function () end) + + local conn_sv_wait = conn_waiting(main, 6, false) + local conn_api_wait = conn_waiting(main, 6, true) + local main_pane = Div{parent=main,x=1,y=2} + + local root_pane = MultiPane{parent=main,x=1,y=1,panes={conn_sv_wait,conn_api_wait,main_pane}} + + root_pane.register(db.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 == LINK_STATE.SV_LINK_ONLY then + root_pane.set_value(2) + else + root_pane.set_value(3) + db.nav.on_loader_connected() + end + end) + + TextBox{parent=main_pane,text="Connected!",x=1,y=6,alignment=core.ALIGN.CENTER} +end + +return create_pages diff --git a/pocket/ui/apps/sys_apps.lua b/pocket/ui/apps/sys_apps.lua index a48f85f..85f8ee0 100644 --- a/pocket/ui/apps/sys_apps.lua +++ b/pocket/ui/apps/sys_apps.lua @@ -3,10 +3,12 @@ -- local comms = require("scada-common.comms") -local lockbox = require("lockbox") local util = require("scada-common.util") +local lockbox = require("lockbox") + local iocontrol = require("pocket.iocontrol") +local pocket = require("pocket.pocket") local core = require("graphics.core") @@ -17,9 +19,10 @@ local TextBox = require("graphics.elements.textbox") local PushButton = require("graphics.elements.controls.push_button") +local ALIGN = core.ALIGN local cpair = core.cpair -local ALIGN = core.ALIGN +local APP_ID = pocket.APP_ID -- create system app pages ---@param root graphics_element parent @@ -32,11 +35,12 @@ local function create_pages(root) local about_root = Div{parent=root,x=1,y=1} - local about_app = db.nav.register_app(iocontrol.APP_ID.ABOUT, about_root) + local about_app = db.nav.register_app(APP_ID.ABOUT, about_root) local about_page = about_app.new_page(nil, 1) - local fw_page = about_app.new_page(about_page, 2) - local hw_page = about_app.new_page(about_page, 3) + local nt_page = about_app.new_page(about_page, 2) + local fw_page = about_app.new_page(about_page, 3) + local hw_page = about_app.new_page(about_page, 4) local about = Div{parent=about_root,x=1,y=2} @@ -46,8 +50,42 @@ local function create_pages(root) local btn_active = cpair(colors.white, colors.black) local label = cpair(colors.lightGray, colors.black) - PushButton{parent=about,x=2,y=3,text="Firmware >",fg_bg=btn_fg_bg,active_fg_bg=btn_active,callback=fw_page.nav_to} - PushButton{parent=about,x=2,y=4,text="Host Details >",fg_bg=btn_fg_bg,active_fg_bg=btn_active,callback=hw_page.nav_to} + PushButton{parent=about,x=2,y=3,text="Network >",fg_bg=btn_fg_bg,active_fg_bg=btn_active,callback=nt_page.nav_to} + PushButton{parent=about,x=2,y=4,text="Firmware >",fg_bg=btn_fg_bg,active_fg_bg=btn_active,callback=fw_page.nav_to} + PushButton{parent=about,x=2,y=5,text="Host Details >",fg_bg=btn_fg_bg,active_fg_bg=btn_active,callback=hw_page.nav_to} + + --#region Network Details + + local config = pocket.config + + local nt_div = Div{parent=about_root,x=1,y=2} + TextBox{parent=nt_div,y=1,text="Network Details",height=1,alignment=ALIGN.CENTER} + + PushButton{parent=nt_div,x=2,y=1,text="<",fg_bg=btn_fg_bg,active_fg_bg=btn_active,callback=about_page.nav_to} + + TextBox{parent=nt_div,x=2,y=3,text="Pocket Address",height=1,alignment=ALIGN.LEFT,fg_bg=label} +---@diagnostic disable-next-line: undefined-field + TextBox{parent=nt_div,x=2,text=util.c(os.getComputerID(),":",config.PKT_Channel),height=1,alignment=ALIGN.LEFT} + + nt_div.line_break() + TextBox{parent=nt_div,x=2,text="Supervisor Address",height=1,alignment=ALIGN.LEFT,fg_bg=label} + local sv = TextBox{parent=nt_div,x=2,text="",height=1,alignment=ALIGN.LEFT} + + nt_div.line_break() + TextBox{parent=nt_div,x=2,text="Coordinator Address",height=1,alignment=ALIGN.LEFT,fg_bg=label} + local coord = TextBox{parent=nt_div,x=2,text="",height=1,alignment=ALIGN.LEFT} + + sv.register(db.ps, "sv_addr", function (addr) sv.set_value(util.c(addr, ":", config.SVR_Channel)) end) + coord.register(db.ps, "api_addr", function (addr) coord.set_value(util.c(addr, ":", config.CRD_Channel)) end) + + nt_div.line_break() + TextBox{parent=nt_div,x=2,text="Message Authentication",height=1,alignment=ALIGN.LEFT,fg_bg=label} + local auth = util.trinary(type(config.AuthKey) == "string" and string.len(config.AuthKey) > 0, "HMAC-MD5", "None") + TextBox{parent=nt_div,x=2,text=auth,height=1,alignment=ALIGN.LEFT} + + --#endregion + + --#region Firmware Versions local fw_div = Div{parent=about_root,x=1,y=2} TextBox{parent=fw_div,y=1,text="Firmware Versions",height=1,alignment=ALIGN.CENTER} @@ -81,6 +119,10 @@ local function create_pages(root) TextBox{parent=fw_list,x=2,text="Lockbox Version",height=1,alignment=ALIGN.LEFT,fg_bg=label} TextBox{parent=fw_list,x=2,text=lockbox.version,height=1,alignment=ALIGN.LEFT} + --#endregion + + --#region Host Versions + local hw_div = Div{parent=about_root,x=1,y=2} TextBox{parent=hw_div,y=1,text="Host Versions",height=1,alignment=ALIGN.CENTER} @@ -94,7 +136,9 @@ local function create_pages(root) TextBox{parent=hw_div,x=2,text="Environment",height=1,alignment=ALIGN.LEFT,fg_bg=label} TextBox{parent=hw_div,x=2,text=_HOST,height=6,alignment=ALIGN.LEFT} - local root_pane = MultiPane{parent=about_root,x=1,y=1,panes={about,fw_div,hw_div}} + --#endregion + + local root_pane = MultiPane{parent=about_root,x=1,y=1,panes={about,nt_div,fw_div,hw_div}} about_app.set_root_pane(root_pane) end diff --git a/pocket/ui/apps/unit.lua b/pocket/ui/apps/unit.lua new file mode 100644 index 0000000..1481a2b --- /dev/null +++ b/pocket/ui/apps/unit.lua @@ -0,0 +1,399 @@ +-- +-- Unit Overview Page +-- + +local util = require("scada-common.util") + +local iocontrol = require("pocket.iocontrol") +local pocket = require("pocket.pocket") + +local style = require("pocket.ui.style") + +local boiler = require("pocket.ui.pages.unit_boiler") +local reactor = require("pocket.ui.pages.unit_reactor") +local turbine = require("pocket.ui.pages.unit_turbine") + +local core = require("graphics.core") + +local Div = require("graphics.elements.div") +local ListBox = require("graphics.elements.listbox") +local MultiPane = require("graphics.elements.multipane") +local TextBox = require("graphics.elements.textbox") + +local WaitingAnim = require("graphics.elements.animations.waiting") + +local PushButton = require("graphics.elements.controls.push_button") + +local DataIndicator = require("graphics.elements.indicators.data") +local IconIndicator = require("graphics.elements.indicators.icon") + +local ALIGN = core.ALIGN +local cpair = core.cpair + +local APP_ID = pocket.APP_ID + +-- local label = style.label +local lu_col = style.label_unit_pair +local text_fg = style.text_fg +local basic_states = style.icon_states.basic_states +local mode_states = style.icon_states.mode_states +local red_ind_s = style.icon_states.red_ind_s +local yel_ind_s = style.icon_states.yel_ind_s + +local emc_ind_s = { + { color = cpair(colors.black, colors.gray), symbol = "-" }, + { color = cpair(colors.black, colors.white), symbol = "\x07" }, + { color = cpair(colors.black, colors.green), symbol = "+" } +} + +-- new unit page view +---@param root graphics_element parent +local function new_view(root) + local db = iocontrol.get_db() + + local frame = Div{parent=root,x=1,y=1} + + local app = db.nav.register_app(APP_ID.UNITS, frame, nil, false, true) + + local load_div = Div{parent=frame,x=1,y=1} + local main = Div{parent=frame,x=1,y=1} + + TextBox{parent=load_div,y=12,text="Loading...",height=1,alignment=ALIGN.CENTER} + WaitingAnim{parent=load_div,x=math.floor(main.get_width()/2)-1,y=8,fg_bg=cpair(colors.yellow,colors._INHERIT)} + + local load_pane = MultiPane{parent=main,x=1,y=1,panes={load_div,main}} + + app.set_sidebar({ { label = " # ", tall = true, color = core.cpair(colors.black, colors.green), callback = function () db.nav.open_app(APP_ID.ROOT) end } }) + + local btn_fg_bg = cpair(colors.yellow, colors.black) + local btn_active = cpair(colors.white, colors.black) + + local nav_links = {} + local page_div = nil ---@type nil|graphics_element + + -- set sidebar to display unit-specific fields based on a specified unit + local function set_sidebar(id) + local unit = db.units[id] ---@type pioctl_unit + + local list = { + { label = " # ", tall = true, color = core.cpair(colors.black, colors.green), callback = function () db.nav.open_app(APP_ID.ROOT) end }, + { label = "U-" .. id, color = core.cpair(colors.black, colors.yellow), callback = function () app.switcher(id) end }, + { label = " \x13 ", color = core.cpair(colors.black, colors.red), callback = nav_links[id].alarm }, + { label = "RPS", tall = true, color = core.cpair(colors.black, colors.cyan), callback = nav_links[id].rps }, + { label = " R ", color = core.cpair(colors.black, colors.lightGray), callback = nav_links[id].reactor }, + { label = "RCS", tall = true, color = core.cpair(colors.black, colors.blue), callback = nav_links[id].rcs }, + } + + for i = 1, unit.num_boilers do + table.insert(list, { label = "B-" .. i, color = core.cpair(colors.black, colors.lightGray), callback = nav_links[id].boiler[i] }) + end + + for i = 1, unit.num_turbines do + table.insert(list, { label = "T-" .. i, color = core.cpair(colors.black, colors.lightGray), callback = nav_links[id].turbine[i] }) + end + + app.set_sidebar(list) + end + + -- load the app (create the elements) + local function load() + page_div = Div{parent=main,y=2,width=main.get_width()} + + local panes = {} + + local active_unit = 1 + + -- create all page divs + for _ = 1, db.facility.num_units do + local div = Div{parent=page_div} + table.insert(panes, div) + table.insert(nav_links, {}) + end + + -- previous unit + local function prev(x) + active_unit = util.trinary(x == 1, db.facility.num_units, x - 1) + app.switcher(active_unit) + set_sidebar(active_unit) + end + + -- next unit + local function next(x) + active_unit = util.trinary(x == db.facility.num_units, 1, x + 1) + app.switcher(active_unit) + set_sidebar(active_unit) + end + + for i = 1, db.facility.num_units do + local u_pane = panes[i] + local u_div = Div{parent=u_pane,x=2,width=main.get_width()-2} + local unit = db.units[i] ---@type pioctl_unit + local u_ps = unit.unit_ps + + -- refresh data callback, every 500ms it will re-send the query + local last_update = 0 + local function update() + if util.time_ms() - last_update >= 500 then + db.api.get_unit(i) + last_update = util.time_ms() + end + end + + --#region Main Unit Overview + + local u_page = app.new_page(nil, i) + u_page.tasks = { update } + + TextBox{parent=u_div,y=1,text="Reactor Unit #"..i,height=1,alignment=ALIGN.CENTER} + PushButton{parent=u_div,x=1,y=1,text="<",fg_bg=btn_fg_bg,active_fg_bg=btn_active,callback=function()prev(i)end} + PushButton{parent=u_div,x=21,y=1,text=">",fg_bg=btn_fg_bg,active_fg_bg=btn_active,callback=function()next(i)end} + + local type = util.trinary(unit.num_boilers > 0, "Sodium Cooled Reactor", "Boiling Water Reactor") + TextBox{parent=u_div,y=3,text=type,height=1,alignment=ALIGN.CENTER,fg_bg=cpair(colors.gray,colors.black)} + + local rate = DataIndicator{parent=u_div,y=5,lu_colors=lu_col,label="Burn",unit="mB/t",format="%10.2f",value=0,commas=true,width=26,fg_bg=text_fg} + local temp = DataIndicator{parent=u_div,lu_colors=lu_col,label="Temp",unit=db.temp_label,format="%10.2f",value=0,commas=true,width=26,fg_bg=text_fg} + + local ctrl = IconIndicator{parent=u_div,x=1,y=8,label="Control State",states=mode_states} + + rate.register(u_ps, "act_burn_rate", rate.update) + temp.register(u_ps, "temp", function (t) temp.update(db.temp_convert(t)) end) + ctrl.register(u_ps, "U_ControlStatus", ctrl.update) + + u_div.line_break() + + local rct = IconIndicator{parent=u_div,x=1,label="Fission Reactor",states=basic_states} + local rps = IconIndicator{parent=u_div,x=1,label="Protection System",states=basic_states} + + rct.register(u_ps, "U_ReactorStatus", rct.update) + rps.register(u_ps, "U_RPS", rps.update) + + u_div.line_break() + + local rcs = IconIndicator{parent=u_div,x=1,label="Coolant System",states=basic_states} + rcs.register(u_ps, "U_RCS", rcs.update) + + for b = 1, unit.num_boilers do + local blr = IconIndicator{parent=u_div,x=1,label="Boiler "..b,states=basic_states} + blr.register(unit.boiler_ps_tbl[b], "BoilerStatus", blr.update) + end + + for t = 1, unit.num_turbines do + local tbn = IconIndicator{parent=u_div,x=1,label="Turbine "..t,states=basic_states} + tbn.register(unit.turbine_ps_tbl[t], "TurbineStatus", tbn.update) + end + + --#endregion + + util.nop() + + --#region Alarms Tab + + local alm_div = Div{parent=page_div} + table.insert(panes, alm_div) + + local alm_page = app.new_page(u_page, #panes) + alm_page.tasks = { update } + nav_links[i].alarm = alm_page.nav_to + + TextBox{parent=alm_div,y=1,text="Status Info Display",height=1,alignment=ALIGN.CENTER} + + local ecam_disp = ListBox{parent=alm_div,x=2,y=3,scroll_height=100,nav_fg_bg=cpair(colors.lightGray,colors.gray),nav_active=cpair(colors.white,colors.gray)} + + ecam_disp.register(u_ps, "U_ECAM", function (data) + local ecam = textutils.unserialize(data) + + ecam_disp.remove_all() + for _, entry in ipairs(ecam) do + local div = Div{parent=ecam_disp,height=1+#entry.items,fg_bg=cpair(entry.color,colors.black)} + local text = TextBox{parent=div,height=1,text=entry.text} + + if entry.help then + PushButton{parent=div,x=21,y=text.get_y(),text="?",callback=function()db.nav.open_help(entry.help)end,fg_bg=cpair(colors.gray,colors.black)} + end + + for _, item in ipairs(entry.items) do + local fg_bg = nil + if item.color then fg_bg = cpair(item.color, colors.black) end + + text = TextBox{parent=div,x=3,height=1,text=item.text,fg_bg=fg_bg} + + if item.help then + PushButton{parent=div,x=21,y=text.get_y(),text="?",callback=function()db.nav.open_help(item.help)end,fg_bg=cpair(colors.gray,colors.black)} + end + end + + ecam_disp.line_break() + end + end) + + --#endregion + + --#region RPS Tab + + local rps_pane = Div{parent=page_div} + local rps_div = Div{parent=rps_pane,x=2,width=main.get_width()-2} + table.insert(panes, rps_div) + + local rps_page = app.new_page(u_page, #panes) + rps_page.tasks = { update } + nav_links[i].rps = rps_page.nav_to + + TextBox{parent=rps_div,y=1,text="Protection System",height=1,alignment=ALIGN.CENTER} + + local r_trip = IconIndicator{parent=rps_div,y=3,label="RPS Trip",states=basic_states} + r_trip.register(u_ps, "U_RPS", r_trip.update) + + local r_mscrm = IconIndicator{parent=rps_div,y=5,label="Manual SCRAM",states=red_ind_s} + local r_ascrm = IconIndicator{parent=rps_div,label="Automatic SCRAM",states=red_ind_s} + local rps_tmo = IconIndicator{parent=rps_div,label="Timeout",states=yel_ind_s} + local rps_flt = IconIndicator{parent=rps_div,label="PPM Fault",states=yel_ind_s} + local rps_sfl = IconIndicator{parent=rps_div,label="Not Formed",states=red_ind_s} + + r_mscrm.register(u_ps, "manual", r_mscrm.update) + r_ascrm.register(u_ps, "automatic", r_ascrm.update) + rps_tmo.register(u_ps, "timeout", rps_tmo.update) + rps_flt.register(u_ps, "fault", rps_flt.update) + rps_sfl.register(u_ps, "sys_fail", rps_sfl.update) + + rps_div.line_break() + local rps_dmg = IconIndicator{parent=rps_div,label="Reactor Damage Hi",states=red_ind_s} + local rps_tmp = IconIndicator{parent=rps_div,label="Temp. Critical",states=red_ind_s} + local rps_nof = IconIndicator{parent=rps_div,label="Fuel Level Lo",states=yel_ind_s} + local rps_exw = IconIndicator{parent=rps_div,label="Waste Level Hi",states=yel_ind_s} + local rps_loc = IconIndicator{parent=rps_div,label="Coolant Lo Lo",states=yel_ind_s} + local rps_exh = IconIndicator{parent=rps_div,label="Heated Coolant Hi",states=yel_ind_s} + + rps_dmg.register(u_ps, "high_dmg", rps_dmg.update) + rps_tmp.register(u_ps, "high_temp", rps_tmp.update) + rps_nof.register(u_ps, "no_fuel", rps_nof.update) + rps_exw.register(u_ps, "ex_waste", rps_exw.update) + rps_loc.register(u_ps, "low_cool", rps_loc.update) + rps_exh.register(u_ps, "ex_hcool", rps_exh.update) + + --#endregion + + --#region Reactor Tab + + nav_links[i].reactor = reactor(app, u_page, panes, page_div, u_ps, update) + + --#endregion + + --#region RCS Tab + + local rcs_pane = Div{parent=page_div} + local rcs_div = Div{parent=rcs_pane,x=2,width=main.get_width()-2} + table.insert(panes, rcs_pane) + + local rcs_page = app.new_page(u_page, #panes) + rcs_page.tasks = { update } + + nav_links[i].rcs = rcs_page.nav_to + + TextBox{parent=rcs_div,y=1,text="Coolant System",height=1,alignment=ALIGN.CENTER} + + local r_rtrip = IconIndicator{parent=rcs_div,y=3,label="RCP Trip",states=red_ind_s} + local r_cflow = IconIndicator{parent=rcs_div,label="RCS Flow Lo",states=yel_ind_s} + local r_clow = IconIndicator{parent=rcs_div,label="Coolant Level Lo",states=yel_ind_s} + + r_rtrip.register(u_ps, "RCPTrip", r_rtrip.update) + r_cflow.register(u_ps, "RCSFlowLow", r_cflow.update) + r_clow.register(u_ps, "CoolantLevelLow", r_clow.update) + + local c_flt = IconIndicator{parent=rcs_div,label="RCS HW Fault",states=yel_ind_s} + local c_emg = IconIndicator{parent=rcs_div,label="Emergency Coolant",states=emc_ind_s} + local c_mwrf = IconIndicator{parent=rcs_div,label="Max Water Return",states=yel_ind_s} + + c_flt.register(u_ps, "RCSFault", c_flt.update) + c_emg.register(u_ps, "EmergencyCoolant", c_emg.update) + c_mwrf.register(u_ps, "MaxWaterReturnFeed", c_mwrf.update) + + -- rcs_div.line_break() + -- TextBox{parent=rcs_div,text="Mismatches",height=1,alignment=ALIGN.CENTER,fg_bg=label} + local c_cfm = IconIndicator{parent=rcs_div,label="Coolant Feed",states=yel_ind_s} + local c_brm = IconIndicator{parent=rcs_div,label="Boil Rate",states=yel_ind_s} + local c_sfm = IconIndicator{parent=rcs_div,label="Steam Feed",states=yel_ind_s} + + c_cfm.register(u_ps, "CoolantFeedMismatch", c_cfm.update) + c_brm.register(u_ps, "BoilRateMismatch", c_brm.update) + c_sfm.register(u_ps, "SteamFeedMismatch", c_sfm.update) + + rcs_div.line_break() + -- TextBox{parent=rcs_div,text="Aggregate Checks",height=1,alignment=ALIGN.CENTER,fg_bg=label} + + if unit.num_boilers > 0 then + local wll = IconIndicator{parent=rcs_div,label="Boiler Water Lo",states=red_ind_s} + local hrl = IconIndicator{parent=rcs_div,label="Heating Rate Lo",states=yel_ind_s} + + wll.register(u_ps, "U_WaterLevelLow", wll.update) + hrl.register(u_ps, "U_HeatingRateLow", hrl.update) + end + + local tospd = IconIndicator{parent=rcs_div,label="TRB Over Speed",states=red_ind_s} + local gtrip = IconIndicator{parent=rcs_div,label="Generator Trip",states=yel_ind_s} + local ttrip = IconIndicator{parent=rcs_div,label="Turbine Trip",states=red_ind_s} + + tospd.register(u_ps, "U_TurbineOverSpeed", tospd.update) + gtrip.register(u_ps, "U_GeneratorTrip", gtrip.update) + ttrip.register(u_ps, "U_TurbineTrip", ttrip.update) + + --#endregion + + --#region Boiler Tabs + + local blr_pane = Div{parent=page_div} + nav_links[i].boiler = {} + + for b_id = 1, unit.num_boilers do + local ps = unit.boiler_ps_tbl[b_id] + nav_links[i].boiler[b_id] = boiler(app, u_page, panes, blr_pane, b_id, ps, update) + end + + --#endregion + + --#region Turbine Tabs + + local tbn_pane = Div{parent=page_div} + nav_links[i].turbine = {} + + for t_id = 1, unit.num_turbines do + local ps = unit.turbine_ps_tbl[t_id] + nav_links[i].turbine[t_id] = turbine(app, u_page, panes, tbn_pane, i, t_id, ps, update) + end + + --#endregion + + util.nop() + end + + -- setup multipane + local u_pane = MultiPane{parent=page_div,x=1,y=1,panes=panes} + app.set_root_pane(u_pane) + + set_sidebar(active_unit) + + -- done, show the app + load_pane.set_value(2) + end + + -- delete the elements and switch back to the loading screen + local function unload() + if page_div then + page_div.delete() + page_div = nil + end + + app.set_sidebar({ { label = " # ", tall = true, color = core.cpair(colors.black, colors.green), callback = function () db.nav.open_app(APP_ID.ROOT) end } }) + app.delete_pages() + + -- show loading screen + load_pane.set_value(1) + end + + app.set_load(load) + app.set_unload(unload) + + return main +end + +return new_view diff --git a/pocket/ui/components/conn_waiting.lua b/pocket/ui/components/conn_waiting.lua index 9cbe1b3..6b69650 100644 --- a/pocket/ui/components/conn_waiting.lua +++ b/pocket/ui/components/conn_waiting.lua @@ -23,16 +23,16 @@ local function init(parent, y, is_api) local root = Div{parent=parent,x=1,y=1} -- bounding box div - local box = Div{parent=root,x=1,y=y,height=5} + local box = Div{parent=root,x=1,y=y,height=6} local waiting_x = math.floor(parent.get_width() / 2) - 1 if is_api then WaitingAnim{parent=box,x=waiting_x,y=1,fg_bg=cpair(colors.blue,style.root.bkg)} - TextBox{parent=box,text="Connecting to API",alignment=ALIGN.CENTER,y=5,height=1,fg_bg=cpair(colors.white,style.root.bkg)} + TextBox{parent=box,text="Connecting to API",alignment=ALIGN.CENTER,y=5,fg_bg=cpair(colors.white,style.root.bkg)} else WaitingAnim{parent=box,x=waiting_x,y=1,fg_bg=cpair(colors.green,style.root.bkg)} - TextBox{parent=box,text="Connecting to Supervisor",alignment=ALIGN.CENTER,y=5,height=1,fg_bg=cpair(colors.white,style.root.bkg)} + TextBox{parent=box,text="Connecting to Supervisor",alignment=ALIGN.CENTER,y=5,fg_bg=cpair(colors.white,style.root.bkg)} end return root diff --git a/pocket/ui/docs.lua b/pocket/ui/docs.lua new file mode 100644 index 0000000..40507a1 --- /dev/null +++ b/pocket/ui/docs.lua @@ -0,0 +1,113 @@ +local docs = {} + +local target + +local function doc(key, name, desc) + ---@class pocket_doc_item + local item = { key = key, name = name, desc = desc } + table.insert(target, item) +end + +-- important to note in the future: The PLC should always be in a chunk with the reactor to ensure it can protect it on chunk load if you do not keep it all chunk loaded + +docs.alarms = {} + +target = docs.alarms +doc("ContainmentBreach", "Containment Breach", "Reactor disconnected or indicated unformed while being at or above 100% damage; explosion assumed.") +doc("ContainmentRadiation", "Containment Radiation", "Environment detector(s) assigned to the unit have observed high levels of radiation.") +doc("ReactorLost", "Reactor Lost", "Reactor PLC has stopped communicating with the supervisor.") +doc("CriticalDamage", "Damage Critical", "Reactor damage has reached or exceeded 100%, so it will explode at any moment.") +doc("ReactorDamage", "Reactor Damage", "Reactor temperature causing increasing damage to the reactor casing.") +doc("ReactorOverTemp", "Reactor Over Temp", "Reactor temperature is at or above maximum safe temperature, so it is now taking damage.") +doc("ReactorHighTemp", "Reactor High Temp", "Reactor temperature is above expected operating levels and may exceed maximum safe temperature soon.") +doc("ReactorWasteLeak", "Reactor Waste Leak", "The reactor is full of spent waste so it will now emit radiation if additional waste is generated.") +doc("ReactorHighWaste", "Reactor High Waste", "Reactor waste levels are high and may leak soon.") +doc("RPSTransient", "RPS Transient", "Reactor protection system was activated.") +doc("RCSTransient", "RCS Transient", "Something is wrong with the reactor coolant system, check RCS indicators for details.") +doc("TurbineTripAlarm", "Turbine Trip", "A turbine stopped rotating, likely due to having full energy storage. This will prevent cooling, so it needs to be resolved before using that unit.") + +docs.annunc = { + unit = { + main_section = {}, rps_section = {}, rcs_section = {} + } +} + +target = docs.annunc.unit.main_section +doc("PLCOnline", "PLC Online", "Indicates if the fission reactor PLC is connected. If it isn't, check that your PLC is on and configured properly.") +doc("PLCHeartbeat", "PLC Heartbeat", "An indicator of status data being live. As status messages are received from the PLC, this light will turn on and off. If it gets stuck, the supervisor has stopped receiving data or a screen has frozen.") +doc("RadiationMonitor", "Radiation Monitor", "On if at least one environment detector is connected and assigned to this unit.") +doc("AutoControl", "Automatic Control", "On if the reactor is under the control of one of the automatic control modes.") +doc("ReactorSCRAM", "Reactor SCRAM", "On if the reactor protection system is holding the reactor SCRAM'd.") +doc("ManualReactorSCRAM", "Manual Reactor SCRAM", "On if the operator (you) initiated a SCRAM.") +doc("AutoReactorSCRAM", "Auto Reactor SCRAM", "On if the automatic control system initiated a SCRAM. The main view screen annunciator will have an indication as to why.") +doc("RadiationWarning", "Radiation Warning", "On if radiation levels are above normal. There is likely a leak somewhere, so that should be identified and fixed. Hazmat suit recommended.") +doc("RCPTrip", "RCP Trip", "Reactor coolant pump tripped. This is a technical concept not directly mapping to Mekansim. Here, it indicates if there is either high heated coolant or low cooled coolant that caused an RPS trip. Check the coolant system if this occurs.") +doc("RCSFlowLow", "RCS Flow Low", "Indicates if the reactor coolant system flow is low. This is observed when the cooled coolant level in the reactor is dropping. This can occur while a turbine spins up, but if it persists, check that the cooling system is operating properly. This can occur with smaller boilers or when using pipes and not having enough.") +doc("CoolantLevelLow", "Coolant Level Low", "On if the reactor coolant level is lower than it should be. Check the coolant system.") +doc("ReactorTempHigh", "Reactor Temp. High", "On if the reactor temperature is above expected maximum operating temperature. This is not yet damaging, but should be attended to. Check coolant system.") +doc("ReactorHighDeltaT", "Reactor High Delta T", "On if the reactor temperature is climbing rapidly. This can occur when a reactor is starting up, but it is a concern if it happens while the burn rate is not increasing.") +doc("FuelInputRateLow", "Fuel Input Rate Low", "On if the fissile fuel levels in the reactor are dropping or very low. Ensure a steady supply of fuel is entering the reactor.") +doc("WasteLineOcclusion", "Waste Line Occlusion", "Waste levels in the reactor are increasing. Ensure your waste processing system is operating at a sufficient rate for your burn rate.") +doc("HighStartupRate", "Startup Rate High", "This is a rough calculation of if your burn rate is high enough to cause a loss of coolant on startup. A burn rate above this is likely to cause that, but it could occur at even higher or even lower rates depending on your setup (such as pipes, water supplies, and boiler tanks).") + +target = docs.annunc.unit.rps_section +doc("rps_tripped", "RPS Trip", "Indicates if the reactor protection system has caused a SCRAM.") +doc("manual", "Manual Reactor SCRAM", "Indicates if the operator (you) tripped the RPS by pressing SCRAM.") +doc("automatic", "Auto Reactor SCRAM", "Indicates if the automatic control system tripped the RPS.") +doc("high_dmg", "Damage Level High", "Indicates if the RPS tripped due to significant reactor damage. Await damage levels to lower.") +doc("ex_waste", "Excess Waste", "Indicates if the RPS tripped due to very high waste levels. Ensure waste processing system is keeping up.") +doc("ex_hcool", "Excess Heated Coolant", "Indicates if the RPS tripped due to very high heated coolant levels. Check that the cooling system is able to keep up with heated coolant flow.") +doc("high_temp", "Temperature High", "Indicates if the RPS tripped due to reaching damaging temperatures. Await damage levels to lower.") +doc("low_cool", "Coolant Level Low Low", "Indicates if the RPS tripped due to very low coolant levels that result in the temperature uncontrollably rising. Ensure that the cooling system can provide sufficient cooled coolant flow.") +doc("no_fuel", "No Fuel", "Indicates if the RPS tripped due to no fuel being available. Check fuel input.") +doc("fault", "PPM Fault", "Indicates if the RPS tripped due to a peripheral access fault. Something went wrong interfacing with the reactor, try restarting the PLC.") +doc("timeout", "Connection Timeout", "Indicates if the RPS tripped due to losing connection with the supervisory computer. Check that your PLC and supervisor remain chunk loaded.") +doc("sys_fail", "System Failure", "Indicates if the RPS tripped due to the reactor not being formed. Ensure that the multi-block is formed.") + +target = docs.annunc.unit.rcs_section +doc("RCSFault", "RCS Hardware Fault", "Indicates if one or more of the RCS devices have a peripheral fault. Check that your machines are formed. If this persists, try rebooting affected RTUs.") +doc("EmergencyCoolant", "Emergency Coolant", "Off if no emergency coolant redstone is configured, white when it is configured but not in use, and green/blue when it is activated. This is based on an RTU having a redstone emergency coolant output configured for this unit.") +doc("CoolantFeedMismatch", "Coolant Feed Mismatch", "The coolant system is accumulating heated coolant or losing cooled coolant, likely due to one of the machines not keeping up with the needs of the reactor. The flow monitor can help figure out where the problem is.") +doc("BoilRateMismatch", "Boil Rate Mismatch", "The total heating rate of the reactor exceed the tolerance from the steam input rate of the turbines OR for sodium setups, the boiler boil rates exceed the tolerance from the steam input rate of the turbines. The flow monitor can help figure out where the problem is.") +doc("SteamFeedMismatch", "Steam Feed Mismatch", "There is an above tolerance difference between turbine flow and steam input rates or the reactor/boilers are gaining steam or losing water. The flow monitor can help figure out where the problem is.") +doc("MaxWaterReturnFeed", "Max Water Return Feed", "The turbines are condensing the max rate of water that they can per the structure build. If water return is insufficient, add more saturating condensers to your turbine(s).") +doc("WaterLevelLow", "Water Level Low", "The water level in the boiler is low. A larger boiler water tank may help, or you can feed additional water into the boiler from elsewhere.") +doc("HeatingRateLow", "Heating Rate Low", "The boiler is not hot enough to boil water, but it is receiving heated coolant. This is almost never a safety concern.") +doc("SteamDumpOpen", "Steam Relief Valve Open", "This turns yellow if the turbine is set to dumping excess and red if it is set to dumping [all]. 'Relief Valve' in this case is that setting allowing the venting of steam. You should never have this set to dumping [all]. Emergency coolant activation from the supervisor will automatically set it to dumping excess to ensure there is no backup of steam as water is added.") +doc("TurbineOverSpeed", "Turbine Over Speed", "The turbine is at steam capacity, but not tripped. You may need more turbines if they can't keep up.") +doc("GeneratorTrip", "Generator Trip", "The turbine is no longer outputting power due to it having nowhere to go. Likely due to full power storage. This will lead to a Turbine Trip if not addressed.") +doc("TurbineTrip", "Turbine Trip", "The turbine has reached its maximum power charge and has stopped rotating, and as a result stopped cooling steam to water. Ensure the turbine has somewhere to output power, as this is the most common cause of reactor meltdowns. However, the likelihood of a meltdown with this system in place is much lower, especially with emergency coolant helping during turbine trips.") + +docs.glossary = { + abbvs = {}, terms = {} +} + +target = docs.glossary.abbvs +doc("G_ACK", "ACK", "Alarm ACKnowledge. Pressing this acknowledges that you understand an alarm occurred and would like to stop the audio tone(s).") +doc("G_CRD", "CRD", "Coordinator. Abbreviation for the coordinator computer.") +doc("G_DBG", "DBG", "Debug. Abbreviation for the debugging sessions from pocket computers found on the supervisor's front panel.") +doc("G_FP", "FP", "Front Panel. See Terminology.") +doc("G_PKT", "PKT", "Pocket. Abbreviation for the pocket computer.") +doc("G_PLC", "PLC", "Programmable Logic Controller. A device that not only reports data and controls outputs, but can also make decisions on its own.") +doc("G_PPM", "PPM", "Protected Peripheral Manager. This is an abstraction layer created for this project that prevents peripheral calls from crashing applications.") +doc("G_RCP", "RCP", "Reactor Coolant Pump. This is from real-world terminology with water-cooled (boiling water and pressurized water) reactors, but in this system it just reflects to the functioning of reactor coolant flow. See the annunciator page on it for more information.") +doc("G_RCS", "RCS", "Reactor Cooling System. The combination of all machines used to cool the reactor (turbines, boilers, dynamic tanks).") +doc("G_RPS", "RPS", "Reactor Protection System. A component of the reactor PLC responsible for keeping the reactor safe.") +doc("G_RTU", "RTU", "Remote Terminal Unit. Provides monitoring to and basic output from a SCADA system, interfacing with various types of devices/interfaces.") +doc("G_SCADA", "SCADA", "Supervisory Control and Data Acquisition. A control systems architecture used in a wide variety process control applications.") +doc("G_SVR", "SVR", "Supervisor. Abbreviation for the supervisory computer.") +doc("G_UI", "UI", "User Interface.") + +target = docs.glossary.terms +doc("G_Fault", "Fault", "Something has gone wrong and/or failed to function.") +doc("G_FrontPanel", "Front Panel", "A basic interface on the front of a device for viewing and sometimes modifying its state. This is what you see when looking at a computer running one of the SCADA applications.") +doc("G_Nominal", "Nominal", "Normal operation. Everything operating as intended.") +doc("G_Ringback", "Ringback", "An indication that an alarm had gone off but is no longer having its trip condition(s) met. This is to make you are aware that it occurred.") +doc("G_SCRAM", "SCRAM", "[Emergency] shut-down of a reactor by stopping the fission. In Mekanism and here, it isn't always for an emergency.") +doc("G_Transient", "Transient", "A temporary change in state from normal operation. Coolant levels dropping or core temperature rising above nominal values are examples of transients.") +doc("G_Trip", "Trip", "A checked condition had occurred, see 'Tripped'.") +doc("G_Tripped", "Tripped", "An alarm condition has been met, and is still met.") +doc("G_Tripping", "Tripping", "Alarm condition(s) is/are met, but has/have not reached the minimum time before the condition(s) is/are deemed a problem.") +doc("G_TurbineTrip", "Turbine Trip", "The turbine stopped, which prevents heated coolant from being cooled. In Mekanism, this would occur when a turbine cannot generate any more energy due to filling its buffer and having no output with any remaining energy capacity.") + +return docs diff --git a/pocket/ui/main.lua b/pocket/ui/main.lua index 02e33fd..4a33327 100644 --- a/pocket/ui/main.lua +++ b/pocket/ui/main.lua @@ -2,16 +2,19 @@ -- Pocket GUI Root -- +local util = require("scada-common.util") + local iocontrol = require("pocket.iocontrol") +local pocket = require("pocket.pocket") local diag_apps = require("pocket.ui.apps.diag_apps") local dummy_app = require("pocket.ui.apps.dummy_app") +local guide_app = require("pocket.ui.apps.guide") +local loader_app = require("pocket.ui.apps.loader") local sys_apps = require("pocket.ui.apps.sys_apps") - -local conn_waiting = require("pocket.ui.components.conn_waiting") +local unit_app = require("pocket.ui.apps.unit") local home_page = require("pocket.ui.pages.home_page") -local unit_page = require("pocket.ui.pages.unit_page") local style = require("pocket.ui.style") @@ -26,72 +29,46 @@ local Sidebar = require("graphics.elements.controls.sidebar") local SignalBar = require("graphics.elements.indicators.signal") -local LINK_STATE = iocontrol.LINK_STATE - local ALIGN = core.ALIGN - local cpair = core.cpair +local APP_ID = pocket.APP_ID + -- create new main view ---@param main graphics_element main displaybox local function init(main) local db = iocontrol.get_db() - -- window header message - TextBox{parent=main,y=1,text="DEV ALPHA APP S C ",alignment=ALIGN.LEFT,height=1,fg_bg=style.header} + local main_pane = Div{parent=main,x=1,y=2} + + -- window header message and connection status + TextBox{parent=main,y=1,text="EARLY ACCESS ALPHA S C ",alignment=ALIGN.LEFT,height=1,fg_bg=style.header} local svr_conn = SignalBar{parent=main,y=1,x=22,compact=true,colors_low_med=cpair(colors.red,colors.yellow),disconnect_color=colors.lightGray,fg_bg=cpair(colors.green,colors.gray)} local crd_conn = SignalBar{parent=main,y=1,x=26,compact=true,colors_low_med=cpair(colors.red,colors.yellow),disconnect_color=colors.lightGray,fg_bg=cpair(colors.green,colors.gray)} db.ps.subscribe("svr_conn_quality", svr_conn.set_value) db.ps.subscribe("crd_conn_quality", crd_conn.set_value) - --#region root panel panes (connection screens + main screen) - - local root_pane_div = Div{parent=main,x=1,y=2} - - local conn_sv_wait = conn_waiting(root_pane_div, 6, false) - local conn_api_wait = conn_waiting(root_pane_div, 6, true) - local main_pane = Div{parent=main,x=1,y=2} - - local root_pane = MultiPane{parent=root_pane_div,x=1,y=1,panes={conn_sv_wait,conn_api_wait,main_pane}} - - root_pane.register(db.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 == LINK_STATE.SV_LINK_ONLY then - root_pane.set_value(2) - else - root_pane.set_value(3) - end - end) - - --#endregion - - --#region main page panel panes & sidebar - local page_div = Div{parent=main_pane,x=4,y=1} - local sidebar_tabs = { - { char = "#", color = cpair(colors.black, colors.green) } - } - + -- create all the apps & pages home_page(page_div) - unit_page(page_div) - - diag_apps(page_div) + unit_app(page_div) + guide_app(page_div) + loader_app(page_div) sys_apps(page_div) + diag_apps(page_div) dummy_app(page_div) - assert(#db.nav.get_containers() == iocontrol.APP_ID.NUM_APPS, "app IDs were not sequential or some apps weren't registered") + -- verify all apps were created + assert(util.table_len(db.nav.get_containers()) == APP_ID.NUM_APPS, "app IDs were not sequential or some apps weren't registered") - local page_pane = MultiPane{parent=page_div,x=1,y=1,panes=db.nav.get_containers()} - db.nav.set_pane(page_pane) - - Sidebar{parent=main_pane,x=1,y=1,tabs=sidebar_tabs,fg_bg=cpair(colors.white,colors.gray),callback=db.nav.open_app} + db.nav.set_pane(MultiPane{parent=page_div,x=1,y=1,panes=db.nav.get_containers()}) + db.nav.set_sidebar(Sidebar{parent=main_pane,x=1,y=1,height=18,fg_bg=cpair(colors.white,colors.gray)}) PushButton{parent=main_pane,x=1,y=19,text="\x1b",min_width=3,fg_bg=cpair(colors.white,colors.gray),active_fg_bg=cpair(colors.gray,colors.black),callback=db.nav.nav_up} - --#endregion + db.nav.open_app(APP_ID.ROOT) end return init diff --git a/pocket/ui/pages/guide_section.lua b/pocket/ui/pages/guide_section.lua new file mode 100644 index 0000000..e73567b --- /dev/null +++ b/pocket/ui/pages/guide_section.lua @@ -0,0 +1,68 @@ +local log = require("scada-common.log") +local util = require("scada-common.util") + +local core = require("graphics.core") + +local Div = require("graphics.elements.div") +local ListBox = require("graphics.elements.listbox") +local TextBox = require("graphics.elements.textbox") + +local PushButton = require("graphics.elements.controls.push_button") + +local ALIGN = core.ALIGN +local cpair = core.cpair + +-- new guide documentation section +---@param data _guide_section_constructor_data +---@param base_page nav_tree_page +---@param title string +---@param items table +---@param scroll_height integer +---@return nav_tree_page +return function (data, base_page, title, items, scroll_height) + local app, page_div, panes, doc_map, search_db, btn_fg_bg, btn_active = table.unpack(data) + + local section_page = app.new_page(base_page, #panes + 1) + local section_div = Div{parent=page_div,x=2} + table.insert(panes, section_div) + TextBox{parent=section_div,y=1,text=title,height=1,alignment=ALIGN.CENTER} + PushButton{parent=section_div,x=3,y=1,text="<",fg_bg=btn_fg_bg,active_fg_bg=btn_active,callback=base_page.nav_to} + + local view_page = app.new_page(section_page, #panes + 1) + local section_view_div = Div{parent=page_div,x=2} + table.insert(panes, section_view_div) + TextBox{parent=section_view_div,y=1,text=title,height=1,alignment=ALIGN.CENTER} + PushButton{parent=section_view_div,x=3,y=1,text="<",fg_bg=btn_fg_bg,active_fg_bg=btn_active,callback=section_page.nav_to} + + local name_list = ListBox{parent=section_div,x=1,y=3,scroll_height=30,nav_fg_bg=cpair(colors.lightGray,colors.gray),nav_active=cpair(colors.white,colors.gray)} + local def_list = ListBox{parent=section_view_div,x=1,y=3,scroll_height=scroll_height,nav_fg_bg=cpair(colors.lightGray,colors.gray),nav_active=cpair(colors.white,colors.gray)} + + local _end + + for i = 1, #items do + local item = items[i] ---@type pocket_doc_item + + local anchor = TextBox{parent=def_list,text=item.name,anchor=true,fg_bg=cpair(colors.blue,colors.black)} + TextBox{parent=def_list,text=item.desc} + _end = Div{parent=def_list,height=1,can_focus=true} + + local function view() + _end.focus() + view_page.nav_to() + anchor.focus() + end + + doc_map[item.key] = view + table.insert(search_db, { string.lower(item.name), item.name, title, view }) + + PushButton{parent=name_list,text=item.name,alignment=ALIGN.LEFT,fg_bg=cpair(colors.blue,colors.black),active_fg_bg=btn_active,callback=view} + + if i % 12 == 0 then util.nop() end + end + + log.debug("guide section " .. title .. " generated with final height ".. _end.get_y()) + + util.nop() + + return section_page +end diff --git a/pocket/ui/pages/home_page.lua b/pocket/ui/pages/home_page.lua index 0ea7c3f..4b7fded 100644 --- a/pocket/ui/pages/home_page.lua +++ b/pocket/ui/pages/home_page.lua @@ -3,6 +3,7 @@ -- local iocontrol = require("pocket.iocontrol") +local pocket = require("pocket.pocket") local core = require("graphics.core") @@ -12,11 +13,10 @@ local TextBox = require("graphics.elements.textbox") local App = require("graphics.elements.controls.app") +local ALIGN = core.ALIGN local cpair = core.cpair -local APP_ID = iocontrol.APP_ID - -local ALIGN = core.ALIGN +local APP_ID = pocket.APP_ID -- new home page view ---@param root graphics_element parent @@ -25,7 +25,7 @@ local function new_view(root) local main = Div{parent=root,x=1,y=1,height=19} - local app = db.nav.register_app(iocontrol.APP_ID.ROOT, main) + local app = db.nav.register_app(APP_ID.ROOT, main) local apps_1 = Div{parent=main,x=1,y=1,height=15} local apps_2 = Div{parent=main,x=1,y=1,height=15} @@ -39,21 +39,26 @@ local function new_view(root) local function open(id) db.nav.open_app(id) end + app.set_sidebar({ + { label = " #\x10", tall = true, color = core.cpair(colors.black, colors.green), callback = function () open(APP_ID.ROOT) end } + }) + local active_fg_bg = cpair(colors.white,colors.gray) - App{parent=apps_1,x=3,y=2,text="U",title="Units",callback=function()open(APP_ID.UNITS)end,app_fg_bg=cpair(colors.black,colors.yellow),active_fg_bg=active_fg_bg} - App{parent=apps_1,x=10,y=2,text="\x17",title="PRC",callback=function()open(APP_ID.DUMMY)end,app_fg_bg=cpair(colors.black,colors.purple),active_fg_bg=active_fg_bg} - App{parent=apps_1,x=17,y=2,text="\x15",title="CTL",callback=function()open(APP_ID.DUMMY)end,app_fg_bg=cpair(colors.black,colors.green),active_fg_bg=active_fg_bg} - App{parent=apps_1,x=3,y=7,text="\x08",title="DEV",callback=function()open(APP_ID.DUMMY)end,app_fg_bg=cpair(colors.black,colors.lightGray),active_fg_bg=active_fg_bg} - App{parent=apps_1,x=10,y=7,text="\x7f",title="Waste",callback=function()open(APP_ID.DUMMY)end,app_fg_bg=cpair(colors.black,colors.brown),active_fg_bg=active_fg_bg} - App{parent=apps_1,x=17,y=7,text="\xb6",title="Guide",callback=function()open(APP_ID.DUMMY)end,app_fg_bg=cpair(colors.black,colors.cyan),active_fg_bg=active_fg_bg} - App{parent=apps_1,x=3,y=12,text="?",title="About",callback=function()open(APP_ID.ABOUT)end,app_fg_bg=cpair(colors.black,colors.white),active_fg_bg=active_fg_bg} + App{parent=apps_1,x=2,y=2,text="U",title="Units",callback=function()open(APP_ID.UNITS)end,app_fg_bg=cpair(colors.black,colors.yellow),active_fg_bg=active_fg_bg} + App{parent=apps_1,x=9,y=2,text="F",title="Facil",callback=function()open(APP_ID.DUMMY)end,app_fg_bg=cpair(colors.black,colors.orange),active_fg_bg=active_fg_bg} + App{parent=apps_1,x=16,y=2,text="\x15",title="Control",callback=function()open(APP_ID.DUMMY)end,app_fg_bg=cpair(colors.black,colors.green),active_fg_bg=active_fg_bg} + App{parent=apps_1,x=2,y=7,text="\x17",title="Process",callback=function()open(APP_ID.DUMMY)end,app_fg_bg=cpair(colors.black,colors.purple),active_fg_bg=active_fg_bg} + App{parent=apps_1,x=9,y=7,text="\x7f",title="Waste",callback=function()open(APP_ID.DUMMY)end,app_fg_bg=cpair(colors.black,colors.brown),active_fg_bg=active_fg_bg} + App{parent=apps_1,x=16,y=7,text="\x08",title="Devices",callback=function()open(APP_ID.DUMMY)end,app_fg_bg=cpair(colors.black,colors.lightGray),active_fg_bg=active_fg_bg} + App{parent=apps_1,x=2,y=12,text="\xb6",title="Guide",callback=function()open(APP_ID.GUIDE)end,app_fg_bg=cpair(colors.black,colors.cyan),active_fg_bg=active_fg_bg} + App{parent=apps_1,x=9,y=12,text="?",title="About",callback=function()open(APP_ID.ABOUT)end,app_fg_bg=cpair(colors.black,colors.white),active_fg_bg=active_fg_bg} TextBox{parent=apps_2,text="Diagnostic Apps",x=1,y=2,height=1,alignment=ALIGN.CENTER} - App{parent=apps_2,x=3,y=4,text="\x0f",title="Alarm",callback=function()open(APP_ID.ALARMS)end,app_fg_bg=cpair(colors.black,colors.red),active_fg_bg=active_fg_bg} - App{parent=apps_2,x=10,y=4,text="\x1e",title="LoopT",callback=function()open(APP_ID.DUMMY)end,app_fg_bg=cpair(colors.black,colors.cyan),active_fg_bg=active_fg_bg} - App{parent=apps_2,x=17,y=4,text="@",title="Comps",callback=function()open(APP_ID.DUMMY)end,app_fg_bg=cpair(colors.black,colors.orange),active_fg_bg=active_fg_bg} + App{parent=apps_2,x=2,y=4,text="\x0f",title="Alarm",callback=function()open(APP_ID.ALARMS)end,app_fg_bg=cpair(colors.black,colors.red),active_fg_bg=active_fg_bg} + App{parent=apps_2,x=9,y=4,text="\x1e",title="LoopT",callback=function()open(APP_ID.DUMMY)end,app_fg_bg=cpair(colors.black,colors.cyan),active_fg_bg=active_fg_bg} + App{parent=apps_2,x=16,y=4,text="@",title="Comps",callback=function()open(APP_ID.DUMMY)end,app_fg_bg=cpair(colors.black,colors.orange),active_fg_bg=active_fg_bg} return main end diff --git a/pocket/ui/pages/unit_boiler.lua b/pocket/ui/pages/unit_boiler.lua new file mode 100644 index 0000000..86b963a --- /dev/null +++ b/pocket/ui/pages/unit_boiler.lua @@ -0,0 +1,131 @@ +local types = require("scada-common.types") +local util = require("scada-common.util") + +local iocontrol = require("pocket.iocontrol") + +local style = require("pocket.ui.style") + +local core = require("graphics.core") + +local Div = require("graphics.elements.div") +local TextBox = require("graphics.elements.textbox") + +local PushButton = require("graphics.elements.controls.push_button") + +local DataIndicator = require("graphics.elements.indicators.data") +local StateIndicator = require("graphics.elements.indicators.state") +local IconIndicator = require("graphics.elements.indicators.icon") +local VerticalBar = require("graphics.elements.indicators.vbar") + +local ALIGN = core.ALIGN +local cpair = core.cpair + +local label = style.label +local lu_col = style.label_unit_pair +local text_fg = style.text_fg +local red_ind_s = style.icon_states.red_ind_s +local yel_ind_s = style.icon_states.yel_ind_s + +-- create a boiler view in the unit app +---@param app pocket_app +---@param u_page nav_tree_page +---@param panes table +---@param blr_pane graphics_element +---@param b_id integer boiler ID +---@param ps psil +---@param update function +return function (app, u_page, panes, blr_pane, b_id, ps, update) + local db = iocontrol.get_db() + + local blr_div = Div{parent=blr_pane,x=2,width=blr_pane.get_width()-2} + table.insert(panes, blr_div) + + local blr_page = app.new_page(u_page, #panes) + blr_page.tasks = { update } + + TextBox{parent=blr_div,y=1,text="BLR #"..b_id,width=8,height=1} + local status = StateIndicator{parent=blr_div,x=10,y=1,states=style.boiler.states,value=1,min_width=12} + status.register(ps, "BoilerStateStatus", status.update) + + local hcool = VerticalBar{parent=blr_div,x=1,y=4,fg_bg=cpair(colors.orange,colors.gray),height=5,width=1} + local water = VerticalBar{parent=blr_div,x=3,y=4,fg_bg=cpair(colors.blue,colors.gray),height=5,width=1} + local steam = VerticalBar{parent=blr_div,x=19,y=4,fg_bg=cpair(colors.white,colors.gray),height=5,width=1} + local ccool = VerticalBar{parent=blr_div,x=21,y=4,fg_bg=cpair(colors.lightBlue,colors.gray),height=5,width=1} + + TextBox{parent=blr_div,text="H",x=1,y=3,width=1,height=1,fg_bg=label} + TextBox{parent=blr_div,text="W",x=3,y=3,width=1,height=1,fg_bg=label} + TextBox{parent=blr_div,text="S",x=19,y=3,width=1,height=1,fg_bg=label} + TextBox{parent=blr_div,text="C",x=21,y=3,width=1,height=1,fg_bg=label} + + hcool.register(ps, "hcool_fill", hcool.update) + water.register(ps, "water_fill", water.update) + steam.register(ps, "steam_fill", steam.update) + ccool.register(ps, "ccool_fill", ccool.update) + + TextBox{parent=blr_div,text="Temperature",x=5,y=5,width=13,height=1,fg_bg=label} + local t_prec = util.trinary(db.temp_label == types.TEMP_SCALE_UNITS[types.TEMP_SCALE.KELVIN], 11, 10) + local temp = DataIndicator{parent=blr_div,x=5,y=6,lu_colors=lu_col,label="",unit=db.temp_label,format="%"..t_prec..".2f",value=0,commas=true,width=13,fg_bg=text_fg} + + temp.register(ps, "temperature", function (t) temp.update(db.temp_convert(t)) end) + + local b_wll = IconIndicator{parent=blr_div,y=10,label="Water Level Lo",states=red_ind_s} + local b_hr = IconIndicator{parent=blr_div,label="Heating Rate Lo",states=yel_ind_s} + + b_wll.register(ps, "WaterLevelLow", b_wll.update) + b_hr.register(ps, "HeatingRateLow", b_hr.update) + + TextBox{parent=blr_div,text="Boil Rate",x=1,y=13,width=12,height=1,fg_bg=label} + local boil_r = DataIndicator{parent=blr_div,x=6,y=14,lu_colors=lu_col,label="",unit="mB/t",format="%11.0f",value=0,commas=true,width=16,fg_bg=text_fg} + + boil_r.register(ps, "boil_rate", boil_r.update) + + local blr_ext_div = Div{parent=blr_pane,x=2,width=blr_pane.get_width()-2} + table.insert(panes, blr_ext_div) + + local blr_ext_page = app.new_page(blr_page, #panes) + blr_ext_page.tasks = { update } + + PushButton{parent=blr_div,x=9,y=18,text="MORE",min_width=6,fg_bg=cpair(colors.lightGray,colors.gray),active_fg_bg=cpair(colors.gray,colors.lightGray),callback=blr_ext_page.nav_to} + PushButton{parent=blr_ext_div,x=9,y=18,text="BACK",min_width=6,fg_bg=cpair(colors.lightGray,colors.gray),active_fg_bg=cpair(colors.gray,colors.lightGray),callback=blr_page.nav_to} + + TextBox{parent=blr_ext_div,y=1,text="More Boiler Info",height=1,alignment=ALIGN.CENTER} + + local function update_amount(indicator) + return function (x) indicator.update(x.amount) end + end + + TextBox{parent=blr_ext_div,text="Hot Coolant",x=1,y=3,width=12,height=1,fg_bg=label} + local heated_p = DataIndicator{parent=blr_ext_div,x=14,y=3,lu_colors=lu_col,label="",unit="%",format="%6.2f",value=0,width=8,fg_bg=text_fg} + local hcool_amnt = DataIndicator{parent=blr_ext_div,x=1,y=4,lu_colors=lu_col,label="",unit="mB",format="%18.0f",value=0,commas=true,width=21,fg_bg=text_fg} + + heated_p.register(ps, "hcool_fill", function (x) heated_p.update(x * 100) end) + hcool_amnt.register(ps, "hcool", update_amount(hcool_amnt)) + + TextBox{parent=blr_ext_div,text="Water Tank",x=1,y=6,width=9,height=1,fg_bg=label} + local fuel_p = DataIndicator{parent=blr_ext_div,x=14,y=6,lu_colors=lu_col,label="",unit="%",format="%6.2f",value=0,width=8,fg_bg=text_fg} + local fuel_amnt = DataIndicator{parent=blr_ext_div,x=1,y=7,lu_colors=lu_col,label="",unit="mB",format="%18.0f",value=0,commas=true,width=21,fg_bg=text_fg} + + fuel_p.register(ps, "water_fill", function (x) fuel_p.update(x * 100) end) + fuel_amnt.register(ps, "water", update_amount(fuel_amnt)) + + TextBox{parent=blr_ext_div,text="Steam Tank",x=1,y=9,width=10,height=1,fg_bg=label} + local steam_p = DataIndicator{parent=blr_ext_div,x=14,y=9,lu_colors=lu_col,label="",unit="%",format="%6.2f",value=0,width=8,fg_bg=text_fg} + local steam_amnt = DataIndicator{parent=blr_ext_div,x=1,y=10,lu_colors=lu_col,label="",unit="mB",format="%18.0f",value=0,commas=true,width=21,fg_bg=text_fg} + + steam_p.register(ps, "steam_fill", function (x) steam_p.update(x * 100) end) + steam_amnt.register(ps, "steam", update_amount(steam_amnt)) + + TextBox{parent=blr_ext_div,text="Cool Coolant",x=1,y=12,width=12,height=1,fg_bg=label} + local cooled_p = DataIndicator{parent=blr_ext_div,x=14,y=12,lu_colors=lu_col,label="",unit="%",format="%6.2f",value=0,width=8,fg_bg=text_fg} + local ccool_amnt = DataIndicator{parent=blr_ext_div,x=1,y=13,lu_colors=lu_col,label="",unit="mB",format="%18.0f",value=0,commas=true,width=21,fg_bg=text_fg} + + cooled_p.register(ps, "ccool_fill", function (x) cooled_p.update(x * 100) end) + ccool_amnt.register(ps, "ccool", update_amount(ccool_amnt)) + + TextBox{parent=blr_ext_div,text="Env. Loss",x=1,y=15,width=9,height=1,fg_bg=label} + local env_loss = DataIndicator{parent=blr_ext_div,x=11,y=15,lu_colors=lu_col,label="",unit="",format="%11.8f",value=0,width=11,fg_bg=text_fg} + + env_loss.register(ps, "env_loss", env_loss.update) + + return blr_page.nav_to +end diff --git a/pocket/ui/pages/unit_page.lua b/pocket/ui/pages/unit_page.lua deleted file mode 100644 index 273ad27..0000000 --- a/pocket/ui/pages/unit_page.lua +++ /dev/null @@ -1,31 +0,0 @@ --- --- Unit Overview Page --- - -local iocontrol = require("pocket.iocontrol") - -local core = require("graphics.core") - -local Div = require("graphics.elements.div") -local TextBox = require("graphics.elements.textbox") - -local ALIGN = core.ALIGN - --- new unit 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 app = db.nav.register_app(iocontrol.APP_ID.UNITS, main) - app.new_page(nil, function () end) - - TextBox{parent=main,y=2,text="UNITS",height=1,alignment=ALIGN.CENTER} - - TextBox{parent=main,y=4,text="work in progress",height=1,alignment=ALIGN.CENTER} - - return main -end - -return new_view diff --git a/pocket/ui/pages/unit_reactor.lua b/pocket/ui/pages/unit_reactor.lua new file mode 100644 index 0000000..b7fb23f --- /dev/null +++ b/pocket/ui/pages/unit_reactor.lua @@ -0,0 +1,158 @@ +local types = require("scada-common.types") +local util = require("scada-common.util") + +local iocontrol = require("pocket.iocontrol") + +local style = require("pocket.ui.style") + +local core = require("graphics.core") + +local Div = require("graphics.elements.div") +local TextBox = require("graphics.elements.textbox") + +local PushButton = require("graphics.elements.controls.push_button") + +local DataIndicator = require("graphics.elements.indicators.data") +local StateIndicator = require("graphics.elements.indicators.state") +local IconIndicator = require("graphics.elements.indicators.icon") +local VerticalBar = require("graphics.elements.indicators.vbar") + +local ALIGN = core.ALIGN +local cpair = core.cpair + +local label = style.label +local lu_col = style.label_unit_pair +local text_fg = style.text_fg +local red_ind_s = style.icon_states.red_ind_s +local yel_ind_s = style.icon_states.yel_ind_s + +-- create a reactor view in the unit app +---@param app pocket_app +---@param u_page nav_tree_page +---@param panes table +---@param page_div graphics_element +---@param u_ps psil +---@param update function +return function (app, u_page, panes, page_div, u_ps, update) + local db = iocontrol.get_db() + + local rct_pane = Div{parent=page_div} + local rct_div = Div{parent=rct_pane,x=2,width=page_div.get_width()-2} + table.insert(panes, rct_div) + + local rct_page = app.new_page(u_page, #panes) + rct_page.tasks = { update } + + TextBox{parent=rct_div,y=1,text="Reactor",width=8,height=1} + local status = StateIndicator{parent=rct_div,x=10,y=1,states=style.reactor.states,value=1,min_width=12} + status.register(u_ps, "U_ReactorStateStatus", status.update) + + local fuel = VerticalBar{parent=rct_div,x=1,y=4,fg_bg=cpair(colors.lightGray,colors.gray),height=5,width=1} + local ccool = VerticalBar{parent=rct_div,x=3,y=4,fg_bg=cpair(colors.blue,colors.gray),height=5,width=1} + local hcool = VerticalBar{parent=rct_div,x=19,y=4,fg_bg=cpair(colors.white,colors.gray),height=5,width=1} + local waste = VerticalBar{parent=rct_div,x=21,y=4,fg_bg=cpair(colors.brown,colors.gray),height=5,width=1} + + TextBox{parent=rct_div,text="F",x=1,y=3,width=1,height=1,fg_bg=label} + TextBox{parent=rct_div,text="C",x=3,y=3,width=1,height=1,fg_bg=label} + TextBox{parent=rct_div,text="H",x=19,y=3,width=1,height=1,fg_bg=label} + TextBox{parent=rct_div,text="W",x=21,y=3,width=1,height=1,fg_bg=label} + + fuel.register(u_ps, "fuel_fill", fuel.update) + ccool.register(u_ps, "ccool_fill", ccool.update) + hcool.register(u_ps, "hcool_fill", hcool.update) + waste.register(u_ps, "waste_fill", waste.update) + + ccool.register(u_ps, "ccool_type", function (type) + if type == types.FLUID.SODIUM then + ccool.recolor(cpair(colors.lightBlue, colors.gray)) + else + ccool.recolor(cpair(colors.blue, colors.gray)) + end + end) + + hcool.register(u_ps, "hcool_type", function (type) + if type == types.FLUID.SUPERHEATED_SODIUM then + hcool.recolor(cpair(colors.orange, colors.gray)) + else + hcool.recolor(cpair(colors.white, colors.gray)) + end + end) + + TextBox{parent=rct_div,text="Burn Rate",x=5,y=4,width=13,height=1,fg_bg=label} + local burn_rate = DataIndicator{parent=rct_div,x=5,y=5,lu_colors=lu_col,label="",unit="mB/t",format="%8.2f",value=0,commas=true,width=13,fg_bg=text_fg} + TextBox{parent=rct_div,text="Temperature",x=5,y=6,width=13,height=1,fg_bg=label} + local t_prec = util.trinary(db.temp_label == types.TEMP_SCALE_UNITS[types.TEMP_SCALE.KELVIN], 11, 10) + local core_temp = DataIndicator{parent=rct_div,x=5,y=7,lu_colors=lu_col,label="",unit=db.temp_label,format="%"..t_prec..".2f",value=0,commas=true,width=13,fg_bg=text_fg} + + burn_rate.register(u_ps, "act_burn_rate", burn_rate.update) + core_temp.register(u_ps, "temp", function (t) core_temp.update(db.temp_convert(t)) end) + + local r_temp = IconIndicator{parent=rct_div,y=10,label="Reactor Temp. Hi",states=red_ind_s} + local r_rhdt = IconIndicator{parent=rct_div,label="Hi Delta Temp.",states=yel_ind_s} + local r_firl = IconIndicator{parent=rct_div,label="Fuel Rate Lo",states=yel_ind_s} + local r_wloc = IconIndicator{parent=rct_div,label="Waste Line Occl.",states=yel_ind_s} + local r_hsrt = IconIndicator{parent=rct_div,label="Hi Startup Rate",states=yel_ind_s} + + r_temp.register(u_ps, "ReactorTempHigh", r_temp.update) + r_rhdt.register(u_ps, "ReactorHighDeltaT", r_rhdt.update) + r_firl.register(u_ps, "FuelInputRateLow", r_firl.update) + r_wloc.register(u_ps, "WasteLineOcclusion", r_wloc.update) + r_hsrt.register(u_ps, "HighStartupRate", r_hsrt.update) + + TextBox{parent=rct_div,text="HR",x=1,y=16,width=4,height=1,fg_bg=label} + local heating_r = DataIndicator{parent=rct_div,x=6,y=16,lu_colors=lu_col,label="",unit="mB/t",format="%11.0f",value=0,commas=true,width=16,fg_bg=text_fg} + TextBox{parent=rct_div,text="DMG",x=1,y=17,width=4,height=1,fg_bg=label} + local damage_p = DataIndicator{parent=rct_div,x=6,y=17,lu_colors=lu_col,label="",unit="%",format="%11.2f",value=0,width=16,fg_bg=text_fg} + + heating_r.register(u_ps, "heating_rate", heating_r.update) + damage_p.register(u_ps, "damage", damage_p.update) + + local rct_ext_div = Div{parent=rct_pane,x=2,width=page_div.get_width()-2} + table.insert(panes, rct_ext_div) + + local rct_ext_page = app.new_page(rct_page, #panes) + rct_ext_page.tasks = { update } + + PushButton{parent=rct_div,x=9,y=18,text="MORE",min_width=6,fg_bg=cpair(colors.lightGray,colors.gray),active_fg_bg=cpair(colors.gray,colors.lightGray),callback=rct_ext_page.nav_to} + PushButton{parent=rct_ext_div,x=9,y=18,text="BACK",min_width=6,fg_bg=cpair(colors.lightGray,colors.gray),active_fg_bg=cpair(colors.gray,colors.lightGray),callback=rct_page.nav_to} + + TextBox{parent=rct_ext_div,y=1,text="More Reactor Info",height=1,alignment=ALIGN.CENTER} + + TextBox{parent=rct_ext_div,text="Fuel Tank",x=1,y=3,width=9,height=1,fg_bg=label} + local fuel_p = DataIndicator{parent=rct_ext_div,x=14,y=3,lu_colors=lu_col,label="",unit="%",format="%6.2f",value=0,width=8,fg_bg=text_fg} + local fuel_amnt = DataIndicator{parent=rct_ext_div,x=1,y=4,lu_colors=lu_col,label="",unit="mB",format="%18.0f",value=0,commas=true,width=21,fg_bg=text_fg} + + fuel_p.register(u_ps, "fuel_fill", function (x) fuel_p.update(x * 100) end) + fuel_amnt.register(u_ps, "fuel", fuel_amnt.update) + + TextBox{parent=rct_ext_div,text="Cool Coolant",x=1,y=6,width=12,height=1,fg_bg=label} + local cooled_p = DataIndicator{parent=rct_ext_div,x=14,y=6,lu_colors=lu_col,label="",unit="%",format="%6.2f",value=0,width=8,fg_bg=text_fg} + local ccool_amnt = DataIndicator{parent=rct_ext_div,x=1,y=7,lu_colors=lu_col,label="",unit="mB",format="%18.0f",value=0,commas=true,width=21,fg_bg=text_fg} + + cooled_p.register(u_ps, "ccool_fill", function (x) cooled_p.update(x * 100) end) + ccool_amnt.register(u_ps, "ccool_amnt", ccool_amnt.update) + + TextBox{parent=rct_ext_div,text="Hot Coolant",x=1,y=9,width=12,height=1,fg_bg=label} + local heated_p = DataIndicator{parent=rct_ext_div,x=14,y=9,lu_colors=lu_col,label="",unit="%",format="%6.2f",value=0,width=8,fg_bg=text_fg} + local hcool_amnt = DataIndicator{parent=rct_ext_div,x=1,y=10,lu_colors=lu_col,label="",unit="mB",format="%18.0f",value=0,commas=true,width=21,fg_bg=text_fg} + + heated_p.register(u_ps, "hcool_fill", function (x) heated_p.update(x * 100) end) + hcool_amnt.register(u_ps, "hcool_amnt", hcool_amnt.update) + + TextBox{parent=rct_ext_div,text="Waste Tank",x=1,y=12,width=10,height=1,fg_bg=label} + local waste_p = DataIndicator{parent=rct_ext_div,x=14,y=12,lu_colors=lu_col,label="",unit="%",format="%6.2f",value=0,width=8,fg_bg=text_fg} + local waste_amnt = DataIndicator{parent=rct_ext_div,x=1,y=13,lu_colors=lu_col,label="",unit="mB",format="%18.0f",value=0,commas=true,width=21,fg_bg=text_fg} + + waste_p.register(u_ps, "waste_fill", function (x) waste_p.update(x * 100) end) + waste_amnt.register(u_ps, "waste", waste_amnt.update) + + TextBox{parent=rct_ext_div,text="Boil Eff.",x=1,y=15,width=9,height=1,fg_bg=label} + TextBox{parent=rct_ext_div,text="Env. Loss",x=1,y=16,width=9,height=1,fg_bg=label} + local boil_eff = DataIndicator{parent=rct_ext_div,x=11,y=15,lu_colors=lu_col,label="",unit="%",format="%9.2f",value=0,width=11,fg_bg=text_fg} + local env_loss = DataIndicator{parent=rct_ext_div,x=11,y=16,lu_colors=lu_col,label="",unit="",format="%11.8f",value=0,width=11,fg_bg=text_fg} + + boil_eff.register(u_ps, "boil_eff", function (x) boil_eff.update(x * 100) end) + env_loss.register(u_ps, "env_loss", env_loss.update) + + return rct_page.nav_to +end diff --git a/pocket/ui/pages/unit_turbine.lua b/pocket/ui/pages/unit_turbine.lua new file mode 100644 index 0000000..03cd865 --- /dev/null +++ b/pocket/ui/pages/unit_turbine.lua @@ -0,0 +1,116 @@ +local util = require("scada-common.util") + +local iocontrol = require("pocket.iocontrol") + +local style = require("pocket.ui.style") + +local core = require("graphics.core") + +local Div = require("graphics.elements.div") +local TextBox = require("graphics.elements.textbox") + +local PushButton = require("graphics.elements.controls.push_button") + +local DataIndicator = require("graphics.elements.indicators.data") +local IconIndicator = require("graphics.elements.indicators.icon") +local PowerIndicator = require("graphics.elements.indicators.power") +local StateIndicator = require("graphics.elements.indicators.state") +local VerticalBar = require("graphics.elements.indicators.vbar") + +local ALIGN = core.ALIGN +local cpair = core.cpair + +local label = style.label +local lu_col = style.label_unit_pair +local text_fg = style.text_fg +local tri_ind_s = style.icon_states.tri_ind_s +local red_ind_s = style.icon_states.red_ind_s +local yel_ind_s = style.icon_states.yel_ind_s + +-- create a turbine view in the unit app +---@param app pocket_app +---@param u_page nav_tree_page +---@param panes table +---@param tbn_pane graphics_element +---@param u_id integer unit ID +---@param t_id integer turbine ID +---@param ps psil +---@param update function +return function (app, u_page, panes, tbn_pane, u_id, t_id, ps, update) + local db = iocontrol.get_db() + + local tbn_div = Div{parent=tbn_pane,x=2,width=tbn_pane.get_width()-2} + table.insert(panes, tbn_div) + + local tbn_page = app.new_page(u_page, #panes) + tbn_page.tasks = { update } + + TextBox{parent=tbn_div,y=1,text="TRBN #"..t_id,width=8,height=1} + local status = StateIndicator{parent=tbn_div,x=10,y=1,states=style.turbine.states,value=1,min_width=12} + status.register(ps, "TurbineStateStatus", status.update) + + local steam = VerticalBar{parent=tbn_div,x=1,y=4,fg_bg=cpair(colors.white,colors.gray),height=5,width=1} + local ccool = VerticalBar{parent=tbn_div,x=21,y=4,fg_bg=cpair(colors.green,colors.gray),height=5,width=1} + + TextBox{parent=tbn_div,text="S",x=1,y=3,width=1,height=1,fg_bg=label} + TextBox{parent=tbn_div,text="E",x=21,y=3,width=1,height=1,fg_bg=label} + + steam.register(ps, "steam_fill", steam.update) + ccool.register(ps, "energy_fill", ccool.update) + + TextBox{parent=tbn_div,text="Production",x=3,y=3,width=17,height=1,fg_bg=label} + local prod_rate = PowerIndicator{parent=tbn_div,x=3,y=4,lu_colors=lu_col,label="",format="%11.2f",value=0,rate=true,width=17,fg_bg=text_fg} + TextBox{parent=tbn_div,text="Flow Rate",x=3,y=5,width=17,height=1,fg_bg=label} + local flow_rate = DataIndicator{parent=tbn_div,x=3,y=6,lu_colors=lu_col,label="",unit="mB/t",format="%11.0f",value=0,commas=true,width=17,fg_bg=text_fg} + TextBox{parent=tbn_div,text="Steam Input Rate",x=3,y=7,width=17,height=1,fg_bg=label} + local input_rate = DataIndicator{parent=tbn_div,x=3,y=8,lu_colors=lu_col,label="",unit="mB/t",format="%11.0f",value=0,commas=true,width=17,fg_bg=text_fg} + + prod_rate.register(ps, "prod_rate", function (val) prod_rate.update(util.joules_to_fe(val)) end) + flow_rate.register(ps, "flow_rate", flow_rate.update) + input_rate.register(ps, "steam_input_rate", input_rate.update) + + local t_sdo = IconIndicator{parent=tbn_div,y=10,label="Steam Dumping",states=tri_ind_s} + local t_tos = IconIndicator{parent=tbn_div,label="Over Speed",states=red_ind_s} + local t_gtrp = IconIndicator{parent=tbn_div,label="Generator Trip",states=yel_ind_s} + local t_trp = IconIndicator{parent=tbn_div,label="Turbine Trip",states=red_ind_s} + + t_sdo.register(ps, "SteamDumpOpen", t_sdo.update) + t_tos.register(ps, "TurbineOverSpeed", t_tos.update) + t_gtrp.register(ps, "GeneratorTrip", t_gtrp.update) + t_trp.register(ps, "TurbineTrip", t_trp.update) + + local tbn_ext_div = Div{parent=tbn_pane,x=2,width=tbn_pane.get_width()-2} + table.insert(panes, tbn_ext_div) + + local tbn_ext_page = app.new_page(tbn_page, #panes) + tbn_ext_page.tasks = { update } + + PushButton{parent=tbn_div,x=9,y=18,text="MORE",min_width=6,fg_bg=cpair(colors.lightGray,colors.gray),active_fg_bg=cpair(colors.gray,colors.lightGray),callback=tbn_ext_page.nav_to} + PushButton{parent=tbn_ext_div,x=9,y=18,text="BACK",min_width=6,fg_bg=cpair(colors.lightGray,colors.gray),active_fg_bg=cpair(colors.gray,colors.lightGray),callback=tbn_page.nav_to} + + TextBox{parent=tbn_ext_div,y=1,text="More Turbine Info",height=1,alignment=ALIGN.CENTER} + + TextBox{parent=tbn_ext_div,text="Steam Tank",x=1,y=3,width=10,height=1,fg_bg=label} + local steam_p = DataIndicator{parent=tbn_ext_div,x=14,y=3,lu_colors=lu_col,label="",unit="%",format="%6.2f",value=0,width=8,fg_bg=text_fg} + local steam_amnt = DataIndicator{parent=tbn_ext_div,x=1,y=4,lu_colors=lu_col,label="",unit="mB",format="%18.0f",value=0,commas=true,width=21,fg_bg=text_fg} + + steam_p.register(ps, "steam_fill", function (x) steam_p.update(x * 100) end) + steam_amnt.register(ps, "steam", function (x) steam_amnt.update(x.amount) end) + + TextBox{parent=tbn_ext_div,text="Energy Fill",x=1,y=6,width=12,height=1,fg_bg=label} + local charge_p = DataIndicator{parent=tbn_ext_div,x=14,y=6,lu_colors=lu_col,label="",unit="%",format="%6.2f",value=0,width=8,fg_bg=text_fg} + local charge_amnt = PowerIndicator{parent=tbn_ext_div,x=1,y=7,lu_colors=lu_col,label="",format="%17.4f",value=0,width=21,fg_bg=text_fg} + + charge_p.register(ps, "energy_fill", function (x) charge_p.update(x * 100) end) + charge_amnt.register(ps, "energy", charge_amnt.update) + + TextBox{parent=tbn_ext_div,text="Rotation Rate",x=1,y=9,width=13,height=1,fg_bg=label} + local rotation = DataIndicator{parent=tbn_ext_div,x=1,y=10,lu_colors=lu_col,label="",unit="",format="%21.12f",value=0,width=21,fg_bg=text_fg} + + rotation.register(ps, "steam", function () + local ok, result = pcall(function () return util.turbine_rotation(db.units[u_id].turbine_data_tbl[t_id]) end) + if ok then rotation.update(result) end + end) + + return tbn_page.nav_to +end diff --git a/pocket/ui/style.lua b/pocket/ui/style.lua index 2fb7526..dc26755 100644 --- a/pocket/ui/style.lua +++ b/pocket/ui/style.lua @@ -12,7 +12,9 @@ local cpair = core.cpair style.root = cpair(colors.white, colors.black) style.header = cpair(colors.white, colors.gray) -style.label = cpair(colors.gray, colors.lightGray) +style.text_fg = cpair(colors.white, colors._INHERIT) +style.label = cpair(colors.lightGray, colors.black) +style.label_unit_pair = cpair(colors.lightGray, colors.lightGray) style.colors = { { c = colors.red, hex = 0xdf4949 }, @@ -33,6 +35,46 @@ style.colors = { -- { c = colors.brown, hex = 0x7f664c } } +local states = {} + +states.basic_states = { + { color = cpair(colors.black, colors.lightGray), symbol = "\x07" }, + { color = cpair(colors.black, colors.red), symbol = "-" }, + { color = cpair(colors.black, colors.yellow), symbol = "\x1e" }, + { color = cpair(colors.black, colors.green), symbol = "+" } +} + +states.mode_states = { + { color = cpair(colors.black, colors.lightGray), symbol = "\x07" }, + { color = cpair(colors.black, colors.red), symbol = "-" }, + { color = cpair(colors.black, colors.green), symbol = "+" }, + { color = cpair(colors.black, colors.purple), symbol = "A" } +} + +states.emc_ind_s = { + { color = cpair(colors.black, colors.gray), symbol = "-" }, + { color = cpair(colors.black, colors.white), symbol = "\x07" }, + { color = cpair(colors.black, colors.green), symbol = "+" } +} + +states.tri_ind_s = { + { color = cpair(colors.black, colors.lightGray), symbol = "+" }, + { color = cpair(colors.black, colors.yellow), symbol = "\x1e" }, + { color = cpair(colors.black, colors.red), symbol = "-" } +} + +states.red_ind_s = { + { color = cpair(colors.black, colors.lightGray), symbol = "+" }, + { color = cpair(colors.black, colors.red), symbol = "-" } +} + +states.yel_ind_s = { + { color = cpair(colors.black, colors.lightGray), symbol = "+" }, + { color = cpair(colors.black, colors.yellow), symbol = "-" } +} + +style.icon_states = states + -- MAIN LAYOUT -- style.reactor = { @@ -40,7 +82,7 @@ style.reactor = { states = { { color = cpair(colors.black, colors.yellow), - text = "PLC OFF-LINE" + text = "OFF-LINE" }, { color = cpair(colors.black, colors.orange), @@ -64,7 +106,7 @@ style.reactor = { }, { color = cpair(colors.black, colors.red), - text = "FORCE DISABLED" + text = "FORCE DSBL" } } } diff --git a/reactor-plc/plc.lua b/reactor-plc/plc.lua index fb906a3..c89974f 100644 --- a/reactor-plc/plc.lua +++ b/reactor-plc/plc.lua @@ -25,8 +25,8 @@ local RPS_LIMITS = const.RPS_LIMITS -- I sure hope the devs don't change this error message, not that it would have safety implications -- I wish they didn't change it to be like this -local PCALL_SCRAM_MSG = "pcall: Scram requires the reactor to be active." -local PCALL_START_MSG = "pcall: Reactor is already active." +local PCALL_SCRAM_MSG = "Scram requires the reactor to be active." +local PCALL_START_MSG = "Reactor is already active." ---@type plc_config local config = {} @@ -307,7 +307,7 @@ function plc.rps_init(reactor, is_formed) log.info("RPS: reactor SCRAM") reactor.scram() - if reactor.__p_is_faulted() and (reactor.__p_last_fault() ~= PCALL_SCRAM_MSG) then + if reactor.__p_is_faulted() and not string.find(reactor.__p_last_fault(), PCALL_SCRAM_MSG) then log.error("RPS: failed reactor SCRAM") return false else @@ -325,7 +325,7 @@ function plc.rps_init(reactor, is_formed) log.info("RPS: reactor start") reactor.activate() - if reactor.__p_is_faulted() and (reactor.__p_last_fault() ~= PCALL_START_MSG) then + if reactor.__p_is_faulted() and not string.find(reactor.__p_last_fault(), PCALL_START_MSG) then log.error("RPS: failed reactor start") else self.reactor_enabled = true @@ -524,8 +524,8 @@ end function plc.comms(version, nic, reactor, rps, conn_watchdog) local self = { sv_addr = comms.BROADCAST, - seq_num = 0, - r_seq_num = nil, + seq_num = util.time_ms() * 10, -- unique per peer, restarting will not re-use seq nums due to message rate + r_seq_num = nil, ---@type nil|integer scrammed = false, linked = false, last_est_ack = ESTABLISH_ACK.ALLOW, @@ -571,33 +571,17 @@ function plc.comms(version, nic, reactor, rps, conn_watchdog) self.seq_num = self.seq_num + 1 end - -- variable reactor status information, excluding heating rate + -- dynamic reactor status information, excluding heating rate ---@return table data_table, boolean faulted - local function _reactor_status() + local function _get_reactor_status() local fuel = nil local waste = nil local coolant = nil local hcoolant = nil - local data_table = { - false, -- getStatus - 0, -- getBurnRate - 0, -- getActualBurnRate - 0, -- getTemperature - 0, -- getDamagePercent - 0, -- getBoilEfficiency - 0, -- getEnvironmentalLoss - 0, -- fuel_amnt - 0, -- getFuelFilledPercentage - 0, -- waste_amnt - 0, -- getWasteFilledPercentage - "", -- coolant_name - 0, -- coolant_amnt - 0, -- getCoolantFilledPercentage - "", -- hcoolant_name - 0, -- hcoolant_amnt - 0 -- getHeatedCoolantFilledPercentage - } + local data_table = {} + + reactor.__p_disable_afc() local tasks = { function () data_table[1] = reactor.getStatus() end, @@ -637,30 +621,32 @@ function plc.comms(version, nic, reactor, rps, conn_watchdog) data_table[16] = hcoolant.amount end + reactor.__p_enable_afc() + return data_table, reactor.__p_is_faulted() end -- update the status cache if changed ---@return boolean changed local function _update_status_cache() - local status, faulted = _reactor_status() + local status, faulted = _get_reactor_status() local changed = false - if self.status_cache ~= nil then - if not faulted then + if not faulted then + if self.status_cache ~= nil then for i = 1, #status do if status[i] ~= self.status_cache[i] then changed = true break end end + else + changed = true end - else - changed = true - end - if changed and not faulted then - self.status_cache = status + if changed then + self.status_cache = status + end end return changed @@ -679,9 +665,11 @@ function plc.comms(version, nic, reactor, rps, conn_watchdog) _send(msg_type, { status }) end - -- send structure properties (these should not change, server will cache these) + -- send static structure properties, cached by server local function _send_struct() - local mek_data = { false, 0, 0, 0, types.new_zero_coordinate(), types.new_zero_coordinate(), 0, 0, 0, 0, 0, 0, 0, 0 } + local mek_data = {} + + reactor.__p_disable_afc() local tasks = { function () mek_data[1] = reactor.getLength() end, @@ -705,6 +693,8 @@ function plc.comms(version, nic, reactor, rps, conn_watchdog) _send(RPLC_TYPE.MEK_STRUCT, mek_data) self.resend_build = false end + + reactor.__p_enable_afc() end -- PUBLIC FUNCTIONS -- @@ -835,8 +825,8 @@ function plc.comms(version, nic, reactor, rps, conn_watchdog) if l_chan == config.PLC_Channel then -- check sequence number if self.r_seq_num == nil then - self.r_seq_num = packet.scada_frame.seq_num() - elseif self.linked and ((self.r_seq_num + 1) ~= packet.scada_frame.seq_num()) then + self.r_seq_num = packet.scada_frame.seq_num() + 1 + elseif self.r_seq_num ~= packet.scada_frame.seq_num() then log.warning("sequence out-of-order: last = " .. self.r_seq_num .. ", new = " .. packet.scada_frame.seq_num()) return elseif self.linked and (src_addr ~= self.sv_addr) then @@ -844,7 +834,7 @@ function plc.comms(version, nic, reactor, rps, conn_watchdog) "); channel in use by another system?") return else - self.r_seq_num = packet.scada_frame.seq_num() + self.r_seq_num = packet.scada_frame.seq_num() + 1 end -- feed the watchdog first so it doesn't uhh...eat our packets :) @@ -1030,10 +1020,9 @@ function plc.comms(version, nic, reactor, rps, conn_watchdog) println_ts("linked!") log.info("supervisor establish request approved, linked to SV (CID#" .. src_addr .. ")") - -- link + reset remote sequence number and cache + -- link + reset cache self.sv_addr = src_addr self.linked = true - self.r_seq_num = nil self.status_cache = nil if plc_state.reactor_formed then _send_struct() end diff --git a/reactor-plc/startup.lua b/reactor-plc/startup.lua index 18ea091..6f6a27b 100644 --- a/reactor-plc/startup.lua +++ b/reactor-plc/startup.lua @@ -18,7 +18,7 @@ local plc = require("reactor-plc.plc") local renderer = require("reactor-plc.renderer") local threads = require("reactor-plc.threads") -local R_PLC_VERSION = "v1.7.10" +local R_PLC_VERSION = "v1.8.0" local println = util.println local println_ts = util.println_ts diff --git a/rtu/configure.lua b/rtu/configure.lua index 7758dc2..9012705 100644 --- a/rtu/configure.lua +++ b/rtu/configure.lua @@ -2,6 +2,7 @@ -- Configuration GUI -- +local constants = require("scada-common.constants") local log = require("scada-common.log") local ppm = require("scada-common.ppm") local rsio = require("scada-common.rsio") @@ -33,45 +34,50 @@ local tri = util.trinary local cpair = core.cpair local IO = rsio.IO +local IO_LVL = rsio.IO_LVL +local IO_MODE = rsio.IO_MODE local LEFT = core.ALIGN.LEFT local CENTER = core.ALIGN.CENTER local RIGHT = core.ALIGN.RIGHT -- rsio port descriptions -local PORT_DESC = { - "Facility SCRAM", - "Facility Acknowledge", - "Reactor SCRAM", - "Reactor RPS Reset", - "Reactor Enable", - "Unit Acknowledge", - "Facility Alarm (high prio)", - "Facility Alarm (any)", - "Waste Plutonium Valve", - "Waste Polonium Valve", - "Waste Po Pellets Valve", - "Waste Antimatter Valve", - "Reactor Active", - "Reactor in Auto Control", - "RPS Tripped", - "RPS Auto SCRAM", - "RPS High Damage", - "RPS High Temperature", - "RPS Low Coolant", - "RPS Excess Heated Coolant", - "RPS Excess Waste", - "RPS Insufficient Fuel", - "RPS PLC Fault", - "RPS Supervisor Timeout", - "Unit Alarm", - "Unit Emergency Cool. Valve" +local PORT_DESC_MAP = { + { IO.F_SCRAM, "Facility SCRAM" }, + { IO.F_ACK, "Facility Acknowledge" }, + { IO.R_SCRAM, "Reactor SCRAM" }, + { IO.R_RESET, "Reactor RPS Reset" }, + { IO.R_ENABLE, "Reactor Enable" }, + { IO.U_ACK, "Unit Acknowledge" }, + { IO.F_ALARM, "Facility Alarm (high prio)" }, + { IO.F_ALARM_ANY, "Facility Alarm (any)" }, + { IO.F_MATRIX_LOW, "Induction Matrix < " .. (100 * constants.RS_THRESHOLDS.IMATRIX_CHARGE_LOW) .. "%" }, + { IO.F_MATRIX_HIGH, "Induction Matrix > " .. (100 * constants.RS_THRESHOLDS.IMATRIX_CHARGE_HIGH) .. "%" }, + { IO.F_MATRIX_CHG, "Induction Matrix Charge %" }, + { IO.WASTE_PU, "Waste Plutonium Valve" }, + { IO.WASTE_PO, "Waste Polonium Valve" }, + { IO.WASTE_POPL, "Waste Po Pellets Valve" }, + { IO.WASTE_AM, "Waste Antimatter Valve" }, + { IO.R_ACTIVE, "Reactor Active" }, + { IO.R_AUTO_CTRL, "Reactor in Auto Control" }, + { IO.R_SCRAMMED, "RPS Tripped" }, + { IO.R_AUTO_SCRAM, "RPS Auto SCRAM" }, + { IO.R_HIGH_DMG, "RPS High Damage" }, + { IO.R_HIGH_TEMP, "RPS High Temperature" }, + { IO.R_LOW_COOLANT, "RPS Low Coolant" }, + { IO.R_EXCESS_HC, "RPS Excess Heated Coolant" }, + { IO.R_EXCESS_WS, "RPS Excess Waste" }, + { IO.R_INSUFF_FUEL, "RPS Insufficient Fuel" }, + { IO.R_PLC_FAULT, "RPS PLC Fault" }, + { IO.R_PLC_TIMEOUT, "RPS Supervisor Timeout" }, + { IO.U_ALARM, "Unit Alarm" }, + { IO.U_EMER_COOL, "Unit Emergency Cool. Valve" } } -- designation (0 = facility, 1 = unit) -local PORT_DSGN = { [-1] = 1, 0, 0, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1 } +local PORT_DSGN = { [-1] = 1, 0, 0, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0 } -assert(#PORT_DESC == rsio.NUM_PORTS) +assert(#PORT_DESC_MAP == rsio.NUM_PORTS) assert(#PORT_DSGN == rsio.NUM_PORTS) -- changes to the config data/format to let the user know @@ -442,7 +448,7 @@ local function config_view(display) TextBox{parent=net_c_3,x=1,y=11,height=1,text="Facility Auth Key"} local key, _, censor = TextField{parent=net_c_3,x=1,y=12,max_len=64,value=ini_cfg.AuthKey,width=32,height=1,fg_bg=bw_fg_bg} - local function censor_key(enable) censor(util.trinary(enable, "*", nil)) end + local function censor_key(enable) censor(tri(enable, "*", nil)) end local hide_key = CheckBox{parent=net_c_3,x=34,y=12,label="Hide",box_fg_bg=cpair(colors.lightBlue,colors.black),callback=censor_key} @@ -555,7 +561,7 @@ local function config_view(display) PushButton{parent=clr_c_2,x=44,y=14,min_width=6,text="Done",callback=function()clr_pane.set_value(1)end,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} local function back_from_colors() - main_pane.set_value(util.trinary(tool_ctl.jumped_to_color, 1, 4)) + main_pane.set_value(tri(tool_ctl.jumped_to_color, 1, 4)) tool_ctl.jumped_to_color = false recolor(1) end @@ -897,7 +903,7 @@ local function config_view(display) tool_ctl.p_desc.reposition(1, 8) tool_ctl.p_desc.set_value("You can connect more than one environment detector for a particular unit or the facility. In that case, the maximum radiation reading from those assigned to that particular unit or the facility will be used for alarms and display.") elseif type == "inductionPort" or type == "spsPort" then - local dev = util.trinary(type == "inductionPort", "induction matrix", "SPS") + local dev = tri(type == "inductionPort", "induction matrix", "SPS") tool_ctl.p_idx.hide(true) tool_ctl.p_unit.hide(true) tool_ctl.p_prompt.set_value("This is the " .. dev .. " for the facility.") @@ -923,7 +929,7 @@ local function config_view(display) tool_ctl.ppm_devs.remove_all() for name, entry in pairs(mounts) do if util.table_contains(RTU_DEV_TYPES, entry.type) then - local bkg = util.trinary(alternate, colors.white, colors.lightGray) + local bkg = tri(alternate, colors.white, colors.lightGray) ---@cast entry ppm_entry local line = Div{parent=tool_ctl.ppm_devs,height=2,fg_bg=cpair(colors.black,bkg)} @@ -1085,8 +1091,9 @@ local function config_view(display) local rs_c_4 = Div{parent=rs_cfg,x=2,y=4,width=49} local rs_c_5 = Div{parent=rs_cfg,x=2,y=4,width=49} local rs_c_6 = Div{parent=rs_cfg,x=2,y=4,width=49} + local rs_c_7 = Div{parent=rs_cfg,x=2,y=4,width=49} - local rs_pane = MultiPane{parent=rs_cfg,x=1,y=4,panes={rs_c_1,rs_c_2,rs_c_3,rs_c_4,rs_c_5,rs_c_6}} + local rs_pane = MultiPane{parent=rs_cfg,x=1,y=4,panes={rs_c_1,rs_c_2,rs_c_3,rs_c_4,rs_c_5,rs_c_6,rs_c_7}} TextBox{parent=rs_cfg,x=1,y=2,height=1,text=" Redstone Connections",fg_bg=cpair(colors.black,colors.red)} @@ -1143,9 +1150,23 @@ local function config_view(display) text = "You selected the ALL_WASTE shortcut." else tool_ctl.rs_cfg_shortcut.hide(true) - tool_ctl.rs_cfg_side_l.set_value(util.trinary(rsio.get_io_dir(port) == rsio.IO_DIR.IN, "Input Side", "Output Side")) + tool_ctl.rs_cfg_side_l.set_value(tri(rsio.get_io_dir(port) == rsio.IO_DIR.IN, "Input Side", "Output Side")) tool_ctl.rs_cfg_color.show() - text = "You selected " .. rsio.to_string(port) .. " (for " + + local io_type = "analog input " + local io_mode = rsio.get_io_mode(port) + local inv = tri(rsio.digital_is_active(port, IO_LVL.LOW) == true, "inverted ", "") + + if io_mode == IO_MODE.DIGITAL_IN then + io_type = inv .. "digital input " + elseif io_mode == IO_MODE.DIGITAL_OUT then + io_type = inv .. "digital output " + elseif io_mode == IO_MODE.ANALOG_OUT then + io_type = "analog output " + end + + text = "You selected the " .. io_type .. rsio.to_string(port) .. " (for " + if PORT_DSGN[port] == 1 then text = text .. "a unit)." tool_ctl.rs_cfg_unit_l.show() @@ -1167,25 +1188,35 @@ local function config_view(display) PushButton{parent=all_w_macro,x=1,y=1,min_width=14,alignment=LEFT,height=1,text=">ALL_WASTE",callback=function()new_rs(-1)end,fg_bg=cpair(colors.black,colors.green),active_fg_bg=cpair(colors.white,colors.black)} TextBox{parent=all_w_macro,x=16,y=1,width=5,height=1,text="[n/a]",fg_bg=cpair(colors.lightGray,colors.white)} TextBox{parent=all_w_macro,x=22,y=1,height=1,text="Create all 4 waste entries",fg_bg=cpair(colors.gray,colors.white)} + for i = 1, rsio.NUM_PORTS do - local name = rsio.to_string(i) - local io_dir = util.trinary(rsio.get_io_dir(i) == rsio.IO_DIR.IN, "[in]", "[out]") - local btn_color = util.trinary(rsio.get_io_dir(i) == rsio.IO_DIR.IN, colors.yellow, colors.lightBlue) + local p = PORT_DESC_MAP[i][1] + local name = rsio.to_string(p) + local io_dir = tri(rsio.get_io_dir(p) == rsio.IO_DIR.IN, "[in]", "[out]") + local btn_color = tri(rsio.get_io_dir(p) == rsio.IO_DIR.IN, colors.yellow, colors.lightBlue) + local entry = Div{parent=rs_ports,height=1} - PushButton{parent=entry,x=1,y=1,min_width=14,alignment=LEFT,height=1,text=">"..name,callback=function()new_rs(i)end,fg_bg=cpair(colors.black,btn_color),active_fg_bg=cpair(colors.white,colors.black)} + PushButton{parent=entry,x=1,y=1,min_width=14,alignment=LEFT,height=1,text=">"..name,callback=function()new_rs(p)end,fg_bg=cpair(colors.black,btn_color),active_fg_bg=cpair(colors.white,colors.black)} TextBox{parent=entry,x=16,y=1,width=5,height=1,text=io_dir,fg_bg=cpair(colors.lightGray,colors.white)} - TextBox{parent=entry,x=22,y=1,height=1,text=PORT_DESC[i],fg_bg=cpair(colors.gray,colors.white)} + TextBox{parent=entry,x=22,y=1,height=1,text=PORT_DESC_MAP[i][2],fg_bg=cpair(colors.gray,colors.white)} end PushButton{parent=rs_c_2,x=1,y=14,text="\x1b Back",callback=function()rs_pane.set_value(1)end,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} - tool_ctl.rs_cfg_selection = TextBox{parent=rs_c_3,x=1,y=1,height=1,text=""} + tool_ctl.rs_cfg_selection = TextBox{parent=rs_c_3,x=1,y=1,height=2,text=""} - tool_ctl.rs_cfg_unit_l = TextBox{parent=rs_c_3,x=27,y=3,width=7,height=1,text="Unit ID"} - tool_ctl.rs_cfg_unit = NumberField{parent=rs_c_3,x=27,y=4,width=10,max_chars=2,min=1,max=4,fg_bg=bw_fg_bg} + PushButton{parent=rs_c_3,x=36,y=3,text="What's that?",min_width=14,callback=function()rs_pane.set_value(7)end,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} - tool_ctl.rs_cfg_side_l = TextBox{parent=rs_c_3,x=1,y=3,width=11,height=1,text="Output Side"} - local side = Radio2D{parent=rs_c_3,x=1,y=4,rows=2,columns=3,default=1,options=side_options,radio_colors=cpair(colors.lightGray,colors.black),select_color=colors.red} + TextBox{parent=rs_c_7,x=1,y=1,height=4,text="(Normal) Digital Input: On if there is a redstone signal, off otherwise\nInverted Digital Input: On without a redstone signal, off otherwise"} + TextBox{parent=rs_c_7,x=1,y=6,height=4,text="(Normal) Digital Output: Redstone signal to 'turn it on', none to 'turn it off'\nInverted Digital Output: No redstone signal to 'turn it on', redstone signal to 'turn it off'"} + TextBox{parent=rs_c_7,x=1,y=11,height=2,text="Analog Input: 0-15 redstone power level input\nAnalog Output: 0-15 scaled redstone power level output"} + PushButton{parent=rs_c_7,x=1,y=14,text="\x1b Back",callback=function()rs_pane.set_value(3)end,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} + + tool_ctl.rs_cfg_side_l = TextBox{parent=rs_c_3,x=1,y=4,width=11,height=1,text="Output Side"} + local side = Radio2D{parent=rs_c_3,x=1,y=5,rows=1,columns=6,default=1,options=side_options,radio_colors=cpair(colors.lightGray,colors.black),select_color=colors.red} + + tool_ctl.rs_cfg_unit_l = TextBox{parent=rs_c_3,x=25,y=7,width=7,height=1,text="Unit ID"} + tool_ctl.rs_cfg_unit = NumberField{parent=rs_c_3,x=33,y=7,width=10,max_chars=2,min=1,max=4,fg_bg=bw_fg_bg} local function set_bundled(bundled) if bundled then tool_ctl.rs_cfg_color.enable() else tool_ctl.rs_cfg_color.disable() end @@ -1216,10 +1247,10 @@ local function config_view(display) if port >= 0 then ---@type rtu_rs_definition local def = { - unit = util.trinary(PORT_DSGN[port] == 1, u, nil), + unit = tri(PORT_DSGN[port] == 1, u, nil), port = port, side = side_options_map[side.get_value()], - color = util.trinary(bundled.get_value(), color_options_map[tool_ctl.rs_cfg_color.get_value()], nil) + color = tri(bundled.get_value(), color_options_map[tool_ctl.rs_cfg_color.get_value()], nil) } if tool_ctl.rs_cfg_editing == false then @@ -1233,10 +1264,10 @@ local function config_view(display) local default_colors = { colors.red, colors.orange, colors.yellow, colors.lime } for i = 0, 3 do table.insert(tmp_cfg.Redstone, { - unit = util.trinary(PORT_DSGN[IO.WASTE_PU + i] == 1, u, nil), + unit = tri(PORT_DSGN[IO.WASTE_PU + i] == 1, u, nil), port = IO.WASTE_PU + i, - side = util.trinary(bundled.get_value(), side_options_map[side.get_value()], default_sides[i + 1]), - color = util.trinary(bundled.get_value(), default_colors[i + 1], nil) + side = tri(bundled.get_value(), side_options_map[side.get_value()], default_sides[i + 1]), + color = tri(bundled.get_value(), default_colors[i + 1], nil) }) end end @@ -1289,7 +1320,7 @@ local function config_view(display) peri_import_list.remove_all() for _, entry in ipairs(config.RTU_DEVICES) do local for_facility = entry.for_reactor == 0 - local ini_unit = util.trinary(for_facility, nil, entry.for_reactor) + local ini_unit = tri(for_facility, nil, entry.for_reactor) local def = { name = entry.name, unit = ini_unit, index = entry.index } local mount = mounts[def.name] ---@type ppm_entry|nil @@ -1368,7 +1399,7 @@ local function config_view(display) table.insert(tmp_cfg.Redstone, def) local name = rsio.to_string(def.port) - local io_dir = util.trinary(rsio.get_io_dir(def.port) == rsio.IO_DIR.IN, "\x1a", "\x1b") + local io_dir = tri(rsio.get_io_dir(def.port) == rsio.IO_DIR.IN, "\x1a", "\x1b") local conn = def.side local unit = "facility" @@ -1431,7 +1462,7 @@ local function config_view(display) local val = util.strval(raw) if f[1] == "AuthKey" then val = string.rep("*", string.len(val)) - elseif f[1] == "LogMode" then val = util.trinary(raw == log.MODE.APPEND, "append", "replace") + elseif f[1] == "LogMode" then val = tri(raw == log.MODE.APPEND, "append", "replace") elseif f[1] == "FrontPanelTheme" then val = util.strval(themes.fp_theme_name(raw)) elseif f[1] == "ColorMode" then @@ -1440,7 +1471,7 @@ local function config_view(display) if val == "nil" then val = "" end - local c = util.trinary(alternate, g_lg_fg_bg, cpair(colors.gray,colors.white)) + local c = tri(alternate, g_lg_fg_bg, cpair(colors.gray,colors.white)) alternate = not alternate if string.len(val) > val_max_w then @@ -1554,7 +1585,7 @@ local function config_view(display) end tool_ctl.rs_cfg_selection.set_value(text) - tool_ctl.rs_cfg_side_l.set_value(util.trinary(rsio.get_io_dir(def.port) == rsio.IO_DIR.IN, "Input Side", "Output Side")) + tool_ctl.rs_cfg_side_l.set_value(tri(rsio.get_io_dir(def.port) == rsio.IO_DIR.IN, "Input Side", "Output Side")) side.set_value(side_to_idx(def.side)) bundled.set_value(def.color ~= nil) tool_ctl.rs_cfg_color.set_value(value) @@ -1575,7 +1606,7 @@ local function config_view(display) local def = cfg.Redstone[i] ---@type rtu_rs_definition local name = rsio.to_string(def.port) - local io_dir = util.trinary(rsio.get_io_mode(def.port) == rsio.IO_DIR.IN, "\x1a", "\x1b") + local io_dir = tri(rsio.get_io_mode(def.port) == rsio.IO_DIR.IN, "\x1a", "\x1b") local conn = def.side local unit = util.strval(def.unit or "F") @@ -1638,9 +1669,11 @@ function configurator.configure(ask_config) elseif event == "paste" then display.handle_paste(param1) elseif event == "peripheral_detach" then +---@diagnostic disable-next-line: discard-returns ppm.handle_unmount(param1) tool_ctl.update_peri_list() elseif event == "peripheral" then +---@diagnostic disable-next-line: discard-returns ppm.mount(param1) tool_ctl.update_peri_list() end diff --git a/rtu/rtu.lua b/rtu/rtu.lua index 71cea40..231c261 100644 --- a/rtu/rtu.lua +++ b/rtu/rtu.lua @@ -284,8 +284,8 @@ end function rtu.comms(version, nic, conn_watchdog) local self = { sv_addr = comms.BROADCAST, - seq_num = 0, - r_seq_num = nil, + seq_num = util.time_ms() * 10, -- unique per peer, restarting will not re-use seq nums due to message rate + r_seq_num = nil, ---@type nil|integer txn_id = 0, last_est_ack = ESTABLISH_ACK.ALLOW } @@ -442,8 +442,8 @@ function rtu.comms(version, nic, conn_watchdog) if l_chan == config.RTU_Channel then -- check sequence number if self.r_seq_num == nil then - self.r_seq_num = packet.scada_frame.seq_num() - elseif rtu_state.linked and ((self.r_seq_num + 1) ~= packet.scada_frame.seq_num()) then + self.r_seq_num = packet.scada_frame.seq_num() + 1 + elseif self.r_seq_num ~= packet.scada_frame.seq_num() then log.warning("sequence out-of-order: last = " .. self.r_seq_num .. ", new = " .. packet.scada_frame.seq_num()) return elseif rtu_state.linked and (src_addr ~= self.sv_addr) then @@ -451,7 +451,7 @@ function rtu.comms(version, nic, conn_watchdog) "); channel in use by another system?") return else - self.r_seq_num = packet.scada_frame.seq_num() + self.r_seq_num = packet.scada_frame.seq_num() + 1 end -- feed watchdog on valid sequence number @@ -556,7 +556,6 @@ function rtu.comms(version, nic, conn_watchdog) -- establish allowed rtu_state.linked = true self.sv_addr = packet.scada_frame.src_addr() - self.r_seq_num = nil println_ts("supervisor connection established") log.info("supervisor connection established") else diff --git a/rtu/startup.lua b/rtu/startup.lua index 9470124..c5000ea 100644 --- a/rtu/startup.lua +++ b/rtu/startup.lua @@ -31,7 +31,7 @@ local sna_rtu = require("rtu.dev.sna_rtu") local sps_rtu = require("rtu.dev.sps_rtu") local turbinev_rtu = require("rtu.dev.turbinev_rtu") -local RTU_VERSION = "v1.9.4" +local RTU_VERSION = "v1.10.1" local RTU_UNIT_TYPE = types.RTU_UNIT_TYPE local RTU_UNIT_HW_STATE = databus.RTU_UNIT_HW_STATE @@ -93,14 +93,6 @@ local function main() network.init_mac(config.AuthKey) end - -- get modem - local modem = ppm.get_wireless_modem() - if modem == nil then - println("boot> wireless modem not found") - log.fatal("no wireless modem on startup") - return - end - -- generate alarm tones audio.generate_tones() @@ -116,14 +108,15 @@ local function main() -- RTU gateway devices (not RTU units) rtu_dev = { + modem = ppm.get_wireless_modem(), sounders = {} }, -- system objects rtu_sys = { - nic = network.nic(modem), - rtu_comms = nil, ---@type rtu_comms - conn_watchdog = nil, ---@type watchdog + nic = nil, ---@type nic + rtu_comms = nil, ---@type rtu_comms + conn_watchdog = nil, ---@type watchdog units = {} }, @@ -134,8 +127,9 @@ local function main() } local smem_sys = __shared_memory.rtu_sys + local smem_dev = __shared_memory.rtu_dev - databus.tx_hw_modem(true) + local rtu_state = __shared_memory.rtu_state ---------------------------------------- -- interpret config and init units @@ -501,8 +495,6 @@ local function main() -- start system ---------------------------------------- - local rtu_state = __shared_memory.rtu_state - log.debug("boot> running sys_config()") if sys_config() then @@ -517,23 +509,33 @@ local function main() log.info("startup> running in headless mode without front panel") end + -- check modem + if smem_dev.modem == nil then + println("startup> wireless modem not found") + log.fatal("no wireless modem on startup") + return + end + + databus.tx_hw_modem(true) + -- 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) + table.insert(smem_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) + databus.tx_hw_spkr_count(#smem_dev.sounders) -- start connection watchdog smem_sys.conn_watchdog = util.new_watchdog(config.ConnTimeout) log.debug("startup> conn watchdog started") -- setup comms + smem_sys.nic = network.nic(smem_dev.modem) smem_sys.rtu_comms = rtu.comms(RTU_VERSION, smem_sys.nic, smem_sys.conn_watchdog) log.debug("startup> comms init") diff --git a/scada-common/comms.lua b/scada-common/comms.lua index fffaaee..fa6f6ff 100644 --- a/scada-common/comms.lua +++ b/scada-common/comms.lua @@ -17,8 +17,8 @@ local max_distance = nil local comms = {} -- protocol/data versions (protocol/data independent changes tracked by util.lua version) -comms.version = "2.5.0" -comms.api_version = "0.0.1" +comms.version = "3.0.0" +comms.api_version = "0.0.3" ---@enum PROTOCOL local PROTOCOL = { @@ -67,7 +67,7 @@ local CRDN_TYPE = { UNIT_STATUSES = 5, -- state of each of the reactor units UNIT_CMD = 6, -- command a reactor unit API_GET_FAC = 7, -- API: get all the facility data - API_GET_UNITS = 8 -- API: get all the reactor unit data + API_GET_UNIT = 8 -- API: get reactor unit data } ---@enum ESTABLISH_ACK @@ -97,7 +97,8 @@ local FAC_COMMAND = { 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 + SET_PU_FB = 5, -- set plutonium fallback mode + SET_SPS_LP = 6 -- set SPS at low power mode } ---@enum UNIT_COMMAND @@ -239,6 +240,8 @@ function comms.scada_packet() ---@nodiscard function public.modem_event() return self.modem_msg_in end ---@nodiscard + function public.raw_header() return { self.src_addr, self.dest_addr, self.seq_num, self.protocol } end + ---@nodiscard function public.raw_sendable() return self.raw end ---@nodiscard @@ -277,7 +280,7 @@ function comms.authd_packet() src_addr = comms.BROADCAST, dest_addr = comms.BROADCAST, mac = "", - payload = "" + payload = {} } ---@class authd_packet @@ -285,14 +288,13 @@ function comms.authd_packet() -- make an authenticated SCADA packet ---@param s_packet scada_packet scada packet to authenticate - ---@param mac function message authentication function + ---@param mac function message authentication hash function function public.make(s_packet, mac) self.valid = true self.src_addr = s_packet.src_addr() self.dest_addr = s_packet.dest_addr() - self.payload = textutils.serialize(s_packet.raw_sendable(), { allow_repetitions = true, compact = true }) - self.mac = mac(self.payload) - self.raw = { self.src_addr, self.dest_addr, self.mac, self.payload } + self.mac = mac(textutils.serialize(s_packet.raw_header(), { allow_repetitions = true, compact = true })) + self.raw = { self.src_addr, self.dest_addr, self.mac, s_packet.raw_sendable() } end -- parse in a modem message as an authenticated SCADA packet @@ -329,14 +331,14 @@ function comms.authd_packet() self.src_addr = nil self.dest_addr = nil self.mac = "" - self.payload = "" + self.payload = {} end -- check if this packet is destined for this device local is_destination = (self.dest_addr == comms.BROADCAST) or (self.dest_addr == COMPUTER_ID) self.valid = is_destination and type(self.src_addr) == "number" and type(self.dest_addr) == "number" and - type(self.mac) == "string" and type(self.payload) == "string" + type(self.mac) == "string" and type(self.payload) == "table" end end diff --git a/scada-common/constants.lua b/scada-common/constants.lua index 20925bd..0472a6c 100644 --- a/scada-common/constants.lua +++ b/scada-common/constants.lua @@ -29,7 +29,7 @@ local annunc = {} annunc.RCSFlowLow_H2O = -3.2 -- flow < -3.2 mB/s annunc.RCSFlowLow_NA = -2.0 -- flow < -2.0 mB/s annunc.CoolantLevelLow = 0.4 -- fill < 40% -annunc.ReactorTempHigh = 1000 -- temp > 1000K +annunc.OpTempTolerance = 5 -- high temp if >= operational temp + X annunc.ReactorHighDeltaT = 50 -- rate > 50K/s annunc.FuelLevelLow = 0.05 -- fill <= 5% annunc.WasteLevelHigh = 0.80 -- fill >= 80% @@ -52,7 +52,6 @@ local alarms = {} -- unit alarms -alarms.HIGH_TEMP = 1150 -- temp >= 1150K alarms.HIGH_WASTE = 0.85 -- fill > 85% alarms.HIGH_RADIATION = 0.00005 -- 50 uSv/h, not yet damaging but this isn't good @@ -66,6 +65,18 @@ constants.ALARM_LIMITS = alarms --#endregion +--#region Supervisor Redstone Activation Thresholds + +---@class _rs_threshold_constants +local rs = {} + +rs.IMATRIX_CHARGE_LOW = 0.05 -- activation threshold (less than) for F_MATRIX_LOW +rs.IMATRIX_CHARGE_HIGH = 0.95 -- activation threshold (greater than) for F_MATRIX_HIGH + +constants.RS_THRESHOLDS = rs + +--#endregion + --#region Supervisor Constants -- milliseconds until coolant flow is assumed to be stable enough to enable certain coolant checks @@ -89,9 +100,11 @@ constants.EXTREME_RADIATION = 100.0 ---@class _mek_constants local mek = {} -mek.TURBINE_GAS_PER_TANK = 64000 -- mekanism: turbineGasPerTank -mek.TURBINE_DISPERSER_FLOW = 1280 -- mekanism: turbineDisperserGasFlow -mek.TURBINE_VENT_FLOW = 32000 -- mekanism: turbineVentGasFlow +mek.BASE_BOIL_TEMP = 373.15 -- mekanism: HeatUtils.BASE_BOIL_TEMP +mek.JOULES_PER_MB = 1000000 -- mekanism: energyPerFissionFuel +mek.TURBINE_GAS_PER_TANK = 64000 -- mekanism: turbineGasPerTank +mek.TURBINE_DISPERSER_FLOW = 1280 -- mekanism: turbineDisperserGasFlow +mek.TURBINE_VENT_FLOW = 32000 -- mekanism: turbineVentGasFlow constants.mek = mek diff --git a/scada-common/network.lua b/scada-common/network.lua index bdaf5c3..c34a1d5 100644 --- a/scada-common/network.lua +++ b/scada-common/network.lua @@ -80,7 +80,7 @@ end ---@param modem table modem to use function network.nic(modem) local self = { - connected = true, -- used to avoid costly MAC calculations if modem isn't even present + connected = true, -- used to avoid costly MAC calculations if modem isn't even present channels = {} } @@ -114,7 +114,7 @@ function network.nic(modem) modem.open(channel) end - -- link all public functions except for transmit + -- link all public functions except for transmit, open, and close for key, func in pairs(modem) do if key ~= "transmit" and key ~= "open" and key ~= "close" and key ~= "closeAll" then public[key] = func end end @@ -175,7 +175,7 @@ function network.nic(modem) ---@param packet scada_packet packet function public.transmit(dest_channel, local_channel, packet) if self.connected then - local tx_packet = packet ---@type authd_packet|scada_packet + local tx_packet = packet ---@type authd_packet|scada_packet if c_eng.hmac ~= nil then -- local start = util.time_ms() @@ -184,7 +184,7 @@ function network.nic(modem) ---@cast tx_packet authd_packet tx_packet.make(packet, compute_hmac) - -- log.debug("crypto.modem.transmit: data processing took " .. (util.time_ms() - start) .. "ms") + -- log.debug("network.modem.transmit: data processing took " .. (util.time_ms() - start) .. "ms") end modem.transmit(dest_channel, local_channel, tx_packet.raw_sendable()) @@ -211,17 +211,18 @@ function network.nic(modem) a_packet.receive(side, sender, reply_to, message, distance) if a_packet.is_valid() then - -- local start = util.time_ms() - local packet_hmac = a_packet.mac() - local msg = a_packet.data() - local computed_hmac = compute_hmac(msg) + s_packet.receive(side, sender, reply_to, a_packet.data(), distance) - 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") + if s_packet.is_valid() then + -- local start = util.time_ms() + local computed_hmac = compute_hmac(textutils.serialize(s_packet.raw_header(), { allow_repetitions = true, compact = true })) + + if a_packet.mac() == computed_hmac then + -- log.debug("network.modem.receive: HMAC verified in " .. (util.time_ms() - start) .. "ms") + s_packet.stamp_authenticated() + else + -- log.debug("network.modem.receive: HMAC failed verification in " .. (util.time_ms() - start) .. "ms") + end end end else diff --git a/scada-common/rsio.lua b/scada-common/rsio.lua index 6d1e688..fb3a50a 100644 --- a/scada-common/rsio.lua +++ b/scada-common/rsio.lua @@ -52,6 +52,8 @@ local IO_PORT = { -- facility F_ALARM = 7, -- active high, facility-wide alarm (any high priority unit alarm) F_ALARM_ANY = 8, -- active high, any alarm regardless of priority + F_MATRIX_LOW = 27, -- active high, induction matrix charge low + F_MATRIX_HIGH = 28, -- active high, induction matrix charge high -- waste WASTE_PU = 9, -- active low, waste -> plutonium -> pellets route @@ -75,17 +77,27 @@ local IO_PORT = { -- unit outputs U_ALARM = 25, -- active high, unit alarm - U_EMER_COOL = 26 -- active low, emergency coolant control + U_EMER_COOL = 26, -- active low, emergency coolant control + + -- analog outputs -- + + -- facility + F_MATRIX_CHG = 29 -- analog charge level of the induction matrix } rsio.IO_LVL = IO_LVL rsio.IO_DIR = IO_DIR rsio.IO_MODE = IO_MODE rsio.IO = IO_PORT -rsio.NUM_PORTS = IO_PORT.U_EMER_COOL + +rsio.NUM_PORTS = 29 +rsio.NUM_DIG_PORTS = 28 +rsio.NUM_ANA_PORTS = 1 -- self checks +assert(rsio.NUM_PORTS == (rsio.NUM_DIG_PORTS + rsio.NUM_ANA_PORTS), "port counts inconsistent") + local dup_chk = {} for _, v in pairs(IO_PORT) do assert(dup_chk[v] ~= true, "duplicate in port list") @@ -96,64 +108,45 @@ assert(#dup_chk == rsio.NUM_PORTS, "port list malformed") --#endregion ---#region Utility Functions +--#region Utility Functions and Attribute Tables -local PORT_NAMES = { - "F_SCRAM", - "F_ACK", - "R_SCRAM", - "R_RESET", - "R_ENABLE", - "U_ACK", - "F_ALARM", - "F_ALARM_ANY", - "WASTE_PU", - "WASTE_PO", - "WASTE_POPL", - "WASTE_AM", - "R_ACTIVE", - "R_AUTO_CTRL", - "R_SCRAMMED", - "R_AUTO_SCRAM", - "R_HIGH_DMG", - "R_HIGH_TEMP", - "R_LOW_COOLANT", - "R_EXCESS_HC", - "R_EXCESS_WS", - "R_INSUFF_FUEL", - "R_PLC_FAULT", - "R_PLC_TIMEOUT", - "U_ALARM", - "U_EMER_COOL" -} +local IO = IO_PORT +-- list of all port names +local PORT_NAMES = {} +for k, v in pairs(IO) do PORT_NAMES[v] = k end + +-- list of all port I/O modes local MODES = { - IO_MODE.DIGITAL_IN, -- F_SCRAM - IO_MODE.DIGITAL_IN, -- F_ACK - IO_MODE.DIGITAL_IN, -- R_SCRAM - IO_MODE.DIGITAL_IN, -- R_RESET - IO_MODE.DIGITAL_IN, -- R_ENABLE - IO_MODE.DIGITAL_IN, -- U_ACK - IO_MODE.DIGITAL_OUT, -- F_ALARM - IO_MODE.DIGITAL_OUT, -- F_ALARM_ANY - IO_MODE.DIGITAL_OUT, -- WASTE_PU - IO_MODE.DIGITAL_OUT, -- WASTE_PO - IO_MODE.DIGITAL_OUT, -- WASTE_POPL - IO_MODE.DIGITAL_OUT, -- WASTE_AM - IO_MODE.DIGITAL_OUT, -- R_ACTIVE - IO_MODE.DIGITAL_OUT, -- R_AUTO_CTRL - IO_MODE.DIGITAL_OUT, -- R_SCRAMMED - IO_MODE.DIGITAL_OUT, -- R_AUTO_SCRAM - IO_MODE.DIGITAL_OUT, -- R_HIGH_DMG - IO_MODE.DIGITAL_OUT, -- R_HIGH_TEMP - IO_MODE.DIGITAL_OUT, -- R_LOW_COOLANT - IO_MODE.DIGITAL_OUT, -- R_EXCESS_HC - IO_MODE.DIGITAL_OUT, -- R_EXCESS_WS - IO_MODE.DIGITAL_OUT, -- R_INSUFF_FUEL - IO_MODE.DIGITAL_OUT, -- R_PLC_FAULT - IO_MODE.DIGITAL_OUT, -- R_PLC_TIMEOUT - IO_MODE.DIGITAL_OUT, -- U_ALARM - IO_MODE.DIGITAL_OUT -- U_EMER_COOL + [IO.F_SCRAM] = IO_MODE.DIGITAL_IN, + [IO.F_ACK] = IO_MODE.DIGITAL_IN, + [IO.R_SCRAM] = IO_MODE.DIGITAL_IN, + [IO.R_RESET] = IO_MODE.DIGITAL_IN, + [IO.R_ENABLE] = IO_MODE.DIGITAL_IN, + [IO.U_ACK] = IO_MODE.DIGITAL_IN, + [IO.F_ALARM] = IO_MODE.DIGITAL_OUT, + [IO.F_ALARM_ANY] = IO_MODE.DIGITAL_OUT, + [IO.F_MATRIX_LOW] = IO_MODE.DIGITAL_OUT, + [IO.F_MATRIX_HIGH] = IO_MODE.DIGITAL_OUT, + [IO.WASTE_PU] = IO_MODE.DIGITAL_OUT, + [IO.WASTE_PO] = IO_MODE.DIGITAL_OUT, + [IO.WASTE_POPL] = IO_MODE.DIGITAL_OUT, + [IO.WASTE_AM] = IO_MODE.DIGITAL_OUT, + [IO.R_ACTIVE] = IO_MODE.DIGITAL_OUT, + [IO.R_AUTO_CTRL] = IO_MODE.DIGITAL_OUT, + [IO.R_SCRAMMED] = IO_MODE.DIGITAL_OUT, + [IO.R_AUTO_SCRAM] = IO_MODE.DIGITAL_OUT, + [IO.R_HIGH_DMG] = IO_MODE.DIGITAL_OUT, + [IO.R_HIGH_TEMP] = IO_MODE.DIGITAL_OUT, + [IO.R_LOW_COOLANT] = IO_MODE.DIGITAL_OUT, + [IO.R_EXCESS_HC] = IO_MODE.DIGITAL_OUT, + [IO.R_EXCESS_WS] = IO_MODE.DIGITAL_OUT, + [IO.R_INSUFF_FUEL] = IO_MODE.DIGITAL_OUT, + [IO.R_PLC_FAULT] = IO_MODE.DIGITAL_OUT, + [IO.R_PLC_TIMEOUT] = IO_MODE.DIGITAL_OUT, + [IO.U_ALARM] = IO_MODE.DIGITAL_OUT, + [IO.U_EMER_COOL] = IO_MODE.DIGITAL_OUT, + [IO.F_MATRIX_CHG] = IO_MODE.ANALOG_OUT } assert(rsio.NUM_PORTS == #PORT_NAMES, "port names length incorrect") @@ -179,74 +172,51 @@ local function _O_ACTIVE_LOW(active) if active then return IO_LVL.LOW else retur -- I/O mappings to I/O function and I/O mode local RS_DIO_MAP = { - -- F_SCRAM - { _in = _I_ACTIVE_LOW, _out = _O_ACTIVE_LOW, mode = IO_DIR.IN }, - -- F_ACK - { _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.IN }, + [IO.F_SCRAM] = { _in = _I_ACTIVE_LOW, _out = _O_ACTIVE_LOW, mode = IO_DIR.IN }, + [IO.F_ACK] = { _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.IN }, - -- R_SCRAM - { _in = _I_ACTIVE_LOW, _out = _O_ACTIVE_LOW, mode = IO_DIR.IN }, - -- R_RESET - { _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.IN }, - -- R_ENABLE - { _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.IN }, + [IO.R_SCRAM] = { _in = _I_ACTIVE_LOW, _out = _O_ACTIVE_LOW, mode = IO_DIR.IN }, + [IO.R_RESET] = { _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.IN }, + [IO.R_ENABLE] = { _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.IN }, - -- U_ACK - { _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.IN }, + [IO.U_ACK] = { _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.IN }, - -- F_ALARM - { _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.OUT }, - -- F_ALARM_ANY - { _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.OUT }, + [IO.F_ALARM] = { _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.OUT }, + [IO.F_ALARM_ANY] = { _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.OUT }, + [IO.F_MATRIX_LOW] = { _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.OUT }, + [IO.F_MATRIX_HIGH] = { _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.OUT }, - -- WASTE_PU - { _in = _I_ACTIVE_LOW, _out = _O_ACTIVE_LOW, mode = IO_DIR.OUT }, - -- WASTE_PO - { _in = _I_ACTIVE_LOW, _out = _O_ACTIVE_LOW, mode = IO_DIR.OUT }, - -- WASTE_POPL - { _in = _I_ACTIVE_LOW, _out = _O_ACTIVE_LOW, mode = IO_DIR.OUT }, - -- WASTE_AM - { _in = _I_ACTIVE_LOW, _out = _O_ACTIVE_LOW, mode = IO_DIR.OUT }, + [IO.WASTE_PU] = { _in = _I_ACTIVE_LOW, _out = _O_ACTIVE_LOW, mode = IO_DIR.OUT }, + [IO.WASTE_PO] = { _in = _I_ACTIVE_LOW, _out = _O_ACTIVE_LOW, mode = IO_DIR.OUT }, + [IO.WASTE_POPL] = { _in = _I_ACTIVE_LOW, _out = _O_ACTIVE_LOW, mode = IO_DIR.OUT }, + [IO.WASTE_AM] = { _in = _I_ACTIVE_LOW, _out = _O_ACTIVE_LOW, mode = IO_DIR.OUT }, - -- R_ACTIVE - { _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.OUT }, - -- R_AUTO_CTRL - { _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.OUT }, - -- R_SCRAMMED - { _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.OUT }, - -- R_AUTO_SCRAM - { _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.OUT }, - -- R_HIGH_DMG - { _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.OUT }, - -- R_HIGH_TEMP - { _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.OUT }, - -- R_LOW_COOLANT - { _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.OUT }, - -- R_EXCESS_HC - { _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.OUT }, - -- R_EXCESS_WS - { _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.OUT }, - -- R_INSUFF_FUEL - { _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.OUT }, - -- R_PLC_FAULT - { _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.OUT }, - -- R_PLC_TIMEOUT - { _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.OUT }, + [IO.R_ACTIVE] = { _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.OUT }, + [IO.R_AUTO_CTRL] = { _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.OUT }, + [IO.R_SCRAMMED] = { _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.OUT }, + [IO.R_AUTO_SCRAM] = { _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.OUT }, + [IO.R_HIGH_DMG] = { _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.OUT }, + [IO.R_HIGH_TEMP] = { _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.OUT }, + [IO.R_LOW_COOLANT] = { _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.OUT }, + [IO.R_EXCESS_HC] = { _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.OUT }, + [IO.R_EXCESS_WS] = { _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.OUT }, + [IO.R_INSUFF_FUEL] = { _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.OUT }, + [IO.R_PLC_FAULT] = { _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.OUT }, + [IO.R_PLC_TIMEOUT] = { _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.OUT }, - -- U_ALARM - { _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.OUT }, - -- U_EMER_COOL - { _in = _I_ACTIVE_LOW, _out = _O_ACTIVE_LOW, mode = IO_DIR.OUT } + [IO.U_ALARM] = { _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.OUT }, + [IO.U_EMER_COOL] = { _in = _I_ACTIVE_LOW, _out = _O_ACTIVE_LOW, mode = IO_DIR.OUT } } -assert(rsio.NUM_PORTS == #RS_DIO_MAP, "RS_DIO_MAP length incorrect") +assert(rsio.NUM_DIG_PORTS == #RS_DIO_MAP, "RS_DIO_MAP length incorrect") -- get the I/O direction of a port ---@nodiscard ---@param port IO_PORT ---@return IO_DIR function rsio.get_io_dir(port) - if rsio.is_valid_port(port) then return RS_DIO_MAP[port].mode + if rsio.is_valid_port(port) then + return util.trinary(MODES[port] == IO_MODE.DIGITAL_OUT or MODES[port] == IO_MODE.ANALOG_OUT, IO_DIR.OUT, IO_DIR.IN) else return IO_DIR.IN end end @@ -310,6 +280,13 @@ end --#region Digital I/O +-- check if a port is digital +---@nodiscard +---@param port IO_PORT +function rsio.is_digital(port) + return rsio.is_valid_port(port) and (MODES[port] == IO_MODE.DIGITAL_IN or MODES[port] == IO_MODE.DIGITAL_OUT) +end + -- get digital I/O level reading from a redstone boolean input value ---@nodiscard ---@param rs_value boolean raw value from redstone @@ -330,7 +307,7 @@ function rsio.digital_write(level) return level == IO_LVL.HIGH end ---@param active boolean state to convert to logic level ---@return IO_LVL|false function rsio.digital_write_active(port, active) - if (not util.is_int(port)) or (port < IO_PORT.F_ALARM) or (port > IO_PORT.U_EMER_COOL) then + if not rsio.is_digital(port) then return false else return RS_DIO_MAP[port]._out(active) @@ -343,9 +320,7 @@ end ---@param level IO_LVL logic level ---@return boolean|nil state true for active, false for inactive, or nil if invalid port or level provided function rsio.digital_is_active(port, level) - if not util.is_int(port) then - return nil - elseif level == IO_LVL.FLOATING or level == IO_LVL.DISCONNECT then + if (not rsio.is_digital(port)) or level == IO_LVL.FLOATING or level == IO_LVL.DISCONNECT then return nil else return RS_DIO_MAP[port]._in(level) @@ -356,6 +331,13 @@ end --#region Analog I/O +-- check if a port is analog +---@nodiscard +---@param port IO_PORT +function rsio.is_analog(port) + return rsio.is_valid_port(port) and (MODES[port] == IO_MODE.ANALOG_IN or MODES[port] == IO_MODE.ANALOG_OUT) +end + -- read an analog value scaled from min to max ---@nodiscard ---@param rs_value number redstone reading (0 to 15) @@ -372,7 +354,7 @@ end ---@param value number value to write (from min to max range) ---@param min number minimum of range ---@param max number maximum of range ----@return number rs_value scaled redstone reading (0 to 15) +---@return integer rs_value scaled redstone reading (0 to 15) function rsio.analog_write(value, min, max) local scaled_value = (value - min) / (max - min) return math.floor(scaled_value * 15) diff --git a/scada-common/types.lua b/scada-common/types.lua index d6fc7af..aeeca16 100644 --- a/scada-common/types.lua +++ b/scada-common/types.lua @@ -74,6 +74,28 @@ function types.new_zero_coordinate() return { x = 0, y = 0, z = 0 } end -- ENUMERATION TYPES -- --#region +---@enum TEMP_SCALE +types.TEMP_SCALE = { + KELVIN = 1, + CELSIUS = 2, + FAHRENHEIT = 3, + RANKINE = 4 +} + +types.TEMP_SCALE_NAMES = { + "Kelvin", + "Celsius", + "Fahrenheit", + "Rankine" +} + +types.TEMP_SCALE_UNITS = { + "K", + "\xb0C", + "\xb0F", + "\xb0R" +} + ---@enum PANEL_LINK_STATE types.PANEL_LINK_STATE = { LINKED = 1, diff --git a/scada-common/util.lua b/scada-common/util.lua index 98a1667..344ffd8 100644 --- a/scada-common/util.lua +++ b/scada-common/util.lua @@ -4,6 +4,8 @@ local cc_strings = require("cc.strings") +local const = require("scada-common.constants") + local math = math local string = string local table = table @@ -22,7 +24,7 @@ local t_pack = table.pack local util = {} -- scada-common version -util.version = "1.2.2" +util.version = "1.3.1" util.TICK_TIME_S = 0.05 util.TICK_TIME_MS = 50 @@ -181,8 +183,7 @@ function util.round(x) return math.floor(x + 0.5) end -- get a new moving average object ---@nodiscard ---@param length integer history length ----@param default number value to fill history with for first call to compute() -function util.mov_avg(length, default) +function util.mov_avg(length) local data = {} local index = 1 local last_t = 0 ---@type number|nil @@ -190,11 +191,15 @@ function util.mov_avg(length, default) ---@class moving_average local public = {} - -- reset all to a given value - ---@param x number value + -- reset all to a given value, or clear all data if no value is given + ---@param x number? value function public.reset(x) + index = 1 data = {} - for _ = 1, length do t_insert(data, x) end + + if x then + for _ = 1, length do t_insert(data, x) end + end end -- record a new value @@ -214,12 +219,15 @@ function util.mov_avg(length, default) ---@nodiscard ---@return number average function public.compute() - local sum = 0 - for i = 1, length do sum = sum + data[i] end - return sum / length - end + if #data == 0 then return 0 end - public.reset(default) + local sum = 0 + for i = 1, #data do + sum = sum + data[i] + end + + return sum / #data + end return public end @@ -362,7 +370,7 @@ end --#endregion ---#region MEKANISM POWER +--#region MEKANISM MATH -- convert Joules to FE ---@nodiscard @@ -428,6 +436,22 @@ function util.power_format(fe, combine_label, format) end end +-- compute Mekanism's rotation rate for a turbine +---@nodiscard +---@param turbine turbinev_session_db turbine data +function util.turbine_rotation(turbine) + local build = turbine.build + + local inner_vol = build.steam_cap / const.mek.TURBINE_GAS_PER_TANK + local disp_rate = (build.dispersers * const.mek.TURBINE_DISPERSER_FLOW) * inner_vol + local vent_rate = build.vents * const.mek.TURBINE_VENT_FLOW + + local max_rate = math.min(disp_rate, vent_rate) + local flow = math.min(max_rate, turbine.tanks.steam.amount) + + return (flow * (turbine.tanks.steam.amount / build.steam_cap)) / max_rate +end + --#endregion --#region UTILITY CLASSES diff --git a/startup.lua b/startup.lua index 811f510..662d989 100644 --- a/startup.lua +++ b/startup.lua @@ -1,35 +1,31 @@ -local util = require("scada-common.util") +local BOOTLOADER_VERSION = "1.1" -local println = util.println - -local BOOTLOADER_VERSION = "1.0" - -println("SCADA BOOTLOADER V" .. BOOTLOADER_VERSION) -println("BOOT> SCANNING FOR APPLICATIONS...") +print("SCADA BOOTLOADER V" .. BOOTLOADER_VERSION) +print("BOOT> SCANNING FOR APPLICATIONS...") local exit_code if fs.exists("reactor-plc/startup.lua") then - println("BOOT> EXEC REACTOR PLC STARTUP") + print("BOOT> EXEC REACTOR PLC STARTUP") exit_code = shell.execute("reactor-plc/startup") elseif fs.exists("rtu/startup.lua") then - println("BOOT> EXEC RTU STARTUP") + print("BOOT> EXEC RTU STARTUP") exit_code = shell.execute("rtu/startup") elseif fs.exists("supervisor/startup.lua") then - println("BOOT> EXEC SUPERVISOR STARTUP") + print("BOOT> EXEC SUPERVISOR STARTUP") exit_code = shell.execute("supervisor/startup") elseif fs.exists("coordinator/startup.lua") then - println("BOOT> EXEC COORDINATOR STARTUP") + print("BOOT> EXEC COORDINATOR STARTUP") exit_code = shell.execute("coordinator/startup") elseif fs.exists("pocket/startup.lua") then - println("BOOT> EXEC POCKET STARTUP") + print("BOOT> EXEC POCKET STARTUP") exit_code = shell.execute("pocket/startup") else - println("BOOT> NO SCADA STARTUP FOUND") - println("BOOT> EXIT") + print("BOOT> NO SCADA STARTUP FOUND") + print("BOOT> EXIT") return false end -if not exit_code then println("BOOT> APPLICATION CRASHED") end +if not exit_code then print("BOOT> APPLICATION CRASHED") end return exit_code diff --git a/supervisor/facility.lua b/supervisor/facility.lua index 33cd267..8fb3746 100644 --- a/supervisor/facility.lua +++ b/supervisor/facility.lua @@ -120,6 +120,8 @@ function facility.new(config, cooling_conf) waste_product = WASTE.PLUTONIUM, current_waste_product = WASTE.PLUTONIUM, pu_fallback = false, + sps_low_power = false, + disabled_sps = false, -- alarm tones tone_states = {}, test_tone_set = false, @@ -128,9 +130,16 @@ function facility.new(config, cooling_conf) test_alarm_states = {}, -- statistics im_stat_init = false, - avg_charge = util.mov_avg(3, 0.0), - avg_inflow = util.mov_avg(6, 0.0), - avg_outflow = util.mov_avg(6, 0.0) + avg_charge = util.mov_avg(3), -- 3 seconds + avg_inflow = util.mov_avg(6), -- 3 seconds + avg_outflow = util.mov_avg(6), -- 3 seconds + -- induction matrix charge delta stats + avg_net = util.mov_avg(60), -- 60 seconds + imtx_last_capacity = 0, + imtx_last_charge = 0, + imtx_last_charge_t = 0, + -- track faulted induction matrix update times to reject + imtx_faulted_times = { 0, 0, 0 } } -- create units @@ -300,23 +309,68 @@ function facility.new(config, cooling_conf) -- calculate moving averages for induction matrix if self.induction[1] ~= nil then - local matrix = self.induction[1] ---@type unit_session - local db = matrix.get_db() ---@type imatrix_session_db + local matrix = self.induction[1] ---@type unit_session + local db = matrix.get_db() ---@type imatrix_session_db - charge_update = db.tanks.last_update + local build_update = db.build.last_update rate_update = db.state.last_update + charge_update = db.tanks.last_update + + local has_data = build_update > 0 and rate_update > 0 and charge_update > 0 + + if matrix.is_faulted() then + -- a fault occured, cannot reliably update stats + has_data = false + self.im_stat_init = false + self.imtx_faulted_times = { build_update, rate_update, charge_update } + elseif not self.im_stat_init then + -- prevent operation with partially invalid data + -- all fields must have updated since the last fault + has_data = self.imtx_faulted_times[1] < build_update and + self.imtx_faulted_times[2] < rate_update and + self.imtx_faulted_times[3] < charge_update + end + + if has_data then + local energy = util.joules_to_fe(db.tanks.energy) + local input = util.joules_to_fe(db.state.last_input) + local output = util.joules_to_fe(db.state.last_output) - if (charge_update > 0) and (rate_update > 0) then if self.im_stat_init then - self.avg_charge.record(util.joules_to_fe(db.tanks.energy), charge_update) - self.avg_inflow.record(util.joules_to_fe(db.state.last_input), rate_update) - self.avg_outflow.record(util.joules_to_fe(db.state.last_output), rate_update) + self.avg_charge.record(energy, charge_update) + self.avg_inflow.record(input, rate_update) + self.avg_outflow.record(output, rate_update) + + if charge_update ~= self.imtx_last_charge_t then + local delta = (energy - self.imtx_last_charge) / (charge_update - self.imtx_last_charge_t) + + self.imtx_last_charge = energy + self.imtx_last_charge_t = charge_update + + -- if the capacity changed, toss out existing data + if db.build.max_energy ~= self.imtx_last_capacity then + self.imtx_last_capacity = db.build.max_energy + self.avg_net.reset() + else + self.avg_net.record(delta, charge_update) + end + end else self.im_stat_init = true - self.avg_charge.reset(util.joules_to_fe(db.tanks.energy)) - self.avg_inflow.reset(util.joules_to_fe(db.state.last_input)) - self.avg_outflow.reset(util.joules_to_fe(db.state.last_output)) + + self.avg_charge.reset(energy) + self.avg_inflow.reset(input) + self.avg_outflow.reset(output) + self.avg_net.reset() + + self.imtx_last_capacity = db.build.max_energy + self.imtx_last_charge = energy + self.imtx_last_charge_t = charge_update end + else + -- prevent use by control systems + rate_update = 0 + charge_update = 0 end else self.im_stat_init = false @@ -475,7 +529,7 @@ function facility.new(config, cooling_conf) self.status_text = { "CHARGE MODE", "running control loop" } log.info("FAC: CHARGE mode starting PID control") - elseif self.last_update ~= charge_update then + elseif self.last_update < charge_update then -- convert to kFE to make constants not microscopic local error = util.round((self.charge_setpoint - avg_charge) / 1000) / 1000 @@ -549,7 +603,7 @@ function facility.new(config, cooling_conf) self.status_text = { "GENERATION MODE", "running control loop" } log.info("FAC: GEN_RATE process mode initial hold completed, starting PID control") end - elseif self.last_update ~= rate_update then + elseif self.last_update < rate_update then -- convert to MFE (in rounded kFE) to make constants not microscopic local error = util.round((self.gen_rate_setpoint - avg_inflow) / 1000) / 1000 @@ -620,8 +674,7 @@ function facility.new(config, cooling_conf) local astatus = self.ascram_status if self.induction[1] ~= nil then - local matrix = self.induction[1] ---@type unit_session - local db = matrix.get_db() ---@type imatrix_session_db + local db = self.induction[1].get_db() ---@type imatrix_session_db -- clear matrix disconnected if astatus.matrix_dc then @@ -774,6 +827,15 @@ function facility.new(config, cooling_conf) self.io_ctl.digital_write(IO.F_ALARM, has_prio_alarm) self.io_ctl.digital_write(IO.F_ALARM_ANY, has_any_alarm) + + -- update induction matrix related outputs + if self.induction[1] ~= nil then + local db = self.induction[1].get_db() ---@type imatrix_session_db + + self.io_ctl.digital_write(IO.F_MATRIX_LOW, db.tanks.energy_fill < const.RS_THRESHOLDS.IMATRIX_CHARGE_LOW) + self.io_ctl.digital_write(IO.F_MATRIX_HIGH, db.tanks.energy_fill > const.RS_THRESHOLDS.IMATRIX_CHARGE_HIGH) + self.io_ctl.analog_write(IO.F_MATRIX_CHG, db.tanks.energy_fill, 0, 1) + end end --#endregion @@ -804,9 +866,25 @@ function facility.new(config, cooling_conf) end -- update waste product - if self.waste_product == WASTE.PLUTONIUM or (self.pu_fallback and insufficent_po_rate) then + + self.current_waste_product = self.waste_product + + if (not self.sps_low_power) and (self.waste_product == WASTE.ANTI_MATTER) and (self.induction[1] ~= nil) then + local db = self.induction[1].get_db() ---@type imatrix_session_db + + if db.tanks.energy_fill >= 0.15 then + self.disabled_sps = false + elseif self.disabled_sps or ((db.tanks.last_update > 0) and (db.tanks.energy_fill < 0.1)) then + self.disabled_sps = true + self.current_waste_product = WASTE.POLONIUM + end + else + self.disabled_sps = false + end + + if self.pu_fallback and insufficent_po_rate then self.current_waste_product = WASTE.PLUTONIUM - else self.current_waste_product = self.waste_product end + end -- make sure dynamic tanks are allowing outflow if required -- set all, rather than trying to determine which is for which (simpler & safer) @@ -1063,6 +1141,14 @@ function facility.new(config, cooling_conf) return self.pu_fallback end + -- enable/disable SPS at low power + ---@param enabled boolean requested state + ---@return boolean enabled newly set value + function public.set_sps_low_power(enabled) + self.sps_low_power = enabled == true + return self.sps_low_power + end + --#endregion --#region Diagnostic Testing @@ -1167,7 +1253,8 @@ function facility.new(config, cooling_conf) self.status_text[2], self.group_map, self.current_waste_product, - (self.current_waste_product == WASTE.PLUTONIUM) and (self.waste_product ~= WASTE.PLUTONIUM) + self.pu_fallback and (self.current_waste_product == WASTE.PLUTONIUM) and (self.waste_product ~= WASTE.PLUTONIUM), + self.disabled_sps } end @@ -1183,15 +1270,21 @@ function facility.new(config, cooling_conf) status.power = { self.avg_charge.compute(), self.avg_inflow.compute(), - self.avg_outflow.compute() + self.avg_outflow.compute(), + 0 } -- status of induction matricies (including tanks) status.induction = {} for i = 1, #self.induction do - local matrix = self.induction[i] ---@type unit_session - local db = matrix.get_db() ---@type imatrix_session_db + local matrix = self.induction[i] ---@type unit_session + local db = matrix.get_db() ---@type imatrix_session_db + status.induction[i] = { matrix.is_faulted(), db.formed, db.state, db.tanks } + + local fe_per_ms = self.avg_net.compute() + local remaining = util.joules_to_fe(util.trinary(fe_per_ms >= 0, db.tanks.energy_need, db.tanks.energy)) + status.power[4] = remaining / fe_per_ms end -- status of sps diff --git a/supervisor/session/coordinator.lua b/supervisor/session/coordinator.lua index c0e6482..a135a44 100644 --- a/supervisor/session/coordinator.lua +++ b/supervisor/session/coordinator.lua @@ -43,12 +43,13 @@ local PERIODICS = { ---@nodiscard ---@param id integer session ID ---@param s_addr integer device source address +---@param i_seq_num integer initial sequence number ---@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 coordinator.new_session(id, s_addr, in_queue, out_queue, timeout, facility, fp_ok) +function coordinator.new_session(id, s_addr, i_seq_num, 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 @@ -57,8 +58,8 @@ function coordinator.new_session(id, s_addr, in_queue, out_queue, timeout, facil local self = { units = facility.get_units(), -- connection properties - seq_num = 0, - r_seq_num = nil, + seq_num = i_seq_num + 2, -- next after the establish approval was sent + r_seq_num = i_seq_num + 1, connected = true, conn_watchdog = util.new_watchdog(timeout), establish_time = util.time_s(), @@ -182,13 +183,11 @@ function coordinator.new_session(id, s_addr, in_queue, out_queue, timeout, facil ---@param pkt mgmt_frame|crdn_frame local function _handle_packet(pkt) -- check sequence number - if self.r_seq_num == nil then - self.r_seq_num = pkt.scada_frame.seq_num() - elseif (self.r_seq_num + 1) ~= pkt.scada_frame.seq_num() then + if self.r_seq_num ~= pkt.scada_frame.seq_num() then log.warning(log_header .. "sequence out-of-order: last = " .. self.r_seq_num .. ", new = " .. pkt.scada_frame.seq_num()) return else - self.r_seq_num = pkt.scada_frame.seq_num() + self.r_seq_num = pkt.scada_frame.seq_num() + 1 end -- feed watchdog @@ -270,6 +269,12 @@ function coordinator.new_session(id, s_addr, in_queue, out_queue, timeout, facil else log.debug(log_header .. "CRDN set pu fallback packet length mismatch") end + elseif cmd == FAC_COMMAND.SET_SPS_LP then + if pkt.length == 2 then + _send(CRDN_TYPE.FAC_CMD, { cmd, facility.set_sps_low_power(pkt.data[2]) }) + else + log.debug(log_header .. "CRDN set sps low power packet length mismatch") + end else log.debug(log_header .. "CRDN facility command unknown") end diff --git a/supervisor/session/plc.lua b/supervisor/session/plc.lua index 5bb097e..4aad6d3 100644 --- a/supervisor/session/plc.lua +++ b/supervisor/session/plc.lua @@ -1,4 +1,5 @@ local comms = require("scada-common.comms") +local const = require("scada-common.constants") local log = require("scada-common.log") local mqueue = require("scada-common.mqueue") local types = require("scada-common.types") @@ -47,12 +48,13 @@ local PERIODICS = { ---@nodiscard ---@param id integer session ID ---@param s_addr integer device source address +---@param i_seq_num integer initial sequence number ---@param reactor_id integer reactor ID ---@param in_queue mqueue in message queue ---@param out_queue mqueue out message queue ---@param timeout number communications timeout ---@param fp_ok boolean if the front panel UI is running -function plc.new_session(id, s_addr, reactor_id, in_queue, out_queue, timeout, fp_ok) +function plc.new_session(id, s_addr, i_seq_num, reactor_id, in_queue, out_queue, timeout, 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 @@ -65,8 +67,8 @@ function plc.new_session(id, s_addr, reactor_id, in_queue, out_queue, timeout, f ramping_rate = false, auto_lock = false, -- connection properties - seq_num = 0, - r_seq_num = nil, + seq_num = i_seq_num + 2, -- next after the establish approval was sent + r_seq_num = i_seq_num + 1, connected = true, received_struct = false, received_status_cache = false, @@ -105,6 +107,8 @@ function plc.new_session(id, s_addr, reactor_id, in_queue, out_queue, timeout, f formed = false, rps_tripped = false, rps_trip_cause = "ok", ---@type rps_trip_cause + max_op_temp_H2O = 1200, + max_op_temp_Na = 1200, ---@class rps_status rps_status = { high_dmg = false, @@ -138,11 +142,11 @@ function plc.new_session(id, s_addr, reactor_id, in_queue, out_queue, timeout, f waste = 0, waste_need = 0, waste_fill = 0.0, - ccool_type = "?", + ccool_type = types.FLUID.EMPTY_GAS, ---@type fluid ccool_amnt = 0, ccool_need = 0, ccool_fill = 0.0, - hcool_type = "?", + hcool_type = types.FLUID.EMPTY_GAS, ---@type fluid hcool_amnt = 0, hcool_need = 0, hcool_fill = 0.0 @@ -169,74 +173,129 @@ function plc.new_session(id, s_addr, reactor_id, in_queue, out_queue, timeout, f ---@class plc_session local public = {} + -- compute maximum expected operational temperatures for high temp warnings + local function _compute_op_temps() + local JOULES_PER_MB = const.mek.JOULES_PER_MB + local BASE_BOIL_TEMP = const.mek.BASE_BOIL_TEMP + + local heat_cap = self.sDB.mek_struct.heat_cap + local max_burn = self.sDB.mek_struct.max_burn + + self.sDB.max_op_temp_H2O = max_burn * 2 * (JOULES_PER_MB * heat_cap ^ -1) + BASE_BOIL_TEMP + self.sDB.max_op_temp_Na = max_burn * (JOULES_PER_MB * heat_cap ^ -1) + BASE_BOIL_TEMP + + log.info(util.sprintf(log_header .. "computed maximum operational temperatures %.3fK (H2O) and %.3fK (Na)", + self.sDB.max_op_temp_H2O, self.sDB.max_op_temp_Na)) + end + -- copy in the RPS status ---@param rps_status table local function _copy_rps_status(rps_status) - self.sDB.rps_tripped = rps_status[1] - self.sDB.rps_trip_cause = rps_status[2] - self.sDB.rps_status.high_dmg = rps_status[3] - self.sDB.rps_status.high_temp = rps_status[4] - self.sDB.rps_status.low_cool = rps_status[5] - self.sDB.rps_status.ex_waste = rps_status[6] - self.sDB.rps_status.ex_hcool = rps_status[7] - self.sDB.rps_status.no_fuel = rps_status[8] - self.sDB.rps_status.fault = rps_status[9] - self.sDB.rps_status.timeout = rps_status[10] - self.sDB.rps_status.manual = rps_status[11] - self.sDB.rps_status.automatic = rps_status[12] - self.sDB.rps_status.sys_fail = rps_status[13] - self.sDB.rps_status.force_dis = rps_status[14] + local rps = self.sDB.rps_status + + self.sDB.rps_tripped = rps_status[1] + self.sDB.rps_trip_cause = rps_status[2] + + rps.high_dmg = rps_status[3] + rps.high_temp = rps_status[4] + rps.low_cool = rps_status[5] + rps.ex_waste = rps_status[6] + rps.ex_hcool = rps_status[7] + rps.no_fuel = rps_status[8] + rps.fault = rps_status[9] + rps.timeout = rps_status[10] + rps.manual = rps_status[11] + rps.automatic = rps_status[12] + rps.sys_fail = rps_status[13] + rps.force_dis = rps_status[14] end -- copy in the reactor status ---@param mek_data table local function _copy_status(mek_data) + local stat = self.sDB.mek_status + local struct = self.sDB.mek_struct + -- copy status information - self.sDB.mek_status.status = mek_data[1] - self.sDB.mek_status.burn_rate = mek_data[2] - self.sDB.mek_status.act_burn_rate = mek_data[3] - self.sDB.mek_status.temp = mek_data[4] - self.sDB.mek_status.damage = mek_data[5] - self.sDB.mek_status.boil_eff = mek_data[6] - self.sDB.mek_status.env_loss = mek_data[7] + stat.status = mek_data[1] + stat.burn_rate = mek_data[2] + stat.act_burn_rate = mek_data[3] + stat.temp = mek_data[4] + stat.damage = mek_data[5] + stat.boil_eff = mek_data[6] + stat.env_loss = mek_data[7] -- copy container information - self.sDB.mek_status.fuel = mek_data[8] - self.sDB.mek_status.fuel_fill = mek_data[9] - self.sDB.mek_status.waste = mek_data[10] - self.sDB.mek_status.waste_fill = mek_data[11] - self.sDB.mek_status.ccool_type = mek_data[12] - self.sDB.mek_status.ccool_amnt = mek_data[13] - self.sDB.mek_status.ccool_fill = mek_data[14] - self.sDB.mek_status.hcool_type = mek_data[15] - self.sDB.mek_status.hcool_amnt = mek_data[16] - self.sDB.mek_status.hcool_fill = mek_data[17] + stat.fuel = mek_data[8] + stat.fuel_fill = mek_data[9] + stat.waste = mek_data[10] + stat.waste_fill = mek_data[11] + stat.ccool_type = mek_data[12] + stat.ccool_amnt = mek_data[13] + stat.ccool_fill = mek_data[14] + stat.hcool_type = mek_data[15] + stat.hcool_amnt = mek_data[16] + stat.hcool_fill = mek_data[17] -- update computable fields if we have our structure if self.received_struct then - self.sDB.mek_status.fuel_need = self.sDB.mek_struct.fuel_cap - self.sDB.mek_status.fuel_fill - self.sDB.mek_status.waste_need = self.sDB.mek_struct.waste_cap - self.sDB.mek_status.waste_fill - self.sDB.mek_status.cool_need = self.sDB.mek_struct.ccool_cap - self.sDB.mek_status.ccool_fill - self.sDB.mek_status.hcool_need = self.sDB.mek_struct.hcool_cap - self.sDB.mek_status.hcool_fill + stat.fuel_need = struct.fuel_cap - stat.fuel_fill + stat.waste_need = struct.waste_cap - stat.waste_fill + stat.cool_need = struct.ccool_cap - stat.ccool_fill + stat.hcool_need = struct.hcool_cap - stat.hcool_fill end end -- copy in the reactor structure ---@param mek_data table local function _copy_struct(mek_data) - self.sDB.mek_struct.length = mek_data[1] - self.sDB.mek_struct.width = mek_data[2] - self.sDB.mek_struct.height = mek_data[3] - self.sDB.mek_struct.min_pos = mek_data[4] - self.sDB.mek_struct.max_pos = mek_data[5] - self.sDB.mek_struct.heat_cap = mek_data[6] - self.sDB.mek_struct.fuel_asm = mek_data[7] - self.sDB.mek_struct.fuel_sa = mek_data[8] - self.sDB.mek_struct.fuel_cap = mek_data[9] - self.sDB.mek_struct.waste_cap = mek_data[10] - self.sDB.mek_struct.ccool_cap = mek_data[11] - self.sDB.mek_struct.hcool_cap = mek_data[12] - self.sDB.mek_struct.max_burn = mek_data[13] + local struct = self.sDB.mek_struct + + struct.length = mek_data[1] + struct.width = mek_data[2] + struct.height = mek_data[3] + struct.min_pos = mek_data[4] + struct.max_pos = mek_data[5] + struct.heat_cap = mek_data[6] + struct.fuel_asm = mek_data[7] + struct.fuel_sa = mek_data[8] + struct.fuel_cap = mek_data[9] + struct.waste_cap = mek_data[10] + struct.ccool_cap = mek_data[11] + struct.hcool_cap = mek_data[12] + struct.max_burn = mek_data[13] + end + + -- handle a reactor status packet + ---@param pkt rplc_frame + local function _handle_status(pkt) + local valid = (type(pkt.data[1]) == "number") and (type(pkt.data[2]) == "boolean") and + (type(pkt.data[3]) == "boolean") and (type(pkt.data[4]) == "boolean") and + (type(pkt.data[5]) == "number") + + if valid then + self.sDB.last_status_update = pkt.data[1] + self.sDB.control_state = pkt.data[2] + self.sDB.no_reactor = pkt.data[3] + self.sDB.formed = pkt.data[4] + self.sDB.auto_ack_token = pkt.data[5] + + if (not self.sDB.no_reactor) and self.sDB.formed and (type(pkt.data[6]) == "number") then + self.sDB.mek_status.heating_rate = pkt.data[6] or 0.0 + + -- attempt to read mek_data table + if type(pkt.data[7]) == "table" then + if #pkt.data[7] == 17 then + _copy_status(pkt.data[7]) + self.received_status_cache = true + else + log.error(log_header .. "RPLC status packet reactor data length mismatch") + end + end + end + else + log.debug(log_header .. "RPLC status packet invalid") + end end -- mark this PLC session as closed, stop watchdog @@ -291,13 +350,11 @@ function plc.new_session(id, s_addr, reactor_id, in_queue, out_queue, timeout, f ---@param pkt mgmt_frame|rplc_frame local function _handle_packet(pkt) -- check sequence number - if self.r_seq_num == nil then - self.r_seq_num = pkt.scada_frame.seq_num() - elseif (self.r_seq_num + 1) ~= pkt.scada_frame.seq_num() then + if self.r_seq_num ~= pkt.scada_frame.seq_num() then log.warning(log_header .. "sequence out-of-order: last = " .. self.r_seq_num .. ", new = " .. pkt.scada_frame.seq_num()) return else - self.r_seq_num = pkt.scada_frame.seq_num() + self.r_seq_num = pkt.scada_frame.seq_num() + 1 end -- process packet @@ -316,47 +373,17 @@ function plc.new_session(id, s_addr, reactor_id, in_queue, out_queue, timeout, f if pkt.type == RPLC_TYPE.STATUS then -- status packet received, update data if pkt.length >= 5 then - if (type(pkt.data[1]) == "number") and (type(pkt.data[2]) == "boolean") and (type(pkt.data[3]) == "boolean") and - (type(pkt.data[4]) == "boolean") and (type(pkt.data[5]) == "number") then - self.sDB.last_status_update = pkt.data[1] - self.sDB.control_state = pkt.data[2] - self.sDB.no_reactor = pkt.data[3] - self.sDB.formed = pkt.data[4] - self.sDB.auto_ack_token = pkt.data[5] - - if (not self.sDB.no_reactor) and self.sDB.formed and (type(pkt.data[6]) == "number") then - self.sDB.mek_status.heating_rate = pkt.data[6] or 0.0 - - -- attempt to read mek_data table - if type(pkt.data[7]) == "table" then - local status = pcall(_copy_status, pkt.data[7]) - if status then - -- copied in status data OK - self.received_status_cache = true - else - -- error copying status data - log.error(log_header .. "failed to parse status packet data") - end - end - end - else - log.debug(log_header .. "RPLC status packet invalid") - end + _handle_status(pkt) else log.debug(log_header .. "RPLC status packet length mismatch") end elseif pkt.type == RPLC_TYPE.MEK_STRUCT then -- received reactor structure, record it - if pkt.length == 14 then - local status = pcall(_copy_struct, pkt.data) - if status then - -- copied in structure data OK - self.received_struct = true - out_queue.push_data(svqtypes.SV_Q_DATA.PLC_BUILD_CHANGED, reactor_id) - else - -- error copying structure data - log.error(log_header .. "failed to parse struct packet data") - end + if pkt.length == 13 then + _copy_struct(pkt.data) + _compute_op_temps() + self.received_struct = true + out_queue.push_data(svqtypes.SV_Q_DATA.PLC_BUILD_CHANGED, reactor_id) else log.debug(log_header .. "RPLC struct packet length mismatch") end @@ -639,6 +666,7 @@ function plc.new_session(id, s_addr, reactor_id, in_queue, out_queue, timeout, f local cmd = message.message if cmd == PLC_S_CMDS.ENABLE then -- enable reactor + self.acks.disable = true if not self.auto_lock then _send(RPLC_TYPE.RPS_ENABLE, {}) end @@ -695,6 +723,7 @@ function plc.new_session(id, s_addr, reactor_id, in_queue, out_queue, timeout, f self.auto_cmd_token = 0 self.ramping_rate = true self.acks.burn_rate = false + self.acks.disable = true self.retry_times.burn_rate_req = util.time() + INITIAL_WAIT _send(RPLC_TYPE.MEK_BURN_RATE, { self.commanded_burn_rate, self.ramping_rate }) end @@ -702,13 +731,14 @@ function plc.new_session(id, s_addr, reactor_id, in_queue, out_queue, timeout, f elseif cmd.key == PLC_S_DATA.AUTO_BURN_RATE then -- set automatic burn rate if self.auto_lock then - cmd.val = math.floor(cmd.val * 100) / 100 -- round to 100ths place + cmd.val = math.floor(cmd.val * 100) / 100 -- round to 100ths place if cmd.val >= 0 and cmd.val <= self.sDB.mek_struct.max_burn then self.auto_cmd_token = util.time_ms() self.commanded_burn_rate = cmd.val -- this is only for manual control, only retry auto ramps self.acks.burn_rate = not self.ramping_rate + self.acks.disable = true self.retry_times.burn_rate_req = util.time() + INITIAL_AUTO_WAIT _send(RPLC_TYPE.AUTO_BURN_RATE, { self.commanded_burn_rate, self.ramping_rate, self.auto_cmd_token }) diff --git a/supervisor/session/pocket.lua b/supervisor/session/pocket.lua index 48756ea..94a4467 100644 --- a/supervisor/session/pocket.lua +++ b/supervisor/session/pocket.lua @@ -30,12 +30,13 @@ local PERIODICS = { ---@nodiscard ---@param id integer session ID ---@param s_addr integer device source address +---@param i_seq_num integer initial sequence number ---@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, facility, fp_ok) +function pocket.new_session(id, s_addr, i_seq_num, 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 @@ -43,8 +44,8 @@ function pocket.new_session(id, s_addr, in_queue, out_queue, timeout, facility, local self = { -- connection properties - seq_num = 0, - r_seq_num = nil, + seq_num = i_seq_num + 2, -- next after the establish approval was sent + r_seq_num = i_seq_num + 1, connected = true, conn_watchdog = util.new_watchdog(timeout), last_rtt = 0, @@ -93,13 +94,11 @@ function pocket.new_session(id, s_addr, in_queue, out_queue, timeout, facility, ---@param pkt mgmt_frame local function _handle_packet(pkt) -- check sequence number - if self.r_seq_num == nil then - self.r_seq_num = pkt.scada_frame.seq_num() - elseif (self.r_seq_num + 1) ~= pkt.scada_frame.seq_num() then + if self.r_seq_num ~= pkt.scada_frame.seq_num() then log.warning(log_header .. "sequence out-of-order: last = " .. self.r_seq_num .. ", new = " .. pkt.scada_frame.seq_num()) return else - self.r_seq_num = pkt.scada_frame.seq_num() + self.r_seq_num = pkt.scada_frame.seq_num() + 1 end -- feed watchdog diff --git a/supervisor/session/rsctl.lua b/supervisor/session/rsctl.lua index 1bdef5a..a369937 100644 --- a/supervisor/session/rsctl.lua +++ b/supervisor/session/rsctl.lua @@ -2,6 +2,8 @@ -- Redstone RTU Session I/O Controller -- +local rsio = require("scada-common.rsio") + local rsctl = {} -- create a new redstone RTU I/O controller @@ -16,7 +18,7 @@ function rsctl.new(redstone_rtus) ---@return boolean function public.is_connected(port) for i = 1, #redstone_rtus do - local db = redstone_rtus[i].get_db() ---@type redstone_session_db + local db = redstone_rtus[i].get_db() ---@type redstone_session_db if db.io[port] ~= nil then return true end end @@ -28,8 +30,8 @@ function rsctl.new(redstone_rtus) ---@param value boolean function public.digital_write(port, value) for i = 1, #redstone_rtus do - local db = redstone_rtus[i].get_db() ---@type redstone_session_db - local io = db.io[port] ---@type rs_db_dig_io|nil + local db = redstone_rtus[i].get_db() ---@type redstone_session_db + local io = db.io[port] ---@type rs_db_dig_io|nil if io ~= nil then io.write(value) end end end @@ -40,12 +42,25 @@ function rsctl.new(redstone_rtus) ---@return boolean|nil function public.digital_read(port) for i = 1, #redstone_rtus do - local db = redstone_rtus[i].get_db() ---@type redstone_session_db - local io = db.io[port] ---@type rs_db_dig_io|nil + local db = redstone_rtus[i].get_db() ---@type redstone_session_db + local io = db.io[port] ---@type rs_db_dig_io|nil if io ~= nil then return io.read() end end end + -- write to an analog redstone port (applies to all RTUs) + ---@param port IO_PORT + ---@param value number value + ---@param min number minimum value for scaling 0 to 15 + ---@param max number maximum value for scaling 0 to 15 + function public.analog_write(port, value, min, max) + for i = 1, #redstone_rtus do + local db = redstone_rtus[i].get_db() ---@type redstone_session_db + local io = db.io[port] ---@type rs_db_ana_io|nil + if io ~= nil then io.write(rsio.analog_write(value, min, max)) end + end + end + return public end diff --git a/supervisor/session/rtu.lua b/supervisor/session/rtu.lua index 6bebb87..756ee01 100644 --- a/supervisor/session/rtu.lua +++ b/supervisor/session/rtu.lua @@ -34,13 +34,14 @@ local PERIODICS = { ---@nodiscard ---@param id integer session ID ---@param s_addr integer device source address +---@param i_seq_num integer initial sequence number ---@param in_queue mqueue in message queue ---@param out_queue mqueue out message queue ---@param timeout number communications timeout ---@param advertisement table RTU device advertisement ---@param facility facility facility data table ---@param fp_ok boolean if the front panel UI is running -function rtu.new_session(id, s_addr, in_queue, out_queue, timeout, advertisement, facility, fp_ok) +function rtu.new_session(id, s_addr, i_seq_num, in_queue, out_queue, timeout, advertisement, 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 @@ -51,8 +52,8 @@ function rtu.new_session(id, s_addr, in_queue, out_queue, timeout, advertisement advert = advertisement, fac_units = facility.get_units(), -- connection properties - seq_num = 0, - r_seq_num = nil, + seq_num = i_seq_num + 2, -- next after the establish approval was sent + r_seq_num = i_seq_num + 1, connected = true, conn_watchdog = util.new_watchdog(timeout), last_rtt = 0, @@ -240,13 +241,11 @@ function rtu.new_session(id, s_addr, in_queue, out_queue, timeout, advertisement ---@param pkt modbus_frame|mgmt_frame local function _handle_packet(pkt) -- check sequence number - if self.r_seq_num == nil then - self.r_seq_num = pkt.scada_frame.seq_num() - elseif (self.r_seq_num + 1) ~= pkt.scada_frame.seq_num() then + if self.r_seq_num ~= pkt.scada_frame.seq_num() then log.warning(log_header .. "sequence out-of-order: last = " .. self.r_seq_num .. ", new = " .. pkt.scada_frame.seq_num()) return else - self.r_seq_num = pkt.scada_frame.seq_num() + self.r_seq_num = pkt.scada_frame.seq_num() + 1 end -- feed watchdog diff --git a/supervisor/session/svsessions.lua b/supervisor/session/svsessions.lua index 7300162..002dd56 100644 --- a/supervisor/session/svsessions.lua +++ b/supervisor/session/svsessions.lua @@ -273,11 +273,12 @@ end -- establish a new PLC session ---@nodiscard ----@param source_addr integer ----@param for_reactor integer ----@param version string +---@param source_addr integer PLC computer ID +---@param i_seq_num integer initial (most recent) sequence number +---@param for_reactor integer unit ID +---@param version string PLC version ---@return integer|false session_id -function svsessions.establish_plc_session(source_addr, for_reactor, version) +function svsessions.establish_plc_session(source_addr, i_seq_num, for_reactor, version) if svsessions.get_reactor_session(for_reactor) == nil and for_reactor >= 1 and for_reactor <= self.config.UnitCount then ---@class plc_session_struct local plc_s = { @@ -294,7 +295,7 @@ function svsessions.establish_plc_session(source_addr, for_reactor, version) local id = self.next_ids.plc - plc_s.instance = plc.new_session(id, source_addr, for_reactor, plc_s.in_queue, plc_s.out_queue, self.config.PLC_Timeout, self.fp_ok) + plc_s.instance = plc.new_session(id, source_addr, i_seq_num, for_reactor, plc_s.in_queue, plc_s.out_queue, self.config.PLC_Timeout, self.fp_ok) table.insert(self.sessions.plc, plc_s) local units = self.facility.get_units() @@ -320,13 +321,14 @@ function svsessions.establish_plc_session(source_addr, for_reactor, version) end end --- establish a new RTU session +-- establish a new RTU gateway session ---@nodiscard ----@param source_addr integer ----@param advertisement table ----@param version string +---@param source_addr integer RTU gateway computer ID +---@param i_seq_num integer initial (most recent) sequence number +---@param advertisement table RTU capability advertisement +---@param version string RTU gateway version ---@return integer session_id -function svsessions.establish_rtu_session(source_addr, advertisement, version) +function svsessions.establish_rtu_session(source_addr, i_seq_num, advertisement, version) ---@class rtu_session_struct local rtu_s = { s_type = "rtu", @@ -341,7 +343,7 @@ function svsessions.establish_rtu_session(source_addr, advertisement, version) local id = self.next_ids.rtu - rtu_s.instance = rtu.new_session(id, source_addr, rtu_s.in_queue, rtu_s.out_queue, self.config.RTU_Timeout, advertisement, self.facility, self.fp_ok) + rtu_s.instance = rtu.new_session(id, source_addr, i_seq_num, rtu_s.in_queue, rtu_s.out_queue, self.config.RTU_Timeout, advertisement, self.facility, self.fp_ok) table.insert(self.sessions.rtu, rtu_s) local mt = { @@ -362,10 +364,11 @@ end -- establish a new coordinator session ---@nodiscard ----@param source_addr integer ----@param version string +---@param source_addr integer coordinator computer ID +---@param i_seq_num integer initial (most recent) sequence number +---@param version string coordinator version ---@return integer|false session_id -function svsessions.establish_crd_session(source_addr, version) +function svsessions.establish_crd_session(source_addr, i_seq_num, version) if svsessions.get_crd_session() == nil then ---@class crd_session_struct local crd_s = { @@ -381,7 +384,7 @@ function svsessions.establish_crd_session(source_addr, version) local id = self.next_ids.crd - crd_s.instance = coordinator.new_session(id, source_addr, crd_s.in_queue, crd_s.out_queue, self.config.CRD_Timeout, self.facility, self.fp_ok) + crd_s.instance = coordinator.new_session(id, source_addr, i_seq_num, crd_s.in_queue, crd_s.out_queue, self.config.CRD_Timeout, self.facility, self.fp_ok) table.insert(self.sessions.crd, crd_s) local mt = { @@ -406,10 +409,11 @@ end -- establish a new pocket diagnostics session ---@nodiscard ----@param source_addr integer ----@param version string +---@param source_addr integer pocket computer ID +---@param i_seq_num integer initial (most recent) sequence number +---@param version string pocket version ---@return integer|false session_id -function svsessions.establish_pdg_session(source_addr, version) +function svsessions.establish_pdg_session(source_addr, i_seq_num, version) ---@class pdg_session_struct local pdg_s = { s_type = "pkt", @@ -424,7 +428,7 @@ 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, self.config.PKT_Timeout, self.facility, self.fp_ok) + pdg_s.instance = pocket.new_session(id, source_addr, i_seq_num, pdg_s.in_queue, pdg_s.out_queue, self.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 1694ebe..edc60f1 100644 --- a/supervisor/startup.lua +++ b/supervisor/startup.lua @@ -21,7 +21,7 @@ local supervisor = require("supervisor.supervisor") local svsessions = require("supervisor.session.svsessions") -local SUPERVISOR_VERSION = "v1.3.6" +local SUPERVISOR_VERSION = "v1.4.0" local println = util.println local println_ts = util.println_ts @@ -214,7 +214,7 @@ local function main() elseif event == "modem_message" then -- got a packet local packet = superv_comms.parse_packet(param1, param2, param3, param4, param5) - superv_comms.handle_packet(packet) + if packet then superv_comms.handle_packet(packet) end elseif event == "mouse_click" or event == "mouse_up" or event == "mouse_drag" or event == "mouse_scroll" or event == "double_click" then -- handle a mouse event diff --git a/supervisor/supervisor.lua b/supervisor/supervisor.lua index 1d79e72..69a98f5 100644 --- a/supervisor/supervisor.lua +++ b/supervisor/supervisor.lua @@ -191,283 +191,282 @@ function supervisor.comms(_version, nic, fp_ok) end -- handle a packet - ---@param packet modbus_frame|rplc_frame|mgmt_frame|crdn_frame|nil + ---@param packet modbus_frame|rplc_frame|mgmt_frame|crdn_frame function public.handle_packet(packet) - if packet ~= nil then - local l_chan = packet.scada_frame.local_channel() - local r_chan = packet.scada_frame.remote_channel() - local src_addr = packet.scada_frame.src_addr() - local protocol = packet.scada_frame.protocol() + local l_chan = packet.scada_frame.local_channel() + local r_chan = packet.scada_frame.remote_channel() + local src_addr = packet.scada_frame.src_addr() + local protocol = packet.scada_frame.protocol() + local i_seq_num = packet.scada_frame.seq_num() - if l_chan ~= config.SVR_Channel then - log.debug("received packet on unconfigured channel " .. l_chan, true) - elseif r_chan == config.PLC_Channel then - -- look for an associated session - local session = svsessions.find_plc_session(src_addr) + if l_chan ~= config.SVR_Channel then + log.debug("received packet on unconfigured channel " .. l_chan, true) + elseif r_chan == config.PLC_Channel then + -- look for an associated session + local session = svsessions.find_plc_session(src_addr) - if protocol == PROTOCOL.RPLC then - ---@cast packet rplc_frame - -- reactor PLC packet - if session ~= nil then - -- pass the packet onto the session handler - session.in_queue.push_packet(packet) - else - -- any other packet should be session related, discard it - log.debug("discarding RPLC packet without a known session") - end - elseif protocol == PROTOCOL.SCADA_MGMT then - ---@cast packet mgmt_frame - -- SCADA management packet - if session ~= nil then - -- pass the packet onto the session handler - session.in_queue.push_packet(packet) - elseif packet.type == MGMT_TYPE.ESTABLISH then - -- establish a new session - local last_ack = self.last_est_acks[src_addr] - - -- validate packet and continue - if packet.length >= 3 and type(packet.data[1]) == "string" and type(packet.data[2]) == "string" then - local comms_v = packet.data[1] - local firmware_v = packet.data[2] - local dev_type = packet.data[3] - - if comms_v ~= comms.version then - if last_ack ~= ESTABLISH_ACK.BAD_VERSION then - log.info(util.c("dropping PLC establish packet with incorrect comms version v", comms_v, " (expected v", comms.version, ")")) - end - - _send_establish(packet.scada_frame, ESTABLISH_ACK.BAD_VERSION) - elseif dev_type == DEVICE_TYPE.PLC then - -- PLC linking request - if packet.length == 4 and type(packet.data[4]) == "number" then - local reactor_id = packet.data[4] - local plc_id = svsessions.establish_plc_session(src_addr, reactor_id, firmware_v) - - if plc_id == false then - -- reactor already has a PLC assigned - if last_ack ~= ESTABLISH_ACK.COLLISION then - log.warning(util.c("PLC_ESTABLISH: assignment collision with reactor ", reactor_id)) - end - - _send_establish(packet.scada_frame, ESTABLISH_ACK.COLLISION) - else - -- got an ID; assigned to a reactor successfully - println(util.c("PLC (", firmware_v, ") [@", src_addr, "] \xbb reactor ", reactor_id, " connected")) - log.info(util.c("PLC_ESTABLISH: PLC (", firmware_v, ") [@", src_addr, "] reactor unit ", reactor_id, " PLC connected with session ID ", plc_id)) - _send_establish(packet.scada_frame, ESTABLISH_ACK.ALLOW) - end - else - log.debug("PLC_ESTABLISH: packet length mismatch/bad parameter type") - _send_establish(packet.scada_frame, ESTABLISH_ACK.DENY) - end - else - log.debug(util.c("illegal establish packet for device ", dev_type, " on PLC channel")) - _send_establish(packet.scada_frame, ESTABLISH_ACK.DENY) - end - else - log.debug("invalid establish packet (on PLC channel)") - _send_establish(packet.scada_frame, ESTABLISH_ACK.DENY) - end - else - -- any other packet should be session related, discard it - log.debug(util.c("discarding PLC SCADA_MGMT packet without a known session from computer ", src_addr)) - end + if protocol == PROTOCOL.RPLC then + ---@cast packet rplc_frame + -- reactor PLC packet + if session ~= nil then + -- pass the packet onto the session handler + session.in_queue.push_packet(packet) else - log.debug(util.c("illegal packet type ", protocol, " on PLC channel")) + -- any other packet should be session related, discard it + log.debug("discarding RPLC packet without a known session") end - elseif r_chan == config.RTU_Channel then - -- look for an associated session - local session = svsessions.find_rtu_session(src_addr) + elseif protocol == PROTOCOL.SCADA_MGMT then + ---@cast packet mgmt_frame + -- SCADA management packet + if session ~= nil then + -- pass the packet onto the session handler + session.in_queue.push_packet(packet) + elseif packet.type == MGMT_TYPE.ESTABLISH then + -- establish a new session + local last_ack = self.last_est_acks[src_addr] - if protocol == PROTOCOL.MODBUS_TCP then - ---@cast packet modbus_frame - -- MODBUS response - if session ~= nil then - -- pass the packet onto the session handler - session.in_queue.push_packet(packet) - else - -- any other packet should be session related, discard it - log.debug("discarding MODBUS_TCP packet without a known session") - end - elseif protocol == PROTOCOL.SCADA_MGMT then - ---@cast packet mgmt_frame - -- SCADA management packet - if session ~= nil then - -- pass the packet onto the session handler - session.in_queue.push_packet(packet) - elseif packet.type == MGMT_TYPE.ESTABLISH then - -- establish a new session - local last_ack = self.last_est_acks[src_addr] + -- validate packet and continue + if packet.length >= 3 and type(packet.data[1]) == "string" and type(packet.data[2]) == "string" then + local comms_v = packet.data[1] + local firmware_v = packet.data[2] + local dev_type = packet.data[3] - -- validate packet and continue - if packet.length >= 3 and type(packet.data[1]) == "string" and type(packet.data[2]) == "string" then - local comms_v = packet.data[1] - local firmware_v = packet.data[2] - local dev_type = packet.data[3] - - if comms_v ~= comms.version then - if last_ack ~= ESTABLISH_ACK.BAD_VERSION then - log.info(util.c("dropping RTU establish packet with incorrect comms version v", comms_v, " (expected v", comms.version, ")")) - end - - _send_establish(packet.scada_frame, ESTABLISH_ACK.BAD_VERSION) - elseif dev_type == DEVICE_TYPE.RTU then - if packet.length == 4 then - -- this is an RTU advertisement for a new session - local rtu_advert = packet.data[4] - local s_id = svsessions.establish_rtu_session(src_addr, rtu_advert, firmware_v) - - println(util.c("RTU (", firmware_v, ") [@", src_addr, "] \xbb connected")) - log.info(util.c("RTU_ESTABLISH: RTU (",firmware_v, ") [@", src_addr, "] connected with session ID ", s_id)) - _send_establish(packet.scada_frame, ESTABLISH_ACK.ALLOW) - else - log.debug("RTU_ESTABLISH: packet length mismatch") - _send_establish(packet.scada_frame, ESTABLISH_ACK.DENY) - end - else - log.debug(util.c("illegal establish packet for device ", dev_type, " on RTU channel")) - _send_establish(packet.scada_frame, ESTABLISH_ACK.DENY) + if comms_v ~= comms.version then + if last_ack ~= ESTABLISH_ACK.BAD_VERSION then + log.info(util.c("dropping PLC establish packet with incorrect comms version v", comms_v, " (expected v", comms.version, ")")) end - else - log.debug("invalid establish packet (on RTU channel)") - _send_establish(packet.scada_frame, ESTABLISH_ACK.DENY) - end - else - -- any other packet should be session related, discard it - log.debug(util.c("discarding RTU SCADA_MGMT packet without a known session from computer ", src_addr)) - end - else - log.debug(util.c("illegal packet type ", protocol, " on RTU channel")) - end - elseif r_chan == config.CRD_Channel then - -- look for an associated session - local session = svsessions.find_crd_session(src_addr) - if protocol == PROTOCOL.SCADA_MGMT then - ---@cast packet mgmt_frame - -- SCADA management packet - if session ~= nil then - -- pass the packet onto the session handler - session.in_queue.push_packet(packet) - elseif packet.type == MGMT_TYPE.ESTABLISH then - -- establish a new session - local last_ack = self.last_est_acks[src_addr] + _send_establish(packet.scada_frame, ESTABLISH_ACK.BAD_VERSION) + elseif dev_type == DEVICE_TYPE.PLC then + -- PLC linking request + if packet.length == 4 and type(packet.data[4]) == "number" then + local reactor_id = packet.data[4] + local plc_id = svsessions.establish_plc_session(src_addr, i_seq_num, reactor_id, firmware_v) - -- validate packet and continue - if packet.length >= 3 and type(packet.data[1]) == "string" and type(packet.data[2]) == "string" then - local comms_v = packet.data[1] - local firmware_v = packet.data[2] - local dev_type = packet.data[3] - - if comms_v ~= comms.version then - if last_ack ~= ESTABLISH_ACK.BAD_VERSION then - log.info(util.c("dropping coordinator establish packet with incorrect comms version v", comms_v, " (expected v", comms.version, ")")) - end - - _send_establish(packet.scada_frame, ESTABLISH_ACK.BAD_VERSION) - elseif dev_type == DEVICE_TYPE.CRD then - -- this is an attempt to establish a new coordinator session - local s_id = svsessions.establish_crd_session(src_addr, firmware_v) - - if s_id ~= false then - 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, { config.UnitCount, cooling_conf }) - else + if plc_id == false then + -- reactor already has a PLC assigned if last_ack ~= ESTABLISH_ACK.COLLISION then - log.info("CRD_ESTABLISH: denied new coordinator [@" .. src_addr .. "] due to already being connected to another coordinator") + log.warning(util.c("PLC_ESTABLISH: assignment collision with reactor ", reactor_id)) end _send_establish(packet.scada_frame, ESTABLISH_ACK.COLLISION) + else + -- got an ID; assigned to a reactor successfully + println(util.c("PLC (", firmware_v, ") [@", src_addr, "] \xbb reactor ", reactor_id, " connected")) + log.info(util.c("PLC_ESTABLISH: PLC (", firmware_v, ") [@", src_addr, "] reactor unit ", reactor_id, " PLC connected with session ID ", plc_id)) + _send_establish(packet.scada_frame, ESTABLISH_ACK.ALLOW) end else - log.debug(util.c("illegal establish packet for device ", dev_type, " on coordinator channel")) + log.debug("PLC_ESTABLISH: packet length mismatch/bad parameter type") _send_establish(packet.scada_frame, ESTABLISH_ACK.DENY) end else - log.debug("CRD_ESTABLISH: establish packet length mismatch") + log.debug(util.c("illegal establish packet for device ", dev_type, " on PLC channel")) _send_establish(packet.scada_frame, ESTABLISH_ACK.DENY) end else - -- any other packet should be session related, discard it - log.debug(util.c("discarding coordinator SCADA_MGMT packet without a known session from computer ", src_addr)) - end - elseif protocol == PROTOCOL.SCADA_CRDN then - ---@cast packet crdn_frame - -- coordinator packet - if session ~= nil then - -- pass the packet onto the session handler - session.in_queue.push_packet(packet) - else - -- any other packet should be session related, discard it - log.debug(util.c("discarding coordinator SCADA_CRDN packet without a known session from computer ", src_addr)) + log.debug("invalid establish packet (on PLC channel)") + _send_establish(packet.scada_frame, ESTABLISH_ACK.DENY) end else - log.debug(util.c("illegal packet type ", protocol, " on coordinator channel")) - end - elseif r_chan == config.PKT_Channel then - -- look for an associated session - local session = svsessions.find_pdg_session(src_addr) - - if protocol == PROTOCOL.SCADA_MGMT then - ---@cast packet mgmt_frame - -- SCADA management packet - if session ~= nil then - -- pass the packet onto the session handler - session.in_queue.push_packet(packet) - elseif packet.type == MGMT_TYPE.ESTABLISH then - -- establish a new session - local last_ack = self.last_est_acks[src_addr] - - -- validate packet and continue - if packet.length >= 3 and type(packet.data[1]) == "string" and type(packet.data[2]) == "string" then - local comms_v = packet.data[1] - local firmware_v = packet.data[2] - local dev_type = packet.data[3] - - if comms_v ~= comms.version then - if last_ack ~= ESTABLISH_ACK.BAD_VERSION then - log.info(util.c("dropping PDG establish packet with incorrect comms version v", comms_v, " (expected v", comms.version, ")")) - end - - _send_establish(packet.scada_frame, ESTABLISH_ACK.BAD_VERSION) - elseif dev_type == DEVICE_TYPE.PKT then - -- this is an attempt to establish a new pocket diagnostic session - local s_id = svsessions.establish_pdg_session(src_addr, firmware_v) - - println(util.c("PKT (", firmware_v, ") [@", src_addr, "] \xbb connected")) - log.info(util.c("PDG_ESTABLISH: pocket (", firmware_v, ") [@", src_addr, "] connected with session ID ", s_id)) - - _send_establish(packet.scada_frame, ESTABLISH_ACK.ALLOW) - else - log.debug(util.c("illegal establish packet for device ", dev_type, " on pocket channel")) - _send_establish(packet.scada_frame, ESTABLISH_ACK.DENY) - end - else - log.debug("PDG_ESTABLISH: establish packet length mismatch") - _send_establish(packet.scada_frame, ESTABLISH_ACK.DENY) - end - else - -- any other packet should be session related, discard it - log.debug(util.c("discarding pocket SCADA_MGMT packet without a known session from computer ", src_addr)) - end - elseif protocol == PROTOCOL.SCADA_CRDN then - ---@cast packet crdn_frame - -- coordinator packet - if session ~= nil then - -- pass the packet onto the session handler - session.in_queue.push_packet(packet) - else - -- any other packet should be session related, discard it - log.debug(util.c("discarding pocket SCADA_CRDN packet without a known session from computer ", src_addr)) - end - else - log.debug(util.c("illegal packet type ", protocol, " on pocket channel")) + -- any other packet should be session related, discard it + log.debug(util.c("discarding PLC SCADA_MGMT packet without a known session from computer ", src_addr)) end else - log.debug("received packet for unknown channel " .. r_chan, true) + log.debug(util.c("illegal packet type ", protocol, " on PLC channel")) end + elseif r_chan == config.RTU_Channel then + -- look for an associated session + local session = svsessions.find_rtu_session(src_addr) + + if protocol == PROTOCOL.MODBUS_TCP then + ---@cast packet modbus_frame + -- MODBUS response + if session ~= nil then + -- pass the packet onto the session handler + session.in_queue.push_packet(packet) + else + -- any other packet should be session related, discard it + log.debug("discarding MODBUS_TCP packet without a known session") + end + elseif protocol == PROTOCOL.SCADA_MGMT then + ---@cast packet mgmt_frame + -- SCADA management packet + if session ~= nil then + -- pass the packet onto the session handler + session.in_queue.push_packet(packet) + elseif packet.type == MGMT_TYPE.ESTABLISH then + -- establish a new session + local last_ack = self.last_est_acks[src_addr] + + -- validate packet and continue + if packet.length >= 3 and type(packet.data[1]) == "string" and type(packet.data[2]) == "string" then + local comms_v = packet.data[1] + local firmware_v = packet.data[2] + local dev_type = packet.data[3] + + if comms_v ~= comms.version then + if last_ack ~= ESTABLISH_ACK.BAD_VERSION then + log.info(util.c("dropping RTU establish packet with incorrect comms version v", comms_v, " (expected v", comms.version, ")")) + end + + _send_establish(packet.scada_frame, ESTABLISH_ACK.BAD_VERSION) + elseif dev_type == DEVICE_TYPE.RTU then + if packet.length == 4 then + -- this is an RTU advertisement for a new session + local rtu_advert = packet.data[4] + local s_id = svsessions.establish_rtu_session(src_addr, i_seq_num, rtu_advert, firmware_v) + + println(util.c("RTU (", firmware_v, ") [@", src_addr, "] \xbb connected")) + log.info(util.c("RTU_ESTABLISH: RTU (",firmware_v, ") [@", src_addr, "] connected with session ID ", s_id)) + _send_establish(packet.scada_frame, ESTABLISH_ACK.ALLOW) + else + log.debug("RTU_ESTABLISH: packet length mismatch") + _send_establish(packet.scada_frame, ESTABLISH_ACK.DENY) + end + else + log.debug(util.c("illegal establish packet for device ", dev_type, " on RTU channel")) + _send_establish(packet.scada_frame, ESTABLISH_ACK.DENY) + end + else + log.debug("invalid establish packet (on RTU channel)") + _send_establish(packet.scada_frame, ESTABLISH_ACK.DENY) + end + else + -- any other packet should be session related, discard it + log.debug(util.c("discarding RTU SCADA_MGMT packet without a known session from computer ", src_addr)) + end + else + log.debug(util.c("illegal packet type ", protocol, " on RTU channel")) + end + elseif r_chan == config.CRD_Channel then + -- look for an associated session + local session = svsessions.find_crd_session(src_addr) + + if protocol == PROTOCOL.SCADA_MGMT then + ---@cast packet mgmt_frame + -- SCADA management packet + if session ~= nil then + -- pass the packet onto the session handler + session.in_queue.push_packet(packet) + elseif packet.type == MGMT_TYPE.ESTABLISH then + -- establish a new session + local last_ack = self.last_est_acks[src_addr] + + -- validate packet and continue + if packet.length >= 3 and type(packet.data[1]) == "string" and type(packet.data[2]) == "string" then + local comms_v = packet.data[1] + local firmware_v = packet.data[2] + local dev_type = packet.data[3] + + if comms_v ~= comms.version then + if last_ack ~= ESTABLISH_ACK.BAD_VERSION then + log.info(util.c("dropping coordinator establish packet with incorrect comms version v", comms_v, " (expected v", comms.version, ")")) + end + + _send_establish(packet.scada_frame, ESTABLISH_ACK.BAD_VERSION) + elseif dev_type == DEVICE_TYPE.CRD then + -- this is an attempt to establish a new coordinator session + local s_id = svsessions.establish_crd_session(src_addr, i_seq_num, firmware_v) + + if s_id ~= false then + 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, { config.UnitCount, 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") + end + + _send_establish(packet.scada_frame, ESTABLISH_ACK.COLLISION) + end + else + log.debug(util.c("illegal establish packet for device ", dev_type, " on coordinator channel")) + _send_establish(packet.scada_frame, ESTABLISH_ACK.DENY) + end + else + log.debug("CRD_ESTABLISH: establish packet length mismatch") + _send_establish(packet.scada_frame, ESTABLISH_ACK.DENY) + end + else + -- any other packet should be session related, discard it + log.debug(util.c("discarding coordinator SCADA_MGMT packet without a known session from computer ", src_addr)) + end + elseif protocol == PROTOCOL.SCADA_CRDN then + ---@cast packet crdn_frame + -- coordinator packet + if session ~= nil then + -- pass the packet onto the session handler + session.in_queue.push_packet(packet) + else + -- any other packet should be session related, discard it + log.debug(util.c("discarding coordinator SCADA_CRDN packet without a known session from computer ", src_addr)) + end + else + log.debug(util.c("illegal packet type ", protocol, " on coordinator channel")) + end + elseif r_chan == config.PKT_Channel then + -- look for an associated session + local session = svsessions.find_pdg_session(src_addr) + + if protocol == PROTOCOL.SCADA_MGMT then + ---@cast packet mgmt_frame + -- SCADA management packet + if session ~= nil then + -- pass the packet onto the session handler + session.in_queue.push_packet(packet) + elseif packet.type == MGMT_TYPE.ESTABLISH then + -- establish a new session + local last_ack = self.last_est_acks[src_addr] + + -- validate packet and continue + if packet.length >= 3 and type(packet.data[1]) == "string" and type(packet.data[2]) == "string" then + local comms_v = packet.data[1] + local firmware_v = packet.data[2] + local dev_type = packet.data[3] + + if comms_v ~= comms.version then + if last_ack ~= ESTABLISH_ACK.BAD_VERSION then + log.info(util.c("dropping PDG establish packet with incorrect comms version v", comms_v, " (expected v", comms.version, ")")) + end + + _send_establish(packet.scada_frame, ESTABLISH_ACK.BAD_VERSION) + elseif dev_type == DEVICE_TYPE.PKT then + -- this is an attempt to establish a new pocket diagnostic session + local s_id = svsessions.establish_pdg_session(src_addr, i_seq_num, firmware_v) + + println(util.c("PKT (", firmware_v, ") [@", src_addr, "] \xbb connected")) + log.info(util.c("PDG_ESTABLISH: pocket (", firmware_v, ") [@", src_addr, "] connected with session ID ", s_id)) + + _send_establish(packet.scada_frame, ESTABLISH_ACK.ALLOW) + else + log.debug(util.c("illegal establish packet for device ", dev_type, " on pocket channel")) + _send_establish(packet.scada_frame, ESTABLISH_ACK.DENY) + end + else + log.debug("PDG_ESTABLISH: establish packet length mismatch") + _send_establish(packet.scada_frame, ESTABLISH_ACK.DENY) + end + else + -- any other packet should be session related, discard it + log.debug(util.c("discarding pocket SCADA_MGMT packet without a known session from computer ", src_addr)) + end + elseif protocol == PROTOCOL.SCADA_CRDN then + ---@cast packet crdn_frame + -- coordinator packet + if session ~= nil then + -- pass the packet onto the session handler + session.in_queue.push_packet(packet) + else + -- any other packet should be session related, discard it + log.debug(util.c("discarding pocket SCADA_CRDN packet without a known session from computer ", src_addr)) + end + else + log.debug(util.c("illegal packet type ", protocol, " on pocket channel")) + end + else + log.debug("received packet for unknown channel " .. r_chan, true) end end diff --git a/supervisor/unit.lua b/supervisor/unit.lua index 6fa4d0a..bf3e8b1 100644 --- a/supervisor/unit.lua +++ b/supervisor/unit.lua @@ -71,8 +71,8 @@ function unit.new(reactor_id, num_boilers, num_turbines, ext_idle) ---@class _unit_self local self = { r_id = reactor_id, - plc_s = nil, ---@class plc_session_struct - plc_i = nil, ---@class plc_session + plc_s = nil, ---@type plc_session_struct + plc_i = nil, ---@type plc_session num_boilers = num_boilers, num_turbines = num_turbines, types = { DT_KEYS = DT_KEYS, AISTATE = AISTATE }, @@ -147,7 +147,8 @@ function unit.new(reactor_id, num_boilers, num_turbines, ext_idle) }, damage = 0, temp = 0, - waste = 0 + waste = 0, + high_temp_lim = 1150 }, ---@class alarm_monitors alarms = { @@ -163,7 +164,7 @@ function unit.new(reactor_id, num_boilers, num_turbines, ext_idle) ReactorDamage = { state = AISTATE.INACTIVE, trip_time = 0, hold_time = 0, id = ALARM.ReactorDamage, tier = PRIO.EMERGENCY }, -- reactor >1200K ReactorOverTemp = { state = AISTATE.INACTIVE, trip_time = 0, hold_time = 0, id = ALARM.ReactorOverTemp, tier = PRIO.URGENT }, - -- reactor >=1150K + -- reactor >= computed high temp limit ReactorHighTemp = { state = AISTATE.INACTIVE, trip_time = 0, hold_time = 1, id = ALARM.ReactorHighTemp, tier = PRIO.TIMELY }, -- waste = 100% ReactorWasteLeak = { state = AISTATE.INACTIVE, trip_time = 0, hold_time = 0, id = ALARM.ReactorWasteLeak, tier = PRIO.EMERGENCY }, @@ -975,7 +976,9 @@ function unit.new(reactor_id, num_boilers, num_turbines, ext_idle) self.db.control.ready, self.db.control.degraded, self.db.control.waste_mode, - self.waste_product + self.waste_product, + self.last_rate_change_ms, + self.turbine_flow_stable } end diff --git a/supervisor/unitlogic.lua b/supervisor/unitlogic.lua index ad2b522..20b00ff 100644 --- a/supervisor/unitlogic.lua +++ b/supervisor/unitlogic.lua @@ -39,21 +39,6 @@ local ALARM_LIMS = const.ALARM_LIMITS ---@class unit_logic_extension local logic = {} --- compute Mekanism's rotation rate for a turbine ----@param turbine turbinev_session_db -local function turbine_rotation(turbine) - local build = turbine.build - - local inner_vol = build.steam_cap / const.mek.TURBINE_GAS_PER_TANK - local disp_rate = (build.dispersers * const.mek.TURBINE_DISPERSER_FLOW) * inner_vol - local vent_rate = build.vents * const.mek.TURBINE_VENT_FLOW - - local max_rate = math.min(disp_rate, vent_rate) - local flow = math.min(max_rate, turbine.tanks.steam.amount) - - return (flow * (turbine.tanks.steam.amount / build.steam_cap)) / max_rate -end - -- update the annunciator ---@param self _unit_self function logic.update_annunciator(self) @@ -133,7 +118,15 @@ function logic.update_annunciator(self) self.last_heartbeat = plc_db.last_status_update end - local flow_low = util.trinary(plc_db.mek_status.ccool_type == types.FLUID.SODIUM, ANNUNC_LIMS.RCSFlowLow_NA, ANNUNC_LIMS.RCSFlowLow_H2O) + local flow_low = ANNUNC_LIMS.RCSFlowLow_H2O + local high_temp = plc_db.max_op_temp_H2O + + if plc_db.mek_status.ccool_type == types.FLUID.SODIUM then + flow_low = ANNUNC_LIMS.RCSFlowLow_NA + high_temp = plc_db.max_op_temp_Na + end + + self.plc_cache.high_temp_lim = math.min(high_temp + ANNUNC_LIMS.OpTempTolerance, 1200) -- update other annunciator fields annunc.ReactorSCRAM = plc_db.rps_tripped @@ -142,7 +135,7 @@ function logic.update_annunciator(self) annunc.RCPTrip = plc_db.rps_tripped and (plc_db.rps_status.ex_hcool or plc_db.rps_status.low_cool) annunc.RCSFlowLow = _get_dt(DT_KEYS.ReactorCCool) < flow_low annunc.CoolantLevelLow = plc_db.mek_status.ccool_fill < ANNUNC_LIMS.CoolantLevelLow - annunc.ReactorTempHigh = plc_db.mek_status.temp > ANNUNC_LIMS.ReactorTempHigh + annunc.ReactorTempHigh = plc_db.mek_status.temp >= self.plc_cache.high_temp_lim annunc.ReactorHighDeltaT = _get_dt(DT_KEYS.ReactorTemp) > ANNUNC_LIMS.ReactorHighDeltaT annunc.FuelInputRateLow = _get_dt(DT_KEYS.ReactorFuel) < -1.0 or plc_db.mek_status.fuel_fill <= ANNUNC_LIMS.FuelLevelLow annunc.WasteLineOcclusion = _get_dt(DT_KEYS.ReactorWaste) > 1.0 or plc_db.mek_status.waste_fill >= ANNUNC_LIMS.WasteLevelHigh @@ -325,7 +318,7 @@ function logic.update_annunciator(self) local last = self.turbine_stability_data[i] if (not self.turbine_flow_stable) and (turbine.state.steam_input_rate > 0) then - local rotation = turbine_rotation(turbine) + local rotation = util.turbine_rotation(turbine) local rotation_stable = false -- see if data updated, and if so, check rotation speed change @@ -542,7 +535,8 @@ function logic.update_alarms(self) end -- High Temperature - _update_alarm_state(self, plc_cache.temp >= ALARM_LIMS.HIGH_TEMP, self.alarms.ReactorHighTemp) + local high_temp = math.min(math.max(self.plc_cache.high_temp_lim, 1100), 1199.995) + _update_alarm_state(self, plc_cache.temp >= high_temp, self.alarms.ReactorHighTemp) -- Waste Leak _update_alarm_state(self, plc_cache.waste >= 1.0, self.alarms.ReactorWasteLeak) @@ -718,11 +712,11 @@ function logic.update_status_text(self) self.status_text[2] = "elevated level of radiation" end elseif is_active(self.alarms.ReactorOverTemp) then - self.status_text = { "CORE OVER TEMP", "reactor core temperature >=1200K" } + self.status_text = { "CORE OVER TEMP", "reactor core temp damaging" } elseif is_active(self.alarms.ReactorWasteLeak) then self.status_text = { "WASTE LEAK", "radioactive waste leak detected" } elseif is_active(self.alarms.ReactorHighTemp) then - self.status_text = { "CORE TEMP HIGH", "reactor core temperature >1150K" } + self.status_text = { "CORE TEMP HIGH", "reactor core temperature high" } elseif is_active(self.alarms.ReactorHighWaste) then self.status_text = { "WASTE LEVEL HIGH", "waste accumulating in reactor" } elseif is_active(self.alarms.TurbineTrip) then diff --git a/test/rstest.lua b/test/rstest.lua index e322e28..612d0d8 100644 --- a/test/rstest.lua +++ b/test/rstest.lua @@ -1,16 +1,28 @@ require("/initenv").init_env() -local rsio = require("scada-common.rsio") -local util = require("scada-common.util") +local rsio = require("scada-common.rsio") +local util = require("scada-common.util") local testutils = require("test.testutils") +local IO = rsio.IO +local IO_LVL = rsio.IO_LVL +local IO_MODE = rsio.IO_MODE + local print = util.print local println = util.println -local IO = rsio.IO -local IO_LVL = rsio.IO_LVL -local IO_MODE = rsio.IO_MODE +-- list of inverted digital signals
+-- only using the key for a quick lookup, value just can't be nil +local DIG_INV = { + [IO.F_SCRAM] = 0, + [IO.R_SCRAM] = 0, + [IO.WASTE_PU] = 0, + [IO.WASTE_PO] = 0, + [IO.WASTE_POPL] = 0, + [IO.WASTE_AM] = 0, + [IO.U_EMER_COOL] = 0 +} println("starting RSIO tester") println("") @@ -50,8 +62,8 @@ testutils.pause() println(">>> checking invalid ports:") -testutils.test_func("rsio.to_string", rsio.to_string, { -1, 100, false }, "") -testutils.test_func_nil("rsio.to_string", rsio.to_string, "") +testutils.test_func("rsio.to_string", rsio.to_string, { -1, 100, false }, "UNKNOWN") +testutils.test_func_nil("rsio.to_string", rsio.to_string, "UNKNOWN") testutils.test_func("rsio.get_io_mode", rsio.get_io_mode, { -1, 100, false }, IO_MODE.ANALOG_IN) testutils.test_func_nil("rsio.get_io_mode", rsio.get_io_mode, IO_MODE.ANALOG_IN) @@ -100,46 +112,35 @@ println(">>> checking port I/O:") print("rsio.digital_is_active(...): ") --- check input ports -assert(rsio.digital_is_active(IO.F_SCRAM, IO_LVL.LOW) == true, "IO_F_SCRAM_HIGH") -assert(rsio.digital_is_active(IO.F_SCRAM, IO_LVL.HIGH) == false, "IO_F_SCRAM_LOW") -assert(rsio.digital_is_active(IO.R_SCRAM, IO_LVL.LOW) == true, "IO_R_SCRAM_HIGH") -assert(rsio.digital_is_active(IO.R_SCRAM, IO_LVL.HIGH) == false, "IO_R_SCRAM_LOW") -assert(rsio.digital_is_active(IO.R_ENABLE, IO_LVL.LOW) == false, "IO_R_ENABLE_HIGH") -assert(rsio.digital_is_active(IO.R_ENABLE, IO_LVL.HIGH) == true, "IO_R_ENABLE_LOW") +-- check all digital ports +for i = 1, rsio.NUM_PORTS do + if rsio.get_io_mode(i) == IO_MODE.DIGITAL_IN or rsio.get_io_mode(i) == IO_MODE.DIGITAL_OUT then + local high = DIG_INV[i] == nil + assert(rsio.digital_is_active(i, IO_LVL.LOW) == not high, "IO_" .. rsio.to_string(i) .. "_LOW") + assert(rsio.digital_is_active(i, IO_LVL.HIGH) == high, "IO_" .. rsio.to_string(i) .. "_HIGH") + end +end --- non-inputs should always return LOW -assert(rsio.digital_is_active(IO.F_ALARM, IO_LVL.LOW) == false, "IO_OUT_READ_LOW") -assert(rsio.digital_is_active(IO.F_ALARM, IO_LVL.HIGH) == false, "IO_OUT_READ_HIGH") +assert(rsio.digital_is_active(IO.F_MATRIX_CHG, IO_LVL.LOW) == nil, "ANA_DIG_READ_LOW") +assert(rsio.digital_is_active(IO.F_MATRIX_CHG, IO_LVL.HIGH) == nil, "ANA_DIG_READ_HIGH") println("PASS") --- check output ports +-- check digital write -print("rsio.digital_write(...): ") +print("rsio.digital_write_active(...): ") --- check output ports -assert(rsio.digital_write_active(IO.F_ALARM, true) == IO_LVL.LOW, "IO_F_ALARM_LOW") -assert(rsio.digital_write_active(IO.F_ALARM, true) == IO_LVL.HIGH, "IO_F_ALARM_HIGH") -assert(rsio.digital_write_active(IO.WASTE_PU, true) == IO_LVL.HIGH, "IO_WASTE_PU_HIGH") -assert(rsio.digital_write_active(IO.WASTE_PU, true) == IO_LVL.LOW, "IO_WASTE_PU_LOW") -assert(rsio.digital_write_active(IO.WASTE_PO, true) == IO_LVL.HIGH, "IO_WASTE_PO_HIGH") -assert(rsio.digital_write_active(IO.WASTE_PO, true) == IO_LVL.LOW, "IO_WASTE_PO_LOW") -assert(rsio.digital_write_active(IO.WASTE_POPL, true) == IO_LVL.HIGH, "IO_WASTE_POPL_HIGH") -assert(rsio.digital_write_active(IO.WASTE_POPL, true) == IO_LVL.LOW, "IO_WASTE_POPL_LOW") -assert(rsio.digital_write_active(IO.WASTE_AM, true) == IO_LVL.HIGH, "IO_WASTE_AM_HIGH") -assert(rsio.digital_write_active(IO.WASTE_AM, true) == IO_LVL.LOW, "IO_WASTE_AM_LOW") - --- check all reactor output ports (all are active high) -for i = IO.R_ALARM, (IO.R_PLC_TIMEOUT - IO.R_ALARM + 1) do - assert(rsio.to_string(i) ~= "", "REACTOR_IO_BAD_PORT") - assert(rsio.digital_write_active(i, false) == IO_LVL.LOW, "IO_" .. rsio.to_string(i) .. "_LOW") - assert(rsio.digital_write_active(i, true) == IO_LVL.HIGH, "IO_" .. rsio.to_string(i) .. "_HIGH") +-- check all digital ports +for i = 1, rsio.NUM_PORTS do + if rsio.get_io_mode(i) == IO_MODE.DIGITAL_IN or rsio.get_io_mode(i) == IO_MODE.DIGITAL_OUT then + local high = DIG_INV[i] == nil + assert(rsio.digital_write_active(i, not high) == IO_LVL.LOW, "IO_" .. rsio.to_string(i) .. "_LOW") + assert(rsio.digital_write_active(i, high) == IO_LVL.HIGH, "IO_" .. rsio.to_string(i) .. "_HIGH") + end end --- non-outputs should always return false -assert(rsio.digital_write_active(IO.F_SCRAM, false) == IO_LVL.LOW, "IO_IN_WRITE_FALSE") -assert(rsio.digital_write_active(IO.F_SCRAM, true) == IO_LVL.LOW, "IO_IN_WRITE_TRUE") +assert(rsio.digital_write_active(IO.F_MATRIX_CHG, true) == false, "ANA_DIG_WRITE_TRUE") +assert(rsio.digital_write_active(IO.F_MATRIX_CHG, false) == false, "ANA_DIG_WRITE_FALSE") println("PASS")