diff --git a/coordinator/ui/components/unit_waiting.lua b/coordinator/ui/components/unit_waiting.lua deleted file mode 100644 index 3b1a846..0000000 --- a/coordinator/ui/components/unit_waiting.lua +++ /dev/null @@ -1,33 +0,0 @@ --- --- Reactor Unit Waiting Spinner --- - -local style = require("coordinator.ui.style") - -local core = require("graphics.core") - -local Div = require("graphics.elements.div") -local TextBox = require("graphics.elements.textbox") - -local WaitingAnim = require("graphics.elements.animations.waiting") - -local TEXT_ALIGN = core.graphics.TEXT_ALIGN - -local cpair = core.graphics.cpair - --- create a unit waiting view ----@param parent graphics_element parent ----@param y integer y offset -local function init(parent, y) - -- bounding box div - local root = Div{parent=parent,x=1,y=y,height=5} - - local waiting_x = math.floor(parent.width() / 2) - 2 - - TextBox{parent=root,text="Waiting for status...",alignment=TEXT_ALIGN.CENTER,y=1,height=1,fg_bg=cpair(colors.black,style.root.bkg)} - WaitingAnim{parent=root,x=waiting_x,y=3,fg_bg=cpair(colors.blue,style.root.bkg)} - - return root -end - -return init diff --git a/graphics/element.lua b/graphics/element.lua index d9bc489..cd69dd4 100644 --- a/graphics/element.lua +++ b/graphics/element.lua @@ -25,6 +25,7 @@ local element = {} ---|multi_button_args ---|push_button_args ---|radio_button_args +---|sidebar_args ---|spinbox_args ---|switch_button_args ---|alarm_indicator_light diff --git a/graphics/elements/animations/waiting.lua b/graphics/elements/animations/waiting.lua index 2b08092..a0d7b3e 100644 --- a/graphics/elements/animations/waiting.lua +++ b/graphics/elements/animations/waiting.lua @@ -85,7 +85,7 @@ local function waiting(args) if state >= 12 then state = 0 end if run_animation then - tcd.dispatch_unique(0.5, animate) + tcd.dispatch_unique(0.15, animate) end end diff --git a/graphics/elements/controls/sidebar.lua b/graphics/elements/controls/sidebar.lua new file mode 100644 index 0000000..885761d --- /dev/null +++ b/graphics/elements/controls/sidebar.lua @@ -0,0 +1,104 @@ +-- Sidebar Graphics Element + +local tcd = require("scada-common.tcallbackdsp") + +local element = require("graphics.element") + +---@class sidebar_tab +---@field char string character identifier +---@field color cpair tab colors (fg/bg) + +---@class sidebar_args +---@field tabs table sidebar tab options +---@field callback function function to call on tab change +---@field parent graphics_element +---@field id? string element id +---@field x? integer 1 if omitted +---@field y? integer 1 if omitted +---@field height? integer parent height if omitted +---@field fg_bg? cpair foreground/background colors + +-- new sidebar tab selector +---@param args sidebar_args +---@return graphics_element element, element_id id +local function sidebar(args) + assert(type(args.tabs) == "table", "graphics.elements.controls.sidebar: tabs is a required field") + assert(#args.tabs > 0, "graphics.elements.controls.sidebar: at least one tab is required") + assert(type(args.callback) == "function", "graphics.elements.controls.sidebar: callback is a required field") + + -- always 3 wide + args.width = 3 + + -- create new graphics element base object + local e = element.new(args) + + assert(e.frame.h >= (#args.tabs * 3), "graphics.elements.controls.sidebar: height insufficent to display all tabs") + + -- default to 1st tab + e.value = 1 + + -- show the button state + ---@param pressed boolean if the currently selected tab should appear as actively pressed + local function draw(pressed) + for i = 1, #args.tabs do + local tab = args.tabs[i] ---@type sidebar_tab + + local y = ((i - 1) * 3) + 1 + + e.window.setCursorPos(1, y) + + if pressed and e.value == i then + e.window.setTextColor(e.fg_bg.fgd) + e.window.setBackgroundColor(e.fg_bg.bkg) + else + e.window.setTextColor(tab.color.fgd) + e.window.setBackgroundColor(tab.color.bkg) + end + + e.window.write(" ") + e.window.setCursorPos(1, y + 1) + if e.value == i then + -- show as selected + e.window.write(" " .. tab.char .. "\x10") + else + -- show as unselected + e.window.write(" " .. tab.char .. " ") + end + e.window.setCursorPos(1, y + 2) + e.window.write(" ") + end + end + + -- handle mouse interaction + ---@param event mouse_interaction mouse event + function e.handle_mouse(event) + -- determine what was pressed + if e.enabled then + local idx = math.ceil(event.y / 3) + + if args.tabs[idx] ~= nil then + e.value = idx + draw(true) + + -- show as unpressed in 0.25 seconds + tcd.dispatch(0.25, function () draw(false) end) + + args.callback(e.value) + end + end + end + + -- set the value + ---@param val integer new value + function e.set_value(val) + e.value = val + draw(false) + end + + -- initial draw + draw(false) + + return e.get() +end + +return sidebar diff --git a/pocket/config.lua b/pocket/config.lua index e69de29..cacd9f1 100644 --- a/pocket/config.lua +++ b/pocket/config.lua @@ -0,0 +1,21 @@ +local config = {} + +-- port of the SCADA supervisor +config.SCADA_SV_PORT = 16100 +-- port for SCADA coordinator API access +config.SCADA_API_PORT = 16200 +-- port to listen to incoming packets FROM servers +config.LISTEN_PORT = 16201 +-- max trusted modem message distance (0 to disable check) +config.TRUSTED_RANGE = 0 +-- time in seconds (>= 2) before assuming a remote device is no longer active +config.COMMS_TIMEOUT = 5 + +-- log path +config.LOG_PATH = "/log.txt" +-- log mode +-- 0 = APPEND (adds to existing file on start) +-- 1 = NEW (replaces existing file on start) +config.LOG_MODE = 0 + +return config diff --git a/pocket/pocket.lua b/pocket/pocket.lua new file mode 100644 index 0000000..2f537a3 --- /dev/null +++ b/pocket/pocket.lua @@ -0,0 +1,5 @@ + + +local pocket = {} + +return pocket diff --git a/pocket/renderer.lua b/pocket/renderer.lua new file mode 100644 index 0000000..d394acc --- /dev/null +++ b/pocket/renderer.lua @@ -0,0 +1,78 @@ +-- +-- Graphics Rendering Control +-- + +local log = require("scada-common.log") +local util = require("scada-common.util") + +local main_view = require("pocket.ui.main") +local style = require("pocket.ui.style") + +local flasher = require("graphics.flasher") + +local renderer = {} + +local ui = { + view = nil +} + +-- start the coordinator GUI +function renderer.start_ui() + if ui.view == nil then + -- reset screen + term.setTextColor(colors.white) + term.setBackgroundColor(colors.black) + term.clear() + term.setCursorPos(1, 1) + + -- set overridden colors + for i = 1, #style.colors do + term.setPaletteColor(style.colors[i].c, style.colors[i].hex) + end + + -- start flasher callback task + flasher.run() + + -- init front panel view + ui.view = main_view(term.current()) + end +end + +-- close out the UI +function renderer.close_ui() + -- stop blinking indicators + flasher.clear() + + if ui.view ~= nil then + -- hide to stop animation callbacks + ui.view.hide() + end + + -- clear root UI elements + ui.view = nil + + -- restore colors + for i = 1, #style.colors do + local r, g, b = term.nativePaletteColor(style.colors[i].c) + term.setPaletteColor(style.colors[i].c, r, g, b) + end + + -- reset terminal + term.setTextColor(colors.white) + term.setBackgroundColor(colors.black) + term.clear() + term.setCursorPos(1, 1) +end + +-- is the UI ready? +---@nodiscard +---@return boolean ready +function renderer.ui_ready() return ui.view ~= nil end + +-- handle a mouse event +---@param event mouse_interaction +function renderer.handle_mouse(event) + ui.view.handle_mouse(event) +end + +return renderer diff --git a/pocket/startup.lua b/pocket/startup.lua index 032bba9..b869975 100644 --- a/pocket/startup.lua +++ b/pocket/startup.lua @@ -1,16 +1,172 @@ -- --- SCADA Coordinator Access on a Pocket Computer +-- SCADA System Access on a Pocket Computer -- require("/initenv").init_env() -local util = require("scada-common.util") +local crash = require("scada-common.crash") +local log = require("scada-common.log") +local ppm = require("scada-common.ppm") +local tcallbackdsp = require("scada-common.tcallbackdsp") +local util = require("scada-common.util") -local POCKET_VERSION = "alpha-v0.0.0" +local core = require("graphics.core") + +local config = require("pocket.config") +local pocket = require("pocket.pocket") +local renderer = require("pocket.renderer") + +local POCKET_VERSION = "alpha-v0.1.0" local print = util.print local println = util.println local print_ts = util.print_ts local println_ts = util.println_ts -println("Sorry, this isn't written yet :(") +---------------------------------------- +-- config validation +---------------------------------------- + +local cfv = util.new_validator() + +cfv.assert_port(config.SCADA_SV_PORT) +cfv.assert_port(config.SCADA_API_PORT) +cfv.assert_port(config.LISTEN_PORT) +cfv.assert_type_int(config.TRUSTED_RANGE) +cfv.assert_type_num(config.COMMS_TIMEOUT) +cfv.assert_min(config.COMMS_TIMEOUT, 2) +cfv.assert_type_str(config.LOG_PATH) +cfv.assert_type_int(config.LOG_MODE) + +assert(cfv.valid(), "bad config file: missing/invalid fields") + +---------------------------------------- +-- log init +---------------------------------------- + +log.init(config.LOG_PATH, config.LOG_MODE) + +log.info("========================================") +log.info("BOOTING pocket.startup " .. POCKET_VERSION) +log.info("========================================") + +crash.set_env("pocket", POCKET_VERSION) + +---------------------------------------- +-- main application +---------------------------------------- + +local function main() + ---------------------------------------- + -- system startup + ---------------------------------------- + + -- mount connected devices + ppm.mount_all() + + ---------------------------------------- + -- setup communications + ---------------------------------------- + + -- get the communications modem + local modem = ppm.get_wireless_modem() + if modem == nil then + println("startup> wireless modem not found: please craft the pocket computer with a wireless modem") + log.fatal("no wireless modem on startup") + return + end + + -- create connection watchdog + local conn_watchdog = util.new_watchdog(config.COMMS_TIMEOUT) + conn_watchdog.cancel() + log.debug("startup> conn watchdog created") + + -- start comms, open all channels + -- local pocket_comms = pocket.comms(POCKET_VERSION, modem, config.SCADA_SV_PORT, config.SCADA_API_PORT, + -- config.LISTEN_PORT, config.TRUSTED_RANGE, conn_watchdog) + -- log.debug("startup> comms init") + + -- base loop clock (2Hz, 10 ticks) + local MAIN_CLOCK = 0.5 + local loop_clock = util.new_clock(MAIN_CLOCK) + + ---------------------------------------- + -- start the UI + ---------------------------------------- + + local ui_ok, message = pcall(renderer.start_ui) + if not ui_ok then + renderer.close_ui() + println_ts(util.c("UI error: ", message)) + log.error(util.c("GUI crashed with error ", message)) + else + -- start clock + loop_clock.start() + end + + ---------------------------------------- + -- main event loop + ---------------------------------------- + + if ui_ok then + -- start connection watchdog + conn_watchdog.feed() + log.debug("startup> conn watchdog started") + end + + -- main event loop + while ui_ok do + local event, param1, param2, param3, param4, param5 = util.pull_event() + + -- handle event + if event == "timer" then + if loop_clock.is_clock(param1) then + -- main loop tick + loop_clock.start() + elseif conn_watchdog.is_timer(param1) then + -- supervisor watchdog timeout + log.info("server timeout") + + -- pocket_comms.close() + else + -- a non-clock/main watchdog timer event + + -- notify timer callback dispatcher + tcallbackdsp.handle(param1) + end + elseif event == "modem_message" then + -- got a packet + -- local packet = pocket_comms.parse_packet(param1, param2, param3, param4, param5) + -- pocket_comms.handle_packet(packet) + + -- -- check if it was a disconnect + -- if not pocket_comms.is_linked() then + -- log_comms("supervisor closed connection") + + -- -- close connection + -- pocket_comms.close() + -- end + elseif event == "mouse_click" then + -- handle a monitor touch event + renderer.handle_mouse(core.events.touch(param1, param2, param3)) + end + + -- check for termination request + if event == "terminate" or ppm.should_terminate() then + log.info("terminate requested, closing connections...") + -- pocket_comms.close() + log.info("supervisor connection closed") + break + end + end + + renderer.close_ui() + + println_ts("exited") + log.info("exited") +end + +if not xpcall(main, crash.handler) then + pcall(renderer.close_ui) + crash.exit() +end diff --git a/pocket/ui/components/conn_waiting.lua b/pocket/ui/components/conn_waiting.lua new file mode 100644 index 0000000..d83d09e --- /dev/null +++ b/pocket/ui/components/conn_waiting.lua @@ -0,0 +1,38 @@ +-- +-- Connection Waiting Spinner +-- + +local style = require("pocket.ui.style") + +local core = require("graphics.core") + +local Div = require("graphics.elements.div") +local TextBox = require("graphics.elements.textbox") + +local WaitingAnim = require("graphics.elements.animations.waiting") + +local TEXT_ALIGN = core.graphics.TEXT_ALIGN + +local cpair = core.graphics.cpair + +-- create a waiting view +---@param parent graphics_element parent +---@param y integer y offset +local function init(parent, y, is_api) + -- bounding box div + local root = Div{parent=parent,x=1,y=y,height=5} + + local waiting_x = math.floor(parent.width() / 2) - 1 + + if is_api then + TextBox{parent=root,text="Connecting to API",alignment=TEXT_ALIGN.CENTER,y=1,height=1,fg_bg=cpair(colors.white,style.root.bkg)} + WaitingAnim{parent=root,x=waiting_x,y=3,fg_bg=cpair(colors.blue,style.root.bkg)} + else + TextBox{parent=root,text="Connecting to Supervisor",alignment=TEXT_ALIGN.CENTER,y=1,height=1,fg_bg=cpair(colors.white,style.root.bkg)} + WaitingAnim{parent=root,x=waiting_x,y=3,fg_bg=cpair(colors.green,style.root.bkg)} + end + + return root +end + +return init diff --git a/pocket/ui/main.lua b/pocket/ui/main.lua new file mode 100644 index 0000000..94f6ca5 --- /dev/null +++ b/pocket/ui/main.lua @@ -0,0 +1,67 @@ +-- +-- Pocket GUI Root +-- + +local util = require("scada-common.util") + +local style = require("pocket.ui.style") + +local conn_waiting = require("pocket.ui.components.conn_waiting") + +local core = require("graphics.core") + +local ColorMap = require("graphics.elements.colormap") +local DisplayBox = require("graphics.elements.displaybox") +local Div = require("graphics.elements.div") +local TextBox = require("graphics.elements.textbox") + +local PushButton = require("graphics.elements.controls.push_button") +local SwitchButton = require("graphics.elements.controls.switch_button") +local Sidebar = require("graphics.elements.controls.sidebar") + +local DataIndicator = require("graphics.elements.indicators.data") + +local TEXT_ALIGN = core.graphics.TEXT_ALIGN + +local cpair = core.graphics.cpair + +-- create new main view +---@param monitor table main viewscreen +local function init(monitor) + local main = DisplayBox{window=monitor,fg_bg=style.root} + + -- window header message + local header = TextBox{parent=main,y=1,text="",alignment=TEXT_ALIGN.LEFT,height=1,fg_bg=style.header} + + -- local api_wait = conn_waiting(main, 8, true) + -- local sv_wait = conn_waiting(main, 8, false) + + 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) + } + } + + local sidebar = Sidebar{parent=main,x=1,y=2,tabs=sidebar_tabs,fg_bg=cpair(colors.white,colors.gray),callback=function()end} + + return main +end + +return init diff --git a/pocket/ui/style.lua b/pocket/ui/style.lua new file mode 100644 index 0000000..b9a09fc --- /dev/null +++ b/pocket/ui/style.lua @@ -0,0 +1,158 @@ +-- +-- Graphics Style Options +-- + +local core = require("graphics.core") + +local style = {} + +local cpair = core.graphics.cpair + +-- GLOBAL -- + +style.root = cpair(colors.white, colors.black) +style.header = cpair(colors.white, colors.gray) +style.label = cpair(colors.gray, colors.lightGray) + +style.colors = { + { c = colors.red, hex = 0xdf4949 }, + { c = colors.orange, hex = 0xffb659 }, + { c = colors.yellow, hex = 0xfffc79 }, + { c = colors.lime, hex = 0x80ff80 }, + { c = colors.green, hex = 0x4aee8a }, + { c = colors.cyan, hex = 0x34bac8 }, + { c = colors.lightBlue, hex = 0x6cc0f2 }, + { c = colors.blue, hex = 0x0096ff }, + { c = colors.purple, hex = 0xb156ee }, + { c = colors.pink, hex = 0xf26ba2 }, + { c = colors.magenta, hex = 0xf9488a }, + -- { c = colors.white, hex = 0xf0f0f0 }, + { c = colors.lightGray, hex = 0xcacaca }, + { c = colors.gray, hex = 0x575757 }, + -- { c = colors.black, hex = 0x191919 }, + -- { c = colors.brown, hex = 0x7f664c } +} + +-- MAIN LAYOUT -- + +style.reactor = { + -- reactor states + states = { + { + color = cpair(colors.black, colors.yellow), + text = "PLC OFF-LINE" + }, + { + color = cpair(colors.black, colors.orange), + text = "NOT FORMED" + }, + { + color = cpair(colors.black, colors.orange), + text = "PLC FAULT" + }, + { + color = cpair(colors.white, colors.gray), + text = "DISABLED" + }, + { + color = cpair(colors.black, colors.green), + text = "ACTIVE" + }, + { + color = cpair(colors.black, colors.red), + text = "SCRAMMED" + }, + { + color = cpair(colors.black, colors.red), + text = "FORCE DISABLED" + } + } +} + +style.boiler = { + -- boiler states + states = { + { + color = cpair(colors.black, colors.yellow), + text = "OFF-LINE" + }, + { + color = cpair(colors.black, colors.orange), + text = "NOT FORMED" + }, + { + color = cpair(colors.black, colors.orange), + text = "RTU FAULT" + }, + { + color = cpair(colors.white, colors.gray), + text = "IDLE" + }, + { + color = cpair(colors.black, colors.green), + text = "ACTIVE" + } + } +} + +style.turbine = { + -- turbine states + states = { + { + color = cpair(colors.black, colors.yellow), + text = "OFF-LINE" + }, + { + color = cpair(colors.black, colors.orange), + text = "NOT FORMED" + }, + { + color = cpair(colors.black, colors.orange), + text = "RTU FAULT" + }, + { + color = cpair(colors.white, colors.gray), + text = "IDLE" + }, + { + color = cpair(colors.black, colors.green), + text = "ACTIVE" + }, + { + color = cpair(colors.black, colors.red), + text = "TRIP" + } + } +} + +style.imatrix = { + -- induction matrix states + states = { + { + color = cpair(colors.black, colors.yellow), + text = "OFF-LINE" + }, + { + color = cpair(colors.black, colors.orange), + text = "NOT FORMED" + }, + { + color = cpair(colors.black, colors.orange), + text = "RTU FAULT" + }, + { + color = cpair(colors.black, colors.green), + text = "ONLINE" + }, + { + color = cpair(colors.black, colors.yellow), + text = "LOW CHARGE" + }, + { + color = cpair(colors.black, colors.yellow), + text = "HIGH CHARGE" + }, + } +} + +return style