diff --git a/README.md b/README.md
index e8f901f..567fea7 100644
--- a/README.md
+++ b/README.md
@@ -6,9 +6,9 @@ Configurable ComputerCraft SCADA system for multi-reactor control of Mekanism fi


-### [Join](https://discord.gg/R9NSCkhcwt) the Discord!
+### Join [the Discord](https://discord.gg/R9NSCkhcwt)!
-
+
## Released Component Versions
@@ -46,6 +46,12 @@ You can install this on a ComputerCraft computer using either:
* `wget https://raw.githubusercontent.com/MikaylaFischler/cc-mek-scada/main/ccmsi.lua`
* `pastebin get sqUN6VUb ccmsi.lua`
+## Contributing
+
+Please reach out to me via Discord or email (or GitHub in some way) if you are thinking of making any contributions at this time. I started this project as a challenge for myself and have been enjoying having something I can work on in my own way.
+
+Once this is out of beta I will be more open to contributions, but for now I am hoping to keep them to a minimum as the remaining challenges are ones I am looking forward to solving.
+
## [SCADA](https://en.wikipedia.org/wiki/SCADA)
> Supervisory control and data acquisition (SCADA) is a control system architecture comprising computers, networked data communications and graphical user interfaces for high-level supervision of machines and processes. It also covers sensors and other devices, such as programmable logic controllers, which interface with process plant or machinery.
diff --git a/coordinator/coordinator.lua b/coordinator/coordinator.lua
index a8d6e5e..e72cccf 100644
--- a/coordinator/coordinator.lua
+++ b/coordinator/coordinator.lua
@@ -89,7 +89,7 @@ function coordinator.load_config()
if type(config.AuthKey) == "string" then
local len = string.len(config.AuthKey)
- cfv.assert_eq(len == 0 or len >= 8, true)
+ cfv.assert(len == 0 or len >= 8)
end
cfv.assert_type_int(config.LogMode)
@@ -192,7 +192,7 @@ end
---@return function? update, function? done
local function log_dmesg(message, dmesg_tag, working)
local colors = {
- GRAPHICS = colors.green,
+ RENDER = colors.green,
SYSTEM = colors.cyan,
BOOT = colors.blue,
COMMS = colors.purple,
@@ -206,7 +206,7 @@ local function log_dmesg(message, dmesg_tag, working)
end
end
-function coordinator.log_graphics(message) log_dmesg(message, "GRAPHICS") end
+function coordinator.log_render(message) log_dmesg(message, "RENDER") end
function coordinator.log_sys(message) log_dmesg(message, "SYSTEM") end
function coordinator.log_boot(message) log_dmesg(message, "BOOT") end
function coordinator.log_comms(message) log_dmesg(message, "COMMS") end
@@ -279,11 +279,12 @@ function coordinator.comms(version, nic, sv_watchdog)
-- send an API establish request response
---@param packet scada_packet
---@param ack ESTABLISH_ACK
- local function _send_api_establish_ack(packet, ack)
+ ---@param data any?
+ local function _send_api_establish_ack(packet, ack, data)
local s_pkt = comms.scada_packet()
local m_pkt = comms.mgmt_packet()
- m_pkt.make(MGMT_TYPE.ESTABLISH, { ack })
+ m_pkt.make(MGMT_TYPE.ESTABLISH, { ack, data })
s_pkt.make(packet.src_addr(), packet.seq_num() + 1, PROTOCOL.SCADA_MGMT, m_pkt.raw_sendable())
nic.transmit(config.PKT_Channel, config.CRD_Channel, s_pkt)
@@ -470,10 +471,11 @@ function coordinator.comms(version, nic, sv_watchdog)
elseif packet.type == MGMT_TYPE.ESTABLISH then
-- establish a new session
-- validate packet and continue
- if packet.length == 3 and type(packet.data[1]) == "string" and type(packet.data[2]) == "string" then
- local comms_v = packet.data[1]
- local firmware_v = packet.data[2]
+ if packet.length == 4 then
+ local comms_v = util.strval(packet.data[1])
+ local firmware_v = util.strval(packet.data[2])
local dev_type = packet.data[3]
+ local api_v = util.strval(packet.data[4])
if comms_v ~= comms.version then
if self.last_api_est_acks[src_addr] ~= ESTABLISH_ACK.BAD_VERSION then
@@ -481,12 +483,19 @@ function coordinator.comms(version, nic, sv_watchdog)
end
_send_api_establish_ack(packet.scada_frame, ESTABLISH_ACK.BAD_VERSION)
+ elseif api_v ~= comms.api_version then
+ if self.last_api_est_acks[src_addr] ~= ESTABLISH_ACK.BAD_API_VERSION then
+ log.info(util.c("dropping API establish packet with incorrect api version v", api_v, " (expected v", comms.api_version, ")"))
+ end
+
+ _send_api_establish_ack(packet.scada_frame, ESTABLISH_ACK.BAD_API_VERSION)
elseif dev_type == DEVICE_TYPE.PKT then
-- pocket linking request
local id = apisessions.establish_session(src_addr, firmware_v)
coordinator.log_comms(util.c("API_ESTABLISH: pocket (", firmware_v, ") [@", src_addr, "] connected with session ID ", id))
- _send_api_establish_ack(packet.scada_frame, ESTABLISH_ACK.ALLOW)
+ local conf = iocontrol.get_db().facility.conf
+ _send_api_establish_ack(packet.scada_frame, ESTABLISH_ACK.ALLOW, { conf.num_units, conf.cooling })
else
log.debug(util.c("API_ESTABLISH: illegal establish packet for device ", dev_type, " on pocket channel"))
_send_api_establish_ack(packet.scada_frame, ESTABLISH_ACK.DENY)
diff --git a/coordinator/iocontrol.lua b/coordinator/iocontrol.lua
index 0cb50e4..1de337f 100644
--- a/coordinator/iocontrol.lua
+++ b/coordinator/iocontrol.lua
@@ -67,6 +67,7 @@ function iocontrol.init(conf, comms, temp_scale)
-- facility data structure
---@class ioctl_facility
io.facility = {
+ conf = conf,
num_units = conf.num_units,
tank_mode = conf.cooling.fac_tank_mode,
tank_defs = conf.cooling.fac_tank_defs,
@@ -279,18 +280,18 @@ function iocontrol.init(conf, comms, temp_scale)
---@type alarms
alarms = {
- ALARM_STATE.INACTIVE, -- containment breach
- ALARM_STATE.INACTIVE, -- containment radiation
- ALARM_STATE.INACTIVE, -- reactor lost
- ALARM_STATE.INACTIVE, -- damage critical
- ALARM_STATE.INACTIVE, -- reactor taking damage
- ALARM_STATE.INACTIVE, -- reactor over temperature
- ALARM_STATE.INACTIVE, -- reactor high temperature
- ALARM_STATE.INACTIVE, -- waste leak
- ALARM_STATE.INACTIVE, -- waste level high
- ALARM_STATE.INACTIVE, -- RPS transient
- ALARM_STATE.INACTIVE, -- RCS transient
- ALARM_STATE.INACTIVE -- turbine trip
+ ALARM_STATE.INACTIVE, -- containment breach
+ ALARM_STATE.INACTIVE, -- containment radiation
+ ALARM_STATE.INACTIVE, -- reactor lost
+ ALARM_STATE.INACTIVE, -- damage critical
+ ALARM_STATE.INACTIVE, -- reactor taking damage
+ ALARM_STATE.INACTIVE, -- reactor over temperature
+ ALARM_STATE.INACTIVE, -- reactor high temperature
+ ALARM_STATE.INACTIVE, -- waste leak
+ ALARM_STATE.INACTIVE, -- waste level high
+ ALARM_STATE.INACTIVE, -- RPS transient
+ ALARM_STATE.INACTIVE, -- RCS transient
+ ALARM_STATE.INACTIVE -- turbine trip
},
annunciator = {}, ---@type annunciator
@@ -376,6 +377,13 @@ function iocontrol.fp_monitor_state(id, connected)
end
end
+-- report thread (routine) statuses
+---@param thread string thread name
+---@param ok boolean thread state
+function iocontrol.fp_rt_status(thread, ok)
+ io.fp.ps.publish(util.c("routine__", thread), ok)
+end
+
-- report PKT firmware version and PKT session connection state
---@param session_id integer PKT session
---@param fw string firmware version
diff --git a/coordinator/renderer.lua b/coordinator/renderer.lua
index d9e4b58..f2d2418 100644
--- a/coordinator/renderer.lua
+++ b/coordinator/renderer.lua
@@ -2,22 +2,26 @@
-- Graphics Rendering Control
--
-local log = require("scada-common.log")
+local log = require("scada-common.log")
+local util = require("scada-common.util")
-local iocontrol = require("coordinator.iocontrol")
+local coordinator = require("coordinator.coordinator")
+local iocontrol = require("coordinator.iocontrol")
-local style = require("coordinator.ui.style")
-local pgi = require("coordinator.ui.pgi")
+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")
+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")
-local core = require("graphics.core")
-local flasher = require("graphics.flasher")
+local core = require("graphics.core")
+local flasher = require("graphics.flasher")
-local DisplayBox = require("graphics.elements.displaybox")
+local DisplayBox = require("graphics.elements.displaybox")
+
+local log_render = coordinator.log_render
---@class coord_renderer
local renderer = {}
@@ -195,18 +199,21 @@ function renderer.try_start_ui()
if engine.monitors.main ~= nil then
engine.ui.main_display = DisplayBox{window=engine.monitors.main,fg_bg=style.root}
main_view(engine.ui.main_display)
+ util.nop()
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)
+ util.nop()
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}
unit_view(engine.ui.unit_displays[idx], idx)
+ util.nop()
end
end)
@@ -247,6 +254,11 @@ function renderer.close_ui()
-- clear unit monitors
for _, monitor in ipairs(engine.monitors.unit_displays) do monitor.clear() end
+ if not engine.disable_flow_view then
+ -- clear flow monitor
+ engine.monitors.flow.clear()
+ end
+
-- re-draw dmesg
engine.dmesg_window.setVisible(true)
engine.dmesg_window.redraw()
@@ -383,12 +395,15 @@ function renderer.handle_resize(name)
engine.dmesg_window.setVisible(not engine.ui_ready)
if engine.ui_ready then
+ local draw_start = util.time_ms()
local ok = pcall(function ()
ui.main_display = DisplayBox{window=device,fg_bg=style.root}
main_view(ui.main_display)
end)
- if not ok then
+ if ok then
+ log_render("main view re-draw completed in " .. (util.time_ms() - draw_start) .. "ms")
+ else
if ui.main_display then
ui.main_display.delete()
ui.main_display = nil
@@ -416,14 +431,15 @@ function renderer.handle_resize(name)
iocontrol.fp_monitor_state("flow", true)
if engine.ui_ready then
- engine.dmesg_window.setVisible(false)
-
+ local draw_start = util.time_ms()
local ok = pcall(function ()
ui.flow_display = DisplayBox{window=device,fg_bg=style.root}
flow_view(ui.flow_display)
end)
- if not ok then
+ if ok then
+ log_render("flow view re-draw completed in " .. (util.time_ms() - draw_start) .. "ms")
+ else
if ui.flow_display then
ui.flow_display.delete()
ui.flow_display = nil
@@ -453,14 +469,15 @@ function renderer.handle_resize(name)
iocontrol.fp_monitor_state(idx, true)
if engine.ui_ready then
- engine.dmesg_window.setVisible(false)
-
+ local draw_start = util.time_ms()
local ok = pcall(function ()
ui.unit_displays[idx] = DisplayBox{window=device,fg_bg=style.root}
unit_view(ui.unit_displays[idx], idx)
end)
- if not ok then
+ if ok then
+ log_render("unit " .. idx .. " view re-draw completed in " .. (util.time_ms() - draw_start) .. "ms")
+ else
if ui.unit_displays[idx] then
ui.unit_displays[idx].delete()
ui.unit_displays[idx] = nil
diff --git a/coordinator/session/pocket.lua b/coordinator/session/pocket.lua
index a19f7c3..7101297 100644
--- a/coordinator/session/pocket.lua
+++ b/coordinator/session/pocket.lua
@@ -8,7 +8,7 @@ local iocontrol = require("coordinator.iocontrol")
local pocket = {}
local PROTOCOL = comms.PROTOCOL
--- local CRDN_TYPE = comms.CRDN_TYPE
+local CRDN_TYPE = comms.CRDN_TYPE
local MGMT_TYPE = comms.MGMT_TYPE
-- retry time constants in ms
@@ -73,18 +73,18 @@ function pocket.new_session(id, s_addr, in_queue, out_queue, timeout)
end
-- send a CRDN packet
- -----@param msg_type CRDN_TYPE
- -----@param msg table
- -- local function _send(msg_type, msg)
- -- local s_pkt = comms.scada_packet()
- -- local c_pkt = comms.crdn_packet()
+ ---@param msg_type CRDN_TYPE
+ ---@param msg table
+ local function _send(msg_type, msg)
+ local s_pkt = comms.scada_packet()
+ local c_pkt = comms.crdn_packet()
- -- c_pkt.make(msg_type, msg)
- -- s_pkt.make(self.seq_num, PROTOCOL.SCADA_CRDN, c_pkt.raw_sendable())
+ c_pkt.make(msg_type, msg)
+ s_pkt.make(s_addr, self.seq_num, PROTOCOL.SCADA_CRDN, c_pkt.raw_sendable())
- -- out_queue.push_packet(s_pkt)
- -- self.seq_num = self.seq_num + 1
- -- end
+ out_queue.push_packet(s_pkt)
+ self.seq_num = self.seq_num + 1
+ end
-- send a SCADA management packet
---@param msg_type MGMT_TYPE
@@ -120,8 +120,39 @@ function pocket.new_session(id, s_addr, in_queue, out_queue, timeout)
if pkt.scada_frame.protocol() == PROTOCOL.SCADA_CRDN then
---@cast pkt crdn_frame
+ local db = iocontrol.get_db()
+
-- handle packet by type
- if pkt.type == nil then
+ if pkt.type == CRDN_TYPE.API_GET_FAC then
+ local fac = db.facility
+
+ local data = {
+ fac.all_sys_ok,
+ fac.rtu_count,
+ fac.radiation,
+ { fac.auto_ready, fac.auto_active, fac.auto_ramping, fac.auto_saturated },
+ { fac.auto_current_waste_product, fac.auto_pu_fallback_active },
+ util.table_len(fac.tank_data_tbl),
+ fac.induction_data_tbl[1] ~= nil,
+ fac.sps_data_tbl[1] ~= nil,
+ }
+
+ _send(CRDN_TYPE.API_GET_FAC, data)
+ elseif pkt.type == CRDN_TYPE.API_GET_UNITS then
+ local data = {}
+
+ for i = 1, #db.units do
+ local u = db.units[i] ---@type ioctl_unit
+ table.insert(data, {
+ u.unit_id,
+ u.num_boilers,
+ u.num_turbines,
+ u.num_snas,
+ u.has_tank
+ })
+ end
+
+ _send(CRDN_TYPE.API_GET_UNITS, data)
else
log.debug(log_header .. "handler received unsupported CRDN packet type " .. pkt.type)
end
diff --git a/coordinator/startup.lua b/coordinator/startup.lua
index 152d37b..0d89a87 100644
--- a/coordinator/startup.lua
+++ b/coordinator/startup.lua
@@ -7,32 +7,29 @@ require("/initenv").init_env()
local comms = require("scada-common.comms")
local crash = require("scada-common.crash")
local log = require("scada-common.log")
+local mqueue = require("scada-common.mqueue")
local network = require("scada-common.network")
local ppm = require("scada-common.ppm")
-local tcd = require("scada-common.tcd")
local util = require("scada-common.util")
-local core = require("graphics.core")
-
local configure = require("coordinator.configure")
local coordinator = require("coordinator.coordinator")
local iocontrol = require("coordinator.iocontrol")
local renderer = require("coordinator.renderer")
local sounder = require("coordinator.sounder")
+local threads = require("coordinator.threads")
-local apisessions = require("coordinator.session.apisessions")
-
-local COORDINATOR_VERSION = "v1.3.5"
+local COORDINATOR_VERSION = "v1.4.2"
local CHUNK_LOAD_DELAY_S = 30.0
-local println = util.println
+local println = util.println
local println_ts = util.println_ts
-local log_graphics = coordinator.log_graphics
-local log_sys = coordinator.log_sys
-local log_boot = coordinator.log_boot
-local log_comms = coordinator.log_comms
+local log_render = coordinator.log_render
+local log_sys = coordinator.log_sys
+local log_boot = coordinator.log_boot
+local log_comms = coordinator.log_comms
local log_crypto = coordinator.log_crypto
----------------------------------------
@@ -129,16 +126,58 @@ local function main()
-- lets get started!
log.info("monitors ready, dmesg output incoming...")
- log_graphics("displays connected and reset")
+ log_render("displays connected and reset")
log_sys("system start on " .. os.date("%c"))
log_boot("starting " .. COORDINATOR_VERSION)
+ ----------------------------------------
+ -- memory allocation
+ ----------------------------------------
+
+ -- shared memory across threads
+ ---@class crd_shared_memory
+ local __shared_memory = {
+ -- time and date format for display
+ date_format = util.trinary(config.Time24Hour, "%X \x04 %A, %B %d %Y", "%r \x04 %A, %B %d %Y"),
+
+ -- coordinator system state flags
+ ---@class crd_state
+ crd_state = {
+ fp_ok = false,
+ ui_ok = true, -- default true, used to abort on fail
+ link_fail = false,
+ shutdown = false
+ },
+
+ -- core coordinator devices
+ crd_dev = {
+ speaker = ppm.get_device("speaker"),
+ modem = ppm.get_wireless_modem()
+ },
+
+ -- system objects
+ crd_sys = {
+ nic = nil, ---@type nic
+ coord_comms = nil, ---@type coord_comms
+ conn_watchdog = nil ---@type watchdog
+ },
+
+ -- message queues
+ q = {
+ mq_render = mqueue.new()
+ }
+ }
+
+ local smem_dev = __shared_memory.crd_dev
+ local smem_sys = __shared_memory.crd_sys
+
+ local crd_state = __shared_memory.crd_state
+
----------------------------------------
-- setup alarm sounder subsystem
----------------------------------------
- local speaker = ppm.get_device("speaker")
- if speaker == nil then
+ if smem_dev.speaker == nil then
log_boot("annunciator alarm speaker not found")
println("startup> speaker not found")
log.fatal("no annunciator alarm speaker found")
@@ -146,7 +185,7 @@ local function main()
else
local sounder_start = util.time_ms()
log_boot("annunciator alarm speaker connected")
- sounder.init(speaker, config.SpeakerVolume)
+ sounder.init(smem_dev.speaker, config.SpeakerVolume)
log_boot("tone generation took " .. (util.time_ms() - sounder_start) .. "ms")
log_sys("annunciator alarm configured")
iocontrol.fp_has_speaker(true)
@@ -163,8 +202,7 @@ local function main()
end
-- get the communications modem
- local modem = ppm.get_wireless_modem()
- if modem == nil then
+ if smem_dev.modem == nil then
log_comms("wireless modem not found")
println("startup> wireless modem not found")
log.fatal("no wireless modem on startup")
@@ -175,243 +213,54 @@ local function main()
end
-- create connection watchdog
- local conn_watchdog = util.new_watchdog(config.SVR_Timeout)
- conn_watchdog.cancel()
+ smem_sys.conn_watchdog = util.new_watchdog(config.SVR_Timeout)
+ smem_sys.conn_watchdog.cancel()
log.debug("startup> conn watchdog created")
-- create network interface then setup comms
- local nic = network.nic(modem)
- local coord_comms = coordinator.comms(COORDINATOR_VERSION, nic, conn_watchdog)
+ smem_sys.nic = network.nic(smem_dev.modem)
+ smem_sys.coord_comms = coordinator.comms(COORDINATOR_VERSION, smem_sys.nic, smem_sys.conn_watchdog)
log.debug("startup> comms init")
log_comms("comms initialized")
- -- base loop clock (2Hz, 10 ticks)
- local MAIN_CLOCK = 0.5
- local loop_clock = util.new_clock(MAIN_CLOCK)
-
----------------------------------------
- -- start front panel & UI start function
+ -- start front panel
----------------------------------------
- log_graphics("starting front panel UI...")
+ log_render("starting front panel UI...")
- local fp_ok, fp_message = renderer.try_start_fp()
- if not fp_ok then
- log_graphics(util.c("front panel UI error: ", fp_message))
+ local fp_message
+ crd_state.fp_ok, fp_message = renderer.try_start_fp()
+ if not crd_state.fp_ok then
+ log_render(util.c("front panel UI error: ", fp_message))
println_ts("front panel UI creation failed")
log.fatal(util.c("front panel GUI render failed with error ", fp_message))
return
- else log_graphics("front panel ready") end
-
- -- start up the main UI
- ---@return boolean ui_ok started ok
- local function start_main_ui()
- log_graphics("starting main UI...")
-
- local draw_start = util.time_ms()
-
- local ui_ok, ui_message = renderer.try_start_ui()
- if not ui_ok then
- log_graphics(util.c("main UI error: ", ui_message))
- log.fatal(util.c("main GUI render failed with error ", ui_message))
- else
- log_graphics("main UI draw took " .. (util.time_ms() - draw_start) .. "ms")
- end
-
- return ui_ok
- end
+ else log_render("front panel ready") end
----------------------------------------
- -- main event loop
+ -- start system
----------------------------------------
- local link_failed = false
- local ui_ok = true
- local date_format = util.trinary(config.Time24Hour, "%X \x04 %A, %B %d %Y", "%r \x04 %A, %B %d %Y")
+ -- init threads
+ local main_thread = threads.thread__main(__shared_memory)
+ local render_thread = threads.thread__render(__shared_memory)
- -- start clock
- loop_clock.start()
+ log.info("startup> completed")
- log_sys("system started successfully")
-
- -- main event loop
- while true do
- local event, param1, param2, param3, param4, param5 = util.pull_event()
-
- -- handle event
- if event == "peripheral_detach" then
- local type, device = ppm.handle_unmount(param1)
-
- if type ~= nil and device ~= nil then
- if type == "modem" then
- -- we only really care if this is our wireless modem
- -- if it is another modem, handle other peripheral losses separately
- if nic.is_modem(device) then
- nic.disconnect()
- log_sys("comms modem disconnected")
-
- local other_modem = ppm.get_wireless_modem()
- if other_modem then
- log_sys("found another wireless modem, using it for comms")
- nic.connect(other_modem)
- else
- -- close out main UI
- renderer.close_ui()
-
- -- alert user to status
- log_sys("awaiting comms modem reconnect...")
-
- iocontrol.fp_has_modem(false)
- end
- else
- log_sys("non-comms modem disconnected")
- end
- elseif type == "monitor" then
- if renderer.handle_disconnect(device) then
- log_sys("lost a configured monitor")
- else
- log_sys("lost an unused monitor")
- end
- elseif type == "speaker" then
- log_sys("lost alarm sounder speaker")
- iocontrol.fp_has_speaker(false)
- end
- end
- elseif event == "peripheral" then
- local type, device = ppm.mount(param1)
-
- if type ~= nil and device ~= nil then
- if type == "modem" then
- if device.isWireless() and not nic.is_connected() then
- -- reconnected modem
- log_sys("comms modem reconnected")
- nic.connect(device)
- iocontrol.fp_has_modem(true)
- elseif device.isWireless() then
- log.info("unused wireless modem reconnected")
- else
- log_sys("wired modem reconnected")
- end
- elseif type == "monitor" then
- if renderer.handle_reconnect(param1, device) then
- log_sys(util.c("configured monitor ", param1, " reconnected"))
- else
- log_sys(util.c("unused monitor ", param1, " connected"))
- end
- elseif type == "speaker" then
- log_sys("alarm sounder speaker reconnected")
- sounder.reconnect(device)
- iocontrol.fp_has_speaker(true)
- end
- end
- elseif event == "monitor_resize" then
- local is_used, is_ok = renderer.handle_resize(param1)
- if is_used then
- log_sys(util.c("configured monitor ", param1, " resized, ", util.trinary(is_ok, "display still fits", "display no longer fits")))
- end
- elseif event == "timer" then
- if loop_clock.is_clock(param1) then
- -- main loop tick
-
- -- toggle heartbeat
- iocontrol.heartbeat()
-
- -- maintain connection
- if nic.is_connected() then
- local ok, start_ui = coord_comms.try_connect()
- if not ok then
- link_failed = true
- log_sys("supervisor connection failed, shutting down...")
- log.fatal("failed to connect to supervisor")
- break
- elseif start_ui then
- log_sys("supervisor connected, proceeding to main UI start")
- ui_ok = start_main_ui()
- if not ui_ok then break end
- end
- end
-
- -- iterate sessions
- apisessions.iterate_all()
-
- -- free any closed sessions
- apisessions.free_all_closed()
-
- -- update date and time string for main display
- if coord_comms.is_linked() then
- iocontrol.get_db().facility.ps.publish("date_time", os.date(date_format))
- end
-
- loop_clock.start()
- elseif conn_watchdog.is_timer(param1) then
- -- supervisor watchdog timeout
- log_comms("supervisor server timeout")
-
- -- close connection, main UI, and stop sounder
- coord_comms.close()
- renderer.close_ui()
- sounder.stop()
- else
- -- a non-clock/main watchdog timer event
-
- -- check API watchdogs
- apisessions.check_all_watchdogs(param1)
-
- -- notify timer callback dispatcher
- tcd.handle(param1)
- end
- elseif event == "modem_message" then
- -- got a packet
- local packet = coord_comms.parse_packet(param1, param2, param3, param4, param5)
-
- -- handle then check if it was a disconnect
- if coord_comms.handle_packet(packet) then
- log_comms("supervisor closed connection")
-
- -- close connection, main UI, and stop sounder
- coord_comms.close()
- renderer.close_ui()
- sounder.stop()
- end
- elseif event == "monitor_touch" or event == "mouse_click" or event == "mouse_up" or
- event == "mouse_drag" or event == "mouse_scroll" or event == "double_click" then
- -- handle a mouse event
- renderer.handle_mouse(core.events.new_mouse_event(event, param1, param2, param3))
- elseif event == "speaker_audio_empty" then
- -- handle speaker buffer emptied
- sounder.continue()
- end
-
- -- check for termination request
- if event == "terminate" or ppm.should_terminate() then
- -- handle supervisor connection
- coord_comms.try_connect(true)
-
- if coord_comms.is_linked() then
- log_comms("terminate requested, closing supervisor connection...")
- else link_failed = true end
-
- coord_comms.close()
- log_comms("supervisor connection closed")
-
- -- handle API sessions
- log_comms("closing api sessions...")
- apisessions.close_all()
- log_comms("api sessions closed")
- break
- end
- end
+ -- run threads
+ parallel.waitForAll(main_thread.p_exec, render_thread.p_exec)
renderer.close_ui()
renderer.close_fp()
sounder.stop()
log_sys("system shutdown")
- if link_failed then println_ts("failed to connect to supervisor") end
- if not ui_ok then println_ts("main UI creation failed") end
+ if crd_state.link_fail then println_ts("failed to connect to supervisor") end
+ if not crd_state.ui_ok then println_ts("main UI creation failed") end
-- close on error exit (such as UI error)
- if coord_comms.is_linked() then coord_comms.close() end
+ if smem_sys.coord_comms.is_linked() then smem_sys.coord_comms.close() end
println_ts("exited")
log.info("exited")
diff --git a/coordinator/threads.lua b/coordinator/threads.lua
new file mode 100644
index 0000000..e7c2d8c
--- /dev/null
+++ b/coordinator/threads.lua
@@ -0,0 +1,363 @@
+local log = require("scada-common.log")
+local mqueue = require("scada-common.mqueue")
+local ppm = require("scada-common.ppm")
+local tcd = require("scada-common.tcd")
+local util = require("scada-common.util")
+
+local coordinator = require("coordinator.coordinator")
+local iocontrol = require("coordinator.iocontrol")
+local renderer = require("coordinator.renderer")
+local sounder = require("coordinator.sounder")
+
+local apisessions = require("coordinator.session.apisessions")
+
+local core = require("graphics.core")
+
+local log_render = coordinator.log_render
+local log_sys = coordinator.log_sys
+local log_comms = coordinator.log_comms
+
+local threads = {}
+
+local MAIN_CLOCK = 0.5 -- (2Hz, 10 ticks)
+local RENDER_SLEEP = 100 -- (100ms, 2 ticks)
+
+local MQ__RENDER_CMD = {
+ START_MAIN_UI = 1
+}
+
+local MQ__RENDER_DATA = {
+ MON_CONNECT = 1,
+ MON_DISCONNECT = 2,
+ MON_RESIZE = 3
+}
+
+-- main thread
+---@nodiscard
+---@param smem crd_shared_memory
+function threads.thread__main(smem)
+ ---@class parallel_thread
+ local public = {}
+
+ -- execute thread
+ function public.exec()
+ iocontrol.fp_rt_status("main", true)
+ log.debug("main thread start")
+
+ local loop_clock = util.new_clock(MAIN_CLOCK)
+
+ -- start clock
+ loop_clock.start()
+
+ log_sys("system started successfully")
+
+ -- load in from shared memory
+ local crd_state = smem.crd_state
+ local nic = smem.crd_sys.nic
+ local coord_comms = smem.crd_sys.coord_comms
+ local conn_watchdog = smem.crd_sys.conn_watchdog
+
+ -- event loop
+ while true do
+ local event, param1, param2, param3, param4, param5 = util.pull_event()
+
+ -- handle event
+ if event == "peripheral_detach" then
+ local type, device = ppm.handle_unmount(param1)
+
+ if type ~= nil and device ~= nil then
+ if type == "modem" then
+ -- we only really care if this is our wireless modem
+ -- if it is another modem, handle other peripheral losses separately
+ if nic.is_modem(device) then
+ nic.disconnect()
+ log_sys("comms modem disconnected")
+
+ local other_modem = ppm.get_wireless_modem()
+ if other_modem then
+ log_sys("found another wireless modem, using it for comms")
+ nic.connect(other_modem)
+ else
+ -- close out main UI
+ renderer.close_ui()
+
+ -- alert user to status
+ log_sys("awaiting comms modem reconnect...")
+
+ iocontrol.fp_has_modem(false)
+ end
+ else
+ log_sys("non-comms modem disconnected")
+ end
+ elseif type == "monitor" then
+ smem.q.mq_render.push_data(MQ__RENDER_DATA.MON_DISCONNECT, device)
+ elseif type == "speaker" then
+ log_sys("lost alarm sounder speaker")
+ iocontrol.fp_has_speaker(false)
+ end
+ end
+ elseif event == "peripheral" then
+ local type, device = ppm.mount(param1)
+
+ if type ~= nil and device ~= nil then
+ if type == "modem" then
+ if device.isWireless() and not nic.is_connected() then
+ -- reconnected modem
+ log_sys("comms modem reconnected")
+ nic.connect(device)
+ iocontrol.fp_has_modem(true)
+ elseif device.isWireless() then
+ log.info("unused wireless modem reconnected")
+ else
+ log_sys("wired modem reconnected")
+ end
+ elseif type == "monitor" then
+ smem.q.mq_render.push_data(MQ__RENDER_DATA.MON_CONNECT, { name = param1, device = device })
+ elseif type == "speaker" then
+ log_sys("alarm sounder speaker reconnected")
+ sounder.reconnect(device)
+ iocontrol.fp_has_speaker(true)
+ end
+ end
+ elseif event == "monitor_resize" then
+ smem.q.mq_render.push_data(MQ__RENDER_DATA.MON_RESIZE, param1)
+ elseif event == "timer" then
+ if loop_clock.is_clock(param1) then
+ -- main loop tick
+
+ -- toggle heartbeat
+ iocontrol.heartbeat()
+
+ -- maintain connection
+ if nic.is_connected() then
+ local ok, start_ui = coord_comms.try_connect()
+ if not ok then
+ crd_state.link_fail = true
+ crd_state.shutdown = true
+ log_sys("supervisor connection failed, shutting down...")
+ log.fatal("failed to connect to supervisor")
+ break
+ elseif start_ui then
+ log_sys("supervisor connected, dispatching main UI start")
+ smem.q.mq_render.push_command(MQ__RENDER_CMD.START_MAIN_UI)
+ end
+ end
+
+ -- iterate sessions and free any closed ones
+ apisessions.iterate_all()
+ apisessions.free_all_closed()
+
+ if renderer.ui_ready() then
+ -- update clock used on main and flow monitors
+ iocontrol.get_db().facility.ps.publish("date_time", os.date(smem.date_format))
+ end
+
+ loop_clock.start()
+ elseif conn_watchdog.is_timer(param1) then
+ -- supervisor watchdog timeout
+ log_comms("supervisor server timeout")
+
+ -- close connection, main UI, and stop sounder
+ coord_comms.close()
+ renderer.close_ui()
+ sounder.stop()
+ else
+ -- a non-clock/main watchdog timer event
+
+ -- check API watchdogs
+ apisessions.check_all_watchdogs(param1)
+
+ -- notify timer callback dispatcher
+ tcd.handle(param1)
+ end
+ elseif event == "modem_message" then
+ -- got a packet
+ local packet = coord_comms.parse_packet(param1, param2, param3, param4, param5)
+
+ -- handle then check if it was a disconnect
+ if coord_comms.handle_packet(packet) then
+ log_comms("supervisor closed connection")
+
+ -- close connection, main UI, and stop sounder
+ coord_comms.close()
+ renderer.close_ui()
+ sounder.stop()
+ end
+ elseif event == "monitor_touch" or event == "mouse_click" or event == "mouse_up" or
+ event == "mouse_drag" or event == "mouse_scroll" or event == "double_click" then
+ -- handle a mouse event
+ renderer.handle_mouse(core.events.new_mouse_event(event, param1, param2, param3))
+ elseif event == "speaker_audio_empty" then
+ -- handle speaker buffer emptied
+ sounder.continue()
+ end
+
+ -- check for termination request or UI crash
+ if event == "terminate" or ppm.should_terminate() then
+ crd_state.shutdown = true
+ log.info("terminate requested, main thread exiting")
+ elseif not crd_state.ui_ok then
+ crd_state.shutdown = true
+ log.info("terminating due to fatal UI error")
+ end
+
+ if crd_state.shutdown then
+ -- handle closing supervisor connection
+ coord_comms.try_connect(true)
+
+ if coord_comms.is_linked() then
+ log_comms("closing supervisor connection...")
+ else crd_state.link_fail = true end
+
+ coord_comms.close()
+ log_comms("supervisor connection closed")
+
+ -- handle API sessions
+ log_comms("closing api sessions...")
+ apisessions.close_all()
+ log_comms("api sessions closed")
+ break
+ end
+ end
+ end
+
+ -- execute the thread in a protected mode, retrying it on return if not shutting down
+ function public.p_exec()
+ local crd_state = smem.crd_state
+
+ while not crd_state.shutdown do
+ local status, result = pcall(public.exec)
+ if status == false then
+ log.fatal(util.strval(result))
+ end
+
+ iocontrol.fp_rt_status("main", false)
+
+ -- if status is true, then we are probably exiting, so this won't matter
+ -- this thread cannot be slept because it will miss events (namely "terminate")
+ if not crd_state.shutdown then
+ log.info("main thread restarting now...")
+ end
+ end
+ end
+
+ return public
+end
+
+-- coordinator renderer thread, tasked with long duration re-draws
+---@nodiscard
+---@param smem crd_shared_memory
+function threads.thread__render(smem)
+ ---@class parallel_thread
+ local public = {}
+
+ -- execute thread
+ function public.exec()
+ iocontrol.fp_rt_status("render", true)
+ log.debug("render thread start")
+
+ -- load in from shared memory
+ local crd_state = smem.crd_state
+ local render_queue = smem.q.mq_render
+
+ local last_update = util.time()
+
+ -- thread loop
+ while true do
+ -- check for messages in the message queue
+ while render_queue.ready() and not crd_state.shutdown do
+ local msg = render_queue.pop()
+
+ if msg ~= nil then
+ if msg.qtype == mqueue.TYPE.COMMAND then
+ -- received a command
+ if msg.message == MQ__RENDER_CMD.START_MAIN_UI then
+ -- stop the UI if it was already started
+ -- this may occur on a quick supervisor disconnect -> connect
+ if renderer.ui_ready() then
+ log_render("closing main UI before executing new request to start")
+ renderer.close_ui()
+ end
+
+ -- start up the main UI
+ log_render("starting main UI...")
+
+ local draw_start = util.time_ms()
+
+ local ui_message
+ crd_state.ui_ok, ui_message = renderer.try_start_ui()
+ if not crd_state.ui_ok then
+ log_render(util.c("main UI error: ", ui_message))
+ log.fatal(util.c("main GUI render failed with error ", ui_message))
+ else
+ log_render("main UI draw took " .. (util.time_ms() - draw_start) .. "ms")
+ end
+ end
+ elseif msg.qtype == mqueue.TYPE.DATA then
+ -- received data
+ local cmd = msg.message ---@type queue_data
+
+ if cmd.key == MQ__RENDER_DATA.MON_CONNECT then
+ -- monitor connected
+ if renderer.handle_reconnect(cmd.val.name, cmd.val.device) then
+ log_sys(util.c("configured monitor ", cmd.val.name, " reconnected"))
+ else
+ log_sys(util.c("unused monitor ", cmd.val.name, " connected"))
+ end
+ elseif cmd.key == MQ__RENDER_DATA.MON_DISCONNECT then
+ -- monitor disconnected
+ if renderer.handle_disconnect(cmd.val) then
+ log_sys("lost a configured monitor")
+ else
+ log_sys("lost an unused monitor")
+ end
+ elseif cmd.key == MQ__RENDER_DATA.MON_RESIZE then
+ -- monitor resized
+ local is_used, is_ok = renderer.handle_resize(cmd.val)
+ if is_used then
+ log_sys(util.c("configured monitor ", cmd.val, " resized, ", util.trinary(is_ok, "display fits", "display does not fit")))
+ end
+ end
+ elseif msg.qtype == mqueue.TYPE.PACKET then
+ -- received a packet
+ end
+ end
+
+ -- quick yield
+ util.nop()
+ end
+
+ -- check for termination request
+ if crd_state.shutdown then
+ log.info("render thread exiting")
+ break
+ end
+
+ -- delay before next check
+ last_update = util.adaptive_delay(RENDER_SLEEP, last_update)
+ end
+ end
+
+ -- execute the thread in a protected mode, retrying it on return if not shutting down
+ function public.p_exec()
+ local crd_state = smem.crd_state
+
+ while not crd_state.shutdown do
+ local status, result = pcall(public.exec)
+ if status == false then
+ log.fatal(util.strval(result))
+ end
+
+ iocontrol.fp_rt_status("render", false)
+
+ if not crd_state.shutdown then
+ log.info("render thread restarting in 5 seconds...")
+ util.psleep(5)
+ end
+ end
+ end
+
+ return public
+end
+
+return threads
diff --git a/coordinator/ui/components/process_ctl.lua b/coordinator/ui/components/process_ctl.lua
index fb0c939..430409b 100644
--- a/coordinator/ui/components/process_ctl.lua
+++ b/coordinator/ui/components/process_ctl.lua
@@ -145,7 +145,7 @@ local function new_view(root, x, y)
local cur_charge = DataIndicator{parent=targets,x=9,y=9,label="",format="%19d",value=0,unit="MFE",commas=true,lu_colors=black,width=23,fg_bg=blk_brn}
c_target.register(facility.ps, "process_charge_target", c_target.set_value)
- cur_charge.register(facility.induction_ps_tbl[1], "energy", function (j) cur_charge.update(util.joules_to_fe(j) / 1000000) end)
+ cur_charge.register(facility.induction_ps_tbl[1], "avg_charge", function (fe) cur_charge.update(fe / 1000000) end)
local gen_tag = Div{parent=targets,x=1,y=11,width=8,height=4,fg_bg=blk_pur}
TextBox{parent=gen_tag,x=2,y=2,text="Gen. Target",width=7,height=2}
diff --git a/coordinator/ui/components/unit_detail.lua b/coordinator/ui/components/unit_detail.lua
index 2264b00..fe36a85 100644
--- a/coordinator/ui/components/unit_detail.lua
+++ b/coordinator/ui/components/unit_detail.lua
@@ -352,6 +352,8 @@ local function init(parent, id)
t3_trp.register(t_ps[3], "TurbineTrip", t3_trp.update)
end
+ util.nop()
+
----------------------
-- reactor controls --
----------------------
diff --git a/coordinator/ui/layout/flow_view.lua b/coordinator/ui/layout/flow_view.lua
index 98d16e9..06edf27 100644
--- a/coordinator/ui/layout/flow_view.lua
+++ b/coordinator/ui/layout/flow_view.lua
@@ -250,6 +250,7 @@ local function init(main)
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))
+ util.nop()
end
PipeNetwork{parent=main,x=139,y=15,pipes=po_pipes,bg=style.theme.bg}
@@ -335,6 +336,8 @@ local function init(main)
end
end
+ util.nop()
+
---------
-- SPS --
---------
diff --git a/coordinator/ui/layout/front_panel.lua b/coordinator/ui/layout/front_panel.lua
index 7124083..7f2738a 100644
--- a/coordinator/ui/layout/front_panel.lua
+++ b/coordinator/ui/layout/front_panel.lua
@@ -100,6 +100,14 @@ local function init(panel, num_units)
local speaker = LED{parent=system,label="SPEAKER",colors=led_grn}
speaker.register(ps, "has_speaker", speaker.update)
+ system.line_break()
+
+ local rt_main = LED{parent=system,label="RT MAIN",colors=led_grn}
+ local rt_render = LED{parent=system,label="RT RENDER",colors=led_grn}
+
+ rt_main.register(ps, "routine__main", rt_main.update)
+ rt_render.register(ps, "routine__render", rt_render.update)
+
---@diagnostic disable-next-line: undefined-field
local comp_id = util.sprintf("(%d)", os.getComputerID())
TextBox{parent=system,x=9,y=4,width=6,height=1,text=comp_id,fg_bg=style.fp.disabled_fg}
diff --git a/coordinator/ui/layout/main_view.lua b/coordinator/ui/layout/main_view.lua
index f31c200..5343139 100644
--- a/coordinator/ui/layout/main_view.lua
+++ b/coordinator/ui/layout/main_view.lua
@@ -2,6 +2,8 @@
-- Main SCADA Coordinator GUI
--
+local util = require("scada-common.util")
+
local iocontrol = require("coordinator.iocontrol")
local style = require("coordinator.ui.style")
@@ -53,6 +55,8 @@ local function init(main)
cnc_y_start = cnc_y_start + row_1_height + 1
+ util.nop()
+
if facility.num_units >= 3 then
-- base offset 3, spacing 1, max height of units 1 and 2
local row_2_offset = cnc_y_start
@@ -64,6 +68,8 @@ local function init(main)
uo_4 = unit_overview(main, 84, row_2_offset, units[4])
cnc_y_start = math.max(cnc_y_start, row_2_offset + uo_4.get_height() + 1)
end
+
+ util.nop()
end
-- command & control
@@ -79,6 +85,8 @@ local function init(main)
process_ctl(main, 2, cnc_bottom_align_start)
+ util.nop()
+
imatrix(main, 131, cnc_bottom_align_start, facility.induction_data_tbl[1], facility.induction_ps_tbl[1])
end
diff --git a/graphics/core.lua b/graphics/core.lua
index b931a3e..b4e1d94 100644
--- a/graphics/core.lua
+++ b/graphics/core.lua
@@ -7,7 +7,7 @@ local flasher = require("graphics.flasher")
local core = {}
-core.version = "2.2.2"
+core.version = "2.2.3"
core.flasher = flasher
core.events = events
diff --git a/graphics/element.lua b/graphics/element.lua
index 5b6d70d..c7cc056 100644
--- a/graphics/element.lua
+++ b/graphics/element.lua
@@ -49,9 +49,11 @@ local element = {}
---|indicator_light_args
---|power_indicator_args
---|rad_indicator_args
+---|signal_bar_args
---|state_indicator_args
---|tristate_indicator_light_args
---|vbar_args
+---|app_multipane_args
---|colormap_args
---|displaybox_args
---|div_args
diff --git a/graphics/elements/appmultipane.lua b/graphics/elements/appmultipane.lua
new file mode 100644
index 0000000..5973182
--- /dev/null
+++ b/graphics/elements/appmultipane.lua
@@ -0,0 +1,109 @@
+-- App Page Multi-Pane Display Graphics Element
+
+local util = require("scada-common.util")
+
+local core = require("graphics.core")
+local element = require("graphics.element")
+local events = require("graphics.events")
+
+local MOUSE_CLICK = core.events.MOUSE_CLICK
+
+---@class app_multipane_args
+---@field panes table panes to swap between
+---@field nav_colors cpair on/off colors (a/b respectively) for page navigator
+---@field scroll_nav boolean? true to allow scrolling to change the active pane
+---@field drag_nav boolean? true to allow mouse dragging to change the active pane (on mouse up)
+---@field callback function? function to call when pane is changed by mouse interaction
+---@field parent graphics_element
+---@field id? string element id
+---@field x? integer 1 if omitted
+---@field y? integer auto incremented if omitted
+---@field width? integer parent width if omitted
+---@field height? integer parent height if omitted
+---@field gframe? graphics_frame frame instead of x/y/width/height
+---@field fg_bg? cpair foreground/background colors
+---@field hidden? boolean true to hide on initial draw
+
+-- new app multipane element
+---@nodiscard
+---@param args app_multipane_args
+---@return graphics_element element, element_id id
+local function multipane(args)
+ element.assert(type(args.panes) == "table", "panes is a required field")
+
+ -- create new graphics element base object
+ local e = element.new(args)
+
+ e.value = 1
+
+ local nav_x_start = math.floor((e.frame.w / 2) - (#args.panes / 2)) + 1
+ local nav_x_end = math.floor((e.frame.w / 2) - (#args.panes / 2)) + #args.panes
+
+ -- show the selected pane
+ function e.redraw()
+ for i = 1, #args.panes do args.panes[i].hide() end
+ args.panes[e.value].show()
+
+ -- draw page indicator dots
+ for i = 1, #args.panes do
+ e.w_set_cur(nav_x_start + (i - 1), e.frame.h)
+ e.w_set_fgd(util.trinary(i == e.value, args.nav_colors.color_a, args.nav_colors.color_b))
+ e.w_write("\x07")
+ end
+ end
+
+ -- handle mouse interaction
+ ---@param event mouse_interaction mouse event
+ function e.handle_mouse(event)
+ local initial = e.value
+
+ if e.enabled then
+ if event.current.y == e.frame.h and event.current.x >= nav_x_start and event.current.x <= nav_x_end then
+ local id = event.current.x - nav_x_start + 1
+
+ if event.type == MOUSE_CLICK.TAP then
+ e.set_value(id)
+ elseif event.type == MOUSE_CLICK.UP then
+ e.set_value(id)
+ end
+ end
+ end
+
+ if args.scroll_nav then
+ if event.type == events.MOUSE_CLICK.SCROLL_DOWN then
+ e.set_value(e.value + 1)
+ elseif event.type == events.MOUSE_CLICK.SCROLL_UP then
+ e.set_value(e.value - 1)
+ end
+ end
+
+ if args.drag_nav then
+ local x1, x2 = event.initial.x, event.current.x
+ if event.type == events.MOUSE_CLICK.UP and e.in_frame_bounds(x1, event.initial.y) and e.in_frame_bounds(x1, event.current.y) then
+ if x2 > x1 then
+ e.set_value(e.value - 1)
+ elseif x2 < x1 then
+ e.set_value(e.value + 1)
+ end
+ end
+ end
+
+ if e.value ~= initial and type(args.callback) == "function" then args.callback(e.value) end
+ end
+
+ -- select which pane is shown
+ ---@param value integer pane to show
+ function e.set_value(value)
+ if (e.value ~= value) and (value > 0) and (value <= #args.panes) then
+ e.value = value
+ e.redraw()
+ end
+ end
+
+ -- initial draw
+ e.redraw()
+
+ return e.complete()
+end
+
+return multipane
diff --git a/graphics/elements/controls/switch_button.lua b/graphics/elements/controls/switch_button.lua
index ac44ca6..8abf8a7 100644
--- a/graphics/elements/controls/switch_button.lua
+++ b/graphics/elements/controls/switch_button.lua
@@ -65,7 +65,7 @@ local function switch_button(args)
end
end
- -- set the value
+ -- set the value (does not call the callback)
---@param val boolean new value
function e.set_value(val)
e.value = val
diff --git a/graphics/elements/indicators/signal.lua b/graphics/elements/indicators/signal.lua
new file mode 100644
index 0000000..7e9c1b8
--- /dev/null
+++ b/graphics/elements/indicators/signal.lua
@@ -0,0 +1,85 @@
+-- Signal Bars Graphics Element
+
+local util = require("scada-common.util")
+
+local element = require("graphics.element")
+
+---@class signal_bar_args
+---@field compact? boolean true to use a single character (works better against edges that extend out colors)
+---@field colors_low_med? cpair color a for low signal quality, color b for medium signal quality
+---@field disconnect_color? color color for the 'x' on disconnect
+---@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 (foreground is used for high signal quality)
+---@field hidden? boolean true to hide on initial draw
+
+-- new signal bar
+---@nodiscard
+---@param args signal_bar_args
+---@return graphics_element element, element_id id
+local function signal_bar(args)
+ args.height = 1
+ args.width = util.trinary(args.compact, 1, 2)
+
+ -- create new graphics element base object
+ local e = element.new(args)
+
+ e.value = 0
+
+ local blit_bkg = args.fg_bg.blit_bkg
+ local blit_0, blit_1, blit_2, blit_3 = args.fg_bg.blit_fgd, args.fg_bg.blit_fgd, args.fg_bg.blit_fgd, args.fg_bg.blit_fgd
+
+ if type(args.colors_low_med) == "table" then
+ blit_1 = args.colors_low_med.blit_a or blit_1
+ blit_2 = args.colors_low_med.blit_b or blit_2
+ end
+
+ if util.is_int(args.disconnect_color) then blit_0 = colors.toBlit(args.disconnect_color) end
+
+ -- on state change (0 = offline, 1 through 3 = low to high signal)
+ ---@param new_state integer signal state
+ function e.on_update(new_state)
+ e.value = new_state
+ e.redraw()
+ end
+
+ -- set signal state (0 = offline, 1 through 3 = low to high signal)
+ ---@param val integer signal state
+ function e.set_value(val) e.on_update(val) end
+
+ -- draw label and signal bar
+ function e.redraw()
+ e.w_set_cur(1, 1)
+
+ if args.compact then
+ if e.value == 1 then
+ e.w_blit("\x90", blit_1, blit_bkg)
+ elseif e.value == 2 then
+ e.w_blit("\x94", blit_2, blit_bkg)
+ elseif e.value == 3 then
+ e.w_blit("\x95", blit_3, blit_bkg)
+ else
+ e.w_blit("x", blit_0, blit_bkg)
+ end
+ else
+ if e.value == 1 then
+ e.w_blit("\x9f ", blit_bkg .. blit_bkg, blit_1 .. blit_bkg)
+ elseif e.value == 2 then
+ e.w_blit("\x9f\x94", blit_bkg .. blit_2, blit_2 .. blit_bkg)
+ elseif e.value == 3 then
+ e.w_blit("\x9f\x81", blit_bkg .. blit_bkg, blit_3 .. blit_3)
+ else
+ e.w_blit(" x", blit_0 .. blit_0, blit_bkg .. blit_bkg)
+ end
+ end
+ end
+
+ -- initial draw
+ e.redraw()
+
+ return e.complete()
+end
+
+return signal_bar
diff --git a/pocket/iocontrol.lua b/pocket/iocontrol.lua
index 30645ae..30f4879 100644
--- a/pocket/iocontrol.lua
+++ b/pocket/iocontrol.lua
@@ -2,18 +2,17 @@
-- I/O Control for Pocket Integration with Supervisor & Coordinator
--
-local psil = require("scada-common.psil")
-
+local log = require("scada-common.log")
+local psil = require("scada-common.psil")
local types = require("scada-common.types")
local ALARM = types.ALARM
-local iocontrol = {}
+---@todo nominal trip time is ping (0ms to 10ms usually)
+local WARN_TT = 40
+local HIGH_TT = 80
----@class pocket_ioctl
-local io = {
- ps = psil.create()
-}
+local iocontrol = {}
---@enum POCKET_LINK_STATE
local LINK_STATE = {
@@ -23,23 +22,175 @@ local LINK_STATE = {
LINKED = 3
}
----@enum NAV_PAGE
-local NAV_PAGE = {
- HOME = 1,
+iocontrol.LINK_STATE = LINK_STATE
+
+---@enum POCKET_APP_ID
+local APP_ID = {
+ ROOT = 1,
+ -- main app page
UNITS = 2,
- REACTORS = 3,
- BOILERS = 4,
- TURBINES = 5,
- DIAG = 6,
- D_ALARMS = 7
+ ABOUT = 3,
+ -- diag app page
+ ALARMS = 4,
+ -- other
+ DUMMY = 5,
+ NUM_APPS = 5
}
-iocontrol.LINK_STATE = LINK_STATE
-iocontrol.NAV_PAGE = NAV_PAGE
+iocontrol.APP_ID = APP_ID
+
+---@class pocket_ioctl
+local io = {
+ version = "unknown",
+ ps = psil.create()
+}
+
+---@class nav_tree_page
+---@field _p nav_tree_page|nil page's parent
+---@field _c table page's children
+---@field nav_to function function to navigate to this page
+---@field switcher function|nil function to switch between children
+---@field tasks table tasks to run while viewing this page
+
+-- allocate the page navigation system
+function iocontrol.alloc_nav()
+ local self = {
+ pane = nil, ---@type graphics_element
+ apps = {},
+ containers = {},
+ cur_app = APP_ID.ROOT
+ }
+
+ self.cur_page = self.root
+
+ ---@class pocket_nav
+ io.nav = {}
+
+ -- set the root pane element to switch between apps with
+ ---@param root_pane graphics_element
+ function io.nav.set_pane(root_pane)
+ self.pane = root_pane
+ end
+
+ -- register an app
+ ---@param app_id POCKET_APP_ID app ID
+ ---@param container graphics_element element that contains this app (usually a Div)
+ ---@param pane graphics_element? multipane if this is a simple paned app, then nav_to must be a number
+ function io.nav.register_app(app_id, container, pane)
+ ---@class pocket_app
+ local app = {
+ root = { _p = nil, _c = {}, nav_to = function () end, tasks = {} }, ---@type nav_tree_page
+ cur_page = nil, ---@type nav_tree_page
+ pane = pane,
+ paned_pages = {}
+ }
+
+ -- delayed set of the pane if it wasn't ready at the start
+ ---@param root_pane graphics_element multipane
+ function app.set_root_pane(root_pane)
+ app.pane = root_pane
+ end
+
+ -- if a pane was provided, this will switch between numbered pages
+ ---@param idx integer page index
+ function app.switcher(idx)
+ if app.paned_pages[idx] then
+ app.paned_pages[idx].nav_to()
+ end
+ end
+
+ -- create a new page entry in the app's page navigation tree
+ ---@param parent nav_tree_page? a parent page or nil to set this as the root
+ ---@param nav_to function|integer function to navigate to this page or pane index
+ ---@return nav_tree_page new_page this new page
+ function app.new_page(parent, nav_to)
+ ---@type nav_tree_page
+ local page = { _p = parent, _c = {}, nav_to = function () end, switcher = function () end, tasks = {} }
+
+ if parent == nil then
+ app.root = page
+ if app.cur_page == nil then app.cur_page = page end
+ end
+
+ if type(nav_to) == "number" then
+ app.paned_pages[nav_to] = page
+
+ function page.nav_to()
+ app.cur_page = page
+ if app.pane then app.pane.set_value(nav_to) end
+ end
+ else
+ function page.nav_to()
+ app.cur_page = page
+ nav_to()
+ end
+ end
+
+ -- switch between children
+ ---@param id integer child ID
+ function page.switcher(id) if page._c[id] then page._c[id].nav_to() end end
+
+ if parent ~= nil then
+ table.insert(page._p._c, page)
+ end
+
+ return page
+ end
+
+ -- get the currently active page
+ function app.get_current_page() return app.cur_page end
+
+ -- attempt to navigate up the tree
+ ---@return boolean success true if successfully navigated up
+ function app.nav_up()
+ local parent = app.cur_page._p
+ if parent then parent.nav_to() end
+ return parent ~= nil
+ end
+
+ self.apps[app_id] = app
+ self.containers[app_id] = container
+
+ return app
+ end
+
+ -- get a list of the app containers (usually Div elements)
+ function io.nav.get_containers() return self.containers end
+
+ -- open a given app
+ ---@param app_id POCKET_APP_ID
+ function io.nav.open_app(app_id)
+ if self.apps[app_id] then
+ self.cur_app = app_id
+ self.pane.set_value(app_id)
+ else
+ log.debug("tried to open unknown app")
+ end
+ end
+
+ -- get the currently active page
+ ---@return nav_tree_page
+ function io.nav.get_current_page()
+ return self.apps[self.cur_app].get_current_page()
+ end
+
+ -- attempt to navigate up
+ function io.nav.nav_up()
+ local app = self.apps[self.cur_app] ---@type pocket_app
+ log.debug("attempting app nav up for app " .. self.cur_app)
+
+ if not app.nav_up() then
+ log.debug("internal app nav up failed, going to home screen")
+ io.nav.open_app(APP_ID.ROOT)
+ end
+ end
+end
-- initialize facility-independent components of pocket iocontrol
---@param comms pocket_comms
function iocontrol.init_core(comms)
+ iocontrol.alloc_nav()
+
---@class pocket_ioctl_diag
io.diag = {}
@@ -76,29 +227,135 @@ function iocontrol.init_core(comms)
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
+---@param conf facility_conf configuration
+---@param temp_scale 1|2|3|4 temperature unit (1 = K, 2 = C, 3 = F, 4 = R)
+function iocontrol.init_fac(conf, temp_scale)
+ -- temperature unit label and conversion function (from Kelvin)
+ if temp_scale == 2 then
+ io.temp_label = "\xb0C"
+ io.temp_convert = function (t) return t - 273.15 end
+ elseif temp_scale == 3 then
+ io.temp_label = "\xb0F"
+ io.temp_convert = function (t) return (1.8 * (t - 273.15)) + 32 end
+ elseif temp_scale == 4 then
+ io.temp_label = "\xb0R"
+ io.temp_convert = function (t) return 1.8 * t end
+ else
+ io.temp_label = "K"
+ io.temp_convert = function (t) return t end
+ end
+
+ -- facility data structure
+ ---@class pioctl_facility
+ io.facility = {
+ 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,
+
+ auto_ready = false,
+ auto_active = false,
+ auto_ramping = false,
+ auto_saturated = false,
+
+ ---@type WASTE_PRODUCT
+ auto_current_waste_product = types.WASTE_PRODUCT.PLUTONIUM,
+ auto_pu_fallback_active = false,
+
+ radiation = types.new_zero_radiation_reading(),
+
+ ps = psil.create(),
+
+ induction_ps_tbl = {},
+ induction_data_tbl = {},
+
+ sps_ps_tbl = {},
+ sps_data_tbl = {},
+
+ tank_ps_tbl = {},
+ tank_data_tbl = {},
+
+ env_d_ps = psil.create(),
+ env_d_data = {}
+ }
+end
-- set network link state
---@param state POCKET_LINK_STATE
-function iocontrol.report_link_state(state) io.ps.publish("link_state", state) end
+function iocontrol.report_link_state(state)
+ io.ps.publish("link_state", state)
+
+ if state == LINK_STATE.API_LINK_ONLY or state == LINK_STATE.UNLINKED then
+ io.ps.publish("svr_conn_quality", 0)
+ end
+
+ if state == LINK_STATE.SV_LINK_ONLY or state == LINK_STATE.UNLINKED then
+ io.ps.publish("crd_conn_quality", 0)
+ end
+end
+
+-- determine supervisor connection quality (trip time)
+---@param trip_time integer
+function iocontrol.report_svr_tt(trip_time)
+ local state = 3
+ if trip_time > HIGH_TT then
+ state = 1
+ elseif trip_time > WARN_TT then
+ state = 2
+ end
+
+ io.ps.publish("svr_conn_quality", state)
+end
+
+-- determine coordinator connection quality (trip time)
+---@param trip_time integer
+function iocontrol.report_crd_tt(trip_time)
+ local state = 3
+ if trip_time > HIGH_TT then
+ state = 1
+ elseif trip_time > WARN_TT then
+ state = 2
+ end
+
+ io.ps.publish("crd_conn_quality", state)
+end
+
+-- populate facility data from API_GET_FAC
+---@param data table
+---@return boolean valid
+function iocontrol.record_facility_data(data)
+ local valid = true
+
+ local fac = io.facility
+
+ fac.all_sys_ok = data[1]
+ fac.rtu_count = data[2]
+ fac.radiation = data[3]
+
+ -- auto control
+ if type(data[4]) == "table" and #data[4] == 4 then
+ fac.auto_ready = data[4][1]
+ fac.auto_active = data[4][2]
+ fac.auto_ramping = data[4][3]
+ fac.auto_saturated = data[4][4]
+ end
+
+ -- waste
+ if type(data[5]) == "table" and #data[5] == 2 then
+ fac.auto_current_waste_product = data[5][1]
+ fac.auto_pu_fallback_active = data[5][2]
+ end
+
+ fac.num_tanks = data[6]
+ fac.has_imatrix = data[7]
+ fac.has_sps = data[8]
+
+ return valid
+end
-- get the IO controller database
function iocontrol.get_db() return io end
diff --git a/pocket/pocket.lua b/pocket/pocket.lua
index 0bec6ba..63149e8 100644
--- a/pocket/pocket.lua
+++ b/pocket/pocket.lua
@@ -8,6 +8,7 @@ local PROTOCOL = comms.PROTOCOL
local DEVICE_TYPE = comms.DEVICE_TYPE
local ESTABLISH_ACK = comms.ESTABLISH_ACK
local MGMT_TYPE = comms.MGMT_TYPE
+local CRDN_TYPE = comms.CRDN_TYPE
local LINK_STATE = iocontrol.LINK_STATE
@@ -125,7 +126,7 @@ function pocket.comms(version, nic, sv_watchdog, api_watchdog)
-- attempt coordinator API connection establishment
local function _send_api_establish()
- _send_crd(MGMT_TYPE.ESTABLISH, { comms.version, version, DEVICE_TYPE.PKT })
+ _send_crd(MGMT_TYPE.ESTABLISH, { comms.version, version, DEVICE_TYPE.PKT, comms.api_version })
end
-- keep alive ack to supervisor
@@ -246,6 +247,25 @@ function pocket.comms(version, nic, sv_watchdog, api_watchdog)
return pkt
end
+ ---@param packet mgmt_frame|crdn_frame
+ ---@param length integer
+ ---@param max integer?
+ ---@return boolean
+ local function _check_length(packet, length, max)
+ local ok = util.trinary(max == nil, packet.length == length, packet.length >= length and packet.length <= (max or 0))
+ if not ok then
+ local fmt = "[comms] RX_PACKET{r_chan=%d,proto=%d,type=%d}: packet length mismatch -> expect %d != actual %d"
+ log.debug(util.sprintf(fmt, packet.scada_frame.remote_channel(), packet.scada_frame.protocol(), packet.type))
+ end
+ return ok
+ end
+
+ ---@param packet mgmt_frame|crdn_frame
+ local function _fail_type(packet)
+ local fmt = "[comms] RX_PACKET{r_chan=%d,proto=%d,type=%d}: unrecognized packet type"
+ log.debug(util.sprintf(fmt, packet.scada_frame.remote_channel(), packet.scada_frame.protocol(), packet.type))
+ end
+
-- handle a packet
---@param packet mgmt_frame|crdn_frame|nil
function public.handle_packet(packet)
@@ -268,7 +288,7 @@ function pocket.comms(version, nic, sv_watchdog, api_watchdog)
return
elseif self.api.linked and (src_addr ~= self.api.addr) then
log.debug("received packet from unknown computer " .. src_addr .. " while linked (API expected " .. self.api.addr ..
- "); channel in use by another system?")
+ "); channel in use by another system?")
return
else
self.api.r_seq_num = packet.scada_frame.seq_num()
@@ -277,12 +297,24 @@ function pocket.comms(version, nic, sv_watchdog, api_watchdog)
-- feed watchdog on valid sequence number
api_watchdog.feed()
- if protocol == PROTOCOL.SCADA_MGMT then
+ if protocol == PROTOCOL.SCADA_CRDN then
+ ---@cast packet crdn_frame
+ if self.api.linked then
+ if packet.type == CRDN_TYPE.API_GET_FAC then
+ if _check_length(packet, 11) then
+ iocontrol.record_facility_data(packet.data)
+ end
+ elseif packet.type == CRDN_TYPE.API_GET_UNITS then
+ else _fail_type(packet) end
+ else
+ log.debug("discarding coordinator SCADA_CRDN packet before linked")
+ end
+ elseif protocol == PROTOCOL.SCADA_MGMT then
---@cast packet mgmt_frame
if self.api.linked then
if packet.type == MGMT_TYPE.KEEP_ALIVE then
-- keep alive request received, echo back
- if packet.length == 1 then
+ if _check_length(packet, 1) then
local timestamp = packet.data[1]
local trip_time = util.time() - timestamp
@@ -290,11 +322,11 @@ function pocket.comms(version, nic, sv_watchdog, api_watchdog)
log.warning("pocket coordinator KEEP_ALIVE trip time > 750ms (" .. trip_time .. "ms)")
end
- -- log.debug("pocket coordinator RTT = " .. trip_time .. "ms")
+ -- log.debug("pocket coordinator TT = " .. trip_time .. "ms")
_send_api_keep_alive_ack(timestamp)
- else
- log.debug("coordinator SCADA keep alive packet length mismatch")
+
+ iocontrol.report_crd_tt(trip_time)
end
elseif packet.type == MGMT_TYPE.CLOSE then
-- handle session close
@@ -303,24 +335,38 @@ function pocket.comms(version, nic, sv_watchdog, api_watchdog)
self.api.r_seq_num = nil
self.api.addr = comms.BROADCAST
log.info("coordinator server connection closed by remote host")
- else
- log.debug("received unknown SCADA_MGMT packet type " .. packet.type .. " from coordinator")
- end
+ else _fail_type(packet) end
elseif packet.type == MGMT_TYPE.ESTABLISH then
-- connection with coordinator established
- if packet.length == 1 then
+ if _check_length(packet, 1, 2) 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 packet.length == 2 then
+ local fac_config = packet.data[2]
- if self.sv.linked then
- iocontrol.report_link_state(LINK_STATE.LINKED)
+ if type(fac_config) == "table" and #fac_config == 2 then
+ -- get configuration
+ local conf = { num_units = fac_config[1], cooling = fac_config[2] }
+
+ ---@todo unit options
+ iocontrol.init_fac(conf, 1)
+
+ 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
+ else
+ log.debug("invalid facility configuration table received from coordinator, establish failed")
+ end
else
- iocontrol.report_link_state(LINK_STATE.API_LINK_ONLY)
+ log.debug("received coordinator establish allow without facility configuration")
end
elseif est_ack == ESTABLISH_ACK.DENY then
if self.api.last_est_ack ~= est_ack then
@@ -334,13 +380,15 @@ function pocket.comms(version, nic, sv_watchdog, api_watchdog)
if self.api.last_est_ack ~= est_ack then
log.info("coordinator comms version mismatch")
end
+ elseif est_ack == ESTABLISH_ACK.BAD_API_VERSION then
+ if self.api.last_est_ack ~= est_ack then
+ log.info("coordinator api 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")
@@ -372,7 +420,7 @@ function pocket.comms(version, nic, sv_watchdog, api_watchdog)
if self.sv.linked then
if packet.type == MGMT_TYPE.KEEP_ALIVE then
-- keep alive request received, echo back
- if packet.length == 1 then
+ if _check_length(packet, 1) then
local timestamp = packet.data[1]
local trip_time = util.time() - timestamp
@@ -380,11 +428,11 @@ function pocket.comms(version, nic, sv_watchdog, api_watchdog)
log.warning("pocket supervisor KEEP_ALIVE trip time > 750ms (" .. trip_time .. "ms)")
end
- -- log.debug("pocket supervisor RTT = " .. trip_time .. "ms")
+ -- log.debug("pocket supervisor TT = " .. trip_time .. "ms")
_send_sv_keep_alive_ack(timestamp)
- else
- log.debug("supervisor SCADA keep alive packet length mismatch")
+
+ iocontrol.report_svr_tt(trip_time)
end
elseif packet.type == MGMT_TYPE.CLOSE then
-- handle session close
@@ -394,12 +442,10 @@ function pocket.comms(version, nic, sv_watchdog, api_watchdog)
self.sv.addr = comms.BROADCAST
log.info("supervisor server connection closed by remote host")
elseif packet.type == MGMT_TYPE.DIAG_TONE_GET then
- if packet.length == 8 then
+ if _check_length(packet, 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 == MGMT_TYPE.DIAG_TONE_SET then
if packet.length == 1 and packet.data[1] == false then
@@ -438,12 +484,10 @@ function pocket.comms(version, nic, sv_watchdog, api_watchdog)
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
+ else _fail_type(packet) end
elseif packet.type == MGMT_TYPE.ESTABLISH then
-- connection with supervisor established
- if packet.length == 1 then
+ if _check_length(packet, 1) then
local est_ack = packet.data[1]
if est_ack == ESTABLISH_ACK.ALLOW then
@@ -474,15 +518,11 @@ function pocket.comms(version, nic, sv_watchdog, api_watchdog)
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
- else
- log.debug("illegal packet type " .. protocol .. " from supervisor", true)
- end
+ else _fail_type(packet) end
else
log.debug("received packet from unconfigured channel " .. r_chan, true)
end
@@ -500,5 +540,4 @@ function pocket.comms(version, nic, sv_watchdog, api_watchdog)
return public
end
-
return pocket
diff --git a/pocket/startup.lua b/pocket/startup.lua
index 5b7c91f..67e2f79 100644
--- a/pocket/startup.lua
+++ b/pocket/startup.lua
@@ -18,7 +18,7 @@ local iocontrol = require("pocket.iocontrol")
local pocket = require("pocket.pocket")
local renderer = require("pocket.renderer")
-local POCKET_VERSION = "v0.7.3-alpha"
+local POCKET_VERSION = "v0.8.0-alpha"
local println = util.println
local println_ts = util.println_ts
@@ -68,6 +68,9 @@ local function main()
-- mount connected devices
ppm.mount_all()
+ -- record version for GUI
+ iocontrol.get_db().version = POCKET_VERSION
+
----------------------------------------
-- setup communications & clocks
----------------------------------------
@@ -131,7 +134,7 @@ local function main()
-- start connection watchdogs
conn_wd.sv.feed()
conn_wd.api.feed()
- log.debug("startup> conn watchdog started")
+ log.debug("startup> conn watchdogs started")
local io_db = iocontrol.get_db()
local nav = io_db.nav
@@ -149,11 +152,8 @@ local function main()
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
+ local page_tasks = nav.get_current_page().tasks
+ for i = 1, #page_tasks do page_tasks[i]() end
loop_clock.start()
elseif conn_wd.sv.is_timer(param1) then
diff --git a/pocket/ui/pages/diag_page.lua b/pocket/ui/apps/diag_apps.lua
similarity index 77%
rename from pocket/ui/pages/diag_page.lua
rename to pocket/ui/apps/diag_apps.lua
index c28aba9..ec8b5e6 100644
--- a/pocket/ui/pages/diag_page.lua
+++ b/pocket/ui/apps/diag_apps.lua
@@ -1,58 +1,39 @@
+--
+-- Diagnostic Apps
+--
+
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 ALIGN = core.ALIGN
--- new diagnostics page view
+-- create diagnostic app pages
---@param root graphics_element parent
-local function new_view(root)
+local function create_pages(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=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 alarm_test = Div{parent=root,x=1,y=1}
+
+ local alarm_app = db.nav.register_app(iocontrol.APP_ID.ALARMS, alarm_test)
+
+ local page = alarm_app.new_page(nil, function () end)
+ page.tasks = { db.diag.tone_test.get_tone_states }
local ttest = db.diag.tone_test
@@ -67,8 +48,6 @@ local function new_view(root)
ttest.ready_warn = TextBox{parent=audio,y=2,text="",height=1,alignment=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=ALIGN.CENTER,fg_bg=audio.get_fg_bg()}
@@ -132,16 +111,6 @@ local function new_view(root)
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
+return create_pages
diff --git a/pocket/ui/apps/dummy_app.lua b/pocket/ui/apps/dummy_app.lua
new file mode 100644
index 0000000..fe21db3
--- /dev/null
+++ b/pocket/ui/apps/dummy_app.lua
@@ -0,0 +1,24 @@
+--
+-- Placeholder App
+--
+
+local iocontrol = require("pocket.iocontrol")
+
+local core = require("graphics.core")
+
+local Div = require("graphics.elements.div")
+local TextBox = require("graphics.elements.textbox")
+
+-- create placeholder app page
+---@param root graphics_element parent
+local function create_pages(root)
+ local db = iocontrol.get_db()
+
+ local main = Div{parent=root,x=1,y=1}
+
+ db.nav.register_app(iocontrol.APP_ID.DUMMY, main).new_page(nil, function () end)
+
+ TextBox{parent=main,text="This app is not implemented yet.",x=1,y=2,alignment=core.ALIGN.CENTER}
+end
+
+return create_pages
diff --git a/pocket/ui/apps/sys_apps.lua b/pocket/ui/apps/sys_apps.lua
new file mode 100644
index 0000000..a48f85f
--- /dev/null
+++ b/pocket/ui/apps/sys_apps.lua
@@ -0,0 +1,102 @@
+--
+-- System Apps
+--
+
+local comms = require("scada-common.comms")
+local lockbox = require("lockbox")
+local util = require("scada-common.util")
+
+local iocontrol = require("pocket.iocontrol")
+
+local core = require("graphics.core")
+
+local Div = require("graphics.elements.div")
+local ListBox = require("graphics.elements.listbox")
+local MultiPane = require("graphics.elements.multipane")
+local TextBox = require("graphics.elements.textbox")
+
+local PushButton = require("graphics.elements.controls.push_button")
+
+local cpair = core.cpair
+
+local ALIGN = core.ALIGN
+
+-- create system app pages
+---@param root graphics_element parent
+local function create_pages(root)
+ local db = iocontrol.get_db()
+
+ ----------------
+ -- About Page --
+ ----------------
+
+ local about_root = Div{parent=root,x=1,y=1}
+
+ local about_app = db.nav.register_app(iocontrol.APP_ID.ABOUT, about_root)
+
+ local about_page = about_app.new_page(nil, 1)
+ local fw_page = about_app.new_page(about_page, 2)
+ local hw_page = about_app.new_page(about_page, 3)
+
+ local about = Div{parent=about_root,x=1,y=2}
+
+ TextBox{parent=about,y=1,text="System Information",height=1,alignment=ALIGN.CENTER}
+
+ local btn_fg_bg = cpair(colors.lightBlue, colors.black)
+ local btn_active = cpair(colors.white, colors.black)
+ local label = cpair(colors.lightGray, colors.black)
+
+ PushButton{parent=about,x=2,y=3,text="Firmware >",fg_bg=btn_fg_bg,active_fg_bg=btn_active,callback=fw_page.nav_to}
+ PushButton{parent=about,x=2,y=4,text="Host Details >",fg_bg=btn_fg_bg,active_fg_bg=btn_active,callback=hw_page.nav_to}
+
+ local fw_div = Div{parent=about_root,x=1,y=2}
+ TextBox{parent=fw_div,y=1,text="Firmware Versions",height=1,alignment=ALIGN.CENTER}
+
+ PushButton{parent=fw_div,x=2,y=1,text="<",fg_bg=btn_fg_bg,active_fg_bg=btn_active,callback=about_page.nav_to}
+
+ local fw_list_box = ListBox{parent=fw_div,x=1,y=3,scroll_height=100,nav_fg_bg=cpair(colors.lightGray,colors.gray),nav_active=cpair(colors.white,colors.gray)}
+
+ local fw_list = Div{parent=fw_list_box,x=1,y=2,height=18}
+
+ TextBox{parent=fw_list,x=2,text="Pocket Version",height=1,alignment=ALIGN.LEFT,fg_bg=label}
+ TextBox{parent=fw_list,x=2,text=db.version,height=1,alignment=ALIGN.LEFT}
+
+ fw_list.line_break()
+ TextBox{parent=fw_list,x=2,text="Comms Version",height=1,alignment=ALIGN.LEFT,fg_bg=label}
+ TextBox{parent=fw_list,x=2,text=comms.version,height=1,alignment=ALIGN.LEFT}
+
+ fw_list.line_break()
+ TextBox{parent=fw_list,x=2,text="API Version",height=1,alignment=ALIGN.LEFT,fg_bg=label}
+ TextBox{parent=fw_list,x=2,text=comms.api_version,height=1,alignment=ALIGN.LEFT}
+
+ fw_list.line_break()
+ TextBox{parent=fw_list,x=2,text="Common Lib Version",height=1,alignment=ALIGN.LEFT,fg_bg=label}
+ TextBox{parent=fw_list,x=2,text=util.version,height=1,alignment=ALIGN.LEFT}
+
+ fw_list.line_break()
+ TextBox{parent=fw_list,x=2,text="Graphics Version",height=1,alignment=ALIGN.LEFT,fg_bg=label}
+ TextBox{parent=fw_list,x=2,text=core.version,height=1,alignment=ALIGN.LEFT}
+
+ fw_list.line_break()
+ TextBox{parent=fw_list,x=2,text="Lockbox Version",height=1,alignment=ALIGN.LEFT,fg_bg=label}
+ TextBox{parent=fw_list,x=2,text=lockbox.version,height=1,alignment=ALIGN.LEFT}
+
+ local hw_div = Div{parent=about_root,x=1,y=2}
+ TextBox{parent=hw_div,y=1,text="Host Versions",height=1,alignment=ALIGN.CENTER}
+
+ PushButton{parent=hw_div,x=2,y=1,text="<",fg_bg=btn_fg_bg,active_fg_bg=btn_active,callback=about_page.nav_to}
+
+ hw_div.line_break()
+ TextBox{parent=hw_div,x=2,text="Lua Version",height=1,alignment=ALIGN.LEFT,fg_bg=label}
+ TextBox{parent=hw_div,x=2,text=_VERSION,height=1,alignment=ALIGN.LEFT}
+
+ hw_div.line_break()
+ TextBox{parent=hw_div,x=2,text="Environment",height=1,alignment=ALIGN.LEFT,fg_bg=label}
+ TextBox{parent=hw_div,x=2,text=_HOST,height=6,alignment=ALIGN.LEFT}
+
+ local root_pane = MultiPane{parent=about_root,x=1,y=1,panes={about,fw_div,hw_div}}
+
+ about_app.set_root_pane(root_pane)
+end
+
+return create_pages
diff --git a/pocket/ui/main.lua b/pocket/ui/main.lua
index 4cd70c6..02e33fd 100644
--- a/pocket/ui/main.lua
+++ b/pocket/ui/main.lua
@@ -4,27 +4,29 @@
local iocontrol = require("pocket.iocontrol")
-local style = require("pocket.ui.style")
+local diag_apps = require("pocket.ui.apps.diag_apps")
+local dummy_app = require("pocket.ui.apps.dummy_app")
+local sys_apps = require("pocket.ui.apps.sys_apps")
local conn_waiting = require("pocket.ui.components.conn_waiting")
-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 style = require("pocket.ui.style")
+
local core = require("graphics.core")
local Div = require("graphics.elements.div")
local MultiPane = require("graphics.elements.multipane")
local TextBox = require("graphics.elements.textbox")
+local PushButton = require("graphics.elements.controls.push_button")
local Sidebar = require("graphics.elements.controls.sidebar")
+local SignalBar = require("graphics.elements.indicators.signal")
+
local LINK_STATE = iocontrol.LINK_STATE
-local NAV_PAGE = iocontrol.NAV_PAGE
local ALIGN = core.ALIGN
@@ -33,26 +35,27 @@ 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
+ local db = iocontrol.get_db()
-- window header message
- TextBox{parent=main,y=1,text="",alignment=ALIGN.LEFT,height=1,fg_bg=style.header}
+ TextBox{parent=main,y=1,text="DEV ALPHA APP S C ",alignment=ALIGN.LEFT,height=1,fg_bg=style.header}
+ local svr_conn = SignalBar{parent=main,y=1,x=22,compact=true,colors_low_med=cpair(colors.red,colors.yellow),disconnect_color=colors.lightGray,fg_bg=cpair(colors.green,colors.gray)}
+ local crd_conn = SignalBar{parent=main,y=1,x=26,compact=true,colors_low_med=cpair(colors.red,colors.yellow),disconnect_color=colors.lightGray,fg_bg=cpair(colors.green,colors.gray)}
- --
- -- root panel panes (connection screens + main screen)
- --
+ db.ps.subscribe("svr_conn_quality", svr_conn.set_value)
+ db.ps.subscribe("crd_conn_quality", crd_conn.set_value)
+
+ --#region root panel panes (connection screens + main screen)
local root_pane_div = Div{parent=main,x=1,y=2}
local conn_sv_wait = conn_waiting(root_pane_div, 6, false)
local conn_api_wait = conn_waiting(root_pane_div, 6, true)
local main_pane = Div{parent=main,x=1,y=2}
- local root_panes = { conn_sv_wait, conn_api_wait, main_pane }
- local root_pane = MultiPane{parent=root_pane_div,x=1,y=1,panes=root_panes}
+ local root_pane = MultiPane{parent=root_pane_div,x=1,y=1,panes={conn_sv_wait,conn_api_wait,main_pane}}
- root_pane.register(ps, "link_state", function (state)
+ root_pane.register(db.ps, "link_state", function (state)
if state == LINK_STATE.UNLINKED or state == LINK_STATE.API_LINK_ONLY then
root_pane.set_value(1)
elseif state == LINK_STATE.SV_LINK_ONLY then
@@ -62,62 +65,33 @@ local function init(main)
end
end)
- --
- -- main page panel panes & sidebar
- --
+ --#endregion
+
+ --#region main page panel panes & sidebar
local page_div = Div{parent=main_pane,x=4,y=1}
local sidebar_tabs = {
- {
- char = "#",
- color = cpair(colors.black,colors.green)
- },
- {
- char = "U",
- color = cpair(colors.black,colors.yellow)
- },
- {
- char = "R",
- color = cpair(colors.black,colors.cyan)
- },
- {
- char = "B",
- color = cpair(colors.black,colors.lightGray)
- },
- {
- char = "T",
- color = cpair(colors.black,colors.white)
- },
- {
- char = "D",
- color = cpair(colors.black,colors.orange)
- }
+ { char = "#", color = cpair(colors.black, colors.green) }
}
- 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) }
+ home_page(page_div)
+ unit_page(page_div)
- local page_pane = MultiPane{parent=page_div,x=1,y=1,panes=panes}
+ diag_apps(page_div)
+ sys_apps(page_div)
+ dummy_app(page_div)
- 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
+ assert(#db.nav.get_containers() == iocontrol.APP_ID.NUM_APPS, "app IDs were not sequential or some apps weren't registered")
- page_pane.set_value(page)
- end
+ local page_pane = MultiPane{parent=page_div,x=1,y=1,panes=db.nav.get_containers()}
+ db.nav.set_pane(page_pane)
- Sidebar{parent=main_pane,x=1,y=1,tabs=sidebar_tabs,fg_bg=cpair(colors.white,colors.gray),callback=navigate_sidebar}
+ Sidebar{parent=main_pane,x=1,y=1,tabs=sidebar_tabs,fg_bg=cpair(colors.white,colors.gray),callback=db.nav.open_app}
+
+ PushButton{parent=main_pane,x=1,y=19,text="\x1b",min_width=3,fg_bg=cpair(colors.white,colors.gray),active_fg_bg=cpair(colors.gray,colors.black),callback=db.nav.nav_up}
+
+ --#endregion
end
return init
diff --git a/pocket/ui/pages/boiler_page.lua b/pocket/ui/pages/boiler_page.lua
deleted file mode 100644
index ff92b05..0000000
--- a/pocket/ui/pages/boiler_page.lua
+++ /dev/null
@@ -1,22 +0,0 @@
--- 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 ALIGN = core.ALIGN
-
--- new boiler 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="BOILERS",x=1,y=1,height=1,alignment=ALIGN.CENTER}
-
- return main
-end
-
-return new_view
diff --git a/pocket/ui/pages/home_page.lua b/pocket/ui/pages/home_page.lua
index d192796..0ea7c3f 100644
--- a/pocket/ui/pages/home_page.lua
+++ b/pocket/ui/pages/home_page.lua
@@ -1,21 +1,59 @@
-local core = require("graphics.core")
+--
+-- Main Home Page
+--
-local Div = require("graphics.elements.div")
+local iocontrol = require("pocket.iocontrol")
-local App = require("graphics.elements.controls.app")
+local core = require("graphics.core")
+
+local AppMultiPane = require("graphics.elements.appmultipane")
+local Div = require("graphics.elements.div")
+local TextBox = require("graphics.elements.textbox")
+
+local App = require("graphics.elements.controls.app")
local cpair = core.cpair
+local APP_ID = iocontrol.APP_ID
+
+local ALIGN = core.ALIGN
+
-- new home page view
---@param root graphics_element parent
local function new_view(root)
- local main = Div{parent=root,x=1,y=1}
+ local db = iocontrol.get_db()
- 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)}
+ local main = Div{parent=root,x=1,y=1,height=19}
+
+ local app = db.nav.register_app(iocontrol.APP_ID.ROOT, main)
+
+ local apps_1 = Div{parent=main,x=1,y=1,height=15}
+ local apps_2 = Div{parent=main,x=1,y=1,height=15}
+
+ local panes = { apps_1, apps_2 }
+
+ local app_pane = AppMultiPane{parent=main,x=1,y=1,height=18,panes=panes,active_color=colors.lightGray,nav_colors=cpair(colors.lightGray,colors.gray),scroll_nav=true,drag_nav=true,callback=app.switcher}
+
+ app.set_root_pane(app_pane)
+ app.new_page(app.new_page(nil, 1), 2)
+
+ local function open(id) db.nav.open_app(id) end
+
+ local active_fg_bg = cpair(colors.white,colors.gray)
+
+ App{parent=apps_1,x=3,y=2,text="U",title="Units",callback=function()open(APP_ID.UNITS)end,app_fg_bg=cpair(colors.black,colors.yellow),active_fg_bg=active_fg_bg}
+ App{parent=apps_1,x=10,y=2,text="\x17",title="PRC",callback=function()open(APP_ID.DUMMY)end,app_fg_bg=cpair(colors.black,colors.purple),active_fg_bg=active_fg_bg}
+ App{parent=apps_1,x=17,y=2,text="\x15",title="CTL",callback=function()open(APP_ID.DUMMY)end,app_fg_bg=cpair(colors.black,colors.green),active_fg_bg=active_fg_bg}
+ App{parent=apps_1,x=3,y=7,text="\x08",title="DEV",callback=function()open(APP_ID.DUMMY)end,app_fg_bg=cpair(colors.black,colors.lightGray),active_fg_bg=active_fg_bg}
+ App{parent=apps_1,x=10,y=7,text="\x7f",title="Waste",callback=function()open(APP_ID.DUMMY)end,app_fg_bg=cpair(colors.black,colors.brown),active_fg_bg=active_fg_bg}
+ App{parent=apps_1,x=17,y=7,text="\xb6",title="Guide",callback=function()open(APP_ID.DUMMY)end,app_fg_bg=cpair(colors.black,colors.cyan),active_fg_bg=active_fg_bg}
+ App{parent=apps_1,x=3,y=12,text="?",title="About",callback=function()open(APP_ID.ABOUT)end,app_fg_bg=cpair(colors.black,colors.white),active_fg_bg=active_fg_bg}
+
+ TextBox{parent=apps_2,text="Diagnostic Apps",x=1,y=2,height=1,alignment=ALIGN.CENTER}
+
+ App{parent=apps_2,x=3,y=4,text="\x0f",title="Alarm",callback=function()open(APP_ID.ALARMS)end,app_fg_bg=cpair(colors.black,colors.red),active_fg_bg=active_fg_bg}
+ App{parent=apps_2,x=10,y=4,text="\x1e",title="LoopT",callback=function()open(APP_ID.DUMMY)end,app_fg_bg=cpair(colors.black,colors.cyan),active_fg_bg=active_fg_bg}
+ App{parent=apps_2,x=17,y=4,text="@",title="Comps",callback=function()open(APP_ID.DUMMY)end,app_fg_bg=cpair(colors.black,colors.orange),active_fg_bg=active_fg_bg}
return main
end
diff --git a/pocket/ui/pages/reactor_page.lua b/pocket/ui/pages/reactor_page.lua
deleted file mode 100644
index ae11436..0000000
--- a/pocket/ui/pages/reactor_page.lua
+++ /dev/null
@@ -1,22 +0,0 @@
--- 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 ALIGN = core.ALIGN
-
--- new reactor 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="REACTOR",x=1,y=1,height=1,alignment=ALIGN.CENTER}
-
- return main
-end
-
-return new_view
diff --git a/pocket/ui/pages/turbine_page.lua b/pocket/ui/pages/turbine_page.lua
deleted file mode 100644
index 41d656c..0000000
--- a/pocket/ui/pages/turbine_page.lua
+++ /dev/null
@@ -1,22 +0,0 @@
--- 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 ALIGN = core.ALIGN
-
--- new turbine 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="TURBINES",x=1,y=1,height=1,alignment=ALIGN.CENTER}
-
- return main
-end
-
-return new_view
diff --git a/pocket/ui/pages/unit_page.lua b/pocket/ui/pages/unit_page.lua
index de61e81..273ad27 100644
--- a/pocket/ui/pages/unit_page.lua
+++ b/pocket/ui/pages/unit_page.lua
@@ -1,20 +1,29 @@
--- local style = require("pocket.ui.style")
+--
+-- Unit Overview Page
+--
-local core = require("graphics.core")
+local iocontrol = require("pocket.iocontrol")
-local Div = require("graphics.elements.div")
-local TextBox = require("graphics.elements.textbox")
+local core = require("graphics.core")
--- local cpair = core.cpair
+local Div = require("graphics.elements.div")
+local TextBox = require("graphics.elements.textbox")
local ALIGN = core.ALIGN
-- new unit page view
---@param root graphics_element parent
local function new_view(root)
+ local db = iocontrol.get_db()
+
local main = Div{parent=root,x=1,y=1}
- TextBox{parent=main,text="UNITS",x=1,y=1,height=1,alignment=ALIGN.CENTER}
+ local app = db.nav.register_app(iocontrol.APP_ID.UNITS, main)
+ app.new_page(nil, function () end)
+
+ TextBox{parent=main,y=2,text="UNITS",height=1,alignment=ALIGN.CENTER}
+
+ TextBox{parent=main,y=4,text="work in progress",height=1,alignment=ALIGN.CENTER}
return main
end
diff --git a/reactor-plc/configure.lua b/reactor-plc/configure.lua
index 43db0a9..482a629 100644
--- a/reactor-plc/configure.lua
+++ b/reactor-plc/configure.lua
@@ -729,7 +729,7 @@ local function config_view(display)
local alternate = false
local inner_width = setting_list.get_width() - 1
- tool_ctl.show_key_btn.enable()
+ if cfg.AuthKey then tool_ctl.show_key_btn.enable() else tool_ctl.show_key_btn.disable() end
tool_ctl.auth_key_value = cfg.AuthKey or "" -- to show auth key
for i = 1, #fields do
@@ -740,7 +740,7 @@ local function config_view(display)
local raw = cfg[f[1]]
local val = util.strval(raw)
- if f[1] == "AuthKey" then val = string.rep("*", string.len(val))
+ if f[1] == "AuthKey" and raw then val = string.rep("*", string.len(val))
elseif f[1] == "LogMode" then val = util.trinary(raw == log.MODE.APPEND, "append", "replace")
elseif f[1] == "EmerCoolColor" and raw ~= nil then val = rsio.color_name(raw)
elseif f[1] == "FrontPanelTheme" then
diff --git a/reactor-plc/plc.lua b/reactor-plc/plc.lua
index b5c299d..fb906a3 100644
--- a/reactor-plc/plc.lua
+++ b/reactor-plc/plc.lua
@@ -74,7 +74,7 @@ function plc.load_config()
if type(config.AuthKey) == "string" then
local len = string.len(config.AuthKey)
- cfv.assert_eq(len == 0 or len >= 8, true)
+ cfv.assert(len == 0 or len >= 8)
end
end
diff --git a/reactor-plc/startup.lua b/reactor-plc/startup.lua
index 6ad612b..024daeb 100644
--- a/reactor-plc/startup.lua
+++ b/reactor-plc/startup.lua
@@ -18,7 +18,7 @@ local plc = require("reactor-plc.plc")
local renderer = require("reactor-plc.renderer")
local threads = require("reactor-plc.threads")
-local R_PLC_VERSION = "v1.7.4"
+local R_PLC_VERSION = "v1.7.9"
local println = util.println
local println_ts = util.println_ts
diff --git a/reactor-plc/threads.lua b/reactor-plc/threads.lua
index afe6acb..22e1d86 100644
--- a/reactor-plc/threads.lua
+++ b/reactor-plc/threads.lua
@@ -71,22 +71,19 @@ function threads.thread__main(smem, init)
-- blink heartbeat indicator
databus.heartbeat()
- -- core clock tick
- if networked then
- -- start next clock timer
- loop_clock.start()
+ -- start next clock timer
+ loop_clock.start()
- -- send updated data
- if nic.is_connected() then
- if plc_comms.is_linked() then
- smem.q.mq_comms_tx.push_command(MQ__COMM_CMD.SEND_STATUS)
+ -- send updated data
+ if networked and nic.is_connected() then
+ if plc_comms.is_linked() then
+ smem.q.mq_comms_tx.push_command(MQ__COMM_CMD.SEND_STATUS)
+ else
+ if ticks_to_update == 0 then
+ plc_comms.send_link_req()
+ ticks_to_update = LINK_TICKS
else
- if ticks_to_update == 0 then
- plc_comms.send_link_req()
- ticks_to_update = LINK_TICKS
- else
- ticks_to_update = ticks_to_update - 1
- end
+ ticks_to_update = ticks_to_update - 1
end
end
end
@@ -368,9 +365,9 @@ function threads.thread__rps(smem)
end
end
- -- if we are in standalone mode, continuously reset RPS
+ -- if we are in standalone mode and the front panel isn't working, continuously reset RPS
-- RPS will trip again if there are faults, but if it isn't cleared, the user can't re-enable
- if not networked then rps.reset(true) end
+ if not (networked or smem.plc_state.fp_ok) then rps.reset(true) end
-- check safety (SCRAM occurs if tripped)
if not plc_state.no_reactor then
@@ -662,8 +659,9 @@ function threads.thread__setpoint_control(smem)
if (type(cur_burn_rate) == "number") and (setpoints.burn_rate ~= cur_burn_rate) and rps.is_active() then
last_burn_sp = setpoints.burn_rate
- -- update without ramp if <= 2.5 mB/t change
- running = math.abs(setpoints.burn_rate - cur_burn_rate) > 2.5
+ -- update without ramp if <= 2.5 mB/t increase
+ -- no need to ramp down, as the ramp up poses the safety risks
+ running = (setpoints.burn_rate - cur_burn_rate) > 2.5
if running then
log.debug(util.c("SPCTL: starting burn rate ramp from ", cur_burn_rate, " mB/t to ", setpoints.burn_rate, " mB/t"))
diff --git a/rtu/rtu.lua b/rtu/rtu.lua
index 7e00b98..71cea40 100644
--- a/rtu/rtu.lua
+++ b/rtu/rtu.lua
@@ -60,7 +60,7 @@ function rtu.load_config()
if type(config.AuthKey) == "string" then
local len = string.len(config.AuthKey)
- cfv.assert_eq(len == 0 or len >= 8, true)
+ cfv.assert(len == 0 or len >= 8)
end
cfv.assert_type_int(config.LogMode)
diff --git a/rtu/startup.lua b/rtu/startup.lua
index 4d7ecbd..9470124 100644
--- a/rtu/startup.lua
+++ b/rtu/startup.lua
@@ -31,7 +31,7 @@ local sna_rtu = require("rtu.dev.sna_rtu")
local sps_rtu = require("rtu.dev.sps_rtu")
local turbinev_rtu = require("rtu.dev.turbinev_rtu")
-local RTU_VERSION = "v1.9.3"
+local RTU_VERSION = "v1.9.4"
local RTU_UNIT_TYPE = types.RTU_UNIT_TYPE
local RTU_UNIT_HW_STATE = databus.RTU_UNIT_HW_STATE
diff --git a/scada-common/comms.lua b/scada-common/comms.lua
index 180e9c8..fffaaee 100644
--- a/scada-common/comms.lua
+++ b/scada-common/comms.lua
@@ -16,8 +16,9 @@ local max_distance = nil
---@class comms
local comms = {}
--- protocol/data version (protocol/data independent changes tracked by util.lua version)
-comms.version = "2.4.5"
+-- protocol/data versions (protocol/data independent changes tracked by util.lua version)
+comms.version = "2.5.0"
+comms.api_version = "0.0.1"
---@enum PROTOCOL
local PROTOCOL = {
@@ -64,7 +65,9 @@ local CRDN_TYPE = {
FAC_CMD = 3, -- faility command
UNIT_BUILDS = 4, -- build of each reactor unit (reactor + RTUs)
UNIT_STATUSES = 5, -- state of each of the reactor units
- UNIT_CMD = 6 -- command a reactor unit
+ UNIT_CMD = 6, -- command a reactor unit
+ API_GET_FAC = 7, -- API: get all the facility data
+ API_GET_UNITS = 8 -- API: get all the reactor unit data
}
---@enum ESTABLISH_ACK
@@ -72,7 +75,8 @@ local ESTABLISH_ACK = {
ALLOW = 0, -- link approved
DENY = 1, -- link denied
COLLISION = 2, -- link denied due to existing active link
- BAD_VERSION = 3 -- link denied due to comms version mismatch
+ BAD_VERSION = 3, -- link denied due to comms version mismatch
+ BAD_API_VERSION = 4 -- link denied due to api version mismatch
}
---@enum DEVICE_TYPE device types for establish messages
diff --git a/scada-common/constants.lua b/scada-common/constants.lua
index eaf6dd3..20925bd 100644
--- a/scada-common/constants.lua
+++ b/scada-common/constants.lua
@@ -68,19 +68,32 @@ constants.ALARM_LIMITS = alarms
--#region Supervisor Constants
--- milliseconds until turbine flow is assumed to be stable enough to enable coolant checks
-constants.FLOW_STABILITY_DELAY_MS = 15000
+-- milliseconds until coolant flow is assumed to be stable enough to enable certain coolant checks
+constants.FLOW_STABILITY_DELAY_MS = 10000
-- Notes on Radiation
-- - background radiation 0.0000001 Sv/h (99.99 nSv/h)
-- - "green tint" radiation 0.00001 Sv/h (10 uSv/h)
-- - damaging radiation 0.00006 Sv/h (60 uSv/h)
-constants.LOW_RADIATION = 0.00001
-constants.HAZARD_RADIATION = 0.00006
-constants.HIGH_RADIATION = 0.001
+constants.LOW_RADIATION = 0.00001
+constants.HAZARD_RADIATION = 0.00006
+constants.HIGH_RADIATION = 0.001
constants.VERY_HIGH_RADIATION = 0.1
-constants.SEVERE_RADIATION = 8.0
-constants.EXTREME_RADIATION = 100.0
+constants.SEVERE_RADIATION = 8.0
+constants.EXTREME_RADIATION = 100.0
+
+--#endregion
+
+--#region Mekanism Configuration Constants
+
+---@class _mek_constants
+local mek = {}
+
+mek.TURBINE_GAS_PER_TANK = 64000 -- mekanism: turbineGasPerTank
+mek.TURBINE_DISPERSER_FLOW = 1280 -- mekanism: turbineDisperserGasFlow
+mek.TURBINE_VENT_FLOW = 32000 -- mekanism: turbineVentGasFlow
+
+constants.mek = mek
--#endregion
diff --git a/scada-common/util.lua b/scada-common/util.lua
index 395be22..5433ab9 100644
--- a/scada-common/util.lua
+++ b/scada-common/util.lua
@@ -22,7 +22,7 @@ local t_pack = table.pack
local util = {}
-- scada-common version
-util.version = "1.2.0"
+util.version = "1.2.1"
util.TICK_TIME_S = 0.05
util.TICK_TIME_MS = 50
diff --git a/supervisor/configure.lua b/supervisor/configure.lua
index cd790e7..8cad070 100644
--- a/supervisor/configure.lua
+++ b/supervisor/configure.lua
@@ -91,6 +91,7 @@ local tmp_cfg = {
CoolingConfig = {},
FacilityTankMode = 0,
FacilityTankDefs = {},
+ ExtChargeIdling = false,
SVR_Channel = nil, ---@type integer
PLC_Channel = nil, ---@type integer
RTU_Channel = nil, ---@type integer
@@ -120,6 +121,7 @@ local fields = {
{ "CoolingConfig", "Cooling Configuration", {} },
{ "FacilityTankMode", "Facility Tank Mode", 0 },
{ "FacilityTankDefs", "Facility Tank Definitions", {} },
+ { "ExtChargeIdling", "Extended Charge Idling", false },
{ "SVR_Channel", "SVR Channel", 16240 },
{ "PLC_Channel", "PLC Channel", 16241 },
{ "RTU_Channel", "RTU Channel", 16242 },
@@ -222,8 +224,9 @@ local function config_view(display)
local svr_c_4 = Div{parent=svr_cfg,x=2,y=4,width=49}
local svr_c_5 = Div{parent=svr_cfg,x=2,y=4,width=49}
local svr_c_6 = Div{parent=svr_cfg,x=2,y=4,width=49}
+ local svr_c_7 = Div{parent=svr_cfg,x=2,y=4,width=49}
- local svr_pane = MultiPane{parent=svr_cfg,x=1,y=4,panes={svr_c_1,svr_c_2,svr_c_3,svr_c_4,svr_c_5,svr_c_6}}
+ local svr_pane = MultiPane{parent=svr_cfg,x=1,y=4,panes={svr_c_1,svr_c_2,svr_c_3,svr_c_4,svr_c_5,svr_c_6,svr_c_7}}
TextBox{parent=svr_cfg,x=1,y=2,height=1,text=" Facility Configuration",fg_bg=cpair(colors.black,colors.yellow)}
@@ -329,7 +332,7 @@ local function config_view(display)
else
tmp_cfg.FacilityTankMode = 0
tmp_cfg.FacilityTankDefs = {}
- main_pane.set_value(3)
+ svr_pane.set_value(7)
end
end
@@ -563,7 +566,7 @@ local function config_view(display)
local function submit_mode()
tmp_cfg.FacilityTankMode = tank_mode.get_value()
- main_pane.set_value(3)
+ svr_pane.set_value(7)
end
PushButton{parent=svr_c_5,x=1,y=14,text="\x1b Back",callback=function()svr_pane.set_value(4)end,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg}
@@ -577,6 +580,23 @@ local function config_view(display)
PushButton{parent=svr_c_6,x=1,y=14,text="\x1b Back",callback=function()svr_pane.set_value(5)end,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg}
+ TextBox{parent=svr_c_7,height=6,text="Charge control provides automatic control to maintain an induction matrix charge level. In order to have smoother control, reactors that were activated will be held on at 0.01 mB/t for a short period before allowing them to turn off. This minimizes overshooting the charge target."}
+ TextBox{parent=svr_c_7,y=8,height=3,text="You can extend this to a full minute to minimize reactors flickering on/off, but there may be more overshoot of the target."}
+
+ local ext_idling = CheckBox{parent=svr_c_7,x=1,y=12,label="Enable Extended Idling",default=ini_cfg.ExtChargeIdling,box_fg_bg=cpair(colors.yellow,colors.black)}
+
+ local function back_from_idling()
+ svr_pane.set_value(util.trinary(tmp_cfg.FacilityTankMode == 0, 3, 5))
+ end
+
+ local function submit_idling()
+ tmp_cfg.ExtChargeIdling = ext_idling.get_value()
+ main_pane.set_value(3)
+ end
+
+ PushButton{parent=svr_c_7,x=1,y=14,text="\x1b Back",callback=back_from_idling,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg}
+ PushButton{parent=svr_c_7,x=44,y=14,text="Next \x1a",callback=submit_idling,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg}
+
--#endregion
--#region Network
diff --git a/supervisor/facility.lua b/supervisor/facility.lua
index 43c6cc4..33cd267 100644
--- a/supervisor/facility.lua
+++ b/supervisor/facility.lua
@@ -23,7 +23,7 @@ local RTU_UNIT_TYPE = types.RTU_UNIT_TYPE
local WASTE_MODE = types.WASTE_MODE
local WASTE = types.WASTE_PRODUCT
-local IO = rsio.IO
+local IO = rsio.IO
local DTV_RTU_S_DATA = qtypes.DTV_RTU_S_DATA
@@ -50,9 +50,9 @@ local START_STATUS = {
BLADE_MISMATCH = 2
}
-local charge_Kp = 0.275
+local charge_Kp = 0.15
local charge_Ki = 0.0
-local charge_Kd = 4.5
+local charge_Kd = 0.6
local rate_Kp = 2.45
local rate_Ki = 0.4825
@@ -63,9 +63,9 @@ local facility = {}
-- create a new facility management object
---@nodiscard
----@param num_reactors integer number of reactor units
+---@param config svr_config supervisor configuration
---@param cooling_conf sv_cooling_conf cooling configurations of reactor units
-function facility.new(num_reactors, cooling_conf)
+function facility.new(config, cooling_conf)
local self = {
units = {},
status_text = { "START UP", "initializing..." },
@@ -134,8 +134,8 @@ function facility.new(num_reactors, cooling_conf)
}
-- create units
- for i = 1, num_reactors do
- table.insert(self.units, unit.new(i, cooling_conf.r_cool[i].BoilerCount, cooling_conf.r_cool[i].TurbineCount))
+ for i = 1, config.UnitCount do
+ table.insert(self.units, unit.new(i, cooling_conf.r_cool[i].BoilerCount, cooling_conf.r_cool[i].TurbineCount, config.ExtChargeIdling))
table.insert(self.group_map, 0)
end
@@ -225,6 +225,14 @@ function facility.new(num_reactors, cooling_conf)
return unallocated, false
end
+ -- set idle state of all assigned reactors
+ ---@param idle boolean idle state
+ local function _set_idling(idle)
+ for i = 1, #self.prio_defs do
+ for _, u in pairs(self.prio_defs[i]) do u.auto_set_idle(idle) end
+ end
+ end
+
-- PUBLIC FUNCTIONS --
---@class facility
@@ -325,10 +333,11 @@ function facility.new(num_reactors, cooling_conf)
--#region
- local avg_charge = self.avg_charge.compute()
- local avg_inflow = self.avg_inflow.compute()
+ local avg_charge = self.avg_charge.compute()
+ local avg_inflow = self.avg_inflow.compute()
+ local avg_outflow = self.avg_outflow.compute()
- local now = util.time_s()
+ local now = os.clock()
local state_changed = self.mode ~= self.last_mode
local next_mode = self.mode
@@ -390,6 +399,7 @@ function facility.new(num_reactors, cooling_conf)
-- disable reactors and disengage auto control
for _, u in pairs(self.prio_defs[i]) do
u.disable()
+ u.auto_set_idle(false)
u.auto_disengage()
end
end
@@ -460,6 +470,9 @@ function facility.new(num_reactors, cooling_conf)
self.last_error = 0
self.accumulator = 0
+ -- enabling idling on all assigned units
+ _set_idling(true)
+
self.status_text = { "CHARGE MODE", "running control loop" }
log.info("FAC: CHARGE mode starting PID control")
elseif self.last_update ~= charge_update then
@@ -475,9 +488,9 @@ function facility.new(num_reactors, cooling_conf)
local integral = self.accumulator
local derivative = (error - self.last_error) / (now - self.last_time)
- local P = (charge_Kp * error)
- local I = (charge_Ki * integral)
- local D = (charge_Kd * derivative)
+ local P = charge_Kp * error
+ local I = charge_Ki * integral
+ local D = charge_Kd * derivative
local output = P + I + D
@@ -486,7 +499,12 @@ function facility.new(num_reactors, cooling_conf)
self.saturated = output ~= out_c
- -- log.debug(util.sprintf("CHARGE[%f] { CHRG[%f] ERR[%f] INT[%f] => OUT[%f] OUT_C[%f] <= P[%f] I[%f] D[%d] }",
+ if not config.ExtChargeIdling then
+ -- stop idling early if the output is zero, we are at or above the setpoint, and are not losing charge
+ _set_idling(not ((out_c == 0) and (error <= 0) and (avg_outflow <= 0)))
+ end
+
+ -- log.debug(util.sprintf("CHARGE[%f] { CHRG[%f] ERR[%f] INT[%f] => OUT[%f] OUT_C[%f] <= P[%f] I[%f] D[%f] }",
-- runtime, avg_charge, error, integral, output, out_c, P, I, D))
_allocate_burn_rate(out_c, true)
@@ -544,9 +562,9 @@ function facility.new(num_reactors, cooling_conf)
local integral = self.accumulator
local derivative = (error - self.last_error) / (now - self.last_time)
- local P = (rate_Kp * error)
- local I = (rate_Ki * integral)
- local D = (rate_Kd * derivative)
+ local P = rate_Kp * error
+ local I = rate_Ki * integral
+ local D = rate_Kd * derivative
-- velocity (rate) (derivative of charge level => rate) feed forward
local FF = self.gen_rate_setpoint / self.charge_conversion
@@ -936,41 +954,41 @@ function facility.new(num_reactors, cooling_conf)
function public.auto_stop() self.mode = PROCESS.INACTIVE end
-- set automatic control configuration and start the process
- ---@param config coord_auto_config configuration
+ ---@param auto_cfg coord_auto_config configuration
---@return table response ready state (successfully started) and current configuration (after updating)
- function public.auto_start(config)
+ function public.auto_start(auto_cfg)
local charge_scaler = 1000000 -- convert MFE to FE
local gen_scaler = 1000 -- convert kFE to FE
local ready = false
-- load up current limits
local limits = {}
- for i = 1, num_reactors do
+ for i = 1, config.UnitCount do
local u = self.units[i] ---@type reactor_unit
limits[i] = u.get_control_inf().lim_br100 * 100
end
-- only allow changes if not running
if self.mode == PROCESS.INACTIVE then
- if (type(config.mode) == "number") and (config.mode > PROCESS.INACTIVE) and (config.mode <= PROCESS.GEN_RATE) then
- self.mode_set = config.mode
+ if (type(auto_cfg.mode) == "number") and (auto_cfg.mode > PROCESS.INACTIVE) and (auto_cfg.mode <= PROCESS.GEN_RATE) then
+ self.mode_set = auto_cfg.mode
end
- if (type(config.burn_target) == "number") and config.burn_target >= 0.1 then
- self.burn_target = config.burn_target
+ if (type(auto_cfg.burn_target) == "number") and auto_cfg.burn_target >= 0.1 then
+ self.burn_target = auto_cfg.burn_target
end
- if (type(config.charge_target) == "number") and config.charge_target >= 0 then
- self.charge_setpoint = config.charge_target * charge_scaler
+ if (type(auto_cfg.charge_target) == "number") and auto_cfg.charge_target >= 0 then
+ self.charge_setpoint = auto_cfg.charge_target * charge_scaler
end
- if (type(config.gen_target) == "number") and config.gen_target >= 0 then
- self.gen_rate_setpoint = config.gen_target * gen_scaler
+ if (type(auto_cfg.gen_target) == "number") and auto_cfg.gen_target >= 0 then
+ self.gen_rate_setpoint = auto_cfg.gen_target * gen_scaler
end
- if (type(config.limits) == "table") and (#config.limits == num_reactors) then
- for i = 1, num_reactors do
- local limit = config.limits[i]
+ if (type(auto_cfg.limits) == "table") and (#auto_cfg.limits == config.UnitCount) then
+ for i = 1, config.UnitCount do
+ local limit = auto_cfg.limits[i]
if (type(limit) == "number") and (limit >= 0.1) then
limits[i] = limit
@@ -1010,7 +1028,7 @@ function facility.new(num_reactors, cooling_conf)
---@param unit_id integer unit ID
---@param group integer group ID or 0 for independent
function public.set_group(unit_id, group)
- if (group >= 0 and group <= 4) and (unit_id > 0 and unit_id <= num_reactors) and self.mode == PROCESS.INACTIVE then
+ if (group >= 0 and group <= 4) and (unit_id > 0 and unit_id <= config.UnitCount) and self.mode == PROCESS.INACTIVE then
-- remove from old group if previously assigned
local old_group = self.group_map[unit_id]
if old_group ~= 0 then
diff --git a/supervisor/session/coordinator.lua b/supervisor/session/coordinator.lua
index 98edaca..c0e6482 100644
--- a/supervisor/session/coordinator.lua
+++ b/supervisor/session/coordinator.lua
@@ -17,9 +17,6 @@ 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
@@ -360,15 +357,7 @@ 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)
- 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
+ return self.conn_watchdog.is_timer(timer) and self.connected
end
-- close the connection
diff --git a/supervisor/session/svsessions.lua b/supervisor/session/svsessions.lua
index e191db6..7300162 100644
--- a/supervisor/session/svsessions.lua
+++ b/supervisor/session/svsessions.lua
@@ -201,7 +201,7 @@ function svsessions.init(nic, fp_ok, config, cooling_conf)
self.nic = nic
self.fp_ok = fp_ok
self.config = config
- self.facility = facility.new(config.UnitCount, cooling_conf)
+ self.facility = facility.new(config, cooling_conf)
end
-- find an RTU session by the computer ID
diff --git a/supervisor/startup.lua b/supervisor/startup.lua
index 806f7b0..1694ebe 100644
--- a/supervisor/startup.lua
+++ b/supervisor/startup.lua
@@ -21,7 +21,7 @@ local supervisor = require("supervisor.supervisor")
local svsessions = require("supervisor.session.svsessions")
-local SUPERVISOR_VERSION = "v1.3.4"
+local SUPERVISOR_VERSION = "v1.3.6"
local println = util.println
local println_ts = util.println_ts
diff --git a/supervisor/supervisor.lua b/supervisor/supervisor.lua
index 3d6d7c7..1d79e72 100644
--- a/supervisor/supervisor.lua
+++ b/supervisor/supervisor.lua
@@ -26,6 +26,7 @@ function supervisor.load_config()
config.CoolingConfig = settings.get("CoolingConfig")
config.FacilityTankMode = settings.get("FacilityTankMode")
config.FacilityTankDefs = settings.get("FacilityTankDefs")
+ config.ExtChargeIdling = settings.get("ExtChargeIdling")
config.SVR_Channel = settings.get("SVR_Channel")
config.PLC_Channel = settings.get("PLC_Channel")
@@ -58,6 +59,8 @@ function supervisor.load_config()
cfv.assert_type_int(config.FacilityTankMode)
cfv.assert_range(config.FacilityTankMode, 0, 8)
+ cfv.assert_type_bool(config.ExtChargeIdling)
+
cfv.assert_channel(config.SVR_Channel)
cfv.assert_channel(config.PLC_Channel)
cfv.assert_channel(config.RTU_Channel)
@@ -78,7 +81,7 @@ function supervisor.load_config()
if type(config.AuthKey) == "string" then
local len = string.len(config.AuthKey)
- cfv.assert_eq(len == 0 or len >= 8, true)
+ cfv.assert(len == 0 or len >= 8)
end
cfv.assert_type_int(config.LogMode)
diff --git a/supervisor/unit.lua b/supervisor/unit.lua
index afdf6f3..6fa4d0a 100644
--- a/supervisor/unit.lua
+++ b/supervisor/unit.lua
@@ -8,9 +8,6 @@ local logic = require("supervisor.unitlogic")
local plc = require("supervisor.session.plc")
local rsctl = require("supervisor.session.rsctl")
----@class reactor_control_unit
-local unit = {}
-
local WASTE_MODE = types.WASTE_MODE
local WASTE = types.WASTE_PRODUCT
local ALARM = types.ALARM
@@ -55,12 +52,22 @@ local AISTATE = {
---@field id ALARM alarm ID
---@field tier integer alarm urgency tier (0 = highest)
+-- burn rate to idle at
+local IDLE_RATE = 0.01
+
+---@class reactor_control_unit
+local unit = {}
+
-- create a new reactor unit
---@nodiscard
---@param reactor_id integer reactor unit number
---@param num_boilers integer number of boilers expected
---@param num_turbines integer number of turbines expected
-function unit.new(reactor_id, num_boilers, num_turbines)
+---@param ext_idle boolean extended idling mode
+function unit.new(reactor_id, num_boilers, num_turbines, ext_idle)
+ -- time (ms) to idle for auto idling
+ local IDLE_TIME = util.trinary(ext_idle, 60000, 10000)
+
---@class _unit_self
local self = {
r_id = reactor_id,
@@ -83,6 +90,9 @@ function unit.new(reactor_id, num_boilers, num_turbines)
emcool_opened = false,
-- auto control
auto_engaged = false,
+ auto_idle = false,
+ auto_idling = false,
+ auto_idle_start = 0,
auto_was_alarmed = false,
ramp_target_br100 = 0,
-- state tracking
@@ -98,6 +108,8 @@ function unit.new(reactor_id, num_boilers, num_turbines)
status_text = { "UNKNOWN", "awaiting connection..." },
-- logic for alarms
had_reactor = false,
+ turbine_flow_stable = false,
+ turbine_stability_data = {},
last_rate_change_ms = 0,
---@type rps_status
last_rps_trips = {
@@ -251,6 +263,7 @@ function unit.new(reactor_id, num_boilers, num_turbines)
table.insert(self.db.annunciator.TurbineOverSpeed, false)
table.insert(self.db.annunciator.GeneratorTrip, false)
table.insert(self.db.annunciator.TurbineTrip, false)
+ table.insert(self.turbine_stability_data, { time_state = 0, time_tanks = 0, rotation = 1 })
end
-- PRIVATE FUNCTIONS --
@@ -530,6 +543,13 @@ function unit.new(reactor_id, num_boilers, num_turbines)
-- re-engage auto lock if it reconnected without it
if self.auto_engaged and not self.plc_i.is_auto_locked() then self.plc_i.auto_lock(true) end
+
+ -- stop idling when completed
+ if self.auto_idling and (((util.time_ms() - self.auto_idle_start) > IDLE_TIME) or not self.auto_idle) then
+ log.info(util.c("UNIT ", self.r_id, ": completed idling period"))
+ self.auto_idling = false
+ self.plc_i.auto_set_burn(0, false)
+ end
end
-- update deltas
@@ -578,6 +598,23 @@ function unit.new(reactor_id, num_boilers, num_turbines)
end
end
+ -- set automatic control idling mode to change behavior when given a burn rate command of zero
+ -- - enabling it will hold the reactor at 0.01 mB/t for a period when commanded zero before disabling
+ -- - disabling it will stop the reactor when commanded zero
+ ---@param idle boolean true to enable, false to disable (and stop)
+ function public.auto_set_idle(idle)
+ if idle and not self.auto_idle then
+ self.auto_idling = false
+ self.auto_idle_start = 0
+ end
+
+ if idle ~= self.auto_idle then
+ log.debug(util.c("UNIT ", self.r_id, ": idling mode changed to ", idle))
+ end
+
+ self.auto_idle = idle
+ end
+
-- get the actual limit of this unit
-- if it is degraded or not ready, the limit will be 0
---@nodiscard
@@ -597,7 +634,35 @@ function unit.new(reactor_id, num_boilers, num_turbines)
if self.auto_engaged then
if self.plc_i ~= nil then
log.debug(util.c("UNIT ", self.r_id, ": commit br100 of ", self.db.control.br100, " with ramp set to ", ramp))
- self.plc_i.auto_set_burn(self.db.control.br100 / 100, ramp)
+
+ local rate = self.db.control.br100 / 100
+
+ if self.auto_idle then
+ if rate <= IDLE_RATE then
+ if self.auto_idle_start == 0 then
+ self.auto_idling = true
+ self.auto_idle_start = util.time_ms()
+ log.info(util.c("UNIT ", self.r_id, ": started idling at ", IDLE_RATE, " mB/t"))
+
+ rate = IDLE_RATE
+ elseif (util.time_ms() - self.auto_idle_start) > IDLE_TIME then
+ if self.auto_idling then
+ self.auto_idling = false
+ log.info(util.c("UNIT ", self.r_id, ": completed idling period"))
+ end
+ else
+ log.debug(util.c("UNIT ", self.r_id, ": continuing idle at ", IDLE_RATE, " mB/t"))
+
+ rate = IDLE_RATE
+ end
+ else
+ self.auto_idling = false
+ self.auto_idle_start = 0
+ end
+ end
+
+ self.plc_i.auto_set_burn(rate, ramp)
+
if ramp then self.ramp_target_br100 = self.db.control.br100 end
end
end
diff --git a/supervisor/unitlogic.lua b/supervisor/unitlogic.lua
index 134652c..ad2b522 100644
--- a/supervisor/unitlogic.lua
+++ b/supervisor/unitlogic.lua
@@ -39,6 +39,21 @@ local ALARM_LIMS = const.ALARM_LIMITS
---@class unit_logic_extension
local logic = {}
+-- compute Mekanism's rotation rate for a turbine
+---@param turbine turbinev_session_db
+local function turbine_rotation(turbine)
+ local build = turbine.build
+
+ local inner_vol = build.steam_cap / const.mek.TURBINE_GAS_PER_TANK
+ local disp_rate = (build.dispersers * const.mek.TURBINE_DISPERSER_FLOW) * inner_vol
+ local vent_rate = build.vents * const.mek.TURBINE_VENT_FLOW
+
+ local max_rate = math.min(disp_rate, vent_rate)
+ local flow = math.min(max_rate, turbine.tanks.steam.amount)
+
+ return (flow * (turbine.tanks.steam.amount / build.steam_cap)) / max_rate
+end
+
-- update the annunciator
---@param self _unit_self
function logic.update_annunciator(self)
@@ -81,6 +96,11 @@ function logic.update_annunciator(self)
-- some alarms wait until the burn rate has stabilized, so keep track of that
if math.abs(_get_dt(DT_KEYS.ReactorBurnR)) > 0 then
self.last_rate_change_ms = util.time_ms()
+ self.turbine_flow_stable = false
+
+ for t = 1, self.num_turbines do
+ self.turbine_stability_data[t] = { time_state = 0, time_tanks = 0, rotation = 1 }
+ end
end
-- record reactor stats
@@ -274,6 +294,7 @@ function logic.update_annunciator(self)
local total_flow_rate = 0
local total_input_rate = 0
local max_water_return_rate = 0
+ local turbines_stable = true
-- recompute blade count on the chance that it may have changed
self.db.control.blade_count = 0
@@ -282,12 +303,14 @@ function logic.update_annunciator(self)
for i = 1, #self.turbines do
local session = self.turbines[i] ---@type unit_session
local turbine = session.get_db() ---@type turbinev_session_db
+ local idx = session.get_device_idx()
annunc.RCSFault = annunc.RCSFault or (not turbine.formed) or session.is_faulted()
+ annunc.TurbineOnline[idx] = true
-- update ready state
- -- - must be formed
- -- - must have received build, state, and tanks at least once
+ -- - must be formed
+ -- - must have received build, state, and tanks at least once
turbines_ready = turbines_ready and turbine.formed and
(turbine.build.last_update > 0) and
(turbine.state.last_update > 0) and
@@ -296,11 +319,56 @@ function logic.update_annunciator(self)
total_flow_rate = total_flow_rate + turbine.state.flow_rate
total_input_rate = total_input_rate + turbine.state.steam_input_rate
max_water_return_rate = max_water_return_rate + turbine.build.max_water_output
+
self.db.control.blade_count = self.db.control.blade_count + turbine.build.blades
- annunc.TurbineOnline[session.get_device_idx()] = true
+ local last = self.turbine_stability_data[i]
+
+ if (not self.turbine_flow_stable) and (turbine.state.steam_input_rate > 0) then
+ local rotation = turbine_rotation(turbine)
+ local rotation_stable = false
+
+ -- see if data updated, and if so, check rotation speed change
+ -- minimal change indicates the turbine is converging on a flow rate
+ if last.time_tanks < turbine.tanks.last_update then
+ if last.time_tanks > 0 then
+ rotation_stable = math.abs(rotation - last.rotation) < 0.00000003
+ end
+
+ last.time_tanks = turbine.tanks.last_update
+ last.rotation = rotation
+ end
+
+ -- flow is stable if the flow rate is at the input rate or at the max (±1 mB/t)
+ local flow_stable = false
+ if last.time_state < turbine.state.last_update then
+ if (last.time_state > 0) and (turbine.state.flow_rate > 0) then
+ flow_stable = math.abs(turbine.state.flow_rate - math.min(turbine.state.steam_input_rate, turbine.build.max_flow_rate)) < 2
+ end
+
+ last.time_state = turbine.state.last_update
+ end
+
+ if rotation_stable then
+ log.debug(util.c("UNIT ", self.r_id, ": turbine ", idx, " reached rotational stability (", rotation, ")"))
+ end
+
+ if flow_stable then
+ log.debug(util.c("UNIT ", self.r_id, ": turbine ", idx, " reached flow stability (", turbine.state.flow_rate, " mB/t)"))
+ end
+
+ turbines_stable = turbines_stable and (rotation_stable or flow_stable)
+ else
+ last.time_state = 0
+ last.time_tanks = 0
+ last.rotation = 1
+
+ turbines_stable = false
+ end
end
+ self.turbine_flow_stable = self.turbine_flow_stable or turbines_stable
+
-- check for boil rate mismatch (> 4% error) either between reactor and turbine or boiler and turbine
annunc.BoilRateMismatch = math.abs(total_boil_rate - total_input_rate) > (0.04 * total_boil_rate)
@@ -508,11 +576,25 @@ function logic.update_alarms(self)
local rcs_trans = any_low or any_over or gen_trip or annunc.RCPTrip or annunc.MaxWaterReturnFeed
- -- annunciator indicators for these states may not indicate a real issue when:
- -- > flow is ramping up right after reactor start
- -- > flow is ramping down after reactor shutdown
- if ((util.time_ms() - self.last_rate_change_ms) > FLOW_STABILITY_DELAY_MS) and plc_cache.active then
- rcs_trans = rcs_trans or annunc.RCSFlowLow or annunc.BoilRateMismatch or annunc.CoolantFeedMismatch or annunc.SteamFeedMismatch
+ if plc_cache.active then
+ -- these conditions may not indicate an issue when flow is changing after a burn rate change
+ if self.num_boilers == 0 then
+ if (util.time_ms() - self.last_rate_change_ms) > FLOW_STABILITY_DELAY_MS then
+ rcs_trans = rcs_trans or annunc.BoilRateMismatch
+ end
+
+ if self.turbine_flow_stable then
+ rcs_trans = rcs_trans or annunc.RCSFlowLow or annunc.CoolantFeedMismatch or annunc.SteamFeedMismatch
+ end
+ else
+ if (util.time_ms() - self.last_rate_change_ms) > FLOW_STABILITY_DELAY_MS then
+ rcs_trans = rcs_trans or annunc.RCSFlowLow or annunc.BoilRateMismatch or annunc.CoolantFeedMismatch
+ end
+
+ if self.turbine_flow_stable then
+ rcs_trans = rcs_trans or annunc.SteamFeedMismatch
+ end
+ end
end
if _update_alarm_state(self, rcs_trans, self.alarms.RCSTransient) then
@@ -666,7 +748,9 @@ function logic.update_status_text(self)
elseif annunc.WasteLineOcclusion then
self.status_text[2] = "insufficient waste output rate"
elseif (util.time_ms() - self.last_rate_change_ms) <= FLOW_STABILITY_DELAY_MS then
- self.status_text[2] = "awaiting flow stability"
+ self.status_text[2] = "awaiting coolant flow stability"
+ elseif not self.turbine_flow_stable then
+ self.status_text[2] = "awaiting turbine flow stability"
else
self.status_text[2] = "system nominal"
end