Merge pull request #196 from MikaylaFischler/front-panels

PLC Front Panel
This commit is contained in:
Mikayla 2023-04-08 22:01:09 -04:00 committed by GitHub
commit c9b67f68dd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 902 additions and 97 deletions

View File

@ -171,15 +171,15 @@ end
function renderer.ui_ready() return engine.ui_ready end
-- handle a touch event
---@param event monitor_touch
function renderer.handle_touch(event)
---@param event mouse_interaction
function renderer.handle_mouse(event)
if event.monitor == engine.monitors.primary_name then
ui.main_layout.handle_touch(event)
ui.main_layout.handle_mouse(event)
else
for id, monitor in pairs(engine.monitors.unit_name_map) do
if event.monitor == monitor then
local layout = ui.unit_layouts[id] ---@type graphics_element
layout.handle_touch(event)
layout.handle_mouse(event)
end
end
end

View File

@ -19,7 +19,7 @@ local iocontrol = require("coordinator.iocontrol")
local renderer = require("coordinator.renderer")
local sounder = require("coordinator.sounder")
local COORDINATOR_VERSION = "v0.12.3"
local COORDINATOR_VERSION = "v0.12.4"
local print = util.print
local println = util.println
@ -354,7 +354,7 @@ local function main()
end
elseif event == "monitor_touch" then
-- handle a monitor touch event
renderer.handle_touch(core.events.touch(param1, param2, param3))
renderer.handle_mouse(core.events.touch(param1, param2, param3))
elseif event == "speaker_audio_empty" then
-- handle speaker buffer emptied
sounder.continue()

View File

@ -10,20 +10,76 @@ core.flasher = flasher
local events = {}
---@class monitor_touch
---@enum click_type
events.click_type = {
VIRTUAL = 0,
LEFT_BUTTON = 1,
RIGHT_BUTTON = 2,
MID_BUTTON = 3
}
---@class mouse_interaction
---@field monitor string
---@field button integer
---@field x integer
---@field y integer
-- create a new touch event definition
-- create a new monitor touch mouse interaction event
---@nodiscard
---@param monitor string
---@param x integer
---@param y integer
---@return monitor_touch
---@return mouse_interaction
function events.touch(monitor, x, y)
return {
monitor = monitor,
button = events.click_type.LEFT_BUTTON,
x = x,
y = y
}
end
-- create a new mouse click mouse interaction event
---@nodiscard
---@param button click_type
---@param x integer
---@param y integer
---@return mouse_interaction
function events.click(button, x, y)
return {
monitor = "terminal",
button = button,
x = x,
y = y
}
end
-- create a new transposed mouse interaction event using the event's monitor/button fields
---@nodiscard
---@param event mouse_interaction
---@param new_x integer
---@param new_y integer
---@return mouse_interaction
function events.mouse_transposed(event, new_x, new_y)
return {
monitor = event.monitor,
button = event.button,
x = new_x,
y = new_y
}
end
-- create a new generic mouse interaction event
---@nodiscard
---@param monitor string
---@param button click_type
---@param x integer
---@param y integer
---@return mouse_interaction
function events.mouse_generic(monitor, button, x, y)
return {
monitor = monitor,
button = button,
x = x,
y = y
}

View File

@ -32,6 +32,9 @@ local element = {}
---|data_indicator_args
---|hbar_args
---|icon_indicator_args
---|indicator_led_args
---|indicator_led_pair_args
---|indicator_led_rgb_args
---|indicator_light_args
---|power_indicator_args
---|rad_indicator_args
@ -100,7 +103,13 @@ function element.new(args)
else
local w, h = self.p_window.getSize()
protected.frame.x = args.x or 1
if args.parent ~= nil then
protected.frame.y = args.y or (next_y - offset_y)
else
protected.frame.y = args.y or next_y
end
protected.frame.w = args.width or w
protected.frame.h = args.height or h
end
@ -157,9 +166,9 @@ function element.new(args)
self.bounds.y2 = self.position.y + f.h - 1
end
-- handle a touch event
---@param event table monitor_touch event
function protected.handle_touch(event)
-- handle a mouse event
---@param event mouse_interaction mouse interaction event
function protected.handle_mouse(event)
end
-- handle data value changes
@ -260,6 +269,11 @@ function element.new(args)
---@param child graphics_template
---@return integer|string key
function public.__add_child(key, child)
-- offset first automatic placement
if self.next_y <= self.child_offset.y then
self.next_y = self.child_offset.y + 1
end
child.prepare_template(self.child_offset.x, self.child_offset.y, self.next_y)
self.next_y = child.frame.y + child.frame.h
@ -396,20 +410,20 @@ function element.new(args)
-- FUNCTION CALLBACKS --
-- handle a monitor touch
---@param event monitor_touch monitor touch event
function public.handle_touch(event)
-- handle a monitor touch or mouse click
---@param event mouse_interaction mouse interaction event
function public.handle_mouse(event)
local in_x = event.x >= self.bounds.x1 and event.x <= self.bounds.x2
local in_y = event.y >= self.bounds.y1 and event.y <= self.bounds.y2
if in_x and in_y then
local event_T = core.events.touch(event.monitor, (event.x - self.position.x) + 1, (event.y - self.position.y) + 1)
local event_T = core.events.mouse_transposed(event, (event.x - self.position.x) + 1, (event.y - self.position.y) + 1)
-- handle the touch event, transformed into the window frame
protected.handle_touch(event_T)
protected.handle_mouse(event_T)
-- pass on touch event to children
for _, val in pairs(self.children) do val.handle_touch(event_T) end
for _, val in pairs(self.children) do val.handle_mouse(event_T) end
end
end

View File

