Merge pull request #215 from MikaylaFischler/193-pocket-main-application

193 pocket main application
This commit is contained in:
Mikayla 2023-04-19 23:01:39 -04:00 committed by GitHub
commit df45f6c984
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
39 changed files with 2226 additions and 232 deletions

View File

@ -21,8 +21,12 @@ jobs:
- name: Luacheck
uses: lunarmodules/luacheck@v1.1.0
with:
# -a = disable warning for unused arguments
# -i 121 = Setting a read-only global variable.
# -u 512 = Loop can be executed at most once.
# -i 542 = An empty if branch.
args: . --no-max-line-length -a -i 121 512 542 --exclude-files ./lockbox/* ./*/config.lua --globals _HOST term fs peripheral rs bit parallel colors textutils shell settings window read periphemu http os
# Argument Explanations
# -a = Disable warning for unused arguments
# -i 121 = Setting a read-only global variable
# 512 = Loop can be executed at most once
# 542 = An empty if branch
# --no-max-line-length = Disable warnings for long line lengths
# --exclude-files ... = Exclude lockbox library (external) and config files
# --globals ... = Override all globals overridden in .vscode/settings.json AND 'os' since CraftOS 'os' differs from Lua's 'os'
args: . --no-max-line-length -a -i 121 512 542 --exclude-files ./lockbox/* ./*/config.lua --globals os _HOST bit colors fs http parallel periphemu peripheral read rs settings shell term textutils window

35
.vscode/settings.json vendored
View File

