diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index e0bcf50..da24e11 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -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 diff --git a/.vscode/settings.json b/.vscode/settings.json index 732eb4a..673ad4e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -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" ] diff --git a/coordinator/apisessions.lua b/coordinator/apisessions.lua deleted file mode 100644 index 8646837..0000000 --- a/coordinator/apisessions.lua +++ /dev/null @@ -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 diff --git a/coordinator/config.lua b/coordinator/config.lua index 052bba4..8e12c53 100644 --- a/coordinator/config.lua +++ b/coordinator/config.lua @@ -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 diff --git a/coordinator/coordinator.lua b/coordinator/coordinator.lua index f12d586..b13134a 100644 --- a/coordinator/coordinator.lua +++ b/coordinator/coordinator.lua @@ -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 diff --git a/coordinator/iocontrol.lua b/coordinator/iocontrol.lua index ac45a2f..e26546d 100644 --- a/coordinator/iocontrol.lua +++ b/coordinator/iocontrol.lua @@ -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 = {}, diff --git a/coordinator/session/api.lua b/coordinator/session/api.lua new file mode 100644 index 0000000..7ed5a07 --- /dev/null +++ b/coordinator/session/api.lua @@ -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 diff --git a/coordinator/session/apisessions.lua b/coordinator/session/apisessions.lua new file mode 100644 index 0000000..c6d5a20 --- /dev/null +++ b/coordinator/session/apisessions.lua @@ -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 diff --git a/coordinator/startup.lua b/coordinator/startup.lua index 7ebd49e..f232587 100644 --- a/coordinator/startup.lua +++ b/coordinator/startup.lua @@ -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 diff --git a/coordinator/ui/components/unit_waiting.lua b/coordinator/ui/components/unit_waiting.lua deleted file mode 100644 index 3b1a846..0000000 --- a/coordinator/ui/components/unit_waiting.lua +++ /dev/null @@ -1,33 +0,0 @@ --- --- Reactor Unit Waiting Spinner --- - -local style = require("coordinator.ui.style") - -local core = require("graphics.core") - -local Div = require("graphics.elements.div") -local TextBox = require("graphics.elements.textbox") - -local WaitingAnim = require("graphics.elements.animations.waiting") - -local TEXT_ALIGN = core.graphics.TEXT_ALIGN - -local cpair = core.graphics.cpair - --- create a unit waiting view ----@param parent graphics_element parent ----@param y integer y offset -local function init(parent, y) - -- bounding box div - local root = Div{parent=parent,x=1,y=y,height=5} - - local waiting_x = math.floor(parent.width() / 2) - 2 - - TextBox{parent=root,text="Waiting for status...",alignment=TEXT_ALIGN.CENTER,y=1,height=1,fg_bg=cpair(colors.black,style.root.bkg)} - WaitingAnim{parent=root,x=waiting_x,y=3,fg_bg=cpair(colors.blue,style.root.bkg)} - - return root -end - -return init diff --git a/graphics/element.lua b/graphics/element.lua index d9bc489..92b2f49 100644 --- a/graphics/element.lua +++ b/graphics/element.lua @@ -25,6 +25,7 @@ local element = {} ---|multi_button_args ---|push_button_args ---|radio_button_args +---|sidebar_args ---|spinbox_args ---|switch_button_args ---|alarm_indicator_light @@ -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 diff --git a/graphics/elements/animations/waiting.lua b/graphics/elements/animations/waiting.lua index 2b08092..a0d7b3e 100644 --- a/graphics/elements/animations/waiting.lua +++ b/graphics/elements/animations/waiting.lua @@ -85,7 +85,7 @@ local function waiting(args) if state >= 12 then state = 0 end if run_animation then - tcd.dispatch_unique(0.5, animate) + tcd.dispatch_unique(0.15, animate) end end diff --git a/graphics/elements/controls/sidebar.lua b/graphics/elements/controls/sidebar.lua new file mode 100644 index 0000000..885761d --- /dev/null +++ b/graphics/elements/controls/sidebar.lua @@ -0,0 +1,104 @@ +-- Sidebar Graphics Element + +local tcd = require("scada-common.tcallbackdsp") + +local element = require("graphics.element") + +---@class sidebar_tab +---@field char string character identifier +---@field color cpair tab colors (fg/bg) + +---@class sidebar_args +---@field tabs table sidebar tab options +---@field callback function function to call on tab change +---@field parent graphics_element +---@field id? string element id +---@field x? integer 1 if omitted +---@field y? integer 1 if omitted +---@field height? integer parent height if omitted +---@field fg_bg? cpair foreground/background colors + +-- new sidebar tab selector +---@param args sidebar_args +---@return graphics_element element, element_id id +local function sidebar(args) + assert(type(args.tabs) == "table", "graphics.elements.controls.sidebar: tabs is a required field") + assert(#args.tabs > 0, "graphics.elements.controls.sidebar: at least one tab is required") + assert(type(args.callback) == "function", "graphics.elements.controls.sidebar: callback is a required field") + + -- always 3 wide + args.width = 3 + + -- create new graphics element base object + local e = element.new(args) + + assert(e.frame.h >= (#args.tabs * 3), "graphics.elements.controls.sidebar: height insufficent to display all tabs") + + -- default to 1st tab + e.value = 1 + + -- show the button state + ---@param pressed boolean if the currently selected tab should appear as actively pressed + local function draw(pressed) + for i = 1, #args.tabs do + local tab = args.tabs[i] ---@type sidebar_tab + + local y = ((i - 1) * 3) + 1 + + e.window.setCursorPos(1, y) + + if pressed and e.value == i then + e.window.setTextColor(e.fg_bg.fgd) + e.window.setBackgroundColor(e.fg_bg.bkg) + else + e.window.setTextColor(tab.color.fgd) + e.window.setBackgroundColor(tab.color.bkg) + end + + e.window.write(" ") + e.window.setCursorPos(1, y + 1) + if e.value == i then + -- show as selected + e.window.write(" " .. tab.char .. "\x10") + else + -- show as unselected + e.window.write(" " .. tab.char .. " ") + end + e.window.setCursorPos(1, y + 2) + e.window.write(" ") + end + end + + -- handle mouse interaction + ---@param event mouse_interaction mouse event + function e.handle_mouse(event) + -- determine what was pressed + if e.enabled then + local idx = math.ceil(event.y / 3) + + if args.tabs[idx] ~= nil then + e.value = idx + draw(true) + + -- show as unpressed in 0.25 seconds + tcd.dispatch(0.25, function () draw(false) end) + + args.callback(e.value) + end + end + end + + -- set the value + ---@param val integer new value + function e.set_value(val) + e.value = val + draw(false) + end + + -- initial draw + draw(false) + + return e.get() +end + +return sidebar diff --git a/graphics/elements/multipane.lua b/graphics/elements/multipane.lua new file mode 100644 index 0000000..8e25bab --- /dev/null +++ b/graphics/elements/multipane.lua @@ -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 diff --git a/pocket/config.lua b/pocket/config.lua index e69de29..cacd9f1 100644 --- a/pocket/config.lua +++ b/pocket/config.lua @@ -0,0 +1,21 @@ +local config = {} + +-- port of the SCADA supervisor +config.SCADA_SV_PORT = 16100 +-- port for SCADA coordinator API access +config.SCADA_API_PORT = 16200 +-- port to listen to incoming packets FROM servers +config.LISTEN_PORT = 16201 +-- max trusted modem message distance (0 to disable check) +config.TRUSTED_RANGE = 0 +-- time in seconds (>= 2) before assuming a remote device is no longer active +config.COMMS_TIMEOUT = 5 + +-- log path +config.LOG_PATH = "/log.txt" +-- log mode +-- 0 = APPEND (adds to existing file on start) +-- 1 = NEW (replaces existing file on start) +config.LOG_MODE = 0 + +return config diff --git a/pocket/coreio.lua b/pocket/coreio.lua new file mode 100644 index 0000000..6f43dfd --- /dev/null +++ b/pocket/coreio.lua @@ -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 diff --git a/pocket/pocket.lua b/pocket/pocket.lua new file mode 100644 index 0000000..97ad87a --- /dev/null +++ b/pocket/pocket.lua @@ -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 diff --git a/pocket/renderer.lua b/pocket/renderer.lua new file mode 100644 index 0000000..4be1ec6 --- /dev/null +++ b/pocket/renderer.lua @@ -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 diff --git a/pocket/startup.lua b/pocket/startup.lua index 3782412..b57756c 100644 --- a/pocket/startup.lua +++ b/pocket/startup.lua @@ -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 diff --git a/pocket/ui/components/boiler_page.lua b/pocket/ui/components/boiler_page.lua new file mode 100644 index 0000000..fd0eca1 --- /dev/null +++ b/pocket/ui/components/boiler_page.lua @@ -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 diff --git a/pocket/ui/components/conn_waiting.lua b/pocket/ui/components/conn_waiting.lua new file mode 100644 index 0000000..cd08652 --- /dev/null +++ b/pocket/ui/components/conn_waiting.lua @@ -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 diff --git a/pocket/ui/components/home_page.lua b/pocket/ui/components/home_page.lua new file mode 100644 index 0000000..5287cac --- /dev/null +++ b/pocket/ui/components/home_page.lua @@ -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 diff --git a/pocket/ui/components/reactor_page.lua b/pocket/ui/components/reactor_page.lua new file mode 100644 index 0000000..50b1939 --- /dev/null +++ b/pocket/ui/components/reactor_page.lua @@ -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 diff --git a/pocket/ui/components/turbine_page.lua b/pocket/ui/components/turbine_page.lua new file mode 100644 index 0000000..9fd7af5 --- /dev/null +++ b/pocket/ui/components/turbine_page.lua @@ -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 diff --git a/pocket/ui/components/unit_page.lua b/pocket/ui/components/unit_page.lua new file mode 100644 index 0000000..2e24df3 --- /dev/null +++ b/pocket/ui/components/unit_page.lua @@ -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 diff --git a/pocket/ui/main.lua b/pocket/ui/main.lua new file mode 100644 index 0000000..d8202a7 --- /dev/null +++ b/pocket/ui/main.lua @@ -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 diff --git a/pocket/ui/style.lua b/pocket/ui/style.lua new file mode 100644 index 0000000..b9a09fc --- /dev/null +++ b/pocket/ui/style.lua @@ -0,0 +1,158 @@ +-- +-- Graphics Style Options +-- + +local core = require("graphics.core") + +local style = {} + +local cpair = core.graphics.cpair + +-- GLOBAL -- + +style.root = cpair(colors.white, colors.black) +style.header = cpair(colors.white, colors.gray) +style.label = cpair(colors.gray, colors.lightGray) + +style.colors = { + { c = colors.red, hex = 0xdf4949 }, + { c = colors.orange, hex = 0xffb659 }, + { c = colors.yellow, hex = 0xfffc79 }, + { c = colors.lime, hex = 0x80ff80 }, + { c = colors.green, hex = 0x4aee8a }, + { c = colors.cyan, hex = 0x34bac8 }, + { c = colors.lightBlue, hex = 0x6cc0f2 }, + { c = colors.blue, hex = 0x0096ff }, + { c = colors.purple, hex = 0xb156ee }, + { c = colors.pink, hex = 0xf26ba2 }, + { c = colors.magenta, hex = 0xf9488a }, + -- { c = colors.white, hex = 0xf0f0f0 }, + { c = colors.lightGray, hex = 0xcacaca }, + { c = colors.gray, hex = 0x575757 }, + -- { c = colors.black, hex = 0x191919 }, + -- { c = colors.brown, hex = 0x7f664c } +} + +-- MAIN LAYOUT -- + +style.reactor = { + -- reactor states + states = { + { + color = cpair(colors.black, colors.yellow), + text = "PLC OFF-LINE" + }, + { + color = cpair(colors.black, colors.orange), + text = "NOT FORMED" + }, + { + color = cpair(colors.black, colors.orange), + text = "PLC FAULT" + }, + { + color = cpair(colors.white, colors.gray), + text = "DISABLED" + }, + { + color = cpair(colors.black, colors.green), + text = "ACTIVE" + }, + { + color = cpair(colors.black, colors.red), + text = "SCRAMMED" + }, + { + color = cpair(colors.black, colors.red), + text = "FORCE DISABLED" + } + } +} + +style.boiler = { + -- boiler states + states = { + { + color = cpair(colors.black, colors.yellow), + text = "OFF-LINE" + }, + { + color = cpair(colors.black, colors.orange), + text = "NOT FORMED" + }, + { + color = cpair(colors.black, colors.orange), + text = "RTU FAULT" + }, + { + color = cpair(colors.white, colors.gray), + text = "IDLE" + }, + { + color = cpair(colors.black, colors.green), + text = "ACTIVE" + } + } +} + +style.turbine = { + -- turbine states + states = { + { + color = cpair(colors.black, colors.yellow), + text = "OFF-LINE" + }, + { + color = cpair(colors.black, colors.orange), + text = "NOT FORMED" + }, + { + color = cpair(colors.black, colors.orange), + text = "RTU FAULT" + }, + { + color = cpair(colors.white, colors.gray), + text = "IDLE" + }, + { + color = cpair(colors.black, colors.green), + text = "ACTIVE" + }, + { + color = cpair(colors.black, colors.red), + text = "TRIP" + } + } +} + +style.imatrix = { + -- induction matrix states + states = { + { + color = cpair(colors.black, colors.yellow), + text = "OFF-LINE" + }, + { + color = cpair(colors.black, colors.orange), + text = "NOT FORMED" + }, + { + color = cpair(colors.black, colors.orange), + text = "RTU FAULT" + }, + { + color = cpair(colors.black, colors.green), + text = "ONLINE" + }, + { + color = cpair(colors.black, colors.yellow), + text = "LOW CHARGE" + }, + { + color = cpair(colors.black, colors.yellow), + text = "HIGH CHARGE" + }, + } +} + +return style diff --git a/reactor-plc/startup.lua b/reactor-plc/startup.lua index eb2d6e8..624b78b 100644 --- a/reactor-plc/startup.lua +++ b/reactor-plc/startup.lua @@ -18,7 +18,7 @@ local plc = require("reactor-plc.plc") local renderer = require("reactor-plc.renderer") local threads = require("reactor-plc.threads") -local R_PLC_VERSION = "v1.1.9" +local R_PLC_VERSION = "v1.1.10" local println = util.println local println_ts = util.println_ts diff --git a/rtu/startup.lua b/rtu/startup.lua index 10fc24e..4551a24 100644 --- a/rtu/startup.lua +++ b/rtu/startup.lua @@ -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 diff --git a/scada-common/comms.lua b/scada-common/comms.lua index 497c3b3..fb54101 100644 --- a/scada-common/comms.lua +++ b/scada-common/comms.lua @@ -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 diff --git a/supervisor/config.lua b/supervisor/config.lua index 47d530d..0aa26d8 100644 --- a/supervisor/config.lua +++ b/supervisor/config.lua @@ -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 diff --git a/supervisor/session/coordinator.lua b/supervisor/session/coordinator.lua index 77ea5eb..50cbfdb 100644 --- a/supervisor/session/coordinator.lua +++ b/supervisor/session/coordinator.lua @@ -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 diff --git a/supervisor/session/plc.lua b/supervisor/session/plc.lua index 656e95b..fd15808 100644 --- a/supervisor/session/plc.lua +++ b/supervisor/session/plc.lua @@ -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 diff --git a/supervisor/session/pocket.lua b/supervisor/session/pocket.lua new file mode 100644 index 0000000..e760512 --- /dev/null +++ b/supervisor/session/pocket.lua @@ -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 diff --git a/supervisor/session/rtu.lua b/supervisor/session/rtu.lua index da5648c..7da723c 100644 --- a/supervisor/session/rtu.lua +++ b/supervisor/session/rtu.lua @@ -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 diff --git a/supervisor/session/rtu/redstone.lua b/supervisor/session/rtu/redstone.lua index 65831d6..bc7b81d 100644 --- a/supervisor/session/rtu/redstone.lua +++ b/supervisor/session/rtu/redstone.lua @@ -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 } diff --git a/supervisor/session/svsessions.lua b/supervisor/session/svsessions.lua index aa3506b..8ad3366 100644 --- a/supervisor/session/svsessions.lua +++ b/supervisor/session/svsessions.lua @@ -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
--- 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() diff --git a/supervisor/startup.lua b/supervisor/startup.lua index a731b42..c111794 100644 --- a/supervisor/startup.lua +++ b/supervisor/startup.lua @@ -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 diff --git a/supervisor/supervisor.lua b/supervisor/supervisor.lua index 1d25ed6..49cf482 100644 --- a/supervisor/supervisor.lua +++ b/supervisor/supervisor.lua @@ -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