@ -140,10 +140,10 @@ local function hazard_button(args)
end
end
-- handle touch
---@param event monitor_touch monitor touch event
-- handle mouse interaction
---@param event mouse_interaction mouse event
---@diagnostic disable-next-line: unused-local
function e.handle_touch(event)
function e.handle_mouse(event)
if e.enabled then
-- change text color to indicate clicked
e.window.setTextColor(args.accent)
@ -178,7 +178,7 @@ local function hazard_button(args)
-- set the value (true simulates pressing the button)
---@param val boolean new value
function e.set_value(val)
if val then e.handle_touch(core.events.touch("", 1, 1)) end
if val then e.handle_mouse(core.events.mouse_generic("", core.events.click_type.VIRTUAL, 1, 1)) end
end
-- show the button as disabled

View File

@ -92,9 +92,10 @@ local function multi_button(args)
end
end
-- handle touch
---@param event monitor_touch monitor touch event
function e.handle_touch(event)
-- handle mouse interaction
---@param event mouse_interaction mouse event
---@diagnostic disable-next-line: unused-local
function e.handle_mouse(event)
-- determine what was pressed
if e.enabled and event.y == 1 then
for i = 1, #args.options do

View File

@ -8,7 +8,7 @@ local element = require("graphics.element")
---@class push_button_args
---@field text string button text
---@field callback function function to call on touch
---@field min_width? integer text length + 2 if omitted
---@field min_width? integer text length if omitted
---@field active_fg_bg? cpair foreground/background colors when pressed
---@field dis_fg_bg? cpair foreground/background colors when disabled
---@field parent graphics_element
@ -47,10 +47,10 @@ local function push_button(args)
e.window.write(args.text)
end
-- handle touch
---@param event monitor_touch monitor touch event
-- handle mouse interaction
---@param event mouse_interaction mouse event
---@diagnostic disable-next-line: unused-local
function e.handle_touch(event)
function e.handle_mouse(event)
if e.enabled then
if args.active_fg_bg ~= nil then
-- show as pressed
@ -78,7 +78,7 @@ local function push_button(args)
-- set the value (true simulates pressing the button)
---@param val boolean new value
function e.set_value(val)
if val then e.handle_touch(core.events.touch("", 1, 1)) end
if val then e.handle_mouse(core.events.mouse_generic("", core.events.click_type.VIRTUAL, 1, 1)) end
end
-- show butten as enabled

View File

@ -79,9 +79,9 @@ local function radio_button(args)
end
end
-- handle touch
---@param event monitor_touch monitor touch event
function e.handle_touch(event)
-- handle mouse interaction
---@param event mouse_interaction mouse event
function e.handle_mouse(event)
-- determine what was pressed
if e.enabled then
if args.options[event.y] ~= nil then

View File

@ -127,9 +127,9 @@ local function spinbox(args)
-- init with the default value
show_num()
-- handle touch
---@param event monitor_touch monitor touch event
function e.handle_touch(event)
-- handle mouse interaction
---@param event mouse_interaction mouse event
function e.handle_mouse(event)
-- only handle if on an increment or decrement arrow
if e.enabled and event.x ~= dec_point_x then
local idx = util.trinary(event.x > dec_point_x, event.x - 1, event.x)

View File

@ -62,10 +62,10 @@ local function switch_button(args)
-- initial draw
draw_state()
-- handle touch
---@param event monitor_touch monitor touch event
-- handle mouse interaction
---@param event mouse_interaction mouse event
---@diagnostic disable-next-line: unused-local
function e.handle_touch(event)
function e.handle_mouse(event)
if e.enabled then
-- toggle state
e.value = not e.value

View File

@ -0,0 +1,98 @@
-- Indicator "LED" Graphics Element
local util = require("scada-common.util")
local element = require("graphics.element")
local flasher = require("graphics.flasher")
---@class indicator_led_args
---@field label string indicator label
---@field colors cpair on/off colors (a/b respectively)
---@field min_label_width? integer label length if omitted
---@field flash? boolean whether to flash on true rather than stay on
---@field period? PERIOD flash period
---@field parent graphics_element
---@field id? string element id
---@field x? integer 1 if omitted
---@field y? integer 1 if omitted
---@field fg_bg? cpair foreground/background colors
-- new indicator LED
---@nodiscard
---@param args indicator_led_args
---@return graphics_element element, element_id id
local function indicator_led(args)
assert(type(args.label) == "string", "graphics.elements.indicators.led: label is a required field")
assert(type(args.colors) == "table", "graphics.elements.indicators.led: colors is a required field")
if args.flash then
assert(util.is_int(args.period), "graphics.elements.indicators.led: period is a required field if flash is enabled")
end
-- single line
args.height = 1
-- determine width
args.width = math.max(args.min_label_width or 1, string.len(args.label)) + 2
-- flasher state
local flash_on = true
-- create new graphics element base object
local e = element.new(args)
-- called by flasher when enabled
local function flash_callback()
e.window.setCursorPos(1, 1)
if flash_on then
e.window.blit("\x8c", args.colors.blit_a, e.fg_bg.blit_bkg)
else
e.window.blit("\x8c", args.colors.blit_b, e.fg_bg.blit_bkg)
end
flash_on = not flash_on
end
-- enable light or start flashing
local function enable()
if args.flash then
flash_on = true
flasher.start(flash_callback, args.period)
else
e.window.setCursorPos(1, 1)
e.window.blit("\x8c", args.colors.blit_a, e.fg_bg.blit_bkg)
end
end
-- disable light or stop flashing
local function disable()
if args.flash then
flash_on = false
flasher.stop(flash_callback)
end
e.window.setCursorPos(1, 1)
e.window.blit("\x8c", args.colors.blit_b, e.fg_bg.blit_bkg)
end
-- on state change
---@param new_state boolean indicator state
function e.on_update(new_state)
e.value = new_state
if new_state then enable() else disable() end
end
-- set indicator state
---@param val boolean indicator state
function e.set_value(val) e.on_update(val) end
-- write label and initial indicator light
e.on_update(false)
e.window.setCursorPos(3, 1)
e.window.write(args.label)
return e.get()
end
return indicator_led

View File