@ -1,21 +1,28 @@
{
"Lua.diagnostics.globals": [
"term",
"fs",
"peripheral",
"rs",
"bit",
"parallel",
"colors",
"textutils",
"shell",
"settings",
"window",
"read",
"periphemu",
"_HOST",
"http"
"bit",
"colors",
"fs",
"http",
"parallel",
"periphemu",
"peripheral",
"read",
"rs",
"settings",
"shell",
"term",
"textutils",
"window"
],
"Lua.diagnostics.severity": {
"unused-local": "Information",
"unused-vararg": "Information",
"unused-function": "Warning",
"unused-label": "Information"
},
"Lua.hint.setType": true,
"Lua.diagnostics.disable": [
"duplicate-set-field"
]

View File

@ -1,22 +0,0 @@
local apisessions = {}
---@param packet capi_frame
---@diagnostic disable-next-line: unused-local
function apisessions.handle_packet(packet)
end
-- attempt to identify which session's watchdog timer fired
---@param timer_event number
---@diagnostic disable-next-line: unused-local
function apisessions.check_all_watchdogs(timer_event)
end
-- delete all closed sessions
function apisessions.free_all_closed()
end
-- close all open connections
function apisessions.close_all()
end
return apisessions

View File

@ -3,13 +3,14 @@ local config = {}
-- port of the SCADA supervisor
config.SCADA_SV_PORT = 16100
-- port to listen to incoming packets from supervisor
config.SCADA_SV_LISTEN = 16101
config.SCADA_SV_CTL_LISTEN = 16101
-- listen port for SCADA coordinator API access
config.SCADA_API_LISTEN = 16200
-- 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
config.SV_TIMEOUT = 5
config.API_TIMEOUT = 5
-- expected number of reactor units, used only to require that number of unit monitors
config.NUM_UNITS = 4

View File

@ -3,10 +3,11 @@ local log = require("scada-common.log")
local ppm = require("scada-common.ppm")
local util = require("scada-common.util")
local apisessions = require("coordinator.apisessions")
local iocontrol = require("coordinator.iocontrol")
local process = require("coordinator.process")
local apisessions = require("coordinator.session.apisessions")
local dialog = require("coordinator.ui.dialog")
local print = util.print
@ -224,7 +225,8 @@ function coordinator.comms(version, modem, sv_port, sv_listen, api_listen, range
sv_r_seq_num = nil,
sv_config_err = false,
connected = false,
last_est_ack = ESTABLISH_ACK.ALLOW
last_est_ack = ESTABLISH_ACK.ALLOW,
last_api_est_acks = {}
}
comms.set_trusted_range(range)
@ -240,6 +242,9 @@ function coordinator.comms(version, modem, sv_port, sv_listen, api_listen, range
_conf_channels()
-- link modem to apisessions
apisessions.init(modem)
-- send a packet to the supervisor
---@param msg_type SCADA_MGMT_TYPE|SCADA_CRDN_TYPE
---@param msg table
@ -262,6 +267,19 @@ function coordinator.comms(version, modem, sv_port, sv_listen, api_listen, range
self.sv_seq_num = self.sv_seq_num + 1
end
-- send an API establish request response
---@param dest integer
---@param msg table
local function _send_api_establish_ack(seq_id, dest, msg)
local s_pkt = comms.scada_packet()
local m_pkt = comms.mgmt_packet()
m_pkt.make(SCADA_MGMT_TYPE.ESTABLISH, msg)
s_pkt.make(seq_id, PROTOCOL.SCADA_MGMT, m_pkt.raw_sendable())
modem.transmit(dest, api_listen, s_pkt.raw_sendable())
end
-- attempt connection establishment
local function _send_establish()
_send_sv(PROTOCOL.SCADA_MGMT, SCADA_MGMT_TYPE.ESTABLISH, { comms.version, version, DEVICE_TYPE.CRDN })
@ -282,6 +300,7 @@ function coordinator.comms(version, modem, sv_port, sv_listen, api_listen, range
---@param new_modem table
function public.reconnect_modem(new_modem)
modem = new_modem
apisessions.relink_modem(new_modem)
_conf_channels()
end
@ -416,13 +435,70 @@ function coordinator.comms(version, modem, sv_port, sv_listen, api_listen, range
---@param packet mgmt_frame|crdn_frame|capi_frame|nil
function public.handle_packet(packet)
if packet ~= nil then
local protocol = packet.scada_frame.protocol()
local l_port = packet.scada_frame.local_port()
local r_port = packet.scada_frame.remote_port()
local protocol = packet.scada_frame.protocol()
if l_port == api_listen then
if protocol == PROTOCOL.COORD_API then
---@cast packet capi_frame
apisessions.handle_packet(packet)
-- look for an associated session
local session = apisessions.find_session(r_port)
-- API packet
if session ~= nil then
-- pass the packet onto the session handler
session.in_queue.push_packet(packet)
else
-- any other packet should be session related, discard it
log.debug("discarding COORD_API packet without a known session")
end
elseif protocol == PROTOCOL.SCADA_MGMT then
---@cast packet mgmt_frame
-- look for an associated session
local session = apisessions.find_session(r_port)
-- SCADA management packet
if session ~= nil then
-- pass the packet onto the session handler
session.in_queue.push_packet(packet)
elseif packet.type == SCADA_MGMT_TYPE.ESTABLISH then
-- establish a new session
local next_seq_id = packet.scada_frame.seq_num() + 1
-- validate packet and continue
if packet.length == 3 and type(packet.data[1]) == "string" and type(packet.data[2]) == "string" then
local comms_v = packet.data[1]
local firmware_v = packet.data[2]
local dev_type = packet.data[3]
if comms_v ~= comms.version then
if self.last_api_est_acks[r_port] ~= ESTABLISH_ACK.BAD_VERSION then
log.info(util.c("dropping API establish packet with incorrect comms version v", comms_v, " (expected v", comms.version, ")"))
self.last_api_est_acks[r_port] = ESTABLISH_ACK.BAD_VERSION
end
_send_api_establish_ack(next_seq_id, r_port, { ESTABLISH_ACK.BAD_VERSION })
elseif dev_type == DEVICE_TYPE.PKT then
-- pocket linking request
local id = apisessions.establish_session(l_port, r_port, firmware_v)
println(util.c("API: pocket (", firmware_v, ") [:", r_port, "] connected with session ID ", id))
coordinator.log_comms(util.c("API: pocket (", firmware_v, ") [:", r_port, "] connected with session ID ", id))
_send_api_establish_ack(next_seq_id, r_port, { ESTABLISH_ACK.ALLOW })
self.last_api_est_acks[r_port] = ESTABLISH_ACK.ALLOW
else
log.debug(util.c("illegal establish packet for device ", dev_type, " on API listening channel"))
_send_api_establish_ack(next_seq_id, r_port, { ESTABLISH_ACK.DENY })
end
else
log.debug("invalid establish packet (on API listening channel)")
_send_api_establish_ack(next_seq_id, r_port, { ESTABLISH_ACK.DENY })
end
else
-- any other packet should be session related, discard it
log.debug(util.c(r_port, "->", l_port, ": discarding SCADA_MGMT packet without a known session"))
end
else
log.debug("illegal packet type " .. protocol .. " on api listening channel", true)
end

View File

@ -18,6 +18,11 @@ local iocontrol = {}
---@class ioctl
local io = {}
-- placeholder acknowledge function for type hinting
---@param success boolean
---@diagnostic disable-next-line: unused-local
local function __generic_ack(success) end
-- initialize the coordinator IO controller
---@param conf facility_conf configuration
---@param comms coord_comms comms reference
@ -45,11 +50,11 @@ function iocontrol.init(conf, comms)
radiation = types.new_zero_radiation_reading(),
save_cfg_ack = function (success) end, ---@param success boolean
start_ack = function (success) end, ---@param success boolean
stop_ack = function (success) end, ---@param success boolean
scram_ack = function (success) end, ---@param success boolean
ack_alarms_ack = function (success) end, ---@param success boolean
save_cfg_ack = __generic_ack,
start_ack = __generic_ack,
stop_ack = __generic_ack,
scram_ack = __generic_ack,
ack_alarms_ack = __generic_ack,
ps = psil.create(),
@ -74,7 +79,6 @@ function iocontrol.init(conf, comms)
---@class ioctl_unit
local entry = {
---@type integer
unit_id = i,
num_boilers = 0,
@ -85,7 +89,8 @@ function iocontrol.init(conf, comms)
waste_control = 0,
radiation = types.new_zero_radiation_reading(),
a_group = 0, -- auto control group
-- auto control group
a_group = 0,
start = function () process.start(i) end,
scram = function () process.scram(i) end,
@ -96,12 +101,12 @@ function iocontrol.init(conf, comms)
set_group = function (grp) process.set_group(i, grp) end, ---@param grp integer|0 group ID or 0
start_ack = function (success) end, ---@param success boolean
scram_ack = function (success) end, ---@param success boolean
reset_rps_ack = function (success) end, ---@param success boolean
ack_alarms_ack = function (success) end, ---@param success boolean
set_burn_ack = function (success) end, ---@param success boolean
set_waste_ack = function (success) end, ---@param success boolean
start_ack = __generic_ack,
scram_ack = __generic_ack,
reset_rps_ack = __generic_ack,
ack_alarms_ack = __generic_ack,
set_burn_ack = __generic_ack,
set_waste_ack = __generic_ack,
alarm_callbacks = {
c_breach = { ack = function () ack(1) end, reset = function () reset(1) end },
@ -134,10 +139,10 @@ function iocontrol.init(conf, comms)
ALARM_STATE.INACTIVE -- turbine trip
},
annunciator = {}, ---@type annunciator
annunciator = {}, ---@type annunciator
unit_ps = psil.create(),
reactor_data = {}, ---@type reactor_db
reactor_data = {}, ---@type reactor_db
boiler_ps_tbl = {},
boiler_data_tbl = {},

251
coordinator/session/api.lua Normal file
View File

@ -0,0 +1,251 @@
local comms = require("scada-common.comms")
local log = require("scada-common.log")
local mqueue = require("scada-common.mqueue")
local util = require("scada-common.util")
local api = {}
local PROTOCOL = comms.PROTOCOL
-- local CAPI_TYPE = comms.CAPI_TYPE
local SCADA_MGMT_TYPE = comms.SCADA_MGMT_TYPE
local println = util.println
-- retry time constants in ms
-- local INITIAL_WAIT = 1500
-- local RETRY_PERIOD = 1000
local API_S_CMDS = {
}
local API_S_DATA = {
}
api.API_S_CMDS = API_S_CMDS
api.API_S_DATA = API_S_DATA
local PERIODICS = {
KEEP_ALIVE = 2000
}
-- pocket API session
---@nodiscard
---@param id integer session ID
---@param in_queue mqueue in message queue
---@param out_queue mqueue out message queue
---@param timeout number communications timeout
function api.new_session(id, in_queue, out_queue, timeout)
local log_header = "api_session(" .. id .. "): "
local self = {
-- connection properties
seq_num = 0,
r_seq_num = nil,
connected = true,
conn_watchdog = util.new_watchdog(timeout),
last_rtt = 0,
-- periodic messages
periodics = {
last_update = 0,
keep_alive = 0
},
-- when to next retry one of these requests
retry_times = {
},
-- command acknowledgements
acks = {
},
-- session database
---@class api_db
sDB = {
}
}
---@class api_session
local public = {}
-- mark this API session as closed, stop watchdog
local function _close()
self.conn_watchdog.cancel()
self.connected = false
end
-- send a CAPI packet
---@param msg_type CAPI_TYPE
---@param msg table
-- local function _send(msg_type, msg)
-- local s_pkt = comms.scada_packet()
-- local c_pkt = comms.capi_packet()
-- c_pkt.make(msg_type, msg)
-- s_pkt.make(self.seq_num, PROTOCOL.COORD_API, c_pkt.raw_sendable())
-- out_queue.push_packet(s_pkt)
-- self.seq_num = self.seq_num + 1
-- end
-- send a SCADA management packet
---@param msg_type SCADA_MGMT_TYPE
---@param msg table
local function _send_mgmt(msg_type, msg)
local s_pkt = comms.scada_packet()
local m_pkt = comms.mgmt_packet()
m_pkt.make(msg_type, msg)
s_pkt.make(self.seq_num, PROTOCOL.SCADA_MGMT, m_pkt.raw_sendable())
out_queue.push_packet(s_pkt)
self.seq_num = self.seq_num + 1
end
-- handle a packet
---@param pkt mgmt_frame|capi_frame
local function _handle_packet(pkt)
-- check sequence number
if self.r_seq_num == nil then
self.r_seq_num = pkt.scada_frame.seq_num()
elseif self.r_seq_num >= pkt.scada_frame.seq_num() then
log.warning(log_header .. "sequence out-of-order: last = " .. self.r_seq_num .. ", new = " .. pkt.scada_frame.seq_num())
return
else
self.r_seq_num = pkt.scada_frame.seq_num()
end
-- feed watchdog
self.conn_watchdog.feed()
-- process packet
if pkt.scada_frame.protocol() == PROTOCOL.COORD_API then
---@cast pkt capi_frame
-- feed watchdog
self.conn_watchdog.feed()
-- handle packet by type
if pkt.type == nil then
else
log.debug(log_header .. "handler received unsupported CAPI packet type " .. pkt.type)
end
elseif pkt.scada_frame.protocol() == PROTOCOL.SCADA_MGMT then
---@cast pkt mgmt_frame
if pkt.type == SCADA_MGMT_TYPE.KEEP_ALIVE then
-- keep alive reply
if pkt.length == 2 then
local srv_start = pkt.data[1]
-- local api_send = pkt.data[2]
local srv_now = util.time()
self.last_rtt = srv_now - srv_start
if self.last_rtt > 750 then
log.warning(log_header .. "API KEEP_ALIVE round trip time > 750ms (" .. self.last_rtt .. "ms)")
end
-- log.debug(log_header .. "API RTT = " .. self.last_rtt .. "ms")
-- log.debug(log_header .. "API TT = " .. (srv_now - api_send) .. "ms")
else
log.debug(log_header .. "SCADA keep alive packet length mismatch")
end
elseif pkt.type == SCADA_MGMT_TYPE.CLOSE then
-- close the session
_close()
else
log.debug(log_header .. "handler received unsupported SCADA_MGMT packet type " .. pkt.type)
end
end
end
-- PUBLIC FUNCTIONS --
-- get the session ID
---@nodiscard
function public.get_id() return id end
-- get the session database
---@nodiscard
function public.get_db() return self.sDB end
-- check if a timer matches this session's watchdog
---@nodiscard
function public.check_wd(timer)
return self.conn_watchdog.is_timer(timer) and self.connected
end
-- close the connection
function public.close()
_close()
_send_mgmt(SCADA_MGMT_TYPE.CLOSE, {})
println("connection to API session " .. id .. " closed by server")
log.info(log_header .. "session closed by server")
end
-- iterate the session
---@nodiscard
---@return boolean connected
function public.iterate()
if self.connected then
------------------
-- handle queue --
------------------
local handle_start = util.time()
while in_queue.ready() and self.connected do
-- get a new message to process
local message = in_queue.pop()
if message ~= nil then
if message.qtype == mqueue.TYPE.PACKET then
-- handle a packet
_handle_packet(message.message)
elseif message.qtype == mqueue.TYPE.COMMAND then
-- handle instruction
elseif message.qtype == mqueue.TYPE.DATA then
-- instruction with body
end
end
-- max 100ms spent processing queue
if util.time() - handle_start > 100 then
log.warning(log_header .. "exceeded 100ms queue process limit")
break
end
end
-- exit if connection was closed
if not self.connected then
println("connection to API session " .. id .. " closed by remote host")
log.info(log_header .. "session closed by remote host")
return self.connected
end
----------------------
-- update periodics --
----------------------
local elapsed = util.time() - self.periodics.last_update
local periodics = self.periodics
-- keep alive
periodics.keep_alive = periodics.keep_alive + elapsed
if periodics.keep_alive >= PERIODICS.KEEP_ALIVE then
_send_mgmt(SCADA_MGMT_TYPE.KEEP_ALIVE, { util.time() })
periodics.keep_alive = 0
end
self.periodics.last_update = util.time()
---------------------
-- attempt retries --
---------------------
-- local rtimes = self.retry_times
end
return self.connected
end
return public
end
return api

View File

@ -0,0 +1,174 @@
local log = require("scada-common.log")
local mqueue = require("scada-common.mqueue")
local util = require("scada-common.util")
local config = require("coordinator.config")
local api = require("coordinator.session.api")
local apisessions = {}
local self = {
modem = nil,
next_id = 0,
sessions = {}
}
-- PRIVATE FUNCTIONS --
-- handle a session output queue
---@param session api_session_struct
local function _api_handle_outq(session)
-- record handler start time
local handle_start = util.time()
-- process output queue
while session.out_queue.ready() do
-- get a new message to process
local msg = session.out_queue.pop()
if msg ~= nil then
if msg.qtype == mqueue.TYPE.PACKET then
-- handle a packet to be sent
self.modem.transmit(session.r_port, session.l_port, msg.message.raw_sendable())
elseif msg.qtype == mqueue.TYPE.COMMAND then
-- handle instruction/notification
elseif msg.qtype == mqueue.TYPE.DATA then
-- instruction/notification with body
end
end
-- max 100ms spent processing queue
if util.time() - handle_start > 100 then
log.warning("API out queue handler exceeded 100ms queue process limit")
log.warning(util.c("offending session: port ", session.r_port))
break
end
end
end
-- cleanly close a session
---@param session api_session_struct
local function _shutdown(session)
session.open = false
session.instance.close()
-- send packets in out queue (namely the close packet)
while session.out_queue.ready() do
local msg = session.out_queue.pop()
if msg ~= nil and msg.qtype == mqueue.TYPE.PACKET then
self.modem.transmit(session.r_port, session.l_port, msg.message.raw_sendable())
end
end
log.debug(util.c("closed API session ", session.instance.get_id(), " on remote port ", session.r_port))
end
-- PUBLIC FUNCTIONS --
-- initialize apisessions
---@param modem table
function apisessions.init(modem)
self.modem = modem
end
-- re-link the modem
---@param modem table
function apisessions.relink_modem(modem)
self.modem = modem
end
-- find a session by remote port
---@nodiscard
---@param port integer
---@return api_session_struct|nil
function apisessions.find_session(port)
for i = 1, #self.sessions do
if self.sessions[i].r_port == port then return self.sessions[i] end
end
return nil
end
-- establish a new API session
---@nodiscard
---@param local_port integer
---@param remote_port integer
---@param version string
---@return integer session_id
function apisessions.establish_session(local_port, remote_port, version)
---@class api_session_struct
local api_s = {
open = true,
version = version,
l_port = local_port,
r_port = remote_port,
in_queue = mqueue.new(),
out_queue = mqueue.new(),
instance = nil ---@type api_session
}
api_s.instance = api.new_session(self.next_id, api_s.in_queue, api_s.out_queue, config.API_TIMEOUT)
table.insert(self.sessions, api_s)
log.debug(util.c("established new API session to ", remote_port, " with ID ", self.next_id))
self.next_id = self.next_id + 1
-- success
return api_s.instance.get_id()
end
-- attempt to identify which session's watchdog timer fired
---@param timer_event number
function apisessions.check_all_watchdogs(timer_event)
for i = 1, #self.sessions do
local session = self.sessions[i] ---@type api_session_struct
if session.open then
local triggered = session.instance.check_wd(timer_event)
if triggered then
log.debug(util.c("watchdog closing API session ", session.instance.get_id(),
" on remote port ", session.r_port, "..."))
_shutdown(session)
end
end
end
end
-- iterate all the API sessions
function apisessions.iterate_all()
for i = 1, #self.sessions do
local session = self.sessions[i] ---@type api_session_struct
if session.open and session.instance.iterate() then
_api_handle_outq(session)
else
session.open = false
end
end
end
-- delete all closed sessions
function apisessions.free_all_closed()
local f = function (session) return session.open end
---@param session api_session_struct
local on_delete = function (session)
log.debug(util.c("free'ing closed API session ", session.instance.get_id(),
" on remote port ", session.r_port))
end
util.filter_table(self.sessions, f, on_delete)
end
-- close all open connections
function apisessions.close_all()
for i = 1, #self.sessions do
local session = self.sessions[i] ---@type api_session_struct
if session.open then _shutdown(session) end
end
apisessions.free_all_closed()
end
return apisessions

View File

@ -12,14 +12,15 @@ local util = require("scada-common.util")
local core = require("graphics.core")
local apisessions = require("coordinator.apisessions")
local config = require("coordinator.config")
local coordinator = require("coordinator.coordinator")
local iocontrol = require("coordinator.iocontrol")
local renderer = require("coordinator.renderer")
local sounder = require("coordinator.sounder")
local COORDINATOR_VERSION = "v0.12.7"
local apisessions = require("coordinator.session.apisessions")
local COORDINATOR_VERSION = "v0.13.3"
local println = util.println
local println_ts = util.println_ts
@ -37,11 +38,13 @@ local log_comms_connecting = coordinator.log_comms_connecting
local cfv = util.new_validator()
cfv.assert_port(config.SCADA_SV_PORT)
cfv.assert_port(config.SCADA_SV_LISTEN)
cfv.assert_port(config.SCADA_SV_CTL_LISTEN)
cfv.assert_port(config.SCADA_API_LISTEN)
cfv.assert_type_int(config.TRUSTED_RANGE)
cfv.assert_type_num(config.COMMS_TIMEOUT)
cfv.assert_min(config.COMMS_TIMEOUT, 2)
cfv.assert_type_num(config.SV_TIMEOUT)
cfv.assert_min(config.SV_TIMEOUT, 2)
cfv.assert_type_num(config.API_TIMEOUT)
cfv.assert_min(config.API_TIMEOUT, 2)
cfv.assert_type_int(config.NUM_UNITS)
cfv.assert_type_num(config.SOUNDER_VOLUME)
cfv.assert_type_bool(config.TIME_24_HOUR)
@ -140,12 +143,12 @@ local function main()
end
-- create connection watchdog
local conn_watchdog = util.new_watchdog(config.COMMS_TIMEOUT)
local conn_watchdog = util.new_watchdog(config.SV_TIMEOUT)
conn_watchdog.cancel()
log.debug("startup> conn watchdog created")
-- start comms, open all channels
local coord_comms = coordinator.comms(COORDINATOR_VERSION, modem, config.SCADA_SV_PORT, config.SCADA_SV_LISTEN,
local coord_comms = coordinator.comms(COORDINATOR_VERSION, modem, config.SCADA_SV_PORT, config.SCADA_SV_CTL_LISTEN,
config.SCADA_API_LISTEN, config.TRUSTED_RANGE, conn_watchdog)
log.debug("startup> comms init")
log_comms("comms initialized")
@ -298,6 +301,9 @@ local function main()
if loop_clock.is_clock(param1) then
-- main loop tick
-- iterate sessions
apisessions.iterate_all()
-- free any closed sessions
apisessions.free_all_closed()
@ -324,7 +330,7 @@ local function main()
else
-- a non-clock/main watchdog timer event
--check API watchdogs
-- check API watchdogs
apisessions.check_all_watchdogs(param1)
-- notify timer callback dispatcher

View File

@ -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

View File

@ -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
@ -44,6 +45,7 @@ local element = {}
---|colormap_args
---|displaybox_args
---|div_args
---|multipane_args
---|pipenet_args
---|rectangle_args
---|textbox_args
@ -166,6 +168,8 @@ function element.new(args)
self.bounds.y2 = self.position.y + f.h - 1
end
---@diagnostic disable: unused-local, unused-vararg
-- handle a mouse event
---@param event mouse_interaction mouse interaction event
function protected.handle_mouse(event)
@ -220,6 +224,8 @@ function element.new(args)
function protected.resize(...)
end
---@diagnostic enable: unused-local, unused-vararg
-- start animations
function protected.start_anim()
end

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,42 @@
-- Multi-Pane Display Graphics Element
local element = require("graphics.element")
---@class multipane_args
---@field panes table panes to swap between
---@field parent graphics_element
---@field id? string element id
---@field x? integer 1 if omitted
---@field y? integer 1 if omitted
---@field width? integer parent width if omitted
---@field height? integer parent height if omitted
---@field gframe? graphics_frame frame instead of x/y/width/height
---@field fg_bg? cpair foreground/background colors
-- new multipane element
---@nodiscard
---@param args multipane_args
---@return graphics_element element, element_id id
local function multipane(args)
assert(type(args.panes) == "table", "graphics.elements.multipane: panes is a required field")
-- create new graphics element base object
local e = element.new(args)
-- select which pane is shown
---@param value integer pane to show
function e.set_value(value)
if (e.value ~= value) and (value > 0) and (value <= #args.panes) then
e.value = value
for i = 1, #args.panes do args.panes[i].hide() end
args.panes[value].show()
end
end
e.set_value(1)
return e.get()
end
return multipane

View File

@ -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

35
pocket/coreio.lua Normal file
View File

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

408
pocket/pocket.lua Normal file
View File

@ -0,0 +1,408 @@
local comms = require("scada-common.comms")
local log = require("scada-common.log")
local util = require("scada-common.util")
local coreio = require("pocket.coreio")
local PROTOCOL = comms.PROTOCOL
local DEVICE_TYPE = comms.DEVICE_TYPE
local ESTABLISH_ACK = comms.ESTABLISH_ACK
local SCADA_MGMT_TYPE = comms.SCADA_MGMT_TYPE
-- local CAPI_TYPE = comms.CAPI_TYPE
local LINK_STATE = coreio.LINK_STATE
local pocket = {}
-- pocket coordinator + supervisor communications
---@nodiscard
---@param version string pocket version
---@param modem table modem device
---@param local_port integer local pocket port
---@param sv_port integer port of supervisor
---@param api_port integer port of coordinator API
---@param range integer trusted device connection range
---@param sv_watchdog watchdog
---@param api_watchdog watchdog
function pocket.comms(version, modem, local_port, sv_port, api_port, range, sv_watchdog, api_watchdog)
local self = {
sv = {
linked = false,
seq_num = 0,
r_seq_num = nil, ---@type nil|integer
last_est_ack = ESTABLISH_ACK.ALLOW
},
api = {
linked = false,
seq_num = 0,
r_seq_num = nil, ---@type nil|integer
last_est_ack = ESTABLISH_ACK.ALLOW
},
establish_delay_counter = 0
}
comms.set_trusted_range(range)
-- PRIVATE FUNCTIONS --
-- configure modem channels
local function _conf_channels()
modem.closeAll()
modem.open(local_port)
end
_conf_channels()
-- send a management packet to the supervisor
---@param msg_type SCADA_MGMT_TYPE
---@param msg table
local function _send_sv(msg_type, msg)
local s_pkt = comms.scada_packet()
local pkt = comms.mgmt_packet()
pkt.make(msg_type, msg)
s_pkt.make(self.sv.seq_num, PROTOCOL.SCADA_MGMT, pkt.raw_sendable())
modem.transmit(sv_port, local_port, s_pkt.raw_sendable())
self.sv.seq_num = self.sv.seq_num + 1
end
-- send a management packet to the coordinator
---@param msg_type SCADA_MGMT_TYPE
---@param msg table
local function _send_crd(msg_type, msg)
local s_pkt = comms.scada_packet()
local pkt = comms.mgmt_packet()
pkt.make(msg_type, msg)
s_pkt.make(self.api.seq_num, PROTOCOL.SCADA_MGMT, pkt.raw_sendable())
modem.transmit(api_port, local_port, s_pkt.raw_sendable())
self.api.seq_num = self.api.seq_num + 1
end
-- send a packet to the coordinator API
---@param msg_type CAPI_TYPE
---@param msg table
-- local function _send_api(msg_type, msg)
-- local s_pkt = comms.scada_packet()
-- local pkt = comms.capi_packet()
-- pkt.make(msg_type, msg)
-- s_pkt.make(self.api.seq_num, PROTOCOL.COORD_API, pkt.raw_sendable())
-- modem.transmit(api_port, local_port, s_pkt.raw_sendable())
-- self.api.seq_num = self.api.seq_num + 1
-- end
-- attempt supervisor connection establishment
local function _send_sv_establish()
_send_sv(SCADA_MGMT_TYPE.ESTABLISH, { comms.version, version, DEVICE_TYPE.PKT })
end
-- attempt coordinator API connection establishment
local function _send_api_establish()
_send_crd(SCADA_MGMT_TYPE.ESTABLISH, { comms.version, version, DEVICE_TYPE.PKT })
end
-- keep alive ack to supervisor
---@param srv_time integer
local function _send_sv_keep_alive_ack(srv_time)
_send_sv(SCADA_MGMT_TYPE.KEEP_ALIVE, { srv_time, util.time() })
end
-- keep alive ack to coordinator
---@param srv_time integer
local function _send_api_keep_alive_ack(srv_time)
_send_crd(SCADA_MGMT_TYPE.KEEP_ALIVE, { srv_time, util.time() })
end
-- PUBLIC FUNCTIONS --
---@class pocket_comms
local public = {}
-- reconnect a newly connected modem
---@param new_modem table
function public.reconnect_modem(new_modem)
modem = new_modem
_conf_channels()
end
-- close connection to the supervisor
function public.close_sv()
sv_watchdog.cancel()
self.sv.linked = false
_send_sv(SCADA_MGMT_TYPE.CLOSE, {})
end
-- close connection to coordinator API server
function public.close_api()
api_watchdog.cancel()
self.api.linked = false
_send_crd(SCADA_MGMT_TYPE.CLOSE, {})
end
-- close the connections to the servers
function public.close()
public.close_sv()
public.close_api()
end
-- attempt to re-link if any of the dependent links aren't active
function public.link_update()
if not self.sv.linked then
coreio.report_link_state(util.trinary(self.api.linked, LINK_STATE.API_LINK_ONLY, LINK_STATE.UNLINKED))
if self.establish_delay_counter <= 0 then
_send_sv_establish()
self.establish_delay_counter = 4
else
self.establish_delay_counter = self.establish_delay_counter - 1
end
elseif not self.api.linked then
coreio.report_link_state(LINK_STATE.SV_LINK_ONLY)
if self.establish_delay_counter <= 0 then
_send_api_establish()
self.establish_delay_counter = 4
else
self.establish_delay_counter = self.establish_delay_counter - 1
end
else
-- linked, all good!
coreio.report_link_state(LINK_STATE.LINKED)
end
end
-- parse a packet
---@param side string
---@param sender integer
---@param reply_to integer
---@param message any
---@param distance integer
---@return mgmt_frame|capi_frame|nil packet
function public.parse_packet(side, sender, reply_to, message, distance)
local pkt = nil
local s_pkt = comms.scada_packet()
-- parse packet as generic SCADA packet
s_pkt.receive(side, sender, reply_to, message, distance)
if s_pkt.is_valid() then
-- get as SCADA management packet
if s_pkt.protocol() == PROTOCOL.SCADA_MGMT then
local mgmt_pkt = comms.mgmt_packet()
if mgmt_pkt.decode(s_pkt) then
pkt = mgmt_pkt.get()
end
-- get as coordinator API packet
elseif s_pkt.protocol() == PROTOCOL.COORD_API then
local capi_pkt = comms.capi_packet()
if capi_pkt.decode(s_pkt) then
pkt = capi_pkt.get()
end
else
log.debug("attempted parse of illegal packet type " .. s_pkt.protocol(), true)
end
end
return pkt
end
-- handle a packet
---@param packet mgmt_frame|capi_frame|nil
function public.handle_packet(packet)
if packet ~= nil then
local l_port = packet.scada_frame.local_port()
local r_port = packet.scada_frame.remote_port()
local protocol = packet.scada_frame.protocol()
if l_port ~= local_port then
log.debug("received packet on unconfigured channel " .. l_port, true)
elseif r_port == api_port then
-- check sequence number
if self.api.r_seq_num == nil then
self.api.r_seq_num = packet.scada_frame.seq_num()
elseif self.connected and self.api.r_seq_num >= packet.scada_frame.seq_num() then
log.warning("sequence out-of-order: last = " .. self.api.r_seq_num .. ", new = " .. packet.scada_frame.seq_num())
return
else
self.api.r_seq_num = packet.scada_frame.seq_num()
end
-- feed watchdog on valid sequence number
api_watchdog.feed()
if protocol == PROTOCOL.COORD_API then
---@cast packet capi_frame
elseif protocol == PROTOCOL.SCADA_MGMT then
---@cast packet mgmt_frame
if packet.type == SCADA_MGMT_TYPE.ESTABLISH then
-- connection with coordinator established
if packet.length == 1 then
local est_ack = packet.data[1]
if est_ack == ESTABLISH_ACK.ALLOW then
log.info("coordinator connection established")
self.establish_delay_counter = 0
self.api.linked = true
if self.sv.linked then
coreio.report_link_state(LINK_STATE.LINKED)
else
coreio.report_link_state(LINK_STATE.API_LINK_ONLY)
end
elseif est_ack == ESTABLISH_ACK.DENY then
if self.api.last_est_ack ~= est_ack then
log.info("coordinator connection denied")
end
elseif est_ack == ESTABLISH_ACK.COLLISION then
if self.api.last_est_ack ~= est_ack then
log.info("coordinator connection denied due to collision")
end
elseif est_ack == ESTABLISH_ACK.BAD_VERSION then
if self.api.last_est_ack ~= est_ack then
log.info("coordinator comms version mismatch")
end
else
log.debug("coordinator SCADA_MGMT establish packet reply unsupported")
end
self.api.last_est_ack = est_ack
else
log.debug("coordinator SCADA_MGMT establish packet length mismatch")
end
elseif self.api.linked then
if packet.type == SCADA_MGMT_TYPE.KEEP_ALIVE then
-- keep alive request received, echo back
if packet.length == 1 then
local timestamp = packet.data[1]
local trip_time = util.time() - timestamp
if trip_time > 750 then
log.warning("pocket coordinator KEEP_ALIVE trip time > 750ms (" .. trip_time .. "ms)")
end
-- log.debug("pocket coordinator RTT = " .. trip_time .. "ms")
_send_api_keep_alive_ack(timestamp)
else
log.debug("coordinator SCADA keep alive packet length mismatch")
end
elseif packet.type == SCADA_MGMT_TYPE.CLOSE then
-- handle session close
api_watchdog.cancel()
self.api.linked = false
log.info("coordinator server connection closed by remote host")
else
log.debug("received unknown SCADA_MGMT packet type " .. packet.type .. " from coordinator")
end
else
log.debug("discarding coordinator non-link SCADA_MGMT packet before linked")
end
else
log.debug("illegal packet type " .. protocol .. " from coordinator", true)
end
elseif r_port == sv_port then
-- check sequence number
if self.sv.r_seq_num == nil then
self.sv.r_seq_num = packet.scada_frame.seq_num()
elseif self.connected and self.sv.r_seq_num >= packet.scada_frame.seq_num() then
log.warning("sequence out-of-order: last = " .. self.sv.r_seq_num .. ", new = " .. packet.scada_frame.seq_num())
return
else
self.sv.r_seq_num = packet.scada_frame.seq_num()
end
-- feed watchdog on valid sequence number
sv_watchdog.feed()
-- handle packet
if protocol == PROTOCOL.SCADA_MGMT then
---@cast packet mgmt_frame
if packet.type == SCADA_MGMT_TYPE.ESTABLISH then
-- connection with supervisor established
if packet.length == 1 then
local est_ack = packet.data[1]
if est_ack == ESTABLISH_ACK.ALLOW then
log.info("supervisor connection established")
self.establish_delay_counter = 0
self.sv.linked = true
if self.api.linked then
coreio.report_link_state(LINK_STATE.LINKED)
else
coreio.report_link_state(LINK_STATE.SV_LINK_ONLY)
end
elseif est_ack == ESTABLISH_ACK.DENY then
if self.sv.last_est_ack ~= est_ack then
log.info("supervisor connection denied")
end
elseif est_ack == ESTABLISH_ACK.COLLISION then
if self.sv.last_est_ack ~= est_ack then
log.info("supervisor connection denied due to collision")
end
elseif est_ack == ESTABLISH_ACK.BAD_VERSION then
if self.sv.last_est_ack ~= est_ack then
log.info("supervisor comms version mismatch")
end
else
log.debug("supervisor SCADA_MGMT establish packet reply unsupported")
end
self.sv.last_est_ack = est_ack
else
log.debug("supervisor SCADA_MGMT establish packet length mismatch")
end
elseif self.sv.linked then
if packet.type == SCADA_MGMT_TYPE.KEEP_ALIVE then
-- keep alive request received, echo back
if packet.length == 1 then
local timestamp = packet.data[1]
local trip_time = util.time() - timestamp
if trip_time > 750 then
log.warning("pocket supervisor KEEP_ALIVE trip time > 750ms (" .. trip_time .. "ms)")
end
-- log.debug("pocket supervisor RTT = " .. trip_time .. "ms")
_send_sv_keep_alive_ack(timestamp)
else
log.debug("supervisor SCADA keep alive packet length mismatch")
end
elseif packet.type == SCADA_MGMT_TYPE.CLOSE then
-- handle session close
sv_watchdog.cancel()
self.sv.linked = false
log.info("supervisor server connection closed by remote host")
else
log.debug("received unknown SCADA_MGMT packet type " .. packet.type .. " from supervisor")
end
else
log.debug("discarding supervisor non-link SCADA_MGMT packet before linked")
end
else
log.debug("illegal packet type " .. protocol .. " from supervisor", true)
end
else
log.debug("received packet from unconfigured channel " .. r_port, true)
end
end
end
-- check if we are still linked with the supervisor
---@nodiscard
function public.is_sv_linked() return self.sv.linked end
-- check if we are still linked with the coordinator
---@nodiscard
function public.is_api_linked() return self.api.linked end
return public
end
return pocket

75
pocket/renderer.lua Normal file
View File

@ -0,0 +1,75 @@
--
-- Graphics Rendering Control
--
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 pocket 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

View File

@ -1,16 +1,178 @@
--
-- 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 coreio = require("pocket.coreio")
local pocket = require("pocket.pocket")
local renderer = require("pocket.renderer")
local POCKET_VERSION = "alpha-v0.2.2"
-- local print = util.print
local println = util.println
-- local print_ts = util.print_ts
-- local println_ts = util.println_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 & clocks
----------------------------------------
coreio.report_link_state(coreio.LINK_STATE.UNLINKED)
-- 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("startup> no wireless modem on startup")
return
end
-- create connection watchdogs
local conn_wd = {
sv = util.new_watchdog(config.COMMS_TIMEOUT),
api = util.new_watchdog(config.COMMS_TIMEOUT)
}
conn_wd.sv.cancel()
conn_wd.api.cancel()
log.debug("startup> conn watchdogs created")
-- start comms, open all channels
local pocket_comms = pocket.comms(POCKET_VERSION, modem, config.LISTEN_PORT, config.SCADA_SV_PORT,
config.SCADA_API_PORT, config.TRUSTED_RANGE, conn_wd.sv, conn_wd.api)
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("startup> GUI crashed with error ", message))
else
-- start clock
loop_clock.start()
end
----------------------------------------
-- main event loop
----------------------------------------
if ui_ok then
-- start connection watchdogs
conn_wd.sv.feed()
conn_wd.api.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
-- relink if necessary
pocket_comms.link_update()
loop_clock.start()
elseif conn_wd.sv.is_timer(param1) then
-- supervisor watchdog timeout
log.info("supervisor server timeout")
pocket_comms.close_sv()
elseif conn_wd.api.is_timer(param1) then
-- coordinator watchdog timeout
log.info("coordinator api server timeout")
pocket_comms.close_api()
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)
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 server connections...")
pocket_comms.close()
log.info("connections 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

View File

@ -0,0 +1,22 @@
-- local style = require("pocket.ui.style")
local core = require("graphics.core")
local Div = require("graphics.elements.div")
local TextBox = require("graphics.elements.textbox")
-- local cpair = core.graphics.cpair
local TEXT_ALIGN = core.graphics.TEXT_ALIGN
-- new boiler page view
---@param root graphics_element parent
local function new_view(root)
local main = Div{parent=root,x=1,y=1}
TextBox{parent=main,text="BOILERS",x=1,y=1,height=1,alignment=TEXT_ALIGN.CENTER}
return main
end
return new_view

View File

@ -0,0 +1,41 @@
--
-- 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)
-- root div
local root = Div{parent=parent,x=1,y=1}
-- bounding box div
local box = Div{parent=root,x=1,y=y,height=5}
local waiting_x = math.floor(parent.width() / 2) - 1
if is_api then
WaitingAnim{parent=box,x=waiting_x,y=1,fg_bg=cpair(colors.blue,style.root.bkg)}
TextBox{parent=box,text="Connecting to API",alignment=TEXT_ALIGN.CENTER,y=5,height=1,fg_bg=cpair(colors.white,style.root.bkg)}
else
WaitingAnim{parent=box,x=waiting_x,y=1,fg_bg=cpair(colors.green,style.root.bkg)}
TextBox{parent=box,text="Connecting to Supervisor",alignment=TEXT_ALIGN.CENTER,y=5,height=1,fg_bg=cpair(colors.white,style.root.bkg)}
end
return root
end
return init

View File

@ -0,0 +1,22 @@
-- local style = require("pocket.ui.style")
local core = require("graphics.core")
local Div = require("graphics.elements.div")
local TextBox = require("graphics.elements.textbox")
-- local cpair = core.graphics.cpair
local TEXT_ALIGN = core.graphics.TEXT_ALIGN
-- new home page view
---@param root graphics_element parent
local function new_view(root)
local main = Div{parent=root,x=1,y=1}
TextBox{parent=main,text="HOME",x=1,y=1,height=1,alignment=TEXT_ALIGN.CENTER}
return main
end
return new_view

View File

@ -0,0 +1,22 @@
-- local style = require("pocket.ui.style")
local core = require("graphics.core")
local Div = require("graphics.elements.div")
local TextBox = require("graphics.elements.textbox")
-- local cpair = core.graphics.cpair
local TEXT_ALIGN = core.graphics.TEXT_ALIGN
-- new reactor page view
---@param root graphics_element parent
local function new_view(root)
local main = Div{parent=root,x=1,y=1}
TextBox{parent=main,text="REACTOR",x=1,y=1,height=1,alignment=TEXT_ALIGN.CENTER}
return main
end
return new_view

View File

@ -0,0 +1,22 @@
-- local style = require("pocket.ui.style")
local core = require("graphics.core")
local Div = require("graphics.elements.div")
local TextBox = require("graphics.elements.textbox")
-- local cpair = core.graphics.cpair
local TEXT_ALIGN = core.graphics.TEXT_ALIGN
-- new turbine page view
---@param root graphics_element parent
local function new_view(root)
local main = Div{parent=root,x=1,y=1}
TextBox{parent=main,text="TURBINES",x=1,y=1,height=1,alignment=TEXT_ALIGN.CENTER}
return main
end
return new_view

View File

@ -0,0 +1,22 @@
-- local style = require("pocket.ui.style")
local core = require("graphics.core")
local Div = require("graphics.elements.div")
local TextBox = require("graphics.elements.textbox")
-- local cpair = core.graphics.cpair
local TEXT_ALIGN = core.graphics.TEXT_ALIGN
-- new unit page view
---@param root graphics_element parent
local function new_view(root)
local main = Div{parent=root,x=1,y=1}
TextBox{parent=main,text="UNITS",x=1,y=1,height=1,alignment=TEXT_ALIGN.CENTER}
return main
end
return new_view

104
pocket/ui/main.lua Normal file
View File

@ -0,0 +1,104 @@
--
-- Pocket GUI Root
--
local coreio = require("pocket.coreio")
local style = require("pocket.ui.style")
local conn_waiting = require("pocket.ui.components.conn_waiting")
local home_page = require("pocket.ui.components.home_page")
local unit_page = require("pocket.ui.components.unit_page")
local reactor_page = require("pocket.ui.components.reactor_page")
local boiler_page = require("pocket.ui.components.boiler_page")
local turbine_page = require("pocket.ui.components.turbine_page")
local core = require("graphics.core")
local DisplayBox = require("graphics.elements.displaybox")
local Div = require("graphics.elements.div")
local MultiPane = require("graphics.elements.multipane")
local TextBox = require("graphics.elements.textbox")
local Sidebar = require("graphics.elements.controls.sidebar")
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
TextBox{parent=main,y=1,text="",alignment=TEXT_ALIGN.LEFT,height=1,fg_bg=style.header}
--
-- root panel panes (connection screens + main screen)
--
local root_pane_div = Div{parent=main,x=1,y=2}
local conn_sv_wait = conn_waiting(root_pane_div, 6, false)
local conn_api_wait = conn_waiting(root_pane_div, 6, true)
local main_pane = Div{parent=main,x=1,y=2}
local root_panes = { conn_sv_wait, conn_api_wait, main_pane }
local root_pane = MultiPane{parent=root_pane_div,x=1,y=1,panes=root_panes}
coreio.core_ps().subscribe("link_state", function (state)
if state == coreio.LINK_STATE.UNLINKED or state == coreio.LINK_STATE.API_LINK_ONLY then
root_pane.set_value(1)
elseif state == coreio.LINK_STATE.SV_LINK_ONLY then
root_pane.set_value(2)
else
root_pane.set_value(3)
end
end)
--
-- main page panel panes & sidebar
--
local page_div = Div{parent=main_pane,x=4,y=1}
local sidebar_tabs = {
{
char = "#",
color = cpair(colors.black,colors.green)
},
{
char = "U",
color = cpair(colors.black,colors.yellow)
},
{
char = "R",
color = cpair(colors.black,colors.cyan)
},
{
char = "B",
color = cpair(colors.black,colors.lightGray)
},
{
char = "T",
color = cpair(colors.black,colors.white)
}
}
local pane_1 = home_page(page_div)
local pane_2 = unit_page(page_div)
local pane_3 = reactor_page(page_div)
local pane_4 = boiler_page(page_div)
local pane_5 = turbine_page(page_div)
local panes = { pane_1, pane_2, pane_3, pane_4, pane_5 }
local page_pane = MultiPane{parent=page_div,x=1,y=1,panes=panes}
Sidebar{parent=main_pane,x=1,y=1,tabs=sidebar_tabs,fg_bg=cpair(colors.white,colors.gray),callback=page_pane.set_value}
return main
end
return init

158
pocket/ui/style.lua Normal file
View File

@ -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

View File

@ -18,7 +18,7 @@ local plc = require("reactor-plc.plc")
local renderer = require("reactor-plc.renderer")
local threads = require("reactor-plc.threads")
local R_PLC_VERSION = "v1.1.9"
local R_PLC_VERSION = "v1.1.10"
local println = util.println
local println_ts = util.println_ts

View File

@ -25,7 +25,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 = "v0.13.4"
local RTU_VERSION = "v0.13.5"
local RTU_UNIT_TYPE = types.RTU_UNIT_TYPE

View File

@ -2,7 +2,7 @@
-- Communications
--
local log = require("scada-common.log")
local log = require("scada-common.log")
---@class comms
local comms = {}
@ -11,7 +11,7 @@ local insert = table.insert
local max_distance = nil
comms.version = "1.4.0"
comms.version = "1.4.1"
---@enum PROTOCOL
local PROTOCOL = {
@ -74,7 +74,8 @@ local DEVICE_TYPE = {
PLC = 0, -- PLC device type for establish
RTU = 1, -- RTU device type for establish
SV = 2, -- supervisor device type for establish
CRDN = 3 -- coordinator device type for establish
CRDN = 3, -- coordinator device type for establish
PKT = 4 -- pocket device type for establish
}
---@enum PLC_AUTO_ACK

View File

@ -2,14 +2,15 @@ local config = {}
-- scada network listen for PLC's and RTU's
config.SCADA_DEV_LISTEN = 16000
-- listen port for SCADA supervisor access by coordinators
config.SCADA_SV_LISTEN = 16100
-- listen port for SCADA supervisor access
config.SCADA_SV_CTL_LISTEN = 16100
-- 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.PLC_TIMEOUT = 5
config.RTU_TIMEOUT = 5
config.CRD_TIMEOUT = 5
config.PKT_TIMEOUT = 5
-- expected number of reactors
config.NUM_REACTORS = 4

View File

@ -173,7 +173,7 @@ function coordinator.new_session(id, in_queue, out_queue, timeout, facility)
end
-- handle a packet
---@param pkt crdn_frame
---@param pkt mgmt_frame|crdn_frame
local function _handle_packet(pkt)
-- check sequence number
if self.r_seq_num == nil then
@ -190,6 +190,7 @@ function coordinator.new_session(id, in_queue, out_queue, timeout, facility)
-- process packet
if pkt.scada_frame.protocol() == PROTOCOL.SCADA_MGMT then
---@cast pkt mgmt_frame
if pkt.type == SCADA_MGMT_TYPE.KEEP_ALIVE then
-- keep alive reply
if pkt.length == 2 then
@ -214,6 +215,7 @@ function coordinator.new_session(id, in_queue, out_queue, timeout, facility)
log.debug(log_header .. "handler received unsupported SCADA_MGMT packet type " .. pkt.type)
end
elseif pkt.scada_frame.protocol() == PROTOCOL.SCADA_CRDN then
---@cast pkt crdn_frame
if pkt.type == SCADA_CRDN_TYPE.INITIAL_BUILDS then
-- acknowledgement to coordinator receiving builds
self.acks.builds = true

View File

@ -64,7 +64,7 @@ function plc.new_session(id, reactor_id, in_queue, out_queue, timeout)
connected = true,
received_struct = false,
received_status_cache = false,
plc_conn_watchdog = util.new_watchdog(timeout),
conn_watchdog = util.new_watchdog(timeout),
last_rtt = 0,
-- periodic messages
periodics = {
@ -233,7 +233,7 @@ function plc.new_session(id, reactor_id, in_queue, out_queue, timeout)
-- mark this PLC session as closed, stop watchdog
local function _close()
self.plc_conn_watchdog.cancel()
self.conn_watchdog.cancel()
self.connected = false
end
@ -279,7 +279,7 @@ function plc.new_session(id, reactor_id, in_queue, out_queue, timeout)
end
-- handle a packet
---@param pkt rplc_frame
---@param pkt mgmt_frame|rplc_frame
local function _handle_packet(pkt)
-- check sequence number
if self.r_seq_num == nil then
@ -293,6 +293,7 @@ function plc.new_session(id, reactor_id, in_queue, out_queue, timeout)
-- process packet
if pkt.scada_frame.protocol() == PROTOCOL.RPLC then
---@cast pkt rplc_frame
-- check reactor ID
if pkt.id ~= reactor_id then
log.warning(log_header .. "discarding RPLC packet with ID not matching reactor ID: reactor " .. reactor_id .. " != " .. pkt.id)
@ -300,7 +301,7 @@ function plc.new_session(id, reactor_id, in_queue, out_queue, timeout)
end
-- feed watchdog
self.plc_conn_watchdog.feed()
self.conn_watchdog.feed()
-- handle packet by type
if pkt.type == RPLC_TYPE.STATUS then
@ -469,6 +470,7 @@ function plc.new_session(id, reactor_id, in_queue, out_queue, timeout)
log.debug(log_header .. "handler received unsupported RPLC packet type " .. pkt.type)
end
elseif pkt.scada_frame.protocol() == PROTOCOL.SCADA_MGMT then
---@cast pkt mgmt_frame
if pkt.type == SCADA_MGMT_TYPE.KEEP_ALIVE then
-- keep alive reply
if pkt.length == 2 then
@ -574,7 +576,7 @@ function plc.new_session(id, reactor_id, in_queue, out_queue, timeout)
-- check if a timer matches this session's watchdog
---@nodiscard
function public.check_wd(timer)
return self.plc_conn_watchdog.is_timer(timer) and self.connected
return self.conn_watchdog.is_timer(timer) and self.connected
end
-- close the connection

View File

@ -0,0 +1,226 @@
local comms = require("scada-common.comms")
local log = require("scada-common.log")
local mqueue = require("scada-common.mqueue")
local util = require("scada-common.util")
local pocket = {}
local PROTOCOL = comms.PROTOCOL
local SCADA_MGMT_TYPE = comms.SCADA_MGMT_TYPE
local println = util.println
-- retry time constants in ms
-- local INITIAL_WAIT = 1500
-- local RETRY_PERIOD = 1000
local POCKET_S_CMDS = {
}
local POCKET_S_DATA = {
}
pocket.POCKET_S_CMDS = POCKET_S_CMDS
pocket.POCKET_S_DATA = POCKET_S_DATA
local PERIODICS = {
KEEP_ALIVE = 2000
}
-- pocket diagnostics session
---@nodiscard
---@param id integer session ID
---@param in_queue mqueue in message queue
---@param out_queue mqueue out message queue
---@param timeout number communications timeout
function pocket.new_session(id, in_queue, out_queue, timeout)
local log_header = "diag_session(" .. id .. "): "
local self = {
-- connection properties
seq_num = 0,
r_seq_num = nil,
connected = true,
conn_watchdog = util.new_watchdog(timeout),
last_rtt = 0,
-- periodic messages
periodics = {
last_update = 0,
keep_alive = 0
},
-- when to next retry one of these requests
retry_times = {
},
-- command acknowledgements
acks = {
},
-- session database
---@class diag_db
sDB = {
}
}
---@class diag_session
local public = {}
-- mark this diagnostics session as closed, stop watchdog
local function _close()
self.conn_watchdog.cancel()
self.connected = false
end
-- send a SCADA management packet
---@param msg_type SCADA_MGMT_TYPE
---@param msg table
local function _send_mgmt(msg_type, msg)
local s_pkt = comms.scada_packet()
local m_pkt = comms.mgmt_packet()
m_pkt.make(msg_type, msg)
s_pkt.make(self.seq_num, PROTOCOL.SCADA_MGMT, m_pkt.raw_sendable())
out_queue.push_packet(s_pkt)
self.seq_num = self.seq_num + 1
end
-- handle a packet
---@param pkt mgmt_frame
local function _handle_packet(pkt)
-- check sequence number
if self.r_seq_num == nil then
self.r_seq_num = pkt.scada_frame.seq_num()
elseif self.r_seq_num >= pkt.scada_frame.seq_num() then
log.warning(log_header .. "sequence out-of-order: last = " .. self.r_seq_num .. ", new = " .. pkt.scada_frame.seq_num())
return
else
self.r_seq_num = pkt.scada_frame.seq_num()
end
-- feed watchdog
self.conn_watchdog.feed()
-- process packet
if pkt.scada_frame.protocol() == PROTOCOL.SCADA_MGMT then
---@cast pkt mgmt_frame
if pkt.type == SCADA_MGMT_TYPE.KEEP_ALIVE then
-- keep alive reply
if pkt.length == 2 then
local srv_start = pkt.data[1]
-- local diag_send = pkt.data[2]
local srv_now = util.time()
self.last_rtt = srv_now - srv_start
if self.last_rtt > 750 then
log.warning(log_header .. "DIAG KEEP_ALIVE round trip time > 750ms (" .. self.last_rtt .. "ms)")
end
-- log.debug(log_header .. "DIAG RTT = " .. self.last_rtt .. "ms")
-- log.debug(log_header .. "DIAG TT = " .. (srv_now - diag_send) .. "ms")
else
log.debug(log_header .. "SCADA keep alive packet length mismatch")
end
elseif pkt.type == SCADA_MGMT_TYPE.CLOSE then
-- close the session
_close()
else
log.debug(log_header .. "handler received unsupported SCADA_MGMT packet type " .. pkt.type)
end
end
end
-- PUBLIC FUNCTIONS --
-- get the session ID
---@nodiscard
function public.get_id() return id end
-- get the session database
---@nodiscard
function public.get_db() return self.sDB end
-- check if a timer matches this session's watchdog
---@nodiscard
function public.check_wd(timer)
return self.conn_watchdog.is_timer(timer) and self.connected
end
-- close the connection
function public.close()
_close()
_send_mgmt(SCADA_MGMT_TYPE.CLOSE, {})
println("connection to pocket diag session " .. id .. " closed by server")
log.info(log_header .. "session closed by server")
end
-- iterate the session
---@nodiscard
---@return boolean connected
function public.iterate()
if self.connected then
------------------
-- handle queue --
------------------
local handle_start = util.time()
while in_queue.ready() and self.connected do
-- get a new message to process
local message = in_queue.pop()
if message ~= nil then
if message.qtype == mqueue.TYPE.PACKET then
-- handle a packet
_handle_packet(message.message)
elseif message.qtype == mqueue.TYPE.COMMAND then
-- handle instruction
elseif message.qtype == mqueue.TYPE.DATA then
-- instruction with body
end
end
-- max 100ms spent processing queue
if util.time() - handle_start > 100 then
log.warning(log_header .. "exceeded 100ms queue process limit")
break
end
end
-- exit if connection was closed
if not self.connected then
println("connection to pocket diag session " .. id .. " closed by remote host")
log.info(log_header .. "session closed by remote host")
return self.connected
end
----------------------
-- update periodics --
----------------------
local elapsed = util.time() - self.periodics.last_update
local periodics = self.periodics
-- keep alive
periodics.keep_alive = periodics.keep_alive + elapsed
if periodics.keep_alive >= PERIODICS.KEEP_ALIVE then
_send_mgmt(SCADA_MGMT_TYPE.KEEP_ALIVE, { util.time() })
periodics.keep_alive = 0
end
self.periodics.last_update = util.time()
---------------------
-- attempt retries --
---------------------
-- local rtimes = self.retry_times
end
return self.connected
end
return public
end
return pocket

View File

@ -47,7 +47,7 @@ function rtu.new_session(id, in_queue, out_queue, timeout, advertisement, facili
seq_num = 0,
r_seq_num = nil,
connected = true,
rtu_conn_watchdog = util.new_watchdog(timeout),
conn_watchdog = util.new_watchdog(timeout),
last_rtt = 0,
-- periodic messages
periodics = {
@ -174,7 +174,7 @@ function rtu.new_session(id, in_queue, out_queue, timeout, advertisement, facili
-- mark this RTU session as closed, stop watchdog
local function _close()
self.rtu_conn_watchdog.cancel()
self.conn_watchdog.cancel()
self.connected = false
-- mark all RTU unit sessions as closed so the reactor unit knows
@ -222,16 +222,17 @@ function rtu.new_session(id, in_queue, out_queue, timeout, advertisement, facili
end
-- feed watchdog
self.rtu_conn_watchdog.feed()
self.conn_watchdog.feed()
-- process packet
if pkt.scada_frame.protocol() == PROTOCOL.MODBUS_TCP then
---@cast pkt modbus_frame
if self.units[pkt.unit_id] ~= nil then
local unit = self.units[pkt.unit_id] ---@type unit_session
---@diagnostic disable-next-line: param-type-mismatch
unit.handle_packet(pkt)
end
elseif pkt.scada_frame.protocol() == PROTOCOL.SCADA_MGMT then
---@cast pkt mgmt_frame
-- handle management packet
if pkt.type == SCADA_MGMT_TYPE.KEEP_ALIVE then
-- keep alive reply
@ -285,7 +286,7 @@ function rtu.new_session(id, in_queue, out_queue, timeout, advertisement, facili
---@nodiscard
---@param timer number
function public.check_wd(timer)
return self.rtu_conn_watchdog.is_timer(timer) and self.connected
return self.conn_watchdog.is_timer(timer) and self.connected
end
-- close the connection

View File

@ -121,6 +121,7 @@ function redstone.new(session_id, unit_id, advert, out_queue)
---@nodiscard
read = function () return rsio.digital_is_active(port, self.phy_io.digital_in[port].phy) end,
---@param active boolean
---@diagnostic disable-next-line: unused-local
write = function (active) end
}
@ -155,6 +156,7 @@ function redstone.new(session_id, unit_id, advert, out_queue)
---@return integer
read = function () return self.phy_io.analog_in[port].phy end,
---@param value integer
---@diagnostic disable-next-line: unused-local
write = function (value) end
}

View File

@ -9,6 +9,7 @@ local svqtypes = require("supervisor.session.svqtypes")
local coordinator = require("supervisor.session.coordinator")
local plc = require("supervisor.session.plc")
local pocket = require("supervisor.session.pocket")
local rtu = require("supervisor.session.rtu")
-- Supervisor Sessions Handler
@ -22,29 +23,28 @@ local CRD_S_DATA = coordinator.CRD_S_DATA
local svsessions = {}
local SESSION_TYPE = {
RTU_SESSION = 0,
PLC_SESSION = 1,
COORD_SESSION = 2
RTU_SESSION = 0, -- RTU gateway
PLC_SESSION = 1, -- reactor PLC
COORD_SESSION = 2, -- coordinator
DIAG_SESSION = 3 -- pocket diagnostics
}
svsessions.SESSION_TYPE = SESSION_TYPE
local self = {
modem = nil,
modem = nil, ---@type table|nil
num_reactors = 0,
facility = nil, ---@type facility
rtu_sessions = {},
plc_sessions = {},
coord_sessions = {},
next_rtu_id = 0,
next_plc_id = 0,
next_coord_id = 0
facility = nil, ---@type facility|nil
sessions = { rtu = {}, plc = {}, coord = {}, diag = {} },
next_ids = { rtu = 0, plc = 0, coord = 0, diag = 0 }
}
---@alias sv_session_structs plc_session_struct|rtu_session_struct|coord_session_struct|diag_session_struct
-- PRIVATE FUNCTIONS --
-- handle a session output queue
---@param session plc_session_struct|rtu_session_struct|coord_session_struct
---@param session sv_session_structs
local function _sv_handle_outq(session)
-- record handler start time
local handle_start = util.time()
@ -112,7 +112,7 @@ end
---@param sessions table
local function _iterate(sessions)
for i = 1, #sessions do
local session = sessions[i] ---@type plc_session_struct|rtu_session_struct|coord_session_struct
local session = sessions[i] ---@type sv_session_structs
if session.open and session.instance.iterate() then
_sv_handle_outq(session)
@ -123,7 +123,7 @@ local function _iterate(sessions)
end
-- cleanly close a session
---@param session plc_session_struct|rtu_session_struct
---@param session sv_session_structs
local function _shutdown(session)
session.open = false
session.instance.close()
@ -143,10 +143,8 @@ end
---@param sessions table
local function _close(sessions)
for i = 1, #sessions do
local session = sessions[i] ---@type plc_session_struct|rtu_session_struct
if session.open then
_shutdown(session)
end
local session = sessions[i] ---@type sv_session_structs
if session.open then _shutdown(session) end
end
end
@ -155,7 +153,7 @@ end
---@param timer_event number
local function _check_watchdogs(sessions, timer_event)
for i = 1, #sessions do
local session = sessions[i] ---@type plc_session_struct|rtu_session_struct
local session = sessions[i] ---@type sv_session_structs
if session.open then
local triggered = session.instance.check_wd(timer_event)
if triggered then
@ -172,6 +170,7 @@ end
local function _free_closed(sessions)
local f = function (session) return session.open end
---@param session sv_session_structs
local on_delete = function (session)
log.debug(util.c("free'ing closed ", session.s_type, " session ", session.instance.get_id(),
" on remote port ", session.r_port))
@ -184,7 +183,7 @@ end
---@nodiscard
---@param list table
---@param port integer
---@return plc_session_struct|rtu_session_struct|coord_session_struct|nil
---@return sv_session_structs|nil
local function _find_session(list, port)
for i = 1, #list do
if list[i].r_port == port then return list[i] end
@ -216,8 +215,8 @@ end
---@return rtu_session_struct|nil
function svsessions.find_rtu_session(remote_port)
-- check RTU sessions
local session = _find_session(self.rtu_sessions, remote_port)
---@cast session rtu_session_struct
local session = _find_session(self.sessions.rtu, remote_port)
---@cast session rtu_session_struct|nil
return session
end
@ -227,8 +226,8 @@ end
---@return plc_session_struct|nil
function svsessions.find_plc_session(remote_port)
-- check PLC sessions
local session = _find_session(self.plc_sessions, remote_port)
---@cast session plc_session_struct
local session = _find_session(self.sessions.plc, remote_port)
---@cast session plc_session_struct|nil
return session
end
@ -238,24 +237,27 @@ end
---@return plc_session_struct|rtu_session_struct|nil
function svsessions.find_device_session(remote_port)
-- check RTU sessions
local session = _find_session(self.rtu_sessions, remote_port)
local session = _find_session(self.sessions.rtu, remote_port)
-- check PLC sessions
if session == nil then session = _find_session(self.plc_sessions, remote_port) end
if session == nil then session = _find_session(self.sessions.plc, remote_port) end
---@cast session plc_session_struct|rtu_session_struct|nil
return session
end
-- find a coordinator session by the remote port<br>
-- only one coordinator is allowed, but this is kept to be consistent with all other session tables
-- find a coordinator or diagnostic access session by the remote port
---@nodiscard
---@param remote_port integer
---@return coord_session_struct|nil
function svsessions.find_coord_session(remote_port)
---@return coord_session_struct|diag_session_struct|nil
function svsessions.find_svctl_session(remote_port)
-- check coordinator sessions
local session = _find_session(self.coord_sessions, remote_port)
---@cast session coord_session_struct
local session = _find_session(self.sessions.coord, remote_port)
-- check diagnostic sessions
if session == nil then session = _find_session(self.sessions.diag, remote_port) end
---@cast session coord_session_struct|diag_session_struct|nil
return session
end
@ -263,7 +265,7 @@ end
---@nodiscard
---@return coord_session_struct|nil
function svsessions.get_coord_session()
return self.coord_sessions[1]
return self.sessions.coord[1]
end
-- get a session by reactor ID
@ -273,9 +275,9 @@ end
function svsessions.get_reactor_session(reactor)
local session = nil
for i = 1, #self.plc_sessions do
if self.plc_sessions[i].reactor == reactor then
session = self.plc_sessions[i]
for i = 1, #self.sessions.plc do
if self.sessions.plc[i].reactor == reactor then
session = self.sessions.plc[i]
end
end
@ -304,15 +306,15 @@ function svsessions.establish_plc_session(local_port, remote_port, for_reactor,
instance = nil ---@type plc_session
}
plc_s.instance = plc.new_session(self.next_plc_id, for_reactor, plc_s.in_queue, plc_s.out_queue, config.PLC_TIMEOUT)
table.insert(self.plc_sessions, plc_s)
plc_s.instance = plc.new_session(self.next_ids.plc, for_reactor, plc_s.in_queue, plc_s.out_queue, config.PLC_TIMEOUT)
table.insert(self.sessions.plc, plc_s)
local units = self.facility.get_units()
units[for_reactor].link_plc_session(plc_s)
log.debug(util.c("established new PLC session to ", remote_port, " with ID ", self.next_plc_id, " for reactor ", for_reactor))
log.debug(util.c("established new PLC session to ", remote_port, " with ID ", self.next_ids.plc, " for reactor ", for_reactor))
self.next_plc_id = self.next_plc_id + 1
self.next_ids.plc = self.next_ids.plc + 1
-- success
return plc_s.instance.get_id()
@ -342,12 +344,12 @@ function svsessions.establish_rtu_session(local_port, remote_port, advertisement
instance = nil ---@type rtu_session
}
rtu_s.instance = rtu.new_session(self.next_rtu_id, rtu_s.in_queue, rtu_s.out_queue, config.RTU_TIMEOUT, advertisement, self.facility)
table.insert(self.rtu_sessions, rtu_s)
rtu_s.instance = rtu.new_session(self.next_ids.rtu, rtu_s.in_queue, rtu_s.out_queue, config.RTU_TIMEOUT, advertisement, self.facility)
table.insert(self.sessions.rtu, rtu_s)
log.debug("established new RTU session to " .. remote_port .. " with ID " .. self.next_rtu_id)
log.debug("established new RTU session to " .. remote_port .. " with ID " .. self.next_ids.rtu)
self.next_rtu_id = self.next_rtu_id + 1
self.next_ids.rtu = self.next_ids.rtu + 1
-- success
return rtu_s.instance.get_id()
@ -373,12 +375,12 @@ function svsessions.establish_coord_session(local_port, remote_port, version)
instance = nil ---@type coord_session
}
coord_s.instance = coordinator.new_session(self.next_coord_id, coord_s.in_queue, coord_s.out_queue, config.CRD_TIMEOUT, self.facility)
table.insert(self.coord_sessions, coord_s)
coord_s.instance = coordinator.new_session(self.next_ids.coord, coord_s.in_queue, coord_s.out_queue, config.CRD_TIMEOUT, self.facility)
table.insert(self.sessions.coord, coord_s)
log.debug("established new coordinator session to " .. remote_port .. " with ID " .. self.next_coord_id)
log.debug("established new coordinator session to " .. remote_port .. " with ID " .. self.next_ids.coord)
self.next_coord_id = self.next_coord_id + 1
self.next_ids.coord = self.next_ids.coord + 1
-- success
return coord_s.instance.get_id()
@ -388,32 +390,49 @@ function svsessions.establish_coord_session(local_port, remote_port, version)
end
end
-- establish a new pocket diagnostics session
---@nodiscard
---@param local_port integer
---@param remote_port integer
---@param version string
---@return integer|false session_id
function svsessions.establish_diag_session(local_port, remote_port, version)
---@class diag_session_struct
local diag_s = {
s_type = "pkt",
open = true,
version = version,
l_port = local_port,
r_port = remote_port,
in_queue = mqueue.new(),
out_queue = mqueue.new(),
instance = nil ---@type diag_session
}
diag_s.instance = pocket.new_session(self.next_ids.diag, diag_s.in_queue, diag_s.out_queue, config.PKT_TIMEOUT)
table.insert(self.sessions.diag, diag_s)
log.debug("established new pocket diagnostics session to " .. remote_port .. " with ID " .. self.next_ids.diag)
self.next_ids.diag = self.next_ids.diag + 1
-- success
return diag_s.instance.get_id()
end
-- attempt to identify which session's watchdog timer fired
---@param timer_event number
function svsessions.check_all_watchdogs(timer_event)
-- check RTU session watchdogs
_check_watchdogs(self.rtu_sessions, timer_event)
-- check PLC session watchdogs
_check_watchdogs(self.plc_sessions, timer_event)
-- check coordinator session watchdogs
_check_watchdogs(self.coord_sessions, timer_event)
for _, list in pairs(self.sessions) do _check_watchdogs(list, timer_event) end
end
-- iterate all sessions
-- iterate all sessions, and update facility/unit data & process control logic
function svsessions.iterate_all()
-- iterate RTU sessions
_iterate(self.rtu_sessions)
-- iterate PLC sessions
_iterate(self.plc_sessions)
-- iterate coordinator sessions
_iterate(self.coord_sessions)
-- iterate sessions
for _, list in pairs(self.sessions) do _iterate(list) end
-- report RTU sessions to facility
self.facility.report_rtus(self.rtu_sessions)
self.facility.report_rtus(self.sessions.rtu)
-- iterate facility
self.facility.update()
@ -424,22 +443,15 @@ end
-- delete all closed sessions
function svsessions.free_all_closed()
-- free closed RTU sessions
_free_closed(self.rtu_sessions)
-- free closed PLC sessions
_free_closed(self.plc_sessions)
-- free closed coordinator sessions
_free_closed(self.coord_sessions)
for _, list in pairs(self.sessions) do _free_closed(list) end
end
-- close all open connections
function svsessions.close_all()
-- close sessions
_close(self.rtu_sessions)
_close(self.plc_sessions)
_close(self.coord_sessions)
for _, list in pairs(self.sessions) do
_close(list)
end
-- free sessions
svsessions.free_all_closed()

View File

@ -9,12 +9,12 @@ local log = require("scada-common.log")
local ppm = require("scada-common.ppm")
local util = require("scada-common.util")
local svsessions = require("supervisor.session.svsessions")
local config = require("supervisor.config")
local supervisor = require("supervisor.supervisor")
local SUPERVISOR_VERSION = "v0.14.5"
local svsessions = require("supervisor.session.svsessions")
local SUPERVISOR_VERSION = "v0.15.3"
local println = util.println
local println_ts = util.println_ts
@ -26,7 +26,7 @@ local println_ts = util.println_ts
local cfv = util.new_validator()
cfv.assert_port(config.SCADA_DEV_LISTEN)
cfv.assert_port(config.SCADA_SV_LISTEN)
cfv.assert_port(config.SCADA_SV_CTL_LISTEN)
cfv.assert_type_int(config.TRUSTED_RANGE)
cfv.assert_type_num(config.PLC_TIMEOUT)
cfv.assert_min(config.PLC_TIMEOUT, 2)
@ -34,6 +34,8 @@ cfv.assert_type_num(config.RTU_TIMEOUT)
cfv.assert_min(config.RTU_TIMEOUT, 2)
cfv.assert_type_num(config.CRD_TIMEOUT)
cfv.assert_min(config.CRD_TIMEOUT, 2)
cfv.assert_type_num(config.PKT_TIMEOUT)
cfv.assert_min(config.PKT_TIMEOUT, 2)
cfv.assert_type_int(config.NUM_REACTORS)
cfv.assert_type_table(config.REACTOR_COOLING)
cfv.assert_type_str(config.LOG_PATH)
@ -89,7 +91,7 @@ local function main()
-- start comms, open all channels
local superv_comms = supervisor.comms(SUPERVISOR_VERSION, config.NUM_REACTORS, config.REACTOR_COOLING, modem,
config.SCADA_DEV_LISTEN, config.SCADA_SV_LISTEN, config.TRUSTED_RANGE)
config.SCADA_DEV_LISTEN, config.SCADA_SV_CTL_LISTEN, config.TRUSTED_RANGE)
-- base loop clock (6.67Hz, 3 ticks)
local MAIN_CLOCK = 0.15

View File

@ -20,9 +20,10 @@ local println = util.println
---@param cooling_conf table cooling configuration table
---@param modem table modem device
---@param dev_listen integer listening port for PLC/RTU devices
---@param coord_listen integer listening port for coordinator
---@param svctl_listen integer listening port for supervisor access
---@param range integer trusted device connection range
function supervisor.comms(version, num_reactors, cooling_conf, modem, dev_listen, coord_listen, range)
---@diagnostic disable-next-line: unused-local
function supervisor.comms(version, num_reactors, cooling_conf, modem, dev_listen, svctl_listen, range)
local self = {
last_est_acks = {}
}
@ -35,7 +36,7 @@ function supervisor.comms(version, num_reactors, cooling_conf, modem, dev_listen
local function _conf_channels()
modem.closeAll()
modem.open(dev_listen)
modem.open(coord_listen)
modem.open(svctl_listen)
end
_conf_channels()
@ -56,18 +57,18 @@ function supervisor.comms(version, num_reactors, cooling_conf, modem, dev_listen
modem.transmit(dest, dev_listen, s_pkt.raw_sendable())
end
-- send coordinator connection establish response
-- send supervisor control access connection establish response
---@param seq_id integer
---@param dest integer
---@param msg table
local function _send_crdn_establish(seq_id, dest, msg)
local function _send_svctl_establish(seq_id, dest, msg)
local s_pkt = comms.scada_packet()
local c_pkt = comms.mgmt_packet()
c_pkt.make(SCADA_MGMT_TYPE.ESTABLISH, msg)
s_pkt.make(seq_id, PROTOCOL.SCADA_MGMT, c_pkt.raw_sendable())
modem.transmit(dest, coord_listen, s_pkt.raw_sendable())
modem.transmit(dest, svctl_listen, s_pkt.raw_sendable())
end
-- PUBLIC FUNCTIONS --
@ -250,9 +251,9 @@ function supervisor.comms(version, num_reactors, cooling_conf, modem, dev_listen
log.debug("illegal packet type " .. protocol .. " on device listening channel")
end
-- coordinator listening channel
elseif l_port == coord_listen then
elseif l_port == svctl_listen then
-- look for an associated session
local session = svsessions.find_coord_session(r_port)
local session = svsessions.find_svctl_session(r_port)
if protocol == PROTOCOL.SCADA_MGMT then
---@cast packet mgmt_frame
@ -276,12 +277,9 @@ function supervisor.comms(version, num_reactors, cooling_conf, modem, dev_listen
self.last_est_acks[r_port] = ESTABLISH_ACK.BAD_VERSION
end
_send_crdn_establish(next_seq_id, r_port, { ESTABLISH_ACK.BAD_VERSION })
elseif dev_type ~= DEVICE_TYPE.CRDN then
log.debug(util.c("illegal establish packet for device ", dev_type, " on CRDN listening channel"))
_send_crdn_establish(next_seq_id, r_port, { ESTABLISH_ACK.DENY })
else
-- this is an attempt to establish a new session
_send_svctl_establish(next_seq_id, r_port, { ESTABLISH_ACK.BAD_VERSION })
elseif dev_type == DEVICE_TYPE.CRDN then
-- this is an attempt to establish a new coordinator session
local s_id = svsessions.establish_coord_session(l_port, r_port, firmware_v)
if s_id ~= false then
@ -291,23 +289,35 @@ function supervisor.comms(version, num_reactors, cooling_conf, modem, dev_listen
table.insert(config, cooling_conf[i].TURBINES)
end
println(util.c("CRD (",firmware_v, ") [:", r_port, "] \xbb connected"))
log.info(util.c("CRDN_ESTABLISH: coordinator (",firmware_v, ") [:", r_port, "] connected with session ID ", s_id))
println(util.c("CRD (", firmware_v, ") [:", r_port, "] \xbb connected"))
log.info(util.c("SVCTL_ESTABLISH: coordinator (", firmware_v, ") [:", r_port, "] connected with session ID ", s_id))
_send_crdn_establish(next_seq_id, r_port, { ESTABLISH_ACK.ALLOW, config })
_send_svctl_establish(next_seq_id, r_port, { ESTABLISH_ACK.ALLOW, config })
self.last_est_acks[r_port] = ESTABLISH_ACK.ALLOW
else
if self.last_est_acks[r_port] ~= ESTABLISH_ACK.COLLISION then
log.info("CRDN_ESTABLISH: denied new coordinator due to already being connected to another coordinator")
log.info("SVCTL_ESTABLISH: denied new coordinator due to already being connected to another coordinator")
self.last_est_acks[r_port] = ESTABLISH_ACK.COLLISION
end
_send_crdn_establish(next_seq_id, r_port, { ESTABLISH_ACK.COLLISION })
_send_svctl_establish(next_seq_id, r_port, { ESTABLISH_ACK.COLLISION })
end
elseif dev_type == DEVICE_TYPE.PKT then
-- this is an attempt to establish a new pocket diagnostic session
local s_id = svsessions.establish_diag_session(l_port, r_port, firmware_v)
println(util.c("PKT (", firmware_v, ") [:", r_port, "] \xbb connected"))
log.info(util.c("SVCTL_ESTABLISH: pocket (", firmware_v, ") [:", r_port, "] connected with session ID ", s_id))
_send_svctl_establish(next_seq_id, r_port, { ESTABLISH_ACK.ALLOW })
self.last_est_acks[r_port] = ESTABLISH_ACK.ALLOW
else
log.debug(util.c("illegal establish packet for device ", dev_type, " on SVCTL listening channel"))
_send_svctl_establish(next_seq_id, r_port, { ESTABLISH_ACK.DENY })
end
else
log.debug("CRDN_ESTABLISH: establish packet length mismatch")
_send_crdn_establish(next_seq_id, r_port, { ESTABLISH_ACK.DENY })
log.debug("SVCTL_ESTABLISH: establish packet length mismatch")
_send_svctl_establish(next_seq_id, r_port, { ESTABLISH_ACK.DENY })
end
else
-- any other packet should be session related, discard it