#306 RTU integration with new settings

This commit is contained in:
Mikayla Fischler 2023-11-06 09:25:44 -05:00
parent 9e13a3a467
commit 1b5e8cb69c
6 changed files with 511 additions and 341 deletions

View File

@ -156,6 +156,7 @@ local tool_ctl = {
p_assign_end = nil, ---@type graphics_element
p_desc = nil, ---@type graphics_element
p_desc_ext = nil, ---@type graphics_element
p_err = nil, ---@type graphics_element
rs_cfg_selection = nil, ---@type graphics_element
rs_cfg_unit_l = nil, ---@type graphics_element
@ -200,7 +201,6 @@ local side_options = { "Top", "Bottom", "Left", "Right", "Front", "Back" }
local side_options_map = { "top", "bottom", "left", "right", "front", "back" }
local color_options = { "Red", "Orange", "Yellow", "Lime", "Green", "Cyan", "Light Blue", "Blue", "Purple", "Magenta", "Pink", "White", "Light Gray", "Gray", "Black", "Brown" }
local color_options_map = { colors.red, colors.orange, colors.yellow, colors.lime, colors.green, colors.cyan, colors.lightBlue, colors.blue, colors.purple, colors.magenta, colors.pink, colors.white, colors.lightGray, colors.gray, colors.black, colors.brown }
local color_name_map = { [colors.red] = "red", [colors.orange] = "orange", [colors.yellow] = "yellow", [colors.lime] = "lime", [colors.green] = "green", [colors.cyan] = "cyan", [colors.lightBlue] = "lightBlue", [colors.blue] = "blue", [colors.purple] = "purple", [colors.magenta] = "magenta", [colors.pink] = "pink", [colors.white] = "white", [colors.lightGray] = "lightGray", [colors.gray] = "gray", [colors.black] = "black", [colors.brown] = "brown" }
-- convert text representation to index
---@param side string
@ -223,7 +223,7 @@ local function deep_copy_peri(data)
local array = {}
for _, d in ipairs(data) do table.insert(array, { unit = d.unit, index = d.index, name = d.name }) end
return array
end
end
-- deep copy redstone defs
local function deep_copy_rs(data)
@ -236,7 +236,7 @@ end
---@param target rtu_config
---@param raw boolean? true to not use default values
local function load_settings(target, raw)
for _, v in pairs(fields) do settings.unset(v[1]) end
for k, _ in pairs(tmp_cfg) do settings.unset(k) end
local loaded = settings.load("/rtu.settings")
@ -271,13 +271,14 @@ local function config_view(display)
--#region MAIN PAGE
local y_start = 5
TextBox{parent=main_page,x=2,y=2,height=2,text_align=CENTER,text="Welcome to the RTU gateway configurator! Please select one of the following options."}
local y_start = 2
if tool_ctl.ask_config then
TextBox{parent=main_page,x=2,y=y_start,height=4,width=49,text_align=CENTER,text="Notice: This device has no valid config so the configurator has been automatically started. If you previously had a valid config, you may want to check the Change Log to see what changed.",fg_bg=cpair(colors.red,colors.lightGray)}
y_start = y_start + 5
else
TextBox{parent=main_page,x=2,y=2,height=2,text_align=CENTER,text="Welcome to the RTU gateway configurator! Please select one of the following options."}
y_start = y_start + 3
end
local function view_config()
@ -520,8 +521,11 @@ local function config_view(display)
if data ~= nil then element.set_value(data) end
end
local function save_and_continue()
for k, v in pairs(tmp_cfg) do settings.set(k, v) end
---@param exclude_conns boolean? true to exclude saving peripheral/redstone connections
local function save_and_continue(exclude_conns)
for k, v in pairs(tmp_cfg) do
if not (exclude_conns and (k == "Peripherals" or k == "Redstone")) then settings.set(k, v) end
end
if settings.save("rtu.settings") then
load_settings(ini_cfg)
@ -537,8 +541,16 @@ local function config_view(display)
try_set(path, ini_cfg.LogPath)
try_set(en_dbg, ini_cfg.LogDebug)
if not exclude_conns then
tmp_cfg.Peripherals = deep_copy_peri(ini_cfg.Peripherals)
tmp_cfg.Redstone = deep_copy_rs(ini_cfg.Redstone)
tool_ctl.update_peri_list()
end
tool_ctl.dev_cfg.enable()
tool_ctl.rs_cfg.enable()
tool_ctl.view_gw_cfg.enable()
if tool_ctl.importing_legacy then
tool_ctl.importing_legacy = false
@ -549,7 +561,7 @@ local function config_view(display)
PushButton{parent=sum_c_1,x=1,y=14,min_width=6,text="\x1b Back",callback=back_from_settings,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg}
tool_ctl.show_key_btn = PushButton{parent=sum_c_1,x=8,y=14,min_width=17,text="Unhide Auth Key",callback=function()tool_ctl.show_auth_key()end,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg,dis_fg_bg=cpair(colors.lightGray,colors.white)}
tool_ctl.settings_apply = PushButton{parent=sum_c_1,x=43,y=14,min_width=7,text="Apply",callback=save_and_continue,fg_bg=cpair(colors.black,colors.green),active_fg_bg=btn_act_fg_bg}
tool_ctl.settings_apply = PushButton{parent=sum_c_1,x=43,y=14,min_width=7,text="Apply",callback=function()save_and_continue(true)end,fg_bg=cpair(colors.black,colors.green),active_fg_bg=btn_act_fg_bg}
tool_ctl.settings_confirm = PushButton{parent=sum_c_1,x=41,y=14,min_width=9,text="Confirm",callback=function()sum_pane.set_value(2)end,fg_bg=cpair(colors.black,colors.green),active_fg_bg=btn_act_fg_bg}
tool_ctl.settings_confirm.hide()
@ -648,8 +660,10 @@ local function config_view(display)
tool_ctl.ppm_devs = ListBox{parent=peri_c_2,x=1,y=3,height=10,width=51,scroll_height=1000,fg_bg=bw_fg_bg,nav_fg_bg=g_lg_fg_bg,nav_active=cpair(colors.black,colors.gray)}
PushButton{parent=peri_c_2,x=1,y=14,min_width=6,text="\x1b Back",callback=function()peri_pane.set_value(1)end,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg}
PushButton{parent=peri_c_2,x=8,y=14,min_width=10,text="Manual +",callback=function()peri_pane.set_value(3)end,fg_bg=cpair(colors.black,colors.orange),active_fg_bg=btn_act_fg_bg}
PushButton{parent=peri_c_2,x=26,y=14,min_width=24,text="I don't see my device!",callback=function()peri_pane.set_value(7)end,fg_bg=cpair(colors.black,colors.yellow),active_fg_bg=btn_act_fg_bg}
TextBox{parent=peri_c_7,x=1,y=1,height=10,text="Make sure your device is either touching the RTU, or connected via wired modems. There should be a wired modem on a side of the RTU then one on the device, connected by a cable. The modem on the device needs to be right clicked to connect it (which will turn its border red), at which point the peripheral name will be shown in the chat."}
TextBox{parent=peri_c_7,x=1,y=9,height=4,text="If it still does not show, it may not be compatible. Currently only Boilers, Turbines, Dynamic Tanks, SNAs, SPSs, Induction Matricies, and Environment Detectors are supported."}
PushButton{parent=peri_c_7,x=1,y=14,min_width=6,text="\x1b Back",callback=function()peri_pane.set_value(2)end,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg}
@ -659,6 +673,7 @@ local function config_view(display)
new_peri_attrs = { name, type }
tool_ctl.peri_cfg_editing = false
tool_ctl.p_err.hide(true)
tool_ctl.p_name_msg.set_value("Configuring peripheral on '" .. name .. "':")
tool_ctl.p_desc_ext.set_value("")
@ -769,8 +784,6 @@ local function config_view(display)
tool_ctl.update_peri_list()
PushButton{parent=peri_c_2,x=1,y=14,min_width=6,text="\x1b Back",callback=function()peri_pane.set_value(1)end,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg}
TextBox{parent=peri_c_3,x=1,y=1,height=4,text_align=CENTER,text="This feature is intended for advanced users. If you are clicking this just because your device is not shown, follow the connection instructions in 'I don't see my device!'."}
TextBox{parent=peri_c_3,x=1,y=6,height=4,text_align=CENTER,text="Peripheral Name"}
local p_name = TextField{parent=peri_c_3,x=1,y=7,width=49,height=1,max_len=128,fg_bg=bw_fg_bg}
@ -813,8 +826,8 @@ local function config_view(display)
tool_ctl.p_desc = TextBox{parent=peri_c_4,x=1,y=7,height=6,text_align=LEFT,text="",fg_bg=g_lg_fg_bg}
tool_ctl.p_desc_ext = TextBox{parent=peri_c_4,x=1,y=6,height=7,text_align=LEFT,text="",fg_bg=g_lg_fg_bg}
local p_err = TextBox{parent=peri_c_4,x=8,y=14,height=1,width=35,text_align=LEFT,text="",fg_bg=cpair(colors.red,colors.lightGray),hidden=true}
p_err.hide(true)
tool_ctl.p_err = TextBox{parent=peri_c_4,x=8,y=14,height=1,width=35,text_align=LEFT,text="",fg_bg=cpair(colors.red,colors.lightGray),hidden=true}
tool_ctl.p_err.hide(true)
local function back_from_peri_opts()
if tool_ctl.peri_cfg_editing ~= false then
@ -842,33 +855,33 @@ local function config_view(display)
if (peri_type == "dynamicValve" or peri_type == "environmentDetector") and for_facility then
-- skip
elseif not (util.is_int(u) and u > 0 and u < 5) then
p_err.set_value("Unit ID must be within 1 through 4.")
p_err.show()
tool_ctl.p_err.set_value("Unit ID must be within 1 through 4.")
tool_ctl.p_err.show()
return
else unit = u end
end
if peri_type == "boilerValve" then
if not (idx == 1 or idx == 2) then
p_err.set_value("Index must be 1 or 2.")
p_err.show()
tool_ctl.p_err.set_value("Index must be 1 or 2.")
tool_ctl.p_err.show()
return
else index = idx end
elseif peri_type == "turbineValve" then
if not (idx == 1 or idx == 2 or idx == 3) then
p_err.set_value("Index must be 1, 2, or 3.")
p_err.show()
tool_ctl.p_err.set_value("Index must be 1, 2, or 3.")
tool_ctl.p_err.show()
return
else index = idx end
elseif peri_type == "dynamicValve" and not for_facility then
elseif peri_type == "dynamicValve" and for_facility then
if not (util.is_int(idx) and idx > 0 and idx < 5) then
p_err.set_value("Index must be within 1 through 4.")
p_err.show()
tool_ctl.p_err.set_value("Index must be within 1 through 4.")
tool_ctl.p_err.show()
return
else index = idx end
end
p_err.hide(true)
tool_ctl.p_err.hide(true)
---@type rtu_peri_definition
local def = { name = peri_name, unit = unit, index = index }
@ -1124,7 +1137,7 @@ local function config_view(display)
local unit = "facility"
if def.unit then unit = "unit " .. def.unit end
if def.color ~= nil then conn = def.side .. "/" .. color_name_map[def.color] end
if def.color ~= nil then conn = def.side .. "/" .. rsio.color_name(def.color) end
local line = Div{parent=rs_import_list,height=1}
TextBox{parent=line,x=1,y=1,width=1,height=1,text=io_dir,fg_bg=cpair(colors.lightGray,colors.white)}
@ -1202,6 +1215,67 @@ local function config_view(display)
end
end
---@param def rtu_peri_definition
---@param idx integer
---@param type string
local function edit_peri_entry(idx, def, type)
-- set inputs BEFORE calling new_peri()
if def.index ~= nil then tool_ctl.p_idx.set_value(def.index) end
if def.unit == nil then
tool_ctl.p_assign_btn.set_value(1)
else
tool_ctl.p_unit.set_value(def.unit)
tool_ctl.p_assign_btn.set_value(2)
end
new_peri(def.name, type)
-- set editing mode AFTER new_peri()
tool_ctl.peri_cfg_editing = idx
end
local function delete_peri_entry(idx)
table.remove(tmp_cfg.Peripherals, idx)
tool_ctl.gen_peri_summary(tmp_cfg)
end
-- generate the peripherals summary list
---@param cfg rtu_config
function tool_ctl.gen_peri_summary(cfg)
peri_list.remove_all()
for i = 1, #cfg.Peripherals do
local def = cfg.Peripherals[i] ---@type rtu_peri_definition
local t = ppm.get_type(def.name)
local t_str = "<disconnected> (connect to edit)"
local disconnected = t == nil
if not disconnected then t_str = "[" .. t .. "]" end
local desc = " \x1a "
if type(def.index) == "number" then
desc = desc .. "#" .. def.index .. " "
end
if type(def.unit) == "number" then
desc = desc .. "for unit " .. def.unit
else
desc = desc .. "for the facility"
end
local entry = Div{parent=peri_list,height=3}
TextBox{parent=entry,x=1,y=1,height=1,text="@ "..def.name,fg_bg=cpair(colors.black,colors.white)}
TextBox{parent=entry,x=1,y=2,height=1,text=" \x1a "..t_str,fg_bg=cpair(colors.gray,colors.white)}
TextBox{parent=entry,x=1,y=3,height=1,text=desc,fg_bg=cpair(colors.gray,colors.white)}
local edit_btn = PushButton{parent=entry,x=41,y=2,min_width=8,height=1,text="EDIT",callback=function()edit_peri_entry(i,def,t or "")end,fg_bg=cpair(colors.black,colors.blue),active_fg_bg=btn_act_fg_bg,dis_fg_bg=cpair(colors.lightGray,colors.white)}
PushButton{parent=entry,x=41,y=3,min_width=8,height=1,text="DELETE",callback=function()delete_peri_entry(i)end,fg_bg=cpair(colors.black,colors.red),active_fg_bg=btn_act_fg_bg}
if disconnected then edit_btn.disable() end
end
end
local function edit_rs_entry(idx)
local def = tmp_cfg.Redstone[idx] ---@type rtu_rs_definition
@ -1252,7 +1326,7 @@ local function config_view(display)
local conn = def.side
local unit = util.strval(def.unit or "F")
if def.color ~= nil then conn = def.side .. "/" .. color_name_map[def.color] end
if def.color ~= nil then conn = def.side .. "/" .. rsio.color_name(def.color) end
local entry = Div{parent=rs_list,height=1}
TextBox{parent=entry,x=1,y=1,width=1,height=1,text=io_dir,fg_bg=cpair(colors.lightGray,colors.white)}
@ -1263,53 +1337,6 @@ local function config_view(display)
PushButton{parent=entry,x=41,y=1,min_width=8,height=1,text="DELETE",callback=function()delete_rs_entry(i)end,fg_bg=cpair(colors.black,colors.red),active_fg_bg=btn_act_fg_bg}
end
end
local function edit_peri_entry(idx, name, type)
new_peri(name, type)
tool_ctl.peri_cfg_editing = idx -- must be after new_peri
end
local function delete_peri_entry(idx)
table.remove(tmp_cfg.Peripherals, idx)
tool_ctl.gen_peri_summary(tmp_cfg)
end
-- generate the peripherals summary list
---@param cfg rtu_config
function tool_ctl.gen_peri_summary(cfg)
peri_list.remove_all()
for i = 1, #cfg.Peripherals do
local def = cfg.Peripherals[i] ---@type rtu_peri_definition
local t = ppm.get_type(def.name)
local t_str = "<disconnected> (connect to edit)"
local disconnected = t == nil
if not disconnected then t_str = "[" .. t .. "]" end
local desc = " \x1a "
if type(def.index) == "number" then
desc = desc .. "#" .. def.index .. " "
end
if type(def.unit) == "number" then
desc = desc .. "for unit " .. def.unit
else
desc = desc .. "for the facility"
end
local entry = Div{parent=peri_list,height=3}
TextBox{parent=entry,x=1,y=1,height=1,text="@ "..def.name,fg_bg=cpair(colors.black,colors.white)}
TextBox{parent=entry,x=1,y=2,height=1,text=" \x1a "..t_str,fg_bg=cpair(colors.gray,colors.white)}
TextBox{parent=entry,x=1,y=3,height=1,text=desc,fg_bg=cpair(colors.gray,colors.white)}
local edit_btn = PushButton{parent=entry,x=41,y=2,min_width=8,height=1,text="EDIT",callback=function()edit_peri_entry(i,def.name,t)end,fg_bg=cpair(colors.black,colors.blue),active_fg_bg=btn_act_fg_bg,dis_fg_bg=cpair(colors.lightGray,colors.white)}
PushButton{parent=entry,x=41,y=3,min_width=8,height=1,text="DELETE",callback=function()delete_peri_entry(i)end,fg_bg=cpair(colors.black,colors.red),active_fg_bg=btn_act_fg_bg}
if disconnected then edit_btn.disable() end
end
end
end
-- reset terminal screen

View File

@ -109,12 +109,10 @@ local function init(panel, units)
unit_hw.register(databus.ps, "unit_hw_" .. i, unit_hw.update)
-- unit name identifier (type + index)
local name = util.c(UNIT_TYPE_LABELS[unit.type + 1], " ", unit.index)
local name_box = TextBox{parent=unit_hw_statuses,y=i,x=3,text=name,height=1}
local function get_name(t) return util.c(UNIT_TYPE_LABELS[t + 1], " ", util.trinary(util.is_int(unit.index), unit.index, "")) end
local name_box = TextBox{parent=unit_hw_statuses,y=i,x=3,text=get_name(unit.type),height=1}
name_box.register(databus.ps, "unit_type_" .. i, function (t)
name_box.set_value(util.c(UNIT_TYPE_LABELS[t + 1], " ", unit.index))
end)
name_box.register(databus.ps, "unit_type_" .. i, function (t) name_box.set_value(get_name(t)) end)
-- assignment (unit # or facility)
local for_unit = util.trinary(unit.reactor == 0, "\x1a FACIL ", "\x1a UNIT " .. unit.reactor)

View File

@ -5,7 +5,6 @@ local log = require("scada-common.log")
local types = require("scada-common.types")
local util = require("scada-common.util")
local config = require("rtu.config")
local databus = require("rtu.databus")
local modbus = require("rtu.modbus")
@ -17,6 +16,51 @@ local ESTABLISH_ACK = comms.ESTABLISH_ACK
local MGMT_TYPE = comms.MGMT_TYPE
local RTU_UNIT_TYPE = types.RTU_UNIT_TYPE
---@type rtu_config
local config = {}
rtu.config = config
-- load the RTU configuration
function rtu.load_config()
if not settings.load("/rtu.settings") then return false end
config.Peripherals = settings.get("Peripherals")
config.Redstone = settings.get("Redstone")
config.SpeakerVolume = settings.get("SpeakerVolume")
config.SVR_Channel = settings.get("SVR_Channel")
config.RTU_Channel = settings.get("RTU_Channel")
config.ConnTimeout = settings.get("ConnTimeout")
config.TrustedRange = settings.get("TrustedRange")
config.AuthKey = settings.get("AuthKey")
config.LogMode = settings.get("LogMode")
config.LogPath = settings.get("LogPath")
config.LogDebug = settings.get("LogDebug")
local cfv = util.new_validator()
cfv.assert_type_num(config.SpeakerVolume)
cfv.assert_channel(config.SVR_Channel)
cfv.assert_channel(config.RTU_Channel)
cfv.assert_type_int(config.ConnTimeout)
cfv.assert_min(config.ConnTimeout, 2)
cfv.assert_type_num(config.TrustedRange)
cfv.assert_min(config.TrustedRange, 0)
cfv.assert_type_str(config.AuthKey)
if type(config.AuthKey) == "string" then
local len = string.len(config.AuthKey)
cfv.assert_eq(len == 0 or len >= 8, true)
end
cfv.assert_type_int(config.LogMode)
cfv.assert_type_str(config.LogPath)
cfv.assert_type_bool(config.LogDebug)
return cfv.valid()
end
-- create a new RTU unit
---@nodiscard
function rtu.init_unit()
@ -175,7 +219,7 @@ function rtu.init_sounder(speaker)
function spkr_ctl.continue()
if spkr_ctl.playing then
if spkr_ctl.speaker ~= nil and spkr_ctl.stream.has_next_block() then
local success = spkr_ctl.speaker.playAudio(spkr_ctl.stream.get_next_block(), config.SOUNDER_VOLUME)
local success = spkr_ctl.speaker.playAudio(spkr_ctl.stream.get_next_block(), config.SpeakerVolume)
if not success then log.error(util.c("rtu_sounder(", spkr_ctl.name, "): error playing audio")) end
end
end
@ -203,11 +247,8 @@ end
---@nodiscard
---@param version string RTU version
---@param nic nic network interface device
---@param rtu_channel integer PLC comms channel
---@param svr_channel integer supervisor server channel
---@param range integer trusted device connection range
---@param conn_watchdog watchdog watchdog reference
function rtu.comms(version, nic, rtu_channel, svr_channel, range, conn_watchdog)
function rtu.comms(version, nic, conn_watchdog)
local self = {
sv_addr = comms.BROADCAST,
seq_num = 0,
@ -218,13 +259,13 @@ function rtu.comms(version, nic, rtu_channel, svr_channel, range, conn_watchdog)
local insert = table.insert
comms.set_trusted_range(range)
comms.set_trusted_range(config.TrustedRange)
-- PRIVATE FUNCTIONS --
-- configure modem channels
nic.closeAll()
nic.open(rtu_channel)
nic.open(config.RTU_Channel)
-- send a scada management packet
---@param msg_type MGMT_TYPE
@ -236,7 +277,7 @@ function rtu.comms(version, nic, rtu_channel, svr_channel, range, conn_watchdog)
m_pkt.make(msg_type, msg)
s_pkt.make(self.sv_addr, self.seq_num, PROTOCOL.SCADA_MGMT, m_pkt.raw_sendable())
nic.transmit(svr_channel, rtu_channel, s_pkt)
nic.transmit(config.SVR_Channel, config.RTU_Channel, s_pkt)
self.seq_num = self.seq_num + 1
end
@ -280,7 +321,7 @@ function rtu.comms(version, nic, rtu_channel, svr_channel, range, conn_watchdog)
function public.send_modbus(m_pkt)
local s_pkt = comms.scada_packet()
s_pkt.make(self.sv_addr, self.seq_num, PROTOCOL.MODBUS_TCP, m_pkt.raw_sendable())
nic.transmit(svr_channel, rtu_channel, s_pkt)
nic.transmit(config.SVR_Channel, config.RTU_Channel, s_pkt)
self.seq_num = self.seq_num + 1
end
@ -365,7 +406,7 @@ function rtu.comms(version, nic, rtu_channel, svr_channel, range, conn_watchdog)
local l_chan = packet.scada_frame.local_channel()
local src_addr = packet.scada_frame.src_addr()
if l_chan == rtu_channel then
if l_chan == config.RTU_Channel then
-- check sequence number
if self.r_seq_num == nil then
self.r_seq_num = packet.scada_frame.seq_num()

View File

@ -15,7 +15,7 @@ local rsio = require("scada-common.rsio")
local types = require("scada-common.types")
local util = require("scada-common.util")
local config = require("rtu.config")
local configure = require("rtu.configure")
local databus = require("rtu.databus")
local modbus = require("rtu.modbus")
local renderer = require("rtu.renderer")
@ -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.6.6"
local RTU_VERSION = "v1.7.0"
local RTU_UNIT_TYPE = types.RTU_UNIT_TYPE
local RTU_UNIT_HW_STATE = databus.RTU_UNIT_HW_STATE
@ -40,27 +40,26 @@ local println = util.println
local println_ts = util.println_ts
----------------------------------------
-- config validation
-- get configuration
----------------------------------------
local cfv = util.new_validator()
if not rtu.load_config() then
-- try to reconfigure (user action)
local success, error = configure.configure(true)
if success then
assert(rtu.load_config(), "failed to load valid RTU configuration")
else
assert(success, "RTU configuration error: " .. error)
end
end
cfv.assert_channel(config.SVR_CHANNEL)
cfv.assert_channel(config.RTU_CHANNEL)
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)
cfv.assert_type_table(config.RTU_DEVICES)
cfv.assert_type_table(config.RTU_REDSTONE)
assert(cfv.valid(), "bad config file: missing/invalid fields")
local config = rtu.config
----------------------------------------
-- log init
----------------------------------------
log.init(config.LOG_PATH, config.LOG_MODE, config.LOG_DEBUG == true)
log.init(config.LogPath, config.LogMode, config.LogDebug)
log.info("========================================")
log.info("BOOTING rtu.startup " .. RTU_VERSION)
@ -85,8 +84,8 @@ local function main()
ppm.mount_all()
-- message authentication init
if type(config.AUTH_KEY) == "string" then
network.init_mac(config.AUTH_KEY)
if type(config.AuthKey) == "string" then
network.init_mac(config.AuthKey)
end
-- get modem
@ -139,126 +138,109 @@ local function main()
local units = __shared_memory.rtu_sys.units
local rtu_redstone = config.RTU_REDSTONE
local rtu_devices = config.RTU_DEVICES
local rtu_redstone = config.Redstone
local rtu_devices = config.Peripherals
-- configure RTU gateway based on config file definitions
local function configure()
-- configure RTU gateway based on settings file definitions
local function sys_config()
-- redstone interfaces
local rs_rtus = {}
-- go through redstone definitions list
for entry_idx = 1, #rtu_redstone do
local rs_rtu = redstone_rtu.new()
local io_table = rtu_redstone[entry_idx].io ---@type table
local io_reactor = rtu_redstone[entry_idx].for_reactor ---@type integer
local entry = rtu_redstone[entry_idx] ---@type rtu_rs_definition
local assignment = ""
local for_reactor = entry.unit
local iface_name = util.trinary(entry.color ~= nil, util.c(entry.side, "/", rsio.color_name(entry.color)), entry.side)
-- CHECK: reactor ID must be >= to 1
if (not util.is_int(io_reactor)) or (io_reactor < 0) then
local message = util.c("configure> redstone entry #", entry_idx, " : ", io_reactor, " isn't an integer >= 0")
if util.is_int(entry.unit) and entry.unit > 0 and entry.unit < 5 then
---@cast for_reactor integer
assignment = "reactor unit " .. entry.unit
if rs_rtus[for_reactor] == nil then
log.debug(util.c("configure> allocated redstone RTU for reactor unit ", entry.unit))
rs_rtus[for_reactor] = { rtu = redstone_rtu.new(), capabilities = {} }
end
elseif entry.unit == nil then
assignment = "facility"
for_reactor = 0
if rs_rtus[for_reactor] == nil then
log.debug(util.c("configure> allocated redstone RTU for the facility"))
rs_rtus[for_reactor] = { rtu = redstone_rtu.new(), capabilities = {} }
end
else
local message = util.c("configure> invalid unit assignment at block index #", entry_idx)
println(message)
log.fatal(message)
return false
end
-- CHECK: io table exists
if type(io_table) ~= "table" then
local message = util.c("configure> redstone entry #", entry_idx, " no IO table found")
println(message)
log.fatal(message)
return false
end
local capabilities = {}
log.debug(util.c("configure> starting redstone RTU I/O linking for reactor ", io_reactor, "..."))
local continue = true
-- CHECK: no duplicate entries
for i = 1, #units do
local unit = units[i] ---@type rtu_unit_registry_entry
if unit.reactor == io_reactor and unit.type == RTU_UNIT_TYPE.REDSTONE then
-- duplicate entry
local message = util.c("configure> skipping definition block #", entry_idx, " for reactor ", io_reactor,
" with already defined redstone I/O")
println(message)
log.warning(message)
continue = false
break
end
end
-- not a duplicate
if continue then
for i = 1, #io_table do
local valid = false
local conf = io_table[i]
-- verify configuration
if rsio.is_valid_port(conf.port) and rsio.is_valid_side(conf.side) then
if conf.bundled_color then
valid = rsio.is_color(conf.bundled_color)
else
valid = true
end
local valid = false
if rsio.is_valid_port(entry.port) and rsio.is_valid_side(entry.side) then
valid = util.trinary(entry.color == nil, true, rsio.is_color(entry.color))
end
local rs_rtu = rs_rtus[for_reactor].rtu
local capabilities = rs_rtus[for_reactor].capabilities
if not valid then
local message = util.c("configure> invalid redstone definition at index ", i, " in definition block #", entry_idx,
" (for reactor ", io_reactor, ")")
local message = util.c("configure> invalid redstone definition at block index #", entry_idx)
println(message)
log.fatal(message)
return false
else
-- link redstone in RTU
local mode = rsio.get_io_mode(conf.port)
local mode = rsio.get_io_mode(entry.port)
if mode == rsio.IO_MODE.DIGITAL_IN then
-- can't have duplicate inputs
if util.table_contains(capabilities, conf.port) then
local message = util.c("configure> skipping duplicate input for port ", rsio.to_string(conf.port), " on side ", conf.side)
if util.table_contains(capabilities, entry.port) then
local message = util.c("configure> skipping duplicate input for port ", rsio.to_string(entry.port), " on side ", iface_name)
println(message)
log.warning(message)
else
rs_rtu.link_di(conf.side, conf.bundled_color)
rs_rtu.link_di(entry.side, entry.color)
end
elseif mode == rsio.IO_MODE.DIGITAL_OUT then
rs_rtu.link_do(conf.side, conf.bundled_color)
rs_rtu.link_do(entry.side, entry.color)
elseif mode == rsio.IO_MODE.ANALOG_IN then
-- can't have duplicate inputs
if util.table_contains(capabilities, conf.port) then
local message = util.c("configure> skipping duplicate input for port ", rsio.to_string(conf.port), " on side ", conf.side)
if util.table_contains(capabilities, entry.port) then
local message = util.c("configure> skipping duplicate input for port ", rsio.to_string(entry.port), " on side ", iface_name)
println(message)
log.warning(message)
else
rs_rtu.link_ai(conf.side)
rs_rtu.link_ai(entry.side)
end
elseif mode == rsio.IO_MODE.ANALOG_OUT then
rs_rtu.link_ao(conf.side)
rs_rtu.link_ao(entry.side)
else
-- should be unreachable code, we already validated ports
log.error("configure> fell through if chain attempting to identify IO mode", true)
log.error("configure> fell through if chain attempting to identify IO mode at block index #" .. entry_idx, true)
println("configure> encountered a software error, check logs")
return false
end
table.insert(capabilities, conf.port)
table.insert(capabilities, entry.port)
log.debug(util.c("configure> linked redstone ", #capabilities, ": ", rsio.to_string(conf.port),
" (", conf.side, ") for reactor ", io_reactor))
log.debug(util.c("configure> linked redstone ", #capabilities, ": ", rsio.to_string(entry.port), " (", iface_name, ") for ", assignment))
end
end
-- create unit entries for redstone RTUs
for for_reactor, def in pairs(rs_rtus) do
---@class rtu_unit_registry_entry
local unit = {
uid = 0, ---@type integer
name = "redstone_io", ---@type string
type = RTU_UNIT_TYPE.REDSTONE, ---@type RTU_UNIT_TYPE
index = entry_idx, ---@type integer
reactor = io_reactor, ---@type integer
device = capabilities, ---@type table use device field for redstone ports
index = false, ---@type integer|false
reactor = for_reactor, ---@type integer
device = def.capabilities, ---@type table use device field for redstone ports
is_multiblock = false, ---@type boolean
formed = nil, ---@type boolean|nil
hw_state = RTU_UNIT_HW_STATE.OK, ---@type RTU_UNIT_HW_STATE
rtu = rs_rtu, ---@type rtu_device|rtu_rs_device
modbus_io = modbus.new(rs_rtu, false),
rtu = def.rtu, ---@type rtu_device|rtu_rs_device
modbus_io = modbus.new(def.rtu, false),
pkt_queue = nil, ---@type mqueue|nil
thread = nil ---@type parallel_thread|nil
}
@ -266,8 +248,8 @@ local function main()
table.insert(units, unit)
local for_message = "facility"
if io_reactor > 0 then
for_message = util.c("reactor ", io_reactor)
if util.is_int(for_reactor) then
for_message = util.c("reactor unit ", for_reactor)
end
log.info(util.c("configure> initialized RTU unit #", #units, ": redstone_io (redstone) [1] for ", for_message))
@ -276,13 +258,13 @@ local function main()
databus.tx_unit_hw_status(unit.uid, unit.hw_state)
end
end
-- mounted peripherals
for i = 1, #rtu_devices do
local name = rtu_devices[i].name
local index = rtu_devices[i].index
local for_reactor = rtu_devices[i].for_reactor
local entry = rtu_devices[i] ---@type rtu_peri_definition
local name = entry.name
local index = entry.index
local for_reactor = util.trinary(entry.unit == nil, 0, entry.unit)
-- CHECK: name is a string
if type(name) ~= "string" then
@ -292,20 +274,37 @@ local function main()
return false
end
-- CHECK: index is an integer >= 1
if (not util.is_int(index)) or (index <= 0) then
local message = util.c("configure> device entry #", i, ": index ", index, " isn't an integer >= 1")
-- CHECK: index type
if (index ~= nil) and (not util.is_int(index)) then
local message = util.c("configure> device entry #", i, ": index ", index, " isn't valid")
println(message)
log.fatal(message)
return false
end
-- CHECK: reactor is an integer >= 0
if (not util.is_int(for_reactor)) or (for_reactor < 0) then
local message = util.c("configure> device entry #", i, ": reactor ", for_reactor, " isn't an integer >= 0")
-- CHECK: index range
local function validate_index(min, max)
if (util.is_int(index) and index < min) and (max == nil or index > max) then
local message = util.c("configure> device entry #", i, ": index ", index, " isn't >= ", min, " and <= ", max)
println(message)
log.fatal(message)
return false
else return true end
end
-- CHECK: reactor is an integer >= 0
local function validate_assign(for_facility)
if for_facility and for_reactor ~= 0 then
local message = util.c("configure> device entry #", i, ": must only be for the facility")
println(message)
log.fatal(message)
return false
elseif (not for_facility) and ((not util.is_int(for_reactor)) or (for_reactor < 1) or (for_reactor > 4)) then
local message = util.c("configure> device entry #", i, ": unit assignment ", for_reactor, " isn't vaild")
println(message)
log.fatal(message)
return false
else return true end
end
local device = ppm.get_periph(name)
@ -330,6 +329,9 @@ local function main()
if type == "boilerValve" then
-- boiler multiblock
if not validate_index(1, 2) then return false end
if not validate_assign() then return false end
rtu_type = RTU_UNIT_TYPE.BOILER_VALVE
rtu_iface, faulted = boilerv_rtu.new(device)
is_multiblock = true
@ -342,6 +344,9 @@ local function main()
end
elseif type == "turbineValve" then
-- turbine multiblock
if not validate_index(1, 3) then return false end
if not validate_assign() then return false end
rtu_type = RTU_UNIT_TYPE.TURBINE_VALVE
rtu_iface, faulted = turbinev_rtu.new(device)
is_multiblock = true
@ -354,6 +359,14 @@ local function main()
end
elseif type == "dynamicValve" then
-- dynamic tank multiblock
if entry.unit == nil then
if not validate_index(1, 4) then return false end
if not validate_assign(true) then return false end
else
if not validate_index(1, 1) then return false end
if not validate_assign() then return false end
end
rtu_type = RTU_UNIT_TYPE.DYNAMIC_VALVE
rtu_iface, faulted = dynamicv_rtu.new(device)
is_multiblock = true
@ -366,6 +379,8 @@ local function main()
end
elseif type == "inductionPort" then
-- induction matrix multiblock
if not validate_assign(true) then return false end
rtu_type = RTU_UNIT_TYPE.IMATRIX
rtu_iface, faulted = imatrix_rtu.new(device)
is_multiblock = true
@ -378,6 +393,8 @@ local function main()
end
elseif type == "spsPort" then
-- SPS multiblock
if not validate_assign(true) then return false end
rtu_type = RTU_UNIT_TYPE.SPS
rtu_iface, faulted = sps_rtu.new(device)
is_multiblock = true
@ -390,10 +407,14 @@ local function main()
end
elseif type == "solarNeutronActivator" then
-- SNA
if not validate_assign() then return false end
rtu_type = RTU_UNIT_TYPE.SNA
rtu_iface, faulted = sna_rtu.new(device)
elseif type == "environmentDetector" then
-- advanced peripherals environment detector
if not validate_assign(entry.unit == nil) then return false end
rtu_type = RTU_UNIT_TYPE.ENV_DETECTOR
rtu_iface, faulted = envd_rtu.new(device)
elseif type == ppm.VIRTUAL_DEVICE_TYPE then
@ -423,7 +444,7 @@ local function main()
uid = 0, ---@type integer
name = name, ---@type string
type = rtu_type, ---@type RTU_UNIT_TYPE
index = index, ---@type integer
index = index or false, ---@type integer|false
reactor = for_reactor, ---@type integer
device = device, ---@type table
is_multiblock = is_multiblock, ---@type boolean
@ -465,7 +486,6 @@ local function main()
databus.tx_unit_hw_status(rtu_unit.uid, rtu_unit.hw_state)
end
-- we made it through all that trusting-user-to-write-a-config-file chaos
return true
end
@ -475,9 +495,9 @@ local function main()
local rtu_state = __shared_memory.rtu_state
log.debug("boot> running configure()")
log.debug("boot> running sys_config()")
if configure() then
if sys_config() then
-- start UI
local message
rtu_state.fp_ok, message = renderer.try_start_ui(units)
@ -502,12 +522,11 @@ local function main()
databus.tx_hw_spkr_count(#__shared_memory.rtu_dev.sounders)
-- start connection watchdog
smem_sys.conn_watchdog = util.new_watchdog(config.COMMS_TIMEOUT)
smem_sys.conn_watchdog = util.new_watchdog(config.ConnTimeout)
log.debug("startup> conn watchdog started")
-- setup comms
smem_sys.rtu_comms = rtu.comms(RTU_VERSION, smem_sys.nic, config.RTU_CHANNEL, config.SVR_CHANNEL,
config.TRUSTED_RANGE, smem_sys.conn_watchdog)
smem_sys.rtu_comms = rtu.comms(RTU_VERSION, smem_sys.nic, smem_sys.conn_watchdog)
log.debug("startup> comms init")
-- init threads

View File

@ -28,6 +28,174 @@ local UNIT_HW_STATE = databus.RTU_UNIT_HW_STATE
local MAIN_CLOCK = 0.5 -- (2Hz, 10 ticks)
local COMMS_SLEEP = 100 -- (100ms, 2 ticks)
---@param smem rtu_shared_memory
---@param println_ts function
---@param iface string
---@param type string
---@param device table
---@param unit rtu_unit_registry_entry
local function handle_unit_mount(smem, println_ts, iface, type, device, unit)
local sys = smem.rtu_sys
-- find disconnected device to reconnect
-- note: cannot check isFormed as that would yield this coroutine and consume events
if unit.name == iface then
local resend_advert = false
local faulted = false
local unknown = false
local invalid = false
-- found, re-link
unit.device = device
if unit.type == RTU_UNIT_TYPE.VIRTUAL then
resend_advert = true
if type == "boilerValve" then
-- boiler multiblock
if unit.reactor < 1 or unit.reactor > 4 then
invalid = true
log.error(util.c("boiler '", unit.name, "' cannot init, not assigned to a valid unit in config"))
end
if (unit.index == false) or unit.index < 1 or unit.index > 2 then
invalid = true
log.error(util.c("boiler '", unit.name, "' cannot init, invalid index provided in config"))
end
unit.type = RTU_UNIT_TYPE.BOILER_VALVE
elseif type == "turbineValve" then
-- turbine multiblock
if unit.reactor < 1 or unit.reactor > 4 then
invalid = true
log.error(util.c("turbine '", unit.name, "' cannot init, not assigned to a valid unit in config"))
end
if (unit.index == false) or unit.index < 1 or unit.index > 3 then
invalid = true
log.error(util.c("turbine '", unit.name, "' cannot init, invalid index provided in config"))
end
unit.type = RTU_UNIT_TYPE.TURBINE_VALVE
elseif type == "dynamicValve" then
-- dynamic tank multiblock
if unit.reactor < 0 or unit.reactor > 4 then
invalid = true
log.error(util.c("dynamic tank '", unit.name, "' cannot init, no valid assignment provided in config"))
end
if (unit.reactor == 0 and ((unit.index == false) or unit.index < 1 or unit.index > 4)) or
(unit.reactor > 0 and unit.index ~= 1) then
invalid = true
log.error(util.c("dynamic tank '", unit.name, "' cannot init, invalid index provided in config"))
end
unit.type = RTU_UNIT_TYPE.DYNAMIC_VALVE
elseif type == "inductionPort" then
-- induction matrix multiblock
if unit.reactor ~= 0 then
invalid = true
log.error(util.c("induction matrix '", unit.name, "' cannot init, not assigned to facility in config"))
end
unit.type = RTU_UNIT_TYPE.IMATRIX
elseif type == "spsPort" then
-- SPS multiblock
if unit.reactor ~= 0 then
invalid = true
log.error(util.c("SPS '", unit.name, "' cannot init, not assigned to facility in config"))
end
unit.type = RTU_UNIT_TYPE.SPS
elseif type == "solarNeutronActivator" then
-- SNA
if unit.reactor < 1 or unit.reactor > 4 then
invalid = true
log.error(util.c("SNA '", unit.name, "' cannot init, not assigned to a valid unit in config"))
end
unit.type = RTU_UNIT_TYPE.SNA
elseif type == "environmentDetector" then
-- advanced peripherals environment detector
if unit.reactor < 0 or unit.reactor > 4 then
invalid = true
log.error(util.c("environment detector '", unit.name, "' cannot init, no valid assignment provided in config"))
end
unit.type = RTU_UNIT_TYPE.ENV_DETECTOR
else
resend_advert = false
log.error(util.c("virtual device '", unit.name, "' cannot init to an unknown type (", type, ")"))
end
databus.tx_unit_hw_type(unit.uid, unit.type)
end
-- if disconnected on startup, config wouldn't have been validated
-- checking now that it has connected; the config isn't valid, so don't connect it
if invalid then
unit.hw_state = UNIT_HW_STATE.OFFLINE
databus.tx_unit_hw_status(unit.uid, unit.hw_state)
return
end
-- note for multiblock structures: if not formed, indexing the multiblock functions results in a PPM fault
if unit.type == RTU_UNIT_TYPE.BOILER_VALVE then
unit.rtu, faulted = boilerv_rtu.new(device)
unit.formed = util.trinary(faulted, false, nil)
elseif unit.type == RTU_UNIT_TYPE.TURBINE_VALVE then
unit.rtu, faulted = turbinev_rtu.new(device)
unit.formed = util.trinary(faulted, false, nil)
elseif unit.type == RTU_UNIT_TYPE.DYNAMIC_VALVE then
unit.rtu, faulted = dynamicv_rtu.new(device)
unit.formed = util.trinary(faulted, false, nil)
elseif unit.type == RTU_UNIT_TYPE.IMATRIX then
unit.rtu, faulted = imatrix_rtu.new(device)
unit.formed = util.trinary(faulted, false, nil)
elseif unit.type == RTU_UNIT_TYPE.SPS then
unit.rtu, faulted = sps_rtu.new(device)
unit.formed = util.trinary(faulted, false, nil)
elseif unit.type == RTU_UNIT_TYPE.SNA then
unit.rtu, faulted = sna_rtu.new(device)
elseif unit.type == RTU_UNIT_TYPE.ENV_DETECTOR then
unit.rtu, faulted = envd_rtu.new(device)
else
unknown = true
log.error(util.c("failed to identify reconnected RTU unit type (", unit.name, ")"), true)
end
if unit.is_multiblock then
unit.hw_state = UNIT_HW_STATE.UNFORMED
if unit.formed == false then
log.info(util.c("assuming ", unit.name, " is not formed due to PPM faults while initializing"))
end
elseif faulted then
unit.hw_state = UNIT_HW_STATE.FAULTED
elseif not unknown then
unit.hw_state = UNIT_HW_STATE.OK
else
unit.hw_state = UNIT_HW_STATE.OFFLINE
end
databus.tx_unit_hw_status(unit.uid, unit.hw_state)
if not unknown then
unit.modbus_io = modbus.new(unit.rtu, true)
local type_name = types.rtu_type_to_string(unit.type)
local message = util.c("reconnected the ", type_name, " on interface ", unit.name)
println_ts(message)
log.info(message)
if resend_advert then
sys.rtu_comms.send_advertisement(sys.units)
else
sys.rtu_comms.send_remounted(unit.uid)
end
end
end
end
-- main thread
---@nodiscard
---@param smem rtu_shared_memory
@ -180,102 +348,7 @@ function threads.thread__main(smem)
else
-- relink lost peripheral to correct unit entry
for i = 1, #units do
local unit = units[i] ---@type rtu_unit_registry_entry
-- find disconnected device to reconnect
-- note: cannot check isFormed as that would yield this coroutine and consume events
if unit.name == param1 then
local resend_advert = false
local faulted = false
local unknown = false
-- found, re-link
unit.device = device
if unit.type == RTU_UNIT_TYPE.VIRTUAL then
resend_advert = true
if type == "boilerValve" then
-- boiler multiblock
unit.type = RTU_UNIT_TYPE.BOILER_VALVE
elseif type == "turbineValve" then
-- turbine multiblock
unit.type = RTU_UNIT_TYPE.TURBINE_VALVE
elseif type == "inductionPort" then
-- induction matrix multiblock
unit.type = RTU_UNIT_TYPE.IMATRIX
elseif type == "spsPort" then
-- SPS multiblock
unit.type = RTU_UNIT_TYPE.SPS
elseif type == "solarNeutronActivator" then
-- SNA
unit.type = RTU_UNIT_TYPE.SNA
elseif type == "environmentDetector" then
-- advanced peripherals environment detector
unit.type = RTU_UNIT_TYPE.ENV_DETECTOR
else
resend_advert = false
log.error(util.c("virtual device '", unit.name, "' cannot init to an unknown type (", type, ")"))
end
databus.tx_unit_hw_type(unit.uid, unit.type)
end
-- note for multiblock structures: if not formed, indexing the multiblock functions results in a PPM fault
if unit.type == RTU_UNIT_TYPE.BOILER_VALVE then
unit.rtu, faulted = boilerv_rtu.new(device)
unit.formed = util.trinary(faulted, false, nil)
elseif unit.type == RTU_UNIT_TYPE.TURBINE_VALVE then
unit.rtu, faulted = turbinev_rtu.new(device)
unit.formed = util.trinary(faulted, false, nil)
elseif unit.type == RTU_UNIT_TYPE.DYNAMIC_VALVE then
unit.rtu, faulted = dynamicv_rtu.new(device)
unit.formed = util.trinary(faulted, false, nil)
elseif unit.type == RTU_UNIT_TYPE.IMATRIX then
unit.rtu, faulted = imatrix_rtu.new(device)
unit.formed = util.trinary(faulted, false, nil)
elseif unit.type == RTU_UNIT_TYPE.SPS then
unit.rtu, faulted = sps_rtu.new(device)
unit.formed = util.trinary(faulted, false, nil)
elseif unit.type == RTU_UNIT_TYPE.SNA then
unit.rtu, faulted = sna_rtu.new(device)
elseif unit.type == RTU_UNIT_TYPE.ENV_DETECTOR then
unit.rtu, faulted = envd_rtu.new(device)
else
unknown = true
log.error(util.c("failed to identify reconnected RTU unit type (", unit.name, ")"), true)
end
if unit.is_multiblock then
unit.hw_state = UNIT_HW_STATE.UNFORMED
if unit.formed == false then
log.info(util.c("assuming ", unit.name, " is not formed due to PPM faults while initializing"))
end
elseif faulted then
unit.hw_state = UNIT_HW_STATE.FAULTED
elseif not unknown then
unit.hw_state = UNIT_HW_STATE.OK
else
unit.hw_state = UNIT_HW_STATE.OFFLINE
end
databus.tx_unit_hw_status(unit.uid, unit.hw_state)
if not unknown then
unit.modbus_io = modbus.new(unit.rtu, true)
local type_name = types.rtu_type_to_string(unit.type)
local message = util.c("reconnected the ", type_name, " on interface ", unit.name)
println_ts(message)
log.info(message)
if resend_advert then
rtu_comms.send_advertisement(units)
else
rtu_comms.send_remounted(unit.uid)
end
end
end
handle_unit_mount(smem, println_ts, param1, type, device, units[i])
end
end
end

View File

@ -285,6 +285,18 @@ function rsio.is_color(color)
return util.is_int(color) and (color > 0) and (_B_AND(color, (color - 1)) == 0)
end
-- color to string
---@nodiscard
---@param color color
---@return string
function rsio.color_name(color)
local color_name_map = { [colors.red] = "red", [colors.orange] = "orange", [colors.yellow] = "yellow", [colors.lime] = "lime", [colors.green] = "green", [colors.cyan] = "cyan", [colors.lightBlue] = "lightBlue", [colors.blue] = "blue", [colors.purple] = "purple", [colors.magenta] = "magenta", [colors.pink] = "pink", [colors.white] = "white", [colors.lightGray] = "lightGray", [colors.gray] = "gray", [colors.black] = "black", [colors.brown] = "brown" }
if rsio.is_color(color) then
return color_name_map[color]
else return "unknown" end
end
--#endregion
--#region Digital I/O