@ -0,0 +1,112 @@
-- Indicator LED Pair Graphics Element (two LEDs provide: off, color_a, color_b)
local util = require("scada-common.util")
local element = require("graphics.element")
local flasher = require("graphics.flasher")
---@class indicator_led_pair_args
---@field label string indicator label
---@field off color color for off
---@field c1 color color for #1 on
---@field c2 color color for #2 on
---@field min_label_width? integer label length if omitted
---@field flash? boolean whether to flash when on rather than stay on
---@field period? PERIOD flash period
---@field parent graphics_element
---@field id? string element id
---@field x? integer 1 if omitted
---@field y? integer 1 if omitted
---@field fg_bg? cpair foreground/background colors
-- new dual LED indicator light
---@nodiscard
---@param args indicator_led_pair_args
---@return graphics_element element, element_id id
local function indicator_led_pair(args)
assert(type(args.label) == "string", "graphics.elements.indicators.ledpair: label is a required field")
assert(type(args.off) == "number", "graphics.elements.indicators.ledpair: off is a required field")
assert(type(args.c1) == "number", "graphics.elements.indicators.ledpair: c1 is a required field")
assert(type(args.c2) == "number", "graphics.elements.indicators.ledpair: c2 is a required field")
if args.flash then
assert(util.is_int(args.period), "graphics.elements.indicators.ledpair: period is a required field if flash is enabled")
end
-- single line
args.height = 1
-- determine width
args.width = math.max(args.min_label_width or 1, string.len(args.label)) + 2
-- flasher state
local flash_on = true
-- blit translations
local co = colors.toBlit(args.off)
local c1 = colors.toBlit(args.c1)
local c2 = colors.toBlit(args.c2)
-- create new graphics element base object
local e = element.new(args)
-- init value for initial check in on_update
e.value = 1
-- called by flasher when enabled
local function flash_callback()
e.window.setCursorPos(1, 1)
if flash_on then
if e.value == 2 then
e.window.blit("\x8c", c1, e.fg_bg.blit_bkg)
elseif e.value == 3 then
e.window.blit("\x8c", c2, e.fg_bg.blit_bkg)
end
else
e.window.blit("\x8c", co, e.fg_bg.blit_bkg)
end
flash_on = not flash_on
end
-- on state change
---@param new_state integer indicator state
function e.on_update(new_state)
local was_off = e.value <= 1
e.value = new_state
e.window.setCursorPos(1, 1)
if args.flash then
if was_off and (new_state > 1) then
flash_on = true
flasher.start(flash_callback, args.period)
elseif new_state <= 1 then
flash_on = false
flasher.stop(flash_callback)
e.window.blit("\x8c", co, e.fg_bg.blit_bkg)
end
elseif new_state == 2 then
e.window.blit("\x8c", c1, e.fg_bg.blit_bkg)
elseif new_state == 3 then
e.window.blit("\x8c", c2, e.fg_bg.blit_bkg)
else
e.window.blit("\x8c", co, e.fg_bg.blit_bkg)
end
end
-- set indicator state
---@param val integer indicator state
function e.set_value(val) e.on_update(val) end
-- write label and initial indicator light
e.on_update(1)
e.window.setCursorPos(3, 1)
e.window.write(args.label)
return e.get()
end
return indicator_led_pair

View File

@ -0,0 +1,57 @@
-- Indicator RGB LED Graphics Element
local element = require("graphics.element")
---@class indicator_led_rgb_args
---@field label string indicator label
---@field colors table colors to use
---@field min_label_width? integer label length if omitted
---@field parent graphics_element
---@field id? string element id
---@field x? integer 1 if omitted
---@field y? integer 1 if omitted
---@field fg_bg? cpair foreground/background colors
-- new RGB LED indicator light
---@nodiscard
---@param args indicator_led_rgb_args
---@return graphics_element element, element_id id
local function indicator_led_rgb(args)
assert(type(args.label) == "string", "graphics.elements.indicators.ledrgb: label is a required field")
assert(type(args.colors) == "table", "graphics.elements.indicators.ledrgb: colors is a required field")
-- single line
args.height = 1
-- determine width
args.width = math.max(args.min_label_width or 1, string.len(args.label)) + 2
-- create new graphics element base object
local e = element.new(args)
-- init value for initial check in on_update
e.value = 1
-- on state change
---@param new_state integer indicator state
function e.on_update(new_state)
e.value = new_state
e.window.setCursorPos(1, 1)
if type(args.colors[new_state]) == "number" then
e.window.blit("\x8c", colors.toBlit(args.colors[new_state]), e.fg_bg.blit_bkg)
end
end
-- set indicator state
---@param val integer indicator state
function e.set_value(val) e.on_update(val) end
-- write label and initial indicator light
e.on_update(1)
e.window.setCursorPos(3, 1)
e.window.write(args.label)
return e.get()
end
return indicator_led_rgb

View File

