Merge pull request #317 from MikaylaFischler/devel

2023.08.22 Release
This commit is contained in:
Mikayla 2023-08-22 18:42:21 -04:00 committed by GitHub
commit f59f484e7b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
59 changed files with 3077 additions and 1115 deletions

125
ccmsi.lua
View File

@ -1,8 +1,6 @@
--
-- ComputerCraft Mekanism SCADA System Installer Utility
--
--[[
CC-MEK-SCADA Installer Utility
Copyright (c) 2023 Mikayla Fischler
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
@ -20,7 +18,7 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
local function println(message) print(tostring(message)) end
local function print(message) term.write(tostring(message)) end
local CCMSI_VERSION = "v1.7d"
local CCMSI_VERSION = "v1.9"
local install_dir = "/.install-cache"
local manifest_path = "https://mikaylafischler.github.io/cc-mek-scada/manifests/"
@ -63,16 +61,15 @@ end
local function pkg_message(message, package) white();print(message .. " ");blue();println(package);white() end
-- indicate actions to be taken based on package differences for installs/updates
local function show_pkg_change(name, v_local, v_remote)
if v_local ~= nil then
if v_local ~= v_remote then
print("[" .. name .. "] updating ");blue();print(v_local);white();print(" \xbb ");blue();println(v_remote);white()
local function show_pkg_change(name, v)
if v.v_local ~= nil then
if v.v_local ~= v.v_remote then
print("[" .. name .. "] updating ");blue();print(v.v_local);white();print(" \xbb ");blue();println(v.v_remote);white()
elseif mode == "install" then
pkg_message("[" .. name .. "] reinstalling", v_local)
end
else
pkg_message("[" .. name .. "] new install of", v_remote)
pkg_message("[" .. name .. "] reinstalling", v.v_local)
end
else pkg_message("[" .. name .. "] new install of", v.v_remote) end
return v.v_local ~= v.v_remote
end
-- read the local manifest file
@ -91,7 +88,7 @@ end
local function get_remote_manifest()
local response, error = http.get(install_manifest)
if response == nil then
orange();println("failed to get installation manifest from GitHub, cannot update or install")
orange();println("Failed to get installation manifest from GitHub, cannot update or install.")
red();println("HTTP error: " .. error);white()
return false, {}
end
@ -216,8 +213,8 @@ if #opts == 0 or opts[1] == "help" then
println(" supervisor - supervisor server application")
println(" coordinator - coordinator application")
println(" pocket - pocket application")
white();println("<branch>");yellow()
println(" second parameter when used with check")
println(" installer - ccmsi installer (update only)")
white();println("<branch>")
lgray();println(" main (default) | latest | devel");white()
return
else
@ -227,10 +224,13 @@ else
return
end
app = get_opt(opts[2], { "reactor-plc", "rtu", "supervisor", "coordinator", "pocket" })
app = get_opt(opts[2], { "reactor-plc", "rtu", "supervisor", "coordinator", "pocket", "installer" })
if app == nil and mode ~= "check" then
red();println("Unrecognized application.");white()
return
elseif app == "installer" and mode ~= "update" then
red();println("Installer app only supports 'update' option.");white()
return
end
-- determine target
@ -276,7 +276,12 @@ if mode == "check" then
print(value);white();println(")")
end
end
if manifest.versions.installer ~= local_manifest.versions.installer then
yellow();println("\nA newer version of the installer is available, it is recommended to update (use 'ccmsi update installer').");white()
end
elseif mode == "install" or mode == "update" then
local update_installer = app == "installer"
local ok, manifest = get_remote_manifest()
if not ok then return end
@ -284,38 +289,60 @@ elseif mode == "install" or mode == "update" then
app = { v_local = nil, v_remote = nil, changed = false },
boot = { v_local = nil, v_remote = nil, changed = false },
comms = { v_local = nil, v_remote = nil, changed = false },
common = { v_local = nil, v_remote = nil, changed = false },
graphics = { v_local = nil, v_remote = nil, changed = false },
lockbox = { v_local = nil, v_remote = nil, changed = false }
}
-- try to find local versions
local local_ok, local_manifest = read_local_manifest()
local local_ok, lmnf = read_local_manifest()
if not local_ok then
if mode == "update" then
red();println("failed to load local installation information, cannot update");white()
red();println("Failed to load local installation information, cannot update.");white()
return
end
else
ver.boot.v_local = local_manifest.versions.bootloader
ver.app.v_local = local_manifest.versions[app]
ver.comms.v_local = local_manifest.versions.comms
ver.graphics.v_local = local_manifest.versions.graphics
ver.lockbox.v_local = local_manifest.versions.lockbox
elseif not update_installer then
ver.boot.v_local = lmnf.versions.bootloader
ver.app.v_local = lmnf.versions[app]
ver.comms.v_local = lmnf.versions.comms
ver.common.v_local = lmnf.versions.common
ver.graphics.v_local = lmnf.versions.graphics
ver.lockbox.v_local = lmnf.versions.lockbox
if local_manifest.versions[app] == nil then
red();println("another application is already installed, please purge it before installing a new application");white()
if lmnf.versions[app] == nil then
red();println("Another application is already installed, please purge it before installing a new application.");white()
return
end
end
local_manifest.versions.installer = CCMSI_VERSION
lmnf.versions.installer = CCMSI_VERSION
if manifest.versions.installer ~= CCMSI_VERSION then
yellow();println("a newer version of the installer is available, it is recommended to download it");white()
if not update_installer then yellow();println("A newer version of the installer is available, it is recommended to update to it.");white() end
if update_installer or ask_y_n("Would you like to update now") then
lgray();println("GET ccmsi.lua")
local dl, err = http.get(repo_path .. "ccmsi.lua")
if dl == nil then
red();println("HTTP Error " .. err)
println("Installer download failed.");white()
else
local handle = fs.open(debug.getinfo(1, "S").source:sub(2), "w") -- this file, regardless of name or location
handle.write(dl.readAll())
handle.close()
green();println("Installer updated successfully.");white()
end
return
end
elseif update_installer then
green();println("Installer already up-to-date.");white()
return
end
ver.boot.v_remote = manifest.versions.bootloader
ver.app.v_remote = manifest.versions[app]
ver.comms.v_remote = manifest.versions.comms
ver.common.v_remote = manifest.versions.common
ver.graphics.v_remote = manifest.versions.graphics
ver.lockbox.v_remote = manifest.versions.lockbox
@ -327,28 +354,15 @@ elseif mode == "install" or mode == "update" then
end
white()
-- display bootloader version change information
show_pkg_change("bootldr", ver.boot.v_local, ver.boot.v_remote)
ver.boot.changed = ver.boot.v_local ~= ver.boot.v_remote
-- display app version change information
show_pkg_change(app, ver.app.v_local, ver.app.v_remote)
ver.app.changed = ver.app.v_local ~= ver.app.v_remote
-- display comms version change information
show_pkg_change("comms", ver.comms.v_local, ver.comms.v_remote)
ver.comms.changed = ver.comms.v_local ~= ver.comms.v_remote
ver.boot.changed = show_pkg_change("bootldr", ver.boot)
ver.common.changed = show_pkg_change("common", ver.common)
ver.comms.changed = show_pkg_change("comms", ver.comms)
if ver.comms.changed and ver.comms.v_local ~= nil then
print("[comms] ");yellow();println("other devices on the network will require an update");white()
end
-- display graphics version change information
show_pkg_change("graphics", ver.graphics.v_local, ver.graphics.v_remote)
ver.graphics.changed = ver.graphics.v_local ~= ver.graphics.v_remote
-- display lockbox version change information
show_pkg_change("lockbox", ver.lockbox.v_local, ver.lockbox.v_remote)
ver.lockbox.changed = ver.lockbox.v_local ~= ver.lockbox.v_remote
ver.app.changed = show_pkg_change(app, ver.app)
ver.graphics.changed = show_pkg_change("graphics", ver.graphics)
ver.lockbox.changed = show_pkg_change("lockbox", ver.lockbox)
-- ask for confirmation
if not ask_y_n("Continue", false) then return end
@ -379,7 +393,7 @@ elseif mode == "install" or mode == "update" then
yellow();println("WARNING: Insufficient space available for a full download!");white()
println("Files can be downloaded one by one, so if you are replacing a current install this will not be a problem unless installation fails.")
if mode == "update" then println("If installation still fails, delete this device's log file or uninstall the app (not purge) and try again.") end
if not ask_y_n("Do you wish to continue?", false) then
if not ask_y_n("Do you wish to continue", false) then
println("Operation cancelled.")
return
end
@ -392,16 +406,13 @@ elseif mode == "install" or mode == "update" then
if dependency == "system" then return not ver.boot.changed
elseif dependency == "graphics" then return not ver.graphics.changed
elseif dependency == "lockbox" then return not ver.lockbox.changed
elseif dependency == "common" then return not (ver.app.changed or ver.comms.changed)
elseif dependency == "common" then return not (ver.common.changed or ver.comms.changed)
elseif dependency == app then return not ver.app.changed
else return true end
end
if not single_file_mode then
if fs.exists(install_dir) then
fs.delete(install_dir)
fs.makeDir(install_dir)
end
if fs.exists(install_dir) then fs.delete(install_dir);fs.makeDir(install_dir) end
-- download all dependencies
for _, dependency in pairs(dependencies) do
@ -417,7 +428,7 @@ elseif mode == "install" or mode == "update" then
local dl, err = http.get(repo_path .. file)
if dl == nil then
red();println("GET HTTP Error " .. err)
red();println("HTTP Error " .. err)
success = false
break
else
@ -482,7 +493,7 @@ elseif mode == "install" or mode == "update" then
local dl, err = http.get(repo_path .. file)
if dl == nil then
red();println("GET HTTP Error " .. err)
red();println("HTTP Error " .. err)
success = false
break
else
@ -552,7 +563,7 @@ elseif mode == "remove" or mode == "purge" then
end)
if not log_deleted then
red();println("failed to delete log file")
red();println("Failed to delete log file.")
white();println("press any key to continue...")
any_key();lgray()
end

View File

@ -26,6 +26,9 @@ config.SOUNDER_VOLUME = 1.0
-- true for 24 hour time on main view screen
config.TIME_24_HOUR = true
-- disable flow view (for legacy layouts)
config.DISABLE_FLOW_VIEW = false
-- log path
config.LOG_PATH = "/log.txt"
-- log mode

View File