@ -7,6 +7,7 @@ local element = require("graphics.element")
---@class rectangle_args
---@field border? graphics_border
---@field thin? boolean true to use extra thin even borders
---@field even_inner? boolean true to make the inner area of a border even
---@field parent graphics_element
---@field id? string element id
---@field x? integer 1 if omitted
@ -66,14 +67,27 @@ local function rectangle(args)
local blit_bg_top_bot = util.strrep(border_blit, e.frame.w)
-- partial bars
local p_a = util.spaces(border_width) .. util.strrep("\x8f", inner_width) .. util.spaces(border_width)
local p_b = util.spaces(border_width) .. util.strrep("\x83", inner_width) .. util.spaces(border_width)
local p_s = spaces
local p_a, p_b, p_s
if args.thin == true then
if args.even_inner == true then
p_a = "\x9c" .. util.strrep("\x8c", inner_width) .. "\x93"
p_b = "\x8d" .. util.strrep("\x8c", inner_width) .. "\x8e"
else
p_a = "\x97" .. util.strrep("\x83", inner_width) .. "\x94"
p_b = "\x8a" .. util.strrep("\x8f", inner_width) .. "\x85"
end
p_s = "\x95" .. util.spaces(inner_width) .. "\x95"
else
if args.even_inner == true then
p_a = util.strrep("\x83", inner_width + width_x2)
p_b = util.strrep("\x8f", inner_width + width_x2)
else
p_a = util.spaces(border_width) .. util.strrep("\x83", inner_width) .. util.spaces(border_width)
p_b = util.spaces(border_width) .. util.strrep("\x8f", inner_width) .. util.spaces(border_width)
end
p_s = spaces
end
local p_inv_fg = util.strrep(border_blit, border_width) .. util.strrep(e.fg_bg.blit_bkg, inner_width) ..
@ -112,10 +126,13 @@ local function rectangle(args)
if args.thin == true then
e.window.blit(p_a, p_inv_bg, p_inv_fg)
else
local _fg = util.trinary(args.even_inner == true, util.strrep(e.fg_bg.blit_bkg, e.frame.w), p_inv_bg)
local _bg = util.trinary(args.even_inner == true, blit_bg_top_bot, p_inv_fg)
if width_x2 % 3 == 1 then
e.window.blit(p_b, p_inv_bg, p_inv_fg)
e.window.blit(p_b, _fg, _bg)
elseif width_x2 % 3 == 2 then
e.window.blit(p_a, p_inv_bg, p_inv_fg)
e.window.blit(p_a, _fg, _bg)
else
-- skip line
e.window.blit(spaces, blit_fg, blit_bg_sides)
@ -129,12 +146,19 @@ local function rectangle(args)
-- partial pixel fill
if args.border.even and y == ((e.frame.h - border_width) + 1) then
if args.thin == true then
e.window.blit(p_b, util.strrep(e.fg_bg.blit_bkg, e.frame.w), blit_bg_top_bot)
if args.even_inner == true then
e.window.blit(p_b, blit_bg_top_bot, util.strrep(e.fg_bg.blit_bkg, e.frame.w))
else
e.window.blit(p_b, util.strrep(e.fg_bg.blit_bkg, e.frame.w), blit_bg_top_bot)
end
else
local _fg = util.trinary(args.even_inner == true, blit_bg_top_bot, p_inv_fg)
local _bg = util.trinary(args.even_inner == true, util.strrep(e.fg_bg.blit_bkg, e.frame.w), blit_bg_top_bot)
if width_x2 % 3 == 1 then
e.window.blit(p_a, p_inv_fg, blit_bg_top_bot)
elseif width_x2 % 3 == 2 or (args.thin == true) then
e.window.blit(p_b, p_inv_fg, blit_bg_top_bot)
e.window.blit(p_a, _fg, _bg)
elseif width_x2 % 3 == 2 then
e.window.blit(p_b, _fg, _bg)
else
-- skip line
e.window.blit(spaces, blit_fg, blit_bg_sides)

View File