@ -49,12 +49,15 @@ end
-- configure monitor layout
---@param num_units integer number of units expected
---@param disable_flow_view boolean disable flow view (legacy)
---@return boolean success, monitors_struct? monitors
function coordinator.configure_monitors(num_units)
function coordinator.configure_monitors(num_units, disable_flow_view)
---@class monitors_struct
local monitors = {
primary = nil,
primary_name = "",
flow = nil,
flow_name = "",
unit_displays = {},
unit_name_map = {}
}
@ -69,8 +72,8 @@ function coordinator.configure_monitors(num_units)
table.insert(available, iface)
end
-- we need a certain number of monitors (1 per unit + 1 primary display)
local num_displays_needed = num_units + 1
-- we need a certain number of monitors (1 per unit + 1 primary display + 1 flow display)
local num_displays_needed = num_units + util.trinary(disable_flow_view, 1, 2)
if #names < num_displays_needed then
local message = "not enough monitors connected (need " .. num_displays_needed .. ")"
println(message)
@ -83,10 +86,12 @@ function coordinator.configure_monitors(num_units)
log.warning("configure_monitors(): failed to load coordinator settings file (may not exist yet)")
else
local _primary = settings.get("PRIMARY_DISPLAY")
local _flow = settings.get("FLOW_DISPLAY")
local _unitd = settings.get("UNIT_DISPLAYS")
-- filter out already assigned monitors
util.filter_table(available, function (x) return x ~= _primary end)
util.filter_table(available, function (x) return x ~= _flow end)
if type(_unitd) == "table" then
util.filter_table(available, function (x) return not util.table_contains(_unitd, x) end)
end
@ -106,7 +111,6 @@ function coordinator.configure_monitors(num_units)
end
while iface_primary_display == nil and #available > 0 do
-- lets get a monitor
iface_primary_display = ask_monitor(available)
end
@ -118,6 +122,33 @@ function coordinator.configure_monitors(num_units)
monitors.primary = ppm.get_periph(iface_primary_display)
monitors.primary_name = iface_primary_display
--------------------------
-- FLOW MONITOR DISPLAY --
--------------------------
if not disable_flow_view then
local iface_flow_display = settings.get("FLOW_DISPLAY") ---@type boolean|string|nil
if not util.table_contains(names, iface_flow_display) then
println("flow monitor display is not connected")
local response = dialog.ask_y_n("would you like to change it", true)
if response == false then return false end
iface_flow_display = nil
end
while iface_flow_display == nil and #available > 0 do
iface_flow_display = ask_monitor(available)
end
if type(iface_flow_display) ~= "string" then return false end
settings.set("FLOW_DISPLAY", iface_flow_display)
util.filter_table(available, function (x) return x ~= iface_flow_display end)
monitors.flow = ppm.get_periph(iface_flow_display)
monitors.flow_name = iface_flow_display
end
-------------------
-- UNIT DISPLAYS --
-------------------
@ -130,7 +161,6 @@ function coordinator.configure_monitors(num_units)
local display = nil
while display == nil and #available > 0 do
-- lets get a monitor
println("please select monitor for unit #" .. i)
display = ask_monitor(available)
end
@ -152,7 +182,6 @@ function coordinator.configure_monitors(num_units)
end
while display == nil and #available > 0 do
-- lets get a monitor
display = ask_monitor(available)
end
@ -217,12 +246,13 @@ end
---@nodiscard
---@param version string coordinator version
---@param nic nic network interface device
---@param num_units integer number of configured units for number of monitors, checked against SV
---@param crd_channel integer port of configured supervisor
---@param svr_channel integer listening port for supervisor replys
---@param pkt_channel integer listening port for pocket API
---@param range integer trusted device connection range
---@param sv_watchdog watchdog
function coordinator.comms(version, nic, crd_channel, svr_channel, pkt_channel, range, sv_watchdog)
function coordinator.comms(version, nic, num_units, crd_channel, svr_channel, pkt_channel, range, sv_watchdog)
local self = {
sv_linked = false,
sv_addr = comms.BROADCAST,
@ -681,21 +711,16 @@ function coordinator.comms(version, nic, crd_channel, svr_channel, pkt_channel,
-- reset to disconnected before validating
iocontrol.fp_link_state(types.PANEL_LINK_STATE.DISCONNECTED)
if type(config) == "table" and #config > 1 then
if type(config) == "table" and #config == 2 then
-- get configuration
---@class facility_conf
local conf = {
num_units = config[1], ---@type integer
defs = {} -- boilers and turbines
cooling = config[2] ---@type sv_cooling_conf
}
if (#config - 1) == (conf.num_units * 2) then
-- record sequence of pairs of [#boilers, #turbines] per unit
for i = 2, #config do
table.insert(conf.defs, config[i])
end
if conf.num_units == num_units then
-- init io controller
iocontrol.init(conf, public)
@ -707,7 +732,7 @@ function coordinator.comms(version, nic, crd_channel, svr_channel, pkt_channel,
iocontrol.fp_link_state(types.PANEL_LINK_STATE.LINKED)
else
self.sv_config_err = true
log.warning("invalid supervisor configuration definitions received, establish failed")
log.warning("supervisor config's number of units don't match coordinator's config, establish failed")
end
else
log.debug("invalid supervisor configuration table received, establish failed")

View File

@ -38,9 +38,7 @@ local function __generic_ack(success) end
---@param comms_v string comms version
function iocontrol.init_fp(firmware_v, comms_v)
---@class ioctl_front_panel
io.fp = {
ps = psil.create()
}
io.fp = { ps = psil.create() }
io.fp.ps.publish("version", firmware_v)
io.fp.ps.publish("comms_version", comms_v)
@ -50,9 +48,12 @@ end
---@param conf facility_conf configuration
---@param comms coord_comms comms reference
function iocontrol.init(conf, comms)
-- facility data structure
---@class ioctl_facility
io.facility = {
num_units = conf.num_units, ---@type integer
num_units = conf.num_units,
tank_mode = conf.cooling.fac_tank_mode,
tank_defs = conf.cooling.fac_tank_defs,
all_sys_ok = false,
rtu_count = 0,
@ -83,6 +84,8 @@ function iocontrol.init(conf, comms)
scram_ack = __generic_ack,
ack_alarms_ack = __generic_ack,
alarm_tones = { false, false, false, false, false, false, false, false },
ps = psil.create(),
induction_ps_tbl = {},
@ -104,6 +107,101 @@ function iocontrol.init(conf, comms)
table.insert(io.facility.sps_ps_tbl, psil.create())
table.insert(io.facility.sps_data_tbl, {})
-- determine tank information
if io.facility.tank_mode == 0 then
io.facility.tank_defs = {}
-- on facility tank mode 0, setup tank defs to match unit TANK option
for i = 1, conf.num_units do
io.facility.tank_defs[i] = util.trinary(conf.cooling.r_cool[i].TANK, 1, 0)
end
io.facility.tank_list = { table.unpack(io.facility.tank_defs) }
else
-- decode the layout of tanks from the connections definitions
local tank_mode = io.facility.tank_mode
local tank_defs = io.facility.tank_defs
local tank_list = { table.unpack(tank_defs) }
local function calc_fdef(start_idx, end_idx)
local first = 4
for i = start_idx, end_idx do
if io.facility.tank_defs[i] == 2 then
if i < first then first = i end
end
end
return first
end
if tank_mode == 1 then
-- (1) 1 total facility tank (A A A A)
local first_fdef = calc_fdef(1, #tank_defs)
for i = 1, #tank_defs do
if i > first_fdef and tank_defs[i] == 2 then
tank_list[i] = 0
end
end
elseif tank_mode == 2 then
-- (2) 2 total facility tanks (A A A B)
local first_fdef = calc_fdef(1, math.min(3, #tank_defs))
for i = 1, #tank_defs do
if (i ~= 4) and (i > first_fdef) and (tank_defs[i] == 2) then
tank_list[i] = 0
end
end
elseif tank_mode == 3 then
-- (3) 2 total facility tanks (A A B B)
for _, a in pairs({ 1, 3 }) do
local b = a + 1
if (tank_defs[a] == 2) and (tank_defs[b] == 2) then
tank_list[b] = 0
end
end
elseif tank_mode == 4 then
-- (4) 2 total facility tanks (A B B B)
local first_fdef = calc_fdef(2, #tank_defs)
for i = 1, #tank_defs do
if (i ~= 1) and (i > first_fdef) and (tank_defs[i] == 2) then
tank_list[i] = 0
end
end
elseif tank_mode == 5 then
-- (5) 3 total facility tanks (A A B C)
local first_fdef = calc_fdef(1, math.min(2, #tank_defs))
for i = 1, #tank_defs do
if (not (i == 3 or i == 4)) and (i > first_fdef) and (tank_defs[i] == 2) then
tank_list[i] = 0
end
end
elseif tank_mode == 6 then
-- (6) 3 total facility tanks (A B B C)
local first_fdef = calc_fdef(2, math.min(3, #tank_defs))
for i = 1, #tank_defs do
if (not (i == 1 or i == 4)) and (i > first_fdef) and (tank_defs[i] == 2) then
tank_list[i] = 0
end
end
elseif tank_mode == 7 then
-- (7) 3 total facility tanks (A B C C)
local first_fdef = calc_fdef(3, #tank_defs)
for i = 1, #tank_defs do
if (not (i == 1 or i == 2)) and (i > first_fdef) and (tank_defs[i] == 2) then
tank_list[i] = 0
end
end
end
io.facility.tank_list = tank_list
end
-- create facility tank tables
for i = 1, #io.facility.tank_list do
if io.facility.tank_list[i] == 2 then
table.insert(io.facility.tank_ps_tbl, psil.create())
table.insert(io.facility.tank_data_tbl, {})
end
end
-- create unit data structures
io.units = {}
for i = 1, conf.num_units do
local function ack(alarm) process.ack_alarm(i, alarm) end
@ -116,6 +214,7 @@ function iocontrol.init(conf, comms)
num_boilers = 0,
num_turbines = 0,
num_snas = 0,
has_tank = conf.cooling.r_cool[i].TANK,
control_state = false,
burn_rate_cmd = 0.0,
@ -190,18 +289,27 @@ function iocontrol.init(conf, comms)
tank_data_tbl = {}
}
-- on other facility modes, overwrite unit TANK option with facility tank defs
if io.facility.tank_mode ~= 0 then
entry.has_tank = conf.cooling.fac_tank_defs[i] > 0
end
-- create boiler tables
for _ = 1, conf.defs[(i * 2) - 1] do
local data = {} ---@type boilerv_session_db
for _ = 1, conf.cooling.r_cool[i].BOILERS do
table.insert(entry.boiler_ps_tbl, psil.create())
table.insert(entry.boiler_data_tbl, data)
table.insert(entry.boiler_data_tbl, {})
end
-- create turbine tables
for _ = 1, conf.defs[i * 2] do
local data = {} ---@type turbinev_session_db
for _ = 1, conf.cooling.r_cool[i].TURBINES do
table.insert(entry.turbine_ps_tbl, psil.create())
table.insert(entry.turbine_data_tbl, data)
table.insert(entry.turbine_data_tbl, {})
end
-- create tank tables
if io.facility.tank_defs[i] == 1 then
table.insert(entry.tank_ps_tbl, psil.create())
table.insert(entry.tank_data_tbl, {})
end
entry.num_boilers = #entry.boiler_data_tbl
@ -232,12 +340,22 @@ function iocontrol.fp_has_speaker(has_speaker) io.fp.ps.publish("has_speaker", h
function iocontrol.fp_link_state(state) io.fp.ps.publish("link_state", state) end
-- report monitor connection state
---@param id integer unit ID or 0 for main
---@param id string|integer unit ID for unit monitor, "main" for main monitor, or "flow" for flow monitor
function iocontrol.fp_monitor_state(id, connected)
local name = "main_monitor"
if id > 0 then name = "unit_monitor_" .. id end
local name = nil
if id == "main" then
name = "main_monitor"
elseif id == "flow" then
name = "flow_monitor"
elseif type(id) == "number" then
name = "unit_monitor_" .. id
end
if name ~= nil then
io.fp.ps.publish(name, connected)
end
end
-- report PKT firmware version and PKT session connection state
---@param session_id integer PKT session
@ -664,6 +782,16 @@ function iocontrol.update_facility_status(status)
end
fac.ps.publish("rtu_count", fac.rtu_count)
-- alarm tone commands
if (type(status[3]) == "table") and (#status[3] == 8) then
fac.alarm_tones = status[3]
sounder.set(fac.alarm_tones)
else
log.debug(log_header .. "alarm tones not a table or length mismatch")
valid = false
end
end
return valid
@ -684,8 +812,7 @@ function iocontrol.update_unit_statuses(statuses)
else
local burn_rate_sum = 0.0
local sna_count_sum = 0
local pu_rate = 0.0
local po_rate = 0.0
local pu_rate, po_rate, po_pl_rate, po_am_rate, spent_rate = 0.0, 0.0, 0.0, 0.0, 0.0
-- get all unit statuses
for i = 1, #statuses do
@ -696,7 +823,7 @@ function iocontrol.update_unit_statuses(statuses)
local burn_rate = 0.0
if type(status) ~= "table" or #status ~= 5 then
if type(status) ~= "table" or #status ~= 6 then
log.debug(log_header .. "invalid status entry in unit statuses (not a table or invalid length)")
valid = false
else
@ -779,6 +906,8 @@ function iocontrol.update_unit_statuses(statuses)
if type(rtu_statuses) == "table" then
-- boiler statuses
if type(rtu_statuses.boilers) == "table" then
local boil_sum = 0
for id = 1, #unit.boiler_ps_tbl do
if rtu_statuses.boilers[i] == nil then
-- disconnected
@ -796,6 +925,8 @@ function iocontrol.update_unit_statuses(statuses)
if rtu_faulted then
ps.publish("computed_status", 3) -- faulted
elseif data.formed then
boil_sum = boil_sum + data.state.boil_rate
if data.state.boil_rate > 0 then
ps.publish("computed_status", 5) -- active
else
@ -809,6 +940,8 @@ function iocontrol.update_unit_statuses(statuses)
valid = false
end
end
unit.unit_ps.publish("boiler_boil_sum", boil_sum)
else
log.debug(log_header .. "boiler list not a table")
valid = false
@ -816,6 +949,8 @@ function iocontrol.update_unit_statuses(statuses)
-- turbine statuses
if type(rtu_statuses.turbines) == "table" then
local flow_sum = 0
for id = 1, #unit.turbine_ps_tbl do
if rtu_statuses.turbines[i] == nil then
-- disconnected
@ -833,6 +968,8 @@ function iocontrol.update_unit_statuses(statuses)
if rtu_faulted then
ps.publish("computed_status", 3) -- faulted
elseif data.formed then
flow_sum = flow_sum + data.state.flow_rate
if data.tanks.energy_fill >= 0.99 then
ps.publish("computed_status", 6) -- trip
elseif data.state.flow_rate < 100 then
@ -848,6 +985,8 @@ function iocontrol.update_unit_statuses(statuses)
valid = false
end
end
unit.unit_ps.publish("turbine_flow_sum", flow_sum)
else
log.debug(log_header .. "turbine list not a table")
valid = false
@ -877,7 +1016,7 @@ function iocontrol.update_unit_statuses(statuses)
elseif data.tanks.fill < 0.20 then
ps.publish("computed_status", 5) -- low
else
ps.publish("computed_status", 5) -- active
ps.publish("computed_status", 4) -- on-line
end
else
ps.publish("computed_status", 2) -- not formed
@ -896,8 +1035,11 @@ function iocontrol.update_unit_statuses(statuses)
if type(rtu_statuses.sna) == "table" then
unit.num_snas = rtu_statuses.sna[1] ---@type integer
unit.sna_prod_rate = rtu_statuses.sna[2] ---@type number
unit.sna_peak_rate = rtu_statuses.sna[3] ---@type number
unit.unit_ps.publish("sna_count", unit.num_snas)
unit.unit_ps.publish("sna_prod_rate", unit.sna_prod_rate)
unit.unit_ps.publish("sna_peak_rate", unit.sna_peak_rate)
sna_count_sum = sna_count_sum + unit.num_snas
else
@ -1002,10 +1144,63 @@ function iocontrol.update_unit_statuses(statuses)
valid = false
end
-- valve states
local valve_states = status[6]
if type(valve_states) == "table" then
if #valve_states == 5 then
unit.unit_ps.publish("V_pu_conn", valve_states[1] > 0)
unit.unit_ps.publish("V_pu_state", valve_states[1] == 2)
unit.unit_ps.publish("V_po_conn", valve_states[2] > 0)
unit.unit_ps.publish("V_po_state", valve_states[2] == 2)
unit.unit_ps.publish("V_pl_conn", valve_states[3] > 0)
unit.unit_ps.publish("V_pl_state", valve_states[3] == 2)
unit.unit_ps.publish("V_am_conn", valve_states[4] > 0)
unit.unit_ps.publish("V_am_state", valve_states[4] == 2)
unit.unit_ps.publish("V_emc_conn", valve_states[5] > 0)
unit.unit_ps.publish("V_emc_state", valve_states[5] == 2)
else
log.debug(log_header .. "valve states length mismatch")
valid = false
end
else
log.debug(log_header .. "valve states not a table")
valid = false
end
-- determine waste production for this unit, add to statistics
local is_pu = unit.waste_product == types.WASTE_PRODUCT.PLUTONIUM
pu_rate = pu_rate + util.trinary(is_pu, burn_rate / 10.0, 0.0)
po_rate = po_rate + util.trinary(not is_pu, math.min(burn_rate / 10.0, unit.sna_prod_rate), 0.0)
local waste_rate = burn_rate / 10.0
local u_spent_rate = waste_rate
local u_pu_rate = util.trinary(is_pu, waste_rate, 0.0)
local u_po_rate = util.trinary(not is_pu, math.min(waste_rate, unit.sna_prod_rate), 0.0)
unit.unit_ps.publish("pu_rate", u_pu_rate)
unit.unit_ps.publish("po_rate", u_po_rate)
unit.unit_ps.publish("sna_in", util.trinary(is_pu, 0, burn_rate))
if unit.waste_product == types.WASTE_PRODUCT.POLONIUM then
unit.unit_ps.publish("po_pl_rate", u_po_rate)
unit.unit_ps.publish("po_am_rate", 0)
po_pl_rate = po_pl_rate + u_po_rate
elseif unit.waste_product == types.WASTE_PRODUCT.ANTI_MATTER then
unit.unit_ps.publish("po_pl_rate", 0)
unit.unit_ps.publish("po_am_rate", u_po_rate)
po_am_rate = po_am_rate + u_po_rate
u_spent_rate = 0
else
unit.unit_ps.publish("po_pl_rate", 0)
unit.unit_ps.publish("po_am_rate", 0)
end
unit.unit_ps.publish("ws_rate", u_spent_rate)
pu_rate = pu_rate + u_pu_rate
po_rate = po_rate + u_po_rate
spent_rate = spent_rate + u_spent_rate
end
end
@ -1013,9 +1208,9 @@ function iocontrol.update_unit_statuses(statuses)
io.facility.ps.publish("sna_count", sna_count_sum)
io.facility.ps.publish("pu_rate", pu_rate)
io.facility.ps.publish("po_rate", po_rate)
-- update alarm sounder
sounder.eval(io.units)
io.facility.ps.publish("po_pl_rate", po_pl_rate)
io.facility.ps.publish("po_am_rate", po_am_rate)
io.facility.ps.publish("spent_waste_rate", spent_rate)
end
return valid

View File

@ -10,6 +10,7 @@ local iocontrol = require("coordinator.iocontrol")
local style = require("coordinator.ui.style")
local pgi = require("coordinator.ui.pgi")
local flow_view = require("coordinator.ui.layout.flow_view")
local panel_view = require("coordinator.ui.layout.front_panel")
local main_view = require("coordinator.ui.layout.main_view")
local unit_view = require("coordinator.ui.layout.unit_view")
@ -29,8 +30,10 @@ local engine = {
ui = {
front_panel = nil, ---@type graphics_element|nil
main_display = nil, ---@type graphics_element|nil
flow_display = nil, ---@type graphics_element|nil
unit_displays = {}
}
},
disable_flow_view = false
}
-- init a display to the "default", but set text scale to 0.5
@ -48,20 +51,28 @@ local function _init_display(monitor)
end
end
-- disable the flow view
---@param disable boolean
function renderer.legacy_disable_flow_view(disable)
engine.disable_flow_view = disable
end
-- link to the monitor peripherals
---@param monitors monitors_struct
function renderer.set_displays(monitors)
engine.monitors = monitors
-- report to front panel as connected
iocontrol.fp_monitor_state(0, true)
iocontrol.fp_monitor_state("main", engine.monitors.primary ~= nil)
iocontrol.fp_monitor_state("flow", engine.monitors.flow ~= nil)
for i = 1, #engine.monitors.unit_displays do iocontrol.fp_monitor_state(i, true) end
end
-- init all displays in use by the renderer
function renderer.init_displays()
-- init primary monitor
-- init primary and flow monitors
_init_display(engine.monitors.primary)
if not engine.disable_flow_view then _init_display(engine.monitors.flow) end
-- init unit displays
for _, monitor in ipairs(engine.monitors.unit_displays) do
@ -88,6 +99,14 @@ function renderer.validate_main_display_width()
return w == 164
end
-- check flow display width
---@nodiscard
---@return boolean width_okay
function renderer.validate_flow_display_width()
local w, _ = engine.monitors.flow.getSize()
return w == 164
end
-- check display sizes
---@nodiscard
---@return boolean valid all unit display dimensions OK
@ -169,6 +188,12 @@ function renderer.start_ui()
main_view(engine.ui.main_display)
end
-- show flow view on flow monitor
if engine.monitors.flow ~= nil then
engine.ui.flow_display = DisplayBox{window=engine.monitors.flow,fg_bg=style.root}
flow_view(engine.ui.flow_display)
end
-- show unit views on unit displays
for idx, display in pairs(engine.monitors.unit_displays) do
engine.ui.unit_displays[idx] = DisplayBox{window=display,fg_bg=style.root}
@ -192,6 +217,7 @@ function renderer.close_ui()
-- delete element trees
if engine.ui.main_display ~= nil then engine.ui.main_display.delete() end
if engine.ui.flow_display ~= nil then engine.ui.flow_display.delete() end
for _, display in pairs(engine.ui.unit_displays) do display.delete() end
-- report ui as not ready
@ -199,6 +225,7 @@ function renderer.close_ui()
-- clear root UI elements
engine.ui.main_display = nil
engine.ui.flow_display = nil
engine.ui.unit_displays = {}
-- clear unit monitors
@ -236,7 +263,18 @@ function renderer.handle_disconnect(device)
engine.monitors.primary = nil
engine.ui.main_display = nil
iocontrol.fp_monitor_state(0, false)
iocontrol.fp_monitor_state("main", false)
elseif engine.monitors.flow == device then
if engine.ui.flow_display ~= nil then
-- delete element tree and clear root UI elements
engine.ui.flow_display.delete()
end
is_used = true
engine.monitors.flow = nil
engine.ui.flow_display = nil
iocontrol.fp_monitor_state("flow", false)
else
for idx, monitor in pairs(engine.monitors.unit_displays) do
if monitor == device then
@ -284,7 +322,18 @@ function renderer.handle_reconnect(name, device)
engine.dmesg_window.redraw()
end
iocontrol.fp_monitor_state(0, true)
iocontrol.fp_monitor_state("main", true)
elseif engine.monitors.flow_name == name then
is_used = true
_init_display(device)
engine.monitors.flow = device
if engine.ui_ready and (engine.ui.flow_display == nil) then
engine.ui.flow_display = DisplayBox{window=device,fg_bg=style.root}
flow_view(engine.ui.flow_display)
end
iocontrol.fp_monitor_state("flow", true)
else
for idx, monitor in ipairs(engine.monitors.unit_name_map) do
if monitor == name then
@ -317,6 +366,8 @@ function renderer.handle_mouse(event)
elseif engine.ui_ready then
if event.monitor == engine.monitors.primary_name then
engine.ui.main_display.handle_mouse(event)
elseif event.monitor == engine.monitors.flow_name then
engine.ui.flow_display.handle_mouse(event)
else
for id, monitor in ipairs(engine.monitors.unit_name_map) do
if event.monitor == monitor then

View File

@ -2,269 +2,25 @@
-- Alarm Sounder
--
local audio = require("scada-common.audio")
local log = require("scada-common.log")
local types = require("scada-common.types")
local util = require("scada-common.util")
local ALARM = types.ALARM
local ALARM_STATE = types.ALARM_STATE
---@class sounder
local sounder = {}
-- note: max samples = 0x20000 (128 * 1024 samples)
local _2_PI = 2 * math.pi -- 2 whole pies, hope you're hungry
local _DRATE = 48000 -- 48kHz audio
local _MAX_VAL = 127 / 2 -- max signed integer in this 8-bit audio
local _05s_SAMPLES = 24000 -- half a second worth of samples
local test_alarms = { false, false, false, false, false, false, false, false, false, false, false, false }
local alarm_ctl = {
speaker = nil,
volume = 0.5,
playing = false,
num_active = 0,
next_block = 1,
-- split audio up into 0.5s samples so specific components can be ended quicker
quad_buffer = { {}, {}, {}, {} }
stream = audio.new_stream()
}
-- sounds modeled after https://www.e2s.com/references-and-guidelines/listen-and-download-alarm-tones
local T_340Hz_Int_2Hz = 1
local T_544Hz_440Hz_Alt = 2
local T_660Hz_Int_125ms = 3
local T_745Hz_Int_1Hz = 4
local T_800Hz_Int = 5
local T_800Hz_1000Hz_Alt = 6
local T_1000Hz_Int = 7
local T_1800Hz_Int_4Hz = 8
local TONES = {
{ active = false, component = { {}, {}, {}, {} } }, -- 340Hz @ 2Hz Intermittent
{ active = false, component = { {}, {}, {}, {} } }, -- 544Hz 100mS / 440Hz 400mS Alternating
{ active = false, component = { {}, {}, {}, {} } }, -- 660Hz @ 125ms On 125ms Off
{ active = false, component = { {}, {}, {}, {} } }, -- 745Hz @ 1Hz Intermittent
{ active = false, component = { {}, {}, {}, {} } }, -- 800Hz @ 0.25s On 1.75s Off
{ active = false, component = { {}, {}, {}, {} } }, -- 800/1000Hz @ 0.25s Alternating
{ active = false, component = { {}, {}, {}, {} } }, -- 1KHz 1s on, 1s off Intermittent
{ active = false, component = { {}, {}, {}, {} } } -- 1.8KHz @ 4Hz Intermittent
}
-- calculate how many samples are in the given number of milliseconds
---@nodiscard
---@param ms integer milliseconds
---@return integer samples
local function ms_to_samples(ms) return math.floor(ms * 48) end
--#region Tone Generation (the Maths)
-- 340Hz @ 2Hz Intermittent
local function gen_tone_1()
local t, dt = 0, _2_PI * 340 / _DRATE
for i = 1, _05s_SAMPLES do
local val = math.floor(math.sin(t) * _MAX_VAL)
TONES[1].component[1][i] = val
TONES[1].component[3][i] = val
TONES[1].component[2][i] = 0
TONES[1].component[4][i] = 0
t = (t + dt) % _2_PI
end
end
-- 544Hz 100mS / 440Hz 400mS Alternating
local function gen_tone_2()
local t1, dt1 = 0, _2_PI * 544 / _DRATE
local t2, dt2 = 0, _2_PI * 440 / _DRATE
local alternate_at = ms_to_samples(100)
for i = 1, _05s_SAMPLES do
local value
if i <= alternate_at then
value = math.floor(math.sin(t1) * _MAX_VAL)
t1 = (t1 + dt1) % _2_PI
else
value = math.floor(math.sin(t2) * _MAX_VAL)
t2 = (t2 + dt2) % _2_PI
end
TONES[2].component[1][i] = value
TONES[2].component[2][i] = value
TONES[2].component[3][i] = value
TONES[2].component[4][i] = value
end
end
-- 660Hz @ 125ms On 125ms Off
local function gen_tone_3()
local elapsed_samples = 0
local alternate_after = ms_to_samples(125)
local alternate_at = alternate_after
local mode = true
local t, dt = 0, _2_PI * 660 / _DRATE
for set = 1, 4 do
for i = 1, _05s_SAMPLES do
if mode then
local val = math.floor(math.sin(t) * _MAX_VAL)
TONES[3].component[set][i] = val
t = (t + dt) % _2_PI
else
t = 0
TONES[3].component[set][i] = 0
end
if elapsed_samples == alternate_at then
mode = not mode
alternate_at = elapsed_samples + alternate_after
end
elapsed_samples = elapsed_samples + 1
end
end
end
-- 745Hz @ 1Hz Intermittent
local function gen_tone_4()
local t, dt = 0, _2_PI * 745 / _DRATE
for i = 1, _05s_SAMPLES do
local val = math.floor(math.sin(t) * _MAX_VAL)
TONES[4].component[1][i] = val
TONES[4].component[3][i] = val
TONES[4].component[2][i] = 0
TONES[4].component[4][i] = 0
t = (t + dt) % _2_PI
end
end
-- 800Hz @ 0.25s On 1.75s Off
local function gen_tone_5()
local t, dt = 0, _2_PI * 800 / _DRATE
local stop_at = ms_to_samples(250)
for i = 1, _05s_SAMPLES do
local val = math.floor(math.sin(t) * _MAX_VAL)
if i > stop_at then
TONES[5].component[1][i] = val
else
TONES[5].component[1][i] = 0
end
TONES[5].component[2][i] = 0
TONES[5].component[3][i] = 0
TONES[5].component[4][i] = 0
t = (t + dt) % _2_PI
end
end
-- 1000/800Hz @ 0.25s Alternating
local function gen_tone_6()
local t1, dt1 = 0, _2_PI * 1000 / _DRATE
local t2, dt2 = 0, _2_PI * 800 / _DRATE
local alternate_at = ms_to_samples(250)
for i = 1, _05s_SAMPLES do
local val
if i <= alternate_at then
val = math.floor(math.sin(t1) * _MAX_VAL)
t1 = (t1 + dt1) % _2_PI
else
val = math.floor(math.sin(t2) * _MAX_VAL)
t2 = (t2 + dt2) % _2_PI
end
TONES[6].component[1][i] = val
TONES[6].component[2][i] = val
TONES[6].component[3][i] = val
TONES[6].component[4][i] = val
end
end
-- 1KHz 1s on, 1s off Intermittent
local function gen_tone_7()
local t, dt = 0, _2_PI * 1000 / _DRATE
for i = 1, _05s_SAMPLES do
local val = math.floor(math.sin(t) * _MAX_VAL)
TONES[7].component[1][i] = val
TONES[7].component[2][i] = val
TONES[7].component[3][i] = 0
TONES[7].component[4][i] = 0
t = (t + dt) % _2_PI
end
end
-- 1800Hz @ 4Hz Intermittent
local function gen_tone_8()
local t, dt = 0, _2_PI * 1800 / _DRATE
local off_at = ms_to_samples(250)
for i = 1, _05s_SAMPLES do
local val = 0
if i <= off_at then
val = math.floor(math.sin(t) * _MAX_VAL)
t = (t + dt) % _2_PI
end
TONES[8].component[1][i] = val
TONES[8].component[2][i] = val
TONES[8].component[3][i] = val
TONES[8].component[4][i] = val
end
end
--#endregion
-- hard audio limiter
---@nodiscard
---@param output number output level
---@return number limited -128.0 to 127.0
local function limit(output)
return math.max(-128, math.min(127, output))
end
-- zero the alarm audio buffer
local function zero()
for i = 1, 4 do
for s = 1, _05s_SAMPLES do alarm_ctl.quad_buffer[i][s] = 0 end
end
end
-- add an alarm to the output buffer
---@param alarm_idx integer tone ID
local function add(alarm_idx)
alarm_ctl.num_active = alarm_ctl.num_active + 1
TONES[alarm_idx].active = true
for i = 1, 4 do
for s = 1, _05s_SAMPLES do
alarm_ctl.quad_buffer[i][s] = limit(alarm_ctl.quad_buffer[i][s] + TONES[alarm_idx].component[i][s])
end
end
end
-- start audio or continue audio on buffer empty
---@return boolean success successfully added buffer to audio output
local function play()
if not alarm_ctl.playing then
alarm_ctl.playing = true
alarm_ctl.next_block = 1
return sounder.continue()
else
return true
end
else return true end
end
-- initialize the annunciator alarm system
@ -273,23 +29,10 @@ end
function sounder.init(speaker, volume)
alarm_ctl.speaker = speaker
alarm_ctl.speaker.stop()
alarm_ctl.volume = volume
alarm_ctl.playing = false
alarm_ctl.num_active = 0
alarm_ctl.next_block = 1
alarm_ctl.stream.stop()
zero()
-- generate tones
gen_tone_1()
gen_tone_2()
gen_tone_3()
gen_tone_4()
gen_tone_5()
gen_tone_6()
gen_tone_7()
gen_tone_8()
audio.generate_tones()
end
-- reconnect the speaker peripheral
@ -297,173 +40,40 @@ end
function sounder.reconnect(speaker)
alarm_ctl.speaker = speaker
alarm_ctl.playing = false
alarm_ctl.next_block = 1
alarm_ctl.num_active = 0
for id = 1, #TONES do TONES[id].active = false end
alarm_ctl.stream.stop()
end
-- check alarm state to enable/disable alarms
---@param units table|nil unit list or nil to use test mode
function sounder.eval(units)
local changed = false
local any_active = false
local new_states = { false, false, false, false, false, false, false, false }
local alarms = { false, false, false, false, false, false, false, false, false, false, false, false }
-- set alarm tones
---@param states table alarm tone commands from supervisor
function sounder.set(states)
-- set tone states
for id = 1, #states do alarm_ctl.stream.set_active(id, states[id]) end
if units ~= nil then
-- check all alarms for all units
for i = 1, #units do
local unit = units[i] ---@type ioctl_unit
for id = 1, #unit.alarms do
alarms[id] = alarms[id] or (unit.alarms[id] == ALARM_STATE.TRIPPED)
end
end
else
alarms = test_alarms
end
-- containment breach is worst case CRITICAL alarm, this takes priority
if alarms[ALARM.ContainmentBreach] then
new_states[T_1800Hz_Int_4Hz] = true
else
-- critical damage is highest priority CRITICAL level alarm
if alarms[ALARM.CriticalDamage] then
new_states[T_660Hz_Int_125ms] = true
else
-- EMERGENCY level alarms + URGENT over temp
if alarms[ALARM.ReactorDamage] or alarms[ALARM.ReactorOverTemp] or alarms[ALARM.ReactorWasteLeak] then
new_states[T_544Hz_440Hz_Alt] = true
-- URGENT level turbine trip
elseif alarms[ALARM.TurbineTrip] then
new_states[T_745Hz_Int_1Hz] = true
-- URGENT level reactor lost
elseif alarms[ALARM.ReactorLost] then
new_states[T_340Hz_Int_2Hz] = true
-- TIMELY level alarms
elseif alarms[ALARM.ReactorHighTemp] or alarms[ALARM.ReactorHighWaste] or alarms[ALARM.RCSTransient] then
new_states[T_800Hz_Int] = true
end
end
-- check RPS transient URGENT level alarm
if alarms[ALARM.RPSTransient] then
new_states[T_1000Hz_Int] = true
-- disable really painful audio combination
new_states[T_340Hz_Int_2Hz] = false
end
end
-- radiation is a big concern, always play this CRITICAL level alarm if active
if alarms[ALARM.ContainmentRadiation] then
new_states[T_800Hz_1000Hz_Alt] = true
-- we are going to disable the RPS trip alarm audio due to conflict, and if it was enabled
-- then we can re-enable the reactor lost alarm audio since it doesn't painfully combine with this one
if new_states[T_1000Hz_Int] and alarms[ALARM.ReactorLost] then new_states[T_340Hz_Int_2Hz] = true end
-- it sounds *really* bad if this is in conjunction with these other tones, so disable them
new_states[T_745Hz_Int_1Hz] = false
new_states[T_800Hz_Int] = false
new_states[T_1000Hz_Int] = false
end
-- check if any changed, check if any active, update active flags
for id = 1, #TONES do
if new_states[id] ~= TONES[id].active then
TONES[id].active = new_states[id]
changed = true
end
if TONES[id].active then any_active = true end
end
-- zero and re-add tones if changed
if changed then
zero()
for id = 1, #TONES do
if TONES[id].active then add(id) end
end
end
if any_active then play() else sounder.stop() end
-- re-compute output if needed, then play audio if available
if alarm_ctl.stream.is_recompute_needed() then alarm_ctl.stream.compute_buffer() end
if alarm_ctl.stream.has_next_block() then play() else sounder.stop() end
end
-- stop all audio and clear output buffer
function sounder.stop()
alarm_ctl.playing = false
alarm_ctl.speaker.stop()
alarm_ctl.next_block = 1
alarm_ctl.num_active = 0
for id = 1, #TONES do TONES[id].active = false end
zero()
alarm_ctl.stream.stop()
end
-- continue audio on buffer empty
---@return boolean success successfully added buffer to audio output
function sounder.continue()
local success = false
if alarm_ctl.playing then
if alarm_ctl.speaker ~= nil and #alarm_ctl.quad_buffer[alarm_ctl.next_block] > 0 then
local success = alarm_ctl.speaker.playAudio(alarm_ctl.quad_buffer[alarm_ctl.next_block], alarm_ctl.volume)
alarm_ctl.next_block = alarm_ctl.next_block + 1
if alarm_ctl.next_block > 4 then alarm_ctl.next_block = 1 end
if not success then
log.debug("SOUNDER: error playing audio")
if alarm_ctl.speaker ~= nil and alarm_ctl.stream.has_next_block() then
success = alarm_ctl.speaker.playAudio(alarm_ctl.stream.get_next_block(), alarm_ctl.volume)
if not success then log.error("SOUNDER: error playing audio") end
end
end
return success
else
return false
end
else
return false
end
end
--#region Test Functions
function sounder.test_1() add(1) play() end -- play tone T_340Hz_Int_2Hz
function sounder.test_2() add(2) play() end -- play tone T_544Hz_440Hz_Alt
function sounder.test_3() add(3) play() end -- play tone T_660Hz_Int_125ms
function sounder.test_4() add(4) play() end -- play tone T_745Hz_Int_1Hz
function sounder.test_5() add(5) play() end -- play tone T_800Hz_Int
function sounder.test_6() add(6) play() end -- play tone T_800Hz_1000Hz_Alt
function sounder.test_7() add(7) play() end -- play tone T_1000Hz_Int
function sounder.test_8() add(8) play() end -- play tone T_1800Hz_Int_4Hz
function sounder.test_breach(active) test_alarms[ALARM.ContainmentBreach] = active end ---@param active boolean
function sounder.test_rad(active) test_alarms[ALARM.ContainmentRadiation] = active end ---@param active boolean
function sounder.test_lost(active) test_alarms[ALARM.ReactorLost] = active end ---@param active boolean
function sounder.test_crit(active) test_alarms[ALARM.CriticalDamage] = active end ---@param active boolean
function sounder.test_dmg(active) test_alarms[ALARM.ReactorDamage] = active end ---@param active boolean
function sounder.test_overtemp(active) test_alarms[ALARM.ReactorOverTemp] = active end ---@param active boolean
function sounder.test_hightemp(active) test_alarms[ALARM.ReactorHighTemp] = active end ---@param active boolean
function sounder.test_wasteleak(active) test_alarms[ALARM.ReactorWasteLeak] = active end ---@param active boolean
function sounder.test_highwaste(active) test_alarms[ALARM.ReactorHighWaste] = active end ---@param active boolean
function sounder.test_rps(active) test_alarms[ALARM.RPSTransient] = active end ---@param active boolean
function sounder.test_rcs(active) test_alarms[ALARM.RCSTransient] = active end ---@param active boolean
function sounder.test_turbinet(active) test_alarms[ALARM.TurbineTrip] = active end ---@param active boolean
-- power rescaling limiter test
function sounder.test_power_scale()
local start = util.time_ms()
zero()
for id = 1, #TONES do
if TONES[id].active then
for i = 1, 4 do
for s = 1, _05s_SAMPLES do
alarm_ctl.quad_buffer[i][s] = limit(alarm_ctl.quad_buffer[i][s] +
(TONES[id].component[i][s] / math.sqrt(alarm_ctl.num_active)))
end
end
end
end
log.debug("SOUNDER: power rescale test took " .. (util.time_ms() - start) .. "ms")
end
--#endregion
return sounder

View File

@ -22,7 +22,7 @@ local sounder = require("coordinator.sounder")
local apisessions = require("coordinator.session.apisessions")
local COORDINATOR_VERSION = "v0.21.2"
local COORDINATOR_VERSION = "v1.0.1"
local println = util.println
local println_ts = util.println_ts
@ -84,7 +84,7 @@ local function main()
iocontrol.init_fp(COORDINATOR_VERSION, comms.version)
-- setup monitors
local configured, monitors = coordinator.configure_monitors(config.NUM_UNITS)
local configured, monitors = coordinator.configure_monitors(config.NUM_UNITS, config.DISABLE_FLOW_VIEW == true)
if not configured or monitors == nil then
println("startup> monitor setup failed")
log.fatal("monitor configuration failed")
@ -92,6 +92,7 @@ local function main()
end
-- init renderer
renderer.legacy_disable_flow_view(config.DISABLE_FLOW_VIEW == true)
renderer.set_displays(monitors)
renderer.init_displays()
@ -99,6 +100,10 @@ local function main()
println("startup> main display must be 8 blocks wide")
log.fatal("main display not wide enough")
return
elseif (config.DISABLE_FLOW_VIEW ~= true) and not renderer.validate_flow_display_width() then
println("startup> flow display must be 8 blocks wide")
log.fatal("flow display not wide enough")
return
elseif not renderer.validate_unit_display_sizes() then
println("startup> one or more unit display dimensions incorrect; they must be 4x4 blocks")
log.fatal("unit display dimensions incorrect")
@ -162,8 +167,8 @@ local function main()
-- create network interface then setup comms
local nic = network.nic(modem)
local coord_comms = coordinator.comms(COORDINATOR_VERSION, nic, config.CRD_CHANNEL, config.SVR_CHANNEL,
config.PKT_CHANNEL, config.TRUSTED_RANGE, conn_watchdog)
local coord_comms = coordinator.comms(COORDINATOR_VERSION, nic, config.NUM_UNITS, config.CRD_CHANNEL,
config.SVR_CHANNEL, config.PKT_CHANNEL, config.TRUSTED_RANGE, conn_watchdog)
log.debug("startup> comms init")
log_comms("comms initialized")

View File

@ -83,9 +83,7 @@ local function new_view(root, x, y, data, ps, id)
local function calc_saturation(val)
if (type(data.build) == "table") and (type(data.build.transfer_cap) == "number") and (data.build.transfer_cap > 0) then
return val / data.build.transfer_cap
else
return 0
end
else return 0 end
end
charge.register(ps, "energy_fill", charge.update)

View File

@ -28,6 +28,16 @@ local TEXT_ALIGN = core.TEXT_ALIGN
local cpair = core.cpair
local border = core.border
local bw_fg_bg = style.bw_fg_bg
local lu_cpair = style.lu_colors
local hzd_fg_bg = style.hzd_fg_bg
local dis_colors = style.dis_colors
local ind_grn = style.ind_grn
local ind_yel = style.ind_yel
local ind_red = style.ind_red
local ind_wht = style.ind_wht
local period = core.flasher.PERIOD
-- new process control view
@ -40,11 +50,6 @@ local function new_view(root, x, y)
local facility = iocontrol.get_db().facility
local units = iocontrol.get_db().units
local bw_fg_bg = cpair(colors.black, colors.white)
local hzd_fg_bg = cpair(colors.white, colors.gray)
local lu_cpair = cpair(colors.gray, colors.gray)
local dis_colors = cpair(colors.white, colors.lightGray)
local main = Div{parent=root,width=128,height=24,x=x,y=y}
local scram = HazardButton{parent=main,x=1,y=1,text="FAC SCRAM",accent=colors.yellow,dis_colors=dis_colors,callback=process.fac_scram,fg_bg=hzd_fg_bg}
@ -53,10 +58,10 @@ local function new_view(root, x, y)
facility.scram_ack = scram.on_response
facility.ack_alarms_ack = ack_a.on_response
local all_ok = IndicatorLight{parent=main,y=5,label="Unit Systems Online",colors=cpair(colors.green,colors.red)}
local all_ok = IndicatorLight{parent=main,y=5,label="Unit Systems Online",colors=ind_grn}
local rad_mon = TriIndicatorLight{parent=main,label="Radiation Monitor",c1=colors.gray,c2=colors.yellow,c3=colors.green}
local ind_mat = IndicatorLight{parent=main,label="Induction Matrix",colors=cpair(colors.green,colors.gray)}
local sps = IndicatorLight{parent=main,label="SPS Connected",colors=cpair(colors.green,colors.gray)}
local ind_mat = IndicatorLight{parent=main,label="Induction Matrix",colors=ind_grn}
local sps = IndicatorLight{parent=main,label="SPS Connected",colors=ind_grn}
all_ok.register(facility.ps, "all_sys_ok", all_ok.update)
rad_mon.register(facility.ps, "rad_computed_status", rad_mon.update)
@ -65,10 +70,10 @@ local function new_view(root, x, y)
main.line_break()
local auto_ready = IndicatorLight{parent=main,label="Configured Units Ready",colors=cpair(colors.green,colors.red)}
local auto_act = IndicatorLight{parent=main,label="Process Active",colors=cpair(colors.green,colors.gray)}
local auto_ramp = IndicatorLight{parent=main,label="Process Ramping",colors=cpair(colors.white,colors.gray),flash=true,period=period.BLINK_250_MS}
local auto_sat = IndicatorLight{parent=main,label="Min/Max Burn Rate",colors=cpair(colors.yellow,colors.gray)}
local auto_ready = IndicatorLight{parent=main,label="Configured Units Ready",colors=ind_grn}
local auto_act = IndicatorLight{parent=main,label="Process Active",colors=ind_grn}
local auto_ramp = IndicatorLight{parent=main,label="Process Ramping",colors=ind_wht,flash=true,period=period.BLINK_250_MS}
local auto_sat = IndicatorLight{parent=main,label="Min/Max Burn Rate",colors=ind_yel}
auto_ready.register(facility.ps, "auto_ready", auto_ready.update)
auto_act.register(facility.ps, "auto_active", auto_act.update)
@ -77,12 +82,12 @@ local function new_view(root, x, y)
main.line_break()
local auto_scram = IndicatorLight{parent=main,label="Automatic SCRAM",colors=cpair(colors.red,colors.gray),flash=true,period=period.BLINK_250_MS}
local matrix_dc = IndicatorLight{parent=main,label="Matrix Disconnected",colors=cpair(colors.yellow,colors.gray),flash=true,period=period.BLINK_500_MS}
local matrix_fill = IndicatorLight{parent=main,label="Matrix Charge High",colors=cpair(colors.red,colors.gray),flash=true,period=period.BLINK_500_MS}
local unit_crit = IndicatorLight{parent=main,label="Unit Critical Alarm",colors=cpair(colors.red,colors.gray),flash=true,period=period.BLINK_250_MS}
local fac_rad_h = IndicatorLight{parent=main,label="Facility Radiation High",colors=cpair(colors.red,colors.gray),flash=true,period=period.BLINK_250_MS}
local gen_fault = IndicatorLight{parent=main,label="Gen. Control Fault",colors=cpair(colors.yellow,colors.gray),flash=true,period=period.BLINK_500_MS}
local auto_scram = IndicatorLight{parent=main,label="Automatic SCRAM",colors=ind_red,flash=true,period=period.BLINK_250_MS}
local matrix_dc = IndicatorLight{parent=main,label="Matrix Disconnected",colors=ind_yel,flash=true,period=period.BLINK_500_MS}
local matrix_fill = IndicatorLight{parent=main,label="Matrix Charge High",colors=ind_red,flash=true,period=period.BLINK_500_MS}
local unit_crit = IndicatorLight{parent=main,label="Unit Critical Alarm",colors=ind_red,flash=true,period=period.BLINK_250_MS}
local fac_rad_h = IndicatorLight{parent=main,label="Facility Radiation High",colors=ind_red,flash=true,period=period.BLINK_250_MS}
local gen_fault = IndicatorLight{parent=main,label="Gen. Control Fault",colors=ind_yel,flash=true,period=period.BLINK_500_MS}
auto_scram.register(facility.ps, "auto_scram", auto_scram.update)
matrix_dc.register(facility.ps, "as_matrix_dc", matrix_dc.update)
@ -307,7 +312,7 @@ local function new_view(root, x, y)
local unit = units[i] ---@type ioctl_unit
TextBox{parent=waste_status,y=i,text="U"..i.." Waste",width=8,height=1}
local a_waste = IndicatorLight{parent=waste_status,x=10,y=i,label="Auto",colors=cpair(colors.white,colors.gray)}
local a_waste = IndicatorLight{parent=waste_status,x=10,y=i,label="Auto",colors=ind_wht}
local waste_m = StateIndicator{parent=waste_status,x=17,y=i,states=style.waste.states_abbrv,value=1,min_width=6}
a_waste.register(unit.unit_ps, "U_AutoWaste", a_waste.update)
@ -330,7 +335,7 @@ local function new_view(root, x, y)
waste_prod.register(facility.ps, "process_waste_product", waste_prod.set_value)
pu_fallback.register(facility.ps, "process_pu_fallback", pu_fallback.set_value)
local fb_active = IndicatorLight{parent=rect,x=2,y=9,label="Fallback Active",colors=cpair(colors.white,colors.gray)}
local fb_active = IndicatorLight{parent=rect,x=2,y=9,label="Fallback Active",colors=ind_wht}
fb_active.register(facility.ps, "pu_fallback_active", fb_active.update)
@ -341,7 +346,7 @@ local function new_view(root, x, y)
local po_rate = DataIndicator{parent=rect,x=2,label="",unit="mB/t",format="%12.2f",value=0,lu_colors=lu_cpair,fg_bg=bw_fg_bg,width=17}
TextBox{parent=rect,x=2,y=17,text="Antimatter Rate",height=1,width=17,fg_bg=style.label}
local am_rate = DataIndicator{parent=rect,x=2,label="",unit="\xb5B/t",format="%12.2f",value=0,lu_colors=lu_cpair,fg_bg=bw_fg_bg,width=17}
local am_rate = DataIndicator{parent=rect,x=2,label="",unit="\xb5B/t",format="%12d",value=0,lu_colors=lu_cpair,fg_bg=bw_fg_bg,width=17}
pu_rate.register(facility.ps, "pu_rate", pu_rate.update)
po_rate.register(facility.ps, "po_rate", po_rate.update)

View File

@ -31,7 +31,7 @@ local function new_view(root, x, y, ps)
local flow_rate = DataIndicator{parent=turbine,x=5,y=4,lu_colors=lu_col,label="",unit="mB/t",format="%10.0f",value=0,commas=true,width=16,fg_bg=text_fg_bg}
status.register(ps, "computed_status", status.update)
prod_rate.register(ps, "prod_rate", function (val) prod_rate.update(util.joules_to_fe(val)) end)
prod_rate.register(ps, "steam_input_rate", function (val) prod_rate.update(util.joules_to_fe(val)) end)
flow_rate.register(ps, "flow_rate", flow_rate.update)
local steam = VerticalBar{parent=turbine,x=2,y=1,fg_bg=cpair(colors.white,colors.gray),height=4,width=1}

View File

@ -31,6 +31,15 @@ local TEXT_ALIGN = core.TEXT_ALIGN
local cpair = core.cpair
local border = core.border
local bw_fg_bg = style.bw_fg_bg
local lu_cpair = style.lu_colors
local hzd_fg_bg = style.hzd_fg_bg
local ind_grn = style.ind_grn
local ind_yel = style.ind_yel
local ind_red = style.ind_red
local ind_wht = style.ind_wht
local period = core.flasher.PERIOD
-- create a unit view
@ -50,10 +59,6 @@ local function init(parent, id)
TextBox{parent=main,text="Reactor Unit #" .. id,alignment=TEXT_ALIGN.CENTER,height=1,fg_bg=style.header}
local bw_fg_bg = cpair(colors.black, colors.white)
local hzd_fg_bg = cpair(colors.white, colors.gray)
local lu_cpair = cpair(colors.gray, colors.gray)
-----------------------------
-- main stats and core map --
-----------------------------
@ -140,7 +145,7 @@ local function init(parent, id)
-- connectivity
local plc_online = IndicatorLight{parent=annunciator,label="PLC Online",colors=cpair(colors.green,colors.red)}
local plc_hbeat = IndicatorLight{parent=annunciator,label="PLC Heartbeat",colors=cpair(colors.white,colors.gray)}
local plc_hbeat = IndicatorLight{parent=annunciator,label="PLC Heartbeat",colors=ind_wht}
local rad_mon = TriIndicatorLight{parent=annunciator,label="Radiation Monitor",c1=colors.gray,c2=colors.yellow,c3=colors.green}
plc_online.register(u_ps, "PLCOnline", plc_online.update)
@ -150,25 +155,25 @@ local function init(parent, id)
annunciator.line_break()
-- operating state
local r_active = IndicatorLight{parent=annunciator,label="Active",colors=cpair(colors.green,colors.gray)}
local r_auto = IndicatorLight{parent=annunciator,label="Automatic Control",colors=cpair(colors.white,colors.gray)}
local r_active = IndicatorLight{parent=annunciator,label="Active",colors=ind_grn}
local r_auto = IndicatorLight{parent=annunciator,label="Automatic Control",colors=ind_wht}
r_active.register(u_ps, "status", r_active.update)
r_auto.register(u_ps, "AutoControl", r_auto.update)
-- main unit transient/warning annunciator panel
local r_scram = IndicatorLight{parent=annunciator,label="Reactor SCRAM",colors=cpair(colors.red,colors.gray)}
local r_mscrm = IndicatorLight{parent=annunciator,label="Manual Reactor SCRAM",colors=cpair(colors.red,colors.gray)}
local r_ascrm = IndicatorLight{parent=annunciator,label="Auto Reactor SCRAM",colors=cpair(colors.red,colors.gray)}
local rad_wrn = IndicatorLight{parent=annunciator,label="Radiation Warning",colors=cpair(colors.yellow,colors.gray)}
local r_rtrip = IndicatorLight{parent=annunciator,label="RCP Trip",colors=cpair(colors.red,colors.gray)}
local r_cflow = IndicatorLight{parent=annunciator,label="RCS Flow Low",colors=cpair(colors.yellow,colors.gray)}
local r_clow = IndicatorLight{parent=annunciator,label="Coolant Level Low",colors=cpair(colors.yellow,colors.gray)}
local r_temp = IndicatorLight{parent=annunciator,label="Reactor Temp. High",colors=cpair(colors.red,colors.gray)}
local r_rhdt = IndicatorLight{parent=annunciator,label="Reactor High Delta T",colors=cpair(colors.yellow,colors.gray)}
local r_firl = IndicatorLight{parent=annunciator,label="Fuel Input Rate Low",colors=cpair(colors.yellow,colors.gray)}
local r_wloc = IndicatorLight{parent=annunciator,label="Waste Line Occlusion",colors=cpair(colors.yellow,colors.gray)}
local r_hsrt = IndicatorLight{parent=annunciator,label="Startup Rate High",colors=cpair(colors.yellow,colors.gray)}
local r_scram = IndicatorLight{parent=annunciator,label="Reactor SCRAM",colors=ind_red}
local r_mscrm = IndicatorLight{parent=annunciator,label="Manual Reactor SCRAM",colors=ind_red}
local r_ascrm = IndicatorLight{parent=annunciator,label="Auto Reactor SCRAM",colors=ind_red}
local rad_wrn = IndicatorLight{parent=annunciator,label="Radiation Warning",colors=ind_yel}
local r_rtrip = IndicatorLight{parent=annunciator,label="RCP Trip",colors=ind_red}
local r_cflow = IndicatorLight{parent=annunciator,label="RCS Flow Low",colors=ind_yel}
local r_clow = IndicatorLight{parent=annunciator,label="Coolant Level Low",colors=ind_yel}
local r_temp = IndicatorLight{parent=annunciator,label="Reactor Temp. High",colors=ind_red}
local r_rhdt = IndicatorLight{parent=annunciator,label="Reactor High Delta T",colors=ind_yel}
local r_firl = IndicatorLight{parent=annunciator,label="Fuel Input Rate Low",colors=ind_yel}
local r_wloc = IndicatorLight{parent=annunciator,label="Waste Line Occlusion",colors=ind_yel}
local r_hsrt = IndicatorLight{parent=annunciator,label="Startup Rate High",colors=ind_yel}
r_scram.register(u_ps, "ReactorSCRAM", r_scram.update)
r_mscrm.register(u_ps, "ManualReactorSCRAM", r_mscrm.update)
@ -189,15 +194,15 @@ local function init(parent, id)
local rps = Rectangle{parent=main,border=border(1,colors.cyan,true),thin=true,width=33,height=12,x=46,y=9}
local rps_annunc = Div{parent=rps,width=31,height=10,x=2,y=1}
local rps_trp = IndicatorLight{parent=rps_annunc,label="RPS Trip",colors=cpair(colors.red,colors.gray),flash=true,period=period.BLINK_250_MS}
local rps_dmg = IndicatorLight{parent=rps_annunc,label="Damage Level High",colors=cpair(colors.red,colors.gray),flash=true,period=period.BLINK_250_MS}
local rps_exh = IndicatorLight{parent=rps_annunc,label="Excess Heated Coolant",colors=cpair(colors.yellow,colors.gray)}
local rps_exw = IndicatorLight{parent=rps_annunc,label="Excess Waste",colors=cpair(colors.yellow,colors.gray)}
local rps_tmp = IndicatorLight{parent=rps_annunc,label="Core Temperature High",colors=cpair(colors.red,colors.gray),flash=true,period=period.BLINK_250_MS}
local rps_nof = IndicatorLight{parent=rps_annunc,label="No Fuel",colors=cpair(colors.yellow,colors.gray)}
local rps_loc = IndicatorLight{parent=rps_annunc,label="Coolant Level Low Low",colors=cpair(colors.yellow,colors.gray)}
local rps_flt = IndicatorLight{parent=rps_annunc,label="PPM Fault",colors=cpair(colors.yellow,colors.gray),flash=true,period=period.BLINK_500_MS}
local rps_tmo = IndicatorLight{parent=rps_annunc,label="Connection Timeout",colors=cpair(colors.yellow,colors.gray),flash=true,period=period.BLINK_500_MS}
local rps_trp = IndicatorLight{parent=rps_annunc,label="RPS Trip",colors=ind_red,flash=true,period=period.BLINK_250_MS}
local rps_dmg = IndicatorLight{parent=rps_annunc,label="Damage Level High",colors=ind_red,flash=true,period=period.BLINK_250_MS}
local rps_exh = IndicatorLight{parent=rps_annunc,label="Excess Heated Coolant",colors=ind_yel}
local rps_exw = IndicatorLight{parent=rps_annunc,label="Excess Waste",colors=ind_yel}
local rps_tmp = IndicatorLight{parent=rps_annunc,label="Core Temperature High",colors=ind_red,flash=true,period=period.BLINK_250_MS}
local rps_nof = IndicatorLight{parent=rps_annunc,label="No Fuel",colors=ind_yel}
local rps_loc = IndicatorLight{parent=rps_annunc,label="Coolant Level Low Low",colors=ind_yel}
local rps_flt = IndicatorLight{parent=rps_annunc,label="PPM Fault",colors=ind_yel,flash=true,period=period.BLINK_500_MS}
local rps_tmo = IndicatorLight{parent=rps_annunc,label="Connection Timeout",colors=ind_yel,flash=true,period=period.BLINK_500_MS}
local rps_sfl = IndicatorLight{parent=rps_annunc,label="System Failure",colors=cpair(colors.orange,colors.gray),flash=true,period=period.BLINK_500_MS}
rps_trp.register(u_ps, "rps_tripped", rps_trp.update)
@ -218,12 +223,12 @@ local function init(parent, id)
local rcs_annunc = Div{parent=rcs,width=27,height=22,x=3,y=1}
local rcs_tags = Div{parent=rcs,width=2,height=16,x=1,y=7}
local c_flt = IndicatorLight{parent=rcs_annunc,label="RCS Hardware Fault",colors=cpair(colors.yellow,colors.gray)}
local c_flt = IndicatorLight{parent=rcs_annunc,label="RCS Hardware Fault",colors=ind_yel}
local c_emg = TriIndicatorLight{parent=rcs_annunc,label="Emergency Coolant",c1=colors.gray,c2=colors.white,c3=colors.green}
local c_cfm = IndicatorLight{parent=rcs_annunc,label="Coolant Feed Mismatch",colors=cpair(colors.yellow,colors.gray)}
local c_brm = IndicatorLight{parent=rcs_annunc,label="Boil Rate Mismatch",colors=cpair(colors.yellow,colors.gray)}
local c_sfm = IndicatorLight{parent=rcs_annunc,label="Steam Feed Mismatch",colors=cpair(colors.yellow,colors.gray)}
local c_mwrf = IndicatorLight{parent=rcs_annunc,label="Max Water Return Feed",colors=cpair(colors.yellow,colors.gray)}
local c_cfm = IndicatorLight{parent=rcs_annunc,label="Coolant Feed Mismatch",colors=ind_yel}
local c_brm = IndicatorLight{parent=rcs_annunc,label="Boil Rate Mismatch",colors=ind_yel}
local c_sfm = IndicatorLight{parent=rcs_annunc,label="Steam Feed Mismatch",colors=ind_yel}
local c_mwrf = IndicatorLight{parent=rcs_annunc,label="Max Water Return Feed",colors=ind_yel}
c_flt.register(u_ps, "RCSFault", c_flt.update)
c_emg.register(u_ps, "EmergencyCoolant", c_emg.update)
@ -246,11 +251,11 @@ local function init(parent, id)
if unit.num_boilers > 0 then
TextBox{parent=rcs_tags,x=1,text="B1",width=2,height=1,fg_bg=bw_fg_bg}
local b1_wll = IndicatorLight{parent=rcs_annunc,label="Water Level Low",colors=cpair(colors.red,colors.gray)}
local b1_wll = IndicatorLight{parent=rcs_annunc,label="Water Level Low",colors=ind_red}
b1_wll.register(b_ps[1], "WasterLevelLow", b1_wll.update)
TextBox{parent=rcs_tags,text="B1",width=2,height=1,fg_bg=bw_fg_bg}
local b1_hr = IndicatorLight{parent=rcs_annunc,label="Heating Rate Low",colors=cpair(colors.yellow,colors.gray)}
local b1_hr = IndicatorLight{parent=rcs_annunc,label="Heating Rate Low",colors=ind_yel}
b1_hr.register(b_ps[1], "HeatingRateLow", b1_hr.update)
end
if unit.num_boilers > 1 then
@ -262,11 +267,11 @@ local function init(parent, id)
end
TextBox{parent=rcs_tags,text="B2",width=2,height=1,fg_bg=bw_fg_bg}
local b2_wll = IndicatorLight{parent=rcs_annunc,label="Water Level Low",colors=cpair(colors.red,colors.gray)}
local b2_wll = IndicatorLight{parent=rcs_annunc,label="Water Level Low",colors=ind_red}
b2_wll.register(b_ps[2], "WasterLevelLow", b2_wll.update)
TextBox{parent=rcs_tags,text="B2",width=2,height=1,fg_bg=bw_fg_bg}
local b2_hr = IndicatorLight{parent=rcs_annunc,label="Heating Rate Low",colors=cpair(colors.yellow,colors.gray)}
local b2_hr = IndicatorLight{parent=rcs_annunc,label="Heating Rate Low",colors=ind_yel}
b2_hr.register(b_ps[2], "HeatingRateLow", b2_hr.update)
end
@ -279,15 +284,15 @@ local function init(parent, id)
t1_sdo.register(t_ps[1], "SteamDumpOpen", t1_sdo.update)
TextBox{parent=rcs_tags,text="T1",width=2,height=1,fg_bg=bw_fg_bg}
local t1_tos = IndicatorLight{parent=rcs_annunc,label="Turbine Over Speed",colors=cpair(colors.red,colors.gray)}
local t1_tos = IndicatorLight{parent=rcs_annunc,label="Turbine Over Speed",colors=ind_red}
t1_tos.register(t_ps[1], "TurbineOverSpeed", t1_tos.update)
TextBox{parent=rcs_tags,text="T1",width=2,height=1,fg_bg=bw_fg_bg}
local t1_gtrp = IndicatorLight{parent=rcs_annunc,label="Generator Trip",colors=cpair(colors.yellow,colors.gray),flash=true,period=period.BLINK_250_MS}
local t1_gtrp = IndicatorLight{parent=rcs_annunc,label="Generator Trip",colors=ind_yel,flash=true,period=period.BLINK_250_MS}
t1_gtrp.register(t_ps[1], "GeneratorTrip", t1_gtrp.update)
TextBox{parent=rcs_tags,text="T1",width=2,height=1,fg_bg=bw_fg_bg}
local t1_trp = IndicatorLight{parent=rcs_annunc,label="Turbine Trip",colors=cpair(colors.red,colors.gray),flash=true,period=period.BLINK_250_MS}
local t1_trp = IndicatorLight{parent=rcs_annunc,label="Turbine Trip",colors=ind_red,flash=true,period=period.BLINK_250_MS}
t1_trp.register(t_ps[1], "TurbineTrip", t1_trp.update)
if unit.num_turbines > 1 then
@ -300,15 +305,15 @@ local function init(parent, id)
t2_sdo.register(t_ps[2], "SteamDumpOpen", t2_sdo.update)
TextBox{parent=rcs_tags,text="T2",width=2,height=1,fg_bg=bw_fg_bg}
local t2_tos = IndicatorLight{parent=rcs_annunc,label="Turbine Over Speed",colors=cpair(colors.red,colors.gray)}
local t2_tos = IndicatorLight{parent=rcs_annunc,label="Turbine Over Speed",colors=ind_red}
t2_tos.register(t_ps[2], "TurbineOverSpeed", t2_tos.update)
TextBox{parent=rcs_tags,text="T2",width=2,height=1,fg_bg=bw_fg_bg}
local t2_gtrp = IndicatorLight{parent=rcs_annunc,label="Generator Trip",colors=cpair(colors.yellow,colors.gray),flash=true,period=period.BLINK_250_MS}
local t2_gtrp = IndicatorLight{parent=rcs_annunc,label="Generator Trip",colors=ind_yel,flash=true,period=period.BLINK_250_MS}
t2_gtrp.register(t_ps[2], "GeneratorTrip", t2_gtrp.update)
TextBox{parent=rcs_tags,text="T2",width=2,height=1,fg_bg=bw_fg_bg}
local t2_trp = IndicatorLight{parent=rcs_annunc,label="Turbine Trip",colors=cpair(colors.red,colors.gray),flash=true,period=period.BLINK_250_MS}
local t2_trp = IndicatorLight{parent=rcs_annunc,label="Turbine Trip",colors=ind_red,flash=true,period=period.BLINK_250_MS}
t2_trp.register(t_ps[2], "TurbineTrip", t2_trp.update)
end
@ -320,15 +325,15 @@ local function init(parent, id)
t3_sdo.register(t_ps[3], "SteamDumpOpen", t3_sdo.update)
TextBox{parent=rcs_tags,text="T3",width=2,height=1,fg_bg=bw_fg_bg}
local t3_tos = IndicatorLight{parent=rcs_annunc,label="Turbine Over Speed",colors=cpair(colors.red,colors.gray)}
local t3_tos = IndicatorLight{parent=rcs_annunc,label="Turbine Over Speed",colors=ind_red}
t3_tos.register(t_ps[3], "TurbineOverSpeed", t3_tos.update)
TextBox{parent=rcs_tags,text="T3",width=2,height=1,fg_bg=bw_fg_bg}
local t3_gtrp = IndicatorLight{parent=rcs_annunc,label="Generator Trip",colors=cpair(colors.yellow,colors.gray),flash=true,period=period.BLINK_250_MS}
local t3_gtrp = IndicatorLight{parent=rcs_annunc,label="Generator Trip",colors=ind_yel,flash=true,period=period.BLINK_250_MS}
t3_gtrp.register(t_ps[3], "GeneratorTrip", t3_gtrp.update)
TextBox{parent=rcs_tags,text="T3",width=2,height=1,fg_bg=bw_fg_bg}
local t3_trp = IndicatorLight{parent=rcs_annunc,label="Turbine Trip",colors=cpair(colors.red,colors.gray),flash=true,period=period.BLINK_250_MS}
local t3_trp = IndicatorLight{parent=rcs_annunc,label="Turbine Trip",colors=ind_red,flash=true,period=period.BLINK_250_MS}
t3_trp.register(t_ps[3], "TurbineTrip", t3_trp.update)
end
@ -343,7 +348,7 @@ local function init(parent, id)
TextBox{parent=burn_control,x=9,y=2,text="mB/t"}
local set_burn = function () unit.set_burn(burn_rate.get_value()) end
local set_burn_btn = PushButton{parent=burn_control,x=14,y=2,text="SET",min_width=5,fg_bg=cpair(colors.black,colors.yellow),active_fg_bg=cpair(colors.white,colors.gray),dis_fg_bg=dis_colors,callback=set_burn}
local set_burn_btn = PushButton{parent=burn_control,x=14,y=2,text="SET",min_width=5,fg_bg=cpair(colors.black,colors.yellow),active_fg_bg=style.wh_gray,dis_fg_bg=dis_colors,callback=set_burn}
burn_rate.register(u_ps, "burn_rate", burn_rate.set_value)
burn_rate.register(u_ps, "max_burn", burn_rate.set_max)
@ -475,7 +480,7 @@ local function init(parent, id)
auto_div.line_break()
local function set_group() unit.set_group(group.get_value() - 1) end
local set_grp_btn = PushButton{parent=auto_div,text="SET",x=4,min_width=5,fg_bg=cpair(colors.black,colors.yellow),active_fg_bg=cpair(colors.white,colors.gray),dis_fg_bg=cpair(colors.gray,colors.white),callback=set_group}
local set_grp_btn = PushButton{parent=auto_div,text="SET",x=4,min_width=5,fg_bg=cpair(colors.black,colors.yellow),active_fg_bg=style.wh_gray,dis_fg_bg=cpair(colors.gray,colors.white),callback=set_group}
auto_div.line_break()
@ -486,8 +491,8 @@ local function init(parent, id)
auto_div.line_break()
local a_rdy = IndicatorLight{parent=auto_div,label="Ready",x=2,colors=cpair(colors.green,colors.gray)}
local a_stb = IndicatorLight{parent=auto_div,label="Standby",x=2,colors=cpair(colors.white,colors.gray),flash=true,period=period.BLINK_1000_MS}
local a_rdy = IndicatorLight{parent=auto_div,label="Ready",x=2,colors=ind_grn}
local a_stb = IndicatorLight{parent=auto_div,label="Standby",x=2,colors=ind_wht,flash=true,period=period.BLINK_1000_MS}
a_rdy.register(u_ps, "U_AutoReady", a_rdy.update)

View File

@ -0,0 +1,225 @@
--
-- Basic Unit Flow Overview
--
local util = require("scada-common.util")
local style = require("coordinator.ui.style")
local core = require("graphics.core")
local Div = require("graphics.elements.div")
local PipeNetwork = require("graphics.elements.pipenet")
local TextBox = require("graphics.elements.textbox")
local Rectangle = require("graphics.elements.rectangle")
local DataIndicator = require("graphics.elements.indicators.data")
local IndicatorLight = require("graphics.elements.indicators.light")
local TriIndicatorLight = require("graphics.elements.indicators.trilight")
local TEXT_ALIGN = core.TEXT_ALIGN
local sprintf = util.sprintf
local cpair = core.cpair
local border = core.border
local pipe = core.pipe
local wh_gray = style.wh_gray
local bw_fg_bg = style.bw_fg_bg
local text_c = style.text_colors
local lu_c = style.lu_colors
local ind_grn = style.ind_grn
local ind_wht = style.ind_wht
-- make a new unit flow window
---@param parent graphics_element parent
---@param x integer top left x
---@param y integer top left y
---@param wide boolean whether to render wide version
---@param unit ioctl_unit unit database entry
local function make(parent, x, y, wide, unit)
local height = 16
local v_start = 1 + ((unit.unit_id - 1) * 5)
local prv_start = 1 + ((unit.unit_id - 1) * 3)
local v_fields = { "pu", "po", "pl", "am" }
local v_names = {
sprintf("PV%02d-PU", v_start),
sprintf("PV%02d-PO", v_start + 1),
sprintf("PV%02d-PL", v_start + 2),
sprintf("PV%02d-AM", v_start + 3),
sprintf("PRV%02d", prv_start),
sprintf("PRV%02d", prv_start + 1),
sprintf("PRV%02d", prv_start + 2)
}
assert(parent.get_height() >= (y + height), "flow display not of sufficient vertical resolution (add an additional row of monitors) " .. y .. "," .. parent.get_height())
local function _wide(a, b) return util.trinary(wide, a, b) end
-- bounding box div
local root = Div{parent=parent,x=x,y=y,width=_wide(136, 114),height=height}
local lg_gray = cpair(colors.lightGray, colors.gray)
------------------
-- COOLING LOOP --
------------------
local reactor = Rectangle{parent=root,x=1,y=1,border=border(1,colors.gray,true),width=19,height=5,fg_bg=wh_gray}
TextBox{parent=reactor,y=1,text="FISSION REACTOR",alignment=TEXT_ALIGN.CENTER,height=1}
TextBox{parent=reactor,y=3,text="UNIT #"..unit.unit_id,alignment=TEXT_ALIGN.CENTER,height=1}
TextBox{parent=root,x=19,y=2,text="\x1b \x80 \x1a",width=1,height=3,fg_bg=lg_gray}
TextBox{parent=root,x=3,y=5,text="\x19",width=1,height=1,fg_bg=lg_gray}
local rc_pipes = {}
local emc_x = 42 -- emergency coolant connection x point
if unit.num_boilers > 0 then
table.insert(rc_pipes, pipe(0, 1, _wide(28, 19), 1, colors.lightBlue, true))
table.insert(rc_pipes, pipe(0, 3, _wide(28, 19), 3, colors.orange, true))
table.insert(rc_pipes, pipe(_wide(46 ,39), 1, _wide(72,58), 1, colors.blue, true))
table.insert(rc_pipes, pipe(_wide(46,39), 3, _wide(72,58), 3, colors.white, true))
else
emc_x = 3
table.insert(rc_pipes, pipe(0, 1, _wide(72,58), 1, colors.blue, true))
table.insert(rc_pipes, pipe(0, 3, _wide(72,58), 3, colors.white, true))
end
if unit.has_tank then
table.insert(rc_pipes, pipe(emc_x, 1, emc_x, 0, colors.blue, true, true))
end
local prv_yo = math.max(3 - unit.num_turbines, 0)
for i = 1, unit.num_turbines do
local py = 2 * (i - 1) + prv_yo
table.insert(rc_pipes, pipe(_wide(92, 78), py, _wide(104, 83), py, colors.white, true))
end
PipeNetwork{parent=root,x=20,y=1,pipes=rc_pipes,bg=colors.lightGray}
if unit.num_boilers > 0 then
local cc_rate = DataIndicator{parent=root,x=_wide(25,22),y=5,lu_colors=lu_c,label="",unit="mB/t",format="%11.0f",value=0,commas=true,width=16,fg_bg=bw_fg_bg}
local hc_rate = DataIndicator{parent=root,x=_wide(25,22),y=3,lu_colors=lu_c,label="",unit="mB/t",format="%11.0f",value=0,commas=true,width=16,fg_bg=bw_fg_bg}
cc_rate.register(unit.unit_ps, "boil_sum", cc_rate.update)
hc_rate.register(unit.unit_ps, "heating_rate", hc_rate.update)
local boiler = Rectangle{parent=root,x=_wide(47,40),y=1,border=border(1, colors.gray, true),width=19,height=5,fg_bg=wh_gray}
TextBox{parent=boiler,y=1,text="THERMO-ELECTRIC",alignment=TEXT_ALIGN.CENTER,height=1}
TextBox{parent=boiler,y=3,text="BOILERS",alignment=TEXT_ALIGN.CENTER,height=1}
TextBox{parent=root,x=_wide(47,40),y=2,text="\x1b \x80 \x1a",width=1,height=3,fg_bg=lg_gray}
TextBox{parent=root,x=_wide(65,58),y=2,text="\x1b \x80 \x1a",width=1,height=3,fg_bg=lg_gray}
local wt_rate = DataIndicator{parent=root,x=_wide(71,61),y=3,lu_colors=lu_c,label="",unit="mB/t",format="%11.0f",value=0,commas=true,width=16,fg_bg=bw_fg_bg}
local st_rate = DataIndicator{parent=root,x=_wide(71,61),y=5,lu_colors=lu_c,label="",unit="mB/t",format="%11.0f",value=0,commas=true,width=16,fg_bg=bw_fg_bg}
wt_rate.register(unit.unit_ps, "turbine_flow_sum", wt_rate.update)
st_rate.register(unit.unit_ps, "boil_sum", st_rate.update)
else
local wt_rate = DataIndicator{parent=root,x=28,y=3,lu_colors=lu_c,label="",unit="mB/t",format="%11.0f",value=0,commas=true,width=16,fg_bg=bw_fg_bg}
local st_rate = DataIndicator{parent=root,x=28,y=5,lu_colors=lu_c,label="",unit="mB/t",format="%11.0f",value=0,commas=true,width=16,fg_bg=bw_fg_bg}
wt_rate.register(unit.unit_ps, "turbine_flow_sum", wt_rate.update)
st_rate.register(unit.unit_ps, "heating_rate", st_rate.update)
end
local turbine = Rectangle{parent=root,x=_wide(93,79),y=1,border=border(1, colors.gray, true),width=19,height=5,fg_bg=wh_gray}
TextBox{parent=turbine,y=1,text="STEAM TURBINE",alignment=TEXT_ALIGN.CENTER,height=1}
TextBox{parent=turbine,y=3,text=util.trinary(unit.num_turbines>1,"GENERATORS","GENERATOR"),alignment=TEXT_ALIGN.CENTER,height=1}
TextBox{parent=root,x=_wide(93,79),y=2,text="\x1b \x80 \x1a",width=1,height=3,fg_bg=lg_gray}
for i = 1, unit.num_turbines do
local ry = 1 + (2 * (i - 1)) + prv_yo
TextBox{parent=root,x=_wide(125,103),y=ry,text="\x10\x11\x7f",fg_bg=text_c,width=3,height=1}
local state = TriIndicatorLight{parent=root,x=_wide(129,107),y=ry,label=v_names[i+4],c1=colors.gray,c2=colors.yellow,c3=colors.red}
state.register(unit.turbine_ps_tbl[i], "SteamDumpOpen", state.update)
end
----------------------
-- WASTE PROCESSING --
----------------------
local waste = Div{parent=root,x=3,y=6}
local waste_pipes = {
pipe(0, 0, _wide(19, 16), 1, colors.brown, true),
pipe(_wide(14, 13), 1, _wide(19, 17), 5, colors.brown, true),
pipe(_wide(22, 19), 1, _wide(49, 45), 1, colors.brown, true),
pipe(_wide(22, 19), 5, _wide(28, 24), 5, colors.brown, true),
pipe(_wide(64, 53), 1, _wide(95, 81), 1, colors.green, true),
pipe(_wide(48, 43), 4, _wide(71, 61), 4, colors.cyan, true),
pipe(_wide(66, 57), 4, _wide(71, 61), 8, colors.cyan, true),
pipe(_wide(74, 63), 4, _wide(95, 81), 4, colors.cyan, true),
pipe(_wide(74, 63), 8, _wide(133, 111), 8, colors.cyan, true),
pipe(_wide(108, 94), 1, _wide(132, 110), 6, colors.black, true, true),
pipe(_wide(108, 94), 4, _wide(111, 95), 1, colors.black, true, true),
pipe(_wide(132, 110), 6, _wide(130, 108), 6, colors.black, true, true)
}
PipeNetwork{parent=waste,x=1,y=1,pipes=waste_pipes,bg=colors.lightGray}
local function _valve(vx, vy, n)
TextBox{parent=waste,x=vx,y=vy,text="\x10\x11",fg_bg=text_c,width=2,height=1}
local conn = IndicatorLight{parent=waste,x=vx-3,y=vy+1,label=v_names[n],colors=ind_grn}
local open = IndicatorLight{parent=waste,x=vx-3,y=vy+2,label="OPEN",colors=ind_wht}
conn.register(unit.unit_ps, util.c("V_", v_fields[n], "_conn"), conn.update)
open.register(unit.unit_ps, util.c("V_", v_fields[n], "_state"), open.update)
end
local function _machine(mx, my, name)
local l = string.len(name) + 2
TextBox{parent=waste,x=mx,y=my,text=string.rep("\x8f",l),alignment=TEXT_ALIGN.CENTER,fg_bg=lg_gray,width=l,height=1}
TextBox{parent=waste,x=mx,y=my+1,text=name,alignment=TEXT_ALIGN.CENTER,fg_bg=wh_gray,width=l,height=1}
end
local waste_rate = DataIndicator{parent=waste,x=1,y=3,lu_colors=lu_c,label="",unit="mB/t",format="%7.2f",value=0,width=12,fg_bg=bw_fg_bg}
local pu_rate = DataIndicator{parent=waste,x=_wide(82,70),y=3,lu_colors=lu_c,label="",unit="mB/t",format="%7.3f",value=0,width=12,fg_bg=bw_fg_bg}
local po_rate = DataIndicator{parent=waste,x=_wide(52,45),y=6,lu_colors=lu_c,label="",unit="mB/t",format="%7.3f",value=0,width=12,fg_bg=bw_fg_bg}
local popl_rate = DataIndicator{parent=waste,x=_wide(82,70),y=6,lu_colors=lu_c,label="",unit="mB/t",format="%7.3f",value=0,width=12,fg_bg=bw_fg_bg}
local poam_rate = DataIndicator{parent=waste,x=_wide(82,70),y=10,lu_colors=lu_c,label="",unit="mB/t",format="%7.3f",value=0,width=12,fg_bg=bw_fg_bg}
local spent_rate = DataIndicator{parent=waste,x=_wide(117,99),y=3,lu_colors=lu_c,label="",unit="mB/t",format="%7.3f",value=0,width=12,fg_bg=bw_fg_bg}
waste_rate.register(unit.unit_ps, "act_burn_rate", waste_rate.update)
pu_rate.register(unit.unit_ps, "pu_rate", pu_rate.update)
po_rate.register(unit.unit_ps, "po_rate", po_rate.update)
popl_rate.register(unit.unit_ps, "po_pl_rate", popl_rate.update)
poam_rate.register(unit.unit_ps, "po_am_rate", poam_rate.update)
spent_rate.register(unit.unit_ps, "ws_rate", spent_rate.update)
_valve(_wide(21, 18), 2, 1)
_valve(_wide(21, 18), 6, 2)
_valve(_wide(73, 62), 5, 3)
_valve(_wide(73, 62), 9, 4)
_machine(_wide(51, 45), 1, "CENTRIFUGE \x1a");
_machine(_wide(97, 83), 1, "PRC [Pu] \x1a");
_machine(_wide(97, 83), 4, "PRC [Po] \x1a");
_machine(_wide(116, 94), 6, "SPENT WASTE \x1b")
TextBox{parent=waste,x=_wide(30,25),y=3,text="SNAs [Po]",alignment=TEXT_ALIGN.CENTER,width=19,height=1,fg_bg=wh_gray}
local sna_po = Rectangle{parent=waste,x=_wide(30,25),y=4,border=border(1,colors.gray,true),width=19,height=7,thin=true,fg_bg=bw_fg_bg}
local sna_act = IndicatorLight{parent=sna_po,label="ACTIVE",colors=ind_grn}
local sna_cnt = DataIndicator{parent=sna_po,x=12,y=1,lu_colors=lu_c,label="CNT",unit="",format="%2d",value=0,width=7}
local sna_pk = DataIndicator{parent=sna_po,y=3,lu_colors=lu_c,label="PEAK",unit="mB/t",format="%7.2f",value=0,width=17}
local sna_max = DataIndicator{parent=sna_po,lu_colors=lu_c,label="MAX",unit="mB/t",format="%8.2f",value=0,width=17}
local sna_in = DataIndicator{parent=sna_po,lu_colors=lu_c,label="IN",unit="mB/t",format="%9.2f",value=0,width=17}
sna_act.register(unit.unit_ps, "po_rate", function (r) sna_act.update(r > 0) end)
sna_cnt.register(unit.unit_ps, "sna_count", sna_cnt.update)
sna_pk.register(unit.unit_ps, "sna_peak_rate", sna_pk.update)
sna_max.register(unit.unit_ps, "sna_prod_rate", sna_max.update)
sna_in.register(unit.unit_ps, "sna_in", sna_in.update)
return root
end
return make

View File

@ -0,0 +1,381 @@
--
-- Flow Monitor GUI
--
local types = require("scada-common.types")
local util = require("scada-common.util")
local iocontrol = require("coordinator.iocontrol")
local style = require("coordinator.ui.style")
local unit_flow = require("coordinator.ui.components.unit_flow")
local core = require("graphics.core")
local Div = require("graphics.elements.div")
local PipeNetwork = require("graphics.elements.pipenet")
local Rectangle = require("graphics.elements.rectangle")
local TextBox = require("graphics.elements.textbox")
local DataIndicator = require("graphics.elements.indicators.data")
local HorizontalBar = require("graphics.elements.indicators.hbar")
local IndicatorLight = require("graphics.elements.indicators.light")
local StateIndicator = require("graphics.elements.indicators.state")
local CONTAINER_MODE = types.CONTAINER_MODE
local TEXT_ALIGN = core.TEXT_ALIGN
local cpair = core.cpair
local border = core.border
local pipe = core.pipe
local wh_gray = style.wh_gray
local bw_fg_bg = style.bw_fg_bg
local text_col = style.text_colors
local lu_col = style.lu_colors
-- create new flow view
---@param main graphics_element main displaybox
local function init(main)
local facility = iocontrol.get_db().facility
local units = iocontrol.get_db().units
local tank_defs = facility.tank_defs
local tank_list = facility.tank_list
-- window header message
local header = TextBox{parent=main,y=1,text="Facility Coolant and Waste Flow Monitor",alignment=TEXT_ALIGN.CENTER,height=1,fg_bg=style.header}
-- max length example: "01:23:45 AM - Wednesday, September 28 2022"
local datetime = TextBox{parent=main,x=(header.get_width()-42),y=1,text="",alignment=TEXT_ALIGN.RIGHT,width=42,height=1,fg_bg=style.header}
datetime.register(facility.ps, "date_time", datetime.set_value)
local po_pipes = {}
local water_pipes = {}
-- get the y offset for this unit index
---@param idx integer unit index
local function y_ofs(idx) return ((idx - 1) * 20) end
-- determinte facility tank start/end from the definitions list
---@param start_idx integer start index of table iteration
---@param end_idx integer end index of table iteration
local function find_fdef(start_idx, end_idx)
local first, last = 4, 0
for i = start_idx, end_idx do
if tank_defs[i] == 2 then
last = i
if i < first then first = i end
end
end
return first, last
end
if facility.tank_mode == 0 or facility.tank_mode == 8 then
-- (0) tanks belong to reactor units OR (8) 4 total facility tanks (A B C D)
for i = 1, facility.num_units do
if units[i].has_tank then
local y = y_ofs(i)
table.insert(water_pipes, pipe(2, y, 2, y + 3, colors.blue, true))
table.insert(water_pipes, pipe(2, y, 21, y, colors.blue, true))
local u = units[i] ---@type ioctl_unit
local x = util.trinary(u.num_boilers == 0, 45, 84)
table.insert(water_pipes, pipe(21, y, x, y + 2, colors.blue, true, true))
end
end
else
-- setup connections for units with emergency coolant, always the same
for i = 1, #tank_defs do
if tank_defs[i] > 0 then
local y = y_ofs(i)
if tank_defs[i] == 2 then
table.insert(water_pipes, pipe(1, y, 21, y, colors.blue, true))
else
table.insert(water_pipes, pipe(2, y, 2, y + 3, colors.blue, true))
table.insert(water_pipes, pipe(2, y, 21, y, colors.blue, true))
end
local u = units[i] ---@type ioctl_unit
local x = util.trinary(u.num_boilers == 0, 45, 84)
table.insert(water_pipes, pipe(21, y, x, y + 2, colors.blue, true, true))
end
end
if facility.tank_mode == 1 then
-- (1) 1 total facility tank (A A A A)
local first_fdef, last_fdef = find_fdef(1, #tank_defs)
for i = 1, #tank_defs do
local y = y_ofs(i)
if i == first_fdef then
table.insert(water_pipes, pipe(0, y, 1, y + 5, colors.blue, true))
elseif i > first_fdef then
if i == last_fdef then
table.insert(water_pipes, pipe(0, y - 14, 0, y, colors.blue, true))
elseif i < last_fdef then
table.insert(water_pipes, pipe(0, y - 14, 0, y + 5, colors.blue, true))
end
end
end
elseif facility.tank_mode == 2 then
-- (2) 2 total facility tanks (A A A B)
local first_fdef, last_fdef = find_fdef(1, math.min(3, #tank_defs))
for i = 1, #tank_defs do
local y = y_ofs(i)
if i == 4 then
if tank_defs[i] == 2 then
table.insert(water_pipes, pipe(0, y, 1, y + 5, colors.blue, true))
end
elseif i == first_fdef then
table.insert(water_pipes, pipe(0, y, 1, y + 5, colors.blue, true))
elseif i > first_fdef then
if i == last_fdef then
table.insert(water_pipes, pipe(0, y - 14, 0, y, colors.blue, true))
elseif i < last_fdef then
table.insert(water_pipes, pipe(0, y - 14, 0, y + 5, colors.blue, true))
end
end
end
elseif facility.tank_mode == 3 then
-- (3) 2 total facility tanks (A A B B)
for _, a in pairs({ 1, 3 }) do
local b = a + 1
if tank_defs[a] == 2 then
table.insert(water_pipes, pipe(0, y_ofs(a), 1, y_ofs(a) + 6, colors.blue, true))
if tank_defs[b] == 2 then
table.insert(water_pipes, pipe(0, y_ofs(b) - 13, 1, y_ofs(b), colors.blue, true))
end
elseif tank_defs[b] == 2 then
table.insert(water_pipes, pipe(0, y_ofs(b), 1, y_ofs(b) + 6, colors.blue, true))
end
end
elseif facility.tank_mode == 4 then
-- (4) 2 total facility tanks (A B B B)
local first_fdef, last_fdef = find_fdef(2, #tank_defs)
for i = 1, #tank_defs do
local y = y_ofs(i)
if i == 1 then
if tank_defs[i] == 2 then
table.insert(water_pipes, pipe(0, y, 1, y + 5, colors.blue, true))
end
elseif i == first_fdef then
table.insert(water_pipes, pipe(0, y, 1, y + 5, colors.blue, true))
elseif i > first_fdef then
if i == last_fdef then
table.insert(water_pipes, pipe(0, y - 14, 0, y, colors.blue, true))
elseif i < last_fdef then
table.insert(water_pipes, pipe(0, y - 14, 0, y + 5, colors.blue, true))
end
end
end
elseif facility.tank_mode == 5 then
-- (5) 3 total facility tanks (A A B C)
local first_fdef, last_fdef = find_fdef(1, math.min(2, #tank_defs))
for i = 1, #tank_defs do
local y = y_ofs(i)
if i == 3 or i == 4 then
if tank_defs[i] == 2 then
table.insert(water_pipes, pipe(0, y, 1, y + 5, colors.blue, true))
end
elseif i == first_fdef then
table.insert(water_pipes, pipe(0, y, 1, y + 5, colors.blue, true))
elseif i > first_fdef then
if i == last_fdef then
table.insert(water_pipes, pipe(0, y - 14, 0, y, colors.blue, true))
elseif i < last_fdef then
table.insert(water_pipes, pipe(0, y - 14, 0, y + 5, colors.blue, true))
end
end
end
elseif facility.tank_mode == 6 then
-- (6) 3 total facility tanks (A B B C)
local first_fdef, last_fdef = find_fdef(2, math.min(3, #tank_defs))
for i = 1, #tank_defs do
local y = y_ofs(i)
if i == 1 or i == 4 then
if tank_defs[i] == 2 then
table.insert(water_pipes, pipe(0, y, 1, y + 5, colors.blue, true))
end
elseif i == first_fdef then
table.insert(water_pipes, pipe(0, y, 1, y + 5, colors.blue, true))
elseif i > first_fdef then
if i == last_fdef then
table.insert(water_pipes, pipe(0, y - 14, 0, y, colors.blue, true))
elseif i < last_fdef then
table.insert(water_pipes, pipe(0, y - 14, 0, y + 5, colors.blue, true))
end
end
end
elseif facility.tank_mode == 7 then
-- (7) 3 total facility tanks (A B C C)
local first_fdef, last_fdef = find_fdef(3, #tank_defs)
for i = 1, #tank_defs do
local y = y_ofs(i)
if i == 1 or i == 2 then
if tank_defs[i] == 2 then
table.insert(water_pipes, pipe(0, y, 1, y + 5, colors.blue, true))
end
elseif i == first_fdef then
table.insert(water_pipes, pipe(0, y, 1, y + 5, colors.blue, true))
elseif i > first_fdef then
if i == last_fdef then
table.insert(water_pipes, pipe(0, y - 14, 0, y, colors.blue, true))
elseif i < last_fdef then
table.insert(water_pipes, pipe(0, y - 14, 0, y + 5, colors.blue, true))
end
end
end
end
end
local flow_x = 3
if #water_pipes > 0 then
flow_x = 25
PipeNetwork{parent=main,x=2,y=3,pipes=water_pipes,bg=colors.lightGray}
end
for i = 1, facility.num_units do
local y_offset = y_ofs(i)
unit_flow(main, flow_x, 5 + y_offset, #water_pipes == 0, units[i])
table.insert(po_pipes, pipe(0, 3 + y_offset, 4, 0, colors.cyan, true, true))
end
PipeNetwork{parent=main,x=139,y=15,pipes=po_pipes,bg=colors.lightGray}
-----------------
-- tank valves --
-----------------
local next_f_id = 1
for i = 1, #tank_defs do
if tank_defs[i] > 0 then
local vy = 3 + y_ofs(i)
TextBox{parent=main,x=12,y=vy,text="\x10\x11",fg_bg=cpair(colors.black,colors.lightGray),width=2,height=1}
local conn = IndicatorLight{parent=main,x=9,y=vy+1,label=util.sprintf("PV%02d-EMC", i * 5),colors=style.ind_grn}
local open = IndicatorLight{parent=main,x=9,y=vy+2,label="OPEN",colors=style.ind_wht}
conn.register(units[i].unit_ps, "V_emc_conn", conn.update)
open.register(units[i].unit_ps, "V_emc_state", open.update)
end
end
-------------------
-- dynamic tanks --
-------------------
for i = 1, #tank_list do
if tank_list[i] > 0 then
local id = "U-" .. i
local f_id = next_f_id
if tank_list[i] == 2 then
id = "F-" .. next_f_id
next_f_id = next_f_id + 1
end
local y_offset = y_ofs(i)
local tank = Div{parent=main,x=3,y=7+y_offset,width=20,height=14}
TextBox{parent=tank,text=" ",height=1,x=1,y=1,fg_bg=cpair(colors.lightGray,colors.gray)}
TextBox{parent=tank,text="DYNAMIC TANK "..id,alignment=TEXT_ALIGN.CENTER,height=1,fg_bg=style.wh_gray}
local tank_box = Rectangle{parent=tank,border=border(1,colors.gray,true),width=20,height=12}
local status = StateIndicator{parent=tank_box,x=3,y=1,states=style.dtank.states,value=1,min_width=14}
TextBox{parent=tank_box,x=2,y=3,text="Fill",height=1,width=10,fg_bg=style.label}
local tank_pcnt = DataIndicator{parent=tank_box,x=10,y=3,label="",format="%5.2f",value=100,unit="%",lu_colors=lu_col,width=8,fg_bg=text_col}
local tank_amnt = DataIndicator{parent=tank_box,x=2,label="",format="%13d",value=0,unit="mB",lu_colors=lu_col,width=16,fg_bg=bw_fg_bg}
TextBox{parent=tank_box,x=2,y=6,text="Water Level",height=1,width=11,fg_bg=style.label}
local level = HorizontalBar{parent=tank_box,x=2,y=7,bar_fg_bg=cpair(colors.blue,colors.gray),height=1,width=16}
TextBox{parent=tank_box,x=2,y=9,text="In/Out Mode",height=1,width=11,fg_bg=style.label}
local can_fill = IndicatorLight{parent=tank_box,x=2,y=10,label="FILL",colors=style.ind_wht}
local can_empty = IndicatorLight{parent=tank_box,x=10,y=10,label="EMPTY",colors=style.ind_wht}
local function _can_fill(mode)
can_fill.update((mode == CONTAINER_MODE.BOTH) or (mode == CONTAINER_MODE.FILL))
end
local function _can_empty(mode)
can_empty.update((mode == CONTAINER_MODE.BOTH) or (mode == CONTAINER_MODE.EMPTY))
end
if tank_list[i] == 1 then
status.register(units[i].tank_ps_tbl[1], "computed_status", status.update)
tank_pcnt.register(units[i].tank_ps_tbl[1], "fill", function (f) tank_pcnt.update(f * 100) end)
tank_amnt.register(units[i].tank_ps_tbl[1], "stored", function (sto) tank_amnt.update(sto.amount) end)
level.register(units[i].tank_ps_tbl[1], "fill", level.update)
can_fill.register(units[i].tank_ps_tbl[1], "container_mode", _can_fill)
can_empty.register(units[i].tank_ps_tbl[1], "container_mode", _can_empty)
else
status.register(facility.tank_ps_tbl[f_id], "computed_status", status.update)
tank_pcnt.register(facility.tank_ps_tbl[f_id], "fill", function (f) tank_pcnt.update(f * 100) end)
tank_amnt.register(facility.tank_ps_tbl[f_id], "stored", function (sto) tank_amnt.update(sto.amount) end)
level.register(facility.tank_ps_tbl[f_id], "fill", level.update)
can_fill.register(facility.tank_ps_tbl[f_id], "container_mode", _can_fill)
can_empty.register(facility.tank_ps_tbl[f_id], "container_mode", _can_empty)
end
end
end
---------
-- SPS --
---------
local sps = Div{parent=main,x=140,y=3,height=12}
TextBox{parent=sps,text=" ",width=24,height=1,x=1,y=1,fg_bg=cpair(colors.lightGray,colors.gray)}
TextBox{parent=sps,text="SPS",alignment=TEXT_ALIGN.CENTER,width=24,height=1,fg_bg=wh_gray}
local sps_box = Rectangle{parent=sps,border=border(1,colors.gray,true),width=24,height=10}
local status = StateIndicator{parent=sps_box,x=5,y=1,states=style.sps.states,value=1,min_width=14}
status.register(facility.sps_ps_tbl[1], "computed_status", status.update)
TextBox{parent=sps_box,x=2,y=3,text="Input Rate",height=1,width=10,fg_bg=style.label}
local sps_in = DataIndicator{parent=sps_box,x=2,label="",format="%15.3f",value=0,unit="mB/t",lu_colors=lu_col,width=20,fg_bg=bw_fg_bg}
sps_in.register(facility.ps, "po_am_rate", sps_in.update)
TextBox{parent=sps_box,x=2,y=6,text="Production Rate",height=1,width=15,fg_bg=style.label}
local sps_rate = DataIndicator{parent=sps_box,x=2,label="",format="%15d",value=0,unit="\xb5B/t",lu_colors=lu_col,width=20,fg_bg=bw_fg_bg}
sps_rate.register(facility.sps_ps_tbl[1], "process_rate", function (r) sps_rate.update(r * 1000) end)
----------------
-- statistics --
----------------
TextBox{parent=main,x=145,y=16,text="PROC. WASTE",alignment=TEXT_ALIGN.CENTER,width=19,height=1,fg_bg=wh_gray}
local pr_waste = Rectangle{parent=main,x=145,y=17,border=border(1,colors.gray,true),width=19,height=5,thin=true,fg_bg=bw_fg_bg}
local pu = DataIndicator{parent=pr_waste,lu_colors=lu_col,label="Pu",unit="mB/t",format="%9.3f",value=0,width=17}
local po = DataIndicator{parent=pr_waste,lu_colors=lu_col,label="Po",unit="mB/t",format="%9.3f",value=0,width=17}
local popl = DataIndicator{parent=pr_waste,lu_colors=lu_col,label="PoPl",unit="mB/t",format="%7.3f",value=0,width=17}
pu.register(facility.ps, "pu_rate", pu.update)
po.register(facility.ps, "po_rate", po.update)
popl.register(facility.ps, "po_pl_rate", popl.update)
TextBox{parent=main,x=145,y=23,text="SPENT WASTE",alignment=TEXT_ALIGN.CENTER,width=19,height=1,fg_bg=wh_gray}
local sp_waste = Rectangle{parent=main,x=145,y=24,border=border(1,colors.gray,true),width=19,height=3,thin=true,fg_bg=bw_fg_bg}
local sum_sp_waste = DataIndicator{parent=sp_waste,lu_colors=lu_col,label="SUM",unit="mB/t",format="%8.3f",value=0,width=17}
sum_sp_waste.register(facility.ps, "spent_waste_rate", sum_sp_waste.update)
end
return init

View File

@ -73,6 +73,9 @@ local function init(panel, num_units)
local main_monitor = LED{parent=monitors,label="MAIN MONITOR",colors=cpair(colors.green,colors.green_off)}
main_monitor.register(ps, "main_monitor", main_monitor.update)
local flow_monitor = LED{parent=monitors,label="FLOW MONITOR",colors=cpair(colors.green,colors.green_off)}
flow_monitor.register(ps, "flow_monitor", flow_monitor.update)
monitors.line_break()
for i = 1, num_units do

View File

@ -68,7 +68,22 @@ style.colors = {
-- { c = colors.brown, hex = 0x7f664c }
}
-- MAIN LAYOUT --
-- COMMON COLOR PAIRS --
style.wh_gray = cpair(colors.white, colors.gray)
style.bw_fg_bg = cpair(colors.black, colors.white)
style.text_colors = cpair(colors.black, colors.lightGray)
style.lu_colors = cpair(colors.gray, colors.gray)
style.hzd_fg_bg = style.wh_gray
style.dis_colors = cpair(colors.white, colors.lightGray)
style.ind_grn = cpair(colors.green, colors.gray)
style.ind_yel = cpair(colors.yellow, colors.gray)
style.ind_red = cpair(colors.red, colors.gray)
style.ind_wht = style.wh_gray
-- UI COMPONENTS --
style.reactor = {
-- reactor states
@ -206,7 +221,7 @@ style.sps = {
text = "RTU FAULT"
},
{
color = cpair(colors.black, colors.gray),
color = cpair(colors.white, colors.gray),
text = "IDLE"
},
{
@ -216,6 +231,36 @@ style.sps = {
}
}
style.dtank = {
-- dynamic tank states
states = {
{
color = cpair(colors.black, colors.yellow),
text = "OFF-LINE"
},
{
color = cpair(colors.black, colors.orange),
text = "NOT FORMED"
},
{
color = cpair(colors.black, colors.orange),
text = "RTU FAULT"
},
{
color = cpair(colors.black, colors.green),
text = "ONLINE"
},
{
color = cpair(colors.black, colors.yellow),
text = "LOW FILL"
},
{
color = cpair(colors.black, colors.green),
text = "FILLED"
},
}
}
style.waste = {
-- auto waste processing states
states = {

View File

@ -7,7 +7,7 @@ local flasher = require("graphics.flasher")
local core = {}
core.version = "1.0.2"
core.version = "1.1.1"
core.flasher = flasher
core.events = events

View File

@ -20,6 +20,7 @@ local element = {}
---@alias graphics_args graphics_args_generic
---|waiting_args
---|app_button_args
---|checkbox_args
---|hazard_button_args
---|multi_button_args
@ -515,9 +516,10 @@ function element.new(args, child_offset_x, child_offset_y)
-- FUNCTION CALLBACKS --
-- handle a monitor touch or mouse click
-- handle a monitor touch or mouse click if this element is visible
---@param event mouse_interaction mouse interaction event
function public.handle_mouse(event)
if protected.window.isVisible() then
local x_ini, y_ini = event.initial.x, event.initial.y
local ini_in = protected.in_window_bounds(x_ini, y_ini)
@ -530,6 +532,7 @@ function element.new(args, child_offset_x, child_offset_y)
for _, child in pairs(protected.children) do child.handle_mouse(event_T) end
end
end
end
-- draw the element given new data
---@vararg any new data

View File

@ -0,0 +1,130 @@
-- App Button Graphics Element
local tcd = require("scada-common.tcd")
local core = require("graphics.core")
local element = require("graphics.element")
local CLICK_TYPE = core.events.CLICK_TYPE
---@class app_button_args
---@field text string app icon text
---@field title string app title text
---@field callback function function to call on touch
---@field app_fg_bg cpair app icon foreground/background colors
---@field active_fg_bg? cpair foreground/background colors when pressed
---@field parent graphics_element
---@field id? string element id
---@field x? integer 1 if omitted
---@field y? integer auto incremented if omitted
---@field fg_bg? cpair foreground/background colors
---@field hidden? boolean true to hide on initial draw
-- new app button
---@param args app_button_args
---@return graphics_element element, element_id id
local function app_button(args)
assert(type(args.text) == "string", "graphics.elements.controls.app: text is a required field")
assert(type(args.title) == "string", "graphics.elements.controls.app: title is a required field")
assert(type(args.callback) == "function", "graphics.elements.controls.app: callback is a required field")
assert(type(args.app_fg_bg) == "table", "graphics.elements.controls.app: app_fg_bg is a required field")
args.height = 4
args.width = 5
-- create new graphics element base object
local e = element.new(args)
-- write app title, centered
e.window.setCursorPos(1, 4)
e.window.setCursorPos(math.floor((e.frame.w - string.len(args.title)) / 2) + 1, 4)
e.window.write(args.title)
-- draw the app button
local function draw()
local fgd = args.app_fg_bg.fgd
local bkg = args.app_fg_bg.bkg
if e.value then
fgd = args.active_fg_bg.fgd
bkg = args.active_fg_bg.bkg
end
-- draw icon
e.window.setCursorPos(1, 1)
e.window.setTextColor(fgd)
e.window.setBackgroundColor(bkg)
e.window.write("\x9f\x83\x83\x83")
e.window.setTextColor(bkg)
e.window.setBackgroundColor(fgd)
e.window.write("\x90")
e.window.setTextColor(fgd)
e.window.setBackgroundColor(bkg)
e.window.setCursorPos(1, 2)
e.window.write("\x95 ")
e.window.setTextColor(bkg)
e.window.setBackgroundColor(fgd)
e.window.write("\x95")
e.window.setCursorPos(1, 3)
e.window.write("\x82\x8f\x8f\x8f\x81")
-- write the icon text
e.window.setCursorPos(3, 2)
e.window.setTextColor(fgd)
e.window.setBackgroundColor(bkg)
e.window.write(args.text)
end
-- draw the app button as pressed (if active_fg_bg set)
local function show_pressed()
if e.enabled and args.active_fg_bg ~= nil then
e.value = true
e.window.setTextColor(args.active_fg_bg.fgd)
e.window.setBackgroundColor(args.active_fg_bg.bkg)
draw()
end
end
-- draw the app button as unpressed (if active_fg_bg set)
local function show_unpressed()
if e.enabled and args.active_fg_bg ~= nil then
e.value = false
e.window.setTextColor(e.fg_bg.fgd)
e.window.setBackgroundColor(e.fg_bg.bkg)
draw()
end
end
-- handle mouse interaction
---@param event mouse_interaction mouse event
function e.handle_mouse(event)
if e.enabled then
if event.type == CLICK_TYPE.TAP then
show_pressed()
-- show as unpressed in 0.25 seconds
if args.active_fg_bg ~= nil then tcd.dispatch(0.25, show_unpressed) end
args.callback()
elseif event.type == CLICK_TYPE.DOWN then
show_pressed()
elseif event.type == CLICK_TYPE.UP then
show_unpressed()
if e.in_frame_bounds(event.current.x, event.current.y) then
args.callback()
end
end
end
end
-- set the value (true simulates pressing the app button)
---@param val boolean new value
function e.set_value(val)
if val then e.handle_mouse(core.events.mouse_generic(core.events.CLICK_TYPE.UP, 1, 1)) end
end
-- initial draw
draw()
return e.complete()
end
return app_button

View File

@ -14,6 +14,12 @@ local element = require("graphics.element")
---@field y? integer auto incremented if omitted
---@field hidden? boolean true to hide on initial draw
---@class _pipe_map_entry
---@field atr boolean align top right (or bottom left for false)
---@field thin boolean thin pipe or not
---@field fg string foreground blit
---@field bg string background blit
-- new pipe network
---@param args pipenet_args
---@return graphics_element element, element_id id
@ -44,6 +50,14 @@ local function pipenet(args)
-- create new graphics element base object
local e = element.new(args)
-- determine if there are any thin pipes involved
local any_thin = false
for p = 1, #args.pipes do
any_thin = args.pipes[p].thin
if any_thin then break end
end
if not any_thin then
-- draw all pipes
for p = 1, #args.pipes do
local pipe = args.pipes[p] ---@type pipe
@ -54,6 +68,11 @@ local function pipenet(args)
local x_step = util.trinary(pipe.x1 >= pipe.x2, -1, 1)
local y_step = util.trinary(pipe.y1 >= pipe.y2, -1, 1)
if pipe.thin then
x_step = util.trinary(pipe.x1 == pipe.x2, 0, x_step)
y_step = util.trinary(pipe.y1 == pipe.y2, 0, y_step)
end
e.window.setCursorPos(x, y)
local c = core.cpair(pipe.color, e.fg_bg.bkg)
@ -106,8 +125,10 @@ local function pipenet(args)
-- corner
if y_step < 0 then
e.window.blit("\x97", c.blit_bkg, c.blit_fgd)
else
elseif y_step > 0 then
e.window.blit("\x8d", c.blit_fgd, c.blit_bkg)
else
e.window.blit("\x8c", c.blit_fgd, c.blit_bkg)
end
else
e.window.blit("\x95", c.blit_fgd, c.blit_bkg)
@ -139,7 +160,154 @@ local function pipenet(args)
end
end
end
end
else
-- build map if using thin pipes, easist way to check adjacent blocks (cannot 'cheat' like with standard width)
local map = {}
-- allocate map
for x = 1, args.width do
table.insert(map, {})
for _ = 1, args.height do table.insert(map[x], false) end
end
-- build map
for p = 1, #args.pipes do
local pipe = args.pipes[p] ---@type pipe
local x = 1 + pipe.x1
local y = 1 + pipe.y1
local x_step = util.trinary(pipe.x1 >= pipe.x2, -1, 1)
local y_step = util.trinary(pipe.y1 >= pipe.y2, -1, 1)
local entry = { atr = pipe.align_tr, thin = pipe.thin, fg = colors.toBlit(pipe.color), bg = e.fg_bg.blit_bkg }
if pipe.align_tr then
-- cross width then height
for _ = 1, pipe.w do
map[x][y] = entry
x = x + x_step
end
x = x - x_step -- back up one
for _ = 1, pipe.h do
map[x][y] = entry
y = y + y_step
end
else
-- cross height then width
for _ = 1, pipe.h do
map[x][y] = entry
y = y + y_step
end
y = y - y_step -- back up one
for _ = 1, pipe.w do
map[x][y] = entry
x = x + x_step
end
end
end
-- render
for x = 1, args.width do
for y = 1, args.height do
local entry = map[x][y] ---@type _pipe_map_entry|false
local char
local invert = false
if entry ~= false then
local function check(cx, cy)
return (map[cx] ~= nil) and (map[cx][cy] ~= nil) and (map[cx][cy] ~= false) and (map[cx][cy].fg == entry.fg)
end
if entry.thin then
if check(x - 1, y) then -- if left
if check(x, y - 1) then -- if above
if check(x + 1, y) then -- if right
if check(x, y + 1) then -- if below
char = util.trinary(entry.atr, "\x91", "\x9d")
invert = entry.atr
else -- not below
char = util.trinary(entry.atr, "\x8e", "\x8d")
end
else -- not right
if check(x, y + 1) then -- if below
char = util.trinary(entry.atr, "\x91", "\x95")
invert = entry.atr
else -- not below
char = util.trinary(entry.atr, "\x8e", "\x85")
end
end
elseif check(x, y + 1) then-- not above, if below
if check(x + 1, y) then -- if right
char = util.trinary(entry.atr, "\x93", "\x9c")
invert = entry.atr
else -- not right
char = util.trinary(entry.atr, "\x93", "\x94")
invert = entry.atr
end
else -- not above, not below
char = "\x8c"
end
elseif check(x + 1, y) then -- not left, if right
if check(x, y - 1) then -- if above
if check(x, y + 1) then -- if below
char = util.trinary(entry.atr, "\x95", "\x9d")
invert = entry.atr
else -- not below
char = util.trinary(entry.atr, "\x8a", "\x8d")
end
else -- not above
if check(x, y + 1) then -- if below
char = util.trinary(entry.atr, "\x97", "\x9c")
invert = entry.atr
else -- not below
char = "\x8c"
end
end
else -- not left, not right
char = "\x95"
invert = entry.atr
end
else
if check(x, y - 1) then -- above
-- not below and (if left or right)
if (not check(x, y + 1)) and (check(x - 1, y) or check(x + 1, y)) then
char = util.trinary(entry.atr, "\x8f", " ")
invert = not entry.atr
else -- not below w/ sides only
char = " "
invert = true
end
elseif check(x, y + 1) then -- not above, if below
-- if left or right
if (check(x - 1, y) or check(x + 1, y)) then
char = "\x83"
invert = true
else -- not left or right
char = " "
invert = true
end
else -- not above, not below
char = util.trinary(entry.atr, "\x8f", "\x83")
invert = not entry.atr
end
end
e.window.setCursorPos(x, y)
if invert then
e.window.blit(char, entry.bg, entry.fg)
else
e.window.blit(char, entry.fg, entry.bg)
end
end
end
end
end
return e.complete()

View File

@ -48,6 +48,7 @@ def make_manifest(size):
"versions" : {
"installer" : get_version("./ccmsi.lua"),
"bootloader" : get_version("./startup.lua"),
"common" : get_version("./scada-common/util.lua", True),
"comms" : get_version("./scada-common/comms.lua", True),
"graphics" : get_version("./graphics/core.lua", True),
"lockbox" : get_version("./lockbox/init.lua", True),

File diff suppressed because one or more lines are too long

View File

@ -1,35 +0,0 @@
--
-- Core I/O - Pocket Central I/O Management
--
local psil = require("scada-common.psil")
local coreio = {}
---@class pocket_core_io
local io = {
ps = psil.create()
}
---@enum POCKET_LINK_STATE
local LINK_STATE = {
UNLINKED = 0,
SV_LINK_ONLY = 1,
API_LINK_ONLY = 2,
LINKED = 3
}
coreio.LINK_STATE = LINK_STATE
-- get the core PSIL
function coreio.core_ps()
return io.ps
end
-- set network link state
---@param state POCKET_LINK_STATE
function coreio.report_link_state(state)
io.ps.publish("link_state", state)
end
return coreio

106
pocket/iocontrol.lua Normal file
View File

@ -0,0 +1,106 @@
--
-- I/O Control for Pocket Integration with Supervisor & Coordinator
--
local psil = require("scada-common.psil")
local types = require("scada-common.types")
local ALARM = types.ALARM
local iocontrol = {}
---@class pocket_ioctl
local io = {
ps = psil.create()
}
---@enum POCKET_LINK_STATE
local LINK_STATE = {
UNLINKED = 0,
SV_LINK_ONLY = 1,
API_LINK_ONLY = 2,
LINKED = 3
}
---@enum NAV_PAGE
local NAV_PAGE = {
HOME = 1,
UNITS = 2,
REACTORS = 3,
BOILERS = 4,
TURBINES = 5,
DIAG = 6,
D_ALARMS = 7
}
iocontrol.LINK_STATE = LINK_STATE
iocontrol.NAV_PAGE = NAV_PAGE
-- initialize facility-independent components of pocket iocontrol
---@param comms pocket_comms
function iocontrol.init_core(comms)
---@class pocket_ioctl_diag
io.diag = {}
-- alarm testing
io.diag.tone_test = {
test_1 = function (state) comms.diag__set_alarm_tone(1, state) end,
test_2 = function (state) comms.diag__set_alarm_tone(2, state) end,
test_3 = function (state) comms.diag__set_alarm_tone(3, state) end,
test_4 = function (state) comms.diag__set_alarm_tone(4, state) end,
test_5 = function (state) comms.diag__set_alarm_tone(5, state) end,
test_6 = function (state) comms.diag__set_alarm_tone(6, state) end,
test_7 = function (state) comms.diag__set_alarm_tone(7, state) end,
test_8 = function (state) comms.diag__set_alarm_tone(8, state) end,
stop_tones = function () comms.diag__set_alarm_tone(0, false) end,
test_breach = function (state) comms.diag__set_alarm(ALARM.ContainmentBreach, state) end,
test_rad = function (state) comms.diag__set_alarm(ALARM.ContainmentRadiation, state) end,
test_lost = function (state) comms.diag__set_alarm(ALARM.ReactorLost, state) end,
test_crit = function (state) comms.diag__set_alarm(ALARM.CriticalDamage, state) end,
test_dmg = function (state) comms.diag__set_alarm(ALARM.ReactorDamage, state) end,
test_overtemp = function (state) comms.diag__set_alarm(ALARM.ReactorOverTemp, state) end,
test_hightemp = function (state) comms.diag__set_alarm(ALARM.ReactorHighTemp, state) end,
test_wasteleak = function (state) comms.diag__set_alarm(ALARM.ReactorWasteLeak, state) end,
test_highwaste = function (state) comms.diag__set_alarm(ALARM.ReactorHighWaste, state) end,
test_rps = function (state) comms.diag__set_alarm(ALARM.RPSTransient, state) end,
test_rcs = function (state) comms.diag__set_alarm(ALARM.RCSTransient, state) end,
test_turbinet = function (state) comms.diag__set_alarm(ALARM.TurbineTrip, state) end,
stop_alarms = function () comms.diag__set_alarm(0, false) end,
get_tone_states = function () comms.diag__get_alarm_tones() end,
ready_warn = nil, ---@type graphics_element
tone_buttons = {},
alarm_buttons = {},
tone_indicators = {} -- indicators to update from supervisor tone states
}
---@class pocket_nav
io.nav = {
page = NAV_PAGE.HOME, ---@type NAV_PAGE
sub_pages = { NAV_PAGE.HOME, NAV_PAGE.UNITS, NAV_PAGE.REACTORS, NAV_PAGE.BOILERS, NAV_PAGE.TURBINES, NAV_PAGE.DIAG },
tasks = {}
}
-- add a task to be performed periodically while on a given page
---@param page NAV_PAGE page to add task to
---@param task function function to execute
function io.nav.register_task(page, task)
if io.nav.tasks[page] == nil then io.nav.tasks[page] = {} end
table.insert(io.nav.tasks[page], task)
end
end
-- initialize facility-dependent components of pocket iocontrol
function iocontrol.init_fac() end
-- set network link state
---@param state POCKET_LINK_STATE
function iocontrol.report_link_state(state) io.ps.publish("link_state", state) end
-- get the IO controller database
function iocontrol.get_db() return io end
return iocontrol

View File

@ -2,15 +2,14 @@ local comms = require("scada-common.comms")
local log = require("scada-common.log")
local util = require("scada-common.util")
local coreio = require("pocket.coreio")
local iocontrol = require("pocket.iocontrol")
local PROTOCOL = comms.PROTOCOL
local DEVICE_TYPE = comms.DEVICE_TYPE
local ESTABLISH_ACK = comms.ESTABLISH_ACK
local SCADA_MGMT_TYPE = comms.SCADA_MGMT_TYPE
-- local CAPI_TYPE = comms.CAPI_TYPE
local LINK_STATE = coreio.LINK_STATE
local LINK_STATE = iocontrol.LINK_STATE
local pocket = {}
@ -79,20 +78,6 @@ function pocket.comms(version, nic, pkt_channel, svr_channel, crd_channel, range
self.api.seq_num = self.api.seq_num + 1
end
-- send a packet to the coordinator API
-----@param msg_type CAPI_TYPE
-----@param msg table
-- local function _send_api(msg_type, msg)
-- local s_pkt = comms.scada_packet()
-- local pkt = comms.capi_packet()
-- pkt.make(msg_type, msg)
-- s_pkt.make(self.api.addr, self.api.seq_num, PROTOCOL.COORD_API, pkt.raw_sendable())
-- nic.transmit(crd_channel, pkt_channel, s_pkt)
-- self.api.seq_num = self.api.seq_num + 1
-- end
-- attempt supervisor connection establishment
local function _send_sv_establish()
_send_sv(SCADA_MGMT_TYPE.ESTABLISH, { comms.version, version, DEVICE_TYPE.PKT })
@ -147,7 +132,7 @@ function pocket.comms(version, nic, pkt_channel, svr_channel, crd_channel, range
-- attempt to re-link if any of the dependent links aren't active
function public.link_update()
if not self.sv.linked then
coreio.report_link_state(util.trinary(self.api.linked, LINK_STATE.API_LINK_ONLY, LINK_STATE.UNLINKED))
iocontrol.report_link_state(util.trinary(self.api.linked, LINK_STATE.API_LINK_ONLY, LINK_STATE.UNLINKED))
if self.establish_delay_counter <= 0 then
_send_sv_establish()
@ -156,7 +141,7 @@ function pocket.comms(version, nic, pkt_channel, svr_channel, crd_channel, range
self.establish_delay_counter = self.establish_delay_counter - 1
end
elseif not self.api.linked then
coreio.report_link_state(LINK_STATE.SV_LINK_ONLY)
iocontrol.report_link_state(LINK_STATE.SV_LINK_ONLY)
if self.establish_delay_counter <= 0 then
_send_api_establish()
@ -166,10 +151,29 @@ function pocket.comms(version, nic, pkt_channel, svr_channel, crd_channel, range
end
else
-- linked, all good!
coreio.report_link_state(LINK_STATE.LINKED)
iocontrol.report_link_state(LINK_STATE.LINKED)
end
end
-- supervisor get active alarm tones
function public.diag__get_alarm_tones()
if self.sv.linked then _send_sv(SCADA_MGMT_TYPE.DIAG_TONE_GET, {}) end
end
-- supervisor test alarm tones by tone
---@param id TONE|0 tone ID, or 0 to stop all
---@param state boolean tone state
function public.diag__set_alarm_tone(id, state)
if self.sv.linked then _send_sv(SCADA_MGMT_TYPE.DIAG_TONE_SET, { id, state }) end
end
-- supervisor test alarm tones by alarm
---@param id ALARM|0 alarm ID, 0 to stop all
---@param state boolean alarm state
function public.diag__set_alarm(id, state)
if self.sv.linked then _send_sv(SCADA_MGMT_TYPE.DIAG_ALARM_SET, { id, state }) end
end
-- parse a packet
---@param side string
---@param sender integer
@ -205,6 +209,8 @@ function pocket.comms(version, nic, pkt_channel, svr_channel, crd_channel, range
-- handle a packet
---@param packet mgmt_frame|capi_frame|nil
function public.handle_packet(packet)
local diag = iocontrol.get_db().diag
if packet ~= nil then
local l_chan = packet.scada_frame.local_channel()
local r_chan = packet.scada_frame.remote_channel()
@ -231,47 +237,9 @@ function pocket.comms(version, nic, pkt_channel, svr_channel, crd_channel, range
-- feed watchdog on valid sequence number
api_watchdog.feed()
if protocol == PROTOCOL.COORD_API then
---@cast packet capi_frame
elseif protocol == PROTOCOL.SCADA_MGMT then
if protocol == PROTOCOL.SCADA_MGMT then
---@cast packet mgmt_frame
if packet.type == SCADA_MGMT_TYPE.ESTABLISH then
-- connection with coordinator established
if packet.length == 1 then
local est_ack = packet.data[1]
if est_ack == ESTABLISH_ACK.ALLOW then
log.info("coordinator connection established")
self.establish_delay_counter = 0
self.api.linked = true
self.api.addr = src_addr
if self.sv.linked then
coreio.report_link_state(LINK_STATE.LINKED)
else
coreio.report_link_state(LINK_STATE.API_LINK_ONLY)
end
elseif est_ack == ESTABLISH_ACK.DENY then
if self.api.last_est_ack ~= est_ack then
log.info("coordinator connection denied")
end
elseif est_ack == ESTABLISH_ACK.COLLISION then
if self.api.last_est_ack ~= est_ack then
log.info("coordinator connection denied due to collision")
end
elseif est_ack == ESTABLISH_ACK.BAD_VERSION then
if self.api.last_est_ack ~= est_ack then
log.info("coordinator comms version mismatch")
end
else
log.debug("coordinator SCADA_MGMT establish packet reply unsupported")
end
self.api.last_est_ack = est_ack
else
log.debug("coordinator SCADA_MGMT establish packet length mismatch")
end
elseif self.api.linked then
if self.api.linked then
if packet.type == SCADA_MGMT_TYPE.KEEP_ALIVE then
-- keep alive request received, echo back
if packet.length == 1 then
@ -298,6 +266,42 @@ function pocket.comms(version, nic, pkt_channel, svr_channel, crd_channel, range
else
log.debug("received unknown SCADA_MGMT packet type " .. packet.type .. " from coordinator")
end
elseif packet.type == SCADA_MGMT_TYPE.ESTABLISH then
-- connection with coordinator established
if packet.length == 1 then
local est_ack = packet.data[1]
if est_ack == ESTABLISH_ACK.ALLOW then
log.info("coordinator connection established")
self.establish_delay_counter = 0
self.api.linked = true
self.api.addr = src_addr
if self.sv.linked then
iocontrol.report_link_state(LINK_STATE.LINKED)
else
iocontrol.report_link_state(LINK_STATE.API_LINK_ONLY)
end
elseif est_ack == ESTABLISH_ACK.DENY then
if self.api.last_est_ack ~= est_ack then
log.info("coordinator connection denied")
end
elseif est_ack == ESTABLISH_ACK.COLLISION then
if self.api.last_est_ack ~= est_ack then
log.info("coordinator connection denied due to collision")
end
elseif est_ack == ESTABLISH_ACK.BAD_VERSION then
if self.api.last_est_ack ~= est_ack then
log.info("coordinator comms version mismatch")
end
else
log.debug("coordinator SCADA_MGMT establish packet reply unsupported")
end
self.api.last_est_ack = est_ack
else
log.debug("coordinator SCADA_MGMT establish packet length mismatch")
end
else
log.debug("discarding coordinator non-link SCADA_MGMT packet before linked")
end
@ -325,43 +329,7 @@ function pocket.comms(version, nic, pkt_channel, svr_channel, crd_channel, range
-- handle packet
if protocol == PROTOCOL.SCADA_MGMT then
---@cast packet mgmt_frame
if packet.type == SCADA_MGMT_TYPE.ESTABLISH then
-- connection with supervisor established
if packet.length == 1 then
local est_ack = packet.data[1]
if est_ack == ESTABLISH_ACK.ALLOW then
log.info("supervisor connection established")
self.establish_delay_counter = 0
self.sv.linked = true
self.sv.addr = src_addr
if self.api.linked then
coreio.report_link_state(LINK_STATE.LINKED)
else
coreio.report_link_state(LINK_STATE.SV_LINK_ONLY)
end
elseif est_ack == ESTABLISH_ACK.DENY then
if self.sv.last_est_ack ~= est_ack then
log.info("supervisor connection denied")
end
elseif est_ack == ESTABLISH_ACK.COLLISION then
if self.sv.last_est_ack ~= est_ack then
log.info("supervisor connection denied due to collision")
end
elseif est_ack == ESTABLISH_ACK.BAD_VERSION then
if self.sv.last_est_ack ~= est_ack then
log.info("supervisor comms version mismatch")
end
else
log.debug("supervisor SCADA_MGMT establish packet reply unsupported")
end
self.sv.last_est_ack = est_ack
else
log.debug("supervisor SCADA_MGMT establish packet length mismatch")
end
elseif self.sv.linked then
if self.sv.linked then
if packet.type == SCADA_MGMT_TYPE.KEEP_ALIVE then
-- keep alive request received, echo back
if packet.length == 1 then
@ -385,9 +353,90 @@ function pocket.comms(version, nic, pkt_channel, svr_channel, crd_channel, range
self.sv.r_seq_num = nil
self.sv.addr = comms.BROADCAST
log.info("supervisor server connection closed by remote host")
elseif packet.type == SCADA_MGMT_TYPE.DIAG_TONE_GET then
if packet.length == 8 then
for i = 1, #packet.data do
diag.tone_test.tone_indicators[i].update(packet.data[i] == true)
end
else
log.debug("supervisor SCADA diag alarm states packet length mismatch")
end
elseif packet.type == SCADA_MGMT_TYPE.DIAG_TONE_SET then
if packet.length == 1 and packet.data[1] == false then
diag.tone_test.ready_warn.set_value("testing denied")
log.debug("supervisor SCADA diag tone set failed")
elseif packet.length == 2 and type(packet.data[2]) == "table" then
local ready = packet.data[1]
local states = packet.data[2]
diag.tone_test.ready_warn.set_value(util.trinary(ready, "", "system not ready"))
for i = 1, #states do
if diag.tone_test.tone_buttons[i] ~= nil then
diag.tone_test.tone_buttons[i].set_value(states[i] == true)
diag.tone_test.tone_indicators[i].update(states[i] == true)
end
end
else
log.debug("supervisor SCADA diag tone set packet length/type mismatch")
end
elseif packet.type == SCADA_MGMT_TYPE.DIAG_ALARM_SET then
if packet.length == 1 and packet.data[1] == false then
diag.tone_test.ready_warn.set_value("testing denied")
log.debug("supervisor SCADA diag alarm set failed")
elseif packet.length == 2 and type(packet.data[2]) == "table" then
local ready = packet.data[1]
local states = packet.data[2]
diag.tone_test.ready_warn.set_value(util.trinary(ready, "", "system not ready"))
for i = 1, #states do
if diag.tone_test.alarm_buttons[i] ~= nil then
diag.tone_test.alarm_buttons[i].set_value(states[i] == true)
end
end
else
log.debug("supervisor SCADA diag alarm set packet length/type mismatch")
end
else
log.debug("received unknown SCADA_MGMT packet type " .. packet.type .. " from supervisor")
end
elseif packet.type == SCADA_MGMT_TYPE.ESTABLISH then
-- connection with supervisor established
if packet.length == 1 then
local est_ack = packet.data[1]
if est_ack == ESTABLISH_ACK.ALLOW then
log.info("supervisor connection established")
self.establish_delay_counter = 0
self.sv.linked = true
self.sv.addr = src_addr
if self.api.linked then
iocontrol.report_link_state(LINK_STATE.LINKED)
else
iocontrol.report_link_state(LINK_STATE.SV_LINK_ONLY)
end
elseif est_ack == ESTABLISH_ACK.DENY then
if self.sv.last_est_ack ~= est_ack then
log.info("supervisor connection denied")
end
elseif est_ack == ESTABLISH_ACK.COLLISION then
if self.sv.last_est_ack ~= est_ack then
log.info("supervisor connection denied due to collision")
end
elseif est_ack == ESTABLISH_ACK.BAD_VERSION then
if self.sv.last_est_ack ~= est_ack then
log.info("supervisor comms version mismatch")
end
else
log.debug("supervisor SCADA_MGMT establish packet reply unsupported")
end
self.sv.last_est_ack = est_ack
else
log.debug("supervisor SCADA_MGMT establish packet length mismatch")
end
else
log.debug("discarding supervisor non-link SCADA_MGMT packet before linked")
end

View File

@ -14,11 +14,11 @@ local util = require("scada-common.util")
local core = require("graphics.core")
local config = require("pocket.config")
local coreio = require("pocket.coreio")
local iocontrol = require("pocket.iocontrol")
local pocket = require("pocket.pocket")
local renderer = require("pocket.renderer")
local POCKET_VERSION = "alpha-v0.5.2"
local POCKET_VERSION = "v0.6.0-alpha"
local println = util.println
local println_ts = util.println_ts
@ -73,7 +73,7 @@ local function main()
network.init_mac(config.AUTH_KEY)
end
coreio.report_link_state(coreio.LINK_STATE.UNLINKED)
iocontrol.report_link_state(iocontrol.LINK_STATE.UNLINKED)
-- get the communications modem
local modem = ppm.get_wireless_modem()
@ -104,6 +104,9 @@ local function main()
local MAIN_CLOCK = 0.5
local loop_clock = util.new_clock(MAIN_CLOCK)
-- init I/O control
iocontrol.init_core(pocket_comms)
----------------------------------------
-- start the UI
----------------------------------------
@ -128,6 +131,9 @@ local function main()
conn_wd.api.feed()
log.debug("startup> conn watchdog started")
local io_db = iocontrol.get_db()
local nav = io_db.nav
-- main event loop
while true do
local event, param1, param2, param3, param4, param5 = util.pull_event()
@ -140,6 +146,13 @@ local function main()
-- relink if necessary
pocket_comms.link_update()
-- update any tasks for the active page
if (type(nav.tasks[nav.page]) == "table") then
for i = 1, #nav.tasks[nav.page] do
nav.tasks[nav.page][i]()
end
end
loop_clock.start()
elseif conn_wd.sv.is_timer(param1) then
-- supervisor watchdog timeout

View File

@ -2,17 +2,18 @@
-- Pocket GUI Root
--
local coreio = require("pocket.coreio")
local iocontrol = require("pocket.iocontrol")
local style = require("pocket.ui.style")
local conn_waiting = require("pocket.ui.components.conn_waiting")
local home_page = require("pocket.ui.pages.home_page")
local unit_page = require("pocket.ui.pages.unit_page")
local reactor_page = require("pocket.ui.pages.reactor_page")
local boiler_page = require("pocket.ui.pages.boiler_page")
local diag_page = require("pocket.ui.pages.diag_page")
local home_page = require("pocket.ui.pages.home_page")
local reactor_page = require("pocket.ui.pages.reactor_page")
local turbine_page = require("pocket.ui.pages.turbine_page")
local unit_page = require("pocket.ui.pages.unit_page")
local core = require("graphics.core")
@ -22,6 +23,9 @@ local TextBox = require("graphics.elements.textbox")
local Sidebar = require("graphics.elements.controls.sidebar")
local LINK_STATE = iocontrol.LINK_STATE
local NAV_PAGE = iocontrol.NAV_PAGE
local TEXT_ALIGN = core.TEXT_ALIGN
local cpair = core.cpair
@ -29,6 +33,9 @@ local cpair = core.cpair
-- create new main view
---@param main graphics_element main displaybox
local function init(main)
local nav = iocontrol.get_db().nav
local ps = iocontrol.get_db().ps
-- window header message
TextBox{parent=main,y=1,text="",alignment=TEXT_ALIGN.LEFT,height=1,fg_bg=style.header}
@ -45,10 +52,10 @@ local function init(main)
local root_pane = MultiPane{parent=root_pane_div,x=1,y=1,panes=root_panes}
root_pane.register(coreio.core_ps(), "link_state", function (state)
if state == coreio.LINK_STATE.UNLINKED or state == coreio.LINK_STATE.API_LINK_ONLY then
root_pane.register(ps, "link_state", function (state)
if state == LINK_STATE.UNLINKED or state == LINK_STATE.API_LINK_ONLY then
root_pane.set_value(1)
elseif state == coreio.LINK_STATE.SV_LINK_ONLY then
elseif state == LINK_STATE.SV_LINK_ONLY then
root_pane.set_value(2)
else
root_pane.set_value(3)
@ -81,19 +88,36 @@ local function init(main)
{
char = "T",
color = cpair(colors.black,colors.white)
},
{
char = "D",
color = cpair(colors.black,colors.orange)
}
}
local pane_1 = home_page(page_div)
local pane_2 = unit_page(page_div)
local pane_3 = reactor_page(page_div)
local pane_4 = boiler_page(page_div)
local pane_5 = turbine_page(page_div)
local panes = { pane_1, pane_2, pane_3, pane_4, pane_5 }
local panes = { home_page(page_div), unit_page(page_div), reactor_page(page_div), boiler_page(page_div), turbine_page(page_div), diag_page(page_div) }
local page_pane = MultiPane{parent=page_div,x=1,y=1,panes=panes}
Sidebar{parent=main_pane,x=1,y=1,tabs=sidebar_tabs,fg_bg=cpair(colors.white,colors.gray),callback=page_pane.set_value}
local function navigate_sidebar(page)
if page == 1 then
nav.page = nav.sub_pages[NAV_PAGE.HOME]
elseif page == 2 then
nav.page = nav.sub_pages[NAV_PAGE.UNITS]
elseif page == 3 then
nav.page = nav.sub_pages[NAV_PAGE.REACTORS]
elseif page == 4 then
nav.page = nav.sub_pages[NAV_PAGE.BOILERS]
elseif page == 5 then
nav.page = nav.sub_pages[NAV_PAGE.TURBINES]
elseif page == 6 then
nav.page = nav.sub_pages[NAV_PAGE.DIAG]
end
page_pane.set_value(page)
end
Sidebar{parent=main_pane,x=1,y=1,tabs=sidebar_tabs,fg_bg=cpair(colors.white,colors.gray),callback=navigate_sidebar}
end
return init

View File

@ -0,0 +1,147 @@
local iocontrol = require("pocket.iocontrol")
local core = require("graphics.core")
local Div = require("graphics.elements.div")
local MultiPane = require("graphics.elements.multipane")
local TextBox = require("graphics.elements.textbox")
local IndicatorLight = require("graphics.elements.indicators.light")
local App = require("graphics.elements.controls.app")
local Checkbox = require("graphics.elements.controls.checkbox")
local PushButton = require("graphics.elements.controls.push_button")
local SwitchButton = require("graphics.elements.controls.switch_button")
local cpair = core.cpair
local NAV_PAGE = iocontrol.NAV_PAGE
local TEXT_ALIGN = core.TEXT_ALIGN
-- new diagnostics page view
---@param root graphics_element parent
local function new_view(root)
local db = iocontrol.get_db()
local main = Div{parent=root,x=1,y=1}
local diag_home = Div{parent=main,x=1,y=1}
TextBox{parent=diag_home,text="Diagnostic Apps",x=1,y=2,height=1,alignment=TEXT_ALIGN.CENTER}
local alarm_test = Div{parent=main,x=1,y=1}
local panes = { diag_home, alarm_test }
local page_pane = MultiPane{parent=main,x=1,y=1,panes=panes}
local function navigate_diag()
page_pane.set_value(1)
db.nav.page = NAV_PAGE.DIAG
db.nav.sub_pages[NAV_PAGE.DIAG] = NAV_PAGE.DIAG
end
local function navigate_alarm()
page_pane.set_value(2)
db.nav.page = NAV_PAGE.D_ALARMS
db.nav.sub_pages[NAV_PAGE.DIAG] = NAV_PAGE.D_ALARMS
end
------------------------
-- Alarm Testing Page --
------------------------
db.nav.register_task(NAV_PAGE.D_ALARMS, db.diag.tone_test.get_tone_states)
local ttest = db.diag.tone_test
local c_wht_gray = cpair(colors.white, colors.gray)
local c_red_gray = cpair(colors.red, colors.gray)
local c_yel_gray = cpair(colors.yellow, colors.gray)
local c_blue_gray = cpair(colors.blue, colors.gray)
local audio = Div{parent=alarm_test,x=1,y=1}
TextBox{parent=audio,y=1,text="Alarm Sounder Tests",height=1,alignment=TEXT_ALIGN.CENTER}
ttest.ready_warn = TextBox{parent=audio,y=2,text="",height=1,alignment=TEXT_ALIGN.CENTER,fg_bg=cpair(colors.yellow,colors.black)}
PushButton{parent=audio,x=13,y=18,text="\x11 BACK",min_width=8,fg_bg=cpair(colors.black,colors.lightGray),active_fg_bg=c_wht_gray,callback=navigate_diag}
local tones = Div{parent=audio,x=2,y=3,height=10,width=8,fg_bg=cpair(colors.black,colors.yellow)}
TextBox{parent=tones,text="Tones",height=1,alignment=TEXT_ALIGN.CENTER,fg_bg=audio.get_fg_bg()}
local test_btns = {}
test_btns[1] = SwitchButton{parent=tones,text="TEST 1",min_width=8,active_fg_bg=c_wht_gray,callback=ttest.test_1}
test_btns[2] = SwitchButton{parent=tones,text="TEST 2",min_width=8,active_fg_bg=c_wht_gray,callback=ttest.test_2}
test_btns[3] = SwitchButton{parent=tones,text="TEST 3",min_width=8,active_fg_bg=c_wht_gray,callback=ttest.test_3}
test_btns[4] = SwitchButton{parent=tones,text="TEST 4",min_width=8,active_fg_bg=c_wht_gray,callback=ttest.test_4}
test_btns[5] = SwitchButton{parent=tones,text="TEST 5",min_width=8,active_fg_bg=c_wht_gray,callback=ttest.test_5}
test_btns[6] = SwitchButton{parent=tones,text="TEST 6",min_width=8,active_fg_bg=c_wht_gray,callback=ttest.test_6}
test_btns[7] = SwitchButton{parent=tones,text="TEST 7",min_width=8,active_fg_bg=c_wht_gray,callback=ttest.test_7}
test_btns[8] = SwitchButton{parent=tones,text="TEST 8",min_width=8,active_fg_bg=c_wht_gray,callback=ttest.test_8}
ttest.tone_buttons = test_btns
local function stop_all_tones()
for i = 1, #test_btns do test_btns[i].set_value(false) end
ttest.stop_tones()
end
PushButton{parent=tones,text="STOP",min_width=8,active_fg_bg=c_wht_gray,fg_bg=cpair(colors.black,colors.red),callback=stop_all_tones}
local alarms = Div{parent=audio,x=11,y=3,height=15,fg_bg=cpair(colors.lightGray,colors.black)}
TextBox{parent=alarms,text="Alarms (\x13)",height=1,alignment=TEXT_ALIGN.CENTER,fg_bg=audio.get_fg_bg()}
local alarm_btns = {}
alarm_btns[1] = Checkbox{parent=alarms,label="BREACH",min_width=15,box_fg_bg=c_red_gray,callback=ttest.test_breach}
alarm_btns[2] = Checkbox{parent=alarms,label="RADIATION",min_width=15,box_fg_bg=c_red_gray,callback=ttest.test_rad}
alarm_btns[3] = Checkbox{parent=alarms,label="RCT LOST",min_width=15,box_fg_bg=c_red_gray,callback=ttest.test_lost}
alarm_btns[4] = Checkbox{parent=alarms,label="CRIT DAMAGE",min_width=15,box_fg_bg=c_red_gray,callback=ttest.test_crit}
alarm_btns[5] = Checkbox{parent=alarms,label="DAMAGE",min_width=15,box_fg_bg=c_red_gray,callback=ttest.test_dmg}
alarm_btns[6] = Checkbox{parent=alarms,label="OVER TEMP",min_width=15,box_fg_bg=c_red_gray,callback=ttest.test_overtemp}
alarm_btns[7] = Checkbox{parent=alarms,label="HIGH TEMP",min_width=15,box_fg_bg=c_yel_gray,callback=ttest.test_hightemp}
alarm_btns[8] = Checkbox{parent=alarms,label="WASTE LEAK",min_width=15,box_fg_bg=c_red_gray,callback=ttest.test_wasteleak}
alarm_btns[9] = Checkbox{parent=alarms,label="WASTE HIGH",min_width=15,box_fg_bg=c_yel_gray,callback=ttest.test_highwaste}
alarm_btns[10] = Checkbox{parent=alarms,label="RPS TRANS",min_width=15,box_fg_bg=c_yel_gray,callback=ttest.test_rps}
alarm_btns[11] = Checkbox{parent=alarms,label="RCS TRANS",min_width=15,box_fg_bg=c_yel_gray,callback=ttest.test_rcs}
alarm_btns[12] = Checkbox{parent=alarms,label="TURBINE TRP",min_width=15,box_fg_bg=c_red_gray,callback=ttest.test_turbinet}
ttest.alarm_buttons = alarm_btns
local function stop_all_alarms()
for i = 1, #alarm_btns do alarm_btns[i].set_value(false) end
ttest.stop_alarms()
end
PushButton{parent=alarms,x=3,y=15,text="STOP \x13",min_width=8,fg_bg=cpair(colors.black,colors.red),active_fg_bg=c_wht_gray,callback=stop_all_alarms}
local states = Div{parent=audio,x=2,y=14,height=5,width=8}
TextBox{parent=states,text="States",height=1,alignment=TEXT_ALIGN.CENTER}
local t_1 = IndicatorLight{parent=states,label="1",colors=c_blue_gray}
local t_2 = IndicatorLight{parent=states,label="2",colors=c_blue_gray}
local t_3 = IndicatorLight{parent=states,label="3",colors=c_blue_gray}
local t_4 = IndicatorLight{parent=states,label="4",colors=c_blue_gray}
local t_5 = IndicatorLight{parent=states,x=6,y=2,label="5",colors=c_blue_gray}
local t_6 = IndicatorLight{parent=states,x=6,label="6",colors=c_blue_gray}
local t_7 = IndicatorLight{parent=states,x=6,label="7",colors=c_blue_gray}
local t_8 = IndicatorLight{parent=states,x=6,label="8",colors=c_blue_gray}
ttest.tone_indicators = { t_1, t_2, t_3, t_4, t_5, t_6, t_7, t_8 }
--------------
-- App List --
--------------
App{parent=diag_home,x=3,y=4,text="\x0f",title="Alarm",callback=navigate_alarm,app_fg_bg=cpair(colors.black,colors.red),active_fg_bg=cpair(colors.white,colors.gray)}
App{parent=diag_home,x=10,y=4,text="\x1e",title="LoopT",callback=function()end,app_fg_bg=cpair(colors.black,colors.cyan)}
App{parent=diag_home,x=17,y=4,text="@",title="Comps",callback=function()end,app_fg_bg=cpair(colors.black,colors.orange)}
return main
end
return new_view

View File

@ -1,20 +1,21 @@
-- local style = require("pocket.ui.style")
local core = require("graphics.core")
local Div = require("graphics.elements.div")
local TextBox = require("graphics.elements.textbox")
-- local cpair = core.cpair
local App = require("graphics.elements.controls.app")
local TEXT_ALIGN = core.TEXT_ALIGN
local cpair = core.cpair
-- new home page view
---@param root graphics_element parent
local function new_view(root)
local main = Div{parent=root,x=1,y=1}
TextBox{parent=main,text="HOME",x=1,y=1,height=1,alignment=TEXT_ALIGN.CENTER}
App{parent=main,x=3,y=2,text="\x17",title="PRC",callback=function()end,app_fg_bg=cpair(colors.black,colors.purple)}
App{parent=main,x=10,y=2,text="\x15",title="CTL",callback=function()end,app_fg_bg=cpair(colors.black,colors.green)}
App{parent=main,x=17,y=2,text="\x08",title="DEV",callback=function()end,app_fg_bg=cpair(colors.black,colors.lightGray)}
App{parent=main,x=3,y=7,text="\x7f",title="Waste",callback=function()end,app_fg_bg=cpair(colors.black,colors.brown)}
App{parent=main,x=10,y=7,text="\xb6",title="Guide",callback=function()end,app_fg_bg=cpair(colors.black,colors.cyan)}
return main
end

View File

@ -19,7 +19,7 @@ local plc = require("reactor-plc.plc")
local renderer = require("reactor-plc.renderer")
local threads = require("reactor-plc.threads")
local R_PLC_VERSION = "v1.5.5"
local R_PLC_VERSION = "v1.5.6"
local println = util.println
local println_ts = util.println_ts

View File

@ -165,7 +165,7 @@ function threads.thread__main(smem, init)
local type, device = ppm.handle_unmount(param1)
if type ~= nil and device ~= nil then
if type == "fissionReactorLogicAdapter" then
if device == plc_dev.reactor then
println_ts("reactor disconnected!")
log.error("reactor logic adapter disconnected")
@ -205,7 +205,7 @@ function threads.thread__main(smem, init)
local type, device = ppm.mount(param1)
if type ~= nil and device ~= nil then
if type == "fissionReactorLogicAdapter" then
if plc_state.no_reactor and (type == "fissionReactorLogicAdapter") then
-- reconnected reactor
plc_dev.reactor = device
plc_state.no_reactor = false

View File

@ -15,6 +15,10 @@ config.COMMS_TIMEOUT = 5
-- all devices on the same network must use the same key
-- config.AUTH_KEY = "SCADAfacility123"
-- alarm sounder volume (0.0 to 3.0, 1.0 being standard max volume, this is the option given to to speaker.play())
-- note: alarm sine waves are at half saturation, so that multiple will be required to reach full scale
config.SOUNDER_VOLUME = 1.0
-- log path
config.LOG_PATH = "/log.txt"
-- log mode

View File

@ -37,6 +37,12 @@ function databus.tx_hw_modem(has_modem)
databus.ps.publish("has_modem", has_modem)
end
-- transmit the number of speakers connected
---@param count integer
function databus.tx_hw_spkr_count(count)
databus.ps.publish("speaker_count", count)
end
-- transmit unit hardware type across the bus
---@param uid integer unit ID
---@param type RTU_UNIT_TYPE

View File

@ -14,6 +14,7 @@ local core = require("graphics.core")
local Div = require("graphics.elements.div")
local TextBox = require("graphics.elements.textbox")
local DataIndicator = require("graphics.elements.indicators.data")
local LED = require("graphics.elements.indicators.led")
local RGBLED = require("graphics.elements.indicators.ledrgb")
@ -21,17 +22,7 @@ local TEXT_ALIGN = core.TEXT_ALIGN
local cpair = core.cpair
local UNIT_TYPE_LABELS = {
"UNKNOWN",
"REDSTONE",
"BOILER",
"TURBINE",
"DYNAMIC TANK",
"IND MATRIX",
"SPS",
"SNA",
"ENV DETECTOR"
}
local UNIT_TYPE_LABELS = { "UNKNOWN", "REDSTONE", "BOILER", "TURBINE", "DYNAMIC TANK", "IND MATRIX", "SPS", "SNA", "ENV DETECTOR" }
-- create new front panel view
@ -72,6 +63,10 @@ local function init(panel, units)
local comp_id = util.sprintf("(%d)", os.getComputerID())
TextBox{parent=system,x=9,y=4,width=6,height=1,text=comp_id,fg_bg=cpair(colors.lightGray,colors.ivory)}
TextBox{parent=system,x=1,y=14,text="SPEAKERS",height=1,width=8,fg_bg=style.label}
local speaker_count = DataIndicator{parent=system,x=10,y=14,label="",format="%3d",value=0,width=3,fg_bg=cpair(colors.gray,colors.white)}
speaker_count.register(databus.ps, "speaker_count", speaker_count.update)
--
-- about label
--

View File

@ -1,9 +1,11 @@
local audio = require("scada-common.audio")
local comms = require("scada-common.comms")
local ppm = require("scada-common.ppm")
local log = require("scada-common.log")
local types = require("scada-common.types")
local util = require("scada-common.util")
local config = require("rtu.config")
local databus = require("rtu.databus")
local modbus = require("rtu.modbus")
@ -155,6 +157,48 @@ function rtu.init_unit()
return protected
end
-- create an alarm speaker sounder
---@param speaker table device peripheral
function rtu.init_sounder(speaker)
---@class rtu_speaker_sounder
local spkr_ctl = {
speaker = speaker,
name = ppm.get_iface(speaker),
playing = false,
stream = audio.new_stream(),
play = function () end,
stop = function () end,
continue = function () end
}
-- continue audio stream if playing
function spkr_ctl.continue()
if spkr_ctl.playing then
if spkr_ctl.speaker ~= nil and spkr_ctl.stream.has_next_block() then
local success = spkr_ctl.speaker.playAudio(spkr_ctl.stream.get_next_block(), config.SOUNDER_VOLUME)
if not success then log.error(util.c("rtu_sounder(", spkr_ctl.name, "): error playing audio")) end
end
end
end
-- start audio stream playback
function spkr_ctl.play()
if not spkr_ctl.playing then
spkr_ctl.playing = true
return spkr_ctl.continue()
end
end
-- stop audio stream playback
function spkr_ctl.stop()
spkr_ctl.playing = false
spkr_ctl.speaker.stop()
spkr_ctl.stream.stop()
end
return spkr_ctl
end
-- RTU Communications
---@nodiscard
---@param version string RTU version
@ -312,7 +356,8 @@ function rtu.comms(version, nic, rtu_channel, svr_channel, range, conn_watchdog)
---@param packet modbus_frame|mgmt_frame
---@param units table RTU units
---@param rtu_state rtu_state
function public.handle_packet(packet, units, rtu_state)
---@param sounders table speaker alarm sounders
function public.handle_packet(packet, units, rtu_state, sounders)
-- print a log message to the terminal as long as the UI isn't running
local function println_ts(message) if not rtu_state.fp_ok then util.println_ts(message) end end
@ -387,7 +432,49 @@ function rtu.comms(version, nic, rtu_channel, svr_channel, range, conn_watchdog)
elseif protocol == PROTOCOL.SCADA_MGMT then
---@cast packet mgmt_frame
-- SCADA management packet
if packet.type == SCADA_MGMT_TYPE.ESTABLISH then
if rtu_state.linked then
if packet.type == SCADA_MGMT_TYPE.KEEP_ALIVE then
-- keep alive request received, echo back
if packet.length == 1 and type(packet.data[1]) == "number" then
local timestamp = packet.data[1]
local trip_time = util.time() - timestamp
if trip_time > 750 then
log.warning("RTU KEEP_ALIVE trip time > 750ms (" .. trip_time .. "ms)")
end
-- log.debug("RTU RTT = " .. trip_time .. "ms")
_send_keep_alive_ack(timestamp)
else
log.debug("SCADA_MGMT keep alive packet length/type mismatch")
end
elseif packet.type == SCADA_MGMT_TYPE.CLOSE then
-- close connection
conn_watchdog.cancel()
public.unlink(rtu_state)
println_ts("server connection closed by remote host")
log.warning("server connection closed by remote host")
elseif packet.type == SCADA_MGMT_TYPE.RTU_ADVERT then
-- request for capabilities again
public.send_advertisement(units)
elseif packet.type == SCADA_MGMT_TYPE.RTU_TONE_ALARM then
-- alarm tone update from supervisor
if (packet.length == 1) and type(packet.data[1] == "table") and (#packet.data[1] == 8) then
local states = packet.data[1]
for i = 1, #sounders do
local s = sounders[i] ---@type rtu_speaker_sounder
-- set tone states
for id = 1, #states do s.stream.set_active(id, states[id] == true) end
end
end
else
-- not supported
log.debug("received unsupported SCADA_MGMT message type " .. packet.type)
end
elseif packet.type == SCADA_MGMT_TYPE.ESTABLISH then
if packet.length == 1 then
local est_ack = packet.data[1]
@ -421,36 +508,6 @@ function rtu.comms(version, nic, rtu_channel, svr_channel, range, conn_watchdog)
else
log.debug("SCADA_MGMT establish packet length mismatch")
end
elseif rtu_state.linked then
if packet.type == SCADA_MGMT_TYPE.KEEP_ALIVE then
-- keep alive request received, echo back
if packet.length == 1 and type(packet.data[1]) == "number" then
local timestamp = packet.data[1]
local trip_time = util.time() - timestamp
if trip_time > 750 then
log.warning("RTU KEEP_ALIVE trip time > 750ms (" .. trip_time .. "ms)")
end
-- log.debug("RTU RTT = " .. trip_time .. "ms")
_send_keep_alive_ack(timestamp)
else
log.debug("SCADA_MGMT keep alive packet length/type mismatch")
end
elseif packet.type == SCADA_MGMT_TYPE.CLOSE then
-- close connection
conn_watchdog.cancel()
public.unlink(rtu_state)
println_ts("server connection closed by remote host")
log.warning("server connection closed by remote host")
elseif packet.type == SCADA_MGMT_TYPE.RTU_ADVERT then
-- request for capabilities again
public.send_advertisement(units)
else
-- not supported
log.debug("received unsupported SCADA_MGMT message type " .. packet.type)
end
else
log.debug("discarding non-link SCADA_MGMT packet before linked")
end

View File

@ -4,6 +4,7 @@
require("/initenv").init_env()
local audio = require("scada-common.audio")
local comms = require("scada-common.comms")
local crash = require("scada-common.crash")
local log = require("scada-common.log")
@ -30,7 +31,7 @@ local sna_rtu = require("rtu.dev.sna_rtu")
local sps_rtu = require("rtu.dev.sps_rtu")
local turbinev_rtu = require("rtu.dev.turbinev_rtu")
local RTU_VERSION = "v1.5.5"
local RTU_VERSION = "v1.6.0"
local RTU_UNIT_TYPE = types.RTU_UNIT_TYPE
local RTU_UNIT_HW_STATE = databus.RTU_UNIT_HW_STATE
@ -96,6 +97,9 @@ local function main()
return
end
-- generate alarm tones
audio.generate_tones()
---@class rtu_shared_memory
local __shared_memory = {
-- RTU system state flags
@ -106,6 +110,11 @@ local function main()
shutdown = false
},
-- RTU gateway devices (not RTU units)
rtu_dev = {
sounders = {}
},
-- system objects
rtu_sys = {
nic = network.nic(modem),
@ -481,6 +490,18 @@ local function main()
log.info("startup> running in headless mode without front panel")
end
-- find and setup all speakers
local speakers = ppm.get_all_devices("speaker")
for _, s in pairs(speakers) do
local sounder = rtu.init_sounder(s)
table.insert(__shared_memory.rtu_dev.sounders, sounder)
log.debug(util.c("startup> added speaker, attached as ", sounder.name))
end
databus.tx_hw_spkr_count(#__shared_memory.rtu_dev.sounders)
-- start connection watchdog
smem_sys.conn_watchdog = util.new_watchdog(config.COMMS_TIMEOUT)
log.debug("startup> conn watchdog started")

View File

@ -8,6 +8,7 @@ local util = require("scada-common.util")
local databus = require("rtu.databus")
local modbus = require("rtu.modbus")
local renderer = require("rtu.renderer")
local rtu = require("rtu.rtu")
local boilerv_rtu = require("rtu.dev.boilerv_rtu")
local dynamicv_rtu = require("rtu.dev.dynamicv_rtu")
@ -24,7 +25,7 @@ local threads = {}
local RTU_UNIT_TYPE = types.RTU_UNIT_TYPE
local UNIT_HW_STATE = databus.RTU_UNIT_HW_STATE
local MAIN_CLOCK = 2 -- (2Hz, 40 ticks)
local MAIN_CLOCK = 0.5 -- (2Hz, 10 ticks)
local COMMS_SLEEP = 100 -- (100ms, 2 ticks)
-- main thread
@ -47,6 +48,7 @@ function threads.thread__main(smem)
-- load in from shared memory
local rtu_state = smem.rtu_state
local sounders = smem.rtu_dev.sounders
local nic = smem.rtu_sys.nic
local rtu_comms = smem.rtu_sys.rtu_comms
local conn_watchdog = smem.rtu_sys.conn_watchdog
@ -66,6 +68,15 @@ function threads.thread__main(smem)
-- blink heartbeat indicator
databus.heartbeat()
-- update speaker states
for _, sounder in pairs(sounders) do
-- re-compute output if needed, then play audio if available
if sounder.stream.is_recompute_needed() then
sounder.stream.compute_buffer()
if sounder.stream.has_next_block() then sounder.play() else sounder.stop() end
end
end
-- start next clock timer
loop_clock.start()
@ -110,6 +121,18 @@ function threads.thread__main(smem)
else
log.warning("non-comms modem disconnected")
end
elseif type == "speaker" then
for i = 1, #sounders do
if sounders[i].speaker == device then
table.remove(sounders, i)
log.warning(util.c("speaker ", param1, " disconnected"))
println_ts("speaker disconnected")
databus.tx_hw_spkr_count(#sounders)
break
end
end
else
for i = 1, #units do
-- find disconnected device
@ -147,6 +170,13 @@ function threads.thread__main(smem)
else
log.info("wired modem reconnected")
end
elseif type == "speaker" then
table.insert(sounders, rtu.init_sounder(device))
println_ts("speaker connected")
log.info(util.c("connected speaker ", param1))
databus.tx_hw_spkr_count(#sounders)
else
-- relink lost peripheral to correct unit entry
for i = 1, #units do
@ -252,6 +282,15 @@ function threads.thread__main(smem)
elseif event == "mouse_click" or event == "mouse_up" or event == "mouse_drag" or event == "mouse_scroll" then
-- handle a mouse event
renderer.handle_mouse(core.events.new_mouse_event(event, param1, param2, param3))
elseif event == "speaker_audio_empty" then
-- handle empty speaker audio buffer
for i = 1, #sounders do
local sounder = sounders[i] ---@type rtu_speaker_sounder
if sounder.name == param1 then
sounder.continue()
break
end
end
end
-- check for termination request
@ -299,6 +338,7 @@ function threads.thread__comms(smem)
-- load in from shared memory
local rtu_state = smem.rtu_state
local sounders = smem.rtu_dev.sounders
local rtu_comms = smem.rtu_sys.rtu_comms
local units = smem.rtu_sys.units
@ -321,8 +361,8 @@ function threads.thread__comms(smem)
-- received data
elseif msg.qtype == mqueue.TYPE.PACKET then
-- received a packet
-- handle the packet (rtu_state passed to allow setting link flag)
rtu_comms.handle_packet(msg.message, units, rtu_state)
-- handle the packet (rtu_state passed to allow setting link flag, sounders passed to manage alarm audio)
rtu_comms.handle_packet(msg.message, units, rtu_state, sounders)
end
end

313
scada-common/audio.lua Normal file
View File

@ -0,0 +1,313 @@
--
-- Audio & Tone Control for Alarms
--
-- sounds modeled after https://www.e2s.com/references-and-guidelines/listen-and-download-alarm-tones
-- note: max samples = 0x20000 (128 * 1024 samples)
local _2_PI = 2 * math.pi -- 2 whole pies, hope you're hungry
local _DRATE = 48000 -- 48kHz audio
local _MAX_VAL = 127 / 2 -- max signed integer in this 8-bit audio
local _05s_SAMPLES = 24000 -- half a second worth of samples
---@class audio
local audio = {}
---@enum TONE
local TONE = {
T_340Hz_Int_2Hz = 1,
T_544Hz_440Hz_Alt = 2,
T_660Hz_Int_125ms = 3,
T_745Hz_Int_1Hz = 4,
T_800Hz_Int = 5,
T_800Hz_1000Hz_Alt = 6,
T_1000Hz_Int = 7,
T_1800Hz_Int_4Hz = 8
}
audio.TONE = TONE
local tone_data = {
{ {}, {}, {}, {} }, -- 340Hz @ 2Hz Intermittent
{ {}, {}, {}, {} }, -- 544Hz 100mS / 440Hz 400mS Alternating
{ {}, {}, {}, {} }, -- 660Hz @ 125ms On 125ms Off
{ {}, {}, {}, {} }, -- 745Hz @ 1Hz Intermittent
{ {}, {}, {}, {} }, -- 800Hz @ 0.25s On 1.75s Off
{ {}, {}, {}, {} }, -- 800/1000Hz @ 0.25s Alternating
{ {}, {}, {}, {} }, -- 1KHz 1s on, 1s off Intermittent
{ {}, {}, {}, {} } -- 1.8KHz @ 4Hz Intermittent
}
-- calculate how many samples are in the given number of milliseconds
---@nodiscard
---@param ms integer milliseconds
---@return integer samples
local function ms_to_samples(ms) return math.floor(ms * 48) end
--#region Tone Generation (the Maths)
-- 340Hz @ 2Hz Intermittent
local function gen_tone_1()
local t, dt = 0, _2_PI * 340 / _DRATE
for i = 1, _05s_SAMPLES do
local val = math.floor(math.sin(t) * _MAX_VAL)
tone_data[1][1][i] = val
tone_data[1][3][i] = val
tone_data[1][2][i] = 0
tone_data[1][4][i] = 0
t = (t + dt) % _2_PI
end
end
-- 544Hz 100mS / 440Hz 400mS Alternating
local function gen_tone_2()
local t1, dt1 = 0, _2_PI * 544 / _DRATE
local t2, dt2 = 0, _2_PI * 440 / _DRATE
local alternate_at = ms_to_samples(100)
for i = 1, _05s_SAMPLES do
local value
if i <= alternate_at then
value = math.floor(math.sin(t1) * _MAX_VAL)
t1 = (t1 + dt1) % _2_PI
else
value = math.floor(math.sin(t2) * _MAX_VAL)
t2 = (t2 + dt2) % _2_PI
end
tone_data[2][1][i] = value
tone_data[2][2][i] = value
tone_data[2][3][i] = value
tone_data[2][4][i] = value
end
end
-- 660Hz @ 125ms On 125ms Off
local function gen_tone_3()
local elapsed_samples = 0
local alternate_after = ms_to_samples(125)
local alternate_at = alternate_after
local mode = true
local t, dt = 0, _2_PI * 660 / _DRATE
for set = 1, 4 do
for i = 1, _05s_SAMPLES do
if mode then
local val = math.floor(math.sin(t) * _MAX_VAL)
tone_data[3][set][i] = val
t = (t + dt) % _2_PI
else
t = 0
tone_data[3][set][i] = 0
end
if elapsed_samples == alternate_at then
mode = not mode
alternate_at = elapsed_samples + alternate_after
end
elapsed_samples = elapsed_samples + 1
end
end
end
-- 745Hz @ 1Hz Intermittent
local function gen_tone_4()
local t, dt = 0, _2_PI * 745 / _DRATE
for i = 1, _05s_SAMPLES do
local val = math.floor(math.sin(t) * _MAX_VAL)
tone_data[4][1][i] = val
tone_data[4][3][i] = val
tone_data[4][2][i] = 0
tone_data[4][4][i] = 0
t = (t + dt) % _2_PI
end
end
-- 800Hz @ 0.25s On 1.75s Off
local function gen_tone_5()
local t, dt = 0, _2_PI * 800 / _DRATE
local stop_at = ms_to_samples(250)
for i = 1, _05s_SAMPLES do
local val = math.floor(math.sin(t) * _MAX_VAL)
if i > stop_at then
tone_data[5][1][i] = val
else
tone_data[5][1][i] = 0
end
tone_data[5][2][i] = 0
tone_data[5][3][i] = 0
tone_data[5][4][i] = 0
t = (t + dt) % _2_PI
end
end
-- 1000/800Hz @ 0.25s Alternating
local function gen_tone_6()
local t1, dt1 = 0, _2_PI * 1000 / _DRATE
local t2, dt2 = 0, _2_PI * 800 / _DRATE
local alternate_at = ms_to_samples(250)
for i = 1, _05s_SAMPLES do
local val
if i <= alternate_at then
val = math.floor(math.sin(t1) * _MAX_VAL)
t1 = (t1 + dt1) % _2_PI
else
val = math.floor(math.sin(t2) * _MAX_VAL)
t2 = (t2 + dt2) % _2_PI
end
tone_data[6][1][i] = val
tone_data[6][2][i] = val
tone_data[6][3][i] = val
tone_data[6][4][i] = val
end
end
-- 1KHz 1s on, 1s off Intermittent
local function gen_tone_7()
local t, dt = 0, _2_PI * 1000 / _DRATE
for i = 1, _05s_SAMPLES do
local val = math.floor(math.sin(t) * _MAX_VAL)
tone_data[7][1][i] = val
tone_data[7][2][i] = val
tone_data[7][3][i] = 0
tone_data[7][4][i] = 0
t = (t + dt) % _2_PI
end
end
-- 1800Hz @ 4Hz Intermittent
local function gen_tone_8()
local t, dt = 0, _2_PI * 1800 / _DRATE
local off_at = ms_to_samples(250)
for i = 1, _05s_SAMPLES do
local val = 0
if i <= off_at then
val = math.floor(math.sin(t) * _MAX_VAL)
t = (t + dt) % _2_PI
end
tone_data[8][1][i] = val
tone_data[8][2][i] = val
tone_data[8][3][i] = val
tone_data[8][4][i] = val
end
end
--#endregion
-- generate all 8 tone sequences
function audio.generate_tones()
gen_tone_1(); gen_tone_2(); gen_tone_3(); gen_tone_4(); gen_tone_5(); gen_tone_6(); gen_tone_7(); gen_tone_8()
end
-- hard audio limiter
---@nodiscard
---@param output number output level
---@return number limited -128.0 to 127.0
local function limit(output)
return math.max(-128, math.min(127, output))
end
-- clear output buffer
---@param buffer table quad buffer
local function clear(buffer)
for i = 1, 4 do
for s = 1, _05s_SAMPLES do buffer[i][s] = 0 end
end
end
-- create a new audio tone stream controller
function audio.new_stream()
local self = {
any_active = false,
need_recompute = false,
next_block = 1,
-- split audio up into 0.5s samples, so specific components can be ended quicker
quad_buffer = { {}, {}, {}, {} },
-- all tone enable states
tone_active = { false, false, false, false, false, false, false, false }
}
clear(self.quad_buffer)
---@class tone_stream
local public = {}
-- add a tone to the output buffer
---@param index TONE tone ID
---@param active boolean active state
function public.set_active(index, active)
if self.tone_active[index] ~= nil then
if self.tone_active[index] ~= active then self.need_recompute = true end
self.tone_active[index] = active
end
end
-- check if a tone is active
---@param index TONE tone index
function public.is_active(index)
if self.tone_active[index] then return self.tone_active[index] end
return false
end
-- set all tones inactive, reset next block, and clear output buffer
function public.stop()
for i = 1, #self.tone_active do self.tone_active[i] = false end
self.next_block = 1
clear(self.quad_buffer)
end
-- check if the output buffer needs to be recomputed due to changes
function public.is_recompute_needed() return self.need_recompute end
-- re-compute the output buffer
function public.compute_buffer()
clear(self.quad_buffer)
self.need_recompute = false
self.any_active = false
for id = 1, #tone_data do
if self.tone_active[id] then
self.any_active = true
for i = 1, 4 do
local buffer = self.quad_buffer[i]
local values = tone_data[id][i]
for s = 1, _05s_SAMPLES do self.quad_buffer[i][s] = limit(buffer[s] + values[s]) end
end
end
end
end
-- check if the next audio block has data
function public.has_next_block() return #self.quad_buffer[self.next_block] > 0 end
-- get the next audio block
function public.get_next_block()
local block = self.quad_buffer[self.next_block]
self.next_block = self.next_block + 1
if self.next_block > 4 then self.next_block = 1 end
return block
end
return public
end
return audio

View File

@ -14,7 +14,7 @@ local max_distance = nil ---@type number|nil maximum acceptable t
---@class comms
local comms = {}
comms.version = "2.1.2"
comms.version = "2.2.1"
---@enum PROTOCOL
local PROTOCOL = {
@ -46,7 +46,11 @@ local SCADA_MGMT_TYPE = {
KEEP_ALIVE = 1, -- keep alive packet w/ RTT
CLOSE = 2, -- close a connection
RTU_ADVERT = 3, -- RTU capability advertisement
RTU_DEV_REMOUNT = 4 -- RTU multiblock possbily changed (formed, unformed) due to PPM remount
RTU_DEV_REMOUNT = 4, -- RTU multiblock possbily changed (formed, unformed) due to PPM remount
RTU_TONE_ALARM = 5, -- instruct RTUs to play specified alarm tones
DIAG_TONE_GET = 6, -- diagnostic: get alarm tones
DIAG_TONE_SET = 7, -- diagnostic: set alarm tones
DIAG_ALARM_SET = 8 -- diagnostic: set alarm to simulate audio for
}
---@enum SCADA_CRDN_TYPE
@ -146,6 +150,7 @@ function comms.scada_packet()
local self = {
modem_msg_in = nil, ---@type modem_message|nil
valid = false,
authenticated = false,
raw = {},
src_addr = comms.BROADCAST,
dest_addr = comms.BROADCAST,
@ -234,6 +239,9 @@ function comms.scada_packet()
return self.valid
end
-- report that this packet has been authenticated (was received with a valid HMAC)
function public.stamp_authenticated() self.authenticated = true end
-- public accessors --
---@nodiscard
@ -248,6 +256,8 @@ function comms.scada_packet()
---@nodiscard
function public.is_valid() return self.valid end
---@nodiscard
function public.is_authenticated() return self.authenticated end
---@nodiscard
function public.src_addr() return self.src_addr end
@ -313,7 +323,7 @@ function comms.authd_packet()
self.valid = false
self.raw = self.modem_msg_in.msg
if (type(max_distance) == "number") and (distance > max_distance) then
if (type(max_distance) == "number") and (type(distance) == "number") and (distance > max_distance) then
-- outside of maximum allowable transmission distance
-- log.debug("comms.authd_packet.receive(): discarding packet with distance " .. distance .. " outside of trusted range")
else
@ -588,7 +598,11 @@ function comms.mgmt_packet()
self.type == SCADA_MGMT_TYPE.CLOSE or
self.type == SCADA_MGMT_TYPE.REMOTE_LINKED or
self.type == SCADA_MGMT_TYPE.RTU_ADVERT or
self.type == SCADA_MGMT_TYPE.RTU_DEV_REMOUNT
self.type == SCADA_MGMT_TYPE.RTU_DEV_REMOUNT or
self.type == SCADA_MGMT_TYPE.RTU_TONE_ALARM or
self.type == SCADA_MGMT_TYPE.DIAG_TONE_GET or
self.type == SCADA_MGMT_TYPE.DIAG_TONE_SET or
self.type == SCADA_MGMT_TYPE.DIAG_ALARM_SET
end
-- make a SCADA management packet

View File

@ -2,10 +2,12 @@
-- System and Safety Constants
--
---@class scada_constants
local constants = {}
--#region Reactor Protection System (on the PLC) Limits
---@class _rps_constants
local rps = {}
rps.MAX_DAMAGE_PERCENT = 90 -- damage >= 90%
@ -21,6 +23,7 @@ constants.RPS_LIMITS = rps
--#region Annunciator Limits
---@class _annunciator_constants
local annunc = {}
annunc.RCSFlowLow_H2O = -3.2 -- flow < -3.2 mB/s
@ -44,6 +47,7 @@ constants.ANNUNCIATOR_LIMITS = annunc
--#region Supervisor Alarm Limits
---@class _alarm_constants
local alarms = {}
-- unit alarms

View File

@ -6,8 +6,10 @@ local comms = require("scada-common.comms")
local log = require("scada-common.log")
local util = require("scada-common.util")
local core = require("graphics.core")
local has_graphics, core = pcall(require, "graphics.core")
local has_lockbox, lockbox = pcall(require, "lockbox")
---@class crash_handler
local crash = {}
local app = "unknown"
@ -34,7 +36,8 @@ function crash.handler(error)
log.info(util.c("APPLICATION: ", app))
log.info(util.c("FIRMWARE VERSION: ", ver))
log.info(util.c("COMMS VERSION: ", comms.version))
log.info(util.c("GRAPHICS VERSION: ", core.version))
if has_graphics then log.info(util.c("GRAPHICS VERSION: ", core.version)) end
if has_lockbox then log.info(util.c("LOCKBOX VERSION: ", lockbox.version)) end
log.info("----------------------------------")
log.info(debug.traceback("--- begin debug trace ---", 1))
log.info("--- end debug trace ---")

View File

@ -4,14 +4,11 @@
local util = require("scada-common.util")
---@class log
---@class logger
local log = {}
---@alias MODE integer
local MODE = {
APPEND = 0,
NEW = 1
}
local MODE = { APPEND = 0, NEW = 1 }
log.MODE = MODE

View File

@ -4,6 +4,14 @@
local mqueue = {}
---@class queue_item
---@field qtype MQ_TYPE
---@field message any
---@class queue_data
---@field key any
---@field val any
---@enum MQ_TYPE
local TYPE = {
COMMAND = 0,
@ -13,22 +21,14 @@ local TYPE = {
mqueue.TYPE = TYPE
local insert = table.insert
local remove = table.remove
-- create a new message queue
---@nodiscard
function mqueue.new()
local queue = {}
local insert = table.insert
local remove = table.remove
---@class queue_item
---@field qtype MQ_TYPE
---@field message any
---@class queue_data
---@field key any
---@field val any
---@class mqueue
local public = {}
@ -48,28 +48,20 @@ function mqueue.new()
-- push a new item onto the queue
---@param qtype MQ_TYPE
---@param message any
local function _push(qtype, message)
insert(queue, { qtype = qtype, message = message })
end
local function _push(qtype, message) insert(queue, { qtype = qtype, message = message }) end
-- push a command onto the queue
---@param message any
function public.push_command(message)
_push(TYPE.COMMAND, message)
end
function public.push_command(message) _push(TYPE.COMMAND, message) end
-- push data onto the queue
---@param key any
---@param value any
function public.push_data(key, value)
_push(TYPE.DATA, { key = key, val = value })
end
function public.push_data(key, value) _push(TYPE.DATA, { key = key, val = value }) end
-- push a packet onto the queue
---@param packet packet|frame
function public.push_packet(packet)
_push(TYPE.PACKET, packet)
end
function public.push_packet(packet) _push(TYPE.PACKET, packet) end
-- get an item off the queue
---@nodiscard
@ -77,9 +69,7 @@ function mqueue.new()
function public.pop()
if #queue > 0 then
return remove(queue, 1)
else
return nil
end
else return nil end
end
return public

View File

@ -2,19 +2,21 @@
-- Network Communications
--
local comms = require("scada-common.comms")
local log = require("scada-common.log")
local util = require("scada-common.util")
local md5 = require("lockbox.digest.md5")
local sha256 = require("lockbox.digest.sha2_256")
local pbkdf2 = require("lockbox.kdf.pbkdf2")
local hmac = require("lockbox.mac.hmac")
local stream = require("lockbox.util.stream")
local array = require("lockbox.util.array")
local comms = require("scada-common.comms")
local log = require("scada-common.log")
local util = require("scada-common.util")
---@class scada_net_interface
local network = {}
-- cryptography engine
local c_eng = {
key = nil,
hmac = nil
@ -212,6 +214,7 @@ function network.nic(modem)
if packet_hmac == computed_hmac then
-- log.debug("crypto.modem.receive: HMAC verified in " .. (util.time_ms() - start) .. "ms")
s_packet.receive(side, sender, reply_to, textutils.unserialize(msg), distance)
s_packet.stamp_authenticated()
else
-- log.debug("crypto.modem.receive: HMAC failed verification in " .. (util.time_ms() - start) .. "ms")
end

View File

@ -9,9 +9,7 @@ local util = require("scada-common.util")
local ppm = {}
local ACCESS_FAULT = nil ---@type nil
local UNDEFINED_FIELD = "undefined field"
local VIRTUAL_DEVICE_TYPE = "ppm_vdev"
ppm.ACCESS_FAULT = ACCESS_FAULT
@ -34,9 +32,9 @@ local ppm_sys = {
mute = false
}
-- wrap peripheral calls with lua protected call as we don't want a disconnect to crash a program<br>
-- also provides peripheral-specific fault checks (auto-clear fault defaults to true)<br>
-- assumes iface is a valid peripheral
-- Wrap peripheral calls with lua protected call as we don't want a disconnect to crash a program.
-- Additionally provides peripheral-specific fault checks (auto-clear fault defaults to true).<br>
-- Note: assumes iface is a valid peripheral.
---@param iface string CC peripheral interface
local function peri_init(iface)
local self = {
@ -307,16 +305,12 @@ end
-- list all available peripherals
---@nodiscard
---@return table names
function ppm.list_avail()
return peripheral.getNames()
end
function ppm.list_avail() return peripheral.getNames() end
-- list mounted peripherals
---@nodiscard
---@return table mounts
function ppm.list_mounts()
return ppm_sys.mounts
end
function ppm.list_mounts() return ppm_sys.mounts end
-- get a mounted peripheral side/interface by device table
---@nodiscard
@ -390,9 +384,7 @@ end
-- get the fission reactor (if multiple, returns the first)
---@nodiscard
---@return table|nil reactor function table
function ppm.get_fission_reactor()
return ppm.get_device("fissionReactorLogicAdapter")
end
function ppm.get_fission_reactor() return ppm.get_device("fissionReactorLogicAdapter") end
-- get the wireless modem (if multiple, returns the first)<br>
-- if this is in a CraftOS emulated environment, wired modems will be used instead
@ -419,9 +411,7 @@ function ppm.get_monitor_list()
local list = {}
for iface, device in pairs(ppm_sys.mounts) do
if device.type == "monitor" then
list[iface] = device
end
if device.type == "monitor" then list[iface] = device end
end
return list

View File

@ -9,14 +9,12 @@ local psil = {}
-- instantiate a new PSI layer
---@nodiscard
function psil.create()
local self = {
ic = {}
}
local ic = {}
-- allocate a new interconnect field
---@key string data key
local function alloc(key)
self.ic[key] = { subscribers = {}, value = nil }
ic[key] = { subscribers = {}, value = nil }
end
---@class psil
@ -28,22 +26,22 @@ function psil.create()
---@param func function function to call on change
function public.subscribe(key, func)
-- allocate new key if not found or notify if value is found
if self.ic[key] == nil then
if ic[key] == nil then
alloc(key)
elseif self.ic[key].value ~= nil then
func(self.ic[key].value)
elseif ic[key].value ~= nil then
func(ic[key].value)
end
-- subscribe to key
table.insert(self.ic[key].subscribers, { notify = func })
table.insert(ic[key].subscribers, { notify = func })
end
-- unsubscribe a function from a given key
---@param key string data key
---@param func function function to unsubscribe
function public.unsubscribe(key, func)
if self.ic[key] ~= nil then
util.filter_table(self.ic[key].subscribers, function (s) return s.notify ~= func end)
if ic[key] ~= nil then
util.filter_table(ic[key].subscribers, function (s) return s.notify ~= func end)
end
end
@ -51,32 +49,32 @@ function psil.create()
---@param key string data key
---@param value any data value
function public.publish(key, value)
if self.ic[key] == nil then alloc(key) end
if ic[key] == nil then alloc(key) end
if self.ic[key].value ~= value then
for i = 1, #self.ic[key].subscribers do
self.ic[key].subscribers[i].notify(value)
if ic[key].value ~= value then
for i = 1, #ic[key].subscribers do
ic[key].subscribers[i].notify(value)
end
end
self.ic[key].value = value
ic[key].value = value
end
-- publish a toggled boolean value to a given key, passing it to all subscribers if it has changed<br>
-- this is intended to be used to toggle boolean indicators such as heartbeats without extra state variables
---@param key string data key
function public.toggle(key)
if self.ic[key] == nil then alloc(key) end
if ic[key] == nil then alloc(key) end
self.ic[key].value = self.ic[key].value == false
ic[key].value = ic[key].value == false
for i = 1, #self.ic[key].subscribers do
self.ic[key].subscribers[i].notify(self.ic[key].value)
for i = 1, #ic[key].subscribers do
ic[key].subscribers[i].notify(ic[key].value)
end
end
-- clear the contents of the interconnect
function public.purge() self.ic = nil end
function public.purge() ic = {} end
return public
end

View File

@ -123,7 +123,7 @@ function rsio.to_string(port)
if util.is_int(port) and port > 0 and port <= #names then
return names[port]
else
return ""
return "UNKNOWN"
end
end

View File

@ -68,7 +68,7 @@ function tcd.handle(event)
end
end
-- identify any overdo callbacks<br>
-- identify any overdue callbacks<br>
-- prints to log debug output
function tcd.diagnostics()
for timer, entry in pairs(registry) do

View File

@ -7,6 +7,9 @@ local cc_strings = require("cc.strings")
---@class util
local util = {}
-- scada-common version
util.version = "1.0.1"
-- ENVIRONMENT CONSTANTS --
util.TICK_TIME_S = 0.05
@ -80,9 +83,9 @@ end
---@return string
function util.strrep(str, n)
local repeated = ""
for _ = 1, n do
repeated = repeated .. str
end
for _ = 1, n do repeated = repeated .. str end
return repeated
end
@ -123,7 +126,9 @@ function util.strwrap(str, limit) return cc_strings.wrap(str, limit) end
---@diagnostic disable-next-line: unused-vararg
function util.concat(...)
local str = ""
for _, v in ipairs(arg) do str = str .. util.strval(v) end
return str
end
@ -322,7 +327,8 @@ end
--- EVENT_CONSUMER: this function consumes events
function util.nop() util.psleep(0.05) end
-- attempt to maintain a minimum loop timing (duration of execution)
-- attempt to maintain a minimum loop timing (duration of execution)<br>
-- note: will not yield for time periods less than 50ms
---@nodiscard
---@param target_timing integer minimum amount of milliseconds to wait for
---@param last_update integer millisecond time of last update
@ -399,7 +405,7 @@ local function GFE(fe) return fe / 1000000000.0 end
local function TFE(fe) return fe / 1000000000000.0 end
local function PFE(fe) return fe / 1000000000000000.0 end
local function EFE(fe) return fe / 1000000000000000000.0 end -- if you accomplish this please touch grass
local function ZFE(fe) return fe / 1000000000000000000000.0 end -- please stop
local function ZFE(fe) return fe / 1000000000000000000000.0 end -- how & why did you do this?
-- format a power value into XXX.XX UNIT format (FE, kFE, MFE, GFE, TFE, PFE, EFE, ZFE)
---@nodiscard

View File

@ -10,27 +10,39 @@ config.RTU_CHANNEL = 16242
config.CRD_CHANNEL = 16243
-- pocket comms channel
config.PKT_CHANNEL = 16244
-- max trusted modem message distance (0 to disable check)
-- max trusted modem message distance
-- (0 to disable check)
config.TRUSTED_RANGE = 0
-- time in seconds (>= 2) before assuming a remote device is no longer active
-- time in seconds (>= 2) before assuming a remote
-- device is no longer active
config.PLC_TIMEOUT = 5
config.RTU_TIMEOUT = 5
config.CRD_TIMEOUT = 5
config.PKT_TIMEOUT = 5
-- facility authentication key (do NOT use one of your passwords)
-- facility authentication key
-- (do NOT use one of your passwords)
-- this enables verifying that messages are authentic
-- all devices on the same network must use the same key
-- all devices on this network must use this key
-- config.AUTH_KEY = "SCADAfacility123"
-- expected number of reactors
config.NUM_REACTORS = 4
-- expected number of boilers/turbines for each reactor
-- expected number of devices for each unit
config.REACTOR_COOLING = {
{ BOILERS = 1, TURBINES = 1 }, -- reactor unit 1
{ BOILERS = 1, TURBINES = 1 }, -- reactor unit 2
{ BOILERS = 1, TURBINES = 1 }, -- reactor unit 3
{ BOILERS = 1, TURBINES = 1 } -- reactor unit 4
-- reactor unit 1
{ BOILERS = 1, TURBINES = 1, TANK = false },
-- reactor unit 2
{ BOILERS = 1, TURBINES = 1, TANK = false },
-- reactor unit 3
{ BOILERS = 1, TURBINES = 1, TANK = false },
-- reactor unit 4
{ BOILERS = 1, TURBINES = 1, TANK = false }
}
-- advanced facility dynamic tank configuration
-- (see wiki for details)
-- by default, dynamic tanks are for each unit
config.FAC_TANK_MODE = 0
config.FAC_TANK_DEFS = { 0, 0, 0, 0 }
-- log path
config.LOG_PATH = "/log.txt"

View File

@ -1,3 +1,4 @@
local audio = require("scada-common.audio")
local const = require("scada-common.constants")
local log = require("scada-common.log")
local rsio = require("scada-common.rsio")
@ -6,17 +7,26 @@ local util = require("scada-common.util")
local unit = require("supervisor.unit")
local qtypes = require("supervisor.session.rtu.qtypes")
local rsctl = require("supervisor.session.rsctl")
local TONE = audio.TONE
local ALARM = types.ALARM
local PRIO = types.ALARM_PRIORITY
local ALARM_STATE = types.ALARM_STATE
local CONTAINER_MODE = types.CONTAINER_MODE
local PROCESS = types.PROCESS
local PROCESS_NAMES = types.PROCESS_NAMES
local PRIO = types.ALARM_PRIORITY
local RTU_UNIT_TYPE = types.RTU_UNIT_TYPE
local WASTE = types.WASTE_PRODUCT
local WASTE_MODE = types.WASTE_MODE
local WASTE = types.WASTE_PRODUCT
local IO = rsio.IO
local DTV_RTU_S_DATA = qtypes.DTV_RTU_S_DATA
-- 7.14 kJ per blade for 1 mB of fissile fuel<br>
-- 2856 FE per blade per 1 mB, 285.6 FE per blade per 0.1 mB (minimum)
local POWER_PER_BLADE = util.joules_to_fe(7140)
@ -54,12 +64,13 @@ local facility = {}
-- create a new facility management object
---@nodiscard
---@param num_reactors integer number of reactor units
---@param cooling_conf table cooling configurations of reactor units
---@param cooling_conf sv_cooling_conf cooling configurations of reactor units
function facility.new(num_reactors, cooling_conf)
local self = {
units = {},
status_text = { "START UP", "initializing..." },
all_sys_ok = false,
allow_testing = false,
-- rtus
rtu_conn_count = 0,
rtu_list = {},
@ -109,6 +120,12 @@ function facility.new(num_reactors, cooling_conf)
waste_product = WASTE.PLUTONIUM,
current_waste_product = WASTE.PLUTONIUM,
pu_fallback = false,
-- alarm tones
tone_states = {},
test_tone_set = false,
test_tone_reset = false,
test_tone_states = {},
test_alarm_states = {},
-- statistics
im_stat_init = false,
avg_charge = util.mov_avg(3, 0.0),
@ -118,7 +135,7 @@ function facility.new(num_reactors, cooling_conf)
-- create units
for i = 1, num_reactors do
table.insert(self.units, unit.new(i, cooling_conf[i].BOILERS, cooling_conf[i].TURBINES))
table.insert(self.units, unit.new(i, cooling_conf.r_cool[i].BOILERS, cooling_conf.r_cool[i].TURBINES))
table.insert(self.group_map, 0)
end
@ -128,6 +145,13 @@ function facility.new(num_reactors, cooling_conf)
-- init redstone RTU I/O controller
self.io_ctl = rsctl.new(self.redstone)
-- fill blank alarm/tone states
for _ = 1, 12 do table.insert(self.test_alarm_states, false) end
for _ = 1, 8 do
table.insert(self.tone_states, false)
table.insert(self.test_tone_states, false)
end
-- check if all auto-controlled units completed ramping
---@nodiscard
local function _all_units_ramped()
@ -262,15 +286,20 @@ function facility.new(num_reactors, cooling_conf)
-- supervisor sessions reporting the list of active RTU sessions
---@param rtu_sessions table session list of all connected RTUs
function public.report_rtus(rtu_sessions)
self.rtu_conn_count = #rtu_sessions
end
function public.report_rtus(rtu_sessions) self.rtu_conn_count = #rtu_sessions end
-- update (iterate) the facility management
function public.update()
-- unlink RTU unit sessions if they are closed
for _, v in pairs(self.rtu_list) do util.filter_table(v, function (u) return u.is_connected() end) end
-- check if test routines are allowed right now
self.allow_testing = true
for i = 1, #self.units do
local u = self.units[i] ---@type reactor_unit
self.allow_testing = self.allow_testing and u.is_safe_idle()
end
-- current state for process control
local charge_update = 0
local rate_update = 0
@ -732,24 +761,138 @@ function facility.new(num_reactors, cooling_conf)
self.io_ctl.digital_write(IO.F_ALARM, has_alarm)
end
-----------------------------
-- Update Waste Processing --
-----------------------------
----------------
-- Unit Tasks --
----------------
local insufficent_po_rate = false
local need_emcool = false
for i = 1, #self.units do
local u = self.units[i] ---@type reactor_unit
-- update auto waste processing
if u.get_control_inf().waste_mode == WASTE_MODE.AUTO then
if (u.get_sna_rate() * 10.0) < u.get_burn_rate() then
insufficent_po_rate = true
break
end
end
-- check if unit activated emergency coolant & uses facility tanks
if (cooling_conf.fac_tank_mode > 0) and u.is_emer_cool_tripped() and (cooling_conf.fac_tank_defs[i] == 2) then
need_emcool = true
end
end
-- update waste product
if self.waste_product == WASTE.PLUTONIUM or (self.pu_fallback and insufficent_po_rate) then
self.current_waste_product = WASTE.PLUTONIUM
else self.current_waste_product = self.waste_product end
-- make sure dynamic tanks are allowing outflow if required
-- set all, rather than trying to determine which is for which (simpler & safer)
-- there should be no need for any to be in fill only mode
if need_emcool then
for i = 1, #self.tanks do
local session = self.tanks[i] ---@type unit_session
local tank = session.get_db() ---@type dynamicv_session_db
if tank.state.container_mode == CONTAINER_MODE.FILL then
session.get_cmd_queue().push_data(DTV_RTU_S_DATA.SET_CONT_MODE, CONTAINER_MODE.BOTH)
end
end
end
if self.waste_product == WASTE.PLUTONIUM or (self.pu_fallback and insufficent_po_rate) then
self.current_waste_product = WASTE.PLUTONIUM
else self.current_waste_product = self.waste_product end
------------------------
-- Update Alarm Tones --
------------------------
local allow_test = self.allow_testing and self.test_tone_set
local alarms = { false, false, false, false, false, false, false, false, false, false, false, false }
-- reset tone states before re-evaluting
for i = 1, #self.tone_states do self.tone_states[i] = false end
if allow_test then
alarms = self.test_alarm_states
else
-- check all alarms for all units
for i = 1, #self.units do
local u = self.units[i] ---@type reactor_unit
for id, alarm in pairs(u.get_alarms()) do
alarms[id] = alarms[id] or (alarm == ALARM_STATE.TRIPPED)
end
end
if not self.test_tone_reset then
-- clear testing alarms if we aren't using them
for i = 1, #self.test_alarm_states do self.test_alarm_states[i] = false end
end
end
-- Evaluate Alarms --
-- containment breach is worst case CRITICAL alarm, this takes priority
if alarms[ALARM.ContainmentBreach] then
self.tone_states[TONE.T_1800Hz_Int_4Hz] = true
else
-- critical damage is highest priority CRITICAL level alarm
if alarms[ALARM.CriticalDamage] then
self.tone_states[TONE.T_660Hz_Int_125ms] = true
else
-- EMERGENCY level alarms + URGENT over temp
if alarms[ALARM.ReactorDamage] or alarms[ALARM.ReactorOverTemp] or alarms[ALARM.ReactorWasteLeak] then
self.tone_states[TONE.T_544Hz_440Hz_Alt] = true
-- URGENT level turbine trip
elseif alarms[ALARM.TurbineTrip] then
self.tone_states[TONE.T_745Hz_Int_1Hz] = true
-- URGENT level reactor lost
elseif alarms[ALARM.ReactorLost] then
self.tone_states[TONE.T_340Hz_Int_2Hz] = true
-- TIMELY level alarms
elseif alarms[ALARM.ReactorHighTemp] or alarms[ALARM.ReactorHighWaste] or alarms[ALARM.RCSTransient] then
self.tone_states[TONE.T_800Hz_Int] = true
end
end
-- check RPS transient URGENT level alarm
if alarms[ALARM.RPSTransient] then
self.tone_states[TONE.T_1000Hz_Int] = true
-- disable really painful audio combination
self.tone_states[TONE.T_340Hz_Int_2Hz] = false
end
end
-- radiation is a big concern, always play this CRITICAL level alarm if active
if alarms[ALARM.ContainmentRadiation] then
self.tone_states[TONE.T_800Hz_1000Hz_Alt] = true
-- we are going to disable the RPS trip alarm audio due to conflict, and if it was enabled
-- then we can re-enable the reactor lost alarm audio since it doesn't painfully combine with this one
if self.tone_states[TONE.T_1000Hz_Int] and alarms[ALARM.ReactorLost] then self.tone_states[TONE.T_340Hz_Int_2Hz] = true end
-- it sounds *really* bad if this is in conjunction with these other tones, so disable them
self.tone_states[TONE.T_745Hz_Int_1Hz] = false
self.tone_states[TONE.T_800Hz_Int] = false
self.tone_states[TONE.T_1000Hz_Int] = false
end
-- add to tone states if testing is active
if allow_test then
for i = 1, #self.tone_states do
self.tone_states[i] = self.tone_states[i] or self.test_tone_states[i]
end
self.test_tone_reset = false
else
if not self.test_tone_reset then
-- clear testing tones if we aren't using them
for i = 1, #self.test_tone_states do self.test_tone_states[i] = false end
end
-- flag that tones were reset
self.test_tone_set = false
self.test_tone_reset = true
end
end
-- call the update function of all units in the facility<br>
@ -891,8 +1034,52 @@ function facility.new(num_reactors, cooling_conf)
return self.pu_fallback
end
-- DIAGNOSTIC TESTING --
-- attempt to set a test tone state
---@param id TONE|0 tone ID or 0 to disable all
---@param state boolean state
---@return boolean allow_testing, table test_tone_states
function public.diag_set_test_tone(id, state)
if self.allow_testing then
self.test_tone_set = true
self.test_tone_reset = false
if id == 0 then
for i = 1, #self.test_tone_states do self.test_tone_states[i] = false end
else
self.test_tone_states[id] = state
end
end
return self.allow_testing, self.test_tone_states
end
-- attempt to set a test alarm state
---@param id ALARM|0 alarm ID or 0 to disable all
---@param state boolean state
---@return boolean allow_testing, table test_alarm_states
function public.diag_set_test_alarm(id, state)
if self.allow_testing then
self.test_tone_set = true
self.test_tone_reset = false
if id == 0 then
for i = 1, #self.test_alarm_states do self.test_alarm_states[i] = false end
else
self.test_alarm_states[id] = state
end
end
return self.allow_testing, self.test_alarm_states
end
-- READ STATES/PROPERTIES --
-- get current alarm tone on/off states
---@nodiscard
function public.get_alarm_tones() return self.tone_states end
-- get build properties of all facility devices
---@nodiscard
---@param type RTU_UNIT_TYPE? type or nil to include only a particular unit type, or to include all if nil

View File

@ -17,6 +17,9 @@ local FAC_COMMAND = comms.FAC_COMMAND
local SV_Q_DATA = svqtypes.SV_Q_DATA
-- grace period in seconds for coordinator to finish UI draw to prevent timeout
local WATCHDOG_GRACE = 20.0
-- retry time constants in ms
-- local INITIAL_WAIT = 1500
local RETRY_PERIOD = 1000
@ -61,6 +64,7 @@ function coordinator.new_session(id, s_addr, in_queue, out_queue, timeout, facil
r_seq_num = nil,
connected = true,
conn_watchdog = util.new_watchdog(timeout),
establish_time = util.time_s(),
last_rtt = 0,
-- periodic messages
periodics = {
@ -150,7 +154,8 @@ function coordinator.new_session(id, s_addr, in_queue, out_queue, timeout, facil
local function _send_fac_status()
local status = {
facility.get_control_status(),
facility.get_rtu_statuses()
facility.get_rtu_statuses(),
facility.get_alarm_tones()
}
_send(SCADA_CRDN_TYPE.FAC_STATUS, status)
@ -168,7 +173,8 @@ function coordinator.new_session(id, s_addr, in_queue, out_queue, timeout, facil
unit.get_rtu_statuses(),
unit.get_annunciator(),
unit.get_alarms(),
unit.get_state()
unit.get_state(),
unit.get_valves()
}
end
@ -354,7 +360,15 @@ function coordinator.new_session(id, s_addr, in_queue, out_queue, timeout, facil
-- check if a timer matches this session's watchdog
---@nodiscard
function public.check_wd(timer)
return self.conn_watchdog.is_timer(timer) and self.connected
local is_wd = self.conn_watchdog.is_timer(timer) and self.connected
-- if we are waiting for initial coordinator UI draw, don't close yet
if is_wd and (util.time_s() - self.establish_time) <= WATCHDOG_GRACE then
self.conn_watchdog.feed()
is_wd = false
end
return is_wd
end
-- close the connection

View File

@ -33,8 +33,9 @@ local PERIODICS = {
---@param in_queue mqueue in message queue
---@param out_queue mqueue out message queue
---@param timeout number communications timeout
---@param facility facility facility data table
---@param fp_ok boolean if the front panel UI is running
function pocket.new_session(id, s_addr, in_queue, out_queue, timeout, fp_ok)
function pocket.new_session(id, s_addr, in_queue, out_queue, timeout, facility, fp_ok)
-- print a log message to the terminal as long as the UI isn't running
local function println(message) if not fp_ok then util.println_ts(message) end end
@ -129,6 +130,55 @@ function pocket.new_session(id, s_addr, in_queue, out_queue, timeout, fp_ok)
elseif pkt.type == SCADA_MGMT_TYPE.CLOSE then
-- close the session
_close()
elseif pkt.type == SCADA_MGMT_TYPE.DIAG_TONE_GET then
-- get the state of alarm tones
_send_mgmt(SCADA_MGMT_TYPE.DIAG_TONE_GET, facility.get_alarm_tones())
elseif pkt.type == SCADA_MGMT_TYPE.DIAG_TONE_SET then
local valid = false
-- attempt to set a tone state
if pkt.scada_frame.is_authenticated() then
if pkt.length == 2 then
if type(pkt.data[1]) == "number" and type(pkt.data[2]) == "boolean" then
valid = true
-- try to set tone states, then send back if testing is allowed
local allow_testing, test_tone_states = facility.diag_set_test_tone(pkt.data[1], pkt.data[2])
_send_mgmt(SCADA_MGMT_TYPE.DIAG_TONE_SET, { allow_testing, test_tone_states })
else
log.debug(log_header .. "SCADA diag tone set packet data type mismatch")
end
else
log.debug(log_header .. "SCADA diag tone set packet length mismatch")
end
else
log.debug(log_header .. "DIAG_TONE_SET is blocked without HMAC for security")
end
if not valid then _send_mgmt(SCADA_MGMT_TYPE.DIAG_TONE_SET, { false }) end
elseif pkt.type == SCADA_MGMT_TYPE.DIAG_ALARM_SET then
local valid = false
-- attempt to set an alarm state
if pkt.scada_frame.is_authenticated() then
if pkt.length == 2 then
if type(pkt.data[1]) == "number" and type(pkt.data[2]) == "boolean" then
valid = true
-- try to set alarm states, then send back if testing is allowed
local allow_testing, test_alarm_states = facility.diag_set_test_alarm(pkt.data[1], pkt.data[2])
_send_mgmt(SCADA_MGMT_TYPE.DIAG_ALARM_SET, { allow_testing, test_alarm_states })
else
log.debug(log_header .. "SCADA diag alarm set packet data type mismatch")
end
else
log.debug(log_header .. "SCADA diag alarm set packet length mismatch")
end
else
log.debug(log_header .. "DIAG_ALARM_SET is blocked without HMAC for security")
end
if not valid then _send_mgmt(SCADA_MGMT_TYPE.DIAG_ALARM_SET, { false }) end
else
log.debug(log_header .. "handler received unsupported SCADA_MGMT packet type " .. pkt.type)
end

View File

@ -11,6 +11,18 @@ function rsctl.new(redstone_rtus)
---@class rs_controller
local public = {}
-- check if a redstone port has available connections
---@param port IO_PORT
---@return boolean
function public.is_connected(port)
for i = 1, #redstone_rtus do
local db = redstone_rtus[i].get_db() ---@type redstone_session_db
if db.io[port] ~= nil then return true end
end
return false
end
-- write to a digital redstone port (applies to all RTUs)
---@param port IO_PORT
---@param value boolean

View File

@ -26,7 +26,8 @@ local SCADA_MGMT_TYPE = comms.SCADA_MGMT_TYPE
local RTU_UNIT_TYPE = types.RTU_UNIT_TYPE
local PERIODICS = {
KEEP_ALIVE = 2000
KEEP_ALIVE = 2000,
ALARM_TONES = 500
}
-- create a new RTU session
@ -58,7 +59,8 @@ function rtu.new_session(id, s_addr, in_queue, out_queue, timeout, advertisement
-- periodic messages
periodics = {
last_update = 0,
keep_alive = 0
keep_alive = 0,
alarm_tones = 0
},
units = {}
}
@ -389,6 +391,14 @@ function rtu.new_session(id, s_addr, in_queue, out_queue, timeout, advertisement
periodics.keep_alive = 0
end
-- alarm tones
periodics.alarm_tones = periodics.alarm_tones + elapsed
if periodics.alarm_tones >= PERIODICS.ALARM_TONES then
_send_mgmt(SCADA_MGMT_TYPE.RTU_TONE_ALARM, { facility.get_alarm_tones() })
periodics.alarm_tones = 0
end
self.periodics.last_update = util.time()
--------------------------------------------

View File

@ -104,8 +104,8 @@ local function _sv_handle_outq(session)
-- max 100ms spent processing queue
if util.time() - handle_start > 100 then
log.warning("[SVS] supervisor out queue handler exceeded 100ms queue process limit")
log.warning(util.c("[SVS] offending session: ", session))
log.debug("[SVS] supervisor out queue handler exceeded 100ms queue process limit")
log.debug(util.c("[SVS] offending session: ", session))
break
end
end
@ -198,7 +198,7 @@ end
---@param nic nic network interface device
---@param fp_ok boolean front panel active
---@param num_reactors integer number of reactors
---@param cooling_conf table cooling configuration definition
---@param cooling_conf sv_cooling_conf cooling configuration definition
function svsessions.init(nic, fp_ok, num_reactors, cooling_conf)
self.nic = nic
self.fp_ok = fp_ok
@ -430,7 +430,8 @@ function svsessions.establish_pdg_session(source_addr, version)
local id = self.next_ids.pdg
pdg_s.instance = pocket.new_session(id, source_addr, pdg_s.in_queue, pdg_s.out_queue, config.PKT_TIMEOUT, self.fp_ok)
pdg_s.instance = pocket.new_session(id, source_addr, pdg_s.in_queue, pdg_s.out_queue, config.PKT_TIMEOUT, self.facility,
self.fp_ok)
table.insert(self.sessions.pdg, pdg_s)
local mt = {

View File

@ -21,7 +21,7 @@ local supervisor = require("supervisor.supervisor")
local svsessions = require("supervisor.session.svsessions")
local SUPERVISOR_VERSION = "v0.20.4"
local SUPERVISOR_VERSION = "v1.0.0"
local println = util.println
local println_ts = util.println_ts
@ -48,11 +48,16 @@ cfv.assert_type_num(config.PKT_TIMEOUT)
cfv.assert_min(config.PKT_TIMEOUT, 2)
cfv.assert_type_int(config.NUM_REACTORS)
cfv.assert_type_table(config.REACTOR_COOLING)
cfv.assert_type_int(config.FAC_TANK_MODE)
cfv.assert_type_table(config.FAC_TANK_DEFS)
cfv.assert_type_str(config.LOG_PATH)
cfv.assert_type_int(config.LOG_MODE)
assert(cfv.valid(), "bad config file: missing/invalid fields")
assert((config.FAC_TANK_MODE == 0) or (config.NUM_REACTORS == #config.FAC_TANK_DEFS),
"bad config file: FAC_TANK_DEFS length not equal to NUM_REACTORS")
cfv.assert_eq(#config.REACTOR_COOLING, config.NUM_REACTORS)
assert(cfv.valid(), "config: number of cooling configs different than number of units")
@ -61,6 +66,7 @@ for i = 1, config.NUM_REACTORS do
assert(cfv.valid(), "config: missing cooling entry for reactor " .. i)
cfv.assert_type_int(config.REACTOR_COOLING[i].BOILERS)
cfv.assert_type_int(config.REACTOR_COOLING[i].TURBINES)
cfv.assert_type_bool(config.REACTOR_COOLING[i].TANK)
assert(cfv.valid(), "config: missing boilers/turbines for reactor " .. i)
cfv.assert_min(config.REACTOR_COOLING[i].BOILERS, 0)
cfv.assert_min(config.REACTOR_COOLING[i].TURBINES, 1)

View File

@ -32,7 +32,8 @@ function supervisor.comms(_version, nic, fp_ok)
-- configuration data
local num_reactors = config.NUM_REACTORS
local cooling_conf = config.REACTOR_COOLING
---@class sv_cooling_conf
local cooling_conf = { r_cool = config.REACTOR_COOLING, fac_tank_mode = config.FAC_TANK_MODE, fac_tank_defs = config.FAC_TANK_DEFS }
local self = {
last_est_acks = {}
@ -295,16 +296,10 @@ function supervisor.comms(_version, nic, fp_ok)
local s_id = svsessions.establish_crd_session(src_addr, firmware_v)
if s_id ~= false then
local cfg = { num_reactors }
for i = 1, #cooling_conf do
table.insert(cfg, cooling_conf[i].BOILERS)
table.insert(cfg, cooling_conf[i].TURBINES)
end
println(util.c("CRD (", firmware_v, ") [@", src_addr, "] \xbb connected"))
log.info(util.c("CRD_ESTABLISH: coordinator (", firmware_v, ") [@", src_addr, "] connected with session ID ", s_id))
_send_establish(packet.scada_frame, ESTABLISH_ACK.ALLOW, cfg)
_send_establish(packet.scada_frame, ESTABLISH_ACK.ALLOW, { num_reactors, cooling_conf })
else
if last_ack ~= ESTABLISH_ACK.COLLISION then
log.info("CRD_ESTABLISH: denied new coordinator [@" .. src_addr .. "] due to already being connected to another coordinator")

View File

@ -333,14 +333,28 @@ function unit.new(reactor_id, num_boilers, num_turbines)
--#region redstone I/O
local __rs_w = self.io_ctl.digital_write
-- create a generic valve interface
---@nodiscard
---@param port IO_PORT
local function _make_valve_iface(port)
---@class unit_valve_iface
local iface = {
open = function () self.io_ctl.digital_write(port, true) end,
close = function () self.io_ctl.digital_write(port, false) end,
-- check valve state
---@nodiscard
---@return 0|1|2 0 for not connected, 1 for inactive, 2 for active
check = function () return util.trinary(self.io_ctl.is_connected(port), util.trinary(self.io_ctl.digital_read(port), 2, 1), 0) end
}
return iface
end
-- valves
local waste_pu = { open = function () __rs_w(IO.WASTE_PU, true) end, close = function () __rs_w(IO.WASTE_PU, false) end }
local waste_sna = { open = function () __rs_w(IO.WASTE_PO, true) end, close = function () __rs_w(IO.WASTE_PO, false) end }
local waste_po = { open = function () __rs_w(IO.WASTE_POPL, true) end, close = function () __rs_w(IO.WASTE_POPL, false) end }
local waste_sps = { open = function () __rs_w(IO.WASTE_AM, true) end, close = function () __rs_w(IO.WASTE_AM, false) end }
local emer_cool = { open = function () __rs_w(IO.U_EMER_COOL, true) end, close = function () __rs_w(IO.U_EMER_COOL, false) end }
local waste_pu = _make_valve_iface(IO.WASTE_PU)
local waste_sna = _make_valve_iface(IO.WASTE_PO)
local waste_po = _make_valve_iface(IO.WASTE_POPL)
local waste_sps = _make_valve_iface(IO.WASTE_AM)
local emer_cool = _make_valve_iface(IO.U_EMER_COOL)
---@class unit_valves
self.valves = {
@ -719,6 +733,27 @@ function unit.new(reactor_id, num_boilers, num_turbines)
return false
end
-- check if the reactor is connected, is stopped, the RPS is not tripped, and no alarms are active
---@nodiscard
function public.is_safe_idle()
-- can't be disconnected
if self.plc_i == nil then return false end
-- reactor must be stopped and RPS can't be tripped
if self.plc_i.get_status().status or self.plc_i.get_db().rps_tripped then return false end
-- alarms must be inactive and not tripping
for _, alarm in pairs(self.alarms) do
if not (alarm.state == AISTATE.INACTIVE or alarm.state == AISTATE.RING_BACK) then return false end
end
return true
end
-- check if emergency coolant activation has been tripped
---@nodiscard
function public.is_emer_cool_tripped() return self.emcool_opened end
-- get build properties of machines
--
-- filter options
@ -813,7 +848,12 @@ function unit.new(reactor_id, num_boilers, num_turbines)
end
-- basic SNA statistical information
status.sna = { #self.snas, public.get_sna_rate() }
local total_peak = 0
for i = 1, #self.snas do
local db = self.snas[i].get_db() ---@type sna_session_db
total_peak = total_peak + db.state.peak_production
end
status.sna = { #self.snas, public.get_sna_rate(), total_peak }
-- radiation monitors (environment detectors)
status.rad_mon = {}
@ -864,6 +904,19 @@ function unit.new(reactor_id, num_boilers, num_turbines)
}
end
-- get valve states
---@nodiscard
function public.get_valves()
local v = self.valves
return {
v.waste_pu.check(),
v.waste_sna.check(),
v.waste_po.check(),
v.waste_sps.check(),
v.emer_cool.check()
}
end
-- get the reactor ID
---@nodiscard
function public.get_id() return self.r_id end

View File

@ -10,11 +10,13 @@ local qtypes = require("supervisor.session.rtu.qtypes")
local RPS_TRIP_CAUSE = types.RPS_TRIP_CAUSE
local TRI_FAIL = types.TRI_FAIL
local CONTAINER_MODE = types.CONTAINER_MODE
local DUMPING_MODE = types.DUMPING_MODE
local PRIO = types.ALARM_PRIORITY
local ALARM_STATE = types.ALARM_STATE
local TBV_RTU_S_DATA = qtypes.TBV_RTU_S_DATA
local DTV_RTU_S_DATA = qtypes.DTV_RTU_S_DATA
local IO = rsio.IO
@ -826,6 +828,16 @@ function logic.handle_redstone(self)
end
end
-- make sure dynamic tanks are allowing outflow
for i = 1, #self.tanks do
local session = self.tanks[i] ---@type unit_session
local tank = session.get_db() ---@type dynamicv_session_db
if tank.state.container_mode == CONTAINER_MODE.FILL then
session.get_cmd_queue().push_data(DTV_RTU_S_DATA.SET_CONT_MODE, CONTAINER_MODE.BOTH)
end
end
if self.db.annunciator.EmergencyCoolant > 1 and not self.emcool_opened then
log.info(util.c("UNIT ", self.r_id, " emergency coolant valve opened"))
log.info(util.c("UNIT ", self.r_id, " turbines set to dump excess steam"))