@ -68,7 +68,7 @@ def make_manifest(size):
"pocket" : list_files("./pocket"),
},
"depends" : {
"reactor-plc" : [ "system", "common" ],
"reactor-plc" : [ "system", "common", "graphics" ],
"rtu" : [ "system", "common" ],
"supervisor" : [ "system", "common" ],
"coordinator" : [ "system", "common", "graphics" ],

101
reactor-plc/databus.lua Normal file
View File

@ -0,0 +1,101 @@
--
-- Data Bus - Central Communication Linking for PLC Front Panel
--
local log = require("scada-common.log")
local psil = require("scada-common.psil")
local util = require("scada-common.util")
local databus = {}
local dbus_iface = {
ps = psil.create(),
rps_scram = function () log.debug("DBUS: unset rps_scram() called") end,
rps_reset = function () log.debug("DBUS: unset rps_reset() called") end
}
-- call to toggle heartbeat signal
function databus.heartbeat() dbus_iface.ps.toggle("heartbeat") end
-- link RPS command functions
---@param scram function reactor SCRAM function
---@param reset function RPS reset function
function databus.link_rps(scram, reset)
dbus_iface.rps_scram = scram
dbus_iface.rps_reset = reset
end
-- transmit a command to the RPS to SCRAM
function databus.rps_scram() dbus_iface.rps_scram() end
-- transmit a command to the RPS to reset
function databus.rps_reset() dbus_iface.rps_reset() end
-- transmit firmware versions across the bus
---@param plc_v string PLC version
---@param comms_v string comms version
function databus.tx_versions(plc_v, comms_v)
dbus_iface.ps.publish("version", plc_v)
dbus_iface.ps.publish("comms_version", comms_v)
end
-- transmit unit ID across the bus
---@param id integer unit ID
function databus.tx_id(id)
dbus_iface.ps.publish("unit_id", id)
end
-- transmit hardware status across the bus
---@param plc_state plc_state
function databus.tx_hw_status(plc_state)
dbus_iface.ps.publish("reactor_dev_state", util.trinary(plc_state.no_reactor, 1, util.trinary(plc_state.reactor_formed, 3, 2)))
dbus_iface.ps.publish("has_modem", not plc_state.no_modem)
dbus_iface.ps.publish("degraded", plc_state.degraded)
dbus_iface.ps.publish("init_ok", plc_state.init_ok)
end
-- transmit thread (routine) statuses
---@param thread string thread name
---@param ok boolean thread state
function databus.tx_rt_status(thread, ok)
dbus_iface.ps.publish(util.c("routine__", thread), ok)
end
-- transmit supervisor link state across the bus
---@param state integer
function databus.tx_link_state(state)
dbus_iface.ps.publish("link_state", state)
end
-- transmit reactor enable state across the bus
---@param active boolean reactor active
function databus.tx_reactor_state(active)
dbus_iface.ps.publish("reactor_active", active)
end
-- transmit RPS data across the bus
---@param tripped boolean RPS tripped
---@param status table RPS status
function databus.tx_rps(tripped, status)
dbus_iface.ps.publish("rps_scram", tripped)
dbus_iface.ps.publish("rps_damage", status[1])
dbus_iface.ps.publish("rps_high_temp", status[2])
dbus_iface.ps.publish("rps_low_ccool", status[3])
dbus_iface.ps.publish("rps_high_waste", status[4])
dbus_iface.ps.publish("rps_high_hcool", status[5])
dbus_iface.ps.publish("rps_no_fuel", status[6])
dbus_iface.ps.publish("rps_fault", status[7])
dbus_iface.ps.publish("rps_timeout", status[8])
dbus_iface.ps.publish("rps_manual", status[9])
dbus_iface.ps.publish("rps_automatic", status[10])
dbus_iface.ps.publish("rps_sysfail", status[11])
end
-- link a function to receive data from the bus
---@param field string field name
---@param func function function to link
function databus.rx_field(field, func)
dbus_iface.ps.subscribe(field, func)
end
return databus

View File

@ -0,0 +1,124 @@
--
-- Main SCADA Coordinator GUI
--
local util = require("scada-common.util")
local databus = require("reactor-plc.databus")
local style = require("reactor-plc.panel.style")
local core = require("graphics.core")
local flasher = require("graphics.flasher")
local DisplayBox = require("graphics.elements.displaybox")
local Div = require("graphics.elements.div")
local Rectangle = require("graphics.elements.rectangle")
local TextBox = require("graphics.elements.textbox")
local PushButton = require("graphics.elements.controls.push_button")
local LED = require("graphics.elements.indicators.led")
local LEDPair = require("graphics.elements.indicators.ledpair")
local RGBLED = require("graphics.elements.indicators.ledrgb")
local TEXT_ALIGN = core.graphics.TEXT_ALIGN
local cpair = core.graphics.cpair
local border = core.graphics.border
-- create new main view
---@param monitor table main viewscreen
local function init(monitor)
local panel = DisplayBox{window=monitor,fg_bg=style.root}
local header = TextBox{parent=panel,y=1,text="REACTOR PLC - UNIT ?",alignment=TEXT_ALIGN.CENTER,height=1,fg_bg=style.header}
databus.rx_field("unit_id", function (id) header.set_value(util.c("REACTOR PLC - UNIT ", id)) end)
local system = Div{parent=panel,width=14,height=18,x=2,y=3}
local init_ok = LED{parent=system,label="STATUS",colors=cpair(colors.green,colors.red)}
local heartbeat = LED{parent=system,label="HEARTBEAT",colors=cpair(colors.green,colors.green_off)}
system.line_break()
databus.rx_field("init_ok", init_ok.update)
databus.rx_field("heartbeat", heartbeat.update)
local reactor = LEDPair{parent=system,label="REACTOR",off=colors.red,c1=colors.yellow,c2=colors.green}
local modem = LED{parent=system,label="MODEM",colors=cpair(colors.green,colors.green_off)}
local network = RGBLED{parent=system,label="NETWORK",colors={colors.green,colors.red,colors.orange,colors.yellow,colors.gray}}
network.update(5)
system.line_break()
databus.rx_field("reactor_dev_state", reactor.update)
databus.rx_field("has_modem", modem.update)
databus.rx_field("link_state", network.update)
local rt_main = LED{parent=system,label="RT MAIN",colors=cpair(colors.green,colors.green_off)}
local rt_rps = LED{parent=system,label="RT RPS",colors=cpair(colors.green,colors.green_off)}
local rt_cmtx = LED{parent=system,label="RT COMMS TX",colors=cpair(colors.green,colors.green_off)}
local rt_cmrx = LED{parent=system,label="RT COMMS RX",colors=cpair(colors.green,colors.green_off)}
local rt_sctl = LED{parent=system,label="RT SPCTL",colors=cpair(colors.green,colors.green_off)}
system.line_break()
databus.rx_field("routine__main", rt_main.update)
databus.rx_field("routine__rps", rt_rps.update)
databus.rx_field("routine__comms_tx", rt_cmtx.update)
databus.rx_field("routine__comms_rx", rt_cmrx.update)
databus.rx_field("routine__spctl", rt_sctl.update)
local status = Div{parent=panel,width=19,height=18,x=17,y=3}
local active = LED{parent=status,x=2,width=12,label="RCT ACTIVE",colors=cpair(colors.green,colors.green_off)}
local status_trip_rct = Rectangle{parent=status,width=20,height=3,x=1,y=2,border=border(1,colors.lightGray,true),even_inner=true,fg_bg=cpair(colors.black,colors.ivory)}
local status_trip = Div{parent=status_trip_rct,width=18,height=1,fg_bg=cpair(colors.black,colors.lightGray)}
local scram = LED{parent=status_trip,width=10,label="RPS TRIP",colors=cpair(colors.red,colors.red_off),flash=true,period=flasher.PERIOD.BLINK_250_MS}
local controls_rct = Rectangle{parent=status,width=17,height=3,x=1,y=5,border=border(1,colors.white,true),even_inner=true,fg_bg=cpair(colors.black,colors.ivory)}
local controls = Div{parent=controls_rct,width=15,height=1,fg_bg=cpair(colors.black,colors.white)}
PushButton{parent=controls,x=1,y=1,min_width=7,text="SCRAM",callback=databus.rps_scram,fg_bg=cpair(colors.black,colors.red),active_fg_bg=cpair(colors.black,colors.red_off)}
PushButton{parent=controls,x=9,y=1,min_width=7,text="RESET",callback=databus.rps_reset,fg_bg=cpair(colors.black,colors.yellow),active_fg_bg=cpair(colors.black,colors.yellow_off)}
databus.rx_field("reactor_active", active.update)
databus.rx_field("rps_scram", scram.update)
local about = Rectangle{parent=panel,width=32,height=3,x=2,y=16,border=border(1,colors.ivory),thin=true,fg_bg=cpair(colors.black,colors.white)}
local fw_v = TextBox{parent=about,x=2,y=1,text="FW: v00.00.00",alignment=TEXT_ALIGN.LEFT,height=1}
local comms_v = TextBox{parent=about,x=17,y=1,text="NT: v00.00.00",alignment=TEXT_ALIGN.LEFT,height=1}
databus.rx_field("version", function (version) fw_v.set_value(util.c("FW: ", version)) end)
databus.rx_field("comms_version", function (version) comms_v.set_value(util.c("NT: v", version)) end)
local rps = Rectangle{parent=panel,width=16,height=16,x=36,y=3,border=border(1,colors.lightGray),thin=true,fg_bg=cpair(colors.black,colors.lightGray)}
local rps_man = LED{parent=rps,label="MANUAL",colors=cpair(colors.red,colors.red_off)}
local rps_auto = LED{parent=rps,label="AUTOMATIC",colors=cpair(colors.red,colors.red_off)}
local rps_tmo = LED{parent=rps,label="TIMEOUT",colors=cpair(colors.red,colors.red_off)}
local rps_flt = LED{parent=rps,label="PLC FAULT",colors=cpair(colors.red,colors.red_off)}
local rps_fail = LED{parent=rps,label="RCT FAULT",colors=cpair(colors.red,colors.red_off)}
rps.line_break()
local rps_dmg = LED{parent=rps,label="HI DAMAGE",colors=cpair(colors.red,colors.red_off)}
local rps_tmp = LED{parent=rps,label="HI TEMP",colors=cpair(colors.red,colors.red_off)}
rps.line_break()
local rps_nof = LED{parent=rps,label="LO FUEL",colors=cpair(colors.red,colors.red_off)}
local rps_wst = LED{parent=rps,label="HI WASTE",colors=cpair(colors.red,colors.red_off)}
rps.line_break()
local rps_ccl = LED{parent=rps,label="LO CCOOLANT",colors=cpair(colors.red,colors.red_off)}
local rps_hcl = LED{parent=rps,label="HI HCOOLANT",colors=cpair(colors.red,colors.red_off)}
databus.rx_field("rps_manual", rps_man.update)
databus.rx_field("rps_automatic", rps_auto.update)
databus.rx_field("rps_timeout", rps_tmo.update)
databus.rx_field("rps_fault", rps_flt.update)
databus.rx_field("rps_sysfail", rps_fail.update)
databus.rx_field("rps_damage", rps_dmg.update)
databus.rx_field("rps_high_temp", rps_tmp.update)
databus.rx_field("rps_no_fuel", rps_nof.update)
databus.rx_field("rps_high_waste", rps_wst.update)
databus.rx_field("rps_low_ccool", rps_ccl.update)
databus.rx_field("rps_high_hcool", rps_hcl.update)
return panel
end
return init

View File

@ -0,0 +1,41 @@
--
-- Graphics Style Options
--
local core = require("graphics.core")
local style = {}
local cpair = core.graphics.cpair
-- GLOBAL --
-- remap global colors
colors.ivory = colors.pink
colors.red_off = colors.brown
colors.yellow_off = colors.magenta
colors.green_off = colors.lime
style.root = cpair(colors.black, colors.ivory)
style.header = cpair(colors.black, colors.lightGray)
style.colors = {
{ c = colors.red, hex = 0xdf4949 }, -- RED ON
{ c = colors.orange, hex = 0xffb659 },
{ c = colors.yellow, hex = 0xf9fb53 },
{ c = colors.lime, hex = 0x16665a }, -- GREEN OFF
{ c = colors.green, hex = 0x6be551 }, -- GREEN ON
{ c = colors.cyan, hex = 0x34bac8 },
{ c = colors.lightBlue, hex = 0x6cc0f2 },
{ c = colors.blue, hex = 0x0096ff },
{ c = colors.purple, hex = 0xb156ee },
{ c = colors.pink, hex = 0xdcd9ca }, -- IVORY
{ c = colors.magenta, hex = 0x85862c }, -- YELLOW OFF
-- { c = colors.white, hex = 0xdcd9ca },
{ c = colors.lightGray, hex = 0xb1b8b3 },
{ c = colors.gray, hex = 0x575757 },
-- { c = colors.black, hex = 0x191919 },
{ c = colors.brown, hex = 0x672223 } -- RED OFF
}
return style

View File

@ -1,5 +1,6 @@
local comms = require("scada-common.comms")
local const = require("scada-common.constants")
local databus = require("reactor-plc.databus")
local log = require("scada-common.log")
local ppm = require("scada-common.ppm")
local rsio = require("scada-common.rsio")
@ -19,11 +20,6 @@ local AUTO_ACK = comms.PLC_AUTO_ACK
local RPS_LIMITS = const.RPS_LIMITS
local print = util.print
local println = util.println
local print_ts = util.print_ts
local println_ts = util.println_ts
-- I sure hope the devs don't change this error message, not that it would have safety implications
-- I wish they didn't change it to be like this
local PCALL_SCRAM_MSG = "pcall: Scram requires the reactor to be active."
@ -389,6 +385,9 @@ function plc.rps_init(reactor, is_formed, emer_cool)
-- update emergency coolant control if configured
_set_emer_cool(self.state[state_keys.low_coolant])
-- report RPS status
databus.tx_rps(self.tripped, self.state)
return self.tripped, status, first_trip
end
@ -440,6 +439,9 @@ function plc.rps_init(reactor, is_formed, emer_cool)
end
end
-- link functions with databus
databus.link_rps(public.trip_manual, public.reset)
return public
end
@ -776,6 +778,11 @@ function plc.comms(id, version, modem, local_port, server_port, range, reactor,
---@param plc_state plc_state PLC state
---@param setpoints setpoints setpoint control table
function public.handle_packet(packet, plc_state, setpoints)
-- print a log message to the terminal as long as the UI isn't running
local function println(message) if not plc_state.fp_ok then util.println(message) end end
local function println_ts(message) if not plc_state.fp_ok then util.println_ts(message) end end
-- handle packets now that we have prints setup
if packet.scada_frame.local_port() == local_port then
-- check sequence number
if self.r_seq_num == nil then
@ -962,6 +969,9 @@ function plc.comms(id, version, modem, local_port, server_port, range, reactor,
-- clear this since this is for something that was unsolicited
self.last_est_ack = ESTABLISH_ACK.ALLOW
-- report link state
databus.tx_link_state(est_ack + 1)
else
log.debug("SCADA_MGMT establish packet length mismatch")
end
@ -1025,6 +1035,9 @@ function plc.comms(id, version, modem, local_port, server_port, range, reactor,
self.linked = est_ack == ESTABLISH_ACK.ALLOW
self.last_est_ack = est_ack
-- report link state
databus.tx_link_state(est_ack + 1)
else
log.debug("SCADA_MGMT establish packet length mismatch")
end

75
reactor-plc/renderer.lua Normal file
View File

@ -0,0 +1,75 @@
--
-- Graphics Rendering Control
--
local style = require("reactor-plc.panel.style")
local panel_view = require("reactor-plc.panel.front_panel")
local flasher = require("graphics.flasher")
local renderer = {}
local ui = {
view = nil
}
-- start the UI
function renderer.start_ui()
if ui.view == nil then
-- reset terminal
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 = panel_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

View File

@ -4,6 +4,7 @@
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")
@ -12,10 +13,12 @@ local rsio = require("scada-common.rsio")
local util = require("scada-common.util")
local config = require("reactor-plc.config")
local databus = require("reactor-plc.databus")
local plc = require("reactor-plc.plc")
local renderer = require("reactor-plc.renderer")
local threads = require("reactor-plc.threads")
local R_PLC_VERSION = "v1.0.1"
local R_PLC_VERSION = "v1.1.4"
local print = util.print
local println = util.println
@ -71,6 +74,10 @@ local function main()
-- startup
----------------------------------------
-- record firmware versions and ID
databus.tx_versions(R_PLC_VERSION, comms.version)
databus.tx_id(config.REACTOR_ID)
-- mount connected devices
ppm.mount_all()
@ -84,6 +91,7 @@ local function main()
---@class plc_state
plc_state = {
init_ok = true,
fp_ok = false,
shutdown = false,
degraded = false,
reactor_formed = true,
@ -155,15 +163,31 @@ local function main()
plc_state.no_modem = true
end
-- print a log message to the terminal as long as the UI isn't running
local function _println_no_fp(message) if not plc_state.fp_ok then println(message) end end
-- PLC init<br>
--- EVENT_CONSUMER: this function consumes events
local function init()
if plc_state.init_ok then
-- just booting up, no fission allowed (neutrons stay put thanks)
if plc_state.reactor_formed and smem_dev.reactor.getStatus() then
if (not plc_state.no_reactor) and plc_state.reactor_formed and smem_dev.reactor.getStatus() then
smem_dev.reactor.scram()
end
-- front panel time!
if not renderer.ui_ready() then
local message = nil
plc_state.fp_ok, message = pcall(renderer.start_ui)
if not plc_state.fp_ok then
renderer.close_ui()
println_ts(util.c("UI error: ", message))
println("init> running without front panel")
log.error(util.c("GUI crashed with error ", message))
log.info("init> running in headless mode without front panel")
end
end
if plc_state.init_ok then
-- init reactor protection system
smem_sys.rps = plc.rps_init(smem_dev.reactor, plc_state.reactor_formed, config.EMERGENCY_COOL)
log.debug("init> rps init")
@ -178,7 +202,7 @@ local function main()
config.TRUSTED_RANGE, smem_dev.reactor, smem_sys.rps, smem_sys.conn_watchdog)
log.debug("init> comms init")
else
println("init> starting in offline mode")
_println_no_fp("init> starting in offline mode")
log.info("init> running without networking")
end
@ -190,12 +214,14 @@ local function main()
util.push_event("clock_start")
println("init> completed")
_println_no_fp("init> completed")
log.info("init> startup completed")
else
println("init> system in degraded state, awaiting devices...")
_println_no_fp("init> system in degraded state, awaiting devices...")
log.warning("init> started in a degraded state, awaiting peripheral connections...")
end
databus.tx_hw_status(plc_state)
end
----------------------------------------
@ -233,6 +259,8 @@ local function main()
parallel.waitForAll(main_thread.p_exec, rps_thread.p_exec)
end
renderer.close_ui()
println_ts("exited")
log.info("exited")
end

View File

@ -1,14 +1,15 @@
local log = require("scada-common.log")
local mqueue = require("scada-common.mqueue")
local ppm = require("scada-common.ppm")
local tcallbackdsp = require("scada-common.tcallbackdsp")
local util = require("scada-common.util")
local threads = {}
local databus = require("reactor-plc.databus")
local renderer = require("reactor-plc.renderer")
local print = util.print
local println = util.println
local print_ts = util.print_ts
local println_ts = util.println_ts
local core = require("graphics.core")
local threads = {}
local MAIN_CLOCK = 0.5 -- (2Hz, 10 ticks)
local RPS_SLEEP = 250 -- (250ms, 5 ticks)
@ -32,11 +33,16 @@ local MQ__COMM_CMD = {
---@param smem plc_shared_memory
---@param init function
function threads.thread__main(smem, init)
-- print a log message to the terminal as long as the UI isn't running
local function println(message) if not smem.plc_state.fp_ok then util.println(message) end end
local function println_ts(message) if not smem.plc_state.fp_ok then util.println_ts(message) end end
---@class parallel_thread
local public = {}
-- execute thread
function public.exec()
databus.tx_rt_status("main", true)
log.debug("main thread init, clock inactive")
-- send status updates at 2Hz (every 10 server ticks) (every loop tick)
@ -61,6 +67,9 @@ function threads.thread__main(smem, init)
-- handle event
if event == "timer" and loop_clock.is_clock(param1) then
-- blink heartbeat indicator
databus.heartbeat()
-- core clock tick
if networked then
-- start next clock timer
@ -133,6 +142,9 @@ function threads.thread__main(smem, init)
-- reactor no longer formed
plc_state.reactor_formed = false
end
-- update indicators
databus.tx_hw_status(plc_state)
elseif event == "modem_message" and networked and plc_state.init_ok and not plc_state.no_modem then
-- got a packet
local packet = plc_comms.parse_packet(param1, param2, param3, param4, param5)
@ -144,6 +156,9 @@ function threads.thread__main(smem, init)
-- haven't heard from server recently? shutdown reactor
plc_comms.unlink()
smem.q.mq_rps.push_command(MQ__RPS_CMD.TRIP_TIMEOUT)
elseif event == "timer" then
-- notify timer callback dispatcher if no other timer case claimed this event
tcallbackdsp.handle(param1)
elseif event == "peripheral_detach" then
-- peripheral disconnect
local type, device = ppm.handle_unmount(param1)
@ -174,6 +189,9 @@ function threads.thread__main(smem, init)
end
end
end
-- update indicators
databus.tx_hw_status(plc_state)
elseif event == "peripheral" then
-- peripheral connect
local type, device = ppm.mount(param1)
@ -237,6 +255,12 @@ function threads.thread__main(smem, init)
plc_state.init_ok = true
init()
end
-- update indicators
databus.tx_hw_status(plc_state)
elseif event == "mouse_click" then
-- handle a monitor touch event
renderer.handle_mouse(core.events.click(param1, param2, param3))
elseif event == "clock_start" then
-- start loop clock
loop_clock.start()
@ -263,6 +287,8 @@ function threads.thread__main(smem, init)
log.fatal(util.strval(result))
end
databus.tx_rt_status("main", false)
-- if status is true, then we are probably exiting, so this won't matter
-- if not, we need to restart the clock
-- this thread cannot be slept because it will miss events (namely "terminate" otherwise)
@ -280,11 +306,16 @@ end
---@nodiscard
---@param smem plc_shared_memory
function threads.thread__rps(smem)
-- print a log message to the terminal as long as the UI isn't running
local function println(message) if not smem.plc_state.fp_ok then util.println(message) end end
local function println_ts(message) if not smem.plc_state.fp_ok then util.println_ts(message) end end
---@class parallel_thread
local public = {}
-- execute thread
function public.exec()
databus.tx_rt_status("rps", true)
log.debug("rps thread start")
-- load in from shared memory
@ -317,12 +348,18 @@ function threads.thread__rps(smem)
was_linked = true
end
if (not plc_state.no_reactor) and rps.is_formed() then
-- check reactor status
---@diagnostic disable-next-line: need-check-nil
local reactor_status = reactor.getStatus()
databus.tx_reactor_state(reactor_status)
-- if we tried to SCRAM but failed, keep trying
-- in that case, SCRAM won't be called until it reconnects (this is the expected use of this check)
---@diagnostic disable-next-line: need-check-nil
if (not plc_state.no_reactor) and rps.is_formed() and rps.is_tripped() and reactor.getStatus() then
if rps.is_tripped() and reactor_status then
rps.scram()
end
end
-- if we are in standalone mode, continuously reset RPS
-- RPS will trip again if there are faults, but if it isn't cleared, the user can't re-enable
@ -405,6 +442,8 @@ function threads.thread__rps(smem)
log.fatal(util.strval(result))
end
databus.tx_rt_status("rps", false)
if not plc_state.shutdown then
if plc_state.init_ok then smem.plc_sys.rps.scram() end
log.info("rps thread restarting in 5 seconds...")
@ -425,6 +464,7 @@ function threads.thread__comms_tx(smem)
-- execute thread
function public.exec()
databus.tx_rt_status("comms_tx", true)
log.debug("comms tx thread start")
-- load in from shared memory
@ -482,6 +522,8 @@ function threads.thread__comms_tx(smem)
log.fatal(util.strval(result))
end
databus.tx_rt_status("comms_tx", false)
if not plc_state.shutdown then
log.info("comms tx thread restarting in 5 seconds...")
util.psleep(5)
@ -501,6 +543,7 @@ function threads.thread__comms_rx(smem)
-- execute thread
function public.exec()
databus.tx_rt_status("comms_rx", true)
log.debug("comms rx thread start")
-- load in from shared memory
@ -558,6 +601,8 @@ function threads.thread__comms_rx(smem)
log.fatal(util.strval(result))
end
databus.tx_rt_status("comms_rx", false)
if not plc_state.shutdown then
log.info("comms rx thread restarting in 5 seconds...")
util.psleep(5)
@ -577,6 +622,7 @@ function threads.thread__setpoint_control(smem)
-- execute thread
function public.exec()
databus.tx_rt_status("spctl", true)
log.debug("setpoint control thread start")
-- load in from shared memory
@ -691,6 +737,8 @@ function threads.thread__setpoint_control(smem)
log.fatal(util.strval(result))
end
databus.tx_rt_status("spctl", false)
if not plc_state.shutdown then
log.info("setpoint control thread restarting in 5 seconds...")
util.psleep(5)

View File

@ -16,7 +16,7 @@ local MODE = {
log.MODE = MODE
-- whether to log debug messages or not
local LOG_DEBUG = false
local LOG_DEBUG = true
local log_sys = {
path = "/log.txt",

View File

@ -51,6 +51,19 @@ function psil.create()
self.ic[key].value = value
end
-- publish a toggled boolean value to a given key, passing it to all subscribers if it has changed<br>
-- this is intended to be used to toggle boolean indicators such as heartbeats without extra state variables
---@param key string data key
function public.toggle(key)
if self.ic[key] == nil then alloc(key) end
self.ic[key].value = self.ic[key].value == false
for i = 1, #self.ic[key].subscribers do
self.ic[key].subscribers[i].notify(self.ic[key].value)
end
end
return public
end