diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..17ae161 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +_notes/ +program.sh \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..70230fe --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,23 @@ +{ + "Lua.diagnostics.globals": [ + "term", + "fs", + "peripheral", + "rs", + "bit", + "parallel", + "colors", + "textutils", + "shell", + "settings", + "window", + "read", + "periphemu", + "mekanismEnergyHelper", + "_HOST", + "http" + ], + "Lua.diagnostics.disable": [ + "duplicate-set-field" + ] +} diff --git a/LICENSE b/LICENSE index 7cd46cf..e6824b5 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2022 Mikayla Fischler +Copyright © 2022 - 2023 Mikayla Fischler Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index b9668b6..440c734 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,69 @@ -# cc-mek-reactor-controller -Configurable ComputerCraft multi-reactor control for Mekanism with a GUI, automatic safety features, waste processing control, and more! +# cc-mek-scada +Configurable ComputerCraft SCADA system for multi-reactor control of Mekanism fission reactors with a GUI, automatic safety features, waste processing control, and more! + +Mod Requirements: +- CC: Tweaked +- Mekanism v10.1+ + +Mod Recommendations: +- Advanced Peripherals (adds the capability to detect environmental radiation levels) + +v10.1+ is required due the complete support of CC:Tweaked added in Mekanism v10.1 + +There was also an apparent bug with boilers disconnecting and reconnecting when active in my test world on 10.0.24, so it may not even have been an option to fully implement this with support for 10.0. + + +## [SCADA](https://en.wikipedia.org/wiki/SCADA) +> Supervisory control and data acquisition (SCADA) is a control system architecture comprising computers, networked data communications and graphical user interfaces for high-level supervision of machines and processes. It also covers sensors and other devices, such as programmable logic controllers, which interface with process plant or machinery. + +This project implements concepts of a SCADA system in ComputerCraft (because why not? ..okay don't answer that). I recommend reviewing that linked wikipedia page on SCADA if you *want* to understand the concepts used here. + +![Architecture](https://upload.wikimedia.org/wikipedia/commons/thumb/1/10/Functional_levels_of_a_Distributed_Control_System.svg/1000px-Functional_levels_of_a_Distributed_Control_System.svg.png) + +SCADA and industrial automation terminology is used throughout the project, such as: +- Supervisory Computer: Gathers data and controls the process +- Coordinating Computer: Used as the HMI component, user requests high-level processing operations +- RTU: Remote Terminal Unit +- PLC: Programmable Logic Controller + +## ComputerCraft Architecture + +### Coordinator Server + +There can only be one of these. This server acts as a hybrid of levels 3 & 4 in the SCADA diagram above. In addition to viewing status and controlling processes with advanced monitors, it can host access for one or more Pocket computers. + +### Supervisory Computers + +There should be one of these per facility system. Currently, that means only one. In the future, multiple supervisors would provide the capability of coordinating between multiple facilities (like a fission facility, fusion facility, etc). + +### RTUs + +RTUs are effectively basic connections between a device and the SCADA system with no internal logic providing the system with I/O capabilities. A single Advanced Computer can represent multiple RTUs as instead I am modeling an RTU as the wired modems connected to that computer rather than the computer itself. Each RTU is referenced separately with an identifier in the modbus communications (see Communications section), so a single computer can distribute instructions to multiple devices. This should save on having a pile of computers everywhere (but if you want to have that, no one's stopping you). + +The RTU control code is relatively unique, as instead of having instructions be decoded simply, due to using modbus, I implemented a generalized RTU interface. To fulfill this, each type of I/O operation is linked to a function rather than implementing the logic itself. For example, to connect an input register to a turbine `getFlowRate()` call, the function reference itself is passed to the `connect_input_reg()` function. A call to `read_input_reg()` on that register address will call the `turbine.getFlowRate()` function and return the result. + +### PLCs + +PLCs are advanced devices that allow for both reporting and control to/from the SCADA system in addition to programed behaviors independent of the SCADA system. Currently there is only one type of PLC, and that is the reactor PLC. This is responsible for reporting on and controlling the reactor as a part of the SCADA system, and independently regulating the safety of the reactor. It checks the status for multiple hazard scenarios and shuts down the reactor if any condition is met. + +There can and should only be one of these per reactor. A single Advanced Computer will act as the PLC, with either a direct connection (physical contact) or a wired modem connection to the reactor logic port. + +## Communications + +A vaguely-modbus [modbus](https://en.wikipedia.org/wiki/Modbus) communication protocol is used for communication with RTUs. Useful terminology for you to know: +- Discrete Inputs: Single Bit Read-Only (digital inputs) +- Coils: Single Bit Read/Write (digital I/O) +- Input Registers: Multi-Byte Read-Only (analog inputs) +- Holding Registers: Multi-Byte Read/Write (analog I/O) + +### Security and Encryption + +TBD, I am planning on AES symmetric encryption for security + HMAC to prevent replay attacks. This will be done utilizing this codebase: https://github.com/somesocks/lua-lockbox. + +This is somewhat important here as otherwise anyone can just control your setup, which is undeseriable. Unlike normal Minecraft PVP chaos, it would be very difficult to identify who is messing with your system, as with an Ender Modem they can do it from effectively anywhere and the server operators would have to check every computer's filesystem to find suspicious code. + +The other security mitigation for commanding (no effect on monitoring) is to enforce a maximum authorized transmission range, which has been added as a configurable feature. + +## Known Issues + +None yet since the switch to requiring 10.1+! diff --git a/ccmsi.lua b/ccmsi.lua new file mode 100644 index 0000000..f728d01 --- /dev/null +++ b/ccmsi.lua @@ -0,0 +1,670 @@ +-- +-- ComputerCraft Mekanism SCADA System Installer Utility +-- + +--[[ +Copyright © 2023 Mikayla Fischler + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the “Software”), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT +LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +]]-- + +local function println(message) print(tostring(message)) end +local function print(message) term.write(tostring(message)) end + +local CCMSI_VERSION = "v1.0" + +local install_dir = "/.install-cache" +local repo_path = "http://raw.githubusercontent.com/MikaylaFischler/cc-mek-scada/" + +local opts = { ... } +local mode = nil +local app = nil + +-- record the local installation manifest +---@param manifest table +---@param dependencies table +local function write_install_manifest(manifest, dependencies) + local versions = {} + for key, value in pairs(manifest.versions) do + local is_dependency = false + for _, dependency in pairs(dependencies) do + if key == "bootloader" and dependency == "system" then + is_dependency = true + break + end + end + + if key == app or key == "comms" or is_dependency then versions[key] = value end + end + + manifest.versions = versions + + local imfile = fs.open("install_manifest.json", "w") + imfile.write(textutils.serializeJSON(manifest)) + imfile.close() +end + +-- +-- get and validate command line options +-- + +println("-- CC Mekanism SCADA Installer " .. CCMSI_VERSION .. " --") + +if #opts == 0 or opts[1] == "help" then + println("usage: ccmsi ") + println("") + term.setTextColor(colors.lightGray) + println(" check - check latest versions avilable") + term.setTextColor(colors.yellow) + println(" ccmsi check for target") + term.setTextColor(colors.lightGray) + println(" install - fresh install, overwrites config") + println(" update - update files EXCEPT for config/logs") + println(" remove - delete files EXCEPT for config/logs") + println(" purge - delete files INCLUDING config/logs") + term.setTextColor(colors.white) + println("") + term.setTextColor(colors.lightGray) + println(" reactor-plc - reactor PLC firmware") + println(" rtu - RTU firmware") + println(" supervisor - supervisor server application") + println(" coordinator - coordinator application") + println(" pocket - pocket application") + term.setTextColor(colors.white) + println("") + term.setTextColor(colors.yellow) + println(" second parameter when used with check") + term.setTextColor(colors.lightGray) + println(" note: defaults to main") + println(" target GitHub tag or branch name") + return +else + for _, v in pairs({ "check", "install", "update", "remove", "purge" }) do + if opts[1] == v then + mode = v + break + end + end + + if mode == nil then + println("unrecognized mode") + return + end + + for _, v in pairs({ "reactor-plc", "rtu", "supervisor", "coordinator", "pocket" }) do + if opts[2] == v then + app = v + break + end + end + + if app == nil and mode ~= "check" then + println("unrecognized application") + return + end +end + +-- +-- run selected mode +-- + +if mode == "check" then + ------------------------- + -- GET REMOTE MANIFEST -- + ------------------------- + + if opts[2] then repo_path = repo_path .. opts[2] .. "/" else repo_path = repo_path .. "main/" end + local install_manifest = repo_path .. "install_manifest.json" + + local response, error = http.get(install_manifest) + + if response == nil then + term.setTextColor(colors.orange) + println("failed to get installation manifest from GitHub, cannot update or install") + term.setTextColor(colors.red) + println("HTTP error: " .. error) + term.setTextColor(colors.white) + return + end + + local ok, manifest = pcall(function () return textutils.unserializeJSON(response.readAll()) end) + + if not ok then + term.setTextColor(colors.red) + println("error parsing remote installation manifest") + term.setTextColor(colors.white) + return + end + + ------------------------ + -- GET LOCAL MANIFEST -- + ------------------------ + + local imfile = fs.open("install_manifest.json", "r") + local local_ok = false + local local_manifest = {} + + if imfile ~= nil then + local_ok, local_manifest = pcall(function () return textutils.unserializeJSON(imfile.readAll()) end) + imfile.close() + end + + if not local_ok then + term.setTextColor(colors.yellow) + println("failed to load local installation information") + term.setTextColor(colors.white) + + local_manifest = { versions = { installer = CCMSI_VERSION } } + else + local_manifest.versions.installer = CCMSI_VERSION + end + + -- list all versions + for key, value in pairs(manifest.versions) do + term.setTextColor(colors.purple) + print(string.format("%-14s", "[" .. key .. "]")) + if key == "installer" or (local_ok and (local_manifest.versions[key] ~= nil)) then + term.setTextColor(colors.blue) + print(local_manifest.versions[key]) + if value ~= local_manifest.versions[key] then + term.setTextColor(colors.white) + print(" (") + term.setTextColor(colors.cyan) + print(value) + term.setTextColor(colors.white) + println(" available)") + else + term.setTextColor(colors.green) + println(" (up to date)") + end + else + term.setTextColor(colors.lightGray) + print("not installed") + term.setTextColor(colors.white) + print(" (latest ") + term.setTextColor(colors.cyan) + print(value) + term.setTextColor(colors.white) + println(")") + end + end +elseif mode == "install" or mode == "update" then + ------------------------- + -- GET REMOTE MANIFEST -- + ------------------------- + + if opts[3] then repo_path = repo_path .. opts[3] .. "/" else repo_path = repo_path .. "main/" end + local install_manifest = repo_path .. "install_manifest.json" + + local response, error = http.get(install_manifest) + + if response == nil then + term.setTextColor(colors.orange) + println("failed to get installation manifest from GitHub, cannot update or install") + term.setTextColor(colors.red) + println("HTTP error: " .. error) + term.setTextColor(colors.white) + return + end + + local ok, manifest = pcall(function () return textutils.unserializeJSON(response.readAll()) end) + + if not ok then + term.setTextColor(colors.red) + println("error parsing remote installation manifest") + term.setTextColor(colors.white) + end + + ------------------------ + -- GET LOCAL MANIFEST -- + ------------------------ + + local imfile = fs.open("install_manifest.json", "r") + local local_ok = false + local local_manifest = {} + + if imfile ~= nil then + local_ok, local_manifest = pcall(function () return textutils.unserializeJSON(imfile.readAll()) end) + imfile.close() + end + + local local_app_version = nil + local local_comms_version = nil + local local_boot_version = nil + + -- try to find local versions + if not local_ok then + if mode == "update" then + term.setTextColor(colors.red) + println("failed to load local installation information, cannot update") + term.setTextColor(colors.white) + return + end + else + local_app_version = local_manifest.versions[app] + local_comms_version = local_manifest.versions.comms + local_boot_version = local_manifest.versions.bootloader + + if local_manifest.versions[app] == nil then + term.setTextColor(colors.red) + println("another application is already installed, please purge it before installing a new application") + term.setTextColor(colors.white) + return + end + + local_manifest.versions.installer = CCMSI_VERSION + if manifest.versions.installer ~= CCMSI_VERSION then + term.setTextColor(colors.yellow) + println("a newer version of the installer is available, consider downloading it") + term.setTextColor(colors.white) + end + end + + local remote_app_version = manifest.versions[app] + local remote_comms_version = manifest.versions.comms + local remote_boot_version = manifest.versions.bootloader + + term.setTextColor(colors.green) + if mode == "install" then + println("installing " .. app .. " files...") + elseif mode == "update" then + println("updating " .. app .. " files... (keeping old config.lua)") + end + term.setTextColor(colors.white) + + -- display bootloader version change information + if local_boot_version ~= nil then + if local_boot_version ~= remote_boot_version then + print("[bootldr] updating ") + term.setTextColor(colors.blue) + print(local_boot_version) + term.setTextColor(colors.white) + print(" \xbb ") + term.setTextColor(colors.blue) + println(remote_boot_version) + term.setTextColor(colors.white) + elseif mode == "install" then + print("[bootldr] reinstalling ") + term.setTextColor(colors.blue) + println(local_boot_version) + term.setTextColor(colors.white) + end + else + print("[bootldr] new install of ") + term.setTextColor(colors.blue) + println(remote_boot_version) + term.setTextColor(colors.white) + end + + -- display app version change information + if local_app_version ~= nil then + if local_app_version ~= remote_app_version then + print("[" .. app .. "] updating ") + term.setTextColor(colors.blue) + print(local_app_version) + term.setTextColor(colors.white) + print(" \xbb ") + term.setTextColor(colors.blue) + println(remote_app_version) + term.setTextColor(colors.white) + elseif mode == "install" then + print("[" .. app .. "] reinstalling ") + term.setTextColor(colors.blue) + println(local_app_version) + term.setTextColor(colors.white) + end + else + print("[" .. app .. "] new install of ") + term.setTextColor(colors.blue) + println(remote_app_version) + term.setTextColor(colors.white) + end + + -- display comms version change information + if local_comms_version ~= nil then + if local_comms_version ~= remote_comms_version then + print("[comms] updating ") + term.setTextColor(colors.blue) + print(local_comms_version) + term.setTextColor(colors.white) + print(" \xbb ") + term.setTextColor(colors.blue) + println(remote_comms_version) + term.setTextColor(colors.white) + print("[comms] ") + term.setTextColor(colors.yellow) + println("other devices on the network will require an update") + term.setTextColor(colors.white) + elseif mode == "install" then + print("[comms] reinstalling ") + term.setTextColor(colors.blue) + println(local_comms_version) + term.setTextColor(colors.white) + end + else + print("[comms] new install of ") + term.setTextColor(colors.blue) + println(remote_comms_version) + term.setTextColor(colors.white) + end + + -------------------------- + -- START INSTALL/UPDATE -- + -------------------------- + + local space_required = manifest.sizes.manifest + local space_available = fs.getFreeSpace("/") + + local single_file_mode = false + local file_list = manifest.files + local size_list = manifest.sizes + local dependencies = manifest.depends[app] + local config_file = app .. "/config.lua" + + table.insert(dependencies, app) + + for _, dependency in pairs(dependencies) do + local size = size_list[dependency] + space_required = space_required + size + end + + -- check space constraints + if space_available < space_required then + single_file_mode = true + term.setTextColor(colors.yellow) + println("WARNING: Insufficient space available for a full download!") + term.setTextColor(colors.white) + println("Files can be downloaded one by one, so if you are replacing a current install this will not be a problem unless installation fails.") + println("Do you wish to continue? (y/N)") + + local confirm = read() + if confirm ~= "y" and confirm ~= "Y" then + println("installation cancelled") + return + end + end + +---@diagnostic disable-next-line: undefined-field + os.sleep(2) + + local success = true + + if not single_file_mode then + if fs.exists(install_dir) then + fs.delete(install_dir) + fs.makeDir(install_dir) + end + + -- download all dependencies + for _, dependency in pairs(dependencies) do + if mode == "update" and ((dependency == "system" and local_boot_version == remote_boot_version) or (local_app_version == remote_app_version)) then + -- skip system package if unchanged, skip app package if not changed + -- skip packages that have no version if app version didn't change + term.setTextColor(colors.white) + print("skipping download of unchanged package ") + term.setTextColor(colors.blue) + println(dependency) + else + term.setTextColor(colors.white) + print("downloading package ") + term.setTextColor(colors.blue) + println(dependency) + + term.setTextColor(colors.lightGray) + local files = file_list[dependency] + for _, file in pairs(files) do + println("GET " .. file) + local dl, err = http.get(repo_path .. file) + + if dl == nil then + term.setTextColor(colors.red) + println("GET HTTP Error " .. err) + success = false + break + else + local handle = fs.open(install_dir .. "/" .. file, "w") + handle.write(dl.readAll()) + handle.close() + end + end + end + end + + -- copy in downloaded files (installation) + if success then + for _, dependency in pairs(dependencies) do + if mode == "update" and ((dependency == "system" and local_boot_version == remote_boot_version) or (local_app_version == remote_app_version)) then + -- skip system package if unchanged, skip app package if not changed + -- skip packages that have no version if app version didn't change + term.setTextColor(colors.white) + print("skipping install of unchanged package ") + term.setTextColor(colors.blue) + println(dependency) + else + term.setTextColor(colors.white) + print("installing package ") + term.setTextColor(colors.blue) + println(dependency) + + term.setTextColor(colors.lightGray) + local files = file_list[dependency] + for _, file in pairs(files) do + if mode == "install" or file ~= config_file then + local temp_file = install_dir .. "/" .. file + if fs.exists(file) then fs.delete(file) end + fs.move(temp_file, file) + end + end + end + end + end + + fs.delete(install_dir) + + if success then + -- if we made it here, then none of the file system functions threw exceptions + -- that means everything is OK + write_install_manifest(manifest, dependencies) + term.setTextColor(colors.green) + if mode == "install" then + println("installation completed successfully") + else + println("update completed successfully") + end + else + if mode == "install" then + term.setTextColor(colors.red) + println("installation failed") + else + term.setTextColor(colors.orange) + println("update failed, existing files unmodified") + end + end + else + -- go through all files and replace one by one + for _, dependency in pairs(dependencies) do + if mode == "update" and ((dependency == "system" and local_boot_version == remote_boot_version) or (local_app_version == remote_app_version)) then + -- skip system package if unchanged, skip app package if not changed + -- skip packages that have no version if app version didn't change + term.setTextColor(colors.white) + print("skipping install of unchanged package ") + term.setTextColor(colors.blue) + println(dependency) + else + term.setTextColor(colors.white) + print("installing package ") + term.setTextColor(colors.blue) + println(dependency) + + term.setTextColor(colors.lightGray) + local files = file_list[dependency] + for _, file in pairs(files) do + if mode == "install" or file ~= config_file then + println("GET " .. file) + local dl, err = http.get(repo_path .. file) + + if dl == nil then + println("GET HTTP Error " .. err) + success = false + break + else + local handle = fs.open("/" .. file, "w") + handle.write(dl.readAll()) + handle.close() + end + end + end + end + end + + if success then + -- if we made it here, then none of the file system functions threw exceptions + -- that means everything is OK + write_install_manifest(manifest, dependencies) + term.setTextColor(colors.green) + if mode == "install" then + println("installation completed successfully") + else + println("update completed successfully") + end + else + term.setTextColor(colors.red) + if mode == "install" then + println("installation failed, files may have been skipped") + else + println("update failed, files may have been skipped") + end + end + end +elseif mode == "remove" or mode == "purge" then + local imfile = fs.open("install_manifest.json", "r") + local ok = false + local manifest = {} + + if imfile ~= nil then + ok, manifest = pcall(function () return textutils.unserializeJSON(imfile.readAll()) end) + imfile.close() + end + + if not ok then + term.setTextColor(colors.red) + println("error parsing local installation manifest") + term.setTextColor(colors.white) + return + elseif mode == "remove" and manifest.versions[app] == nil then + term.setTextColor(colors.red) + println(app .. " is not installed") + term.setTextColor(colors.white) + return + end + + term.setTextColor(colors.orange) + if mode == "remove" then + println("removing all " .. app .. " files except for config.lua and log.txt...") + elseif mode == "purge" then + println("purging all " .. app .. " files...") + end + +---@diagnostic disable-next-line: undefined-field + os.sleep(2) + + local file_list = manifest.files + local dependencies = manifest.depends[app] + local config_file = app .. "/config.lua" + + table.insert(dependencies, app) + + term.setTextColor(colors.lightGray) + + -- delete log file if purging + if mode == "purge" and fs.exists(config_file) then + local log_deleted = pcall(function () + local config = require(app .. ".config") + if fs.exists(config.LOG_PATH) then + fs.delete(config.LOG_PATH) + println("deleted log file " .. config.LOG_PATH) + end + end) + + if not log_deleted then + term.setTextColor(colors.red) + println("failed to delete log file") + term.setTextColor(colors.lightGray) +---@diagnostic disable-next-line: undefined-field + os.sleep(1) + end + end + + -- delete all files except config unless purging + for _, dependency in pairs(dependencies) do + local files = file_list[dependency] + for _, file in pairs(files) do + if mode == "purge" or file ~= config_file then + if fs.exists(file) then + fs.delete(file) + println("deleted " .. file) + end + end + end + + -- delete folders that we should be deleteing + if mode == "purge" or dependency ~= app then + local folder = files[1] + while true do + local dir = fs.getDir(folder) + if dir == "" or dir == ".." then + break + else + folder = dir + end + end + + if fs.isDir(folder) then + fs.delete(folder) + println("deleted directory " .. folder) + end + elseif dependency == app then + for _, folder in pairs(files) do + while true do + local dir = fs.getDir(folder) + if dir == "" or dir == ".." or dir == app then + break + else + folder = dir + end + end + + if folder ~= app and fs.isDir(folder) then + fs.delete(folder) + println("deleted app subdirectory " .. folder) + end + end + end + end + + -- only delete manifest if purging + if mode == "purge" then + fs.delete("install_manifest.json") + println("deleted install_manifest.json") + else + -- remove all data from versions list to show nothing is installed + manifest.versions = {} + imfile = fs.open("install_manifest.json", "w") + imfile.write(textutils.serializeJSON(manifest)) + imfile.close() + end + + term.setTextColor(colors.green) + println("done!") +end + +term.setTextColor(colors.white) diff --git a/coordinator/apisessions.lua b/coordinator/apisessions.lua new file mode 100644 index 0000000..268052e --- /dev/null +++ b/coordinator/apisessions.lua @@ -0,0 +1,20 @@ +local apisessions = {} + +---@param packet capi_frame +function apisessions.handle_packet(packet) +end + +-- attempt to identify which session's watchdog timer fired +---@param timer_event number +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 new file mode 100644 index 0000000..052bba4 --- /dev/null +++ b/coordinator/config.lua @@ -0,0 +1,31 @@ +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 +-- 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 + +-- expected number of reactor units, used only to require that number of unit monitors +config.NUM_UNITS = 4 + +-- alarm sounder volume (0.0 to 3.0, 1.0 being standard max volume, this is the option given to to speaker.play()) +-- note: alarm sine waves are at half saturation, so that multiple will be required to reach full scale +config.SOUNDER_VOLUME = 1.0 + +-- true for 24 hour time on main view screen +config.TIME_24_HOUR = true + +-- 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/coordinator/coordinator.lua b/coordinator/coordinator.lua new file mode 100644 index 0000000..821c82a --- /dev/null +++ b/coordinator/coordinator.lua @@ -0,0 +1,671 @@ +local comms = require("scada-common.comms") +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 dialog = require("coordinator.ui.dialog") + +local print = util.print +local println = util.println +local print_ts = util.print_ts +local println_ts = util.println_ts + +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 SCADA_CRDN_TYPE = comms.SCADA_CRDN_TYPE +local UNIT_COMMAND = comms.UNIT_COMMAND +local FAC_COMMAND = comms.FAC_COMMAND + +local coordinator = {} + +-- request the user to select a monitor +---@nodiscard +---@param names table available monitors +---@return boolean|string|nil +local function ask_monitor(names) + println("available monitors:") + for i = 1, #names do + print(" " .. names[i]) + end + println("") + println("select a monitor or type c to cancel") + + local iface = dialog.ask_options(names, "c") + + if iface ~= false and iface ~= nil then + util.filter_table(names, function (x) return x ~= iface end) + end + + return iface +end + +-- configure monitor layout +---@param num_units integer number of units expected +---@return boolean success, monitors_struct? monitors +function coordinator.configure_monitors(num_units) + ---@class monitors_struct + local monitors = { + primary = nil, + primary_name = "", + unit_displays = {}, + unit_name_map = {} + } + + local monitors_avail = ppm.get_monitor_list() + local names = {} + local available = {} + + -- get all interface names + for iface, _ in pairs(monitors_avail) do + table.insert(names, iface) + table.insert(available, iface) + end + + -- we need a certain number of monitors (1 per unit + 1 primary display) + local num_displays_needed = num_units + 1 + if #names < num_displays_needed then + local message = "not enough monitors connected (need " .. num_displays_needed .. ")" + println(message) + log.warning(message) + return false + end + + -- attempt to load settings + if not settings.load("/coord.settings") then + log.warning("configure_monitors(): failed to load coordinator settings file (may not exist yet)") + else + local _primary = settings.get("PRIMARY_DISPLAY") + local _unitd = settings.get("UNIT_DISPLAYS") + + -- filter out already assigned monitors + util.filter_table(available, function (x) return x ~= _primary end) + if type(_unitd) == "table" then + util.filter_table(available, function (x) return not util.table_contains(_unitd, x) end) + end + end + + --------------------- + -- PRIMARY DISPLAY -- + --------------------- + + local iface_primary_display = settings.get("PRIMARY_DISPLAY") ---@type boolean|string|nil + + if not util.table_contains(names, iface_primary_display) then + println("primary display is not connected") + local response = dialog.ask_y_n("would you like to change it", true) + if response == false then return false end + iface_primary_display = nil + end + + while iface_primary_display == nil and #available > 0 do + -- lets get a monitor + iface_primary_display = ask_monitor(available) + end + + if type(iface_primary_display) ~= "string" then return false end + + settings.set("PRIMARY_DISPLAY", iface_primary_display) + util.filter_table(available, function (x) return x ~= iface_primary_display end) + + monitors.primary = ppm.get_periph(iface_primary_display) + monitors.primary_name = iface_primary_display + + ------------------- + -- UNIT DISPLAYS -- + ------------------- + + local unit_displays = settings.get("UNIT_DISPLAYS") + + if unit_displays == nil then + unit_displays = {} + for i = 1, num_units do + local display = nil + + while display == nil and #available > 0 do + -- lets get a monitor + println("please select monitor for unit #" .. i) + display = ask_monitor(available) + end + + if display == false then return false end + + unit_displays[i] = display + end + else + -- make sure all displays are connected + for i = 1, num_units do + local display = unit_displays[i] + + if not util.table_contains(names, display) then + println("unit #" .. i .. " display is not connected") + local response = dialog.ask_y_n("would you like to change it", true) + if response == false then return false end + display = nil + end + + while display == nil and #available > 0 do + -- lets get a monitor + display = ask_monitor(available) + end + + if display == false then return false end + + unit_displays[i] = display + end + end + + settings.set("UNIT_DISPLAYS", unit_displays) + if not settings.save("/coord.settings") then + log.warning("configure_monitors(): failed to save coordinator settings file") + end + + for i = 1, #unit_displays do + monitors.unit_displays[i] = ppm.get_periph(unit_displays[i]) + monitors.unit_name_map[i] = unit_displays[i] + end + + return true, monitors +end + +-- dmesg print wrapper +---@param message string message +---@param dmesg_tag string tag +---@param working? boolean to use dmesg_working +---@return function? update, function? done +local function log_dmesg(message, dmesg_tag, working) + local colors = { + GRAPHICS = colors.green, + SYSTEM = colors.cyan, + BOOT = colors.blue, + COMMS = colors.purple + } + + if working then + return log.dmesg_working(message, dmesg_tag, colors[dmesg_tag]) + else + log.dmesg(message, dmesg_tag, colors[dmesg_tag]) + end +end + +function coordinator.log_graphics(message) log_dmesg(message, "GRAPHICS") end +function coordinator.log_sys(message) log_dmesg(message, "SYSTEM") end +function coordinator.log_boot(message) log_dmesg(message, "BOOT") end +function coordinator.log_comms(message) log_dmesg(message, "COMMS") end + +-- log a message for communications connecting, providing access to progress indication control functions +---@nodiscard +---@param message string +---@return function update, function done +function coordinator.log_comms_connecting(message) + local update, done = log_dmesg(message, "COMMS", true) + ---@cast update function + ---@cast done function + return update, done +end + +-- coordinator communications +---@nodiscard +---@param version string coordinator version +---@param modem table modem device +---@param sv_port integer port of configured supervisor +---@param sv_listen integer listening port for supervisor replys +---@param api_listen integer listening port for pocket API +---@param range integer trusted device connection range +---@param sv_watchdog watchdog +function coordinator.comms(version, modem, sv_port, sv_listen, api_listen, range, sv_watchdog) + local self = { + sv_linked = false, + sv_seq_num = 0, + sv_r_seq_num = nil, + sv_config_err = false, + connected = false, + last_est_ack = ESTABLISH_ACK.ALLOW + } + + comms.set_trusted_range(range) + + -- PRIVATE FUNCTIONS -- + + -- configure modem channels + local function _conf_channels() + modem.closeAll() + modem.open(sv_listen) + modem.open(api_listen) + end + + _conf_channels() + + -- send a packet to the supervisor + ---@param msg_type SCADA_MGMT_TYPE|SCADA_CRDN_TYPE + ---@param msg table + local function _send_sv(protocol, msg_type, msg) + local s_pkt = comms.scada_packet() + local pkt = nil ---@type mgmt_packet|crdn_packet + + if protocol == PROTOCOL.SCADA_MGMT then + pkt = comms.mgmt_packet() + elseif protocol == PROTOCOL.SCADA_CRDN then + pkt = comms.crdn_packet() + else + return + end + + pkt.make(msg_type, msg) + s_pkt.make(self.sv_seq_num, protocol, pkt.raw_sendable()) + + modem.transmit(sv_port, sv_listen, s_pkt.raw_sendable()) + self.sv_seq_num = self.sv_seq_num + 1 + end + + -- attempt connection establishment + local function _send_establish() + _send_sv(PROTOCOL.SCADA_MGMT, SCADA_MGMT_TYPE.ESTABLISH, { comms.version, version, DEVICE_TYPE.CRDN }) + end + + -- keep alive ack + ---@param srv_time integer + local function _send_keep_alive_ack(srv_time) + _send_sv(PROTOCOL.SCADA_MGMT, SCADA_MGMT_TYPE.KEEP_ALIVE, { srv_time, util.time() }) + end + + -- PUBLIC FUNCTIONS -- + + ---@class coord_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 the connection to the server + function public.close() + sv_watchdog.cancel() + self.sv_linked = false + _send_sv(PROTOCOL.SCADA_MGMT, SCADA_MGMT_TYPE.CLOSE, {}) + end + + -- attempt to connect to the subervisor + ---@nodiscard + ---@param timeout_s number timeout in seconds + ---@param tick_dmesg_waiting function callback to tick dmesg waiting + ---@param task_done function callback to show done on dmesg + ---@return boolean sv_linked true if connected, false otherwise + --- EVENT_CONSUMER: this function consumes events + function public.sv_connect(timeout_s, tick_dmesg_waiting, task_done) + local clock = util.new_clock(1) + local start = util.time_s() + local terminated = false + + _send_establish() + + clock.start() + + while (util.time_s() - start) < timeout_s and (not self.sv_linked) and (not self.sv_config_err) do + local event, p1, p2, p3, p4, p5 = util.pull_event() + + if event == "timer" and clock.is_clock(p1) then + -- timed out attempt, try again + tick_dmesg_waiting(math.max(0, timeout_s - (util.time_s() - start))) + _send_establish() + clock.start() + elseif event == "modem_message" then + -- handle message + local packet = public.parse_packet(p1, p2, p3, p4, p5) + if packet ~= nil and packet.type == SCADA_MGMT_TYPE.ESTABLISH then + public.handle_packet(packet) + end + elseif event == "terminate" then + terminated = true + break + end + end + + task_done(self.sv_linked) + + if terminated then + coordinator.log_comms("supervisor connection attempt cancelled by user") + elseif self.sv_config_err then + coordinator.log_comms("supervisor cooling configuration invalid, check supervisor config file") + elseif not self.sv_linked then + if self.last_est_ack == ESTABLISH_ACK.DENY then + coordinator.log_comms("supervisor connection attempt denied") + elseif self.last_est_ack == ESTABLISH_ACK.COLLISION then + coordinator.log_comms("supervisor connection failed due to collision") + elseif self.last_est_ack == ESTABLISH_ACK.BAD_VERSION then + coordinator.log_comms("supervisor connection failed due to version mismatch") + else + coordinator.log_comms("supervisor connection failed with no valid response") + end + end + + return self.sv_linked + end + + -- send a facility command + ---@param cmd FAC_COMMAND command + function public.send_fac_command(cmd) + _send_sv(PROTOCOL.SCADA_CRDN, SCADA_CRDN_TYPE.FAC_CMD, { cmd }) + end + + -- send the auto process control configuration with a start command + ---@param config coord_auto_config configuration + function public.send_auto_start(config) + _send_sv(PROTOCOL.SCADA_CRDN, SCADA_CRDN_TYPE.FAC_CMD, { + FAC_COMMAND.START, config.mode, config.burn_target, config.charge_target, config.gen_target, config.limits + }) + end + + -- send a unit command + ---@param cmd UNIT_COMMAND command + ---@param unit integer unit ID + ---@param option any? optional option options for the optional options (like burn rate) (does option still look like a word?) + function public.send_unit_command(cmd, unit, option) + _send_sv(PROTOCOL.SCADA_CRDN, SCADA_CRDN_TYPE.UNIT_CMD, { cmd, unit, option }) + end + + -- parse a packet + ---@param side string + ---@param sender integer + ---@param reply_to integer + ---@param message any + ---@param distance integer + ---@return mgmt_frame|crdn_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 packet + elseif s_pkt.protocol() == PROTOCOL.SCADA_CRDN then + local crdn_pkt = comms.crdn_packet() + if crdn_pkt.decode(s_pkt) then + pkt = crdn_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|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() + + if l_port == api_listen then + if protocol == PROTOCOL.COORD_API then + ---@cast packet capi_frame + apisessions.handle_packet(packet) + else + log.debug("illegal packet type " .. protocol .. " on api listening channel", true) + end + elseif l_port == sv_listen 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_CRDN then + ---@cast packet crdn_frame + if self.sv_linked then + if packet.type == SCADA_CRDN_TYPE.INITIAL_BUILDS then + if packet.length == 2 then + -- record builds + local fac_builds = iocontrol.record_facility_builds(packet.data[1]) + local unit_builds = iocontrol.record_unit_builds(packet.data[2]) + + if fac_builds and unit_builds then + -- acknowledge receipt of builds + _send_sv(PROTOCOL.SCADA_CRDN, SCADA_CRDN_TYPE.INITIAL_BUILDS, {}) + else + log.debug("received invalid INITIAL_BUILDS packet") + end + else + log.debug("INITIAL_BUILDS packet length mismatch") + end + elseif packet.type == SCADA_CRDN_TYPE.FAC_BUILDS then + if packet.length == 1 then + -- record facility builds + if iocontrol.record_facility_builds(packet.data[1]) then + -- acknowledge receipt of builds + _send_sv(PROTOCOL.SCADA_CRDN, SCADA_CRDN_TYPE.FAC_BUILDS, {}) + else + log.debug("received invalid FAC_BUILDS packet") + end + else + log.debug("FAC_BUILDS packet length mismatch") + end + elseif packet.type == SCADA_CRDN_TYPE.FAC_STATUS then + -- update facility status + if not iocontrol.update_facility_status(packet.data) then + log.debug("received invalid FAC_STATUS packet") + end + elseif packet.type == SCADA_CRDN_TYPE.FAC_CMD then + -- facility command acknowledgement + if packet.length >= 2 then + local cmd = packet.data[1] + local ack = packet.data[2] == true + + if cmd == FAC_COMMAND.SCRAM_ALL then + iocontrol.get_db().facility.scram_ack(ack) + elseif cmd == FAC_COMMAND.STOP then + iocontrol.get_db().facility.stop_ack(ack) + elseif cmd == FAC_COMMAND.START then + if packet.length == 7 then + process.start_ack_handle({ table.unpack(packet.data, 2) }) + else + log.debug("SCADA_CRDN process start (with configuration) ack echo packet length mismatch") + end + elseif cmd == FAC_COMMAND.ACK_ALL_ALARMS then + iocontrol.get_db().facility.ack_alarms_ack(ack) + else + log.debug(util.c("received facility command ack with unknown command ", cmd)) + end + else + log.debug("SCADA_CRDN facility command ack packet length mismatch") + end + elseif packet.type == SCADA_CRDN_TYPE.UNIT_BUILDS then + -- record builds + if packet.length == 1 then + if iocontrol.record_unit_builds(packet.data[1]) then + -- acknowledge receipt of builds + _send_sv(PROTOCOL.SCADA_CRDN, SCADA_CRDN_TYPE.UNIT_BUILDS, {}) + else + log.debug("received invalid UNIT_BUILDS packet") + end + else + log.debug("UNIT_BUILDS packet length mismatch") + end + elseif packet.type == SCADA_CRDN_TYPE.UNIT_STATUSES then + -- update statuses + if not iocontrol.update_unit_statuses(packet.data) then + log.error("received invalid UNIT_STATUSES packet") + end + elseif packet.type == SCADA_CRDN_TYPE.UNIT_CMD then + -- unit command acknowledgement + if packet.length == 3 then + local cmd = packet.data[1] + local unit_id = packet.data[2] + local ack = packet.data[3] == true + + local unit = iocontrol.get_db().units[unit_id] ---@type ioctl_unit + + if unit ~= nil then + if cmd == UNIT_COMMAND.SCRAM then + unit.scram_ack(ack) + elseif cmd == UNIT_COMMAND.START then + unit.start_ack(ack) + elseif cmd == UNIT_COMMAND.RESET_RPS then + unit.reset_rps_ack(ack) + elseif cmd == UNIT_COMMAND.SET_BURN then + unit.set_burn_ack(ack) + elseif cmd == UNIT_COMMAND.SET_WASTE then + unit.set_waste_ack(ack) + elseif cmd == UNIT_COMMAND.ACK_ALL_ALARMS then + unit.ack_alarms_ack(ack) + elseif cmd == UNIT_COMMAND.SET_GROUP then + -- UI will be updated to display current group if changed successfully + else + log.debug(util.c("received unit command ack with unknown command ", cmd)) + end + else + log.debug(util.c("received unit command ack with unknown unit ", unit_id)) + end + else + log.debug("SCADA_CRDN unit command ack packet length mismatch") + end + else + log.warning("received unknown SCADA_CRDN packet type " .. packet.type) + end + else + log.debug("discarding SCADA_CRDN packet before linked") + end + elseif protocol == PROTOCOL.SCADA_MGMT then + ---@cast packet mgmt_frame + if packet.type == SCADA_MGMT_TYPE.ESTABLISH then + -- connection with supervisor established + if packet.length == 2 then + local est_ack = packet.data[1] + local config = packet.data[2] + + if est_ack == ESTABLISH_ACK.ALLOW then + if type(config) == "table" and #config > 1 then + -- get configuration + + ---@class facility_conf + local conf = { + num_units = config[1], ---@type integer + defs = {} -- boilers and turbines + } + + if (#config - 1) == (conf.num_units * 2) then + -- record sequence of pairs of [#boilers, #turbines] per unit + for i = 2, #config do + table.insert(conf.defs, config[i]) + end + + -- init io controller + iocontrol.init(conf, public) + + self.sv_linked = true + self.sv_config_err = false + else + self.sv_config_err = true + log.warning("invalid supervisor configuration definitions received, establish failed") + end + else + log.debug("invalid supervisor configuration table received, establish failed") + end + else + log.debug("SCADA_MGMT establish packet reply (len = 2) unsupported") + end + + self.last_est_ack = est_ack + elseif packet.length == 1 then + local est_ack = packet.data[1] + + if est_ack == ESTABLISH_ACK.DENY then + if self.last_est_ack ~= est_ack then + log.info("supervisor connection denied") + end + elseif est_ack == ESTABLISH_ACK.COLLISION then + if self.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.last_est_ack ~= est_ack then + log.info("supervisor comms version mismatch") + end + else + log.debug("SCADA_MGMT establish packet reply (len = 1) unsupported") + end + + self.last_est_ack = est_ack + else + log.debug("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("coord KEEP_ALIVE trip time > 750ms (" .. trip_time .. "ms)") + end + + -- log.debug("coord RTT = " .. trip_time .. "ms") + + iocontrol.get_db().facility.ps.publish("sv_ping", trip_time) + + _send_keep_alive_ack(timestamp) + else + log.debug("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 + println_ts("server connection closed by remote host") + log.info("server connection closed by remote host") + else + log.debug("received unknown SCADA_MGMT packet type " .. packet.type) + end + else + log.debug("discarding non-link SCADA_MGMT packet before linked") + end + else + log.debug("illegal packet type " .. protocol .. " on supervisor listening channel", true) + end + else + log.debug("received packet on unconfigured channel " .. l_port, true) + end + end + end + + -- check if the coordinator is still linked to the supervisor + ---@nodiscard + function public.is_linked() return self.sv_linked end + + return public +end + +return coordinator diff --git a/coordinator/iocontrol.lua b/coordinator/iocontrol.lua new file mode 100644 index 0000000..fc54fb3 --- /dev/null +++ b/coordinator/iocontrol.lua @@ -0,0 +1,772 @@ +-- +-- I/O Control for Supervisor/Coordinator Integration +-- + +local log = require("scada-common.log") +local psil = require("scada-common.psil") +local types = require("scada-common.types") +local util = require("scada-common.util") + +local process = require("coordinator.process") +local sounder = require("coordinator.sounder") + +local ALARM_STATE = types.ALARM_STATE +local PROCESS = types.PROCESS + +local iocontrol = {} + +---@class ioctl +local io = {} + +-- initialize the coordinator IO controller +---@param conf facility_conf configuration +---@param comms coord_comms comms reference +function iocontrol.init(conf, comms) + ---@class ioctl_facility + io.facility = { + num_units = conf.num_units, ---@type integer + all_sys_ok = false, + rtu_count = 0, + + auto_ready = false, + auto_active = false, + auto_ramping = false, + auto_saturated = false, + + auto_scram = false, + ---@type ascram_status + ascram_status = { + matrix_dc = false, + matrix_fill = false, + crit_alarm = false, + radiation = false, + gen_fault = false + }, + + 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 + + ps = psil.create(), + + induction_ps_tbl = {}, + induction_data_tbl = {}, + + env_d_ps = psil.create(), + env_d_data = {} + } + + -- create induction tables (currently only 1 is supported) + for _ = 1, conf.num_units do + local data = {} ---@type imatrix_session_db + table.insert(io.facility.induction_ps_tbl, psil.create()) + table.insert(io.facility.induction_data_tbl, data) + end + + io.units = {} + for i = 1, conf.num_units do + local function ack(alarm) process.ack_alarm(i, alarm) end + local function reset(alarm) process.reset_alarm(i, alarm) end + + ---@class ioctl_unit + local entry = { + ---@type integer + unit_id = i, + + num_boilers = 0, + num_turbines = 0, + + control_state = false, + burn_rate_cmd = 0.0, + waste_control = 0, + radiation = types.new_zero_radiation_reading(), + + a_group = 0, -- auto control group + + start = function () process.start(i) end, + scram = function () process.scram(i) end, + reset_rps = function () process.reset_rps(i) end, + ack_alarms = function () process.ack_all_alarms(i) end, + set_burn = function (rate) process.set_rate(i, rate) end, ---@param rate number burn rate + set_waste = function (mode) process.set_waste(i, mode) end, ---@param mode integer waste processing mode + + 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 + + alarm_callbacks = { + c_breach = { ack = function () ack(1) end, reset = function () reset(1) end }, + radiation = { ack = function () ack(2) end, reset = function () reset(2) end }, + r_lost = { ack = function () ack(3) end, reset = function () reset(3) end }, + dmg_crit = { ack = function () ack(4) end, reset = function () reset(4) end }, + damage = { ack = function () ack(5) end, reset = function () reset(5) end }, + over_temp = { ack = function () ack(6) end, reset = function () reset(6) end }, + high_temp = { ack = function () ack(7) end, reset = function () reset(7) end }, + waste_leak = { ack = function () ack(8) end, reset = function () reset(8) end }, + waste_high = { ack = function () ack(9) end, reset = function () reset(9) end }, + rps_trans = { ack = function () ack(10) end, reset = function () reset(10) end }, + rcs_trans = { ack = function () ack(11) end, reset = function () reset(11) end }, + t_trip = { ack = function () ack(12) end, reset = function () reset(12) end } + }, + + ---@type alarms + alarms = { + ALARM_STATE.INACTIVE, -- containment breach + ALARM_STATE.INACTIVE, -- containment radiation + ALARM_STATE.INACTIVE, -- reactor lost + ALARM_STATE.INACTIVE, -- damage critical + ALARM_STATE.INACTIVE, -- reactor taking damage + ALARM_STATE.INACTIVE, -- reactor over temperature + ALARM_STATE.INACTIVE, -- reactor high temperature + ALARM_STATE.INACTIVE, -- waste leak + ALARM_STATE.INACTIVE, -- waste level high + ALARM_STATE.INACTIVE, -- RPS transient + ALARM_STATE.INACTIVE, -- RCS transient + ALARM_STATE.INACTIVE -- turbine trip + }, + + annunciator = {}, ---@type annunciator + + unit_ps = psil.create(), + reactor_data = {}, ---@type reactor_db + + boiler_ps_tbl = {}, + boiler_data_tbl = {}, + + turbine_ps_tbl = {}, + turbine_data_tbl = {} + } + + -- create boiler tables + for _ = 1, conf.defs[(i * 2) - 1] do + local data = {} ---@type boilerv_session_db + table.insert(entry.boiler_ps_tbl, psil.create()) + table.insert(entry.boiler_data_tbl, data) + end + + -- create turbine tables + for _ = 1, conf.defs[i * 2] do + local data = {} ---@type turbinev_session_db + table.insert(entry.turbine_ps_tbl, psil.create()) + table.insert(entry.turbine_data_tbl, data) + end + + entry.num_boilers = #entry.boiler_data_tbl + entry.num_turbines = #entry.turbine_data_tbl + + table.insert(io.units, entry) + end + + -- pass IO control here since it can't be require'd due to a require loop + process.init(io, comms) +end + +-- populate facility structure builds +---@param build table +---@return boolean valid +function iocontrol.record_facility_builds(build) + local valid = true + + if type(build) == "table" then + local fac = io.facility + + -- induction matricies + if type(build.induction) == "table" then + for id, matrix in pairs(build.induction) do + if type(fac.induction_data_tbl[id]) == "table" then + fac.induction_data_tbl[id].formed = matrix[1] ---@type boolean + fac.induction_data_tbl[id].build = matrix[2] ---@type table + + fac.induction_ps_tbl[id].publish("formed", matrix[1]) + + for key, val in pairs(fac.induction_data_tbl[id].build) do + fac.induction_ps_tbl[id].publish(key, val) + end + else + log.debug(util.c("iocontrol.record_facility_builds: invalid induction matrix id ", id)) + valid = false + end + end + end + else + log.debug("facility builds not a table") + valid = false + end + + return valid +end + +-- populate unit structure builds +---@param builds table +---@return boolean valid +function iocontrol.record_unit_builds(builds) + local valid = true + + -- note: if not all units and RTUs are connected, some will be nil + for id, build in pairs(builds) do + local unit = io.units[id] ---@type ioctl_unit + + local log_header = util.c("iocontrol.record_unit_builds[UNIT ", id, "]: ") + + if type(build) ~= "table" then + log.debug(log_header .. "build not a table") + valid = false + elseif type(unit) ~= "table" then + log.debug(log_header .. "invalid unit id") + valid = false + else + -- reactor build + if type(build.reactor) == "table" then + unit.reactor_data.mek_struct = build.reactor ---@type mek_struct + for key, val in pairs(unit.reactor_data.mek_struct) do + unit.unit_ps.publish(key, val) + end + + if (type(unit.reactor_data.mek_struct.length) == "number") and (unit.reactor_data.mek_struct.length ~= 0) and + (type(unit.reactor_data.mek_struct.width) == "number") and (unit.reactor_data.mek_struct.width ~= 0) then + unit.unit_ps.publish("size", { unit.reactor_data.mek_struct.length, unit.reactor_data.mek_struct.width }) + end + end + + -- boiler builds + if type(build.boilers) == "table" then + for b_id, boiler in pairs(build.boilers) do + if type(unit.boiler_data_tbl[b_id]) == "table" then + unit.boiler_data_tbl[b_id].formed = boiler[1] ---@type boolean + unit.boiler_data_tbl[b_id].build = boiler[2] ---@type table + + unit.boiler_ps_tbl[b_id].publish("formed", boiler[1]) + + for key, val in pairs(unit.boiler_data_tbl[b_id].build) do + unit.boiler_ps_tbl[b_id].publish(key, val) + end + else + log.debug(util.c(log_header, "invalid boiler id ", b_id)) + valid = false + end + end + end + + -- turbine builds + if type(build.turbines) == "table" then + for t_id, turbine in pairs(build.turbines) do + if type(unit.turbine_data_tbl[t_id]) == "table" then + unit.turbine_data_tbl[t_id].formed = turbine[1] ---@type boolean + unit.turbine_data_tbl[t_id].build = turbine[2] ---@type table + + unit.turbine_ps_tbl[t_id].publish("formed", turbine[1]) + + for key, val in pairs(unit.turbine_data_tbl[t_id].build) do + unit.turbine_ps_tbl[t_id].publish(key, val) + end + else + log.debug(util.c(log_header, "invalid turbine id ", t_id)) + valid = false + end + end + end + end + end + + return valid +end + +-- update facility status +---@param status table +---@return boolean valid +function iocontrol.update_facility_status(status) + local valid = true + local log_header = util.c("iocontrol.update_facility_status: ") + + if type(status) ~= "table" then + log.debug(util.c(log_header, "status not a table")) + valid = false + else + local fac = io.facility + + -- auto control status information + + local ctl_status = status[1] + + if type(ctl_status) == "table" and #ctl_status == 14 then + fac.all_sys_ok = ctl_status[1] + fac.auto_ready = ctl_status[2] + + if type(ctl_status[3]) == "number" then + fac.auto_active = ctl_status[3] > PROCESS.INACTIVE + else + fac.auto_active = false + valid = false + end + + fac.auto_ramping = ctl_status[4] + fac.auto_saturated = ctl_status[5] + + fac.auto_scram = ctl_status[6] + fac.ascram_status.matrix_dc = ctl_status[7] + fac.ascram_status.matrix_fill = ctl_status[8] + fac.ascram_status.crit_alarm = ctl_status[9] + fac.ascram_status.radiation = ctl_status[10] + fac.ascram_status.gen_fault = ctl_status[11] + + fac.status_line_1 = ctl_status[12] + fac.status_line_2 = ctl_status[13] + + fac.ps.publish("all_sys_ok", fac.all_sys_ok) + fac.ps.publish("auto_ready", fac.auto_ready) + fac.ps.publish("auto_active", fac.auto_active) + fac.ps.publish("auto_ramping", fac.auto_ramping) + fac.ps.publish("auto_saturated", fac.auto_saturated) + fac.ps.publish("auto_scram", fac.auto_scram) + fac.ps.publish("as_matrix_dc", fac.ascram_status.matrix_dc) + fac.ps.publish("as_matrix_fill", fac.ascram_status.matrix_fill) + fac.ps.publish("as_crit_alarm", fac.ascram_status.crit_alarm) + fac.ps.publish("as_radiation", fac.ascram_status.radiation) + fac.ps.publish("as_gen_fault", fac.ascram_status.gen_fault) + fac.ps.publish("status_line_1", fac.status_line_1) + fac.ps.publish("status_line_2", fac.status_line_2) + + local group_map = ctl_status[14] + + if (type(group_map) == "table") and (#group_map == fac.num_units) then + local names = { "Manual", "Primary", "Secondary", "Tertiary", "Backup" } + for i = 1, #group_map do + io.units[i].a_group = group_map[i] + io.units[i].unit_ps.publish("auto_group_id", group_map[i]) + io.units[i].unit_ps.publish("auto_group", names[group_map[i] + 1]) + end + end + else + log.debug(log_header .. "control status not a table or length mismatch") + valid = false + end + + -- RTU statuses + + local rtu_statuses = status[2] + + fac.rtu_count = 0 + + if type(rtu_statuses) == "table" then + -- connected RTU count + fac.rtu_count = rtu_statuses.count + + -- power statistics + if type(rtu_statuses.power) == "table" then + fac.induction_ps_tbl[1].publish("avg_charge", rtu_statuses.power[1]) + fac.induction_ps_tbl[1].publish("avg_inflow", rtu_statuses.power[2]) + fac.induction_ps_tbl[1].publish("avg_outflow", rtu_statuses.power[3]) + else + log.debug(log_header .. "power statistics list not a table") + valid = false + end + + -- induction matricies statuses + if type(rtu_statuses.induction) == "table" then + for id = 1, #fac.induction_ps_tbl do + if rtu_statuses.induction[id] == nil then + -- disconnected + fac.induction_ps_tbl[id].publish("computed_status", 1) + end + end + + for id, matrix in pairs(rtu_statuses.induction) do + if type(fac.induction_data_tbl[id]) == "table" then + local rtu_faulted = matrix[1] ---@type boolean + fac.induction_data_tbl[id].formed = matrix[2] ---@type boolean + fac.induction_data_tbl[id].state = matrix[3] ---@type table + fac.induction_data_tbl[id].tanks = matrix[4] ---@type table + + local data = fac.induction_data_tbl[id] ---@type imatrix_session_db + + fac.induction_ps_tbl[id].publish("formed", data.formed) + fac.induction_ps_tbl[id].publish("faulted", rtu_faulted) + + if data.formed then + if rtu_faulted then + fac.induction_ps_tbl[id].publish("computed_status", 3) -- faulted + elseif data.tanks.energy_fill >= 0.99 then + fac.induction_ps_tbl[id].publish("computed_status", 6) -- full + elseif data.tanks.energy_fill <= 0.01 then + fac.induction_ps_tbl[id].publish("computed_status", 5) -- empty + else + fac.induction_ps_tbl[id].publish("computed_status", 4) -- on-line + end + else + fac.induction_ps_tbl[id].publish("computed_status", 2) -- not formed + end + + for key, val in pairs(fac.induction_data_tbl[id].state) do + fac.induction_ps_tbl[id].publish(key, val) + end + + for key, val in pairs(fac.induction_data_tbl[id].tanks) do + fac.induction_ps_tbl[id].publish(key, val) + end + else + log.debug(util.c(log_header, "invalid induction matrix id ", id)) + end + end + else + log.debug(log_header .. "induction matrix list not a table") + valid = false + end + + -- environment detector status + if type(rtu_statuses.rad_mon) == "table" then + if #rtu_statuses.rad_mon > 0 then + local rad_mon = rtu_statuses.rad_mon[1] + local rtu_faulted = rad_mon[1] ---@type boolean + fac.radiation = rad_mon[2] ---@type number + + fac.ps.publish("rad_computed_status", util.trinary(rtu_faulted, 2, 3)) + fac.ps.publish("radiation", fac.radiation) + else + fac.radiation = types.new_zero_radiation_reading() + fac.ps.publish("rad_computed_status", 1) + end + else + log.debug(log_header .. "radiation monitor list not a table") + valid = false + end + else + log.debug(log_header .. "rtu statuses not a table") + valid = false + end + + fac.ps.publish("rtu_count", fac.rtu_count) + end + + return valid +end + +-- update unit statuses +---@param statuses table +---@return boolean valid +function iocontrol.update_unit_statuses(statuses) + local valid = true + + if type(statuses) ~= "table" then + log.debug("iocontrol.update_unit_statuses: unit statuses not a table") + valid = false + elseif #statuses ~= #io.units then + log.debug("iocontrol.update_unit_statuses: number of provided unit statuses does not match expected number of units") + valid = false + else + local burn_rate_sum = 0.0 + + -- get all unit statuses + for i = 1, #statuses do + local log_header = util.c("iocontrol.update_unit_statuses[unit ", i, "]: ") + + local unit = io.units[i] ---@type ioctl_unit + local status = statuses[i] + + if type(status) ~= "table" or #status ~= 5 then + log.debug(log_header .. "invalid status entry in unit statuses (not a table or invalid length)") + valid = false + else + -- reactor PLC status + local reactor_status = status[1] + + if type(reactor_status) ~= "table" then + reactor_status = {} + log.debug(log_header .. "reactor status not a table") + end + + if #reactor_status == 0 then + unit.unit_ps.publish("computed_status", 1) -- disconnected + elseif #reactor_status == 3 then + local mek_status = reactor_status[1] + local rps_status = reactor_status[2] + local gen_status = reactor_status[3] + + if #gen_status == 6 then + unit.reactor_data.last_status_update = gen_status[1] + unit.reactor_data.control_state = gen_status[2] + unit.reactor_data.rps_tripped = gen_status[3] + unit.reactor_data.rps_trip_cause = gen_status[4] + unit.reactor_data.no_reactor = gen_status[5] + unit.reactor_data.formed = gen_status[6] + else + log.debug(log_header .. "reactor general status length mismatch") + end + + unit.reactor_data.rps_status = rps_status ---@type rps_status + unit.reactor_data.mek_status = mek_status ---@type mek_status + + -- if status hasn't been received, mek_status = {} + if type(unit.reactor_data.mek_status.act_burn_rate) == "number" then + burn_rate_sum = burn_rate_sum + unit.reactor_data.mek_status.act_burn_rate + end + + if unit.reactor_data.mek_status.status then + unit.unit_ps.publish("computed_status", 5) -- running + else + if unit.reactor_data.no_reactor then + unit.unit_ps.publish("computed_status", 3) -- faulted + elseif not unit.reactor_data.formed then + unit.unit_ps.publish("computed_status", 2) -- multiblock not formed + elseif unit.reactor_data.rps_status.force_dis then + unit.unit_ps.publish("computed_status", 7) -- reactor force disabled + elseif unit.reactor_data.rps_tripped and unit.reactor_data.rps_trip_cause ~= "manual" then + unit.unit_ps.publish("computed_status", 6) -- SCRAM + else + unit.unit_ps.publish("computed_status", 4) -- disabled + end + end + + for key, val in pairs(unit.reactor_data) do + if key ~= "rps_status" and key ~= "mek_struct" and key ~= "mek_status" then + unit.unit_ps.publish(key, val) + end + end + + if type(unit.reactor_data.rps_status) == "table" then + for key, val in pairs(unit.reactor_data.rps_status) do + unit.unit_ps.publish(key, val) + end + end + + if type(unit.reactor_data.mek_status) == "table" then + for key, val in pairs(unit.reactor_data.mek_status) do + unit.unit_ps.publish(key, val) + end + end + else + log.debug(log_header .. "reactor status length mismatch") + valid = false + end + + -- RTU statuses + local rtu_statuses = status[2] + + if type(rtu_statuses) == "table" then + -- boiler statuses + if type(rtu_statuses.boilers) == "table" then + for id = 1, #unit.boiler_ps_tbl do + if rtu_statuses.boilers[i] == nil then + -- disconnected + unit.boiler_ps_tbl[id].publish("computed_status", 1) + end + end + + for id, boiler in pairs(rtu_statuses.boilers) do + if type(unit.boiler_data_tbl[id]) == "table" then + local rtu_faulted = boiler[1] ---@type boolean + unit.boiler_data_tbl[id].formed = boiler[2] ---@type boolean + unit.boiler_data_tbl[id].state = boiler[3] ---@type table + unit.boiler_data_tbl[id].tanks = boiler[4] ---@type table + + local data = unit.boiler_data_tbl[id] ---@type boilerv_session_db + + unit.boiler_ps_tbl[id].publish("formed", data.formed) + unit.boiler_ps_tbl[id].publish("faulted", rtu_faulted) + + if rtu_faulted then + unit.boiler_ps_tbl[id].publish("computed_status", 3) -- faulted + elseif data.formed then + if data.state.boil_rate > 0 then + unit.boiler_ps_tbl[id].publish("computed_status", 5) -- active + else + unit.boiler_ps_tbl[id].publish("computed_status", 4) -- idle + end + else + unit.boiler_ps_tbl[id].publish("computed_status", 2) -- not formed + end + + for key, val in pairs(unit.boiler_data_tbl[id].state) do + unit.boiler_ps_tbl[id].publish(key, val) + end + + for key, val in pairs(unit.boiler_data_tbl[id].tanks) do + unit.boiler_ps_tbl[id].publish(key, val) + end + else + log.debug(util.c(log_header, "invalid boiler id ", id)) + valid = false + end + end + else + log.debug(log_header .. "boiler list not a table") + valid = false + end + + -- turbine statuses + if type(rtu_statuses.turbines) == "table" then + for id = 1, #unit.turbine_ps_tbl do + if rtu_statuses.turbines[i] == nil then + -- disconnected + unit.turbine_ps_tbl[id].publish("computed_status", 1) + end + end + + for id, turbine in pairs(rtu_statuses.turbines) do + if type(unit.turbine_data_tbl[id]) == "table" then + local rtu_faulted = turbine[1] ---@type boolean + unit.turbine_data_tbl[id].formed = turbine[2] ---@type boolean + unit.turbine_data_tbl[id].state = turbine[3] ---@type table + unit.turbine_data_tbl[id].tanks = turbine[4] ---@type table + + local data = unit.turbine_data_tbl[id] ---@type turbinev_session_db + + unit.turbine_ps_tbl[id].publish("formed", data.formed) + unit.turbine_ps_tbl[id].publish("faulted", rtu_faulted) + + if rtu_faulted then + unit.turbine_ps_tbl[id].publish("computed_status", 3) -- faulted + elseif data.formed then + if data.tanks.energy_fill >= 0.99 then + unit.turbine_ps_tbl[id].publish("computed_status", 6) -- trip + elseif data.state.flow_rate < 100 then + unit.turbine_ps_tbl[id].publish("computed_status", 4) -- idle + else + unit.turbine_ps_tbl[id].publish("computed_status", 5) -- active + end + else + unit.turbine_ps_tbl[id].publish("computed_status", 2) -- not formed + end + + for key, val in pairs(unit.turbine_data_tbl[id].state) do + unit.turbine_ps_tbl[id].publish(key, val) + end + + for key, val in pairs(unit.turbine_data_tbl[id].tanks) do + unit.turbine_ps_tbl[id].publish(key, val) + end + else + log.debug(util.c(log_header, "invalid turbine id ", id)) + valid = false + end + end + else + log.debug(log_header .. "turbine list not a table") + valid = false + end + + -- environment detector status + if type(rtu_statuses.rad_mon) == "table" then + if #rtu_statuses.rad_mon > 0 then + local rad_mon = rtu_statuses.rad_mon[1] + local rtu_faulted = rad_mon[1] ---@type boolean + unit.radiation = rad_mon[2] ---@type number + + unit.unit_ps.publish("radiation", unit.radiation) + else + unit.radiation = types.new_zero_radiation_reading() + end + else + log.debug(log_header .. "radiation monitor list not a table") + valid = false + end + else + log.debug(log_header .. "rtu list not a table") + valid = false + end + + -- annunciator + unit.annunciator = status[3] + + if type(unit.annunciator) ~= "table" then + unit.annunciator = {} + log.debug(log_header .. "annunciator state not a table") + valid = false + end + + for key, val in pairs(unit.annunciator) do + if key == "TurbineTrip" then + -- split up turbine trip table for all turbines and a general OR combination + local trips = val + local any = false + + for id = 1, #trips do + any = any or trips[id] + unit.turbine_ps_tbl[id].publish(key, trips[id]) + end + + unit.unit_ps.publish("TurbineTrip", any) + elseif key == "BoilerOnline" or key == "HeatingRateLow" or key == "WaterLevelLow" then + -- split up array for all boilers + for id = 1, #val do + unit.boiler_ps_tbl[id].publish(key, val[id]) + end + elseif key == "TurbineOnline" or key == "SteamDumpOpen" or key == "TurbineOverSpeed" then + -- split up array for all turbines + for id = 1, #val do + unit.turbine_ps_tbl[id].publish(key, val[id]) + end + elseif type(val) == "table" then + -- we missed one of the tables? + log.debug(log_header .. "unrecognized table found in annunciator list, this is a bug") + valid = false + else + -- non-table fields + unit.unit_ps.publish(key, val) + end + end + + -- alarms + local alarm_states = status[4] + + if type(alarm_states) == "table" then + for id = 1, #alarm_states do + local state = alarm_states[id] + + unit.alarms[id] = state + + if state == types.ALARM_STATE.TRIPPED or state == types.ALARM_STATE.ACKED then + unit.unit_ps.publish("Alarm_" .. id, 2) + elseif state == types.ALARM_STATE.RING_BACK then + unit.unit_ps.publish("Alarm_" .. id, 3) + else + unit.unit_ps.publish("Alarm_" .. id, 1) + end + end + else + log.debug(log_header .. "alarm states not a table") + valid = false + end + + -- unit state fields + local unit_state = status[5] + + if type(unit_state) == "table" then + if #unit_state == 5 then + unit.unit_ps.publish("U_StatusLine1", unit_state[1]) + unit.unit_ps.publish("U_StatusLine2", unit_state[2]) + unit.unit_ps.publish("U_WasteMode", unit_state[3]) + unit.unit_ps.publish("U_AutoReady", unit_state[4]) + unit.unit_ps.publish("U_AutoDegraded", unit_state[5]) + else + log.debug(log_header .. "unit state length mismatch") + valid = false + end + else + log.debug(log_header .. "unit state not a table") + valid = false + end + end + end + + io.facility.ps.publish("burn_sum", burn_rate_sum) + + -- update alarm sounder + sounder.eval(io.units) + end + + return valid +end + +-- get the IO controller database +function iocontrol.get_db() return io end + +return iocontrol diff --git a/coordinator/process.lua b/coordinator/process.lua new file mode 100644 index 0000000..1e318ed --- /dev/null +++ b/coordinator/process.lua @@ -0,0 +1,273 @@ +-- +-- Process Control Management +-- + +local comms = require("scada-common.comms") +local log = require("scada-common.log") +local types = require("scada-common.types") +local util = require("scada-common.util") + +local FAC_COMMAND = comms.FAC_COMMAND +local UNIT_COMMAND = comms.UNIT_COMMAND + +local PROCESS = types.PROCESS + +---@class process_controller +local process = {} + +local self = { + io = nil, ---@type ioctl + comms = nil, ---@type coord_comms + ---@class coord_auto_config + config = { + mode = PROCESS.INACTIVE, + burn_target = 0.0, + charge_target = 0.0, + gen_target = 0.0, + limits = {} + } +} + +-------------------------- +-- UNIT COMMAND CONTROL -- +-------------------------- + +-- initialize the process controller +---@param iocontrol ioctl iocontrl system +---@param coord_comms coord_comms coordinator communications +function process.init(iocontrol, coord_comms) + self.io = iocontrol + self.comms = coord_comms + + for i = 1, self.io.facility.num_units do + self.config.limits[i] = 0.1 + end + + -- load settings + if not settings.load("/coord.settings") then + log.error("process.init(): failed to load coordinator settings file") + end + + local config = settings.get("PROCESS") ---@type coord_auto_config|nil + + if type(config) == "table" then + self.config.mode = config.mode + self.config.burn_target = config.burn_target + self.config.charge_target = config.charge_target + self.config.gen_target = config.gen_target + self.config.limits = config.limits + + self.io.facility.ps.publish("process_mode", self.config.mode) + self.io.facility.ps.publish("process_burn_target", self.config.burn_target) + self.io.facility.ps.publish("process_charge_target", self.config.charge_target) + self.io.facility.ps.publish("process_gen_target", self.config.gen_target) + + for id = 1, math.min(#self.config.limits, self.io.facility.num_units) do + local unit = self.io.units[id] ---@type ioctl_unit + unit.unit_ps.publish("burn_limit", self.config.limits[id]) + end + + log.info("PROCESS: loaded auto control settings from coord.settings") + end + + local waste_mode = settings.get("WASTE_MODES") ---@type table|nil + + if type(waste_mode) == "table" then + for id, mode in pairs(waste_mode) do + self.comms.send_unit_command(UNIT_COMMAND.SET_WASTE, id, mode) + end + + log.info("PROCESS: loaded waste mode settings from coord.settings") + end + + local prio_groups = settings.get("PRIORITY_GROUPS") ---@type table|nil + + if type(prio_groups) == "table" then + for id, group in pairs(prio_groups) do + self.comms.send_unit_command(UNIT_COMMAND.SET_GROUP, id, group) + end + + log.info("PROCESS: loaded priority groups settings from coord.settings") + end +end + +-- facility SCRAM command +function process.fac_scram() + self.comms.send_fac_command(FAC_COMMAND.SCRAM_ALL) + log.debug("PROCESS: FAC SCRAM ALL") +end + +-- facility alarm acknowledge command +function process.fac_ack_alarms() + self.comms.send_fac_command(FAC_COMMAND.ACK_ALL_ALARMS) + log.debug("PROCESS: FAC ACK ALL ALARMS") +end + +-- start reactor +---@param id integer unit ID +function process.start(id) + self.io.units[id].control_state = true + self.comms.send_unit_command(UNIT_COMMAND.START, id) + log.debug(util.c("PROCESS: UNIT[", id, "] START")) +end + +-- SCRAM reactor +---@param id integer unit ID +function process.scram(id) + self.io.units[id].control_state = false + self.comms.send_unit_command(UNIT_COMMAND.SCRAM, id) + log.debug(util.c("PROCESS: UNIT[", id, "] SCRAM")) +end + +-- reset reactor protection system +---@param id integer unit ID +function process.reset_rps(id) + self.comms.send_unit_command(UNIT_COMMAND.RESET_RPS, id) + log.debug(util.c("PROCESS: UNIT[", id, "] RESET RPS")) +end + +-- set burn rate +---@param id integer unit ID +---@param rate number burn rate +function process.set_rate(id, rate) + self.comms.send_unit_command(UNIT_COMMAND.SET_BURN, id, rate) + log.debug(util.c("PROCESS: UNIT[", id, "] SET BURN ", rate)) +end + +-- set waste mode +---@param id integer unit ID +---@param mode integer waste mode +function process.set_waste(id, mode) + -- publish so that if it fails then it gets reset + self.io.units[id].unit_ps.publish("U_WasteMode", mode) + + self.comms.send_unit_command(UNIT_COMMAND.SET_WASTE, id, mode) + log.debug(util.c("PROCESS: UNIT[", id, "] SET WASTE ", mode)) + + local waste_mode = settings.get("WASTE_MODES") ---@type table|nil + + if type(waste_mode) ~= "table" then waste_mode = {} end + + waste_mode[id] = mode + + settings.set("WASTE_MODES", waste_mode) + + if not settings.save("/coord.settings") then + log.error("process.set_waste(): failed to save coordinator settings file") + end +end + +-- acknowledge all alarms +---@param id integer unit ID +function process.ack_all_alarms(id) + self.comms.send_unit_command(UNIT_COMMAND.ACK_ALL_ALARMS, id) + log.debug(util.c("PROCESS: UNIT[", id, "] ACK ALL ALARMS")) +end + +-- acknowledge an alarm +---@param id integer unit ID +---@param alarm integer alarm ID +function process.ack_alarm(id, alarm) + self.comms.send_unit_command(UNIT_COMMAND.ACK_ALARM, id, alarm) + log.debug(util.c("PROCESS: UNIT[", id, "] ACK ALARM ", alarm)) +end + +-- reset an alarm +---@param id integer unit ID +---@param alarm integer alarm ID +function process.reset_alarm(id, alarm) + self.comms.send_unit_command(UNIT_COMMAND.RESET_ALARM, id, alarm) + log.debug(util.c("PROCESS: UNIT[", id, "] RESET ALARM ", alarm)) +end + +-- assign a unit to a group +---@param unit_id integer unit ID +---@param group_id integer|0 group ID or 0 for independent +function process.set_group(unit_id, group_id) + self.comms.send_unit_command(UNIT_COMMAND.SET_GROUP, unit_id, group_id) + log.debug(util.c("PROCESS: UNIT[", unit_id, "] SET GROUP ", group_id)) + + local prio_groups = settings.get("PRIORITY_GROUPS") ---@type table|nil + + if type(prio_groups) ~= "table" then prio_groups = {} end + + prio_groups[unit_id] = group_id + + settings.set("PRIORITY_GROUPS", prio_groups) + + if not settings.save("/coord.settings") then + log.error("process.set_group(): failed to save coordinator settings file") + end +end + +-------------------------- +-- AUTO PROCESS CONTROL -- +-------------------------- + +-- stop automatic process control +function process.stop_auto() + self.comms.send_fac_command(FAC_COMMAND.STOP) + log.debug("PROCESS: STOP AUTO CTL") +end + +-- start automatic process control +function process.start_auto() + self.comms.send_auto_start(self.config) + log.debug("PROCESS: START AUTO CTL") +end + +-- save process control settings +---@param mode PROCESS control mode +---@param burn_target number burn rate target +---@param charge_target number charge target +---@param gen_target number generation rate target +---@param limits table unit burn rate limits +function process.save(mode, burn_target, charge_target, gen_target, limits) + -- attempt to load settings + if not settings.load("/coord.settings") then + log.warning("process.save(): failed to load coordinator settings file") + end + + -- config table + self.config = { + mode = mode, + burn_target = burn_target, + charge_target = charge_target, + gen_target = gen_target, + limits = limits + } + + -- save config + settings.set("PROCESS", self.config) + local saved = settings.save("/coord.settings") + + if not saved then + log.warning("process.save(): failed to save coordinator settings file") + end + + self.io.facility.save_cfg_ack(saved) +end + +-- handle a start command acknowledgement +---@param response table ack and configuration reply +function process.start_ack_handle(response) + local ack = response[1] + + self.config.mode = response[2] + self.config.burn_target = response[3] + self.config.charge_target = response[4] + self.config.gen_target = response[5] + + for i = 1, #response[6] do + self.config.limits[i] = response[6][i] + end + + self.io.facility.ps.publish("auto_mode", self.config.mode) + self.io.facility.ps.publish("burn_target", self.config.burn_target) + self.io.facility.ps.publish("charge_target", self.config.charge_target) + self.io.facility.ps.publish("gen_target", self.config.gen_target) + + self.io.facility.start_ack(ack) +end + +return process diff --git a/coordinator/renderer.lua b/coordinator/renderer.lua new file mode 100644 index 0000000..edf0de8 --- /dev/null +++ b/coordinator/renderer.lua @@ -0,0 +1,188 @@ +-- +-- Graphics Rendering Control +-- + +local log = require("scada-common.log") +local util = require("scada-common.util") + +local style = require("coordinator.ui.style") + +local main_view = require("coordinator.ui.layout.main_view") +local unit_view = require("coordinator.ui.layout.unit_view") + +local flasher = require("graphics.flasher") + +local renderer = {} + +-- render engine +local engine = { + monitors = nil, + dmesg_window = nil, + ui_ready = false +} + +-- UI layouts +local ui = { + main_layout = nil, + unit_layouts = {} +} + +-- init a display to the "default", but set text scale to 0.5 +---@param monitor table monitor +local function _init_display(monitor) + monitor.setTextScale(0.5) + monitor.setTextColor(colors.white) + monitor.setBackgroundColor(colors.black) + monitor.clear() + monitor.setCursorPos(1, 1) + + -- set overridden colors + for i = 1, #style.colors do + monitor.setPaletteColor(style.colors[i].c, style.colors[i].hex) + end +end + +-- link to the monitor peripherals +---@param monitors monitors_struct +function renderer.set_displays(monitors) + engine.monitors = monitors +end + +-- check if the renderer is configured to use a given monitor peripheral +---@nodiscard +---@param periph table peripheral +---@return boolean is_used +function renderer.is_monitor_used(periph) + if engine.monitors ~= nil then + if engine.monitors.primary == periph then + return true + else + for i = 1, #engine.monitors.unit_displays do + if engine.monitors.unit_displays[i] == periph then + return true + end + end + end + end + + return false +end + +-- init all displays in use by the renderer +function renderer.init_displays() + -- init primary monitor + _init_display(engine.monitors.primary) + + -- init unit displays + for _, monitor in pairs(engine.monitors.unit_displays) do + _init_display(monitor) + end +end + +-- check main display width +---@nodiscard +---@return boolean width_okay +function renderer.validate_main_display_width() + local w, _ = engine.monitors.primary.getSize() + return w == 164 +end + +-- check display sizes +---@nodiscard +---@return boolean valid all unit display dimensions OK +function renderer.validate_unit_display_sizes() + local valid = true + + for id, monitor in pairs(engine.monitors.unit_displays) do + local w, h = monitor.getSize() + if w ~= 79 or h ~= 52 then + log.warning(util.c("RENDERER: unit ", id, " display resolution not 79 wide by 52 tall: ", w, ", ", h)) + valid = false + end + end + + return valid +end + +-- initialize the dmesg output window +function renderer.init_dmesg() + local disp_x, disp_y = engine.monitors.primary.getSize() + engine.dmesg_window = window.create(engine.monitors.primary, 1, 1, disp_x, disp_y) + + log.direct_dmesg(engine.dmesg_window) +end + +-- start the coordinator GUI +function renderer.start_ui() + if not engine.ui_ready then + -- hide dmesg + engine.dmesg_window.setVisible(false) + + -- show main view on main monitor + ui.main_layout = main_view(engine.monitors.primary) + + -- show unit views on unit displays + for id, monitor in pairs(engine.monitors.unit_displays) do + table.insert(ui.unit_layouts, unit_view(monitor, id)) + end + + -- start flasher callback task + flasher.run() + + -- report ui as ready + engine.ui_ready = true + end +end + +-- close out the UI +function renderer.close_ui() + -- report ui as not ready + engine.ui_ready = false + + -- stop blinking indicators + flasher.clear() + + if engine.ui_ready then + -- hide to stop animation callbacks + ui.main_layout.hide() + for i = 1, #ui.unit_layouts do + ui.unit_layouts[i].hide() + engine.monitors.unit_displays[i].clear() + end + else + -- clear unit displays + for i = 1, #ui.unit_layouts do + engine.monitors.unit_displays[i].clear() + end + end + + -- clear root UI elements + ui.main_layout = nil + ui.unit_layouts = {} + + -- re-draw dmesg + engine.dmesg_window.setVisible(true) + engine.dmesg_window.redraw() +end + +-- is the UI ready? +---@nodiscard +---@return boolean ready +function renderer.ui_ready() return engine.ui_ready end + +-- handle a touch event +---@param event monitor_touch +function renderer.handle_touch(event) + if event.monitor == engine.monitors.primary_name then + ui.main_layout.handle_touch(event) + else + for id, monitor in pairs(engine.monitors.unit_name_map) do + if event.monitor == monitor then + local layout = ui.unit_layouts[id] ---@type graphics_element + layout.handle_touch(event) + end + end + end +end + +return renderer diff --git a/coordinator/sounder.lua b/coordinator/sounder.lua new file mode 100644 index 0000000..6eafb8d --- /dev/null +++ b/coordinator/sounder.lua @@ -0,0 +1,468 @@ +-- +-- Alarm Sounder +-- + +local log = require("scada-common.log") +local types = require("scada-common.types") +local util = require("scada-common.util") + +local ALARM = types.ALARM +local ALARM_STATE = types.ALARM_STATE + +---@class sounder +local sounder = {} + +local _2_PI = 2 * math.pi -- 2 whole pies, hope you're hungry +local _DRATE = 48000 -- 48kHz audio +local _MAX_VAL = 127 / 2 -- max signed integer in this 8-bit audio +local _MAX_SAMPLES = 0x20000 -- 128 * 1024 samples +local _05s_SAMPLES = 24000 -- half a second worth of samples + +local test_alarms = { false, false, false, false, false, false, false, false, false, false, false, false } + +local alarm_ctl = { + speaker = nil, + volume = 0.5, + playing = false, + num_active = 0, + next_block = 1, + -- split audio up into 0.5s samples so specific components can be ended quicker + quad_buffer = { {}, {}, {}, {} } +} + +-- sounds modeled after https://www.e2s.com/references-and-guidelines/listen-and-download-alarm-tones + +local T_340Hz_Int_2Hz = 1 +local T_544Hz_440Hz_Alt = 2 +local T_660Hz_Int_125ms = 3 +local T_745Hz_Int_1Hz = 4 +local T_800Hz_Int = 5 +local T_800Hz_1000Hz_Alt = 6 +local T_1000Hz_Int = 7 +local T_1800Hz_Int_4Hz = 8 + +local TONES = { + { active = false, component = { {}, {}, {}, {} } }, -- 340Hz @ 2Hz Intermittent + { active = false, component = { {}, {}, {}, {} } }, -- 544Hz 100mS / 440Hz 400mS Alternating + { active = false, component = { {}, {}, {}, {} } }, -- 660Hz @ 125ms On 125ms Off + { active = false, component = { {}, {}, {}, {} } }, -- 745Hz @ 1Hz Intermittent + { active = false, component = { {}, {}, {}, {} } }, -- 800Hz @ 0.25s On 1.75s Off + { active = false, component = { {}, {}, {}, {} } }, -- 800/1000Hz @ 0.25s Alternating + { active = false, component = { {}, {}, {}, {} } }, -- 1KHz 1s on, 1s off Intermittent + { active = false, component = { {}, {}, {}, {} } } -- 1.8KHz @ 4Hz Intermittent +} + +-- calculate how many samples are in the given number of milliseconds +---@nodiscard +---@param ms integer milliseconds +---@return integer samples +local function ms_to_samples(ms) return math.floor(ms * 48) end + +--#region Tone Generation (the Maths) + +-- 340Hz @ 2Hz Intermittent +local function gen_tone_1() + local t, dt = 0, _2_PI * 340 / _DRATE + + for i = 1, _05s_SAMPLES do + local val = math.floor(math.sin(t) * _MAX_VAL) + TONES[1].component[1][i] = val + TONES[1].component[3][i] = val + TONES[1].component[2][i] = 0 + TONES[1].component[4][i] = 0 + t = (t + dt) % _2_PI + end +end + +-- 544Hz 100mS / 440Hz 400mS Alternating +local function gen_tone_2() + local t1, dt1 = 0, _2_PI * 544 / _DRATE + local t2, dt2 = 0, _2_PI * 440 / _DRATE + local alternate_at = ms_to_samples(100) + + for i = 1, _05s_SAMPLES do + local value + + if i <= alternate_at then + value = math.floor(math.sin(t1) * _MAX_VAL) + t1 = (t1 + dt1) % _2_PI + else + value = math.floor(math.sin(t2) * _MAX_VAL) + t2 = (t2 + dt2) % _2_PI + end + + TONES[2].component[1][i] = value + TONES[2].component[2][i] = value + TONES[2].component[3][i] = value + TONES[2].component[4][i] = value + end +end + +-- 660Hz @ 125ms On 125ms Off +local function gen_tone_3() + local elapsed_samples = 0 + local alternate_after = ms_to_samples(125) + local alternate_at = alternate_after + local mode = true + + local t, dt = 0, _2_PI * 660 / _DRATE + + for set = 1, 4 do + for i = 1, _05s_SAMPLES do + if mode then + local val = math.floor(math.sin(t) * _MAX_VAL) + TONES[3].component[set][i] = val + t = (t + dt) % _2_PI + else + t = 0 + TONES[3].component[set][i] = 0 + end + + if elapsed_samples == alternate_at then + mode = not mode + alternate_at = elapsed_samples + alternate_after + end + + elapsed_samples = elapsed_samples + 1 + end + end +end + +-- 745Hz @ 1Hz Intermittent +local function gen_tone_4() + local t, dt = 0, _2_PI * 745 / _DRATE + + for i = 1, _05s_SAMPLES do + local val = math.floor(math.sin(t) * _MAX_VAL) + TONES[4].component[1][i] = val + TONES[4].component[3][i] = val + TONES[4].component[2][i] = 0 + TONES[4].component[4][i] = 0 + t = (t + dt) % _2_PI + end +end + +-- 800Hz @ 0.25s On 1.75s Off +local function gen_tone_5() + local t, dt = 0, _2_PI * 800 / _DRATE + local stop_at = ms_to_samples(250) + + for i = 1, _05s_SAMPLES do + local val = math.floor(math.sin(t) * _MAX_VAL) + + if i > stop_at then + TONES[5].component[1][i] = val + else + TONES[5].component[1][i] = 0 + end + + TONES[5].component[2][i] = 0 + TONES[5].component[3][i] = 0 + TONES[5].component[4][i] = 0 + + t = (t + dt) % _2_PI + end +end + +-- 1000/800Hz @ 0.25s Alternating +local function gen_tone_6() + local t1, dt1 = 0, _2_PI * 1000 / _DRATE + local t2, dt2 = 0, _2_PI * 800 / _DRATE + + local alternate_at = ms_to_samples(250) + + for i = 1, _05s_SAMPLES do + local val + if i <= alternate_at then + val = math.floor(math.sin(t1) * _MAX_VAL) + t1 = (t1 + dt1) % _2_PI + else + val = math.floor(math.sin(t2) * _MAX_VAL) + t2 = (t2 + dt2) % _2_PI + end + + TONES[6].component[1][i] = val + TONES[6].component[2][i] = val + TONES[6].component[3][i] = val + TONES[6].component[4][i] = val + end +end + +-- 1KHz 1s on, 1s off Intermittent +local function gen_tone_7() + local t, dt = 0, _2_PI * 1000 / _DRATE + + for i = 1, _05s_SAMPLES do + local val = math.floor(math.sin(t) * _MAX_VAL) + TONES[7].component[1][i] = val + TONES[7].component[2][i] = val + TONES[7].component[3][i] = 0 + TONES[7].component[4][i] = 0 + t = (t + dt) % _2_PI + end +end + +-- 1800Hz @ 4Hz Intermittent +local function gen_tone_8() + local t, dt = 0, _2_PI * 1800 / _DRATE + + local off_at = ms_to_samples(250) + + for i = 1, _05s_SAMPLES do + local val = 0 + + if i <= off_at then + val = math.floor(math.sin(t) * _MAX_VAL) + t = (t + dt) % _2_PI + end + + TONES[8].component[1][i] = val + TONES[8].component[2][i] = val + TONES[8].component[3][i] = val + TONES[8].component[4][i] = val + end +end + +--#endregion + +-- hard audio limiter +---@nodiscard +---@param output number output level +---@return number limited -128.0 to 127.0 +local function limit(output) + return math.max(-128, math.min(127, output)) +end + +-- zero the alarm audio buffer +local function zero() + for i = 1, 4 do + for s = 1, _05s_SAMPLES do alarm_ctl.quad_buffer[i][s] = 0 end + end +end + +-- add an alarm to the output buffer +---@param alarm_idx integer tone ID +local function add(alarm_idx) + alarm_ctl.num_active = alarm_ctl.num_active + 1 + TONES[alarm_idx].active = true + + for i = 1, 4 do + for s = 1, _05s_SAMPLES do + alarm_ctl.quad_buffer[i][s] = limit(alarm_ctl.quad_buffer[i][s] + TONES[alarm_idx].component[i][s]) + end + end +end + +-- start audio or continue audio on buffer empty +---@return boolean success successfully added buffer to audio output +local function play() + if not alarm_ctl.playing then + alarm_ctl.playing = true + alarm_ctl.next_block = 1 + + return sounder.continue() + else + return true + end +end + +-- initialize the annunciator alarm system +---@param speaker table speaker peripheral +---@param volume number speaker volume +function sounder.init(speaker, volume) + alarm_ctl.speaker = speaker + alarm_ctl.speaker.stop() + + alarm_ctl.volume = volume + alarm_ctl.playing = false + alarm_ctl.num_active = 0 + alarm_ctl.next_block = 1 + + zero() + + -- generate tones + gen_tone_1() + gen_tone_2() + gen_tone_3() + gen_tone_4() + gen_tone_5() + gen_tone_6() + gen_tone_7() + gen_tone_8() +end + +-- reconnect the speaker peripheral +---@param speaker table speaker peripheral +function sounder.reconnect(speaker) + alarm_ctl.speaker = speaker + alarm_ctl.playing = false + alarm_ctl.next_block = 1 + alarm_ctl.num_active = 0 + for id = 1, #TONES do TONES[id].active = false end +end + +-- check alarm state to enable/disable alarms +---@param units table|nil unit list or nil to use test mode +function sounder.eval(units) + local changed = false + local any_active = false + local new_states = { false, false, false, false, false, false, false, false } + local alarms = { false, false, false, false, false, false, false, false, false, false, false, false } + + if units ~= nil then + -- check all alarms for all units + for i = 1, #units do + local unit = units[i] ---@type ioctl_unit + for id = 1, #unit.alarms do + alarms[id] = alarms[id] or (unit.alarms[id] == ALARM_STATE.TRIPPED) + end + end + else + alarms = test_alarms + end + + -- containment breach is worst case CRITICAL alarm, this takes priority + if alarms[ALARM.ContainmentBreach] then + new_states[T_1800Hz_Int_4Hz] = true + else + -- critical damage is highest priority CRITICAL level alarm + if alarms[ALARM.CriticalDamage] then + new_states[T_660Hz_Int_125ms] = true + else + -- EMERGENCY level alarms + URGENT over temp + if alarms[ALARM.ReactorDamage] or alarms[ALARM.ReactorOverTemp] or alarms[ALARM.ReactorWasteLeak] then + new_states[T_544Hz_440Hz_Alt] = true + -- URGENT level turbine trip + elseif alarms[ALARM.TurbineTrip] then + new_states[T_745Hz_Int_1Hz] = true + -- URGENT level reactor lost + elseif alarms[ALARM.ReactorLost] then + new_states[T_340Hz_Int_2Hz] = true + -- TIMELY level alarms + elseif alarms[ALARM.ReactorHighTemp] or alarms[ALARM.ReactorHighWaste] or alarms[ALARM.RCSTransient] then + new_states[T_800Hz_Int] = true + end + end + + -- check RPS transient URGENT level alarm + if alarms[ALARM.RPSTransient] then + new_states[T_1000Hz_Int] = true + -- disable really painful audio combination + new_states[T_340Hz_Int_2Hz] = false + end + end + + -- radiation is a big concern, always play this CRITICAL level alarm if active + if alarms[ALARM.ContainmentRadiation] then + new_states[T_800Hz_1000Hz_Alt] = true + -- we are going to disable the RPS trip alarm audio due to conflict, and if it was enabled + -- then we can re-enable the reactor lost alarm audio since it doesn't painfully combine with this one + if new_states[T_1000Hz_Int] and alarms[ALARM.ReactorLost] then new_states[T_340Hz_Int_2Hz] = true end + -- it sounds *really* bad if this is in conjunction with these other tones, so disable them + new_states[T_745Hz_Int_1Hz] = false + new_states[T_800Hz_Int] = false + new_states[T_1000Hz_Int] = false + end + + -- check if any changed, check if any active, update active flags + for id = 1, #TONES do + if new_states[id] ~= TONES[id].active then + TONES[id].active = new_states[id] + changed = true + end + + if TONES[id].active then any_active = true end + end + + -- zero and re-add tones if changed + if changed then + zero() + + for id = 1, #TONES do + if TONES[id].active then add(id) end + end + end + + if any_active then play() else sounder.stop() end +end + +-- stop all audio and clear output buffer +function sounder.stop() + alarm_ctl.playing = false + alarm_ctl.speaker.stop() + alarm_ctl.next_block = 1 + alarm_ctl.num_active = 0 + for id = 1, #TONES do TONES[id].active = false end + zero() +end + +-- continue audio on buffer empty +---@return boolean success successfully added buffer to audio output +function sounder.continue() + if alarm_ctl.playing then + if alarm_ctl.speaker ~= nil and #alarm_ctl.quad_buffer[alarm_ctl.next_block] > 0 then + local success = alarm_ctl.speaker.playAudio(alarm_ctl.quad_buffer[alarm_ctl.next_block], alarm_ctl.volume) + + alarm_ctl.next_block = alarm_ctl.next_block + 1 + if alarm_ctl.next_block > 4 then alarm_ctl.next_block = 1 end + + if not success then + log.debug("SOUNDER: error playing audio") + end + + return success + else + return false + end + else + return false + end +end + +--#region Test Functions + +function sounder.test_1() add(1) play() end -- play tone T_340Hz_Int_2Hz +function sounder.test_2() add(2) play() end -- play tone T_544Hz_440Hz_Alt +function sounder.test_3() add(3) play() end -- play tone T_660Hz_Int_125ms +function sounder.test_4() add(4) play() end -- play tone T_745Hz_Int_1Hz +function sounder.test_5() add(5) play() end -- play tone T_800Hz_Int +function sounder.test_6() add(6) play() end -- play tone T_800Hz_1000Hz_Alt +function sounder.test_7() add(7) play() end -- play tone T_1000Hz_Int +function sounder.test_8() add(8) play() end -- play tone T_1800Hz_Int_4Hz + +function sounder.test_breach(active) test_alarms[ALARM.ContainmentBreach] = active end ---@param active boolean +function sounder.test_rad(active) test_alarms[ALARM.ContainmentRadiation] = active end ---@param active boolean +function sounder.test_lost(active) test_alarms[ALARM.ReactorLost] = active end ---@param active boolean +function sounder.test_crit(active) test_alarms[ALARM.CriticalDamage] = active end ---@param active boolean +function sounder.test_dmg(active) test_alarms[ALARM.ReactorDamage] = active end ---@param active boolean +function sounder.test_overtemp(active) test_alarms[ALARM.ReactorOverTemp] = active end ---@param active boolean +function sounder.test_hightemp(active) test_alarms[ALARM.ReactorHighTemp] = active end ---@param active boolean +function sounder.test_wasteleak(active) test_alarms[ALARM.ReactorWasteLeak] = active end ---@param active boolean +function sounder.test_highwaste(active) test_alarms[ALARM.ReactorHighWaste] = active end ---@param active boolean +function sounder.test_rps(active) test_alarms[ALARM.RPSTransient] = active end ---@param active boolean +function sounder.test_rcs(active) test_alarms[ALARM.RCSTransient] = active end ---@param active boolean +function sounder.test_turbinet(active) test_alarms[ALARM.TurbineTrip] = active end ---@param active boolean + +-- power rescaling limiter test +function sounder.test_power_scale() + local start = util.time_ms() + + zero() + + for id = 1, #TONES do + if TONES[id].active then + for i = 1, 4 do + for s = 1, _05s_SAMPLES do + alarm_ctl.quad_buffer[i][s] = limit(alarm_ctl.quad_buffer[i][s] + + (TONES[id].component[i][s] / math.sqrt(alarm_ctl.num_active))) + end + end + end + end + + log.debug("SOUNDER: power rescale test took " .. (util.time_ms() - start) .. "ms") +end + +--#endregion + +return sounder diff --git a/coordinator/startup.lua b/coordinator/startup.lua new file mode 100644 index 0000000..39629fd --- /dev/null +++ b/coordinator/startup.lua @@ -0,0 +1,388 @@ +-- +-- Nuclear Generation Facility SCADA Coordinator +-- + +require("/initenv").init_env() + +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 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.2" + +local print = util.print +local println = util.println +local print_ts = util.print_ts +local println_ts = util.println_ts + +local log_graphics = coordinator.log_graphics +local log_sys = coordinator.log_sys +local log_boot = coordinator.log_boot +local log_comms = coordinator.log_comms +local log_comms_connecting = coordinator.log_comms_connecting + +---------------------------------------- +-- config validation +---------------------------------------- + +local cfv = util.new_validator() + +cfv.assert_port(config.SCADA_SV_PORT) +cfv.assert_port(config.SCADA_SV_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_int(config.NUM_UNITS) +cfv.assert_type_num(config.SOUNDER_VOLUME) +cfv.assert_type_bool(config.TIME_24_HOUR) +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 coordinator.startup " .. COORDINATOR_VERSION) +log.info("========================================") +println(">> SCADA Coordinator " .. COORDINATOR_VERSION .. " <<") + +crash.set_env("coordinator", COORDINATOR_VERSION) + +---------------------------------------- +-- main application +---------------------------------------- + +local function main() + ---------------------------------------- + -- system startup + ---------------------------------------- + + -- mount connected devices + ppm.mount_all() + + -- setup monitors + local configured, monitors = coordinator.configure_monitors(config.NUM_UNITS) + if not configured or monitors == nil then + println("startup> monitor setup failed") + log.fatal("monitor configuration failed") + return + end + + -- init renderer + renderer.set_displays(monitors) + renderer.init_displays() + + if not renderer.validate_main_display_width() then + println("startup> main display must be 8 blocks wide") + log.fatal("main display not wide enough") + return + elseif not renderer.validate_unit_display_sizes() then + println("startup> one or more unit display dimensions incorrect; they must be 4x4 blocks") + log.fatal("unit display dimensions incorrect") + return + end + + renderer.init_dmesg() + + -- lets get started! + log.info("monitors ready, dmesg output incoming...") + + log_graphics("displays connected and reset") + log_sys("system start on " .. os.date("%c")) + log_boot("starting " .. COORDINATOR_VERSION) + + ---------------------------------------- + -- setup alarm sounder subsystem + ---------------------------------------- + + local speaker = ppm.get_device("speaker") + if speaker == nil then + log_boot("annunciator alarm speaker not found") + println("startup> speaker not found") + log.fatal("no annunciator alarm speaker found") + return + else + local sounder_start = util.time_ms() + log_boot("annunciator alarm speaker connected") + sounder.init(speaker, config.SOUNDER_VOLUME) + log_boot("tone generation took " .. (util.time_ms() - sounder_start) .. "ms") + log_sys("annunciator alarm configured") + end + + ---------------------------------------- + -- setup communications + ---------------------------------------- + + -- get the communications modem + local modem = ppm.get_wireless_modem() + if modem == nil then + log_comms("wireless modem not found") + println("startup> wireless modem not found") + log.fatal("no wireless modem on startup") + return + else + log_comms("wireless modem connected") + end + + -- create connection watchdog + local conn_watchdog = util.new_watchdog(config.COMMS_TIMEOUT) + conn_watchdog.cancel() + log.debug("startup> conn watchdog created") + + -- start comms, open all channels + local coord_comms = coordinator.comms(COORDINATOR_VERSION, modem, config.SCADA_SV_PORT, config.SCADA_SV_LISTEN, + config.SCADA_API_LISTEN, config.TRUSTED_RANGE, conn_watchdog) + log.debug("startup> comms init") + log_comms("comms initialized") + + -- base loop clock (2Hz, 10 ticks) + local MAIN_CLOCK = 0.5 + local loop_clock = util.new_clock(MAIN_CLOCK) + + ---------------------------------------- + -- connect to the supervisor + ---------------------------------------- + + -- attempt to connect to the supervisor or exit + local function init_connect_sv() + local tick_waiting, task_done = log_comms_connecting("attempting to connect to configured supervisor on channel " .. config.SCADA_SV_PORT) + + -- attempt to establish a connection with the supervisory computer + if not coord_comms.sv_connect(60, tick_waiting, task_done) then + log_sys("supervisor connection failed, shutting down...") + log.fatal("failed to connect to supervisor") + return false + end + + return true + end + + if not init_connect_sv() then + println("startup> failed to connect to supervisor") + log_sys("system shutdown") + return + else + log_sys("supervisor connected, proceeding to UI start") + end + + ---------------------------------------- + -- start the UI + ---------------------------------------- + + -- start up the UI + ---@return boolean ui_ok started ok + local function init_start_ui() + log_graphics("starting UI...") + + local draw_start = util.time_ms() + + local ui_ok, message = pcall(renderer.start_ui) + if not ui_ok then + renderer.close_ui() + log_graphics(util.c("UI crashed: ", message)) + println_ts("UI crashed") + log.fatal(util.c("GUI crashed with error ", message)) + else + log_graphics("first UI draw took " .. (util.time_ms() - draw_start) .. "ms") + + -- start clock + loop_clock.start() + end + + return ui_ok + end + + local ui_ok = init_start_ui() + + ---------------------------------------- + -- main event loop + ---------------------------------------- + + local date_format = util.trinary(config.TIME_24_HOUR, "%X \x04 %A, %B %d %Y", "%r \x04 %A, %B %d %Y") + + local no_modem = false + + if ui_ok then + -- start connection watchdog + conn_watchdog.feed() + log.debug("startup> conn watchdog started") + + log_sys("system started successfully") + end + + -- main event loop + while ui_ok do + local event, param1, param2, param3, param4, param5 = util.pull_event() + + -- handle event + if event == "peripheral_detach" then + local type, device = ppm.handle_unmount(param1) + + if type ~= nil and device ~= nil then + if type == "modem" then + -- we only really care if this is our wireless modem + if device == modem then + no_modem = true + log_sys("comms modem disconnected") + println_ts("wireless modem disconnected!") + + -- close out UI + renderer.close_ui() + + -- alert user to status + log_sys("awaiting comms modem reconnect...") + else + log_sys("non-comms modem disconnected") + end + elseif type == "monitor" then + if renderer.is_monitor_used(device) then + -- "halt and catch fire" style handling + local msg = "lost a configured monitor, system will now exit" + println_ts(msg) + log_sys(msg) + break + else + log_sys("lost unused monitor, ignoring") + end + elseif type == "speaker" then + local msg = "lost alarm sounder speaker" + println_ts(msg) + log_sys(msg) + end + end + elseif event == "peripheral" then + local type, device = ppm.mount(param1) + + if type ~= nil and device ~= nil then + if type == "modem" then + if device.isWireless() then + -- reconnected modem + no_modem = false + modem = device + coord_comms.reconnect_modem(modem) + + log_sys("comms modem reconnected") + println_ts("wireless modem reconnected.") + + -- re-init system + if not init_connect_sv() then break end + ui_ok = init_start_ui() + else + log_sys("wired modem reconnected") + end + elseif type == "monitor" then + -- not supported, system will exit on loss of in-use monitors + elseif type == "speaker" then + local msg = "alarm sounder speaker reconnected" + println_ts(msg) + log_sys(msg) + sounder.reconnect(device) + end + end + elseif event == "timer" then + if loop_clock.is_clock(param1) then + -- main loop tick + + -- free any closed sessions + apisessions.free_all_closed() + + -- update date and time string for main display + iocontrol.get_db().facility.ps.publish("date_time", os.date(date_format)) + + loop_clock.start() + elseif conn_watchdog.is_timer(param1) then + -- supervisor watchdog timeout + local msg = "supervisor server timeout" + log_comms(msg) + println_ts(msg) + + -- close connection, UI, and stop sounder + coord_comms.close() + renderer.close_ui() + sounder.stop() + + if not no_modem then + -- try to re-connect to the supervisor + if not init_connect_sv() then break end + ui_ok = init_start_ui() + end + else + -- a non-clock/main watchdog timer event + + --check API watchdogs + apisessions.check_all_watchdogs(param1) + + -- notify timer callback dispatcher + tcallbackdsp.handle(param1) + end + elseif event == "modem_message" then + -- got a packet + local packet = coord_comms.parse_packet(param1, param2, param3, param4, param5) + coord_comms.handle_packet(packet) + + -- check if it was a disconnect + if not coord_comms.is_linked() then + log_comms("supervisor closed connection") + + -- close connection, UI, and stop sounder + coord_comms.close() + renderer.close_ui() + sounder.stop() + + if not no_modem then + -- try to re-connect to the supervisor + if not init_connect_sv() then break end + ui_ok = init_start_ui() + end + end + elseif event == "monitor_touch" then + -- handle a monitor touch event + renderer.handle_touch(core.events.touch(param1, param2, param3)) + elseif event == "speaker_audio_empty" then + -- handle speaker buffer emptied + sounder.continue() + end + + -- check for termination request + if event == "terminate" or ppm.should_terminate() then + println_ts("terminate requested, closing connections...") + log_comms("terminate requested, closing supervisor connection...") + coord_comms.close() + log_comms("supervisor connection closed") + log_comms("closing api sessions...") + apisessions.close_all() + log_comms("api sessions closed") + break + end + end + + renderer.close_ui() + sounder.stop() + log_sys("system shutdown") + + println_ts("exited") + log.info("exited") +end + +if not xpcall(main, crash.handler) then + pcall(renderer.close_ui) + pcall(sounder.stop) + crash.exit() +end diff --git a/coordinator/ui/components/boiler.lua b/coordinator/ui/components/boiler.lua new file mode 100644 index 0000000..c4a433b --- /dev/null +++ b/coordinator/ui/components/boiler.lua @@ -0,0 +1,50 @@ +local style = require("coordinator.ui.style") + +local core = require("graphics.core") + +local Rectangle = require("graphics.elements.rectangle") +local TextBox = require("graphics.elements.textbox") + +local DataIndicator = require("graphics.elements.indicators.data") +local StateIndicator = require("graphics.elements.indicators.state") +local VerticalBar = require("graphics.elements.indicators.vbar") + +local cpair = core.graphics.cpair +local border = core.graphics.border + +-- new boiler view +---@param root graphics_element parent +---@param x integer top left x +---@param y integer top left y +---@param ps psil ps interface +local function new_view(root, x, y, ps) + local boiler = Rectangle{parent=root,border=border(1, colors.gray, true),width=31,height=7,x=x,y=y} + + local text_fg_bg = cpair(colors.black, colors.lightGray) + local lu_col = cpair(colors.gray, colors.gray) + + local status = StateIndicator{parent=boiler,x=9,y=1,states=style.boiler.states,value=1,min_width=12} + local temp = DataIndicator{parent=boiler,x=5,y=3,lu_colors=lu_col,label="Temp:",unit="K",format="%10.2f",value=0,width=22,fg_bg=text_fg_bg} + local boil_r = DataIndicator{parent=boiler,x=5,y=4,lu_colors=lu_col,label="Boil:",unit="mB/t",format="%10.0f",value=0,commas=true,width=22,fg_bg=text_fg_bg} + + ps.subscribe("computed_status", status.update) + ps.subscribe("temperature", temp.update) + ps.subscribe("boil_rate", boil_r.update) + + TextBox{parent=boiler,text="H",x=2,y=5,height=1,width=1,fg_bg=text_fg_bg} + TextBox{parent=boiler,text="W",x=3,y=5,height=1,width=1,fg_bg=text_fg_bg} + TextBox{parent=boiler,text="S",x=27,y=5,height=1,width=1,fg_bg=text_fg_bg} + TextBox{parent=boiler,text="C",x=28,y=5,height=1,width=1,fg_bg=text_fg_bg} + + local hcool = VerticalBar{parent=boiler,x=2,y=1,fg_bg=cpair(colors.orange,colors.gray),height=4,width=1} + local water = VerticalBar{parent=boiler,x=3,y=1,fg_bg=cpair(colors.blue,colors.gray),height=4,width=1} + local steam = VerticalBar{parent=boiler,x=27,y=1,fg_bg=cpair(colors.white,colors.gray),height=4,width=1} + local ccool = VerticalBar{parent=boiler,x=28,y=1,fg_bg=cpair(colors.lightBlue,colors.gray),height=4,width=1} + + ps.subscribe("hcool_fill", hcool.update) + ps.subscribe("water_fill", water.update) + ps.subscribe("steam_fill", steam.update) + ps.subscribe("ccool_fill", ccool.update) +end + +return new_view diff --git a/coordinator/ui/components/imatrix.lua b/coordinator/ui/components/imatrix.lua new file mode 100644 index 0000000..2910fbb --- /dev/null +++ b/coordinator/ui/components/imatrix.lua @@ -0,0 +1,96 @@ +local util = require("scada-common.util") + +local style = require("coordinator.ui.style") + +local core = require("graphics.core") + +local Div = require("graphics.elements.div") +local Rectangle = require("graphics.elements.rectangle") +local TextBox = require("graphics.elements.textbox") + +local DataIndicator = require("graphics.elements.indicators.data") +local PowerIndicator = require("graphics.elements.indicators.power") +local StateIndicator = require("graphics.elements.indicators.state") +local VerticalBar = require("graphics.elements.indicators.vbar") + +local cpair = core.graphics.cpair +local border = core.graphics.border + +local TEXT_ALIGN = core.graphics.TEXT_ALIGN + +-- new induction matrix view +---@param root graphics_element parent +---@param x integer top left x +---@param y integer top left y +---@param data imatrix_session_db matrix data +---@param ps psil ps interface +---@param id number? matrix ID +local function new_view(root, x, y, data, ps, id) + local title = "INDUCTION MATRIX" + if type(id) == "number" then title = title .. id end + + local matrix = Div{parent=root,fg_bg=style.root,width=33,height=24,x=x,y=y} + + TextBox{parent=matrix,text=" ",width=33,height=1,x=1,y=1,fg_bg=cpair(colors.lightGray,colors.gray)} + TextBox{parent=matrix,text=title,alignment=TEXT_ALIGN.CENTER,width=33,height=1,x=1,y=2,fg_bg=cpair(colors.lightGray,colors.gray)} + + local rect = Rectangle{parent=matrix,border=border(1,colors.gray,true),width=33,height=22,x=1,y=3} + + local text_fg_bg = cpair(colors.black, colors.lightGray) + local label_fg_bg = cpair(colors.gray, colors.lightGray) + local lu_col = cpair(colors.gray, colors.gray) + + local status = StateIndicator{parent=rect,x=10,y=1,states=style.imatrix.states,value=1,min_width=14} + local energy = PowerIndicator{parent=rect,x=7,y=3,lu_colors=lu_col,label="Energy: ",format="%8.2f",value=0,width=26,fg_bg=text_fg_bg} + local capacity = PowerIndicator{parent=rect,x=7,y=4,lu_colors=lu_col,label="Capacity:",format="%8.2f",value=0,width=26,fg_bg=text_fg_bg} + local input = PowerIndicator{parent=rect,x=7,y=5,lu_colors=lu_col,label="Input: ",format="%8.2f",rate=true,value=0,width=26,fg_bg=text_fg_bg} + local output = PowerIndicator{parent=rect,x=7,y=6,lu_colors=lu_col,label="Output: ",format="%8.2f",rate=true,value=0,width=26,fg_bg=text_fg_bg} + + local avg_chg = PowerIndicator{parent=rect,x=7,y=8,lu_colors=lu_col,label="Avg. Chg:",format="%8.2f",value=0,width=26,fg_bg=text_fg_bg} + local avg_in = PowerIndicator{parent=rect,x=7,y=9,lu_colors=lu_col,label="Avg. In: ",format="%8.2f",rate=true,value=0,width=26,fg_bg=text_fg_bg} + local avg_out = PowerIndicator{parent=rect,x=7,y=10,lu_colors=lu_col,label="Avg. Out:",format="%8.2f",rate=true,value=0,width=26,fg_bg=text_fg_bg} + + ps.subscribe("computed_status", status.update) + ps.subscribe("energy", function (val) energy.update(util.joules_to_fe(val)) end) + ps.subscribe("max_energy", function (val) capacity.update(util.joules_to_fe(val)) end) + ps.subscribe("last_input", function (val) input.update(util.joules_to_fe(val)) end) + ps.subscribe("last_output", function (val) output.update(util.joules_to_fe(val)) end) + + ps.subscribe("avg_charge", avg_chg.update) + ps.subscribe("avg_inflow", avg_in.update) + ps.subscribe("avg_outflow", avg_out.update) + + local fill = DataIndicator{parent=rect,x=11,y=12,lu_colors=lu_col,label="Fill:",unit="%",format="%8.2f",value=0,width=18,fg_bg=text_fg_bg} + + local cells = DataIndicator{parent=rect,x=11,y=14,lu_colors=lu_col,label="Cells: ",format="%7d",value=0,width=18,fg_bg=text_fg_bg} + local providers = DataIndicator{parent=rect,x=11,y=15,lu_colors=lu_col,label="Providers:",format="%7d",value=0,width=18,fg_bg=text_fg_bg} + + TextBox{parent=rect,text="Transfer Capacity",x=11,y=17,height=1,width=17,fg_bg=label_fg_bg} + local trans_cap = PowerIndicator{parent=rect,x=19,y=18,lu_colors=lu_col,label="",format="%5.2f",rate=true,value=0,width=12,fg_bg=text_fg_bg} + + ps.subscribe("cells", cells.update) + ps.subscribe("providers", providers.update) + ps.subscribe("energy_fill", function (val) fill.update(val * 100) end) + ps.subscribe("transfer_cap", function (val) trans_cap.update(util.joules_to_fe(val)) end) + + local charge = VerticalBar{parent=rect,x=2,y=2,fg_bg=cpair(colors.green,colors.gray),height=17,width=4} + local in_cap = VerticalBar{parent=rect,x=7,y=12,fg_bg=cpair(colors.red,colors.gray),height=7,width=1} + local out_cap = VerticalBar{parent=rect,x=9,y=12,fg_bg=cpair(colors.blue,colors.gray),height=7,width=1} + + TextBox{parent=rect,text="FILL",x=2,y=20,height=1,width=4,fg_bg=text_fg_bg} + TextBox{parent=rect,text="I/O",x=7,y=20,height=1,width=3,fg_bg=text_fg_bg} + + local function calc_saturation(val) + if (type(data.build) == "table") and (type(data.build.transfer_cap) == "number") and (data.build.transfer_cap > 0) then + return val / data.build.transfer_cap + else + return 0 + end + end + + ps.subscribe("energy_fill", charge.update) + ps.subscribe("last_input", function (val) in_cap.update(calc_saturation(val)) end) + ps.subscribe("last_output", function (val) out_cap.update(calc_saturation(val)) end) +end + +return new_view diff --git a/coordinator/ui/components/processctl.lua b/coordinator/ui/components/processctl.lua new file mode 100644 index 0000000..5b8d8ae --- /dev/null +++ b/coordinator/ui/components/processctl.lua @@ -0,0 +1,267 @@ +local tcd = require("scada-common.tcallbackdsp") +local util = require("scada-common.util") + +local iocontrol = require("coordinator.iocontrol") +local process = require("coordinator.process") + +local style = require("coordinator.ui.style") + +local core = require("graphics.core") + +local Div = require("graphics.elements.div") +local Rectangle = require("graphics.elements.rectangle") +local TextBox = require("graphics.elements.textbox") + +local DataIndicator = require("graphics.elements.indicators.data") +local IndicatorLight = require("graphics.elements.indicators.light") +local RadIndicator = require("graphics.elements.indicators.rad") +local TriIndicatorLight = require("graphics.elements.indicators.trilight") + +local HazardButton = require("graphics.elements.controls.hazard_button") +local RadioButton = require("graphics.elements.controls.radio_button") +local SpinboxNumeric = require("graphics.elements.controls.spinbox_numeric") + +local TEXT_ALIGN = core.graphics.TEXT_ALIGN + +local cpair = core.graphics.cpair +local border = core.graphics.border + +local period = core.flasher.PERIOD + +-- new process control view +---@param root graphics_element parent +---@param x integer top left x +---@param y integer top left y +local function new_view(root, x, y) + assert(root.height() >= (y + 24), "main display not of sufficient vertical resolution (add an additional row of monitors)") + + local facility = iocontrol.get_db().facility + local units = iocontrol.get_db().units + + local bw_fg_bg = cpair(colors.black, colors.white) + local hzd_fg_bg = cpair(colors.white, colors.gray) + local lu_cpair = cpair(colors.gray, colors.gray) + local dis_colors = cpair(colors.white, colors.lightGray) + + local main = Div{parent=root,width=104,height=24,x=x,y=y} + + local scram = HazardButton{parent=main,x=1,y=1,text="FAC SCRAM",accent=colors.yellow,dis_colors=dis_colors,callback=process.fac_scram,fg_bg=hzd_fg_bg} + local ack_a = HazardButton{parent=main,x=16,y=1,text="ACK \x13",accent=colors.orange,dis_colors=dis_colors,callback=process.fac_ack_alarms,fg_bg=hzd_fg_bg} + + facility.scram_ack = scram.on_response + facility.ack_alarms_ack = ack_a.on_response + + local all_ok = IndicatorLight{parent=main,y=5,label="Unit Systems Online",colors=cpair(colors.green,colors.red)} + local ind_mat = IndicatorLight{parent=main,label="Induction Matrix",colors=cpair(colors.green,colors.gray)} + local rad_mon = TriIndicatorLight{parent=main,label="Radiation Monitor",c1=colors.gray,c2=colors.yellow,c3=colors.green} + + facility.ps.subscribe("all_sys_ok", all_ok.update) + facility.induction_ps_tbl[1].subscribe("computed_status", function (status) ind_mat.update(status > 1) end) + facility.ps.subscribe("rad_computed_status", rad_mon.update) + + main.line_break() + + local auto_ready = IndicatorLight{parent=main,label="Configured Units Ready",colors=cpair(colors.green,colors.red)} + local auto_act = IndicatorLight{parent=main,label="Process Active",colors=cpair(colors.green,colors.gray)} + local auto_ramp = IndicatorLight{parent=main,label="Process Ramping",colors=cpair(colors.white,colors.gray),flash=true,period=period.BLINK_250_MS} + local auto_sat = IndicatorLight{parent=main,label="Min/Max Burn Rate",colors=cpair(colors.yellow,colors.gray)} + + facility.ps.subscribe("auto_ready", auto_ready.update) + facility.ps.subscribe("auto_active", auto_act.update) + facility.ps.subscribe("auto_ramping", auto_ramp.update) + facility.ps.subscribe("auto_saturated", auto_sat.update) + + main.line_break() + + local auto_scram = IndicatorLight{parent=main,label="Automatic SCRAM",colors=cpair(colors.red,colors.gray),flash=true,period=period.BLINK_250_MS} + local matrix_dc = IndicatorLight{parent=main,label="Matrix Disconnected",colors=cpair(colors.yellow,colors.gray),flash=true,period=period.BLINK_500_MS} + local matrix_fill = IndicatorLight{parent=main,label="Matrix Charge High",colors=cpair(colors.red,colors.gray),flash=true,period=period.BLINK_500_MS} + local unit_crit = IndicatorLight{parent=main,label="Unit Critical Alarm",colors=cpair(colors.red,colors.gray),flash=true,period=period.BLINK_250_MS} + local fac_rad_h = IndicatorLight{parent=main,label="Facility Radiation High",colors=cpair(colors.red,colors.gray),flash=true,period=period.BLINK_250_MS} + local gen_fault = IndicatorLight{parent=main,label="Gen. Control Fault",colors=cpair(colors.yellow,colors.gray),flash=true,period=period.BLINK_500_MS} + + facility.ps.subscribe("auto_scram", auto_scram.update) + facility.ps.subscribe("as_matrix_dc", matrix_dc.update) + facility.ps.subscribe("as_matrix_fill", matrix_fill.update) + facility.ps.subscribe("as_crit_alarm", unit_crit.update) + facility.ps.subscribe("as_radiation", fac_rad_h.update) + facility.ps.subscribe("as_gen_fault", gen_fault.update) + + TextBox{parent=main,y=23,text="Radiation",height=1,width=13,fg_bg=style.label} + local radiation = RadIndicator{parent=main,label="",format="%9.3f",lu_colors=lu_cpair,width=13,fg_bg=bw_fg_bg} + facility.ps.subscribe("radiation", radiation.update) + + TextBox{parent=main,x=15,y=23,text="Linked RTUs",height=1,width=11,fg_bg=style.label} + local rtu_count = DataIndicator{parent=main,x=15,y=24,label="",format="%11d",value=0,lu_colors=lu_cpair,width=11,fg_bg=bw_fg_bg} + facility.ps.subscribe("rtu_count", rtu_count.update) + + --------------------- + -- process control -- + --------------------- + + local proc = Div{parent=main,width=78,height=24,x=27,y=1} + + ----------------------------- + -- process control targets -- + ----------------------------- + + local targets = Div{parent=proc,width=31,height=24,x=1,y=1} + + local burn_tag = Div{parent=targets,x=1,y=1,width=8,height=4,fg_bg=cpair(colors.black,colors.purple)} + TextBox{parent=burn_tag,x=2,y=2,text="Burn Target",width=7,height=2} + + local burn_target = Div{parent=targets,x=9,y=1,width=23,height=3,fg_bg=cpair(colors.gray,colors.white)} + local b_target = SpinboxNumeric{parent=burn_target,x=11,y=1,whole_num_precision=4,fractional_precision=1,min=0.1,arrow_fg_bg=cpair(colors.gray,colors.white),fg_bg=bw_fg_bg} + TextBox{parent=burn_target,x=18,y=2,text="mB/t"} + local burn_sum = DataIndicator{parent=targets,x=9,y=4,label="",format="%18.1f",value=0,unit="mB/t",commas=true,lu_colors=cpair(colors.black,colors.black),width=23,fg_bg=cpair(colors.black,colors.brown)} + + facility.ps.subscribe("process_burn_target", b_target.set_value) + facility.ps.subscribe("burn_sum", burn_sum.update) + + local chg_tag = Div{parent=targets,x=1,y=6,width=8,height=4,fg_bg=cpair(colors.black,colors.purple)} + TextBox{parent=chg_tag,x=2,y=2,text="Charge Target",width=7,height=2} + + local chg_target = Div{parent=targets,x=9,y=6,width=23,height=3,fg_bg=cpair(colors.gray,colors.white)} + local c_target = SpinboxNumeric{parent=chg_target,x=2,y=1,whole_num_precision=15,fractional_precision=0,min=0,arrow_fg_bg=cpair(colors.gray,colors.white),fg_bg=bw_fg_bg} + TextBox{parent=chg_target,x=18,y=2,text="MFE"} + local cur_charge = DataIndicator{parent=targets,x=9,y=9,label="",format="%19d",value=0,unit="MFE",commas=true,lu_colors=cpair(colors.black,colors.black),width=23,fg_bg=cpair(colors.black,colors.brown)} + + facility.ps.subscribe("process_charge_target", c_target.set_value) + facility.induction_ps_tbl[1].subscribe("energy", function (j) cur_charge.update(util.joules_to_fe(j) / 1000000) end) + + local gen_tag = Div{parent=targets,x=1,y=11,width=8,height=4,fg_bg=cpair(colors.black,colors.purple)} + TextBox{parent=gen_tag,x=2,y=2,text="Gen. Target",width=7,height=2} + + local gen_target = Div{parent=targets,x=9,y=11,width=23,height=3,fg_bg=cpair(colors.gray,colors.white)} + local g_target = SpinboxNumeric{parent=gen_target,x=8,y=1,whole_num_precision=9,fractional_precision=0,min=0,arrow_fg_bg=cpair(colors.gray,colors.white),fg_bg=bw_fg_bg} + TextBox{parent=gen_target,x=18,y=2,text="kFE/t"} + local cur_gen = DataIndicator{parent=targets,x=9,y=14,label="",format="%17d",value=0,unit="kFE/t",commas=true,lu_colors=cpair(colors.black,colors.black),width=23,fg_bg=cpair(colors.black,colors.brown)} + + facility.ps.subscribe("process_gen_target", g_target.set_value) + facility.induction_ps_tbl[1].subscribe("last_input", function (j) cur_gen.update(util.round(util.joules_to_fe(j) / 1000)) end) + + ----------------- + -- unit limits -- + ----------------- + + local limit_div = Div{parent=proc,width=21,height=19,x=34,y=6} + + local rate_limits = {} + + for i = 1, facility.num_units do + local unit = units[i] ---@type ioctl_unit + + local _y = ((i - 1) * 5) + 1 + + local unit_tag = Div{parent=limit_div,x=1,y=_y,width=8,height=4,fg_bg=cpair(colors.black,colors.lightBlue)} + TextBox{parent=unit_tag,x=2,y=2,text="Unit "..i.." Limit",width=7,height=2} + + local lim_ctl = Div{parent=limit_div,x=9,y=_y,width=14,height=3,fg_bg=cpair(colors.gray,colors.white)} + rate_limits[i] = SpinboxNumeric{parent=lim_ctl,x=2,y=1,whole_num_precision=4,fractional_precision=1,min=0.1,arrow_fg_bg=cpair(colors.gray,colors.white),fg_bg=bw_fg_bg} + TextBox{parent=lim_ctl,x=9,y=2,text="mB/t",width=4,height=1} + + unit.unit_ps.subscribe("max_burn", rate_limits[i].set_max) + unit.unit_ps.subscribe("burn_limit", rate_limits[i].set_value) + + local cur_burn = DataIndicator{parent=limit_div,x=9,y=_y+3,label="",format="%7.1f",value=0,unit="mB/t",commas=false,lu_colors=cpair(colors.black,colors.black),width=14,fg_bg=cpair(colors.black,colors.brown)} + + unit.unit_ps.subscribe("act_burn_rate", cur_burn.update) + end + + ------------------- + -- unit statuses -- + ------------------- + + local stat_div = Div{parent=proc,width=38,height=19,x=57,y=6} + + for i = 1, facility.num_units do + local unit = units[i] ---@type ioctl_unit + + local _y = ((i - 1) * 5) + 1 + + local unit_tag = Div{parent=stat_div,x=1,y=_y,width=8,height=4,fg_bg=cpair(colors.black,colors.lightBlue)} + TextBox{parent=unit_tag,x=2,y=2,text="Unit "..i.." Status",width=7,height=2} + + local lights = Div{parent=stat_div,x=9,y=_y,width=12,height=4,fg_bg=bw_fg_bg} + local ready = IndicatorLight{parent=lights,x=2,y=2,label="Ready",colors=cpair(colors.green,colors.gray)} + local degraded = IndicatorLight{parent=lights,x=2,y=3,label="Degraded",colors=cpair(colors.red,colors.gray),flash=true,period=period.BLINK_250_MS} + + unit.unit_ps.subscribe("U_AutoReady", ready.update) + unit.unit_ps.subscribe("U_AutoDegraded", degraded.update) + end + + ------------------------- + -- controls and status -- + ------------------------- + + local ctl_opts = { "Monitored Max Burn", "Combined Burn Rate", "Charge Level", "Generation Rate" } + local mode = RadioButton{parent=proc,x=34,y=1,options=ctl_opts,callback=function()end,radio_colors=cpair(colors.purple,colors.black),radio_bg=colors.gray} + + facility.ps.subscribe("process_mode", mode.set_value) + + local u_stat = Rectangle{parent=proc,border=border(1,colors.gray,true),thin=true,width=31,height=4,x=1,y=16,fg_bg=bw_fg_bg} + local stat_line_1 = TextBox{parent=u_stat,x=1,y=1,text="UNKNOWN",width=31,height=1,alignment=TEXT_ALIGN.CENTER,fg_bg=bw_fg_bg} + local stat_line_2 = TextBox{parent=u_stat,x=1,y=2,text="awaiting data...",width=31,height=1,alignment=TEXT_ALIGN.CENTER,fg_bg=cpair(colors.gray, colors.white)} + + facility.ps.subscribe("status_line_1", stat_line_1.set_value) + facility.ps.subscribe("status_line_2", stat_line_2.set_value) + + local auto_controls = Div{parent=proc,x=1,y=20,width=31,height=5,fg_bg=cpair(colors.gray,colors.white)} + + -- save the automatic process control configuration without starting + local function _save_cfg() + local limits = {} + for i = 1, #rate_limits do limits[i] = rate_limits[i].get_value() end + + process.save(mode.get_value(), b_target.get_value(), c_target.get_value(), g_target.get_value(), limits) + end + + -- start automatic control after saving process control settings + local function _start_auto() + _save_cfg() + process.start_auto() + end + + local save = HazardButton{parent=auto_controls,x=2,y=2,text="SAVE",accent=colors.purple,dis_colors=dis_colors,callback=_save_cfg,fg_bg=hzd_fg_bg} + local start = HazardButton{parent=auto_controls,x=13,y=2,text="START",accent=colors.lightBlue,dis_colors=dis_colors,callback=_start_auto,fg_bg=hzd_fg_bg} + local stop = HazardButton{parent=auto_controls,x=23,y=2,text="STOP",accent=colors.red,dis_colors=dis_colors,callback=process.stop_auto,fg_bg=hzd_fg_bg} + + facility.start_ack = start.on_response + facility.stop_ack = stop.on_response + + function facility.save_cfg_ack(ack) + tcd.dispatch(0.2, function () save.on_response(ack) end) + end + + facility.ps.subscribe("auto_ready", function (ready) + if ready and (not facility.auto_active) then start.enable() else start.disable() end + end) + + facility.ps.subscribe("auto_active", function (active) + if active then + b_target.disable() + c_target.disable() + g_target.disable() + + mode.disable() + start.disable() + + for i = 1, #rate_limits do + rate_limits[i].disable() + end + else + b_target.enable() + c_target.enable() + g_target.enable() + + mode.enable() + if facility.auto_ready then start.enable() end + + for i = 1, #rate_limits do + rate_limits[i].enable() + end + end + end) +end + +return new_view diff --git a/coordinator/ui/components/reactor.lua b/coordinator/ui/components/reactor.lua new file mode 100644 index 0000000..a17fc75 --- /dev/null +++ b/coordinator/ui/components/reactor.lua @@ -0,0 +1,73 @@ +local types = require("scada-common.types") + +local style = require("coordinator.ui.style") + +local core = require("graphics.core") + +local Rectangle = require("graphics.elements.rectangle") +local TextBox = require("graphics.elements.textbox") + +local DataIndicator = require("graphics.elements.indicators.data") +local HorizontalBar = require("graphics.elements.indicators.hbar") +local StateIndicator = require("graphics.elements.indicators.state") + +local cpair = core.graphics.cpair +local border = core.graphics.border + +-- create new reactor view +---@param root graphics_element parent +---@param x integer top left x +---@param y integer top left y +---@param data reactor_db reactor data +---@param ps psil ps interface +local function new_view(root, x, y, data, ps) + local reactor = Rectangle{parent=root,border=border(1, colors.gray, true),width=30,height=7,x=x,y=y} + + local text_fg_bg = cpair(colors.black, colors.lightGray) + local lu_col = cpair(colors.gray, colors.gray) + + local status = StateIndicator{parent=reactor,x=6,y=1,states=style.reactor.states,value=1,min_width=16} + local core_temp = DataIndicator{parent=reactor,x=2,y=3,lu_colors=lu_col,label="Core Temp:",unit="K",format="%10.2f",value=0,width=26,fg_bg=text_fg_bg} + local burn_r = DataIndicator{parent=reactor,x=2,y=4,lu_colors=lu_col,label="Burn Rate:",unit="mB/t",format="%10.2f",value=0,width=26,fg_bg=text_fg_bg} + local heating_r = DataIndicator{parent=reactor,x=2,y=5,lu_colors=lu_col,label="Heating:",unit="mB/t",format="%12.0f",value=0,commas=true,width=26,fg_bg=text_fg_bg} + + ps.subscribe("computed_status", status.update) + ps.subscribe("temp", core_temp.update) + ps.subscribe("act_burn_rate", burn_r.update) + ps.subscribe("heating_rate", heating_r.update) + + local reactor_fills = Rectangle{parent=root,border=border(1, colors.gray, true),width=24,height=7,x=(x + 29),y=y} + + TextBox{parent=reactor_fills,text="FUEL",x=2,y=1,height=1,fg_bg=text_fg_bg} + TextBox{parent=reactor_fills,text="COOL",x=2,y=2,height=1,fg_bg=text_fg_bg} + TextBox{parent=reactor_fills,text="HCOOL",x=2,y=4,height=1,fg_bg=text_fg_bg} + TextBox{parent=reactor_fills,text="WASTE",x=2,y=5,height=1,fg_bg=text_fg_bg} + + local fuel = HorizontalBar{parent=reactor_fills,x=8,y=1,show_percent=true,bar_fg_bg=cpair(colors.black,colors.gray),height=1,width=14} + local ccool = HorizontalBar{parent=reactor_fills,x=8,y=2,show_percent=true,bar_fg_bg=cpair(colors.blue,colors.gray),height=1,width=14} + local hcool = HorizontalBar{parent=reactor_fills,x=8,y=4,show_percent=true,bar_fg_bg=cpair(colors.white,colors.gray),height=1,width=14} + local waste = HorizontalBar{parent=reactor_fills,x=8,y=5,show_percent=true,bar_fg_bg=cpair(colors.brown,colors.gray),height=1,width=14} + + ps.subscribe("ccool_type", function (type) + if type == types.FLUID.SODIUM then + ccool.recolor(cpair(colors.lightBlue, colors.gray)) + else + ccool.recolor(cpair(colors.blue, colors.gray)) + end + end) + + ps.subscribe("hcool_type", function (type) + if type == types.FLUID.SUPERHEATED_SODIUM then + hcool.recolor(cpair(colors.orange, colors.gray)) + else + hcool.recolor(cpair(colors.white, colors.gray)) + end + end) + + ps.subscribe("fuel_fill", fuel.update) + ps.subscribe("ccool_fill", ccool.update) + ps.subscribe("hcool_fill", hcool.update) + ps.subscribe("waste_fill", waste.update) +end + +return new_view diff --git a/coordinator/ui/components/turbine.lua b/coordinator/ui/components/turbine.lua new file mode 100644 index 0000000..e4d6967 --- /dev/null +++ b/coordinator/ui/components/turbine.lua @@ -0,0 +1,47 @@ +local util = require("scada-common.util") + +local style = require("coordinator.ui.style") + +local core = require("graphics.core") + +local Rectangle = require("graphics.elements.rectangle") +local TextBox = require("graphics.elements.textbox") + +local DataIndicator = require("graphics.elements.indicators.data") +local PowerIndicator = require("graphics.elements.indicators.power") +local StateIndicator = require("graphics.elements.indicators.state") +local VerticalBar = require("graphics.elements.indicators.vbar") + +local cpair = core.graphics.cpair +local border = core.graphics.border + +-- new turbine view +---@param root graphics_element parent +---@param x integer top left x +---@param y integer top left y +---@param ps psil ps interface +local function new_view(root, x, y, ps) + local turbine = Rectangle{parent=root,border=border(1, colors.gray, true),width=23,height=7,x=x,y=y} + + local text_fg_bg = cpair(colors.black, colors.lightGray) + local lu_col = cpair(colors.gray, colors.gray) + + local status = StateIndicator{parent=turbine,x=7,y=1,states=style.turbine.states,value=1,min_width=12} + local prod_rate = PowerIndicator{parent=turbine,x=5,y=3,lu_colors=lu_col,label="",format="%10.2f",value=0,rate=true,width=16,fg_bg=text_fg_bg} + local flow_rate = DataIndicator{parent=turbine,x=5,y=4,lu_colors=lu_col,label="",unit="mB/t",format="%10.0f",value=0,commas=true,width=16,fg_bg=text_fg_bg} + + ps.subscribe("computed_status", status.update) + ps.subscribe("prod_rate", function (val) prod_rate.update(util.joules_to_fe(val)) end) + ps.subscribe("flow_rate", flow_rate.update) + + local steam = VerticalBar{parent=turbine,x=2,y=1,fg_bg=cpair(colors.white,colors.gray),height=4,width=1} + local energy = VerticalBar{parent=turbine,x=3,y=1,fg_bg=cpair(colors.green,colors.gray),height=4,width=1} + + TextBox{parent=turbine,text="S",x=2,y=5,height=1,width=1,fg_bg=text_fg_bg} + TextBox{parent=turbine,text="E",x=3,y=5,height=1,width=1,fg_bg=text_fg_bg} + + ps.subscribe("steam_fill", steam.update) + ps.subscribe("energy_fill", energy.update) +end + +return new_view diff --git a/coordinator/ui/components/unit_detail.lua b/coordinator/ui/components/unit_detail.lua new file mode 100644 index 0000000..b6fc8af --- /dev/null +++ b/coordinator/ui/components/unit_detail.lua @@ -0,0 +1,520 @@ +-- +-- Reactor Unit SCADA Coordinator GUI +-- + +local iocontrol = require("coordinator.iocontrol") + +local style = require("coordinator.ui.style") + +local core = require("graphics.core") + +local Div = require("graphics.elements.div") +local Rectangle = require("graphics.elements.rectangle") +local TextBox = require("graphics.elements.textbox") + +local AlarmLight = require("graphics.elements.indicators.alight") +local CoreMap = require("graphics.elements.indicators.coremap") +local DataIndicator = require("graphics.elements.indicators.data") +local IndicatorLight = require("graphics.elements.indicators.light") +local RadIndicator = require("graphics.elements.indicators.rad") +local TriIndicatorLight = require("graphics.elements.indicators.trilight") +local VerticalBar = require("graphics.elements.indicators.vbar") + +local HazardButton = require("graphics.elements.controls.hazard_button") +local MultiButton = require("graphics.elements.controls.multi_button") +local PushButton = require("graphics.elements.controls.push_button") +local RadioButton = require("graphics.elements.controls.radio_button") +local SpinboxNumeric = require("graphics.elements.controls.spinbox_numeric") + +local TEXT_ALIGN = core.graphics.TEXT_ALIGN + +local cpair = core.graphics.cpair +local border = core.graphics.border + +local period = core.flasher.PERIOD + +local waste_opts = { + { + text = "Auto", + fg_bg = cpair(colors.black, colors.lightGray), + active_fg_bg = cpair(colors.white, colors.gray) + }, + { + text = "Pu", + fg_bg = cpair(colors.black, colors.lightGray), + active_fg_bg = cpair(colors.black, colors.green) + }, + { + text = "Po", + fg_bg = cpair(colors.black, colors.lightGray), + active_fg_bg = cpair(colors.black, colors.cyan) + }, + { + text = "AM", + fg_bg = cpair(colors.black, colors.lightGray), + active_fg_bg = cpair(colors.black, colors.purple) + } +} + +-- create a unit view +---@param parent graphics_element parent +---@param id integer +local function init(parent, id) + local unit = iocontrol.get_db().units[id] ---@type ioctl_unit + local f_ps = iocontrol.get_db().facility.ps + local u_ps = unit.unit_ps + local b_ps = unit.boiler_ps_tbl + local t_ps = unit.turbine_ps_tbl + + local main = Div{parent=parent,x=1,y=1} + + TextBox{parent=main,text="Reactor Unit #" .. id,alignment=TEXT_ALIGN.CENTER,height=1,fg_bg=style.header} + + local bw_fg_bg = cpair(colors.black, colors.white) + local hzd_fg_bg = cpair(colors.white, colors.gray) + local lu_cpair = cpair(colors.gray, colors.gray) + + ----------------------------- + -- main stats and core map -- + ----------------------------- + + local core_map = CoreMap{parent=main,x=2,y=3,reactor_l=18,reactor_w=18} + u_ps.subscribe("temp", core_map.update) + u_ps.subscribe("size", function (s) core_map.resize(s[1], s[2]) end) + + TextBox{parent=main,x=12,y=22,text="Heating Rate",height=1,width=12,fg_bg=style.label} + local heating_r = DataIndicator{parent=main,x=12,label="",format="%14.0f",value=0,unit="mB/t",commas=true,lu_colors=lu_cpair,width=19,fg_bg=bw_fg_bg} + u_ps.subscribe("heating_rate", heating_r.update) + + TextBox{parent=main,x=12,y=25,text="Commanded Burn Rate",height=1,width=19,fg_bg=style.label} + local burn_r = DataIndicator{parent=main,x=12,label="",format="%14.2f",value=0,unit="mB/t",lu_colors=lu_cpair,width=19,fg_bg=bw_fg_bg} + u_ps.subscribe("burn_rate", burn_r.update) + + TextBox{parent=main,text="F",x=2,y=22,width=1,height=1,fg_bg=style.label} + TextBox{parent=main,text="C",x=4,y=22,width=1,height=1,fg_bg=style.label} + TextBox{parent=main,text="\x1a",x=6,y=24,width=1,height=1,fg_bg=style.label} + TextBox{parent=main,text="\x1a",x=6,y=25,width=1,height=1,fg_bg=style.label} + TextBox{parent=main,text="H",x=8,y=22,width=1,height=1,fg_bg=style.label} + TextBox{parent=main,text="W",x=10,y=22,width=1,height=1,fg_bg=style.label} + + local fuel = VerticalBar{parent=main,x=2,y=23,fg_bg=cpair(colors.black,colors.gray),height=4,width=1} + local ccool = VerticalBar{parent=main,x=4,y=23,fg_bg=cpair(colors.blue,colors.gray),height=4,width=1} + local hcool = VerticalBar{parent=main,x=8,y=23,fg_bg=cpair(colors.white,colors.gray),height=4,width=1} + local waste = VerticalBar{parent=main,x=10,y=23,fg_bg=cpair(colors.brown,colors.gray),height=4,width=1} + + u_ps.subscribe("fuel_fill", fuel.update) + u_ps.subscribe("ccool_fill", ccool.update) + u_ps.subscribe("hcool_fill", hcool.update) + u_ps.subscribe("waste_fill", waste.update) + + u_ps.subscribe("ccool_type", function (type) + if type == "mekanism:sodium" then + ccool.recolor(cpair(colors.lightBlue, colors.gray)) + else + ccool.recolor(cpair(colors.blue, colors.gray)) + end + end) + + u_ps.subscribe("hcool_type", function (type) + if type == "mekanism:superheated_sodium" then + hcool.recolor(cpair(colors.orange, colors.gray)) + else + hcool.recolor(cpair(colors.white, colors.gray)) + end + end) + + TextBox{parent=main,x=32,y=22,text="Core Temp",height=1,width=9,fg_bg=style.label} + local core_temp = DataIndicator{parent=main,x=32,label="",format="%11.2f",value=0,unit="K",lu_colors=lu_cpair,width=13,fg_bg=bw_fg_bg} + u_ps.subscribe("temp", core_temp.update) + + TextBox{parent=main,x=32,y=25,text="Burn Rate",height=1,width=9,fg_bg=style.label} + local act_burn_r = DataIndicator{parent=main,x=32,label="",format="%8.2f",value=0,unit="mB/t",lu_colors=lu_cpair,width=13,fg_bg=bw_fg_bg} + u_ps.subscribe("act_burn_rate", act_burn_r.update) + + TextBox{parent=main,x=32,y=28,text="Damage",height=1,width=6,fg_bg=style.label} + local damage_p = DataIndicator{parent=main,x=32,label="",format="%11.0f",value=0,unit="%",lu_colors=lu_cpair,width=13,fg_bg=bw_fg_bg} + u_ps.subscribe("damage", damage_p.update) + + TextBox{parent=main,x=32,y=31,text="Radiation",height=1,width=21,fg_bg=style.label} + local radiation = RadIndicator{parent=main,x=32,label="",format="%9.3f",lu_colors=lu_cpair,width=13,fg_bg=bw_fg_bg} + u_ps.subscribe("radiation", radiation.update) + + ------------------- + -- system status -- + ------------------- + + local u_stat = Rectangle{parent=main,border=border(1,colors.gray,true),thin=true,width=33,height=4,x=46,y=3,fg_bg=bw_fg_bg} + local stat_line_1 = TextBox{parent=u_stat,x=1,y=1,text="UNKNOWN",width=33,height=1,alignment=TEXT_ALIGN.CENTER,fg_bg=bw_fg_bg} + local stat_line_2 = TextBox{parent=u_stat,x=1,y=2,text="awaiting data...",width=33,height=1,alignment=TEXT_ALIGN.CENTER,fg_bg=cpair(colors.gray, colors.white)} + + u_ps.subscribe("U_StatusLine1", stat_line_1.set_value) + u_ps.subscribe("U_StatusLine2", stat_line_2.set_value) + + ----------------- + -- annunciator -- + ----------------- + + -- annunciator colors (generally) per IAEA-TECDOC-812 recommendations + + local annunciator = Div{parent=main,width=23,height=18,x=22,y=3} + + -- connectivity + local plc_online = IndicatorLight{parent=annunciator,label="PLC Online",colors=cpair(colors.green,colors.red)} + local plc_hbeat = IndicatorLight{parent=annunciator,label="PLC Heartbeat",colors=cpair(colors.white,colors.gray)} + local rad_mon = TriIndicatorLight{parent=annunciator,label="Radiation Monitor",c1=colors.gray,c2=colors.yellow,c3=colors.green} + + u_ps.subscribe("PLCOnline", plc_online.update) + u_ps.subscribe("PLCHeartbeat", plc_hbeat.update) + u_ps.subscribe("RadiationMonitor", rad_mon.update) + + annunciator.line_break() + + -- operating state + local r_active = IndicatorLight{parent=annunciator,label="Active",colors=cpair(colors.green,colors.gray)} + local r_auto = IndicatorLight{parent=annunciator,label="Automatic Control",colors=cpair(colors.white,colors.gray)} + + u_ps.subscribe("status", r_active.update) + u_ps.subscribe("AutoControl", r_auto.update) + + -- main unit transient/warning annunciator panel + local r_scram = IndicatorLight{parent=annunciator,label="Reactor SCRAM",colors=cpair(colors.red,colors.gray)} + local r_mscrm = IndicatorLight{parent=annunciator,label="Manual Reactor SCRAM",colors=cpair(colors.red,colors.gray)} + local r_ascrm = IndicatorLight{parent=annunciator,label="Auto Reactor SCRAM",colors=cpair(colors.red,colors.gray)} + local rad_wrn = IndicatorLight{parent=annunciator,label="Radiation Warning",colors=cpair(colors.yellow,colors.gray)} + local r_rtrip = IndicatorLight{parent=annunciator,label="RCP Trip",colors=cpair(colors.red,colors.gray)} + local r_cflow = IndicatorLight{parent=annunciator,label="RCS Flow Low",colors=cpair(colors.yellow,colors.gray)} + local r_clow = IndicatorLight{parent=annunciator,label="Coolant Level Low",colors=cpair(colors.yellow,colors.gray)} + local r_temp = IndicatorLight{parent=annunciator,label="Reactor Temp. High",colors=cpair(colors.red,colors.gray)} + local r_rhdt = IndicatorLight{parent=annunciator,label="Reactor High Delta T",colors=cpair(colors.yellow,colors.gray)} + local r_firl = IndicatorLight{parent=annunciator,label="Fuel Input Rate Low",colors=cpair(colors.yellow,colors.gray)} + local r_wloc = IndicatorLight{parent=annunciator,label="Waste Line Occlusion",colors=cpair(colors.yellow,colors.gray)} + local r_hsrt = IndicatorLight{parent=annunciator,label="Startup Rate High",colors=cpair(colors.yellow,colors.gray)} + + u_ps.subscribe("ReactorSCRAM", r_scram.update) + u_ps.subscribe("ManualReactorSCRAM", r_mscrm.update) + u_ps.subscribe("AutoReactorSCRAM", r_ascrm.update) + u_ps.subscribe("RadiationWarning", rad_wrn.update) + u_ps.subscribe("RCPTrip", r_rtrip.update) + u_ps.subscribe("RCSFlowLow", r_cflow.update) + u_ps.subscribe("CoolantLevelLow", r_clow.update) + u_ps.subscribe("ReactorTempHigh", r_temp.update) + u_ps.subscribe("ReactorHighDeltaT", r_rhdt.update) + u_ps.subscribe("FuelInputRateLow", r_firl.update) + u_ps.subscribe("WasteLineOcclusion", r_wloc.update) + u_ps.subscribe("HighStartupRate", r_hsrt.update) + + -- RPS annunciator panel + + TextBox{parent=main,text="REACTOR PROTECTION SYSTEM",fg_bg=cpair(colors.black,colors.cyan),alignment=TEXT_ALIGN.CENTER,width=33,height=1,x=46,y=8} + local rps = Rectangle{parent=main,border=border(1,colors.cyan,true),thin=true,width=33,height=12,x=46,y=9} + local rps_annunc = Div{parent=rps,width=31,height=10,x=2,y=1} + + local rps_trp = IndicatorLight{parent=rps_annunc,label="RPS Trip",colors=cpair(colors.red,colors.gray),flash=true,period=period.BLINK_250_MS} + local rps_dmg = IndicatorLight{parent=rps_annunc,label="Damage Level High",colors=cpair(colors.red,colors.gray),flash=true,period=period.BLINK_250_MS} + local rps_exh = IndicatorLight{parent=rps_annunc,label="Excess Heated Coolant",colors=cpair(colors.yellow,colors.gray)} + local rps_exw = IndicatorLight{parent=rps_annunc,label="Excess Waste",colors=cpair(colors.yellow,colors.gray)} + local rps_tmp = IndicatorLight{parent=rps_annunc,label="Core Temperature High",colors=cpair(colors.red,colors.gray),flash=true,period=period.BLINK_250_MS} + local rps_nof = IndicatorLight{parent=rps_annunc,label="No Fuel",colors=cpair(colors.yellow,colors.gray)} + local rps_loc = IndicatorLight{parent=rps_annunc,label="Coolant Level Low Low",colors=cpair(colors.yellow,colors.gray)} + local rps_flt = IndicatorLight{parent=rps_annunc,label="PPM Fault",colors=cpair(colors.yellow,colors.gray),flash=true,period=period.BLINK_500_MS} + local rps_tmo = IndicatorLight{parent=rps_annunc,label="Connection Timeout",colors=cpair(colors.yellow,colors.gray),flash=true,period=period.BLINK_500_MS} + local rps_sfl = IndicatorLight{parent=rps_annunc,label="System Failure",colors=cpair(colors.orange,colors.gray),flash=true,period=period.BLINK_500_MS} + + u_ps.subscribe("rps_tripped", rps_trp.update) + u_ps.subscribe("high_dmg", rps_dmg.update) + u_ps.subscribe("ex_hcool", rps_exh.update) + u_ps.subscribe("ex_waste", rps_exw.update) + u_ps.subscribe("high_temp", rps_tmp.update) + u_ps.subscribe("no_fuel", rps_nof.update) + u_ps.subscribe("low_cool", rps_loc.update) + u_ps.subscribe("fault", rps_flt.update) + u_ps.subscribe("timeout", rps_tmo.update) + u_ps.subscribe("sys_fail", rps_sfl.update) + + -- cooling annunciator panel + + TextBox{parent=main,text="REACTOR COOLANT SYSTEM",fg_bg=cpair(colors.black,colors.blue),alignment=TEXT_ALIGN.CENTER,width=33,height=1,x=46,y=22} + local rcs = Rectangle{parent=main,border=border(1,colors.blue,true),thin=true,width=33,height=24,x=46,y=23} + local rcs_annunc = Div{parent=rcs,width=27,height=23,x=2,y=1} + local rcs_tags = Div{parent=rcs,width=2,height=14,x=29,y=9} + + local c_flt = IndicatorLight{parent=rcs_annunc,label="RCS Hardware Fault",colors=cpair(colors.yellow,colors.gray)} + local c_emg = TriIndicatorLight{parent=rcs_annunc,label="Emergency Coolant",c1=colors.gray,c2=colors.white,c3=colors.green} + local c_cfm = IndicatorLight{parent=rcs_annunc,label="Coolant Feed Mismatch",colors=cpair(colors.yellow,colors.gray)} + local c_brm = IndicatorLight{parent=rcs_annunc,label="Boil Rate Mismatch",colors=cpair(colors.yellow,colors.gray)} + local c_sfm = IndicatorLight{parent=rcs_annunc,label="Steam Feed Mismatch",colors=cpair(colors.yellow,colors.gray)} + local c_mwrf = IndicatorLight{parent=rcs_annunc,label="Max Water Return Feed",colors=cpair(colors.yellow,colors.gray)} + local c_tbnt = IndicatorLight{parent=rcs_annunc,label="Turbine Trip",colors=cpair(colors.red,colors.gray),flash=true,period=period.BLINK_250_MS} + + u_ps.subscribe("RCSFault", c_flt.update) + u_ps.subscribe("EmergencyCoolant", c_emg.update) + u_ps.subscribe("CoolantFeedMismatch", c_cfm.update) + u_ps.subscribe("BoilRateMismatch", c_brm.update) + u_ps.subscribe("SteamFeedMismatch", c_sfm.update) + u_ps.subscribe("MaxWaterReturnFeed", c_mwrf.update) + u_ps.subscribe("TurbineTrip", c_tbnt.update) + + rcs_annunc.line_break() + + -- boiler annunciator panel(s) + + if unit.num_boilers > 0 then + TextBox{parent=rcs_tags,x=1,text="B1",width=2,height=1,fg_bg=bw_fg_bg} + local b1_wll = IndicatorLight{parent=rcs_annunc,label="Water Level Low",colors=cpair(colors.red,colors.gray)} + b_ps[1].subscribe("WasterLevelLow", b1_wll.update) + + TextBox{parent=rcs_tags,text="B1",width=2,height=1,fg_bg=bw_fg_bg} + local b1_hr = IndicatorLight{parent=rcs_annunc,label="Heating Rate Low",colors=cpair(colors.yellow,colors.gray)} + b_ps[1].subscribe("HeatingRateLow", b1_hr.update) + end + if unit.num_boilers > 1 then + TextBox{parent=rcs_tags,text="B2",width=2,height=1,fg_bg=bw_fg_bg} + local b2_wll = IndicatorLight{parent=rcs_annunc,label="Water Level Low",colors=cpair(colors.red,colors.gray)} + b_ps[2].subscribe("WasterLevelLow", b2_wll.update) + + TextBox{parent=rcs_tags,text="B2",width=2,height=1,fg_bg=bw_fg_bg} + local b2_hr = IndicatorLight{parent=rcs_annunc,label="Heating Rate Low",colors=cpair(colors.yellow,colors.gray)} + b_ps[2].subscribe("HeatingRateLow", b2_hr.update) + end + + -- turbine annunciator panels + + if unit.num_boilers == 0 then + TextBox{parent=rcs_tags,text="T1",width=2,height=1,fg_bg=bw_fg_bg} + else + rcs_tags.line_break() + rcs_annunc.line_break() + TextBox{parent=rcs_tags,text="T1",width=2,height=1,fg_bg=bw_fg_bg} + end + + local t1_sdo = TriIndicatorLight{parent=rcs_annunc,label="Steam Relief Valve Open",c1=colors.gray,c2=colors.yellow,c3=colors.red} + t_ps[1].subscribe("SteamDumpOpen", t1_sdo.update) + + TextBox{parent=rcs_tags,text="T1",width=2,height=1,fg_bg=bw_fg_bg} + local t1_tos = IndicatorLight{parent=rcs_annunc,label="Turbine Over Speed",colors=cpair(colors.red,colors.gray)} + t_ps[1].subscribe("TurbineOverSpeed", t1_tos.update) + + TextBox{parent=rcs_tags,text="T1",width=2,height=1,fg_bg=bw_fg_bg} + local t1_trp = IndicatorLight{parent=rcs_annunc,label="Turbine Trip",colors=cpair(colors.red,colors.gray),flash=true,period=period.BLINK_250_MS} + t_ps[1].subscribe("TurbineTrip", t1_trp.update) + + if unit.num_turbines > 1 then + TextBox{parent=rcs_tags,text="T2",width=2,height=1,fg_bg=bw_fg_bg} + local t2_sdo = TriIndicatorLight{parent=rcs_annunc,label="Steam Relief Valve Open",c1=colors.gray,c2=colors.yellow,c3=colors.red} + t_ps[2].subscribe("SteamDumpOpen", t2_sdo.update) + + TextBox{parent=rcs_tags,text="T2",width=2,height=1,fg_bg=bw_fg_bg} + local t2_tos = IndicatorLight{parent=rcs_annunc,label="Turbine Over Speed",colors=cpair(colors.red,colors.gray)} + t_ps[2].subscribe("TurbineOverSpeed", t2_tos.update) + + TextBox{parent=rcs_tags,text="T2",width=2,height=1,fg_bg=bw_fg_bg} + local t2_trp = IndicatorLight{parent=rcs_annunc,label="Turbine Trip",colors=cpair(colors.red,colors.gray),flash=true,period=period.BLINK_250_MS} + t_ps[2].subscribe("TurbineTrip", t2_trp.update) + end + + if unit.num_turbines > 2 then + TextBox{parent=rcs_tags,text="T3",width=2,height=1,fg_bg=bw_fg_bg} + local t3_sdo = TriIndicatorLight{parent=rcs_annunc,label="Steam Relief Valve Open",c1=colors.gray,c2=colors.yellow,c3=colors.red} + t_ps[3].subscribe("SteamDumpOpen", t3_sdo.update) + + TextBox{parent=rcs_tags,text="T3",width=2,height=1,fg_bg=bw_fg_bg} + local t3_tos = IndicatorLight{parent=rcs_annunc,label="Turbine Over Speed",colors=cpair(colors.red,colors.gray)} + t_ps[3].subscribe("TurbineOverSpeed", t3_tos.update) + + TextBox{parent=rcs_tags,text="T3",width=2,height=1,fg_bg=bw_fg_bg} + local t3_trp = IndicatorLight{parent=rcs_annunc,label="Turbine Trip",colors=cpair(colors.red,colors.gray),flash=true,period=period.BLINK_250_MS} + t_ps[3].subscribe("TurbineTrip", t3_trp.update) + end + + ---------------------- + -- reactor controls -- + ---------------------- + + local dis_colors = cpair(colors.white, colors.lightGray) + + local burn_control = Div{parent=main,x=12,y=28,width=19,height=3,fg_bg=cpair(colors.gray,colors.white)} + local burn_rate = SpinboxNumeric{parent=burn_control,x=2,y=1,whole_num_precision=4,fractional_precision=1,min=0.1,arrow_fg_bg=cpair(colors.gray,colors.white),fg_bg=bw_fg_bg} + TextBox{parent=burn_control,x=9,y=2,text="mB/t"} + + local set_burn = function () unit.set_burn(burn_rate.get_value()) end + local set_burn_btn = PushButton{parent=burn_control,x=14,y=2,text="SET",min_width=5,fg_bg=cpair(colors.black,colors.yellow),active_fg_bg=cpair(colors.white,colors.gray),dis_fg_bg=dis_colors,callback=set_burn} + + u_ps.subscribe("burn_rate", burn_rate.set_value) + u_ps.subscribe("max_burn", burn_rate.set_max) + + local start = HazardButton{parent=main,x=2,y=28,text="START",accent=colors.lightBlue,dis_colors=dis_colors,callback=unit.start,fg_bg=hzd_fg_bg} + local ack_a = HazardButton{parent=main,x=12,y=32,text="ACK \x13",accent=colors.orange,dis_colors=dis_colors,callback=unit.ack_alarms,fg_bg=hzd_fg_bg} + local scram = HazardButton{parent=main,x=2,y=32,text="SCRAM",accent=colors.yellow,dis_colors=dis_colors,callback=unit.scram,fg_bg=hzd_fg_bg} + local reset = HazardButton{parent=main,x=22,y=32,text="RESET",accent=colors.red,dis_colors=dis_colors,callback=unit.reset_rps,fg_bg=hzd_fg_bg} + + unit.start_ack = start.on_response + unit.scram_ack = scram.on_response + unit.reset_rps_ack = reset.on_response + unit.ack_alarms_ack = ack_a.on_response + + local function start_button_en_check() + if (unit.reactor_data ~= nil) and (unit.reactor_data.mek_status ~= nil) then + local can_start = (not unit.reactor_data.mek_status.status) and + (not unit.reactor_data.rps_tripped) and + (unit.a_group == 0) + if can_start then start.enable() else start.disable() end + end + end + + u_ps.subscribe("status", start_button_en_check) + u_ps.subscribe("rps_tripped", start_button_en_check) + u_ps.subscribe("rps_tripped", function (active) if active then reset.enable() else reset.disable() end end) + + TextBox{parent=main,text="WASTE PROCESSING",fg_bg=cpair(colors.black,colors.brown),alignment=TEXT_ALIGN.CENTER,width=33,height=1,x=46,y=48} + local waste_proc = Rectangle{parent=main,border=border(1,colors.brown,true),thin=true,width=33,height=3,x=46,y=49} + local waste_div = Div{parent=waste_proc,x=2,y=1,width=31,height=1} + + local waste_mode = MultiButton{parent=waste_div,x=1,y=1,options=waste_opts,callback=unit.set_waste,min_width=6} + + u_ps.subscribe("U_WasteMode", waste_mode.set_value) + + ---------------------- + -- alarm management -- + ---------------------- + + local alarm_panel = Div{parent=main,x=2,y=36,width=29,height=16,fg_bg=bw_fg_bg} + + local a_brc = AlarmLight{parent=alarm_panel,x=6,y=2,label="Containment Breach",c1=colors.gray,c2=colors.red,c3=colors.green,flash=true,period=period.BLINK_250_MS} + local a_rad = AlarmLight{parent=alarm_panel,x=6,label="Containment Radiation",c1=colors.gray,c2=colors.red,c3=colors.green,flash=true,period=period.BLINK_250_MS} + local a_dmg = AlarmLight{parent=alarm_panel,x=6,label="Critical Damage",c1=colors.gray,c2=colors.red,c3=colors.green,flash=true,period=period.BLINK_250_MS} + alarm_panel.line_break() + local a_rcl = AlarmLight{parent=alarm_panel,x=6,label="Reactor Lost",c1=colors.gray,c2=colors.red,c3=colors.green,flash=true,period=period.BLINK_250_MS} + local a_rcd = AlarmLight{parent=alarm_panel,x=6,label="Reactor Damage",c1=colors.gray,c2=colors.red,c3=colors.green,flash=true,period=period.BLINK_250_MS} + local a_rot = AlarmLight{parent=alarm_panel,x=6,label="Reactor Over Temp",c1=colors.gray,c2=colors.red,c3=colors.green,flash=true,period=period.BLINK_250_MS} + local a_rht = AlarmLight{parent=alarm_panel,x=6,label="Reactor High Temp",c1=colors.gray,c2=colors.yellow,c3=colors.green,flash=true,period=period.BLINK_500_MS} + local a_rwl = AlarmLight{parent=alarm_panel,x=6,label="Reactor Waste Leak",c1=colors.gray,c2=colors.red,c3=colors.green,flash=true,period=period.BLINK_250_MS} + local a_rwh = AlarmLight{parent=alarm_panel,x=6,label="Reactor Waste High",c1=colors.gray,c2=colors.yellow,c3=colors.green,flash=true,period=period.BLINK_500_MS} + alarm_panel.line_break() + local a_rps = AlarmLight{parent=alarm_panel,x=6,label="RPS Transient",c1=colors.gray,c2=colors.yellow,c3=colors.green,flash=true,period=period.BLINK_500_MS} + local a_clt = AlarmLight{parent=alarm_panel,x=6,label="RCS Transient",c1=colors.gray,c2=colors.yellow,c3=colors.green,flash=true,period=period.BLINK_500_MS} + local a_tbt = AlarmLight{parent=alarm_panel,x=6,label="Turbine Trip",c1=colors.gray,c2=colors.red,c3=colors.green,flash=true,period=period.BLINK_250_MS} + + u_ps.subscribe("Alarm_1", a_brc.update) + u_ps.subscribe("Alarm_2", a_rad.update) + u_ps.subscribe("Alarm_4", a_dmg.update) + + u_ps.subscribe("Alarm_3", a_rcl.update) + u_ps.subscribe("Alarm_5", a_rcd.update) + u_ps.subscribe("Alarm_6", a_rot.update) + u_ps.subscribe("Alarm_7", a_rht.update) + u_ps.subscribe("Alarm_8", a_rwl.update) + u_ps.subscribe("Alarm_9", a_rwh.update) + + u_ps.subscribe("Alarm_10", a_rps.update) + u_ps.subscribe("Alarm_11", a_clt.update) + u_ps.subscribe("Alarm_12", a_tbt.update) + + -- ack's and resets + + local c = unit.alarm_callbacks + local ack_fg_bg = cpair(colors.black, colors.orange) + local rst_fg_bg = cpair(colors.black, colors.lime) + local active_fg_bg = cpair(colors.white, colors.gray) + + PushButton{parent=alarm_panel,x=2,y=2,text="\x13",callback=c.c_breach.ack,fg_bg=ack_fg_bg,active_fg_bg=active_fg_bg} + PushButton{parent=alarm_panel,x=4,y=2,text="R",callback=c.c_breach.reset,fg_bg=rst_fg_bg,active_fg_bg=active_fg_bg} + PushButton{parent=alarm_panel,x=2,y=3,text="\x13",callback=c.radiation.ack,fg_bg=ack_fg_bg,active_fg_bg=active_fg_bg} + PushButton{parent=alarm_panel,x=4,y=3,text="R",callback=c.radiation.reset,fg_bg=rst_fg_bg,active_fg_bg=active_fg_bg} + PushButton{parent=alarm_panel,x=2,y=4,text="\x13",callback=c.dmg_crit.ack,fg_bg=ack_fg_bg,active_fg_bg=active_fg_bg} + PushButton{parent=alarm_panel,x=4,y=4,text="R",callback=c.dmg_crit.reset,fg_bg=rst_fg_bg,active_fg_bg=active_fg_bg} + + PushButton{parent=alarm_panel,x=2,y=6,text="\x13",callback=c.r_lost.ack,fg_bg=ack_fg_bg,active_fg_bg=active_fg_bg} + PushButton{parent=alarm_panel,x=4,y=6,text="R",callback=c.r_lost.reset,fg_bg=rst_fg_bg,active_fg_bg=active_fg_bg} + PushButton{parent=alarm_panel,x=2,y=7,text="\x13",callback=c.damage.ack,fg_bg=ack_fg_bg,active_fg_bg=active_fg_bg} + PushButton{parent=alarm_panel,x=4,y=7,text="R",callback=c.damage.reset,fg_bg=rst_fg_bg,active_fg_bg=active_fg_bg} + PushButton{parent=alarm_panel,x=2,y=8,text="\x13",callback=c.over_temp.ack,fg_bg=ack_fg_bg,active_fg_bg=active_fg_bg} + PushButton{parent=alarm_panel,x=4,y=8,text="R",callback=c.over_temp.reset,fg_bg=rst_fg_bg,active_fg_bg=active_fg_bg} + PushButton{parent=alarm_panel,x=2,y=9,text="\x13",callback=c.high_temp.ack,fg_bg=ack_fg_bg,active_fg_bg=active_fg_bg} + PushButton{parent=alarm_panel,x=4,y=9,text="R",callback=c.high_temp.reset,fg_bg=rst_fg_bg,active_fg_bg=active_fg_bg} + PushButton{parent=alarm_panel,x=2,y=10,text="\x13",callback=c.waste_leak.ack,fg_bg=ack_fg_bg,active_fg_bg=active_fg_bg} + PushButton{parent=alarm_panel,x=4,y=10,text="R",callback=c.waste_leak.reset,fg_bg=rst_fg_bg,active_fg_bg=active_fg_bg} + PushButton{parent=alarm_panel,x=2,y=11,text="\x13",callback=c.waste_high.ack,fg_bg=ack_fg_bg,active_fg_bg=active_fg_bg} + PushButton{parent=alarm_panel,x=4,y=11,text="R",callback=c.waste_high.reset,fg_bg=rst_fg_bg,active_fg_bg=active_fg_bg} + + PushButton{parent=alarm_panel,x=2,y=13,text="\x13",callback=c.rps_trans.ack,fg_bg=ack_fg_bg,active_fg_bg=active_fg_bg} + PushButton{parent=alarm_panel,x=4,y=13,text="R",callback=c.rps_trans.reset,fg_bg=rst_fg_bg,active_fg_bg=active_fg_bg} + PushButton{parent=alarm_panel,x=2,y=14,text="\x13",callback=c.rcs_trans.ack,fg_bg=ack_fg_bg,active_fg_bg=active_fg_bg} + PushButton{parent=alarm_panel,x=4,y=14,text="R",callback=c.rcs_trans.reset,fg_bg=rst_fg_bg,active_fg_bg=active_fg_bg} + PushButton{parent=alarm_panel,x=2,y=15,text="\x13",callback=c.t_trip.ack,fg_bg=ack_fg_bg,active_fg_bg=active_fg_bg} + PushButton{parent=alarm_panel,x=4,y=15,text="R",callback=c.t_trip.reset,fg_bg=rst_fg_bg,active_fg_bg=active_fg_bg} + + -- color tags + + TextBox{parent=alarm_panel,x=5,y=13,text="\x95",width=1,height=1,fg_bg=cpair(colors.white,colors.cyan)} + TextBox{parent=alarm_panel,x=5,text="\x95",width=1,height=1,fg_bg=cpair(colors.white,colors.blue)} + TextBox{parent=alarm_panel,x=5,text="\x95",width=1,height=1,fg_bg=cpair(colors.white,colors.blue)} + + -------------------------------- + -- automatic control settings -- + -------------------------------- + + TextBox{parent=main,text="AUTO CTRL",fg_bg=cpair(colors.black,colors.purple),alignment=TEXT_ALIGN.CENTER,width=13,height=1,x=32,y=36} + local auto_ctl = Rectangle{parent=main,border=border(1,colors.purple,true),thin=true,width=13,height=15,x=32,y=37} + local auto_div = Div{parent=auto_ctl,width=13,height=15,x=1,y=1} + + local ctl_opts = { "Manual", "Primary", "Secondary", "Tertiary", "Backup" } + + local group = RadioButton{parent=auto_div,options=ctl_opts,callback=function()end,radio_colors=cpair(colors.blue,colors.white),radio_bg=colors.gray} + + u_ps.subscribe("auto_group_id", function (gid) group.set_value(gid + 1) end) + + auto_div.line_break() + + local function set_group() unit.set_group(group.get_value() - 1) end + local set_grp_btn = PushButton{parent=auto_div,text="SET",x=4,min_width=5,fg_bg=cpair(colors.black,colors.yellow),active_fg_bg=cpair(colors.white,colors.gray),dis_fg_bg=cpair(colors.gray,colors.white),callback=set_group} + + auto_div.line_break() + + TextBox{parent=auto_div,text="Prio. Group",height=1,width=11,fg_bg=style.label} + local auto_grp = TextBox{parent=auto_div,text="Manual",height=1,width=11,fg_bg=bw_fg_bg} + + u_ps.subscribe("auto_group", auto_grp.set_value) + + auto_div.line_break() + + local a_rdy = IndicatorLight{parent=auto_div,label="Ready",x=2,colors=cpair(colors.green,colors.gray)} + local a_stb = IndicatorLight{parent=auto_div,label="Standby",x=2,colors=cpair(colors.white,colors.gray),flash=true,period=period.BLINK_1000_MS} + + u_ps.subscribe("U_AutoReady", a_rdy.update) + + -- update standby indicator + u_ps.subscribe("status", function (active) + a_stb.update(unit.annunciator.AutoControl and (not active)) + end) + + -- enable and disable controls based on group assignment + u_ps.subscribe("auto_group_id", function (gid) + start_button_en_check() + + if gid == 0 then + burn_rate.enable() + set_burn_btn.enable() + else + burn_rate.disable() + set_burn_btn.disable() + end + end) + + -- enable and disable controls based on auto control state (start button is handled separately) + u_ps.subscribe("AutoControl", function (auto_active) + start_button_en_check() + + if auto_active then + a_stb.update(unit.reactor_data.mek_status.status == false) + else a_stb.update(false) end + end) + + -- can't change group if auto is engaged regardless of if this unit is part of auto control + f_ps.subscribe("auto_active", function (auto_active) + if auto_active then set_grp_btn.disable() else set_grp_btn.enable() end + end) + + return main +end + +return init diff --git a/coordinator/ui/components/unit_overview.lua b/coordinator/ui/components/unit_overview.lua new file mode 100644 index 0000000..e5a07f9 --- /dev/null +++ b/coordinator/ui/components/unit_overview.lua @@ -0,0 +1,176 @@ +-- +-- Basic Unit Overview +-- + +local core = require("graphics.core") + +local style = require("coordinator.ui.style") + +local reactor_view = require("coordinator.ui.components.reactor") +local boiler_view = require("coordinator.ui.components.boiler") +local turbine_view = require("coordinator.ui.components.turbine") + +local Div = require("graphics.elements.div") +local PipeNetwork = require("graphics.elements.pipenet") +local TextBox = require("graphics.elements.textbox") + +local TEXT_ALIGN = core.graphics.TEXT_ALIGN + +local pipe = core.graphics.pipe + +-- make a new unit overview window +---@param parent graphics_element parent +---@param x integer top left x +---@param y integer top left y +---@param unit ioctl_unit unit database entry +local function make(parent, x, y, unit) + local height = 0 + local num_boilers = #unit.boiler_data_tbl + local num_turbines = #unit.turbine_data_tbl + + assert(num_boilers >= 0 and num_boilers <= 2, "minimum 0 boilers, maximum 2 boilers") + assert(num_turbines >= 1 and num_turbines <= 3, "minimum 1 turbine, maximum 3 turbines") + + if num_boilers == 0 and num_turbines == 1 then + height = 9 + elseif num_boilers == 1 and num_turbines <= 2 then + height = 17 + else + height = 25 + end + + assert(parent.height() >= (y + height), "main display not of sufficient vertical resolution (add an additional row of monitors)") + + -- bounding box div + local root = Div{parent=parent,x=x,y=y,width=80,height=height} + + -- unit header message + TextBox{parent=root,text="Unit #"..unit.unit_id,alignment=TEXT_ALIGN.CENTER,height=1,fg_bg=style.header} + + ------------- + -- REACTOR -- + ------------- + + reactor_view(root, 1, 3, unit.reactor_data, unit.unit_ps) + + if num_boilers > 0 then + local coolant_pipes = {} + + if num_boilers >= 2 then + table.insert(coolant_pipes, pipe(0, 0, 11, 12, colors.lightBlue)) + end + + table.insert(coolant_pipes, pipe(0, 0, 11, 3, colors.lightBlue)) + table.insert(coolant_pipes, pipe(2, 0, 11, 2, colors.orange)) + + if num_boilers >= 2 then + table.insert(coolant_pipes, pipe(2, 0, 11, 11, colors.orange)) + end + + PipeNetwork{parent=root,x=4,y=10,pipes=coolant_pipes,bg=colors.lightGray} + end + + ------------- + -- BOILERS -- + ------------- + + if num_boilers >= 1 then boiler_view(root, 16, 11, unit.boiler_ps_tbl[1]) end + if num_boilers >= 2 then boiler_view(root, 16, 19, unit.boiler_ps_tbl[2]) end + + -------------- + -- TURBINES -- + -------------- + + local t_idx = 1 + local no_boilers = num_boilers == 0 + + if (num_turbines >= 3) or no_boilers or (num_boilers == 1 and num_turbines >= 2) then + turbine_view(root, 58, 3, unit.turbine_ps_tbl[t_idx]) + t_idx = t_idx + 1 + end + + if (num_turbines >= 1 and not no_boilers) or num_turbines >= 2 then + turbine_view(root, 58, 11, unit.turbine_ps_tbl[t_idx]) + t_idx = t_idx + 1 + end + + if (num_turbines >= 2 and num_boilers >= 2) or num_turbines >= 3 then + turbine_view(root, 58, 19, unit.turbine_ps_tbl[t_idx]) + end + + local steam_pipes_b = {} + + if no_boilers then + table.insert(steam_pipes_b, pipe(0, 1, 3, 1, colors.white)) -- steam to turbine 1 + table.insert(steam_pipes_b, pipe(0, 2, 3, 2, colors.blue)) -- water to turbine 1 + + if num_turbines >= 2 then + table.insert(steam_pipes_b, pipe(1, 2, 3, 9, colors.white)) -- steam to turbine 2 + table.insert(steam_pipes_b, pipe(2, 3, 3, 10, colors.blue)) -- water to turbine 2 + end + + if num_turbines >= 3 then + table.insert(steam_pipes_b, pipe(1, 9, 3, 17, colors.white)) -- steam boiler 1 to turbine 1 junction end + table.insert(steam_pipes_b, pipe(2, 10, 3, 18, colors.blue)) -- water boiler 1 to turbine 1 junction start + end + else + -- boiler side pipes + local steam_pipes_a = { + -- boiler 1 steam/water pipes + pipe(0, 1, 6, 1, colors.white, false, true), -- steam boiler 1 to turbine junction + pipe(0, 2, 6, 2, colors.blue, false, true) -- water boiler 1 to turbine junction + } + + if num_boilers >= 2 then + -- boiler 2 steam/water pipes + table.insert(steam_pipes_a, pipe(0, 9, 6, 9, colors.white, false, true)) -- steam boiler 2 to turbine junction + table.insert(steam_pipes_a, pipe(0, 10, 6, 10, colors.blue, false, true)) -- water boiler 2 to turbine junction + end + + -- turbine side pipes + + if num_turbines >= 3 or (num_boilers == 1 and num_turbines == 2) then + table.insert(steam_pipes_b, pipe(0, 9, 1, 2, colors.white, false, true)) -- steam boiler 1 to turbine 1 junction start + table.insert(steam_pipes_b, pipe(1, 1, 3, 1, colors.white, false, false)) -- steam boiler 1 to turbine 1 junction end + end + + table.insert(steam_pipes_b, pipe(0, 9, 3, 9, colors.white, false, true)) -- steam boiler 1 to turbine 2 + + if num_turbines >= 3 or (num_boilers == 1 and num_turbines == 2) then + table.insert(steam_pipes_b, pipe(0, 10, 2, 3, colors.blue, false, true)) -- water boiler 1 to turbine 1 junction start + table.insert(steam_pipes_b, pipe(2, 2, 3, 2, colors.blue, false, false)) -- water boiler 1 to turbine 1 junction end + end + + table.insert(steam_pipes_b, pipe(0, 10, 3, 10, colors.blue, false, true)) -- water boiler 1 to turbine 2 + + if num_turbines >= 3 or (num_turbines >= 2 and num_boilers >= 2) then + if num_boilers >= 2 then + table.insert(steam_pipes_b, pipe(0, 17, 1, 9, colors.white, false, true)) -- steam boiler 2 to turbine 2 junction + table.insert(steam_pipes_b, pipe(0, 17, 3, 17, colors.white, false, true)) -- steam boiler 2 to turbine 3 + + table.insert(steam_pipes_b, pipe(0, 18, 2, 10, colors.blue, false, true)) -- water boiler 2 to turbine 3 + table.insert(steam_pipes_b, pipe(0, 18, 3, 18, colors.blue, false, true)) -- water boiler 2 to turbine 2 junction + else + table.insert(steam_pipes_b, pipe(1, 17, 1, 9, colors.white, false, true)) -- steam boiler 2 to turbine 2 junction + table.insert(steam_pipes_b, pipe(1, 17, 3, 17, colors.white, false, true)) -- steam boiler 2 to turbine 3 + + table.insert(steam_pipes_b, pipe(2, 18, 2, 10, colors.blue, false, true)) -- water boiler 2 to turbine 3 + table.insert(steam_pipes_b, pipe(2, 18, 3, 18, colors.blue, false, true)) -- water boiler 2 to turbine 2 junction + end + elseif num_turbines == 1 and num_boilers >= 2 then + table.insert(steam_pipes_b, pipe(0, 17, 1, 9, colors.white, false, true)) -- steam boiler 2 to turbine 2 junction + table.insert(steam_pipes_b, pipe(0, 17, 1, 17, colors.white, false, true)) -- steam boiler 2 to turbine 3 + + table.insert(steam_pipes_b, pipe(0, 18, 2, 10, colors.blue, false, true)) -- water boiler 2 to turbine 3 + table.insert(steam_pipes_b, pipe(0, 18, 2, 18, colors.blue, false, true)) -- water boiler 2 to turbine 2 junction + end + + PipeNetwork{parent=root,x=47,y=11,pipes=steam_pipes_a,bg=colors.lightGray} + end + + PipeNetwork{parent=root,x=54,y=3,pipes=steam_pipes_b,bg=colors.lightGray} + + return root +end + +return make diff --git a/coordinator/ui/components/unit_waiting.lua b/coordinator/ui/components/unit_waiting.lua new file mode 100644 index 0000000..3b1a846 --- /dev/null +++ b/coordinator/ui/components/unit_waiting.lua @@ -0,0 +1,33 @@ +-- +-- 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/coordinator/ui/dialog.lua b/coordinator/ui/dialog.lua new file mode 100644 index 0000000..676ae2b --- /dev/null +++ b/coordinator/ui/dialog.lua @@ -0,0 +1,52 @@ +local completion = require("cc.completion") + +local util = require("scada-common.util") + +local print = util.print + +local dialog = {} + +-- ask the user yes or no +---@nodiscard +---@param question string +---@param default boolean +---@return boolean|nil +function dialog.ask_y_n(question, default) + print(question) + + if default == true then + print(" (Y/n)? ") + else + print(" (y/N)? ") + end + + local response = read(nil, nil) + + if response == "" then + return default + elseif response == "Y" or response == "y" then + return true + elseif response == "N" or response == "n" then + return false + else + return nil + end +end + +-- ask the user for an input within a set of options +---@nodiscard +---@param options table +---@param cancel string +---@return boolean|string|nil +function dialog.ask_options(options, cancel) + print("> ") + local response = read(nil, nil, function(text) return completion.choice(text, options) end) + + if response == cancel then return false end + + if util.table_contains(options, response) then + return response + else return nil end +end + +return dialog diff --git a/coordinator/ui/layout/main_view.lua b/coordinator/ui/layout/main_view.lua new file mode 100644 index 0000000..d9a726f --- /dev/null +++ b/coordinator/ui/layout/main_view.lua @@ -0,0 +1,100 @@ +-- +-- Main SCADA Coordinator GUI +-- + +local util = require("scada-common.util") + +local iocontrol = require("coordinator.iocontrol") +local sounder = require("coordinator.sounder") + +local style = require("coordinator.ui.style") + +local imatrix = require("coordinator.ui.components.imatrix") +local process_ctl = require("coordinator.ui.components.processctl") +local unit_overview = require("coordinator.ui.components.unit_overview") + +local core = require("graphics.core") + +local ColorMap = require("graphics.elements.colormap") +local DisplayBox = require("graphics.elements.displaybox") +local Div = require("graphics.elements.div") +local TextBox = require("graphics.elements.textbox") + +local PushButton = require("graphics.elements.controls.push_button") +local SwitchButton = require("graphics.elements.controls.switch_button") + +local DataIndicator = require("graphics.elements.indicators.data") + +local TEXT_ALIGN = core.graphics.TEXT_ALIGN + +local cpair = core.graphics.cpair + +-- create new main view +---@param monitor table main viewscreen +local function init(monitor) + local facility = iocontrol.get_db().facility + local units = iocontrol.get_db().units + + local main = DisplayBox{window=monitor,fg_bg=style.root} + + -- window header message + local header = TextBox{parent=main,y=1,text="Nuclear Generation Facility SCADA Coordinator",alignment=TEXT_ALIGN.CENTER,height=1,fg_bg=style.header} + local ping = DataIndicator{parent=main,x=1,y=1,label="SVTT",format="%d",value=0,unit="ms",lu_colors=cpair(colors.lightGray, colors.white),width=12,fg_bg=style.header} + -- max length example: "01:23:45 AM - Wednesday, September 28 2022" + local datetime = TextBox{parent=main,x=(header.width()-42),y=1,text="",alignment=TEXT_ALIGN.RIGHT,width=42,height=1,fg_bg=style.header} + + facility.ps.subscribe("sv_ping", ping.update) + facility.ps.subscribe("date_time", datetime.set_value) + + local uo_1, uo_2, uo_3, uo_4 ---@type graphics_element + + local cnc_y_start = 3 + local row_1_height = 0 + + -- unit overviews + if facility.num_units >= 1 then + uo_1 = unit_overview(main, 2, 3, units[1]) + row_1_height = uo_1.height() + end + + if facility.num_units >= 2 then + uo_2 = unit_overview(main, 84, 3, units[2]) + row_1_height = math.max(row_1_height, uo_2.height()) + end + + cnc_y_start = cnc_y_start + row_1_height + 1 + + if facility.num_units >= 3 then + -- base offset 3, spacing 1, max height of units 1 and 2 + local row_2_offset = cnc_y_start + + uo_3 = unit_overview(main, 2, row_2_offset, units[3]) + cnc_y_start = row_2_offset + uo_3.height() + 1 + + if facility.num_units == 4 then + uo_4 = unit_overview(main, 84, row_2_offset, units[4]) + cnc_y_start = math.max(cnc_y_start, row_2_offset + uo_4.height() + 1) + end + end + + -- command & control + + cnc_y_start = cnc_y_start + + -- induction matrix and process control interfaces are 24 tall + space needed for divider + local cnc_bottom_align_start = main.height() - 26 + + assert(cnc_bottom_align_start >= cnc_y_start, "main display not of sufficient vertical resolution (add an additional row of monitors)") + + TextBox{parent=main,y=cnc_bottom_align_start,text=util.strrep("\x8c", header.width()),alignment=TEXT_ALIGN.CENTER,height=1,fg_bg=cpair(colors.lightGray,colors.gray)} + + cnc_bottom_align_start = cnc_bottom_align_start + 2 + + process_ctl(main, 2, cnc_bottom_align_start) + + imatrix(main, 131, cnc_bottom_align_start, facility.induction_data_tbl[1], facility.induction_ps_tbl[1]) + + return main +end + +return init diff --git a/coordinator/ui/layout/unit_view.lua b/coordinator/ui/layout/unit_view.lua new file mode 100644 index 0000000..1c5fddf --- /dev/null +++ b/coordinator/ui/layout/unit_view.lua @@ -0,0 +1,22 @@ +-- +-- Reactor Unit SCADA Coordinator GUI +-- + +local style = require("coordinator.ui.style") + +local unit_detail = require("coordinator.ui.components.unit_detail") + +local DisplayBox = require("graphics.elements.displaybox") + +-- create a unit view +---@param monitor table +---@param id integer +local function init(monitor, id) + local main = DisplayBox{window=monitor,fg_bg=style.root} + + unit_detail(main, id) + + return main +end + +return init diff --git a/coordinator/ui/style.lua b/coordinator/ui/style.lua new file mode 100644 index 0000000..74923f2 --- /dev/null +++ b/coordinator/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.black, colors.lightGray) +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/graphics/core.lua b/graphics/core.lua new file mode 100644 index 0000000..98c8ed5 --- /dev/null +++ b/graphics/core.lua @@ -0,0 +1,154 @@ +-- +-- Graphics Core Functions and Objects +-- + +local core = {} + +local flasher = require("graphics.flasher") + +core.flasher = flasher + +local events = {} + +---@class monitor_touch +---@field monitor string +---@field x integer +---@field y integer + +-- create a new touch event definition +---@nodiscard +---@param monitor string +---@param x integer +---@param y integer +---@return monitor_touch +function events.touch(monitor, x, y) + return { + monitor = monitor, + x = x, + y = y + } +end + +core.events = events + +local graphics = {} + +---@enum TEXT_ALIGN +graphics.TEXT_ALIGN = { + LEFT = 1, + CENTER = 2, + RIGHT = 3 +} + +---@class graphics_border +---@field width integer +---@field color color +---@field even boolean + +---@alias element_id string|integer + +-- create a new border definition +---@nodiscard +---@param width integer border width +---@param color color border color +---@param even? boolean whether to pad width extra to account for rectangular pixels, defaults to false +---@return graphics_border +function graphics.border(width, color, even) + return { + width = width, + color = color, + even = even or false -- convert nil to false + } +end + +---@class graphics_frame +---@field x integer +---@field y integer +---@field w integer +---@field h integer + +-- create a new graphics frame definition +---@nodiscard +---@param x integer +---@param y integer +---@param w integer +---@param h integer +---@return graphics_frame +function graphics.gframe(x, y, w, h) + return { + x = x, + y = y, + w = w, + h = h + } +end + +---@class cpair +---@field color_a color +---@field color_b color +---@field blit_a string +---@field blit_b string +---@field fgd color +---@field bkg color +---@field blit_fgd string +---@field blit_bkg string + +-- create a new color pair definition +---@nodiscard +---@param a color +---@param b color +---@return cpair +function graphics.cpair(a, b) + return { + -- color pairs + color_a = a, + color_b = b, + blit_a = colors.toBlit(a), + blit_b = colors.toBlit(b), + -- aliases + fgd = a, + bkg = b, + blit_fgd = colors.toBlit(a), + blit_bkg = colors.toBlit(b) + } +end + +---@class pipe +---@field x1 integer starting x, origin is 0 +---@field y1 integer starting y, origin is 0 +---@field x2 integer ending x, origin is 0 +---@field y2 integer ending y, origin is 0 +---@field w integer width +---@field h integer height +---@field color color pipe color +---@field thin boolean true for 1 subpixel, false (default) for 2 +---@field align_tr boolean false to align bottom left (default), true to align top right + +-- create a new pipe
+-- note: pipe coordinate origin is (0, 0) +---@nodiscard +---@param x1 integer starting x, origin is 0 +---@param y1 integer starting y, origin is 0 +---@param x2 integer ending x, origin is 0 +---@param y2 integer ending y, origin is 0 +---@param color color pipe color +---@param thin? boolean true for 1 subpixel, false (default) for 2 +---@param align_tr? boolean false to align bottom left (default), true to align top right +---@return pipe +function graphics.pipe(x1, y1, x2, y2, color, thin, align_tr) + return { + x1 = x1, + y1 = y1, + x2 = x2, + y2 = y2, + w = math.abs(x2 - x1) + 1, + h = math.abs(y2 - y1) + 1, + color = color, + thin = thin or false, + align_tr = align_tr or false + } +end + +core.graphics = graphics + +return core diff --git a/graphics/element.lua b/graphics/element.lua new file mode 100644 index 0000000..8aa3ce9 --- /dev/null +++ b/graphics/element.lua @@ -0,0 +1,458 @@ +-- +-- Generic Graphics Element +-- + +local core = require("graphics.core") + +local element = {} + +---@class graphics_args_generic +---@field window? table +---@field parent? graphics_element +---@field id? string element id +---@field x? integer 1 if omitted +---@field y? integer next line if omitted +---@field offset_x? integer 0 if omitted +---@field offset_y? integer 0 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 + +---@alias graphics_args graphics_args_generic +---|waiting_args +---|hazard_button_args +---|multi_button_args +---|push_button_args +---|radio_button_args +---|spinbox_args +---|switch_button_args +---|alarm_indicator_light +---|core_map_args +---|data_indicator_args +---|hbar_args +---|icon_indicator_args +---|indicator_light_args +---|power_indicator_args +---|rad_indicator_args +---|state_indicator_args +---|tristate_indicator_light_args +---|vbar_args +---|colormap_args +---|displaybox_args +---|div_args +---|pipenet_args +---|rectangle_args +---|textbox_args +---|tiling_args + +-- a base graphics element, should not be created on its own +---@nodiscard +---@param args graphics_args arguments +function element.new(args) + local self = { + id = -1, + elem_type = debug.getinfo(2).name, + define_completed = false, + p_window = nil, ---@type table + position = { x = 1, y = 1 }, + child_offset = { x = 0, y = 0 }, + bounds = { x1 = 1, y1 = 1, x2 = 1, y2 = 1}, + next_y = 1, + children = {}, + mt = {} + } + + ---@class graphics_template + local protected = { + enabled = true, + value = nil, ---@type any + window = nil, ---@type table + fg_bg = core.graphics.cpair(colors.white, colors.black), + frame = core.graphics.gframe(1, 1, 1, 1) + } + + -- element as string + function self.mt.__tostring() + return "graphics.element{" .. self.elem_type .. "} @ " .. tostring(self) + end + + ---@class graphics_element + local public = {} + + setmetatable(public, self.mt) + + ------------------------- + -- PROTECTED FUNCTIONS -- + ------------------------- + + -- prepare the template + ---@param offset_x integer x offset + ---@param offset_y integer y offset + ---@param next_y integer next line if no y was provided + function protected.prepare_template(offset_x, offset_y, next_y) + -- get frame coordinates/size + if args.gframe ~= nil then + protected.frame.x = args.gframe.x + protected.frame.y = args.gframe.y + protected.frame.w = args.gframe.w + protected.frame.h = args.gframe.h + else + local w, h = self.p_window.getSize() + protected.frame.x = args.x or 1 + protected.frame.y = args.y or next_y + protected.frame.w = args.width or w + protected.frame.h = args.height or h + end + + -- inner offsets + if args.offset_x ~= nil then self.child_offset.x = args.offset_x end + if args.offset_y ~= nil then self.child_offset.y = args.offset_y end + + -- adjust window frame if applicable + local f = protected.frame + local x = f.x + local y = f.y + + -- apply offsets + if args.parent ~= nil then + -- constrain to parent inner width/height + local w, h = self.p_window.getSize() + f.w = math.min(f.w, w - ((2 * offset_x) + (f.x - 1))) + f.h = math.min(f.h, h - ((2 * offset_y) + (f.y - 1))) + + -- offset x/y + f.x = x + offset_x + f.y = y + offset_y + end + + -- check frame + assert(f.x >= 1, "graphics.element{" .. self.elem_type .. "}: frame x not >= 1") + assert(f.y >= 1, "graphics.element{" .. self.elem_type .. "}: frame y not >= 1") + assert(f.w >= 1, "graphics.element{" .. self.elem_type .. "}: frame width not >= 1") + assert(f.h >= 1, "graphics.element{" .. self.elem_type .. "}: frame height not >= 1") + + -- create window + protected.window = window.create(self.p_window, f.x, f.y, f.w, f.h, true) + + -- init colors + if args.fg_bg ~= nil then + protected.fg_bg = args.fg_bg + elseif args.parent ~= nil then + protected.fg_bg = args.parent.get_fg_bg() + end + + -- set colors + protected.window.setBackgroundColor(protected.fg_bg.bkg) + protected.window.setTextColor(protected.fg_bg.fgd) + protected.window.clear() + + -- record position + self.position.x, self.position.y = protected.window.getPosition() + + -- calculate bounds + self.bounds.x1 = self.position.x + self.bounds.x2 = self.position.x + f.w - 1 + self.bounds.y1 = self.position.y + self.bounds.y2 = self.position.y + f.h - 1 + end + + -- handle a touch event + ---@param event table monitor_touch event + function protected.handle_touch(event) + end + + -- handle data value changes + ---@vararg any value(s) + function protected.on_update(...) + end + + -- callback on control press responses + ---@param result any + function protected.response_callback(result) + end + + -- get value + ---@nodiscard + function protected.get_value() + return protected.value + end + + -- set value + ---@param value any value to set + function protected.set_value(value) + end + + -- set minimum input value + ---@param min integer minimum allowed value + function protected.set_min(min) + end + + -- set maximum input value + ---@param max integer maximum allowed value + function protected.set_max(max) + end + + -- enable the control + function protected.enable() + end + + -- disable the control + function protected.disable() + end + + -- custom recolor command, varies by element if implemented + ---@vararg cpair|color color(s) + function protected.recolor(...) + end + + -- custom resize command, varies by element if implemented + ---@vararg integer sizing + function protected.resize(...) + end + + -- start animations + function protected.start_anim() + end + + -- stop animations + function protected.stop_anim() + end + + -- get public interface + ---@nodiscard + ---@return graphics_element element, element_id id + function protected.get() return public, self.id end + + ----------- + -- SETUP -- + ----------- + + -- get the parent window + self.p_window = args.window + if self.p_window == nil and args.parent ~= nil then + self.p_window = args.parent.window() + end + + -- check window + assert(self.p_window, "graphics.element{" .. self.elem_type .. "}: no parent window provided") + + -- prepare the template + if args.parent == nil then + protected.prepare_template(0, 0, 1) + else + self.id = args.parent.__add_child(args.id, protected) + end + + ---------------------- + -- PUBLIC FUNCTIONS -- + ---------------------- + + -- get the window object + ---@nodiscard + function public.window() return protected.window end + + -- CHILD ELEMENTS -- + + -- add a child element + ---@nodiscard + ---@param key string|nil id + ---@param child graphics_template + ---@return integer|string key + function public.__add_child(key, child) + child.prepare_template(self.child_offset.x, self.child_offset.y, self.next_y) + + self.next_y = child.frame.y + child.frame.h + + local child_element = child.get() + + if key == nil then + table.insert(self.children, child_element) + return #self.children + else + self.children[key] = child_element + return key + end + end + + -- get a child element + ---@nodiscard + ---@return graphics_element + function public.get_child(key) return self.children[key] end + + -- remove child + ---@param key string|integer + function public.remove(key) self.children[key] = nil end + + -- attempt to get a child element by ID (does not include this element itself) + ---@nodiscard + ---@param id element_id + ---@return graphics_element|nil element + function public.get_element_by_id(id) + if self.children[id] == nil then + for _, child in pairs(self.children) do + local elem = child.get_element_by_id(id) + if elem ~= nil then return elem end + end + else + return self.children[id] + end + + return nil + end + + -- AUTO-PLACEMENT -- + + -- skip a line for automatically placed elements + function public.line_break() + self.next_y = self.next_y + 1 + end + + -- PROPERTIES -- + + -- get the foreground/background colors + ---@nodiscard + ---@return cpair fg_bg + function public.get_fg_bg() + return protected.fg_bg + end + + -- get element x + ---@nodiscard + ---@return integer x + function public.get_x() + return protected.frame.x + end + + -- get element y + ---@nodiscard + ---@return integer y + function public.get_y() + return protected.frame.y + end + + -- get element width + ---@nodiscard + ---@return integer width + function public.width() + return protected.frame.w + end + + -- get element height + ---@nodiscard + ---@return integer height + function public.height() + return protected.frame.h + end + + -- get the element value + ---@nodiscard + ---@return any value + function public.get_value() + return protected.get_value() + end + + -- set the element value + ---@param value any new value + function public.set_value(value) + protected.set_value(value) + end + + -- set minimum input value + ---@param min integer minimum allowed value + function public.set_min(min) + protected.set_min(min) + end + + -- set maximum input value + ---@param max integer maximum allowed value + function public.set_max(max) + protected.set_max(max) + end + + -- enable the element + function public.enable() + protected.enabled = true + protected.enable() + end + + -- disable the element + function public.disable() + protected.enabled = false + protected.disable() + end + + -- custom recolor command, varies by element if implemented + ---@vararg cpair|color color(s) + function public.recolor(...) + protected.recolor(...) + end + + -- resize attributes of the element value if supported + ---@vararg number dimensions (element specific) + function public.resize(...) + protected.resize(...) + end + + -- FUNCTION CALLBACKS -- + + -- handle a monitor touch + ---@param event monitor_touch monitor touch event + function public.handle_touch(event) + local in_x = event.x >= self.bounds.x1 and event.x <= self.bounds.x2 + local in_y = event.y >= self.bounds.y1 and event.y <= self.bounds.y2 + + if in_x and in_y then + local event_T = core.events.touch(event.monitor, (event.x - self.position.x) + 1, (event.y - self.position.y) + 1) + + -- handle the touch event, transformed into the window frame + protected.handle_touch(event_T) + + -- pass on touch event to children + for _, val in pairs(self.children) do val.handle_touch(event_T) end + end + end + + -- draw the element given new data + ---@vararg any new data + function public.update(...) + protected.on_update(...) + end + + -- on a control request response + ---@param result any + function public.on_response(result) + protected.response_callback(result) + end + + -- VISIBILITY -- + + -- show the element + function public.show() + protected.window.setVisible(true) + protected.start_anim() + + for i = 1, #self.children do + self.children[i].show() + end + end + + -- hide the element + function public.hide() + protected.stop_anim() + for i = 1, #self.children do + self.children[i].hide() + end + + protected.window.setVisible(false) + end + + -- re-draw the element + function public.redraw() + protected.window.redraw() + end + + return protected +end + +return element diff --git a/graphics/elements/animations/waiting.lua b/graphics/elements/animations/waiting.lua new file mode 100644 index 0000000..2b08092 --- /dev/null +++ b/graphics/elements/animations/waiting.lua @@ -0,0 +1,108 @@ +-- Loading/Waiting Animation Graphics Element + +local tcd = require("scada-common.tcallbackdsp") + +local element = require("graphics.element") + +---@class waiting_args +---@field parent graphics_element +---@field id? string element id +---@field x? integer 1 if omitted +---@field y? integer 1 if omitted +---@field fg_bg? cpair foreground/background colors + +-- new waiting animation element +---@param args waiting_args +---@return graphics_element element, element_id id +local function waiting(args) + local state = 0 + local run_animation = false + + args.width = 4 + args.height = 3 + + -- create new graphics element base object + local e = element.new(args) + + local blit_fg = e.fg_bg.blit_fgd + local blit_bg = e.fg_bg.blit_bkg + local blit_fg_2x = e.fg_bg.blit_fgd .. e.fg_bg.blit_fgd + local blit_bg_2x = e.fg_bg.blit_bkg .. e.fg_bg.blit_bkg + + -- tick the animation + local function animate() + e.window.clear() + + if state >= 0 and state < 7 then + -- top + e.window.setCursorPos(1 + math.floor(state / 2), 1) + if state % 2 == 0 then + e.window.blit("\x8f", blit_fg, blit_bg) + else + e.window.blit("\x8a\x85", blit_fg_2x, blit_bg_2x) + end + + -- bottom + e.window.setCursorPos(4 - math.ceil(state / 2), 3) + if state % 2 == 0 then + e.window.blit("\x8f", blit_fg, blit_bg) + else + e.window.blit("\x8a\x85", blit_fg_2x, blit_bg_2x) + end + else + local st = state - 7 + + -- right + if st % 3 == 0 then + e.window.setCursorPos(4, 1 + math.floor(st / 3)) + e.window.blit("\x83", blit_bg, blit_fg) + elseif st % 3 == 1 then + e.window.setCursorPos(4, 1 + math.floor(st / 3)) + e.window.blit("\x8f", blit_bg, blit_fg) + e.window.setCursorPos(4, 2 + math.floor(st / 3)) + e.window.blit("\x83", blit_fg, blit_bg) + else + e.window.setCursorPos(4, 2 + math.floor(st / 3)) + e.window.blit("\x8f", blit_fg, blit_bg) + end + + -- left + if st % 3 == 0 then + e.window.setCursorPos(1, 3 - math.floor(st / 3)) + e.window.blit("\x83", blit_fg, blit_bg) + e.window.setCursorPos(1, 2 - math.floor(st / 3)) + e.window.blit("\x8f", blit_bg, blit_fg) + elseif st % 3 == 1 then + e.window.setCursorPos(1, 2 - math.floor(st / 3)) + e.window.blit("\x83", blit_bg, blit_fg) + else + e.window.setCursorPos(1, 2 - math.floor(st / 3)) + e.window.blit("\x8f", blit_fg, blit_bg) + end + end + + state = state + 1 + if state >= 12 then state = 0 end + + if run_animation then + tcd.dispatch_unique(0.5, animate) + end + end + + -- start the animation + function e.start_anim() + run_animation = true + animate() + end + + -- stop the animation + function e.stop_anim() + run_animation = false + end + + e.start_anim() + + return e.get() +end + +return waiting diff --git a/graphics/elements/colormap.lua b/graphics/elements/colormap.lua new file mode 100644 index 0000000..4c7ba94 --- /dev/null +++ b/graphics/elements/colormap.lua @@ -0,0 +1,33 @@ +-- Color Map Graphics Element + +local util = require("scada-common.util") + +local element = require("graphics.element") + +---@class colormap_args +---@field parent graphics_element +---@field id? string element id +---@field x? integer 1 if omitted +---@field y? integer 1 if omitted + +-- new color map +---@param args colormap_args +---@return graphics_element element, element_id id +local function colormap(args) + local bkg = "008877FFCCEE114455DD9933BBAA2266" + local spaces = util.spaces(32) + + args.width = 32 + args.height = 1 + + -- create new graphics element base object + local e = element.new(args) + + -- draw color map + e.window.setCursorPos(1, 1) + e.window.blit(spaces, bkg, bkg) + + return e.get() +end + +return colormap diff --git a/graphics/elements/controls/hazard_button.lua b/graphics/elements/controls/hazard_button.lua new file mode 100644 index 0000000..0b59df6 --- /dev/null +++ b/graphics/elements/controls/hazard_button.lua @@ -0,0 +1,208 @@ +-- Hazard-bordered Button Graphics Element + +local tcd = require("scada-common.tcallbackdsp") +local util = require("scada-common.util") + +local core = require("graphics.core") +local element = require("graphics.element") + +---@class hazard_button_args +---@field text string text to show on button +---@field accent color accent color for hazard border +---@field dis_colors? cpair text color and border color when disabled +---@field callback function function to call on touch +---@field parent graphics_element +---@field id? string element id +---@field x? integer 1 if omitted +---@field y? integer 1 if omitted +---@field fg_bg? cpair foreground/background colors + +-- new hazard button +---@param args hazard_button_args +---@return graphics_element element, element_id id +local function hazard_button(args) + assert(type(args.text) == "string", "graphics.elements.controls.hazard_button: text is a required field") + assert(type(args.accent) == "number", "graphics.elements.controls.hazard_button: accent is a required field") + assert(type(args.callback) == "function", "graphics.elements.controls.hazard_button: callback is a required field") + + -- static dimensions + args.height = 3 + args.width = string.len(args.text) + 4 + + -- create new graphics element base object + local e = element.new(args) + + -- write the button text + e.window.setCursorPos(3, 2) + e.window.write(args.text) + + -- draw border + ---@param accent color accent color + local function draw_border(accent) + -- top + e.window.setTextColor(accent) + e.window.setBackgroundColor(args.fg_bg.bkg) + e.window.setCursorPos(1, 1) + e.window.write("\x99" .. util.strrep("\x89", args.width - 2) .. "\x99") + + -- center left + e.window.setCursorPos(1, 2) + e.window.setTextColor(args.fg_bg.bkg) + e.window.setBackgroundColor(accent) + e.window.write("\x99") + + -- center right + e.window.setTextColor(args.fg_bg.bkg) + e.window.setBackgroundColor(accent) + e.window.setCursorPos(args.width, 2) + e.window.write("\x99") + + -- bottom + e.window.setTextColor(accent) + e.window.setBackgroundColor(args.fg_bg.bkg) + e.window.setCursorPos(1, 3) + e.window.write("\x99" .. util.strrep("\x98", args.width - 2) .. "\x99") + end + + -- on request timeout: recursively calls itself to double flash button text + ---@param n integer call count + local function on_timeout(n) + -- start at 0 + if n == nil then n = 0 end + + if n == 0 then + -- go back off + e.window.setTextColor(args.fg_bg.fgd) + e.window.setCursorPos(3, 2) + e.window.write(args.text) + end + + if n >= 4 then + -- done + elseif n % 2 == 0 then + -- toggle text color on after 0.25 seconds + tcd.dispatch(0.25, function () + e.window.setTextColor(args.accent) + e.window.setCursorPos(3, 2) + e.window.write(args.text) + on_timeout(n + 1) + on_timeout(n + 1) + end) + elseif n % 1 then + -- toggle text color off after 0.25 seconds + tcd.dispatch(0.25, function () + e.window.setTextColor(args.fg_bg.fgd) + e.window.setCursorPos(3, 2) + e.window.write(args.text) + on_timeout(n + 1) + end) + end + end + + -- blink routine for success indication + local function on_success() + e.window.setTextColor(args.fg_bg.fgd) + e.window.setCursorPos(3, 2) + e.window.write(args.text) + end + + -- blink routine for failure indication + ---@param n integer call count + local function on_failure(n) + -- start at 0 + if n == nil then n = 0 end + + if n == 0 then + -- go back off + e.window.setTextColor(args.fg_bg.fgd) + e.window.setCursorPos(3, 2) + e.window.write(args.text) + end + + if n >= 2 then + -- done + elseif n % 2 == 0 then + -- toggle text color on after 0.5 seconds + tcd.dispatch(0.5, function () + e.window.setTextColor(args.accent) + e.window.setCursorPos(3, 2) + e.window.write(args.text) + on_failure(n + 1) + end) + elseif n % 1 then + -- toggle text color off after 0.25 seconds + tcd.dispatch(0.25, function () + e.window.setTextColor(args.fg_bg.fgd) + e.window.setCursorPos(3, 2) + e.window.write(args.text) + on_failure(n + 1) + end) + end + end + + -- handle touch + ---@param event monitor_touch monitor touch event +---@diagnostic disable-next-line: unused-local + function e.handle_touch(event) + if e.enabled then + -- change text color to indicate clicked + e.window.setTextColor(args.accent) + e.window.setCursorPos(3, 2) + e.window.write(args.text) + + -- abort any other callbacks + tcd.abort(on_timeout) + tcd.abort(on_success) + tcd.abort(on_failure) + + -- 1.5 second timeout + tcd.dispatch(1.5, on_timeout) + + -- call the touch callback + args.callback() + end + end + + -- callback on request response + ---@param result boolean true for success, false for failure + function e.response_callback(result) + tcd.abort(on_timeout) + + if result then + on_success() + else + on_failure(0) + end + end + + -- set the value (true simulates pressing the button) + ---@param val boolean new value + function e.set_value(val) + if val then e.handle_touch(core.events.touch("", 1, 1)) end + end + + -- show the button as disabled + function e.disable() + if args.dis_colors then + draw_border(args.dis_colors.color_a) + e.window.setTextColor(args.dis_colors.color_b) + e.window.setCursorPos(3, 2) + e.window.write(args.text) + end + end + + -- show the button as enabled + function e.enable() + draw_border(args.accent) + e.window.setTextColor(args.fg_bg.fgd) + e.window.setCursorPos(3, 2) + e.window.write(args.text) + end + + -- initial draw of border + draw_border(args.accent) + + return e.get() +end + +return hazard_button diff --git a/graphics/elements/controls/multi_button.lua b/graphics/elements/controls/multi_button.lua new file mode 100644 index 0000000..2cf583a --- /dev/null +++ b/graphics/elements/controls/multi_button.lua @@ -0,0 +1,125 @@ +-- Multi Button Graphics Element + +local util = require("scada-common.util") + +local element = require("graphics.element") + +---@class button_option +---@field text string +---@field fg_bg cpair +---@field active_fg_bg cpair +---@field _lpad integer automatically calculated left pad +---@field _start_x integer starting touch x range (inclusive) +---@field _end_x integer ending touch x range (inclusive) + +---@class multi_button_args +---@field options table button options +---@field callback function function to call on touch +---@field default? integer default state, defaults to options[1] +---@field min_width? integer text length + 2 if omitted +---@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 multi button (latch selection, exclusively one button at a time) +---@param args multi_button_args +---@return graphics_element element, element_id id +local function multi_button(args) + assert(type(args.options) == "table", "graphics.elements.controls.multi_button: options is a required field") + assert(#args.options > 0, "graphics.elements.controls.multi_button: at least one option is required") + assert(type(args.callback) == "function", "graphics.elements.controls.multi_button: callback is a required field") + assert(type(args.default) == "nil" or (type(args.default) == "number" and args.default > 0), + "graphics.elements.controls.multi_button: default must be nil or a number > 0") + assert(type(args.min_width) == "nil" or (type(args.min_width) == "number" and args.min_width > 0), + "graphics.elements.controls.multi_button: min_width must be nil or a number > 0") + + -- single line + args.height = 1 + + -- determine widths + local max_width = 1 + for i = 1, #args.options do + local opt = args.options[i] ---@type button_option + if string.len(opt.text) > max_width then + max_width = string.len(opt.text) + end + end + + local button_width = math.max(max_width, args.min_width or 0) + + args.width = (button_width * #args.options) + #args.options + 1 + + -- create new graphics element base object + local e = element.new(args) + + -- button state (convert nil to 1 if missing) + e.value = args.default or 1 + + -- calculate required button information + local next_x = 2 + for i = 1, #args.options do + local opt = args.options[i] ---@type button_option + local w = string.len(opt.text) + + opt._lpad = math.floor((e.frame.w - w) / 2) + opt._start_x = next_x + opt._end_x = next_x + button_width - 1 + + next_x = next_x + (button_width + 1) + end + + -- show the button state + local function draw() + for i = 1, #args.options do + local opt = args.options[i] ---@type button_option + + e.window.setCursorPos(opt._start_x, 1) + + if e.value == i then + -- show as pressed + e.window.setTextColor(opt.active_fg_bg.fgd) + e.window.setBackgroundColor(opt.active_fg_bg.bkg) + else + -- show as unpressed + e.window.setTextColor(opt.fg_bg.fgd) + e.window.setBackgroundColor(opt.fg_bg.bkg) + end + + e.window.write(util.pad(opt.text, button_width)) + end + end + + -- handle touch + ---@param event monitor_touch monitor touch event + function e.handle_touch(event) + -- determine what was pressed + if e.enabled and event.y == 1 then + for i = 1, #args.options do + local opt = args.options[i] ---@type button_option + + if event.x >= opt._start_x and event.x <= opt._end_x then + e.value = i + draw() + args.callback(e.value) + end + end + end + end + + -- set the value + ---@param val integer new value + function e.set_value(val) + e.value = val + draw() + end + + -- initial draw + draw() + + return e.get() +end + +return multi_button diff --git a/graphics/elements/controls/push_button.lua b/graphics/elements/controls/push_button.lua new file mode 100644 index 0000000..8cb89c9 --- /dev/null +++ b/graphics/elements/controls/push_button.lua @@ -0,0 +1,110 @@ +-- Button Graphics Element + +local tcd = require("scada-common.tcallbackdsp") + +local core = require("graphics.core") +local element = require("graphics.element") + +---@class push_button_args +---@field text string button text +---@field callback function function to call on touch +---@field min_width? integer text length + 2 if omitted +---@field active_fg_bg? cpair foreground/background colors when pressed +---@field dis_fg_bg? cpair foreground/background colors when disabled +---@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 push button +---@param args push_button_args +---@return graphics_element element, element_id id +local function push_button(args) + assert(type(args.text) == "string", "graphics.elements.controls.push_button: text is a required field") + assert(type(args.callback) == "function", "graphics.elements.controls.push_button: callback is a required field") + + local text_width = string.len(args.text) + + -- single line height, calculate width + args.height = 1 + args.min_width = args.min_width or 0 + args.width = math.max(text_width, args.min_width) + + -- create new graphics element base object + local e = element.new(args) + + local h_pad = math.floor((e.frame.w - text_width) / 2) + 1 + local v_pad = math.floor(e.frame.h / 2) + 1 + + -- draw the button + local function draw() + e.window.clear() + + -- write the button text + e.window.setCursorPos(h_pad, v_pad) + e.window.write(args.text) + end + + -- handle touch + ---@param event monitor_touch monitor touch event +---@diagnostic disable-next-line: unused-local + function e.handle_touch(event) + if e.enabled then + if args.active_fg_bg ~= nil then + -- show as pressed + e.value = true + e.window.setTextColor(args.active_fg_bg.fgd) + e.window.setBackgroundColor(args.active_fg_bg.bkg) + draw() + + -- show as unpressed in 0.25 seconds + tcd.dispatch(0.25, function () + e.value = false + if e.enabled then + e.window.setTextColor(e.fg_bg.fgd) + e.window.setBackgroundColor(e.fg_bg.bkg) + end + draw() + end) + end + + -- call the touch callback + args.callback() + end + end + + -- set the value (true simulates pressing the button) + ---@param val boolean new value + function e.set_value(val) + if val then e.handle_touch(core.events.touch("", 1, 1)) end + end + + -- show butten as enabled + function e.enable() + if args.dis_fg_bg ~= nil then + e.value = false + e.window.setTextColor(e.fg_bg.fgd) + e.window.setBackgroundColor(e.fg_bg.bkg) + draw() + end + end + + -- show button as disabled + function e.disable() + if args.dis_fg_bg ~= nil then + e.value = false + e.window.setTextColor(args.dis_fg_bg.fgd) + e.window.setBackgroundColor(args.dis_fg_bg.bkg) + draw() + end + end + + -- initial draw + draw() + + return e.get() +end + +return push_button diff --git a/graphics/elements/controls/radio_button.lua b/graphics/elements/controls/radio_button.lua new file mode 100644 index 0000000..025cad1 --- /dev/null +++ b/graphics/elements/controls/radio_button.lua @@ -0,0 +1,108 @@ +-- Radio Button Graphics Element + +local element = require("graphics.element") + +---@class radio_button_args +---@field options table button options +---@field callback function function to call on touch +---@field radio_colors cpair colors for radio button center dot when active (a) or inactive (b) +---@field radio_bg color background color of radio button +---@field default? integer default state, defaults to options[1] +---@field min_width? integer text length + 2 if omitted +---@field parent graphics_element +---@field id? string element id +---@field x? integer 1 if omitted +---@field y? integer 1 if omitted +---@field fg_bg? cpair foreground/background colors + +-- new radio button list (latch selection, exclusively one button at a time) +---@param args radio_button_args +---@return graphics_element element, element_id id +local function radio_button(args) + assert(type(args.options) == "table", "graphics.elements.controls.radio_button: options is a required field") + assert(#args.options > 0, "graphics.elements.controls.radio_button: at least one option is required") + assert(type(args.callback) == "function", "graphics.elements.controls.radio_button: callback is a required field") + assert(type(args.default) == "nil" or (type(args.default) == "number" and args.default > 0), + "graphics.elements.controls.radio_button: default must be nil or a number > 0") + assert(type(args.min_width) == "nil" or (type(args.min_width) == "number" and args.min_width > 0), + "graphics.elements.controls.radio_button: min_width must be nil or a number > 0") + + -- one line per option + args.height = #args.options + + -- determine widths + local max_width = 1 + for i = 1, #args.options do + local opt = args.options[i] ---@type string + if string.len(opt) > max_width then + max_width = string.len(opt) + end + end + + local button_text_width = math.max(max_width, args.min_width or 0) + + args.width = button_text_width + 2 + + -- create new graphics element base object + local e = element.new(args) + + -- button state (convert nil to 1 if missing) + e.value = args.default or 1 + + -- show the button state + local function draw() + for i = 1, #args.options do + local opt = args.options[i] ---@type string + + e.window.setCursorPos(1, i) + + if e.value == i then + -- show as selected + e.window.setTextColor(args.radio_colors.color_a) + e.window.setBackgroundColor(args.radio_bg) + else + -- show as unselected + e.window.setTextColor(args.radio_colors.color_b) + e.window.setBackgroundColor(args.radio_bg) + end + + e.window.write("\x88") + + e.window.setTextColor(args.radio_bg) + e.window.setBackgroundColor(e.fg_bg.bkg) + e.window.write("\x95") + + -- write button text + e.window.setTextColor(e.fg_bg.fgd) + e.window.setBackgroundColor(e.fg_bg.bkg) + e.window.write(opt) + end + end + + -- handle touch + ---@param event monitor_touch monitor touch event + function e.handle_touch(event) + -- determine what was pressed + if e.enabled then + if args.options[event.y] ~= nil then + e.value = event.y + draw() + args.callback(e.value) + end + end + end + + -- set the value + ---@param val integer new value + function e.set_value(val) + e.value = val + draw() + end + + -- initial draw + draw() + + return e.get() +end + +return radio_button diff --git a/graphics/elements/controls/spinbox_numeric.lua b/graphics/elements/controls/spinbox_numeric.lua new file mode 100644 index 0000000..088d847 --- /dev/null +++ b/graphics/elements/controls/spinbox_numeric.lua @@ -0,0 +1,191 @@ +-- Spinbox Numeric Graphics Element + +local util = require("scada-common.util") + +local element = require("graphics.element") + +---@class spinbox_args +---@field default? number default value, defaults to 0.0 +---@field min? number default 0, currently must be 0 or greater +---@field max? number default max number that can be displayed with the digits configuration +---@field whole_num_precision integer number of whole number digits +---@field fractional_precision integer number of fractional digits +---@field arrow_fg_bg cpair arrow foreground/background colors +---@field arrow_disable? color color when disabled (default light gray) +---@field parent graphics_element +---@field id? string element id +---@field x? integer 1 if omitted +---@field y? integer 1 if omitted +---@field fg_bg? cpair foreground/background colors + +-- new spinbox control (minimum value is 0) +---@param args spinbox_args +---@return graphics_element element, element_id id +local function spinbox(args) + -- properties + local digits = {} + local wn_prec = args.whole_num_precision + local fr_prec = args.fractional_precision + + assert(util.is_int(wn_prec), "graphics.element.controls.spinbox_numeric: whole number precision must be an integer") + assert(util.is_int(fr_prec), "graphics.element.controls.spinbox_numeric: fractional precision must be an integer") + + local fmt = "" + local fmt_init = "" + + if fr_prec > 0 then + fmt = "%" .. (wn_prec + fr_prec + 1) .. "." .. fr_prec .. "f" + fmt_init = "%0" .. (wn_prec + fr_prec + 1) .. "." .. fr_prec .. "f" + else + fmt = "%" .. wn_prec .. "d" + fmt_init = "%0" .. wn_prec .. "d" + end + + local dec_point_x = args.whole_num_precision + 1 + + assert(type(args.arrow_fg_bg) == "table", "graphics.element.spinbox_numeric: arrow_fg_bg is a required field") + + -- determine widths + args.width = wn_prec + fr_prec + util.trinary(fr_prec > 0, 1, 0) + args.height = 3 + + -- create new graphics element base object + local e = element.new(args) + + -- set initial value + e.value = args.default or 0 + + -- draw the arrows + local function draw_arrows(color) + e.window.setBackgroundColor(args.arrow_fg_bg.bkg) + e.window.setTextColor(color) + e.window.setCursorPos(1, 1) + e.window.write(util.strrep("\x1e", wn_prec)) + e.window.setCursorPos(1, 3) + e.window.write(util.strrep("\x1f", wn_prec)) + if fr_prec > 0 then + e.window.setCursorPos(1 + wn_prec, 1) + e.window.write(" " .. util.strrep("\x1e", fr_prec)) + e.window.setCursorPos(1 + wn_prec, 3) + e.window.write(" " .. util.strrep("\x1f", fr_prec)) + end + end + + draw_arrows(args.arrow_fg_bg.fgd) + + -- populate digits from current value + local function set_digits() + local initial_str = util.sprintf(fmt_init, e.value) + + digits = {} +---@diagnostic disable-next-line: discard-returns + initial_str:gsub("%d", function (char) table.insert(digits, char) end) + end + + -- update the value per digits table + local function update_value() + e.value = 0 + for i = 1, #digits do + local pow = math.abs(wn_prec - i) + if i <= wn_prec then + e.value = e.value + (digits[i] * (10 ^ pow)) + else + e.value = e.value + (digits[i] * (10 ^ -pow)) + end + end + end + + -- print out the current value + local function show_num() + -- enforce limits + if (type(args.min) == "number") and (e.value < args.min) then + e.value = args.min + set_digits() + elseif e.value < 0 then + e.value = 0 + set_digits() + else + if string.len(util.sprintf(fmt, e.value)) > args.width then + -- max printable exceeded, so max out to all 9s + for i = 1, #digits do digits[i] = 9 end + update_value() + elseif (type(args.max) == "number") and (e.value > args.max) then + e.value = args.max + set_digits() + else + set_digits() + end + end + + -- draw + e.window.setBackgroundColor(e.fg_bg.bkg) + e.window.setTextColor(e.fg_bg.fgd) + e.window.setCursorPos(1, 2) + e.window.write(util.sprintf(fmt, e.value)) + end + + -- init with the default value + show_num() + + -- handle touch + ---@param event monitor_touch monitor touch event + function e.handle_touch(event) + -- only handle if on an increment or decrement arrow + if e.enabled and event.x ~= dec_point_x then + local idx = util.trinary(event.x > dec_point_x, event.x - 1, event.x) + if digits[idx] ~= nil then + if event.y == 1 then + -- increment + digits[idx] = digits[idx] + 1 + elseif event.y == 3 then + -- decrement + digits[idx] = digits[idx] - 1 + end + + update_value() + show_num() + end + end + end + + -- set the value + ---@param val number number to show + function e.set_value(val) + e.value = val + show_num() + end + + -- set minimum input value + ---@param min integer minimum allowed value + function e.set_min(min) + if min >= 0 then + args.min = min + show_num() + end + end + + -- set maximum input value + ---@param max integer maximum allowed value + function e.set_max(max) + args.max = max + show_num() + end + + -- enable this input + function e.enable() + draw_arrows(args.arrow_fg_bg.fgd) + end + + -- disable this input + function e.disable() + draw_arrows(args.arrow_disable or colors.lightGray) + end + + -- default to zero, init digits table + e.value = 0 + set_digits() + + return e.get() +end + +return spinbox diff --git a/graphics/elements/controls/switch_button.lua b/graphics/elements/controls/switch_button.lua new file mode 100644 index 0000000..bf138f2 --- /dev/null +++ b/graphics/elements/controls/switch_button.lua @@ -0,0 +1,90 @@ +-- Button Graphics Element + +local element = require("graphics.element") + +---@class switch_button_args +---@field text string button text +---@field callback function function to call on touch +---@field default? boolean default state, defaults to off (false) +---@field min_width? integer text length + 2 if omitted +---@field active_fg_bg cpair foreground/background colors when pressed +---@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 switch button (latch high/low) +---@param args switch_button_args +---@return graphics_element element, element_id id +local function switch_button(args) + assert(type(args.text) == "string", "graphics.elements.controls.switch_button: text is a required field") + assert(type(args.callback) == "function", "graphics.elements.controls.switch_button: callback is a required field") + assert(type(args.active_fg_bg) == "table", "graphics.elements.controls.switch_button: active_fg_bg is a required field") + + -- single line + args.height = 1 + + -- determine widths + local text_width = string.len(args.text) + args.width = math.max(text_width + 2, args.min_width) + + -- create new graphics element base object + local e = element.new(args) + + -- button state (convert nil to false if missing) + e.value = args.default or false + + local h_pad = math.floor((e.frame.w - text_width) / 2) + 1 + local v_pad = math.floor(e.frame.h / 2) + 1 + + -- show the button state + local function draw_state() + if e.value then + -- show as pressed + e.window.setTextColor(args.active_fg_bg.fgd) + e.window.setBackgroundColor(args.active_fg_bg.bkg) + else + -- show as unpressed + e.window.setTextColor(e.fg_bg.fgd) + e.window.setBackgroundColor(e.fg_bg.bkg) + end + + -- clear to redraw background + e.window.clear() + + -- write the button text + e.window.setCursorPos(h_pad, v_pad) + e.window.write(args.text) + end + + -- initial draw + draw_state() + + -- handle touch + ---@param event monitor_touch monitor touch event +---@diagnostic disable-next-line: unused-local + function e.handle_touch(event) + if e.enabled then + -- toggle state + e.value = not e.value + draw_state() + + -- call the touch callback with state + args.callback(e.value) + end + end + + -- set the value + ---@param val boolean new value + function e.set_value(val) + -- set state + e.value = val + draw_state() + end + + return e.get() +end + +return switch_button diff --git a/graphics/elements/displaybox.lua b/graphics/elements/displaybox.lua new file mode 100644 index 0000000..c7e5c9f --- /dev/null +++ b/graphics/elements/displaybox.lua @@ -0,0 +1,22 @@ +-- Root Display Box Graphics Element + +local element = require("graphics.element") + +---@class displaybox_args +---@field window table +---@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 root display box +---@nodiscard +---@param args displaybox_args +local function displaybox(args) + -- create new graphics element base object + return element.new(args).get() +end + +return displaybox diff --git a/graphics/elements/div.lua b/graphics/elements/div.lua new file mode 100644 index 0000000..5eeef71 --- /dev/null +++ b/graphics/elements/div.lua @@ -0,0 +1,24 @@ +-- Div (Division, like in HTML) Graphics Element + +local element = require("graphics.element") + +---@class div_args +---@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 div element +---@nodiscard +---@param args div_args +---@return graphics_element element, element_id id +local function div(args) + -- create new graphics element base object + return element.new(args).get() +end + +return div diff --git a/graphics/elements/indicators/alight.lua b/graphics/elements/indicators/alight.lua new file mode 100644 index 0000000..8bb8fa6 --- /dev/null +++ b/graphics/elements/indicators/alight.lua @@ -0,0 +1,114 @@ +-- Tri-State Alarm Indicator Light Graphics Element + +local util = require("scada-common.util") + +local element = require("graphics.element") +local flasher = require("graphics.flasher") + +---@class alarm_indicator_light +---@field label string indicator label +---@field c1 color color for off state +---@field c2 color color for alarm state +---@field c3 color color for ring-back state +---@field min_label_width? integer label length if omitted +---@field flash? boolean whether to flash on alarm state rather than stay on +---@field period? PERIOD flash period +---@field parent graphics_element +---@field id? string element id +---@field x? integer 1 if omitted +---@field y? integer 1 if omitted +---@field fg_bg? cpair foreground/background colors + +-- new alarm indicator light +---@nodiscard +---@param args alarm_indicator_light +---@return graphics_element element, element_id id +local function alarm_indicator_light(args) + assert(type(args.label) == "string", "graphics.elements.indicators.alight: label is a required field") + assert(type(args.c1) == "number", "graphics.elements.indicators.alight: c1 is a required field") + assert(type(args.c2) == "number", "graphics.elements.indicators.alight: c2 is a required field") + assert(type(args.c3) == "number", "graphics.elements.indicators.alight: c3 is a required field") + + if args.flash then + assert(util.is_int(args.period), "graphics.elements.indicators.alight: period is a required field if flash is enabled") + end + + -- single line + args.height = 1 + + -- determine width + args.width = math.max(args.min_label_width or 1, string.len(args.label)) + 2 + + -- flasher state + local flash_on = true + + -- blit translations + local c1 = colors.toBlit(args.c1) + local c2 = colors.toBlit(args.c2) + local c3 = colors.toBlit(args.c3) + + -- create new graphics element base object + local e = element.new(args) + + -- called by flasher when enabled + local function flash_callback() + e.window.setCursorPos(1, 1) + + if flash_on then + if e.value == 2 then + e.window.blit(" \x95", "0" .. c2, c2 .. e.fg_bg.blit_bkg) + end + else + if e.value == 3 then + e.window.blit(" \x95", "0" .. c3, c3 .. e.fg_bg.blit_bkg) + else + e.window.blit(" \x95", "0" .. c1, c1 .. e.fg_bg.blit_bkg) + end + end + + flash_on = not flash_on + end + + -- on state change + ---@param new_state integer indicator state + function e.on_update(new_state) + local was_off = e.value ~= 2 + + e.value = new_state + e.window.setCursorPos(1, 1) + + if args.flash then + if was_off and (new_state == 2) then + flash_on = true + flasher.start(flash_callback, args.period) + elseif new_state ~= 2 then + flash_on = false + flasher.stop(flash_callback) + + if new_state == 3 then + e.window.blit(" \x95", "0" .. c3, c3 .. e.fg_bg.blit_bkg) + else + e.window.blit(" \x95", "0" .. c1, c1 .. e.fg_bg.blit_bkg) + end + end + elseif new_state == 2 then + e.window.blit(" \x95", "0" .. c2, c2 .. e.fg_bg.blit_bkg) + elseif new_state == 3 then + e.window.blit(" \x95", "0" .. c3, c3 .. e.fg_bg.blit_bkg) + else + e.window.blit(" \x95", "0" .. c1, c1 .. e.fg_bg.blit_bkg) + end + end + + -- set indicator state + ---@param val integer indicator state + function e.set_value(val) e.on_update(val) end + + -- write label and initial indicator light + e.on_update(1) + e.window.write(args.label) + + return e.get() +end + +return alarm_indicator_light diff --git a/graphics/elements/indicators/coremap.lua b/graphics/elements/indicators/coremap.lua new file mode 100644 index 0000000..c50348b --- /dev/null +++ b/graphics/elements/indicators/coremap.lua @@ -0,0 +1,169 @@ +-- Reactor Core View Graphics Element + +local util = require("scada-common.util") + +local core = require("graphics.core") +local element = require("graphics.element") + +---@class core_map_args +---@field reactor_l integer reactor length +---@field reactor_w integer reactor width +---@field parent graphics_element +---@field id? string element id +---@field x? integer 1 if omitted +---@field y? integer 1 if omitted + +-- new core map box +---@nodiscard +---@param args core_map_args +---@return graphics_element element, element_id id +local function core_map(args) + assert(util.is_int(args.reactor_l), "graphics.elements.indicators.coremap: reactor_l is a required field") + assert(util.is_int(args.reactor_w), "graphics.elements.indicators.coremap: reactor_w is a required field") + + -- require max dimensions + args.width = 18 + args.height = 18 + + -- inherit only foreground color + args.fg_bg = core.graphics.cpair(args.parent.get_fg_bg().fgd, colors.gray) + + -- create new graphics element base object + local e = element.new(args) + + local alternator = true + + local core_l = args.reactor_l - 2 + local core_w = args.reactor_w - 2 + + local shift_x = 8 - math.floor(core_l / 2) + local shift_y = 8 - math.floor(core_w / 2) + + local start_x = 2 + shift_x + local start_y = 2 + shift_y + + local inner_width = core_l + local inner_height = core_w + + -- create coordinate grid and frame + local function draw_frame() + e.window.setTextColor(colors.white) + + for x = 0, (inner_width - 1) do + e.window.setCursorPos(x + start_x, 1) + e.window.write(util.sprintf("%X", x)) + end + + for y = 0, (inner_height - 1) do + e.window.setCursorPos(1, y + start_y) + e.window.write(util.sprintf("%X", y)) + end + + -- even out bottom edge + e.window.setTextColor(e.fg_bg.bkg) + e.window.setBackgroundColor(args.parent.get_fg_bg().bkg) + e.window.setCursorPos(1, e.frame.h) + e.window.write(util.strrep("\x8f", e.frame.w)) + e.window.setTextColor(e.fg_bg.fgd) + e.window.setBackgroundColor(e.fg_bg.bkg) + end + + -- draw the core + ---@param t number temperature in K + local function draw_core(t) + local i = 1 + local back_c = "F" + local text_c = "8" + + -- determine fuel assembly coloring + if t <= 300 then + -- gray + text_c = "8" + elseif t <= 350 then + -- blue + text_c = "3" + elseif t < 600 then + -- green + text_c = "D" + elseif t < 1000 then + -- yellow + text_c = "4" + -- back_c = "8" + elseif t < 1200 then + -- orange + text_c = "1" + elseif t < 1300 then + -- red + text_c = "E" + else + -- pink + text_c = "2" + end + + -- draw pattern + for y = start_y, inner_height + (start_y - 1) do + e.window.setCursorPos(start_x, y) + for _ = 1, inner_width do + if alternator then + i = i + 1 + e.window.blit("\x07", text_c, back_c) + else + e.window.blit("\x07", "7", "8") + end + + alternator = not alternator + end + + if inner_width % 2 == 0 then alternator = not alternator end + end + + -- reset alternator + alternator = true + end + + -- on state change + ---@param temperature number temperature in Kelvin + function e.on_update(temperature) + e.value = temperature + draw_core(e.value) + end + + -- set temperature to display + ---@param val number degrees K + function e.set_value(val) e.on_update(val) end + + -- resize reactor dimensions + ---@param reactor_l integer reactor length (rendered in 2D top-down as width) + ---@param reactor_w integer reactor width (rendered in 2D top-down as height) + function e.resize(reactor_l, reactor_w) + -- enforce possible dimensions + if reactor_l > 18 then reactor_l = 18 elseif reactor_l < 3 then reactor_l = 3 end + if reactor_w > 18 then reactor_w = 18 elseif reactor_w < 3 then reactor_w = 3 end + + -- update dimensions + core_l = reactor_l - 2 + core_w = reactor_w - 2 + shift_x = 8 - math.floor(core_l / 2) + shift_y = 8 - math.floor(core_w / 2) + start_x = 2 + shift_x + start_y = 2 + shift_y + inner_width = core_l + inner_height = core_w + + e.window.clear() + + -- re-draw + draw_frame() + e.on_update(e.value) + end + + -- initial (one-time except for resize()) frame draw + draw_frame() + + -- initial draw + e.on_update(0) + + return e.get() +end + +return core_map diff --git a/graphics/elements/indicators/data.lua b/graphics/elements/indicators/data.lua new file mode 100644 index 0000000..66d45dc --- /dev/null +++ b/graphics/elements/indicators/data.lua @@ -0,0 +1,93 @@ +-- Data Indicator Graphics Element + +local util = require("scada-common.util") + +local element = require("graphics.element") + +---@class data_indicator_args +---@field label string indicator label +---@field unit? string indicator unit +---@field format string data format (lua string format) +---@field commas? boolean whether to use commas if a number is given (default to false) +---@field lu_colors? cpair label foreground color (a), unit foreground color (b) +---@field value any default value +---@field parent graphics_element +---@field id? string element id +---@field x? integer 1 if omitted +---@field y? integer 1 if omitted +---@field width integer length +---@field fg_bg? cpair foreground/background colors + +-- new data indicator +---@nodiscard +---@param args data_indicator_args +---@return graphics_element element, element_id id +local function data(args) + assert(type(args.label) == "string", "graphics.elements.indicators.data: label is a required field") + assert(type(args.format) == "string", "graphics.elements.indicators.data: format is a required field") + assert(args.value ~= nil, "graphics.elements.indicators.data: value is a required field") + assert(util.is_int(args.width), "graphics.elements.indicators.data: width is a required field") + + -- single line + args.height = 1 + + -- create new graphics element base object + local e = element.new(args) + + -- label color + if args.lu_colors ~= nil then + e.window.setTextColor(args.lu_colors.color_a) + end + + -- write label + e.window.setCursorPos(1, 1) + e.window.write(args.label) + + local label_len = string.len(args.label) + local data_start = 1 + local clear_width = args.width + + if label_len > 0 then + data_start = data_start + (label_len + 1) + clear_width = args.width - (label_len + 1) + end + + -- on state change + ---@param value any new value + function e.on_update(value) + e.value = value + + -- clear old data and label + e.window.setCursorPos(data_start, 1) + e.window.write(util.spaces(clear_width)) + + -- write data + local data_str = util.sprintf(args.format, value) + e.window.setCursorPos(data_start, 1) + e.window.setTextColor(e.fg_bg.fgd) + if args.commas then + e.window.write(util.comma_format(data_str)) + else + e.window.write(data_str) + end + + -- write label + if args.unit ~= nil then + if args.lu_colors ~= nil then + e.window.setTextColor(args.lu_colors.color_b) + end + e.window.write(" " .. args.unit) + end + end + + -- set the value + ---@param val any new value + function e.set_value(val) e.on_update(val) end + + -- initial value draw + e.on_update(args.value) + + return e.get() +end + +return data diff --git a/graphics/elements/indicators/hbar.lua b/graphics/elements/indicators/hbar.lua new file mode 100644 index 0000000..2d9b110 --- /dev/null +++ b/graphics/elements/indicators/hbar.lua @@ -0,0 +1,125 @@ +-- Horizontal Bar Graphics Element + +local util = require("scada-common.util") + +local element = require("graphics.element") + +---@class hbar_args +---@field show_percent? boolean whether or not to show the percent +---@field bar_fg_bg? cpair bar foreground/background colors if showing percent +---@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 horizontal bar +---@nodiscard +---@param args hbar_args +---@return graphics_element element, element_id id +local function hbar(args) + -- properties/state + local last_num_bars = -1 + + -- create new graphics element base object + local e = element.new(args) + + -- bar width is width - 5 characters for " 100%" if showing percent + local bar_width = util.trinary(args.show_percent, e.frame.w - 5, e.frame.w) + + assert(bar_width > 0, "graphics.elements.indicators.hbar: too small for bar") + + -- determine bar colors + local bar_bkg = e.fg_bg.blit_bkg + local bar_fgd = e.fg_bg.blit_fgd + if args.bar_fg_bg ~= nil then + bar_bkg = args.bar_fg_bg.blit_bkg + bar_fgd = args.bar_fg_bg.blit_fgd + end + + -- handle data changes + ---@param fraction number 0.0 to 1.0 + function e.on_update(fraction) + e.value = fraction + + -- enforce minimum and maximum + if fraction < 0 then + fraction = 0.0 + elseif fraction > 1 then + fraction = 1.0 + end + + -- compute number of bars + local num_bars = util.round(fraction * (bar_width * 2)) + + -- redraw bar if changed + if num_bars ~= last_num_bars then + last_num_bars = num_bars + + local fgd = "" + local bkg = "" + local spaces = "" + + -- fill percentage + for _ = 1, num_bars / 2 do + spaces = spaces .. " " + fgd = fgd .. bar_fgd + bkg = bkg .. bar_bkg + end + + -- add fractional bar if needed + if num_bars % 2 == 1 then + spaces = spaces .. "\x95" + fgd = fgd .. bar_bkg + bkg = bkg .. bar_fgd + end + + -- pad background + for _ = 1, ((bar_width * 2) - num_bars) / 2 do + spaces = spaces .. " " + fgd = fgd .. bar_bkg + bkg = bkg .. bar_bkg + end + + -- draw bar + for y = 1, e.frame.h do + e.window.setCursorPos(1, y) + -- intentionally swapped fgd/bkg since we use spaces as fill, but they are the opposite + e.window.blit(spaces, bkg, fgd) + end + end + + -- update percentage + if args.show_percent then + e.window.setCursorPos(bar_width + 2, math.max(1, math.ceil(e.frame.h / 2))) + e.window.write(util.sprintf("%3.0f%%", fraction * 100)) + end + end + + -- change bar color + ---@param bar_fg_bg cpair new bar colors + function e.recolor(bar_fg_bg) + bar_bkg = bar_fg_bg.blit_bkg + bar_fgd = bar_fg_bg.blit_fgd + + -- re-draw + last_num_bars = 0 + if type(e.value) == "number" then + e.on_update(e.value) + end + end + + -- set the percentage value + ---@param val number 0.0 to 1.0 + function e.set_value(val) e.on_update(val) end + + -- initialize to 0 + e.on_update(0) + + return e.get() +end + +return hbar diff --git a/graphics/elements/indicators/icon.lua b/graphics/elements/indicators/icon.lua new file mode 100644 index 0000000..f31479d --- /dev/null +++ b/graphics/elements/indicators/icon.lua @@ -0,0 +1,74 @@ +-- Icon Indicator Graphics Element + +local util = require("scada-common.util") + +local element = require("graphics.element") + +---@class icon_sym_color +---@field color cpair +---@field symbol string + +---@class icon_indicator_args +---@field label string indicator label +---@field states table state color and symbol table +---@field value? integer default state, defaults to 1 +---@field min_label_width? integer label length if omitted +---@field parent graphics_element +---@field id? string element id +---@field x? integer 1 if omitted +---@field y? integer 1 if omitted +---@field fg_bg? cpair foreground/background colors + +-- new icon indicator +---@nodiscard +---@param args icon_indicator_args +---@return graphics_element element, element_id id +local function icon(args) + assert(type(args.label) == "string", "graphics.elements.indicators.icon: label is a required field") + assert(type(args.states) == "table", "graphics.elements.indicators.icon: states is a required field") + + -- single line + args.height = 1 + + -- determine width + args.width = math.max(args.min_label_width or 1, string.len(args.label)) + 4 + + -- create new graphics element base object + local e = element.new(args) + + -- state blit strings + local state_blit_cmds = {} + for i = 1, #args.states do + local sym_color = args.states[i] ---@type icon_sym_color + + table.insert(state_blit_cmds, { + text = " " .. sym_color.symbol .. " ", + fgd = util.strrep(sym_color.color.blit_fgd, 3), + bkg = util.strrep(sym_color.color.blit_bkg, 3) + }) + end + + -- write label and initial indicator light + e.window.setCursorPos(5, 1) + e.window.write(args.label) + + -- on state change + ---@param new_state integer indicator state + function e.on_update(new_state) + local blit_cmd = state_blit_cmds[new_state] + e.value = new_state + e.window.setCursorPos(1, 1) + e.window.blit(blit_cmd.text, blit_cmd.fgd, blit_cmd.bkg) + end + + -- set indicator state + ---@param val integer indicator state + function e.set_value(val) e.on_update(val) end + + -- initial icon draw + e.on_update(args.value or 1) + + return e.get() +end + +return icon diff --git a/graphics/elements/indicators/light.lua b/graphics/elements/indicators/light.lua new file mode 100644 index 0000000..e764ad9 --- /dev/null +++ b/graphics/elements/indicators/light.lua @@ -0,0 +1,98 @@ +-- Indicator Light Graphics Element + +local util = require("scada-common.util") + +local element = require("graphics.element") +local flasher = require("graphics.flasher") + +---@class indicator_light_args +---@field label string indicator label +---@field colors cpair on/off colors (a/b respectively) +---@field min_label_width? integer label length if omitted +---@field flash? boolean whether to flash on true rather than stay on +---@field period? PERIOD flash period +---@field parent graphics_element +---@field id? string element id +---@field x? integer 1 if omitted +---@field y? integer 1 if omitted +---@field fg_bg? cpair foreground/background colors + +-- new indicator light +---@nodiscard +---@param args indicator_light_args +---@return graphics_element element, element_id id +local function indicator_light(args) + assert(type(args.label) == "string", "graphics.elements.indicators.light: label is a required field") + assert(type(args.colors) == "table", "graphics.elements.indicators.light: colors is a required field") + + if args.flash then + assert(util.is_int(args.period), "graphics.elements.indicators.light: period is a required field if flash is enabled") + end + + -- single line + args.height = 1 + + -- determine width + args.width = math.max(args.min_label_width or 1, string.len(args.label)) + 2 + + -- flasher state + local flash_on = true + + -- create new graphics element base object + local e = element.new(args) + + -- called by flasher when enabled + local function flash_callback() + e.window.setCursorPos(1, 1) + + if flash_on then + e.window.blit(" \x95", "0" .. args.colors.blit_a, args.colors.blit_a .. e.fg_bg.blit_bkg) + else + e.window.blit(" \x95", "0" .. args.colors.blit_b, args.colors.blit_b .. e.fg_bg.blit_bkg) + end + + flash_on = not flash_on + end + + -- enable light or start flashing + local function enable() + if args.flash then + flash_on = true + flasher.start(flash_callback, args.period) + else + e.window.setCursorPos(1, 1) + e.window.blit(" \x95", "0" .. args.colors.blit_a, args.colors.blit_a .. e.fg_bg.blit_bkg) + end + end + + -- disable light or stop flashing + local function disable() + if args.flash then + flash_on = false + flasher.stop(flash_callback) + end + + e.window.setCursorPos(1, 1) + e.window.blit(" \x95", "0" .. args.colors.blit_b, args.colors.blit_b .. e.fg_bg.blit_bkg) + end + + -- on state change + ---@param new_state boolean indicator state + function e.on_update(new_state) + e.value = new_state + if new_state then enable() else disable() end + end + + -- set indicator state + ---@param val boolean indicator state + function e.set_value(val) e.on_update(val) end + + -- write label and initial indicator light + e.on_update(false) + e.window.setCursorPos(3, 1) + e.window.write(args.label) + + return e.get() +end + +return indicator_light diff --git a/graphics/elements/indicators/power.lua b/graphics/elements/indicators/power.lua new file mode 100644 index 0000000..1d727ae --- /dev/null +++ b/graphics/elements/indicators/power.lua @@ -0,0 +1,85 @@ +-- Power Indicator Graphics Element + +local util = require("scada-common.util") + +local element = require("graphics.element") + +---@class power_indicator_args +---@field label string indicator label +---@field format string power format override (lua string format) +---@field rate boolean? whether to append /t to the end (power per tick) +---@field lu_colors? cpair label foreground color (a), unit foreground color (b) +---@field value any default value +---@field parent graphics_element +---@field id? string element id +---@field x? integer 1 if omitted +---@field y? integer 1 if omitted +---@field width integer length +---@field fg_bg? cpair foreground/background colors + +-- new power indicator +---@nodiscard +---@param args power_indicator_args +---@return graphics_element element, element_id id +local function power(args) + assert(args.value ~= nil, "graphics.elements.indicators.power: value is a required field") + assert(util.is_int(args.width), "graphics.elements.indicators.power: width is a required field") + + -- single line + args.height = 1 + + -- create new graphics element base object + local e = element.new(args) + + -- label color + if args.lu_colors ~= nil then + e.window.setTextColor(args.lu_colors.color_a) + end + + -- write label + e.window.setCursorPos(1, 1) + e.window.write(args.label) + + local data_start = string.len(args.label) + 2 + if string.len(args.label) == 0 then data_start = 1 end + + -- on state change + ---@param value any new value + function e.on_update(value) + e.value = value + + local data_str, unit = util.power_format(value, false, args.format) + + -- write data + e.window.setCursorPos(data_start, 1) + e.window.setTextColor(e.fg_bg.fgd) + e.window.write(util.comma_format(data_str)) + + -- write unit + if args.lu_colors ~= nil then + e.window.setTextColor(args.lu_colors.color_b) + end + + -- append per tick if rate is set + -- add space to FE so we don't end up with FEE (after having kFE for example) + if args.rate == true then + unit = unit .. "/t" + if unit == "FE/t" then unit = "FE/t " end + else + if unit == "FE" then unit = "FE " end + end + + e.window.write(" " .. unit) + end + + -- set the value + ---@param val any new value + function e.set_value(val) e.on_update(val) end + + -- initial value draw + e.on_update(args.value) + + return e.get() +end + +return power diff --git a/graphics/elements/indicators/rad.lua b/graphics/elements/indicators/rad.lua new file mode 100644 index 0000000..2e4ad56 --- /dev/null +++ b/graphics/elements/indicators/rad.lua @@ -0,0 +1,90 @@ +-- Radiation Indicator Graphics Element + +local types = require("scada-common.types") +local util = require("scada-common.util") + +local element = require("graphics.element") + +---@class rad_indicator_args +---@field label string indicator label +---@field format string data format (lua string format) +---@field commas? boolean whether to use commas if a number is given (default to false) +---@field lu_colors? cpair label foreground color (a), unit foreground color (b) +---@field value any default value +---@field parent graphics_element +---@field id? string element id +---@field x? integer 1 if omitted +---@field y? integer 1 if omitted +---@field width integer length +---@field fg_bg? cpair foreground/background colors + +-- new radiation indicator +---@nodiscard +---@param args rad_indicator_args +---@return graphics_element element, element_id id +local function rad(args) + assert(type(args.label) == "string", "graphics.elements.indicators.rad: label is a required field") + assert(type(args.format) == "string", "graphics.elements.indicators.rad: format is a required field") + assert(util.is_int(args.width), "graphics.elements.indicators.rad: width is a required field") + + -- single line + args.height = 1 + + -- create new graphics element base object + local e = element.new(args) + + -- label color + if args.lu_colors ~= nil then + e.window.setTextColor(args.lu_colors.color_a) + end + + -- write label + e.window.setCursorPos(1, 1) + e.window.write(args.label) + + local label_len = string.len(args.label) + local data_start = 1 + local clear_width = args.width + + if label_len > 0 then + data_start = data_start + (label_len + 1) + clear_width = args.width - (label_len + 1) + end + + -- on state change + ---@param value any new value + function e.on_update(value) + e.value = value.radiation + + -- clear old data and label + e.window.setCursorPos(data_start, 1) + e.window.write(util.spaces(clear_width)) + + -- write data + local data_str = util.sprintf(args.format, e.value) + e.window.setCursorPos(data_start, 1) + e.window.setTextColor(e.fg_bg.fgd) + if args.commas then + e.window.write(util.comma_format(data_str)) + else + e.window.write(data_str) + end + + -- write unit + if args.lu_colors ~= nil then + e.window.setTextColor(args.lu_colors.color_b) + end + e.window.write(" " .. value.unit) + end + + -- set the value + ---@param val any new value + function e.set_value(val) e.on_update(val) end + + -- initial value draw + e.on_update(types.new_zero_radiation_reading()) + + return e.get() +end + +return rad diff --git a/graphics/elements/indicators/state.lua b/graphics/elements/indicators/state.lua new file mode 100644 index 0000000..10d081b --- /dev/null +++ b/graphics/elements/indicators/state.lua @@ -0,0 +1,80 @@ +-- State (Text) Indicator Graphics Element + +local util = require("scada-common.util") + +local element = require("graphics.element") + +---@class state_text_color +---@field color cpair +---@field text string + +---@class state_indicator_args +---@field states table state color and text table +---@field value? integer default state, defaults to 1 +---@field min_width? integer max state text length if omitted +---@field parent graphics_element +---@field id? string element id +---@field x? integer 1 if omitted +---@field y? integer 1 if omitted +---@field height? integer 1 if omitted, must be an odd number +---@field fg_bg? cpair foreground/background colors + +-- new state indicator +---@nodiscard +---@param args state_indicator_args +---@return graphics_element element, element_id id +local function state_indicator(args) + assert(type(args.states) == "table", "graphics.elements.indicators.state: states is a required field") + + -- determine height + if util.is_int(args.height) then + assert(args.height % 2 == 1, "graphics.elements.indicators.state: height should be an odd number") + else + args.height = 1 + end + + -- initial guess at width + args.width = args.min_width or 1 + + -- state blit strings + local state_blit_cmds = {} + for i = 1, #args.states do + local state_def = args.states[i] ---@type state_text_color + + -- re-determine width + if string.len(state_def.text) > args.width then + args.width = string.len(state_def.text) + end + + local text = util.pad(state_def.text, args.width) + + table.insert(state_blit_cmds, { + text = text, + fgd = util.strrep(state_def.color.blit_fgd, string.len(text)), + bkg = util.strrep(state_def.color.blit_bkg, string.len(text)) + }) + end + + -- create new graphics element base object + local e = element.new(args) + + -- on state change + ---@param new_state integer indicator state + function e.on_update(new_state) + local blit_cmd = state_blit_cmds[new_state] + e.value = new_state + e.window.setCursorPos(1, 1) + e.window.blit(blit_cmd.text, blit_cmd.fgd, blit_cmd.bkg) + end + + -- set indicator state + ---@param val integer indicator state + function e.set_value(val) e.on_update(val) end + + -- initial draw + e.on_update(args.value or 1) + + return e.get() +end + +return state_indicator diff --git a/graphics/elements/indicators/trilight.lua b/graphics/elements/indicators/trilight.lua new file mode 100644 index 0000000..543ebf5 --- /dev/null +++ b/graphics/elements/indicators/trilight.lua @@ -0,0 +1,111 @@ +-- Tri-State Indicator Light Graphics Element + +local util = require("scada-common.util") + +local element = require("graphics.element") +local flasher = require("graphics.flasher") + +---@class tristate_indicator_light_args +---@field label string indicator label +---@field c1 color color for state 1 +---@field c2 color color for state 2 +---@field c3 color color for state 3 +---@field min_label_width? integer label length if omitted +---@field flash? boolean whether to flash on state 2 or 3 rather than stay on +---@field period? PERIOD flash period +---@field parent graphics_element +---@field id? string element id +---@field x? integer 1 if omitted +---@field y? integer 1 if omitted +---@field fg_bg? cpair foreground/background colors + +-- new tri-state indicator light +---@nodiscard +---@param args tristate_indicator_light_args +---@return graphics_element element, element_id id +local function tristate_indicator_light(args) + assert(type(args.label) == "string", "graphics.elements.indicators.trilight: label is a required field") + assert(type(args.c1) == "number", "graphics.elements.indicators.trilight: c1 is a required field") + assert(type(args.c2) == "number", "graphics.elements.indicators.trilight: c2 is a required field") + assert(type(args.c3) == "number", "graphics.elements.indicators.trilight: c3 is a required field") + + if args.flash then + assert(util.is_int(args.period), "graphics.elements.indicators.trilight: period is a required field if flash is enabled") + end + + -- single line + args.height = 1 + + -- determine width + args.width = math.max(args.min_label_width or 1, string.len(args.label)) + 2 + + -- flasher state + local flash_on = true + + -- blit translations + local c1 = colors.toBlit(args.c1) + local c2 = colors.toBlit(args.c2) + local c3 = colors.toBlit(args.c3) + + -- create new graphics element base object + local e = element.new(args) + + -- init value for initial check in on_update + e.value = 1 + + -- called by flasher when enabled + local function flash_callback() + e.window.setCursorPos(1, 1) + + if flash_on then + if e.value == 2 then + e.window.blit(" \x95", "0" .. c2, c2 .. e.fg_bg.blit_bkg) + elseif e.value == 3 then + e.window.blit(" \x95", "0" .. c3, c3 .. e.fg_bg.blit_bkg) + end + else + e.window.blit(" \x95", "0" .. c1, c1 .. e.fg_bg.blit_bkg) + end + + flash_on = not flash_on + end + + -- on state change + ---@param new_state integer indicator state + function e.on_update(new_state) + local was_off = e.value <= 1 + + e.value = new_state + e.window.setCursorPos(1, 1) + + if args.flash then + if was_off and (new_state > 1) then + flash_on = true + flasher.start(flash_callback, args.period) + elseif new_state <= 1 then + flash_on = false + flasher.stop(flash_callback) + + e.window.blit(" \x95", "0" .. c1, c1 .. e.fg_bg.blit_bkg) + end + elseif new_state == 2 then + e.window.blit(" \x95", "0" .. c2, c2 .. e.fg_bg.blit_bkg) + elseif new_state == 3 then + e.window.blit(" \x95", "0" .. c3, c3 .. e.fg_bg.blit_bkg) + else + e.window.blit(" \x95", "0" .. c1, c1 .. e.fg_bg.blit_bkg) + end + end + + -- set indicator state + ---@param val integer indicator state + function e.set_value(val) e.on_update(val) end + + -- write label and initial indicator light + e.on_update(1) + e.window.write(args.label) + + return e.get() +end + +return tristate_indicator_light diff --git a/graphics/elements/indicators/vbar.lua b/graphics/elements/indicators/vbar.lua new file mode 100644 index 0000000..fe7f9bc --- /dev/null +++ b/graphics/elements/indicators/vbar.lua @@ -0,0 +1,105 @@ +-- Vertical Bar Graphics Element + +local util = require("scada-common.util") + +local element = require("graphics.element") + +---@class vbar_args +---@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 vertical bar +---@nodiscard +---@param args vbar_args +---@return graphics_element element, element_id id +local function vbar(args) + -- properties/state + local last_num_bars = -1 + + -- create new graphics element base object + local e = element.new(args) + + -- blit strings + local fgd = util.strrep(e.fg_bg.blit_fgd, e.frame.w) + local bkg = util.strrep(e.fg_bg.blit_bkg, e.frame.w) + local spaces = util.spaces(e.frame.w) + local one_third = util.strrep("\x8f", e.frame.w) + local two_thirds = util.strrep("\x83", e.frame.w) + + -- handle data changes + ---@param fraction number 0.0 to 1.0 + function e.on_update(fraction) + e.value = fraction + + -- enforce minimum and maximum + if fraction < 0 then + fraction = 0.0 + elseif fraction > 1 then + fraction = 1.0 + end + + -- compute number of bars + local num_bars = util.round(fraction * (e.frame.h * 3)) + + -- redraw only if number of bars has changed + if num_bars ~= last_num_bars then + last_num_bars = num_bars + + -- start bottom up + local y = e.frame.h + + -- start at base of vertical bar + e.window.setCursorPos(1, y) + + -- fill percentage + for _ = 1, num_bars / 3 do + e.window.blit(spaces, bkg, fgd) + y = y - 1 + e.window.setCursorPos(1, y) + end + + -- add fractional bar if needed + if num_bars % 3 == 1 then + e.window.blit(one_third, bkg, fgd) + y = y - 1 + elseif num_bars % 3 == 2 then + e.window.blit(two_thirds, bkg, fgd) + y = y - 1 + end + + -- fill the rest blank + while y > 0 do + e.window.setCursorPos(1, y) + e.window.blit(spaces, fgd, bkg) + y = y - 1 + end + end + end + + -- change bar color + ---@param fg_bg cpair new bar colors + function e.recolor(fg_bg) + fgd = util.strrep(fg_bg.blit_fgd, e.frame.w) + bkg = util.strrep(fg_bg.blit_bkg, e.frame.w) + + -- re-draw + last_num_bars = 0 + if type(e.value) == "number" then + e.on_update(e.value) + end + end + + -- set the percentage value + ---@param val number 0.0 to 1.0 + function e.set_value(val) e.on_update(val) end + + return e.get() +end + +return vbar diff --git a/graphics/elements/pipenet.lua b/graphics/elements/pipenet.lua new file mode 100644 index 0000000..71ee9fd --- /dev/null +++ b/graphics/elements/pipenet.lua @@ -0,0 +1,147 @@ +-- Pipe Graphics Element + +local util = require("scada-common.util") + +local core = require("graphics.core") +local element = require("graphics.element") + +---@class pipenet_args +---@field pipes table pipe list +---@field bg? color background color +---@field parent graphics_element +---@field id? string element id +---@field x? integer 1 if omitted +---@field y? integer 1 if omitted + +-- new pipe network +---@param args pipenet_args +---@return graphics_element element, element_id id +local function pipenet(args) + assert(type(args.pipes) == "table", "graphics.elements.indicators.pipenet: pipes is a required field") + + args.width = 0 + args.height = 0 + + -- determine width/height + for i = 1, #args.pipes do + local pipe = args.pipes[i] ---@type pipe + + local true_w = pipe.w + math.min(pipe.x1, pipe.x2) + local true_h = pipe.h + math.min(pipe.y1, pipe.y2) + + if true_w > args.width then args.width = true_w end + if true_h > args.height then args.height = true_h end + end + + args.x = args.x or 1 + args.y = args.y or 1 + + if args.bg ~= nil then + args.fg_bg = core.graphics.cpair(args.bg, args.bg) + end + + -- create new graphics element base object + local e = element.new(args) + + -- draw all pipes + for p = 1, #args.pipes do + local pipe = args.pipes[p] ---@type pipe + + local x = 1 + pipe.x1 + local y = 1 + pipe.y1 + + local x_step = util.trinary(pipe.x1 >= pipe.x2, -1, 1) + local y_step = util.trinary(pipe.y1 >= pipe.y2, -1, 1) + + e.window.setCursorPos(x, y) + + local c = core.graphics.cpair(pipe.color, e.fg_bg.bkg) + + if pipe.align_tr then + -- cross width then height + for i = 1, pipe.w do + if pipe.thin then + if i == pipe.w then + -- corner + if y_step > 0 then + e.window.blit("\x93", c.blit_bkg, c.blit_fgd) + else + e.window.blit("\x8e", c.blit_fgd, c.blit_bkg) + end + else + e.window.blit("\x8c", c.blit_fgd, c.blit_bkg) + end + else + if i == pipe.w and y_step > 0 then + -- corner + e.window.blit(" ", c.blit_bkg, c.blit_fgd) + else + e.window.blit("\x8f", c.blit_fgd, c.blit_bkg) + end + end + + x = x + x_step + e.window.setCursorPos(x, y) + end + + -- back up one + x = x - x_step + + for _ = 1, pipe.h - 1 do + y = y + y_step + e.window.setCursorPos(x, y) + + if pipe.thin then + e.window.blit("\x95", c.blit_bkg, c.blit_fgd) + else + e.window.blit(" ", c.blit_bkg, c.blit_fgd) + end + end + else + -- cross height then width + for i = 1, pipe.h do + if pipe.thin then + if i == pipe.h then + -- corner + if y_step < 0 then + e.window.blit("\x97", c.blit_bkg, c.blit_fgd) + else + e.window.blit("\x8d", c.blit_fgd, c.blit_bkg) + end + else + e.window.blit("\x95", c.blit_fgd, c.blit_bkg) + end + else + if i == pipe.h and y_step < 0 then + -- corner + e.window.blit("\x83", c.blit_bkg, c.blit_fgd) + else + e.window.blit(" ", c.blit_bkg, c.blit_fgd) + end + end + + y = y + y_step + e.window.setCursorPos(x, y) + end + + -- back up one + y = y - y_step + + for _ = 1, pipe.w - 1 do + x = x + x_step + e.window.setCursorPos(x, y) + + if pipe.thin then + e.window.blit("\x8c", c.blit_fgd, c.blit_bkg) + else + e.window.blit("\x83", c.blit_bkg, c.blit_fgd) + end + end + end + + end + + return e.get() +end + +return pipenet diff --git a/graphics/elements/rectangle.lua b/graphics/elements/rectangle.lua new file mode 100644 index 0000000..6422cbc --- /dev/null +++ b/graphics/elements/rectangle.lua @@ -0,0 +1,159 @@ +-- Rectangle Graphics Element + +local util = require("scada-common.util") + +local element = require("graphics.element") + +---@class rectangle_args +---@field border? graphics_border +---@field thin? boolean true to use extra thin even borders +---@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 rectangle +---@param args rectangle_args +---@return graphics_element element, element_id id +local function rectangle(args) + assert(args.border ~= nil or args.thin ~= true, "graphics.elements.rectangle: thin requires border to be provided") + + -- if thin, then width will always need to be 1 + if args.thin == true then + args.border.width = 1 + args.border.even = true + end + + -- offset children + if args.border ~= nil then + args.offset_x = args.border.width + args.offset_y = args.border.width + + -- slightly different y offset if the border is set to even + if args.border.even then + local width_x2 = (2 * args.border.width) + args.offset_y = math.floor(width_x2 / 3) + util.trinary(width_x2 % 3 > 0, 1, 0) + end + end + + -- create new graphics element base object + local e = element.new(args) + + -- draw bordered box if requested + -- element constructor will have drawn basic colored rectangle regardless + if args.border ~= nil then + e.window.setCursorPos(1, 1) + + local border_width = args.offset_x + local border_height = args.offset_y + local border_blit = colors.toBlit(args.border.color) + local width_x2 = border_width * 2 + local inner_width = e.frame.w - width_x2 + + -- check dimensions + assert(width_x2 <= e.frame.w, "graphics.elements.rectangle: border too thick for width") + assert(width_x2 <= e.frame.h, "graphics.elements.rectangle: border too thick for height") + + -- form the basic line strings and top/bottom blit strings + local spaces = util.spaces(e.frame.w) + local blit_fg = util.strrep(e.fg_bg.blit_fgd, e.frame.w) + local blit_fg_sides = blit_fg + local blit_bg_sides = "" + local blit_bg_top_bot = util.strrep(border_blit, e.frame.w) + + -- partial bars + local p_a = util.spaces(border_width) .. util.strrep("\x8f", inner_width) .. util.spaces(border_width) + local p_b = util.spaces(border_width) .. util.strrep("\x83", inner_width) .. util.spaces(border_width) + local p_s = spaces + + if args.thin == true then + p_a = "\x97" .. util.strrep("\x83", inner_width) .. "\x94" + p_b = "\x8a" .. util.strrep("\x8f", inner_width) .. "\x85" + p_s = "\x95" .. util.spaces(inner_width) .. "\x95" + end + + local p_inv_fg = util.strrep(border_blit, border_width) .. util.strrep(e.fg_bg.blit_bkg, inner_width) .. + util.strrep(border_blit, border_width) + local p_inv_bg = util.strrep(e.fg_bg.blit_bkg, border_width) .. util.strrep(border_blit, inner_width) .. + util.strrep(e.fg_bg.blit_bkg, border_width) + + if args.thin == true then + p_inv_fg = e.fg_bg.blit_bkg .. util.strrep(e.fg_bg.blit_bkg, inner_width) .. util.strrep(border_blit, border_width) + p_inv_bg = border_blit .. util.strrep(border_blit, inner_width) .. util.strrep(e.fg_bg.blit_bkg, border_width) + + blit_fg_sides = border_blit .. util.strrep(e.fg_bg.blit_bkg, inner_width) .. e.fg_bg.blit_bkg + end + + -- form the body blit strings (sides are border, inside is normal) + for x = 1, e.frame.w do + -- edges get border color, center gets normal + if x <= border_width or x > (e.frame.w - border_width) then + if args.thin and x == 1 then + blit_bg_sides = blit_bg_sides .. e.fg_bg.blit_bkg + else + blit_bg_sides = blit_bg_sides .. border_blit + end + else + blit_bg_sides = blit_bg_sides .. e.fg_bg.blit_bkg + end + end + + -- draw rectangle with borders + for y = 1, e.frame.h do + e.window.setCursorPos(1, y) + -- top border + if y <= border_height then + -- partial pixel fill + if args.border.even and y == border_height then + if args.thin == true then + e.window.blit(p_a, p_inv_bg, p_inv_fg) + else + if width_x2 % 3 == 1 then + e.window.blit(p_b, p_inv_bg, p_inv_fg) + elseif width_x2 % 3 == 2 then + e.window.blit(p_a, p_inv_bg, p_inv_fg) + else + -- skip line + e.window.blit(spaces, blit_fg, blit_bg_sides) + end + end + else + e.window.blit(spaces, blit_fg, blit_bg_top_bot) + end + -- bottom border + elseif y > (e.frame.h - border_width) then + -- partial pixel fill + if args.border.even and y == ((e.frame.h - border_width) + 1) then + if args.thin == true then + e.window.blit(p_b, util.strrep(e.fg_bg.blit_bkg, e.frame.w), blit_bg_top_bot) + else + if width_x2 % 3 == 1 then + e.window.blit(p_a, p_inv_fg, blit_bg_top_bot) + elseif width_x2 % 3 == 2 or (args.thin == true) then + e.window.blit(p_b, p_inv_fg, blit_bg_top_bot) + else + -- skip line + e.window.blit(spaces, blit_fg, blit_bg_sides) + end + end + else + e.window.blit(spaces, blit_fg, blit_bg_top_bot) + end + else + if args.thin == true then + e.window.blit(p_s, blit_fg_sides, blit_bg_sides) + else + e.window.blit(p_s, blit_fg, blit_bg_sides) + end + end + end + end + + return e.get() +end + +return rectangle diff --git a/graphics/elements/textbox.lua b/graphics/elements/textbox.lua new file mode 100644 index 0000000..c911677 --- /dev/null +++ b/graphics/elements/textbox.lua @@ -0,0 +1,70 @@ +-- Text Box Graphics Element + +local util = require("scada-common.util") + +local core = require("graphics.core") +local element = require("graphics.element") + +local TEXT_ALIGN = core.graphics.TEXT_ALIGN + +---@class textbox_args +---@field text string text to show +---@field alignment? TEXT_ALIGN text alignment, left by default +---@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 text box +---@param args textbox_args +---@return graphics_element element, element_id id +local function textbox(args) + assert(type(args.text) == "string", "graphics.elements.textbox: text is a required field") + + -- create new graphics element base object + local e = element.new(args) + + local alignment = args.alignment or TEXT_ALIGN.LEFT + + -- draw textbox + + local function display_text(text) + e.value = text + + local lines = util.strwrap(text, e.frame.w) + + for i = 1, #lines do + if i > e.frame.h then break end + + local len = string.len(lines[i]) + + -- use cursor position to align this line + if alignment == TEXT_ALIGN.CENTER then + e.window.setCursorPos(math.floor((e.frame.w - len) / 2) + 1, i) + elseif alignment == TEXT_ALIGN.RIGHT then + e.window.setCursorPos((e.frame.w - len) + 1, i) + else + e.window.setCursorPos(1, i) + end + + e.window.write(lines[i]) + end + end + + display_text(args.text) + + -- set the string value and re-draw the text + ---@param val string value + function e.set_value(val) + e.window.clear() + display_text(val) + end + + return e.get() +end + +return textbox diff --git a/graphics/elements/tiling.lua b/graphics/elements/tiling.lua new file mode 100644 index 0000000..a97438a --- /dev/null +++ b/graphics/elements/tiling.lua @@ -0,0 +1,87 @@ +-- "Basketweave" Tiling Graphics Element + +local util = require("scada-common.util") + +local element = require("graphics.element") + +---@class tiling_args +---@field fill_c cpair colors to fill with +---@field even? boolean whether to account for rectangular pixels +---@field border_c? color optional frame color +---@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 tiling box +---@param args tiling_args +---@return graphics_element element, element_id id +local function tiling(args) + assert(type(args.fill_c) == "table", "graphics.elements.tiling: fill_c is a required field") + + -- create new graphics element base object + local e = element.new(args) + + -- draw tiling box + + local fill_a = args.fill_c.blit_a + local fill_b = args.fill_c.blit_b + + local even = args.even == true + + local start_x = 1 + local start_y = 1 + local inner_width = math.floor(e.frame.w / util.trinary(even, 2, 1)) + local inner_height = e.frame.h + local alternator = true + + -- border + if args.border_c ~= nil then + e.window.setBackgroundColor(args.border_c) + e.window.clear() + + start_x = 1 + util.trinary(even, 2, 1) + start_y = 2 + + inner_width = math.floor((e.frame.w - 2 * util.trinary(even, 2, 1)) / util.trinary(even, 2, 1)) + inner_height = e.frame.h - 2 + end + + -- check dimensions + assert(inner_width > 0, "graphics.elements.tiling: inner_width <= 0") + assert(inner_height > 0, "graphics.elements.tiling: inner_height <= 0") + assert(start_x <= inner_width, "graphics.elements.tiling: start_x > inner_width") + assert(start_y <= inner_height, "graphics.elements.tiling: start_y > inner_height") + + -- create pattern + for y = start_y, inner_height + (start_y - 1) do + e.window.setCursorPos(start_x, y) + for _ = 1, inner_width do + if alternator then + if even then + e.window.blit(" ", "00", fill_a .. fill_a) + else + e.window.blit(" ", "0", fill_a) + end + else + if even then + e.window.blit(" ", "00", fill_b .. fill_b) + else + e.window.blit(" ", "0", fill_b) + end + end + + alternator = not alternator + end + + if inner_width % 2 == 0 then alternator = not alternator end + end + + return e.get() +end + +return tiling diff --git a/graphics/flasher.lua b/graphics/flasher.lua new file mode 100644 index 0000000..0a3d9ea --- /dev/null +++ b/graphics/flasher.lua @@ -0,0 +1,80 @@ +-- +-- Indicator Light Flasher +-- + +local tcd = require("scada-common.tcallbackdsp") + +local flasher = {} + +-- note: no additional call needs to be made in a main loop as this class automatically uses the TCD to operate + +---@alias PERIOD integer +local PERIOD = { + BLINK_250_MS = 1, + BLINK_500_MS = 2, + BLINK_1000_MS = 3 +} + +flasher.PERIOD = PERIOD + +local active = false +local registry = { {}, {}, {} } -- one registry table per period +local callback_counter = 0 + +-- blink registered indicators
+-- this assumes it is called every 250ms, it does no checking of time on its own +local function callback_250ms() + if active then + for _, f in ipairs(registry[PERIOD.BLINK_250_MS]) do f() end + + if callback_counter % 2 == 0 then + for _, f in ipairs(registry[PERIOD.BLINK_500_MS]) do f() end + end + + if callback_counter % 4 == 0 then + for _, f in ipairs(registry[PERIOD.BLINK_1000_MS]) do f() end + end + + callback_counter = callback_counter + 1 + + tcd.dispatch_unique(0.25, callback_250ms) + end +end + +-- start/resume the flasher periodic +function flasher.run() + active = true + callback_250ms() +end + +-- clear all blinking indicators and stop the flasher periodic +function flasher.clear() + active = false + callback_counter = 0 + registry = { {}, {}, {} } +end + +-- register a function to be called on the selected blink period
+-- times are not strictly enforced, but all with a given period will be set at the same time +---@param f function function to call each period +---@param period PERIOD time period option (1, 2, or 3) +function flasher.start(f, period) + if type(registry[period]) == "table" then + table.insert(registry[period], f) + end +end + +-- stop a function from being called at the blink period +---@param f function function callback registered +function flasher.stop(f) + for i = 1, #registry do + for key, val in ipairs(registry[i]) do + if val == f then + table.remove(registry[i], key) + return + end + end + end +end + +return flasher diff --git a/imgen.py b/imgen.py new file mode 100644 index 0000000..faab46a --- /dev/null +++ b/imgen.py @@ -0,0 +1,106 @@ +import json +import os + +# list files in a directory +def list_files(path): + list = [] + + for (root, dirs, files) in os.walk(path): + for f in files: + list.append(root[2:] + "/" + f) + + return list + +# get size of all files in a directory +def dir_size(path): + total = 0 + + for (root, dirs, files) in os.walk(path): + for f in files: + total += os.path.getsize(root + "/" + f) + + return total + +# get the version of an application at the provided path +def get_version(path, is_comms = False): + ver = "" + string = "comms.version = \"" + + if not is_comms: + string = "_VERSION = \"" + + f = open(path, "r") + + for line in f: + pos = line.find(string) + if pos >= 0: + ver = line[(pos + len(string)):(len(line) - 2)] + break + + f.close() + + return ver + +# generate installation manifest object +def make_manifest(size): + manifest = { + "versions" : { + "installer" : get_version("./ccmsi.lua"), + "bootloader" : get_version("./startup.lua"), + "comms" : get_version("./scada-common/comms.lua", True), + "reactor-plc" : get_version("./reactor-plc/startup.lua"), + "rtu" : get_version("./rtu/startup.lua"), + "supervisor" : get_version("./supervisor/startup.lua"), + "coordinator" : get_version("./coordinator/startup.lua"), + "pocket" : get_version("./pocket/startup.lua") + }, + "files" : { + # common files + "system" : [ "initenv.lua", "startup.lua" ], + "common" : list_files("./scada-common"), + "graphics" : list_files("./graphics"), + "lockbox" : list_files("./lockbox"), + # platform files + "reactor-plc" : list_files("./reactor-plc"), + "rtu" : list_files("./rtu"), + "supervisor" : list_files("./supervisor"), + "coordinator" : list_files("./coordinator"), + "pocket" : list_files("./pocket"), + }, + "depends" : { + "reactor-plc" : [ "system", "common" ], + "rtu" : [ "system", "common" ], + "supervisor" : [ "system", "common" ], + "coordinator" : [ "system", "common", "graphics" ], + "pocket" : [ "system", "common", "graphics" ] + }, + "sizes" : { + # manifest file estimate + "manifest" : size, + # common files + "system" : os.path.getsize("initenv.lua") + os.path.getsize("startup.lua"), + "common" : dir_size("./scada-common"), + "graphics" : dir_size("./graphics"), + "lockbox" : dir_size("./lockbox"), + # platform files + "reactor-plc" : dir_size("./reactor-plc"), + "rtu" : dir_size("./rtu"), + "supervisor" : dir_size("./supervisor"), + "coordinator" : dir_size("./coordinator"), + "pocket" : dir_size("./pocket"), + } + } + + return manifest + +# write initial manifest with placeholder size +f = open("install_manifest.json", "w") +json.dump(make_manifest("-----"), f) +f.close() + +manifest_size = os.path.getsize("install_manifest.json") + +# calculate file size then regenerate with embedded size +f = open("install_manifest.json", "w") +json.dump(make_manifest(manifest_size), f) +f.close() diff --git a/initenv.lua b/initenv.lua new file mode 100644 index 0000000..fffb705 --- /dev/null +++ b/initenv.lua @@ -0,0 +1,18 @@ +-- +-- Initialize the Post-Boot Module Environment +-- + +return { + -- initialize booted environment + init_env = function () + local _require = require("cc.require") + local _env = setmetatable({}, { __index = _ENV }) + + -- overwrite require/package globals + require, package = _require.make(_env, "/") + + -- reset terminal + term.clear() + term.setCursorPos(1, 1) + end +} diff --git a/install_manifest.json b/install_manifest.json new file mode 100644 index 0000000..78bb226 --- /dev/null +++ b/install_manifest.json @@ -0,0 +1 @@ +{"versions": {"installer": "v1.0", "bootloader": "0.2", "comms": "1.4.0", "reactor-plc": "v1.0.0", "rtu": "v0.13.0", "supervisor": "v0.14.0", "coordinator": "v0.12.2", "pocket": "alpha-v0.0.0"}, "files": {"system": ["initenv.lua", "startup.lua"], "common": ["scada-common/crypto.lua", "scada-common/ppm.lua", "scada-common/comms.lua", "scada-common/psil.lua", "scada-common/tcallbackdsp.lua", "scada-common/rsio.lua", "scada-common/constants.lua", "scada-common/mqueue.lua", "scada-common/crash.lua", "scada-common/log.lua", "scada-common/types.lua", "scada-common/util.lua"], "graphics": ["graphics/element.lua", "graphics/flasher.lua", "graphics/core.lua", "graphics/elements/textbox.lua", "graphics/elements/displaybox.lua", "graphics/elements/pipenet.lua", "graphics/elements/rectangle.lua", "graphics/elements/div.lua", "graphics/elements/tiling.lua", "graphics/elements/colormap.lua", "graphics/elements/indicators/alight.lua", "graphics/elements/indicators/icon.lua", "graphics/elements/indicators/power.lua", "graphics/elements/indicators/rad.lua", "graphics/elements/indicators/state.lua", "graphics/elements/indicators/light.lua", "graphics/elements/indicators/vbar.lua", "graphics/elements/indicators/coremap.lua", "graphics/elements/indicators/data.lua", "graphics/elements/indicators/hbar.lua", "graphics/elements/indicators/trilight.lua", "graphics/elements/controls/switch_button.lua", "graphics/elements/controls/spinbox_numeric.lua", "graphics/elements/controls/hazard_button.lua", "graphics/elements/controls/push_button.lua", "graphics/elements/controls/radio_button.lua", "graphics/elements/controls/multi_button.lua", "graphics/elements/animations/waiting.lua"], "lockbox": ["lockbox/init.lua", "lockbox/LICENSE", "lockbox/kdf/pbkdf2.lua", "lockbox/util/bit.lua", "lockbox/util/array.lua", "lockbox/util/stream.lua", "lockbox/util/queue.lua", "lockbox/digest/sha2_224.lua", "lockbox/digest/sha1.lua", "lockbox/digest/sha2_256.lua", "lockbox/cipher/aes128.lua", "lockbox/cipher/aes256.lua", "lockbox/cipher/aes192.lua", "lockbox/cipher/mode/ofb.lua", "lockbox/cipher/mode/cbc.lua", "lockbox/cipher/mode/ctr.lua", "lockbox/cipher/mode/cfb.lua", "lockbox/mac/hmac.lua", "lockbox/padding/ansix923.lua", "lockbox/padding/pkcs7.lua", "lockbox/padding/zero.lua", "lockbox/padding/isoiec7816.lua"], "reactor-plc": ["reactor-plc/threads.lua", "reactor-plc/plc.lua", "reactor-plc/config.lua", "reactor-plc/startup.lua"], "rtu": ["rtu/threads.lua", "rtu/rtu.lua", "rtu/modbus.lua", "rtu/config.lua", "rtu/startup.lua", "rtu/dev/sps_rtu.lua", "rtu/dev/envd_rtu.lua", "rtu/dev/boilerv_rtu.lua", "rtu/dev/redstone_rtu.lua", "rtu/dev/sna_rtu.lua", "rtu/dev/imatrix_rtu.lua", "rtu/dev/turbinev_rtu.lua"], "supervisor": ["supervisor/supervisor.lua", "supervisor/unit.lua", "supervisor/config.lua", "supervisor/startup.lua", "supervisor/unitlogic.lua", "supervisor/facility.lua", "supervisor/session/coordinator.lua", "supervisor/session/svqtypes.lua", "supervisor/session/svsessions.lua", "supervisor/session/rtu.lua", "supervisor/session/plc.lua", "supervisor/session/rsctl.lua", "supervisor/session/rtu/boilerv.lua", "supervisor/session/rtu/txnctrl.lua", "supervisor/session/rtu/unit_session.lua", "supervisor/session/rtu/turbinev.lua", "supervisor/session/rtu/envd.lua", "supervisor/session/rtu/imatrix.lua", "supervisor/session/rtu/sps.lua", "supervisor/session/rtu/qtypes.lua", "supervisor/session/rtu/sna.lua", "supervisor/session/rtu/redstone.lua"], "coordinator": ["coordinator/coordinator.lua", "coordinator/renderer.lua", "coordinator/iocontrol.lua", "coordinator/sounder.lua", "coordinator/config.lua", "coordinator/startup.lua", "coordinator/apisessions.lua", "coordinator/process.lua", "coordinator/ui/dialog.lua", "coordinator/ui/style.lua", "coordinator/ui/layout/main_view.lua", "coordinator/ui/layout/unit_view.lua", "coordinator/ui/components/reactor.lua", "coordinator/ui/components/processctl.lua", "coordinator/ui/components/unit_overview.lua", "coordinator/ui/components/boiler.lua", "coordinator/ui/components/unit_detail.lua", "coordinator/ui/components/imatrix.lua", "coordinator/ui/components/unit_waiting.lua", "coordinator/ui/components/turbine.lua"], "pocket": ["pocket/config.lua", "pocket/startup.lua"]}, "depends": {"reactor-plc": ["system", "common"], "rtu": ["system", "common"], "supervisor": ["system", "common"], "coordinator": ["system", "common", "graphics"], "pocket": ["system", "common", "graphics"]}, "sizes": {"manifest": 4646, "system": 1982, "common": 91084, "graphics": 99858, "lockbox": 100797, "reactor-plc": 75529, "rtu": 82913, "supervisor": 274491, "coordinator": 180346, "pocket": 335}} \ No newline at end of file diff --git a/lockbox/LICENSE b/lockbox/LICENSE new file mode 100644 index 0000000..9fed1ed --- /dev/null +++ b/lockbox/LICENSE @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright (c) 2015 James L. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/lockbox/cipher/aes128.lua b/lockbox/cipher/aes128.lua new file mode 100644 index 0000000..0726ac4 --- /dev/null +++ b/lockbox/cipher/aes128.lua @@ -0,0 +1,415 @@ +local Array = require("lockbox.util.array"); +local Bit = require("lockbox.util.bit"); + +local XOR = Bit.bxor; + +local SBOX = { + [0] = 0x63, 0x7C, 0x77, 0x7B, 0xF2, 0x6B, 0x6F, 0xC5, 0x30, 0x01, 0x67, 0x2B, 0xFE, 0xD7, 0xAB, 0x76, + 0xCA, 0x82, 0xC9, 0x7D, 0xFA, 0x59, 0x47, 0xF0, 0xAD, 0xD4, 0xA2, 0xAF, 0x9C, 0xA4, 0x72, 0xC0, + 0xB7, 0xFD, 0x93, 0x26, 0x36, 0x3F, 0xF7, 0xCC, 0x34, 0xA5, 0xE5, 0xF1, 0x71, 0xD8, 0x31, 0x15, + 0x04, 0xC7, 0x23, 0xC3, 0x18, 0x96, 0x05, 0x9A, 0x07, 0x12, 0x80, 0xE2, 0xEB, 0x27, 0xB2, 0x75, + 0x09, 0x83, 0x2C, 0x1A, 0x1B, 0x6E, 0x5A, 0xA0, 0x52, 0x3B, 0xD6, 0xB3, 0x29, 0xE3, 0x2F, 0x84, + 0x53, 0xD1, 0x00, 0xED, 0x20, 0xFC, 0xB1, 0x5B, 0x6A, 0xCB, 0xBE, 0x39, 0x4A, 0x4C, 0x58, 0xCF, + 0xD0, 0xEF, 0xAA, 0xFB, 0x43, 0x4D, 0x33, 0x85, 0x45, 0xF9, 0x02, 0x7F, 0x50, 0x3C, 0x9F, 0xA8, + 0x51, 0xA3, 0x40, 0x8F, 0x92, 0x9D, 0x38, 0xF5, 0xBC, 0xB6, 0xDA, 0x21, 0x10, 0xFF, 0xF3, 0xD2, + 0xCD, 0x0C, 0x13, 0xEC, 0x5F, 0x97, 0x44, 0x17, 0xC4, 0xA7, 0x7E, 0x3D, 0x64, 0x5D, 0x19, 0x73, + 0x60, 0x81, 0x4F, 0xDC, 0x22, 0x2A, 0x90, 0x88, 0x46, 0xEE, 0xB8, 0x14, 0xDE, 0x5E, 0x0B, 0xDB, + 0xE0, 0x32, 0x3A, 0x0A, 0x49, 0x06, 0x24, 0x5C, 0xC2, 0xD3, 0xAC, 0x62, 0x91, 0x95, 0xE4, 0x79, + 0xE7, 0xC8, 0x37, 0x6D, 0x8D, 0xD5, 0x4E, 0xA9, 0x6C, 0x56, 0xF4, 0xEA, 0x65, 0x7A, 0xAE, 0x08, + 0xBA, 0x78, 0x25, 0x2E, 0x1C, 0xA6, 0xB4, 0xC6, 0xE8, 0xDD, 0x74, 0x1F, 0x4B, 0xBD, 0x8B, 0x8A, + 0x70, 0x3E, 0xB5, 0x66, 0x48, 0x03, 0xF6, 0x0E, 0x61, 0x35, 0x57, 0xB9, 0x86, 0xC1, 0x1D, 0x9E, + 0xE1, 0xF8, 0x98, 0x11, 0x69, 0xD9, 0x8E, 0x94, 0x9B, 0x1E, 0x87, 0xE9, 0xCE, 0x55, 0x28, 0xDF, + 0x8C, 0xA1, 0x89, 0x0D, 0xBF, 0xE6, 0x42, 0x68, 0x41, 0x99, 0x2D, 0x0F, 0xB0, 0x54, 0xBB, 0x16}; + +local ISBOX = { + [0] = 0x52, 0x09, 0x6A, 0xD5, 0x30, 0x36, 0xA5, 0x38, 0xBF, 0x40, 0xA3, 0x9E, 0x81, 0xF3, 0xD7, 0xFB, + 0x7C, 0xE3, 0x39, 0x82, 0x9B, 0x2F, 0xFF, 0x87, 0x34, 0x8E, 0x43, 0x44, 0xC4, 0xDE, 0xE9, 0xCB, + 0x54, 0x7B, 0x94, 0x32, 0xA6, 0xC2, 0x23, 0x3D, 0xEE, 0x4C, 0x95, 0x0B, 0x42, 0xFA, 0xC3, 0x4E, + 0x08, 0x2E, 0xA1, 0x66, 0x28, 0xD9, 0x24, 0xB2, 0x76, 0x5B, 0xA2, 0x49, 0x6D, 0x8B, 0xD1, 0x25, + 0x72, 0xF8, 0xF6, 0x64, 0x86, 0x68, 0x98, 0x16, 0xD4, 0xA4, 0x5C, 0xCC, 0x5D, 0x65, 0xB6, 0x92, + 0x6C, 0x70, 0x48, 0x50, 0xFD, 0xED, 0xB9, 0xDA, 0x5E, 0x15, 0x46, 0x57, 0xA7, 0x8D, 0x9D, 0x84, + 0x90, 0xD8, 0xAB, 0x00, 0x8C, 0xBC, 0xD3, 0x0A, 0xF7, 0xE4, 0x58, 0x05, 0xB8, 0xB3, 0x45, 0x06, + 0xD0, 0x2C, 0x1E, 0x8F, 0xCA, 0x3F, 0x0F, 0x02, 0xC1, 0xAF, 0xBD, 0x03, 0x01, 0x13, 0x8A, 0x6B, + 0x3A, 0x91, 0x11, 0x41, 0x4F, 0x67, 0xDC, 0xEA, 0x97, 0xF2, 0xCF, 0xCE, 0xF0, 0xB4, 0xE6, 0x73, + 0x96, 0xAC, 0x74, 0x22, 0xE7, 0xAD, 0x35, 0x85, 0xE2, 0xF9, 0x37, 0xE8, 0x1C, 0x75, 0xDF, 0x6E, + 0x47, 0xF1, 0x1A, 0x71, 0x1D, 0x29, 0xC5, 0x89, 0x6F, 0xB7, 0x62, 0x0E, 0xAA, 0x18, 0xBE, 0x1B, + 0xFC, 0x56, 0x3E, 0x4B, 0xC6, 0xD2, 0x79, 0x20, 0x9A, 0xDB, 0xC0, 0xFE, 0x78, 0xCD, 0x5A, 0xF4, + 0x1F, 0xDD, 0xA8, 0x33, 0x88, 0x07, 0xC7, 0x31, 0xB1, 0x12, 0x10, 0x59, 0x27, 0x80, 0xEC, 0x5F, + 0x60, 0x51, 0x7F, 0xA9, 0x19, 0xB5, 0x4A, 0x0D, 0x2D, 0xE5, 0x7A, 0x9F, 0x93, 0xC9, 0x9C, 0xEF, + 0xA0, 0xE0, 0x3B, 0x4D, 0xAE, 0x2A, 0xF5, 0xB0, 0xC8, 0xEB, 0xBB, 0x3C, 0x83, 0x53, 0x99, 0x61, + 0x17, 0x2B, 0x04, 0x7E, 0xBA, 0x77, 0xD6, 0x26, 0xE1, 0x69, 0x14, 0x63, 0x55, 0x21, 0x0C, 0x7D}; + +local ROW_SHIFT = { 1, 6, 11, 16, 5, 10, 15, 4, 9, 14, 3, 8, 13, 2, 7, 12, }; +local IROW_SHIFT = { 1, 14, 11, 8, 5, 2, 15, 12, 9, 6, 3, 16, 13, 10, 7, 4, }; + +local ETABLE = { + [0] = 0x01, 0x03, 0x05, 0x0F, 0x11, 0x33, 0x55, 0xFF, 0x1A, 0x2E, 0x72, 0x96, 0xA1, 0xF8, 0x13, 0x35, + 0x5F, 0xE1, 0x38, 0x48, 0xD8, 0x73, 0x95, 0xA4, 0xF7, 0x02, 0x06, 0x0A, 0x1E, 0x22, 0x66, 0xAA, + 0xE5, 0x34, 0x5C, 0xE4, 0x37, 0x59, 0xEB, 0x26, 0x6A, 0xBE, 0xD9, 0x70, 0x90, 0xAB, 0xE6, 0x31, + 0x53, 0xF5, 0x04, 0x0C, 0x14, 0x3C, 0x44, 0xCC, 0x4F, 0xD1, 0x68, 0xB8, 0xD3, 0x6E, 0xB2, 0xCD, + 0x4C, 0xD4, 0x67, 0xA9, 0xE0, 0x3B, 0x4D, 0xD7, 0x62, 0xA6, 0xF1, 0x08, 0x18, 0x28, 0x78, 0x88, + 0x83, 0x9E, 0xB9, 0xD0, 0x6B, 0xBD, 0xDC, 0x7F, 0x81, 0x98, 0xB3, 0xCE, 0x49, 0xDB, 0x76, 0x9A, + 0xB5, 0xC4, 0x57, 0xF9, 0x10, 0x30, 0x50, 0xF0, 0x0B, 0x1D, 0x27, 0x69, 0xBB, 0xD6, 0x61, 0xA3, + 0xFE, 0x19, 0x2B, 0x7D, 0x87, 0x92, 0xAD, 0xEC, 0x2F, 0x71, 0x93, 0xAE, 0xE9, 0x20, 0x60, 0xA0, + 0xFB, 0x16, 0x3A, 0x4E, 0xD2, 0x6D, 0xB7, 0xC2, 0x5D, 0xE7, 0x32, 0x56, 0xFA, 0x15, 0x3F, 0x41, + 0xC3, 0x5E, 0xE2, 0x3D, 0x47, 0xC9, 0x40, 0xC0, 0x5B, 0xED, 0x2C, 0x74, 0x9C, 0xBF, 0xDA, 0x75, + 0x9F, 0xBA, 0xD5, 0x64, 0xAC, 0xEF, 0x2A, 0x7E, 0x82, 0x9D, 0xBC, 0xDF, 0x7A, 0x8E, 0x89, 0x80, + 0x9B, 0xB6, 0xC1, 0x58, 0xE8, 0x23, 0x65, 0xAF, 0xEA, 0x25, 0x6F, 0xB1, 0xC8, 0x43, 0xC5, 0x54, + 0xFC, 0x1F, 0x21, 0x63, 0xA5, 0xF4, 0x07, 0x09, 0x1B, 0x2D, 0x77, 0x99, 0xB0, 0xCB, 0x46, 0xCA, + 0x45, 0xCF, 0x4A, 0xDE, 0x79, 0x8B, 0x86, 0x91, 0xA8, 0xE3, 0x3E, 0x42, 0xC6, 0x51, 0xF3, 0x0E, + 0x12, 0x36, 0x5A, 0xEE, 0x29, 0x7B, 0x8D, 0x8C, 0x8F, 0x8A, 0x85, 0x94, 0xA7, 0xF2, 0x0D, 0x17, + 0x39, 0x4B, 0xDD, 0x7C, 0x84, 0x97, 0xA2, 0xFD, 0x1C, 0x24, 0x6C, 0xB4, 0xC7, 0x52, 0xF6, 0x01}; + +local LTABLE = { + [0] = 0x00, 0x00, 0x19, 0x01, 0x32, 0x02, 0x1A, 0xC6, 0x4B, 0xC7, 0x1B, 0x68, 0x33, 0xEE, 0xDF, 0x03, + 0x64, 0x04, 0xE0, 0x0E, 0x34, 0x8D, 0x81, 0xEF, 0x4C, 0x71, 0x08, 0xC8, 0xF8, 0x69, 0x1C, 0xC1, + 0x7D, 0xC2, 0x1D, 0xB5, 0xF9, 0xB9, 0x27, 0x6A, 0x4D, 0xE4, 0xA6, 0x72, 0x9A, 0xC9, 0x09, 0x78, + 0x65, 0x2F, 0x8A, 0x05, 0x21, 0x0F, 0xE1, 0x24, 0x12, 0xF0, 0x82, 0x45, 0x35, 0x93, 0xDA, 0x8E, + 0x96, 0x8F, 0xDB, 0xBD, 0x36, 0xD0, 0xCE, 0x94, 0x13, 0x5C, 0xD2, 0xF1, 0x40, 0x46, 0x83, 0x38, + 0x66, 0xDD, 0xFD, 0x30, 0xBF, 0x06, 0x8B, 0x62, 0xB3, 0x25, 0xE2, 0x98, 0x22, 0x88, 0x91, 0x10, + 0x7E, 0x6E, 0x48, 0xC3, 0xA3, 0xB6, 0x1E, 0x42, 0x3A, 0x6B, 0x28, 0x54, 0xFA, 0x85, 0x3D, 0xBA, + 0x2B, 0x79, 0x0A, 0x15, 0x9B, 0x9F, 0x5E, 0xCA, 0x4E, 0xD4, 0xAC, 0xE5, 0xF3, 0x73, 0xA7, 0x57, + 0xAF, 0x58, 0xA8, 0x50, 0xF4, 0xEA, 0xD6, 0x74, 0x4F, 0xAE, 0xE9, 0xD5, 0xE7, 0xE6, 0xAD, 0xE8, + 0x2C, 0xD7, 0x75, 0x7A, 0xEB, 0x16, 0x0B, 0xF5, 0x59, 0xCB, 0x5F, 0xB0, 0x9C, 0xA9, 0x51, 0xA0, + 0x7F, 0x0C, 0xF6, 0x6F, 0x17, 0xC4, 0x49, 0xEC, 0xD8, 0x43, 0x1F, 0x2D, 0xA4, 0x76, 0x7B, 0xB7, + 0xCC, 0xBB, 0x3E, 0x5A, 0xFB, 0x60, 0xB1, 0x86, 0x3B, 0x52, 0xA1, 0x6C, 0xAA, 0x55, 0x29, 0x9D, + 0x97, 0xB2, 0x87, 0x90, 0x61, 0xBE, 0xDC, 0xFC, 0xBC, 0x95, 0xCF, 0xCD, 0x37, 0x3F, 0x5B, 0xD1, + 0x53, 0x39, 0x84, 0x3C, 0x41, 0xA2, 0x6D, 0x47, 0x14, 0x2A, 0x9E, 0x5D, 0x56, 0xF2, 0xD3, 0xAB, + 0x44, 0x11, 0x92, 0xD9, 0x23, 0x20, 0x2E, 0x89, 0xB4, 0x7C, 0xB8, 0x26, 0x77, 0x99, 0xE3, 0xA5, + 0x67, 0x4A, 0xED, 0xDE, 0xC5, 0x31, 0xFE, 0x18, 0x0D, 0x63, 0x8C, 0x80, 0xC0, 0xF7, 0x70, 0x07}; + +local MIXTABLE = { + 0x02, 0x03, 0x01, 0x01, + 0x01, 0x02, 0x03, 0x01, + 0x01, 0x01, 0x02, 0x03, + 0x03, 0x01, 0x01, 0x02}; + +local IMIXTABLE = { + 0x0E, 0x0B, 0x0D, 0x09, + 0x09, 0x0E, 0x0B, 0x0D, + 0x0D, 0x09, 0x0E, 0x0B, + 0x0B, 0x0D, 0x09, 0x0E}; + +local RCON = { +[0] = 0x8d, 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36, 0x6c, 0xd8, 0xab, 0x4d, 0x9a, +0x2f, 0x5e, 0xbc, 0x63, 0xc6, 0x97, 0x35, 0x6a, 0xd4, 0xb3, 0x7d, 0xfa, 0xef, 0xc5, 0x91, 0x39, +0x72, 0xe4, 0xd3, 0xbd, 0x61, 0xc2, 0x9f, 0x25, 0x4a, 0x94, 0x33, 0x66, 0xcc, 0x83, 0x1d, 0x3a, +0x74, 0xe8, 0xcb, 0x8d, 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36, 0x6c, 0xd8, +0xab, 0x4d, 0x9a, 0x2f, 0x5e, 0xbc, 0x63, 0xc6, 0x97, 0x35, 0x6a, 0xd4, 0xb3, 0x7d, 0xfa, 0xef, +0xc5, 0x91, 0x39, 0x72, 0xe4, 0xd3, 0xbd, 0x61, 0xc2, 0x9f, 0x25, 0x4a, 0x94, 0x33, 0x66, 0xcc, +0x83, 0x1d, 0x3a, 0x74, 0xe8, 0xcb, 0x8d, 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, +0x36, 0x6c, 0xd8, 0xab, 0x4d, 0x9a, 0x2f, 0x5e, 0xbc, 0x63, 0xc6, 0x97, 0x35, 0x6a, 0xd4, 0xb3, +0x7d, 0xfa, 0xef, 0xc5, 0x91, 0x39, 0x72, 0xe4, 0xd3, 0xbd, 0x61, 0xc2, 0x9f, 0x25, 0x4a, 0x94, +0x33, 0x66, 0xcc, 0x83, 0x1d, 0x3a, 0x74, 0xe8, 0xcb, 0x8d, 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, +0x40, 0x80, 0x1b, 0x36, 0x6c, 0xd8, 0xab, 0x4d, 0x9a, 0x2f, 0x5e, 0xbc, 0x63, 0xc6, 0x97, 0x35, +0x6a, 0xd4, 0xb3, 0x7d, 0xfa, 0xef, 0xc5, 0x91, 0x39, 0x72, 0xe4, 0xd3, 0xbd, 0x61, 0xc2, 0x9f, +0x25, 0x4a, 0x94, 0x33, 0x66, 0xcc, 0x83, 0x1d, 0x3a, 0x74, 0xe8, 0xcb, 0x8d, 0x01, 0x02, 0x04, +0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36, 0x6c, 0xd8, 0xab, 0x4d, 0x9a, 0x2f, 0x5e, 0xbc, 0x63, +0xc6, 0x97, 0x35, 0x6a, 0xd4, 0xb3, 0x7d, 0xfa, 0xef, 0xc5, 0x91, 0x39, 0x72, 0xe4, 0xd3, 0xbd, +0x61, 0xc2, 0x9f, 0x25, 0x4a, 0x94, 0x33, 0x66, 0xcc, 0x83, 0x1d, 0x3a, 0x74, 0xe8, 0xcb, 0x8d}; + + +local GMUL = function(A, B) + if(A == 0x01) then return B; end + if(B == 0x01) then return A; end + if(A == 0x00) then return 0; end + if(B == 0x00) then return 0; end + + local LA = LTABLE[A]; + local LB = LTABLE[B]; + + local sum = LA + LB; + if (sum > 0xFF) then sum = sum - 0xFF; end + + return ETABLE[sum]; +end + +local byteSub = Array.substitute; + +local shiftRow = Array.permute; + +local mixCol = function(i, mix) + local out = {}; + + local a, b, c, d; + + a = GMUL(i[ 1], mix[ 1]); + b = GMUL(i[ 2], mix[ 2]); + c = GMUL(i[ 3], mix[ 3]); + d = GMUL(i[ 4], mix[ 4]); + out[ 1] = XOR(XOR(a, b), XOR(c, d)); + a = GMUL(i[ 1], mix[ 5]); + b = GMUL(i[ 2], mix[ 6]); + c = GMUL(i[ 3], mix[ 7]); + d = GMUL(i[ 4], mix[ 8]); + out[ 2] = XOR(XOR(a, b), XOR(c, d)); + a = GMUL(i[ 1], mix[ 9]); + b = GMUL(i[ 2], mix[10]); + c = GMUL(i[ 3], mix[11]); + d = GMUL(i[ 4], mix[12]); + out[ 3] = XOR(XOR(a, b), XOR(c, d)); + a = GMUL(i[ 1], mix[13]); + b = GMUL(i[ 2], mix[14]); + c = GMUL(i[ 3], mix[15]); + d = GMUL(i[ 4], mix[16]); + out[ 4] = XOR(XOR(a, b), XOR(c, d)); + + + a = GMUL(i[ 5], mix[ 1]); + b = GMUL(i[ 6], mix[ 2]); + c = GMUL(i[ 7], mix[ 3]); + d = GMUL(i[ 8], mix[ 4]); + out[ 5] = XOR(XOR(a, b), XOR(c, d)); + a = GMUL(i[ 5], mix[ 5]); + b = GMUL(i[ 6], mix[ 6]); + c = GMUL(i[ 7], mix[ 7]); + d = GMUL(i[ 8], mix[ 8]); + out[ 6] = XOR(XOR(a, b), XOR(c, d)); + a = GMUL(i[ 5], mix[ 9]); + b = GMUL(i[ 6], mix[10]); + c = GMUL(i[ 7], mix[11]); + d = GMUL(i[ 8], mix[12]); + out[ 7] = XOR(XOR(a, b), XOR(c, d)); + a = GMUL(i[ 5], mix[13]); + b = GMUL(i[ 6], mix[14]); + c = GMUL(i[ 7], mix[15]); + d = GMUL(i[ 8], mix[16]); + out[ 8] = XOR(XOR(a, b), XOR(c, d)); + + + a = GMUL(i[ 9], mix[ 1]); + b = GMUL(i[10], mix[ 2]); + c = GMUL(i[11], mix[ 3]); + d = GMUL(i[12], mix[ 4]); + out[ 9] = XOR(XOR(a, b), XOR(c, d)); + a = GMUL(i[ 9], mix[ 5]); + b = GMUL(i[10], mix[ 6]); + c = GMUL(i[11], mix[ 7]); + d = GMUL(i[12], mix[ 8]); + out[10] = XOR(XOR(a, b), XOR(c, d)); + a = GMUL(i[ 9], mix[ 9]); + b = GMUL(i[10], mix[10]); + c = GMUL(i[11], mix[11]); + d = GMUL(i[12], mix[12]); + out[11] = XOR(XOR(a, b), XOR(c, d)); + a = GMUL(i[ 9], mix[13]); + b = GMUL(i[10], mix[14]); + c = GMUL(i[11], mix[15]); + d = GMUL(i[12], mix[16]); + out[12] = XOR(XOR(a, b), XOR(c, d)); + + + a = GMUL(i[13], mix[ 1]); + b = GMUL(i[14], mix[ 2]); + c = GMUL(i[15], mix[ 3]); + d = GMUL(i[16], mix[ 4]); + out[13] = XOR(XOR(a, b), XOR(c, d)); + a = GMUL(i[13], mix[ 5]); + b = GMUL(i[14], mix[ 6]); + c = GMUL(i[15], mix[ 7]); + d = GMUL(i[16], mix[ 8]); + out[14] = XOR(XOR(a, b), XOR(c, d)); + a = GMUL(i[13], mix[ 9]); + b = GMUL(i[14], mix[10]); + c = GMUL(i[15], mix[11]); + d = GMUL(i[16], mix[12]); + out[15] = XOR(XOR(a, b), XOR(c, d)); + a = GMUL(i[13], mix[13]); + b = GMUL(i[14], mix[14]); + c = GMUL(i[15], mix[15]); + d = GMUL(i[16], mix[16]); + out[16] = XOR(XOR(a, b), XOR(c, d)); + + return out; +end + +local keyRound = function(key, round) + local out = {}; + + out[ 1] = XOR(key[ 1], XOR(SBOX[key[14]], RCON[round])); + out[ 2] = XOR(key[ 2], SBOX[key[15]]); + out[ 3] = XOR(key[ 3], SBOX[key[16]]); + out[ 4] = XOR(key[ 4], SBOX[key[13]]); + + out[ 5] = XOR(out[ 1], key[ 5]); + out[ 6] = XOR(out[ 2], key[ 6]); + out[ 7] = XOR(out[ 3], key[ 7]); + out[ 8] = XOR(out[ 4], key[ 8]); + + out[ 9] = XOR(out[ 5], key[ 9]); + out[10] = XOR(out[ 6], key[10]); + out[11] = XOR(out[ 7], key[11]); + out[12] = XOR(out[ 8], key[12]); + + out[13] = XOR(out[ 9], key[13]); + out[14] = XOR(out[10], key[14]); + out[15] = XOR(out[11], key[15]); + out[16] = XOR(out[12], key[16]); + + return out; +end + +local keyExpand = function(key) + local keys = {}; + + local temp = key; + + keys[1] = temp; + + for i = 1, 10 do + temp = keyRound(temp, i); + keys[i + 1] = temp; + end + + return keys; + +end + +local addKey = Array.XOR; + + + +local AES = {}; + +AES.blockSize = 16; + +AES.encrypt = function(_key, block) + + local key = keyExpand(_key); + + --round 0 + block = addKey(block, key[1]); + + --round 1 + block = byteSub(block, SBOX); + block = shiftRow(block, ROW_SHIFT); + block = mixCol(block, MIXTABLE); + block = addKey(block, key[2]); + + --round 2 + block = byteSub(block, SBOX); + block = shiftRow(block, ROW_SHIFT); + block = mixCol(block, MIXTABLE); + block = addKey(block, key[3]); + + --round 3 + block = byteSub(block, SBOX); + block = shiftRow(block, ROW_SHIFT); + block = mixCol(block, MIXTABLE); + block = addKey(block, key[4]); + + --round 4 + block = byteSub(block, SBOX); + block = shiftRow(block, ROW_SHIFT); + block = mixCol(block, MIXTABLE); + block = addKey(block, key[5]); + + --round 5 + block = byteSub(block, SBOX); + block = shiftRow(block, ROW_SHIFT); + block = mixCol(block, MIXTABLE); + block = addKey(block, key[6]); + + --round 6 + block = byteSub(block, SBOX); + block = shiftRow(block, ROW_SHIFT); + block = mixCol(block, MIXTABLE); + block = addKey(block, key[7]); + + --round 7 + block = byteSub(block, SBOX); + block = shiftRow(block, ROW_SHIFT); + block = mixCol(block, MIXTABLE); + block = addKey(block, key[8]); + + --round 8 + block = byteSub(block, SBOX); + block = shiftRow(block, ROW_SHIFT); + block = mixCol(block, MIXTABLE); + block = addKey(block, key[9]); + + --round 9 + block = byteSub(block, SBOX); + block = shiftRow(block, ROW_SHIFT); + block = mixCol(block, MIXTABLE); + block = addKey(block, key[10]); + + --round 10 + block = byteSub(block, SBOX); + block = shiftRow(block, ROW_SHIFT); + block = addKey(block, key[11]); + + return block; + +end + +AES.decrypt = function(_key, block) + + local key = keyExpand(_key); + + --round 0 + block = addKey(block, key[11]); + + --round 1 + block = shiftRow(block, IROW_SHIFT); + block = byteSub(block, ISBOX); + block = addKey(block, key[10]); + block = mixCol(block, IMIXTABLE); + + --round 2 + block = shiftRow(block, IROW_SHIFT); + block = byteSub(block, ISBOX); + block = addKey(block, key[9]); + block = mixCol(block, IMIXTABLE); + + --round 3 + block = shiftRow(block, IROW_SHIFT); + block = byteSub(block, ISBOX); + block = addKey(block, key[8]); + block = mixCol(block, IMIXTABLE); + + --round 4 + block = shiftRow(block, IROW_SHIFT); + block = byteSub(block, ISBOX); + block = addKey(block, key[7]); + block = mixCol(block, IMIXTABLE); + + --round 5 + block = shiftRow(block, IROW_SHIFT); + block = byteSub(block, ISBOX); + block = addKey(block, key[6]); + block = mixCol(block, IMIXTABLE); + + --round 6 + block = shiftRow(block, IROW_SHIFT); + block = byteSub(block, ISBOX); + block = addKey(block, key[5]); + block = mixCol(block, IMIXTABLE); + + --round 7 + block = shiftRow(block, IROW_SHIFT); + block = byteSub(block, ISBOX); + block = addKey(block, key[4]); + block = mixCol(block, IMIXTABLE); + + --round 8 + block = shiftRow(block, IROW_SHIFT); + block = byteSub(block, ISBOX); + block = addKey(block, key[3]); + block = mixCol(block, IMIXTABLE); + + --round 9 + block = shiftRow(block, IROW_SHIFT); + block = byteSub(block, ISBOX); + block = addKey(block, key[2]); + block = mixCol(block, IMIXTABLE); + + --round 10 + block = shiftRow(block, IROW_SHIFT); + block = byteSub(block, ISBOX); + block = addKey(block, key[1]); + + return block; +end + +return AES; diff --git a/lockbox/cipher/aes192.lua b/lockbox/cipher/aes192.lua new file mode 100644 index 0000000..5f55b0e --- /dev/null +++ b/lockbox/cipher/aes192.lua @@ -0,0 +1,462 @@ + +local Array = require("lockbox.util.array"); +local Bit = require("lockbox.util.bit"); + +local XOR = Bit.bxor; + +local SBOX = { + [0] = 0x63, 0x7C, 0x77, 0x7B, 0xF2, 0x6B, 0x6F, 0xC5, 0x30, 0x01, 0x67, 0x2B, 0xFE, 0xD7, 0xAB, 0x76, + 0xCA, 0x82, 0xC9, 0x7D, 0xFA, 0x59, 0x47, 0xF0, 0xAD, 0xD4, 0xA2, 0xAF, 0x9C, 0xA4, 0x72, 0xC0, + 0xB7, 0xFD, 0x93, 0x26, 0x36, 0x3F, 0xF7, 0xCC, 0x34, 0xA5, 0xE5, 0xF1, 0x71, 0xD8, 0x31, 0x15, + 0x04, 0xC7, 0x23, 0xC3, 0x18, 0x96, 0x05, 0x9A, 0x07, 0x12, 0x80, 0xE2, 0xEB, 0x27, 0xB2, 0x75, + 0x09, 0x83, 0x2C, 0x1A, 0x1B, 0x6E, 0x5A, 0xA0, 0x52, 0x3B, 0xD6, 0xB3, 0x29, 0xE3, 0x2F, 0x84, + 0x53, 0xD1, 0x00, 0xED, 0x20, 0xFC, 0xB1, 0x5B, 0x6A, 0xCB, 0xBE, 0x39, 0x4A, 0x4C, 0x58, 0xCF, + 0xD0, 0xEF, 0xAA, 0xFB, 0x43, 0x4D, 0x33, 0x85, 0x45, 0xF9, 0x02, 0x7F, 0x50, 0x3C, 0x9F, 0xA8, + 0x51, 0xA3, 0x40, 0x8F, 0x92, 0x9D, 0x38, 0xF5, 0xBC, 0xB6, 0xDA, 0x21, 0x10, 0xFF, 0xF3, 0xD2, + 0xCD, 0x0C, 0x13, 0xEC, 0x5F, 0x97, 0x44, 0x17, 0xC4, 0xA7, 0x7E, 0x3D, 0x64, 0x5D, 0x19, 0x73, + 0x60, 0x81, 0x4F, 0xDC, 0x22, 0x2A, 0x90, 0x88, 0x46, 0xEE, 0xB8, 0x14, 0xDE, 0x5E, 0x0B, 0xDB, + 0xE0, 0x32, 0x3A, 0x0A, 0x49, 0x06, 0x24, 0x5C, 0xC2, 0xD3, 0xAC, 0x62, 0x91, 0x95, 0xE4, 0x79, + 0xE7, 0xC8, 0x37, 0x6D, 0x8D, 0xD5, 0x4E, 0xA9, 0x6C, 0x56, 0xF4, 0xEA, 0x65, 0x7A, 0xAE, 0x08, + 0xBA, 0x78, 0x25, 0x2E, 0x1C, 0xA6, 0xB4, 0xC6, 0xE8, 0xDD, 0x74, 0x1F, 0x4B, 0xBD, 0x8B, 0x8A, + 0x70, 0x3E, 0xB5, 0x66, 0x48, 0x03, 0xF6, 0x0E, 0x61, 0x35, 0x57, 0xB9, 0x86, 0xC1, 0x1D, 0x9E, + 0xE1, 0xF8, 0x98, 0x11, 0x69, 0xD9, 0x8E, 0x94, 0x9B, 0x1E, 0x87, 0xE9, 0xCE, 0x55, 0x28, 0xDF, + 0x8C, 0xA1, 0x89, 0x0D, 0xBF, 0xE6, 0x42, 0x68, 0x41, 0x99, 0x2D, 0x0F, 0xB0, 0x54, 0xBB, 0x16}; + +local ISBOX = { + [0] = 0x52, 0x09, 0x6A, 0xD5, 0x30, 0x36, 0xA5, 0x38, 0xBF, 0x40, 0xA3, 0x9E, 0x81, 0xF3, 0xD7, 0xFB, + 0x7C, 0xE3, 0x39, 0x82, 0x9B, 0x2F, 0xFF, 0x87, 0x34, 0x8E, 0x43, 0x44, 0xC4, 0xDE, 0xE9, 0xCB, + 0x54, 0x7B, 0x94, 0x32, 0xA6, 0xC2, 0x23, 0x3D, 0xEE, 0x4C, 0x95, 0x0B, 0x42, 0xFA, 0xC3, 0x4E, + 0x08, 0x2E, 0xA1, 0x66, 0x28, 0xD9, 0x24, 0xB2, 0x76, 0x5B, 0xA2, 0x49, 0x6D, 0x8B, 0xD1, 0x25, + 0x72, 0xF8, 0xF6, 0x64, 0x86, 0x68, 0x98, 0x16, 0xD4, 0xA4, 0x5C, 0xCC, 0x5D, 0x65, 0xB6, 0x92, + 0x6C, 0x70, 0x48, 0x50, 0xFD, 0xED, 0xB9, 0xDA, 0x5E, 0x15, 0x46, 0x57, 0xA7, 0x8D, 0x9D, 0x84, + 0x90, 0xD8, 0xAB, 0x00, 0x8C, 0xBC, 0xD3, 0x0A, 0xF7, 0xE4, 0x58, 0x05, 0xB8, 0xB3, 0x45, 0x06, + 0xD0, 0x2C, 0x1E, 0x8F, 0xCA, 0x3F, 0x0F, 0x02, 0xC1, 0xAF, 0xBD, 0x03, 0x01, 0x13, 0x8A, 0x6B, + 0x3A, 0x91, 0x11, 0x41, 0x4F, 0x67, 0xDC, 0xEA, 0x97, 0xF2, 0xCF, 0xCE, 0xF0, 0xB4, 0xE6, 0x73, + 0x96, 0xAC, 0x74, 0x22, 0xE7, 0xAD, 0x35, 0x85, 0xE2, 0xF9, 0x37, 0xE8, 0x1C, 0x75, 0xDF, 0x6E, + 0x47, 0xF1, 0x1A, 0x71, 0x1D, 0x29, 0xC5, 0x89, 0x6F, 0xB7, 0x62, 0x0E, 0xAA, 0x18, 0xBE, 0x1B, + 0xFC, 0x56, 0x3E, 0x4B, 0xC6, 0xD2, 0x79, 0x20, 0x9A, 0xDB, 0xC0, 0xFE, 0x78, 0xCD, 0x5A, 0xF4, + 0x1F, 0xDD, 0xA8, 0x33, 0x88, 0x07, 0xC7, 0x31, 0xB1, 0x12, 0x10, 0x59, 0x27, 0x80, 0xEC, 0x5F, + 0x60, 0x51, 0x7F, 0xA9, 0x19, 0xB5, 0x4A, 0x0D, 0x2D, 0xE5, 0x7A, 0x9F, 0x93, 0xC9, 0x9C, 0xEF, + 0xA0, 0xE0, 0x3B, 0x4D, 0xAE, 0x2A, 0xF5, 0xB0, 0xC8, 0xEB, 0xBB, 0x3C, 0x83, 0x53, 0x99, 0x61, + 0x17, 0x2B, 0x04, 0x7E, 0xBA, 0x77, 0xD6, 0x26, 0xE1, 0x69, 0x14, 0x63, 0x55, 0x21, 0x0C, 0x7D}; + +local ROW_SHIFT = { 1, 6, 11, 16, 5, 10, 15, 4, 9, 14, 3, 8, 13, 2, 7, 12, }; +local IROW_SHIFT = { 1, 14, 11, 8, 5, 2, 15, 12, 9, 6, 3, 16, 13, 10, 7, 4, }; + +local ETABLE = { + [0] = 0x01, 0x03, 0x05, 0x0F, 0x11, 0x33, 0x55, 0xFF, 0x1A, 0x2E, 0x72, 0x96, 0xA1, 0xF8, 0x13, 0x35, + 0x5F, 0xE1, 0x38, 0x48, 0xD8, 0x73, 0x95, 0xA4, 0xF7, 0x02, 0x06, 0x0A, 0x1E, 0x22, 0x66, 0xAA, + 0xE5, 0x34, 0x5C, 0xE4, 0x37, 0x59, 0xEB, 0x26, 0x6A, 0xBE, 0xD9, 0x70, 0x90, 0xAB, 0xE6, 0x31, + 0x53, 0xF5, 0x04, 0x0C, 0x14, 0x3C, 0x44, 0xCC, 0x4F, 0xD1, 0x68, 0xB8, 0xD3, 0x6E, 0xB2, 0xCD, + 0x4C, 0xD4, 0x67, 0xA9, 0xE0, 0x3B, 0x4D, 0xD7, 0x62, 0xA6, 0xF1, 0x08, 0x18, 0x28, 0x78, 0x88, + 0x83, 0x9E, 0xB9, 0xD0, 0x6B, 0xBD, 0xDC, 0x7F, 0x81, 0x98, 0xB3, 0xCE, 0x49, 0xDB, 0x76, 0x9A, + 0xB5, 0xC4, 0x57, 0xF9, 0x10, 0x30, 0x50, 0xF0, 0x0B, 0x1D, 0x27, 0x69, 0xBB, 0xD6, 0x61, 0xA3, + 0xFE, 0x19, 0x2B, 0x7D, 0x87, 0x92, 0xAD, 0xEC, 0x2F, 0x71, 0x93, 0xAE, 0xE9, 0x20, 0x60, 0xA0, + 0xFB, 0x16, 0x3A, 0x4E, 0xD2, 0x6D, 0xB7, 0xC2, 0x5D, 0xE7, 0x32, 0x56, 0xFA, 0x15, 0x3F, 0x41, + 0xC3, 0x5E, 0xE2, 0x3D, 0x47, 0xC9, 0x40, 0xC0, 0x5B, 0xED, 0x2C, 0x74, 0x9C, 0xBF, 0xDA, 0x75, + 0x9F, 0xBA, 0xD5, 0x64, 0xAC, 0xEF, 0x2A, 0x7E, 0x82, 0x9D, 0xBC, 0xDF, 0x7A, 0x8E, 0x89, 0x80, + 0x9B, 0xB6, 0xC1, 0x58, 0xE8, 0x23, 0x65, 0xAF, 0xEA, 0x25, 0x6F, 0xB1, 0xC8, 0x43, 0xC5, 0x54, + 0xFC, 0x1F, 0x21, 0x63, 0xA5, 0xF4, 0x07, 0x09, 0x1B, 0x2D, 0x77, 0x99, 0xB0, 0xCB, 0x46, 0xCA, + 0x45, 0xCF, 0x4A, 0xDE, 0x79, 0x8B, 0x86, 0x91, 0xA8, 0xE3, 0x3E, 0x42, 0xC6, 0x51, 0xF3, 0x0E, + 0x12, 0x36, 0x5A, 0xEE, 0x29, 0x7B, 0x8D, 0x8C, 0x8F, 0x8A, 0x85, 0x94, 0xA7, 0xF2, 0x0D, 0x17, + 0x39, 0x4B, 0xDD, 0x7C, 0x84, 0x97, 0xA2, 0xFD, 0x1C, 0x24, 0x6C, 0xB4, 0xC7, 0x52, 0xF6, 0x01}; + +local LTABLE = { + [0] = 0x00, 0x00, 0x19, 0x01, 0x32, 0x02, 0x1A, 0xC6, 0x4B, 0xC7, 0x1B, 0x68, 0x33, 0xEE, 0xDF, 0x03, + 0x64, 0x04, 0xE0, 0x0E, 0x34, 0x8D, 0x81, 0xEF, 0x4C, 0x71, 0x08, 0xC8, 0xF8, 0x69, 0x1C, 0xC1, + 0x7D, 0xC2, 0x1D, 0xB5, 0xF9, 0xB9, 0x27, 0x6A, 0x4D, 0xE4, 0xA6, 0x72, 0x9A, 0xC9, 0x09, 0x78, + 0x65, 0x2F, 0x8A, 0x05, 0x21, 0x0F, 0xE1, 0x24, 0x12, 0xF0, 0x82, 0x45, 0x35, 0x93, 0xDA, 0x8E, + 0x96, 0x8F, 0xDB, 0xBD, 0x36, 0xD0, 0xCE, 0x94, 0x13, 0x5C, 0xD2, 0xF1, 0x40, 0x46, 0x83, 0x38, + 0x66, 0xDD, 0xFD, 0x30, 0xBF, 0x06, 0x8B, 0x62, 0xB3, 0x25, 0xE2, 0x98, 0x22, 0x88, 0x91, 0x10, + 0x7E, 0x6E, 0x48, 0xC3, 0xA3, 0xB6, 0x1E, 0x42, 0x3A, 0x6B, 0x28, 0x54, 0xFA, 0x85, 0x3D, 0xBA, + 0x2B, 0x79, 0x0A, 0x15, 0x9B, 0x9F, 0x5E, 0xCA, 0x4E, 0xD4, 0xAC, 0xE5, 0xF3, 0x73, 0xA7, 0x57, + 0xAF, 0x58, 0xA8, 0x50, 0xF4, 0xEA, 0xD6, 0x74, 0x4F, 0xAE, 0xE9, 0xD5, 0xE7, 0xE6, 0xAD, 0xE8, + 0x2C, 0xD7, 0x75, 0x7A, 0xEB, 0x16, 0x0B, 0xF5, 0x59, 0xCB, 0x5F, 0xB0, 0x9C, 0xA9, 0x51, 0xA0, + 0x7F, 0x0C, 0xF6, 0x6F, 0x17, 0xC4, 0x49, 0xEC, 0xD8, 0x43, 0x1F, 0x2D, 0xA4, 0x76, 0x7B, 0xB7, + 0xCC, 0xBB, 0x3E, 0x5A, 0xFB, 0x60, 0xB1, 0x86, 0x3B, 0x52, 0xA1, 0x6C, 0xAA, 0x55, 0x29, 0x9D, + 0x97, 0xB2, 0x87, 0x90, 0x61, 0xBE, 0xDC, 0xFC, 0xBC, 0x95, 0xCF, 0xCD, 0x37, 0x3F, 0x5B, 0xD1, + 0x53, 0x39, 0x84, 0x3C, 0x41, 0xA2, 0x6D, 0x47, 0x14, 0x2A, 0x9E, 0x5D, 0x56, 0xF2, 0xD3, 0xAB, + 0x44, 0x11, 0x92, 0xD9, 0x23, 0x20, 0x2E, 0x89, 0xB4, 0x7C, 0xB8, 0x26, 0x77, 0x99, 0xE3, 0xA5, + 0x67, 0x4A, 0xED, 0xDE, 0xC5, 0x31, 0xFE, 0x18, 0x0D, 0x63, 0x8C, 0x80, 0xC0, 0xF7, 0x70, 0x07}; + +local MIXTABLE = { + 0x02, 0x03, 0x01, 0x01, + 0x01, 0x02, 0x03, 0x01, + 0x01, 0x01, 0x02, 0x03, + 0x03, 0x01, 0x01, 0x02}; + +local IMIXTABLE = { + 0x0E, 0x0B, 0x0D, 0x09, + 0x09, 0x0E, 0x0B, 0x0D, + 0x0D, 0x09, 0x0E, 0x0B, + 0x0B, 0x0D, 0x09, 0x0E}; + +local RCON = { +[0] = 0x8d, 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36, 0x6c, 0xd8, 0xab, 0x4d, 0x9a, +0x2f, 0x5e, 0xbc, 0x63, 0xc6, 0x97, 0x35, 0x6a, 0xd4, 0xb3, 0x7d, 0xfa, 0xef, 0xc5, 0x91, 0x39, +0x72, 0xe4, 0xd3, 0xbd, 0x61, 0xc2, 0x9f, 0x25, 0x4a, 0x94, 0x33, 0x66, 0xcc, 0x83, 0x1d, 0x3a, +0x74, 0xe8, 0xcb, 0x8d, 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36, 0x6c, 0xd8, +0xab, 0x4d, 0x9a, 0x2f, 0x5e, 0xbc, 0x63, 0xc6, 0x97, 0x35, 0x6a, 0xd4, 0xb3, 0x7d, 0xfa, 0xef, +0xc5, 0x91, 0x39, 0x72, 0xe4, 0xd3, 0xbd, 0x61, 0xc2, 0x9f, 0x25, 0x4a, 0x94, 0x33, 0x66, 0xcc, +0x83, 0x1d, 0x3a, 0x74, 0xe8, 0xcb, 0x8d, 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, +0x36, 0x6c, 0xd8, 0xab, 0x4d, 0x9a, 0x2f, 0x5e, 0xbc, 0x63, 0xc6, 0x97, 0x35, 0x6a, 0xd4, 0xb3, +0x7d, 0xfa, 0xef, 0xc5, 0x91, 0x39, 0x72, 0xe4, 0xd3, 0xbd, 0x61, 0xc2, 0x9f, 0x25, 0x4a, 0x94, +0x33, 0x66, 0xcc, 0x83, 0x1d, 0x3a, 0x74, 0xe8, 0xcb, 0x8d, 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, +0x40, 0x80, 0x1b, 0x36, 0x6c, 0xd8, 0xab, 0x4d, 0x9a, 0x2f, 0x5e, 0xbc, 0x63, 0xc6, 0x97, 0x35, +0x6a, 0xd4, 0xb3, 0x7d, 0xfa, 0xef, 0xc5, 0x91, 0x39, 0x72, 0xe4, 0xd3, 0xbd, 0x61, 0xc2, 0x9f, +0x25, 0x4a, 0x94, 0x33, 0x66, 0xcc, 0x83, 0x1d, 0x3a, 0x74, 0xe8, 0xcb, 0x8d, 0x01, 0x02, 0x04, +0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36, 0x6c, 0xd8, 0xab, 0x4d, 0x9a, 0x2f, 0x5e, 0xbc, 0x63, +0xc6, 0x97, 0x35, 0x6a, 0xd4, 0xb3, 0x7d, 0xfa, 0xef, 0xc5, 0x91, 0x39, 0x72, 0xe4, 0xd3, 0xbd, +0x61, 0xc2, 0x9f, 0x25, 0x4a, 0x94, 0x33, 0x66, 0xcc, 0x83, 0x1d, 0x3a, 0x74, 0xe8, 0xcb, 0x8d}; + + +local GMUL = function(A, B) + if(A == 0x01) then return B; end + if(B == 0x01) then return A; end + if(A == 0x00) then return 0; end + if(B == 0x00) then return 0; end + + local LA = LTABLE[A]; + local LB = LTABLE[B]; + + local sum = LA + LB; + if (sum > 0xFF) then sum = sum - 0xFF; end + + return ETABLE[sum]; +end + +local byteSub = Array.substitute; + +local shiftRow = Array.permute; + +local mixCol = function(i, mix) + local out = {}; + + local a, b, c, d; + + a = GMUL(i[ 1], mix[ 1]); + b = GMUL(i[ 2], mix[ 2]); + c = GMUL(i[ 3], mix[ 3]); + d = GMUL(i[ 4], mix[ 4]); + out[ 1] = XOR(XOR(a, b), XOR(c, d)); + a = GMUL(i[ 1], mix[ 5]); + b = GMUL(i[ 2], mix[ 6]); + c = GMUL(i[ 3], mix[ 7]); + d = GMUL(i[ 4], mix[ 8]); + out[ 2] = XOR(XOR(a, b), XOR(c, d)); + a = GMUL(i[ 1], mix[ 9]); + b = GMUL(i[ 2], mix[10]); + c = GMUL(i[ 3], mix[11]); + d = GMUL(i[ 4], mix[12]); + out[ 3] = XOR(XOR(a, b), XOR(c, d)); + a = GMUL(i[ 1], mix[13]); + b = GMUL(i[ 2], mix[14]); + c = GMUL(i[ 3], mix[15]); + d = GMUL(i[ 4], mix[16]); + out[ 4] = XOR(XOR(a, b), XOR(c, d)); + + + a = GMUL(i[ 5], mix[ 1]); + b = GMUL(i[ 6], mix[ 2]); + c = GMUL(i[ 7], mix[ 3]); + d = GMUL(i[ 8], mix[ 4]); + out[ 5] = XOR(XOR(a, b), XOR(c, d)); + a = GMUL(i[ 5], mix[ 5]); + b = GMUL(i[ 6], mix[ 6]); + c = GMUL(i[ 7], mix[ 7]); + d = GMUL(i[ 8], mix[ 8]); + out[ 6] = XOR(XOR(a, b), XOR(c, d)); + a = GMUL(i[ 5], mix[ 9]); + b = GMUL(i[ 6], mix[10]); + c = GMUL(i[ 7], mix[11]); + d = GMUL(i[ 8], mix[12]); + out[ 7] = XOR(XOR(a, b), XOR(c, d)); + a = GMUL(i[ 5], mix[13]); + b = GMUL(i[ 6], mix[14]); + c = GMUL(i[ 7], mix[15]); + d = GMUL(i[ 8], mix[16]); + out[ 8] = XOR(XOR(a, b), XOR(c, d)); + + + a = GMUL(i[ 9], mix[ 1]); + b = GMUL(i[10], mix[ 2]); + c = GMUL(i[11], mix[ 3]); + d = GMUL(i[12], mix[ 4]); + out[ 9] = XOR(XOR(a, b), XOR(c, d)); + a = GMUL(i[ 9], mix[ 5]); + b = GMUL(i[10], mix[ 6]); + c = GMUL(i[11], mix[ 7]); + d = GMUL(i[12], mix[ 8]); + out[10] = XOR(XOR(a, b), XOR(c, d)); + a = GMUL(i[ 9], mix[ 9]); + b = GMUL(i[10], mix[10]); + c = GMUL(i[11], mix[11]); + d = GMUL(i[12], mix[12]); + out[11] = XOR(XOR(a, b), XOR(c, d)); + a = GMUL(i[ 9], mix[13]); + b = GMUL(i[10], mix[14]); + c = GMUL(i[11], mix[15]); + d = GMUL(i[12], mix[16]); + out[12] = XOR(XOR(a, b), XOR(c, d)); + + + a = GMUL(i[13], mix[ 1]); + b = GMUL(i[14], mix[ 2]); + c = GMUL(i[15], mix[ 3]); + d = GMUL(i[16], mix[ 4]); + out[13] = XOR(XOR(a, b), XOR(c, d)); + a = GMUL(i[13], mix[ 5]); + b = GMUL(i[14], mix[ 6]); + c = GMUL(i[15], mix[ 7]); + d = GMUL(i[16], mix[ 8]); + out[14] = XOR(XOR(a, b), XOR(c, d)); + a = GMUL(i[13], mix[ 9]); + b = GMUL(i[14], mix[10]); + c = GMUL(i[15], mix[11]); + d = GMUL(i[16], mix[12]); + out[15] = XOR(XOR(a, b), XOR(c, d)); + a = GMUL(i[13], mix[13]); + b = GMUL(i[14], mix[14]); + c = GMUL(i[15], mix[15]); + d = GMUL(i[16], mix[16]); + out[16] = XOR(XOR(a, b), XOR(c, d)); + + return out; +end + +local keyRound = function(key, round) + local i = (round - 1) * 24; + local out = key; + + out[25 + i] = XOR(key[ 1 + i], XOR(SBOX[key[22 + i]], RCON[round])); + out[26 + i] = XOR(key[ 2 + i], SBOX[key[23 + i]]); + out[27 + i] = XOR(key[ 3 + i], SBOX[key[24 + i]]); + out[28 + i] = XOR(key[ 4 + i], SBOX[key[21 + i]]); + + out[29 + i] = XOR(out[25 + i], key[ 5 + i]); + out[30 + i] = XOR(out[26 + i], key[ 6 + i]); + out[31 + i] = XOR(out[27 + i], key[ 7 + i]); + out[32 + i] = XOR(out[28 + i], key[ 8 + i]); + + out[33 + i] = XOR(out[29 + i], key[ 9 + i]); + out[34 + i] = XOR(out[30 + i], key[10 + i]); + out[35 + i] = XOR(out[31 + i], key[11 + i]); + out[36 + i] = XOR(out[32 + i], key[12 + i]); + + out[37 + i] = XOR(out[33 + i], key[13 + i]); + out[38 + i] = XOR(out[34 + i], key[14 + i]); + out[39 + i] = XOR(out[35 + i], key[15 + i]); + out[40 + i] = XOR(out[36 + i], key[16 + i]); + + out[41 + i] = XOR(out[37 + i], key[17 + i]); + out[42 + i] = XOR(out[38 + i], key[18 + i]); + out[43 + i] = XOR(out[39 + i], key[19 + i]); + out[44 + i] = XOR(out[40 + i], key[20 + i]); + + out[45 + i] = XOR(out[41 + i], key[21 + i]); + out[46 + i] = XOR(out[42 + i], key[22 + i]); + out[47 + i] = XOR(out[43 + i], key[23 + i]); + out[48 + i] = XOR(out[44 + i], key[24 + i]); + + return out; +end + +local keyExpand = function(key) + local bytes = Array.copy(key); + + for i = 1, 8 do + keyRound(bytes, i); + end + + local keys = {}; + + keys[ 1] = Array.slice(bytes, 1, 16); + keys[ 2] = Array.slice(bytes, 17, 32); + keys[ 3] = Array.slice(bytes, 33, 48); + keys[ 4] = Array.slice(bytes, 49, 64); + keys[ 5] = Array.slice(bytes, 65, 80); + keys[ 6] = Array.slice(bytes, 81, 96); + keys[ 7] = Array.slice(bytes, 97, 112); + keys[ 8] = Array.slice(bytes, 113, 128); + keys[ 9] = Array.slice(bytes, 129, 144); + keys[10] = Array.slice(bytes, 145, 160); + keys[11] = Array.slice(bytes, 161, 176); + keys[12] = Array.slice(bytes, 177, 192); + keys[13] = Array.slice(bytes, 193, 208); + + return keys; + +end + +local addKey = Array.XOR; + + + +local AES = {}; + +AES.blockSize = 16; + +AES.encrypt = function(_key, block) + + local key = keyExpand(_key); + + --round 0 + block = addKey(block, key[1]); + + --round 1 + block = byteSub(block, SBOX); + block = shiftRow(block, ROW_SHIFT); + block = mixCol(block, MIXTABLE); + block = addKey(block, key[2]); + + --round 2 + block = byteSub(block, SBOX); + block = shiftRow(block, ROW_SHIFT); + block = mixCol(block, MIXTABLE); + block = addKey(block, key[3]); + + --round 3 + block = byteSub(block, SBOX); + block = shiftRow(block, ROW_SHIFT); + block = mixCol(block, MIXTABLE); + block = addKey(block, key[4]); + + --round 4 + block = byteSub(block, SBOX); + block = shiftRow(block, ROW_SHIFT); + block = mixCol(block, MIXTABLE); + block = addKey(block, key[5]); + + --round 5 + block = byteSub(block, SBOX); + block = shiftRow(block, ROW_SHIFT); + block = mixCol(block, MIXTABLE); + block = addKey(block, key[6]); + + --round 6 + block = byteSub(block, SBOX); + block = shiftRow(block, ROW_SHIFT); + block = mixCol(block, MIXTABLE); + block = addKey(block, key[7]); + + --round 7 + block = byteSub(block, SBOX); + block = shiftRow(block, ROW_SHIFT); + block = mixCol(block, MIXTABLE); + block = addKey(block, key[8]); + + --round 8 + block = byteSub(block, SBOX); + block = shiftRow(block, ROW_SHIFT); + block = mixCol(block, MIXTABLE); + block = addKey(block, key[9]); + + --round 9 + block = byteSub(block, SBOX); + block = shiftRow(block, ROW_SHIFT); + block = mixCol(block, MIXTABLE); + block = addKey(block, key[10]); + + --round 10 + block = byteSub(block, SBOX); + block = shiftRow(block, ROW_SHIFT); + block = mixCol(block, MIXTABLE); + block = addKey(block, key[11]); + + --round 11 + block = byteSub(block, SBOX); + block = shiftRow(block, ROW_SHIFT); + block = mixCol(block, MIXTABLE); + block = addKey(block, key[12]); + + --round 12 + block = byteSub(block, SBOX); + block = shiftRow(block, ROW_SHIFT); + block = addKey(block, key[13]); + + return block; + +end + +AES.decrypt = function(_key, block) + + local key = keyExpand(_key); + + --round 0 + block = addKey(block, key[13]); + + --round 1 + block = shiftRow(block, IROW_SHIFT); + block = byteSub(block, ISBOX); + block = addKey(block, key[12]); + block = mixCol(block, IMIXTABLE); + + --round 2 + block = shiftRow(block, IROW_SHIFT); + block = byteSub(block, ISBOX); + block = addKey(block, key[11]); + block = mixCol(block, IMIXTABLE); + + --round 3 + block = shiftRow(block, IROW_SHIFT); + block = byteSub(block, ISBOX); + block = addKey(block, key[10]); + block = mixCol(block, IMIXTABLE); + + --round 4 + block = shiftRow(block, IROW_SHIFT); + block = byteSub(block, ISBOX); + block = addKey(block, key[9]); + block = mixCol(block, IMIXTABLE); + + --round 5 + block = shiftRow(block, IROW_SHIFT); + block = byteSub(block, ISBOX); + block = addKey(block, key[8]); + block = mixCol(block, IMIXTABLE); + + --round 6 + block = shiftRow(block, IROW_SHIFT); + block = byteSub(block, ISBOX); + block = addKey(block, key[7]); + block = mixCol(block, IMIXTABLE); + + --round 7 + block = shiftRow(block, IROW_SHIFT); + block = byteSub(block, ISBOX); + block = addKey(block, key[6]); + block = mixCol(block, IMIXTABLE); + + --round 8 + block = shiftRow(block, IROW_SHIFT); + block = byteSub(block, ISBOX); + block = addKey(block, key[5]); + block = mixCol(block, IMIXTABLE); + + --round 9 + block = shiftRow(block, IROW_SHIFT); + block = byteSub(block, ISBOX); + block = addKey(block, key[4]); + block = mixCol(block, IMIXTABLE); + + --round 10 + block = shiftRow(block, IROW_SHIFT); + block = byteSub(block, ISBOX); + block = addKey(block, key[3]); + block = mixCol(block, IMIXTABLE); + + --round 11 + block = shiftRow(block, IROW_SHIFT); + block = byteSub(block, ISBOX); + block = addKey(block, key[2]); + block = mixCol(block, IMIXTABLE); + + --round 12 + block = shiftRow(block, IROW_SHIFT); + block = byteSub(block, ISBOX); + block = addKey(block, key[1]); + + return block; +end + +return AES; diff --git a/lockbox/cipher/aes256.lua b/lockbox/cipher/aes256.lua new file mode 100644 index 0000000..854bae9 --- /dev/null +++ b/lockbox/cipher/aes256.lua @@ -0,0 +1,498 @@ +local Array = require("lockbox.util.array"); +local Bit = require("lockbox.util.bit"); + +local XOR = Bit.bxor; + +local SBOX = { + [0] = 0x63, 0x7C, 0x77, 0x7B, 0xF2, 0x6B, 0x6F, 0xC5, 0x30, 0x01, 0x67, 0x2B, 0xFE, 0xD7, 0xAB, 0x76, + 0xCA, 0x82, 0xC9, 0x7D, 0xFA, 0x59, 0x47, 0xF0, 0xAD, 0xD4, 0xA2, 0xAF, 0x9C, 0xA4, 0x72, 0xC0, + 0xB7, 0xFD, 0x93, 0x26, 0x36, 0x3F, 0xF7, 0xCC, 0x34, 0xA5, 0xE5, 0xF1, 0x71, 0xD8, 0x31, 0x15, + 0x04, 0xC7, 0x23, 0xC3, 0x18, 0x96, 0x05, 0x9A, 0x07, 0x12, 0x80, 0xE2, 0xEB, 0x27, 0xB2, 0x75, + 0x09, 0x83, 0x2C, 0x1A, 0x1B, 0x6E, 0x5A, 0xA0, 0x52, 0x3B, 0xD6, 0xB3, 0x29, 0xE3, 0x2F, 0x84, + 0x53, 0xD1, 0x00, 0xED, 0x20, 0xFC, 0xB1, 0x5B, 0x6A, 0xCB, 0xBE, 0x39, 0x4A, 0x4C, 0x58, 0xCF, + 0xD0, 0xEF, 0xAA, 0xFB, 0x43, 0x4D, 0x33, 0x85, 0x45, 0xF9, 0x02, 0x7F, 0x50, 0x3C, 0x9F, 0xA8, + 0x51, 0xA3, 0x40, 0x8F, 0x92, 0x9D, 0x38, 0xF5, 0xBC, 0xB6, 0xDA, 0x21, 0x10, 0xFF, 0xF3, 0xD2, + 0xCD, 0x0C, 0x13, 0xEC, 0x5F, 0x97, 0x44, 0x17, 0xC4, 0xA7, 0x7E, 0x3D, 0x64, 0x5D, 0x19, 0x73, + 0x60, 0x81, 0x4F, 0xDC, 0x22, 0x2A, 0x90, 0x88, 0x46, 0xEE, 0xB8, 0x14, 0xDE, 0x5E, 0x0B, 0xDB, + 0xE0, 0x32, 0x3A, 0x0A, 0x49, 0x06, 0x24, 0x5C, 0xC2, 0xD3, 0xAC, 0x62, 0x91, 0x95, 0xE4, 0x79, + 0xE7, 0xC8, 0x37, 0x6D, 0x8D, 0xD5, 0x4E, 0xA9, 0x6C, 0x56, 0xF4, 0xEA, 0x65, 0x7A, 0xAE, 0x08, + 0xBA, 0x78, 0x25, 0x2E, 0x1C, 0xA6, 0xB4, 0xC6, 0xE8, 0xDD, 0x74, 0x1F, 0x4B, 0xBD, 0x8B, 0x8A, + 0x70, 0x3E, 0xB5, 0x66, 0x48, 0x03, 0xF6, 0x0E, 0x61, 0x35, 0x57, 0xB9, 0x86, 0xC1, 0x1D, 0x9E, + 0xE1, 0xF8, 0x98, 0x11, 0x69, 0xD9, 0x8E, 0x94, 0x9B, 0x1E, 0x87, 0xE9, 0xCE, 0x55, 0x28, 0xDF, + 0x8C, 0xA1, 0x89, 0x0D, 0xBF, 0xE6, 0x42, 0x68, 0x41, 0x99, 0x2D, 0x0F, 0xB0, 0x54, 0xBB, 0x16}; + +local ISBOX = { + [0] = 0x52, 0x09, 0x6A, 0xD5, 0x30, 0x36, 0xA5, 0x38, 0xBF, 0x40, 0xA3, 0x9E, 0x81, 0xF3, 0xD7, 0xFB, + 0x7C, 0xE3, 0x39, 0x82, 0x9B, 0x2F, 0xFF, 0x87, 0x34, 0x8E, 0x43, 0x44, 0xC4, 0xDE, 0xE9, 0xCB, + 0x54, 0x7B, 0x94, 0x32, 0xA6, 0xC2, 0x23, 0x3D, 0xEE, 0x4C, 0x95, 0x0B, 0x42, 0xFA, 0xC3, 0x4E, + 0x08, 0x2E, 0xA1, 0x66, 0x28, 0xD9, 0x24, 0xB2, 0x76, 0x5B, 0xA2, 0x49, 0x6D, 0x8B, 0xD1, 0x25, + 0x72, 0xF8, 0xF6, 0x64, 0x86, 0x68, 0x98, 0x16, 0xD4, 0xA4, 0x5C, 0xCC, 0x5D, 0x65, 0xB6, 0x92, + 0x6C, 0x70, 0x48, 0x50, 0xFD, 0xED, 0xB9, 0xDA, 0x5E, 0x15, 0x46, 0x57, 0xA7, 0x8D, 0x9D, 0x84, + 0x90, 0xD8, 0xAB, 0x00, 0x8C, 0xBC, 0xD3, 0x0A, 0xF7, 0xE4, 0x58, 0x05, 0xB8, 0xB3, 0x45, 0x06, + 0xD0, 0x2C, 0x1E, 0x8F, 0xCA, 0x3F, 0x0F, 0x02, 0xC1, 0xAF, 0xBD, 0x03, 0x01, 0x13, 0x8A, 0x6B, + 0x3A, 0x91, 0x11, 0x41, 0x4F, 0x67, 0xDC, 0xEA, 0x97, 0xF2, 0xCF, 0xCE, 0xF0, 0xB4, 0xE6, 0x73, + 0x96, 0xAC, 0x74, 0x22, 0xE7, 0xAD, 0x35, 0x85, 0xE2, 0xF9, 0x37, 0xE8, 0x1C, 0x75, 0xDF, 0x6E, + 0x47, 0xF1, 0x1A, 0x71, 0x1D, 0x29, 0xC5, 0x89, 0x6F, 0xB7, 0x62, 0x0E, 0xAA, 0x18, 0xBE, 0x1B, + 0xFC, 0x56, 0x3E, 0x4B, 0xC6, 0xD2, 0x79, 0x20, 0x9A, 0xDB, 0xC0, 0xFE, 0x78, 0xCD, 0x5A, 0xF4, + 0x1F, 0xDD, 0xA8, 0x33, 0x88, 0x07, 0xC7, 0x31, 0xB1, 0x12, 0x10, 0x59, 0x27, 0x80, 0xEC, 0x5F, + 0x60, 0x51, 0x7F, 0xA9, 0x19, 0xB5, 0x4A, 0x0D, 0x2D, 0xE5, 0x7A, 0x9F, 0x93, 0xC9, 0x9C, 0xEF, + 0xA0, 0xE0, 0x3B, 0x4D, 0xAE, 0x2A, 0xF5, 0xB0, 0xC8, 0xEB, 0xBB, 0x3C, 0x83, 0x53, 0x99, 0x61, + 0x17, 0x2B, 0x04, 0x7E, 0xBA, 0x77, 0xD6, 0x26, 0xE1, 0x69, 0x14, 0x63, 0x55, 0x21, 0x0C, 0x7D}; + +local ROW_SHIFT = { 1, 6, 11, 16, 5, 10, 15, 4, 9, 14, 3, 8, 13, 2, 7, 12, }; +local IROW_SHIFT = { 1, 14, 11, 8, 5, 2, 15, 12, 9, 6, 3, 16, 13, 10, 7, 4, }; + +local ETABLE = { + [0] = 0x01, 0x03, 0x05, 0x0F, 0x11, 0x33, 0x55, 0xFF, 0x1A, 0x2E, 0x72, 0x96, 0xA1, 0xF8, 0x13, 0x35, + 0x5F, 0xE1, 0x38, 0x48, 0xD8, 0x73, 0x95, 0xA4, 0xF7, 0x02, 0x06, 0x0A, 0x1E, 0x22, 0x66, 0xAA, + 0xE5, 0x34, 0x5C, 0xE4, 0x37, 0x59, 0xEB, 0x26, 0x6A, 0xBE, 0xD9, 0x70, 0x90, 0xAB, 0xE6, 0x31, + 0x53, 0xF5, 0x04, 0x0C, 0x14, 0x3C, 0x44, 0xCC, 0x4F, 0xD1, 0x68, 0xB8, 0xD3, 0x6E, 0xB2, 0xCD, + 0x4C, 0xD4, 0x67, 0xA9, 0xE0, 0x3B, 0x4D, 0xD7, 0x62, 0xA6, 0xF1, 0x08, 0x18, 0x28, 0x78, 0x88, + 0x83, 0x9E, 0xB9, 0xD0, 0x6B, 0xBD, 0xDC, 0x7F, 0x81, 0x98, 0xB3, 0xCE, 0x49, 0xDB, 0x76, 0x9A, + 0xB5, 0xC4, 0x57, 0xF9, 0x10, 0x30, 0x50, 0xF0, 0x0B, 0x1D, 0x27, 0x69, 0xBB, 0xD6, 0x61, 0xA3, + 0xFE, 0x19, 0x2B, 0x7D, 0x87, 0x92, 0xAD, 0xEC, 0x2F, 0x71, 0x93, 0xAE, 0xE9, 0x20, 0x60, 0xA0, + 0xFB, 0x16, 0x3A, 0x4E, 0xD2, 0x6D, 0xB7, 0xC2, 0x5D, 0xE7, 0x32, 0x56, 0xFA, 0x15, 0x3F, 0x41, + 0xC3, 0x5E, 0xE2, 0x3D, 0x47, 0xC9, 0x40, 0xC0, 0x5B, 0xED, 0x2C, 0x74, 0x9C, 0xBF, 0xDA, 0x75, + 0x9F, 0xBA, 0xD5, 0x64, 0xAC, 0xEF, 0x2A, 0x7E, 0x82, 0x9D, 0xBC, 0xDF, 0x7A, 0x8E, 0x89, 0x80, + 0x9B, 0xB6, 0xC1, 0x58, 0xE8, 0x23, 0x65, 0xAF, 0xEA, 0x25, 0x6F, 0xB1, 0xC8, 0x43, 0xC5, 0x54, + 0xFC, 0x1F, 0x21, 0x63, 0xA5, 0xF4, 0x07, 0x09, 0x1B, 0x2D, 0x77, 0x99, 0xB0, 0xCB, 0x46, 0xCA, + 0x45, 0xCF, 0x4A, 0xDE, 0x79, 0x8B, 0x86, 0x91, 0xA8, 0xE3, 0x3E, 0x42, 0xC6, 0x51, 0xF3, 0x0E, + 0x12, 0x36, 0x5A, 0xEE, 0x29, 0x7B, 0x8D, 0x8C, 0x8F, 0x8A, 0x85, 0x94, 0xA7, 0xF2, 0x0D, 0x17, + 0x39, 0x4B, 0xDD, 0x7C, 0x84, 0x97, 0xA2, 0xFD, 0x1C, 0x24, 0x6C, 0xB4, 0xC7, 0x52, 0xF6, 0x01}; + +local LTABLE = { + [0] = 0x00, 0x00, 0x19, 0x01, 0x32, 0x02, 0x1A, 0xC6, 0x4B, 0xC7, 0x1B, 0x68, 0x33, 0xEE, 0xDF, 0x03, + 0x64, 0x04, 0xE0, 0x0E, 0x34, 0x8D, 0x81, 0xEF, 0x4C, 0x71, 0x08, 0xC8, 0xF8, 0x69, 0x1C, 0xC1, + 0x7D, 0xC2, 0x1D, 0xB5, 0xF9, 0xB9, 0x27, 0x6A, 0x4D, 0xE4, 0xA6, 0x72, 0x9A, 0xC9, 0x09, 0x78, + 0x65, 0x2F, 0x8A, 0x05, 0x21, 0x0F, 0xE1, 0x24, 0x12, 0xF0, 0x82, 0x45, 0x35, 0x93, 0xDA, 0x8E, + 0x96, 0x8F, 0xDB, 0xBD, 0x36, 0xD0, 0xCE, 0x94, 0x13, 0x5C, 0xD2, 0xF1, 0x40, 0x46, 0x83, 0x38, + 0x66, 0xDD, 0xFD, 0x30, 0xBF, 0x06, 0x8B, 0x62, 0xB3, 0x25, 0xE2, 0x98, 0x22, 0x88, 0x91, 0x10, + 0x7E, 0x6E, 0x48, 0xC3, 0xA3, 0xB6, 0x1E, 0x42, 0x3A, 0x6B, 0x28, 0x54, 0xFA, 0x85, 0x3D, 0xBA, + 0x2B, 0x79, 0x0A, 0x15, 0x9B, 0x9F, 0x5E, 0xCA, 0x4E, 0xD4, 0xAC, 0xE5, 0xF3, 0x73, 0xA7, 0x57, + 0xAF, 0x58, 0xA8, 0x50, 0xF4, 0xEA, 0xD6, 0x74, 0x4F, 0xAE, 0xE9, 0xD5, 0xE7, 0xE6, 0xAD, 0xE8, + 0x2C, 0xD7, 0x75, 0x7A, 0xEB, 0x16, 0x0B, 0xF5, 0x59, 0xCB, 0x5F, 0xB0, 0x9C, 0xA9, 0x51, 0xA0, + 0x7F, 0x0C, 0xF6, 0x6F, 0x17, 0xC4, 0x49, 0xEC, 0xD8, 0x43, 0x1F, 0x2D, 0xA4, 0x76, 0x7B, 0xB7, + 0xCC, 0xBB, 0x3E, 0x5A, 0xFB, 0x60, 0xB1, 0x86, 0x3B, 0x52, 0xA1, 0x6C, 0xAA, 0x55, 0x29, 0x9D, + 0x97, 0xB2, 0x87, 0x90, 0x61, 0xBE, 0xDC, 0xFC, 0xBC, 0x95, 0xCF, 0xCD, 0x37, 0x3F, 0x5B, 0xD1, + 0x53, 0x39, 0x84, 0x3C, 0x41, 0xA2, 0x6D, 0x47, 0x14, 0x2A, 0x9E, 0x5D, 0x56, 0xF2, 0xD3, 0xAB, + 0x44, 0x11, 0x92, 0xD9, 0x23, 0x20, 0x2E, 0x89, 0xB4, 0x7C, 0xB8, 0x26, 0x77, 0x99, 0xE3, 0xA5, + 0x67, 0x4A, 0xED, 0xDE, 0xC5, 0x31, 0xFE, 0x18, 0x0D, 0x63, 0x8C, 0x80, 0xC0, 0xF7, 0x70, 0x07}; + +local MIXTABLE = { + 0x02, 0x03, 0x01, 0x01, + 0x01, 0x02, 0x03, 0x01, + 0x01, 0x01, 0x02, 0x03, + 0x03, 0x01, 0x01, 0x02}; + +local IMIXTABLE = { + 0x0E, 0x0B, 0x0D, 0x09, + 0x09, 0x0E, 0x0B, 0x0D, + 0x0D, 0x09, 0x0E, 0x0B, + 0x0B, 0x0D, 0x09, 0x0E}; + +local RCON = { +[0] = 0x8d, 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36, 0x6c, 0xd8, 0xab, 0x4d, 0x9a, +0x2f, 0x5e, 0xbc, 0x63, 0xc6, 0x97, 0x35, 0x6a, 0xd4, 0xb3, 0x7d, 0xfa, 0xef, 0xc5, 0x91, 0x39, +0x72, 0xe4, 0xd3, 0xbd, 0x61, 0xc2, 0x9f, 0x25, 0x4a, 0x94, 0x33, 0x66, 0xcc, 0x83, 0x1d, 0x3a, +0x74, 0xe8, 0xcb, 0x8d, 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36, 0x6c, 0xd8, +0xab, 0x4d, 0x9a, 0x2f, 0x5e, 0xbc, 0x63, 0xc6, 0x97, 0x35, 0x6a, 0xd4, 0xb3, 0x7d, 0xfa, 0xef, +0xc5, 0x91, 0x39, 0x72, 0xe4, 0xd3, 0xbd, 0x61, 0xc2, 0x9f, 0x25, 0x4a, 0x94, 0x33, 0x66, 0xcc, +0x83, 0x1d, 0x3a, 0x74, 0xe8, 0xcb, 0x8d, 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, +0x36, 0x6c, 0xd8, 0xab, 0x4d, 0x9a, 0x2f, 0x5e, 0xbc, 0x63, 0xc6, 0x97, 0x35, 0x6a, 0xd4, 0xb3, +0x7d, 0xfa, 0xef, 0xc5, 0x91, 0x39, 0x72, 0xe4, 0xd3, 0xbd, 0x61, 0xc2, 0x9f, 0x25, 0x4a, 0x94, +0x33, 0x66, 0xcc, 0x83, 0x1d, 0x3a, 0x74, 0xe8, 0xcb, 0x8d, 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, +0x40, 0x80, 0x1b, 0x36, 0x6c, 0xd8, 0xab, 0x4d, 0x9a, 0x2f, 0x5e, 0xbc, 0x63, 0xc6, 0x97, 0x35, +0x6a, 0xd4, 0xb3, 0x7d, 0xfa, 0xef, 0xc5, 0x91, 0x39, 0x72, 0xe4, 0xd3, 0xbd, 0x61, 0xc2, 0x9f, +0x25, 0x4a, 0x94, 0x33, 0x66, 0xcc, 0x83, 0x1d, 0x3a, 0x74, 0xe8, 0xcb, 0x8d, 0x01, 0x02, 0x04, +0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36, 0x6c, 0xd8, 0xab, 0x4d, 0x9a, 0x2f, 0x5e, 0xbc, 0x63, +0xc6, 0x97, 0x35, 0x6a, 0xd4, 0xb3, 0x7d, 0xfa, 0xef, 0xc5, 0x91, 0x39, 0x72, 0xe4, 0xd3, 0xbd, +0x61, 0xc2, 0x9f, 0x25, 0x4a, 0x94, 0x33, 0x66, 0xcc, 0x83, 0x1d, 0x3a, 0x74, 0xe8, 0xcb, 0x8d}; + + +local GMUL = function(A, B) + if(A == 0x01) then return B; end + if(B == 0x01) then return A; end + if(A == 0x00) then return 0; end + if(B == 0x00) then return 0; end + + local LA = LTABLE[A]; + local LB = LTABLE[B]; + + local sum = LA + LB; + if (sum > 0xFF) then sum = sum - 0xFF; end + + return ETABLE[sum]; +end + +local byteSub = Array.substitute; + +local shiftRow = Array.permute; + +local mixCol = function(i, mix) + local out = {}; + + local a, b, c, d; + + a = GMUL(i[ 1], mix[ 1]); + b = GMUL(i[ 2], mix[ 2]); + c = GMUL(i[ 3], mix[ 3]); + d = GMUL(i[ 4], mix[ 4]); + out[ 1] = XOR(XOR(a, b), XOR(c, d)); + a = GMUL(i[ 1], mix[ 5]); + b = GMUL(i[ 2], mix[ 6]); + c = GMUL(i[ 3], mix[ 7]); + d = GMUL(i[ 4], mix[ 8]); + out[ 2] = XOR(XOR(a, b), XOR(c, d)); + a = GMUL(i[ 1], mix[ 9]); + b = GMUL(i[ 2], mix[10]); + c = GMUL(i[ 3], mix[11]); + d = GMUL(i[ 4], mix[12]); + out[ 3] = XOR(XOR(a, b), XOR(c, d)); + a = GMUL(i[ 1], mix[13]); + b = GMUL(i[ 2], mix[14]); + c = GMUL(i[ 3], mix[15]); + d = GMUL(i[ 4], mix[16]); + out[ 4] = XOR(XOR(a, b), XOR(c, d)); + + + a = GMUL(i[ 5], mix[ 1]); + b = GMUL(i[ 6], mix[ 2]); + c = GMUL(i[ 7], mix[ 3]); + d = GMUL(i[ 8], mix[ 4]); + out[ 5] = XOR(XOR(a, b), XOR(c, d)); + a = GMUL(i[ 5], mix[ 5]); + b = GMUL(i[ 6], mix[ 6]); + c = GMUL(i[ 7], mix[ 7]); + d = GMUL(i[ 8], mix[ 8]); + out[ 6] = XOR(XOR(a, b), XOR(c, d)); + a = GMUL(i[ 5], mix[ 9]); + b = GMUL(i[ 6], mix[10]); + c = GMUL(i[ 7], mix[11]); + d = GMUL(i[ 8], mix[12]); + out[ 7] = XOR(XOR(a, b), XOR(c, d)); + a = GMUL(i[ 5], mix[13]); + b = GMUL(i[ 6], mix[14]); + c = GMUL(i[ 7], mix[15]); + d = GMUL(i[ 8], mix[16]); + out[ 8] = XOR(XOR(a, b), XOR(c, d)); + + + a = GMUL(i[ 9], mix[ 1]); + b = GMUL(i[10], mix[ 2]); + c = GMUL(i[11], mix[ 3]); + d = GMUL(i[12], mix[ 4]); + out[ 9] = XOR(XOR(a, b), XOR(c, d)); + a = GMUL(i[ 9], mix[ 5]); + b = GMUL(i[10], mix[ 6]); + c = GMUL(i[11], mix[ 7]); + d = GMUL(i[12], mix[ 8]); + out[10] = XOR(XOR(a, b), XOR(c, d)); + a = GMUL(i[ 9], mix[ 9]); + b = GMUL(i[10], mix[10]); + c = GMUL(i[11], mix[11]); + d = GMUL(i[12], mix[12]); + out[11] = XOR(XOR(a, b), XOR(c, d)); + a = GMUL(i[ 9], mix[13]); + b = GMUL(i[10], mix[14]); + c = GMUL(i[11], mix[15]); + d = GMUL(i[12], mix[16]); + out[12] = XOR(XOR(a, b), XOR(c, d)); + + + a = GMUL(i[13], mix[ 1]); + b = GMUL(i[14], mix[ 2]); + c = GMUL(i[15], mix[ 3]); + d = GMUL(i[16], mix[ 4]); + out[13] = XOR(XOR(a, b), XOR(c, d)); + a = GMUL(i[13], mix[ 5]); + b = GMUL(i[14], mix[ 6]); + c = GMUL(i[15], mix[ 7]); + d = GMUL(i[16], mix[ 8]); + out[14] = XOR(XOR(a, b), XOR(c, d)); + a = GMUL(i[13], mix[ 9]); + b = GMUL(i[14], mix[10]); + c = GMUL(i[15], mix[11]); + d = GMUL(i[16], mix[12]); + out[15] = XOR(XOR(a, b), XOR(c, d)); + a = GMUL(i[13], mix[13]); + b = GMUL(i[14], mix[14]); + c = GMUL(i[15], mix[15]); + d = GMUL(i[16], mix[16]); + out[16] = XOR(XOR(a, b), XOR(c, d)); + + return out; +end + +local keyRound = function(key, round) + local i = (round - 1) * 32; + local out = key; + + out[33 + i] = XOR(key[ 1 + i], XOR(SBOX[key[30 + i]], RCON[round])); + out[34 + i] = XOR(key[ 2 + i], SBOX[key[31 + i]]); + out[35 + i] = XOR(key[ 3 + i], SBOX[key[32 + i]]); + out[36 + i] = XOR(key[ 4 + i], SBOX[key[29 + i]]); + + out[37 + i] = XOR(out[33 + i], key[ 5 + i]); + out[38 + i] = XOR(out[34 + i], key[ 6 + i]); + out[39 + i] = XOR(out[35 + i], key[ 7 + i]); + out[40 + i] = XOR(out[36 + i], key[ 8 + i]); + + out[41 + i] = XOR(out[37 + i], key[ 9 + i]); + out[42 + i] = XOR(out[38 + i], key[10 + i]); + out[43 + i] = XOR(out[39 + i], key[11 + i]); + out[44 + i] = XOR(out[40 + i], key[12 + i]); + + out[45 + i] = XOR(out[41 + i], key[13 + i]); + out[46 + i] = XOR(out[42 + i], key[14 + i]); + out[47 + i] = XOR(out[43 + i], key[15 + i]); + out[48 + i] = XOR(out[44 + i], key[16 + i]); + + + out[49 + i] = XOR(SBOX[out[45 + i]], key[17 + i]); + out[50 + i] = XOR(SBOX[out[46 + i]], key[18 + i]); + out[51 + i] = XOR(SBOX[out[47 + i]], key[19 + i]); + out[52 + i] = XOR(SBOX[out[48 + i]], key[20 + i]); + + out[53 + i] = XOR(out[49 + i], key[21 + i]); + out[54 + i] = XOR(out[50 + i], key[22 + i]); + out[55 + i] = XOR(out[51 + i], key[23 + i]); + out[56 + i] = XOR(out[52 + i], key[24 + i]); + + out[57 + i] = XOR(out[53 + i], key[25 + i]); + out[58 + i] = XOR(out[54 + i], key[26 + i]); + out[59 + i] = XOR(out[55 + i], key[27 + i]); + out[60 + i] = XOR(out[56 + i], key[28 + i]); + + out[61 + i] = XOR(out[57 + i], key[29 + i]); + out[62 + i] = XOR(out[58 + i], key[30 + i]); + out[63 + i] = XOR(out[59 + i], key[31 + i]); + out[64 + i] = XOR(out[60 + i], key[32 + i]); + + return out; +end + +local keyExpand = function(key) + local bytes = Array.copy(key); + + for i = 1, 7 do + keyRound(bytes, i); + end + + local keys = {}; + + keys[ 1] = Array.slice(bytes, 1, 16); + keys[ 2] = Array.slice(bytes, 17, 32); + keys[ 3] = Array.slice(bytes, 33, 48); + keys[ 4] = Array.slice(bytes, 49, 64); + keys[ 5] = Array.slice(bytes, 65, 80); + keys[ 6] = Array.slice(bytes, 81, 96); + keys[ 7] = Array.slice(bytes, 97, 112); + keys[ 8] = Array.slice(bytes, 113, 128); + keys[ 9] = Array.slice(bytes, 129, 144); + keys[10] = Array.slice(bytes, 145, 160); + keys[11] = Array.slice(bytes, 161, 176); + keys[12] = Array.slice(bytes, 177, 192); + keys[13] = Array.slice(bytes, 193, 208); + keys[14] = Array.slice(bytes, 209, 224); + keys[15] = Array.slice(bytes, 225, 240); + + return keys; + +end + +local addKey = Array.XOR; + + + +local AES = {}; + +AES.blockSize = 16; + +AES.encrypt = function(_key, block) + + local key = keyExpand(_key); + + --round 0 + block = addKey(block, key[1]); + + --round 1 + block = byteSub(block, SBOX); + block = shiftRow(block, ROW_SHIFT); + block = mixCol(block, MIXTABLE); + block = addKey(block, key[2]); + + --round 2 + block = byteSub(block, SBOX); + block = shiftRow(block, ROW_SHIFT); + block = mixCol(block, MIXTABLE); + block = addKey(block, key[3]); + + --round 3 + block = byteSub(block, SBOX); + block = shiftRow(block, ROW_SHIFT); + block = mixCol(block, MIXTABLE); + block = addKey(block, key[4]); + + --round 4 + block = byteSub(block, SBOX); + block = shiftRow(block, ROW_SHIFT); + block = mixCol(block, MIXTABLE); + block = addKey(block, key[5]); + + --round 5 + block = byteSub(block, SBOX); + block = shiftRow(block, ROW_SHIFT); + block = mixCol(block, MIXTABLE); + block = addKey(block, key[6]); + + --round 6 + block = byteSub(block, SBOX); + block = shiftRow(block, ROW_SHIFT); + block = mixCol(block, MIXTABLE); + block = addKey(block, key[7]); + + --round 7 + block = byteSub(block, SBOX); + block = shiftRow(block, ROW_SHIFT); + block = mixCol(block, MIXTABLE); + block = addKey(block, key[8]); + + --round 8 + block = byteSub(block, SBOX); + block = shiftRow(block, ROW_SHIFT); + block = mixCol(block, MIXTABLE); + block = addKey(block, key[9]); + + --round 9 + block = byteSub(block, SBOX); + block = shiftRow(block, ROW_SHIFT); + block = mixCol(block, MIXTABLE); + block = addKey(block, key[10]); + + --round 10 + block = byteSub(block, SBOX); + block = shiftRow(block, ROW_SHIFT); + block = mixCol(block, MIXTABLE); + block = addKey(block, key[11]); + + --round 11 + block = byteSub(block, SBOX); + block = shiftRow(block, ROW_SHIFT); + block = mixCol(block, MIXTABLE); + block = addKey(block, key[12]); + + --round 12 + block = byteSub(block, SBOX); + block = shiftRow(block, ROW_SHIFT); + block = mixCol(block, MIXTABLE); + block = addKey(block, key[13]); + + --round 13 + block = byteSub(block, SBOX); + block = shiftRow(block, ROW_SHIFT); + block = mixCol(block, MIXTABLE); + block = addKey(block, key[14]); + + --round 14 + block = byteSub(block, SBOX); + block = shiftRow(block, ROW_SHIFT); + block = addKey(block, key[15]); + + return block; + +end + +AES.decrypt = function(_key, block) + + local key = keyExpand(_key); + + --round 0 + block = addKey(block, key[15]); + + --round 1 + block = shiftRow(block, IROW_SHIFT); + block = byteSub(block, ISBOX); + block = addKey(block, key[14]); + block = mixCol(block, IMIXTABLE); + + --round 2 + block = shiftRow(block, IROW_SHIFT); + block = byteSub(block, ISBOX); + block = addKey(block, key[13]); + block = mixCol(block, IMIXTABLE); + + --round 3 + block = shiftRow(block, IROW_SHIFT); + block = byteSub(block, ISBOX); + block = addKey(block, key[12]); + block = mixCol(block, IMIXTABLE); + + --round 4 + block = shiftRow(block, IROW_SHIFT); + block = byteSub(block, ISBOX); + block = addKey(block, key[11]); + block = mixCol(block, IMIXTABLE); + + --round 5 + block = shiftRow(block, IROW_SHIFT); + block = byteSub(block, ISBOX); + block = addKey(block, key[10]); + block = mixCol(block, IMIXTABLE); + + --round 6 + block = shiftRow(block, IROW_SHIFT); + block = byteSub(block, ISBOX); + block = addKey(block, key[9]); + block = mixCol(block, IMIXTABLE); + + --round 7 + block = shiftRow(block, IROW_SHIFT); + block = byteSub(block, ISBOX); + block = addKey(block, key[8]); + block = mixCol(block, IMIXTABLE); + + --round 8 + block = shiftRow(block, IROW_SHIFT); + block = byteSub(block, ISBOX); + block = addKey(block, key[7]); + block = mixCol(block, IMIXTABLE); + + --round 9 + block = shiftRow(block, IROW_SHIFT); + block = byteSub(block, ISBOX); + block = addKey(block, key[6]); + block = mixCol(block, IMIXTABLE); + + --round 10 + block = shiftRow(block, IROW_SHIFT); + block = byteSub(block, ISBOX); + block = addKey(block, key[5]); + block = mixCol(block, IMIXTABLE); + + --round 11 + block = shiftRow(block, IROW_SHIFT); + block = byteSub(block, ISBOX); + block = addKey(block, key[4]); + block = mixCol(block, IMIXTABLE); + + --round 12 + block = shiftRow(block, IROW_SHIFT); + block = byteSub(block, ISBOX); + block = addKey(block, key[3]); + block = mixCol(block, IMIXTABLE); + + --round 13 + block = shiftRow(block, IROW_SHIFT); + block = byteSub(block, ISBOX); + block = addKey(block, key[2]); + block = mixCol(block, IMIXTABLE); + + --round 14 + block = shiftRow(block, IROW_SHIFT); + block = byteSub(block, ISBOX); + block = addKey(block, key[1]); + + return block; +end + +return AES; diff --git a/lockbox/cipher/mode/cbc.lua b/lockbox/cipher/mode/cbc.lua new file mode 100644 index 0000000..a02ff2e --- /dev/null +++ b/lockbox/cipher/mode/cbc.lua @@ -0,0 +1,164 @@ +local Array = require("lockbox.util.array"); +local Stream = require("lockbox.util.stream"); +local Queue = require("lockbox.util.queue"); + +local CBC = {}; + +CBC.Cipher = function() + + local public = {}; + + local key; + local blockCipher; + local padding; + local inputQueue; + local outputQueue; + local iv; + + public.setKey = function(keyBytes) + key = keyBytes; + return public; + end + + public.setBlockCipher = function(cipher) + blockCipher = cipher; + return public; + end + + public.setPadding = function(paddingMode) + padding = paddingMode; + return public; + end + + public.init = function() + inputQueue = Queue(); + outputQueue = Queue(); + iv = nil; + return public; + end + + public.update = function(messageStream) + local byte = messageStream(); + while (byte ~= nil) do + inputQueue.push(byte); + if(inputQueue.size() >= blockCipher.blockSize) then + local block = Array.readFromQueue(inputQueue, blockCipher.blockSize); + + if(iv == nil) then + iv = block; + else + local out = Array.XOR(iv, block); + out = blockCipher.encrypt(key, out); + Array.writeToQueue(outputQueue, out); + iv = out; + end + end + byte = messageStream(); + end + return public; + end + + public.finish = function() + local paddingStream = padding(blockCipher.blockSize, inputQueue.getHead()); + public.update(paddingStream); + + return public; + end + + public.getOutputQueue = function() + return outputQueue; + end + + public.asHex = function() + return Stream.toHex(outputQueue.pop); + end + + public.asBytes = function() + return Stream.toArray(outputQueue.pop); + end + + return public; + +end + + +CBC.Decipher = function() + + local public = {}; + + local key; + local blockCipher; + local padding; + local inputQueue; + local outputQueue; + local iv; + + public.setKey = function(keyBytes) + key = keyBytes; + return public; + end + + public.setBlockCipher = function(cipher) + blockCipher = cipher; + return public; + end + + public.setPadding = function(paddingMode) + padding = paddingMode; + return public; + end + + public.init = function() + inputQueue = Queue(); + outputQueue = Queue(); + iv = nil; + return public; + end + + public.update = function(messageStream) + local byte = messageStream(); + while (byte ~= nil) do + inputQueue.push(byte); + if(inputQueue.size() >= blockCipher.blockSize) then + local block = Array.readFromQueue(inputQueue, blockCipher.blockSize); + + if(iv == nil) then + iv = block; + else + local out = block; + out = blockCipher.decrypt(key, out); + out = Array.XOR(iv, out); + Array.writeToQueue(outputQueue, out); + iv = block; + end + end + byte = messageStream(); + end + return public; + end + + public.finish = function() + local paddingStream = padding(blockCipher.blockSize, inputQueue.getHead()); + public.update(paddingStream); + + return public; + end + + public.getOutputQueue = function() + return outputQueue; + end + + public.asHex = function() + return Stream.toHex(outputQueue.pop); + end + + public.asBytes = function() + return Stream.toArray(outputQueue.pop); + end + + return public; + +end + +return CBC; + diff --git a/lockbox/cipher/mode/cfb.lua b/lockbox/cipher/mode/cfb.lua new file mode 100644 index 0000000..c736d52 --- /dev/null +++ b/lockbox/cipher/mode/cfb.lua @@ -0,0 +1,163 @@ +local Array = require("lockbox.util.array"); +local Stream = require("lockbox.util.stream"); +local Queue = require("lockbox.util.queue"); + +local CFB = {}; + +CFB.Cipher = function() + + local public = {}; + + local key; + local blockCipher; + local padding; + local inputQueue; + local outputQueue; + local iv; + + public.setKey = function(keyBytes) + key = keyBytes; + return public; + end + + public.setBlockCipher = function(cipher) + blockCipher = cipher; + return public; + end + + public.setPadding = function(paddingMode) + padding = paddingMode; + return public; + end + + public.init = function() + inputQueue = Queue(); + outputQueue = Queue(); + iv = nil; + return public; + end + + public.update = function(messageStream) + local byte = messageStream(); + while (byte ~= nil) do + inputQueue.push(byte); + if(inputQueue.size() >= blockCipher.blockSize) then + local block = Array.readFromQueue(inputQueue, blockCipher.blockSize); + + if(iv == nil) then + iv = block; + else + local out = iv; + out = blockCipher.encrypt(key, out); + out = Array.XOR(out, block); + Array.writeToQueue(outputQueue, out); + iv = out; + end + end + byte = messageStream(); + end + return public; + end + + public.finish = function() + local paddingStream = padding(blockCipher.blockSize, inputQueue.getHead()); + public.update(paddingStream); + + return public; + end + + public.getOutputQueue = function() + return outputQueue; + end + + public.asHex = function() + return Stream.toHex(outputQueue.pop); + end + + public.asBytes = function() + return Stream.toArray(outputQueue.pop); + end + + return public; + +end + +CFB.Decipher = function() + + local public = {}; + + local key; + local blockCipher; + local padding; + local inputQueue; + local outputQueue; + local iv; + + public.setKey = function(keyBytes) + key = keyBytes; + return public; + end + + public.setBlockCipher = function(cipher) + blockCipher = cipher; + return public; + end + + public.setPadding = function(paddingMode) + padding = paddingMode; + return public; + end + + public.init = function() + inputQueue = Queue(); + outputQueue = Queue(); + iv = nil; + return public; + end + + public.update = function(messageStream) + local byte = messageStream(); + while (byte ~= nil) do + inputQueue.push(byte); + if(inputQueue.size() >= blockCipher.blockSize) then + local block = Array.readFromQueue(inputQueue, blockCipher.blockSize); + + if(iv == nil) then + iv = block; + else + local out = iv; + out = blockCipher.encrypt(key, out); + out = Array.XOR(out, block); + Array.writeToQueue(outputQueue, out); + iv = block; + end + end + byte = messageStream(); + end + return public; + end + + public.finish = function() + local paddingStream = padding(blockCipher.blockSize, inputQueue.getHead()); + public.update(paddingStream); + + return public; + end + + public.getOutputQueue = function() + return outputQueue; + end + + public.asHex = function() + return Stream.toHex(outputQueue.pop); + end + + public.asBytes = function() + return Stream.toArray(outputQueue.pop); + end + + return public; + +end + +return CFB; diff --git a/lockbox/cipher/mode/ctr.lua b/lockbox/cipher/mode/ctr.lua new file mode 100644 index 0000000..beb8ef0 --- /dev/null +++ b/lockbox/cipher/mode/ctr.lua @@ -0,0 +1,248 @@ +local Array = require("lockbox.util.array"); +local Stream = require("lockbox.util.stream"); +local Queue = require("lockbox.util.queue"); + +local Bit = require("lockbox.util.bit"); + +local AND = Bit.band; + +local CTR = {}; + +CTR.Cipher = function() + + local public = {}; + + local key; + local blockCipher; + local padding; + local inputQueue; + local outputQueue; + local iv; + + public.setKey = function(keyBytes) + key = keyBytes; + return public; + end + + public.setBlockCipher = function(cipher) + blockCipher = cipher; + return public; + end + + public.setPadding = function(paddingMode) + padding = paddingMode; + return public; + end + + public.init = function() + inputQueue = Queue(); + outputQueue = Queue(); + iv = nil; + return public; + end + + local updateIV = function() + iv[16] = iv[16] + 1; + if iv[16] <= 0xFF then return; end + iv[16] = AND(iv[16], 0xFF); + + iv[15] = iv[15] + 1; + if iv[15] <= 0xFF then return; end + iv[15] = AND(iv[15], 0xFF); + + iv[14] = iv[14] + 1; + if iv[14] <= 0xFF then return; end + iv[14] = AND(iv[14], 0xFF); + + iv[13] = iv[13] + 1; + if iv[13] <= 0xFF then return; end + iv[13] = AND(iv[13], 0xFF); + + iv[12] = iv[12] + 1; + if iv[12] <= 0xFF then return; end + iv[12] = AND(iv[12], 0xFF); + + iv[11] = iv[11] + 1; + if iv[11] <= 0xFF then return; end + iv[11] = AND(iv[11], 0xFF); + + iv[10] = iv[10] + 1; + if iv[10] <= 0xFF then return; end + iv[10] = AND(iv[10], 0xFF); + + iv[9] = iv[9] + 1; + if iv[9] <= 0xFF then return; end + iv[9] = AND(iv[9], 0xFF); + + return; + end + + public.update = function(messageStream) + local byte = messageStream(); + while (byte ~= nil) do + inputQueue.push(byte); + + if(inputQueue.size() >= blockCipher.blockSize) then + local block = Array.readFromQueue(inputQueue, blockCipher.blockSize); + + if(iv == nil) then + iv = block; + else + local out = iv; + out = blockCipher.encrypt(key, out); + + out = Array.XOR(out, block); + Array.writeToQueue(outputQueue, out); + updateIV(); + end + end + byte = messageStream(); + end + return public; + end + + public.finish = function() + local paddingStream = padding(blockCipher.blockSize, inputQueue.getHead()); + public.update(paddingStream); + + return public; + end + + public.getOutputQueue = function() + return outputQueue; + end + + public.asHex = function() + return Stream.toHex(outputQueue.pop); + end + + public.asBytes = function() + return Stream.toArray(outputQueue.pop); + end + + return public; + +end + + +CTR.Decipher = function() + + local public = {}; + + local key; + local blockCipher; + local padding; + local inputQueue; + local outputQueue; + local iv; + + public.setKey = function(keyBytes) + key = keyBytes; + return public; + end + + public.setBlockCipher = function(cipher) + blockCipher = cipher; + return public; + end + + public.setPadding = function(paddingMode) + padding = paddingMode; + return public; + end + + public.init = function() + inputQueue = Queue(); + outputQueue = Queue(); + iv = nil; + return public; + end + + local updateIV = function() + iv[16] = iv[16] + 1; + if iv[16] <= 0xFF then return; end + iv[16] = AND(iv[16], 0xFF); + + iv[15] = iv[15] + 1; + if iv[15] <= 0xFF then return; end + iv[15] = AND(iv[15], 0xFF); + + iv[14] = iv[14] + 1; + if iv[14] <= 0xFF then return; end + iv[14] = AND(iv[14], 0xFF); + + iv[13] = iv[13] + 1; + if iv[13] <= 0xFF then return; end + iv[13] = AND(iv[13], 0xFF); + + iv[12] = iv[12] + 1; + if iv[12] <= 0xFF then return; end + iv[12] = AND(iv[12], 0xFF); + + iv[11] = iv[11] + 1; + if iv[11] <= 0xFF then return; end + iv[11] = AND(iv[11], 0xFF); + + iv[10] = iv[10] + 1; + if iv[10] <= 0xFF then return; end + iv[10] = AND(iv[10], 0xFF); + + iv[9] = iv[9] + 1; + if iv[9] <= 0xFF then return; end + iv[9] = AND(iv[9], 0xFF); + + return; + end + + public.update = function(messageStream) + local byte = messageStream(); + while (byte ~= nil) do + inputQueue.push(byte); + + if(inputQueue.size() >= blockCipher.blockSize) then + local block = Array.readFromQueue(inputQueue, blockCipher.blockSize); + + if(iv == nil) then + iv = block; + else + local out = iv; + out = blockCipher.encrypt(key, out); + + out = Array.XOR(out, block); + Array.writeToQueue(outputQueue, out); + updateIV(); + end + end + byte = messageStream(); + end + return public; + end + + public.finish = function() + local paddingStream = padding(blockCipher.blockSize, inputQueue.getHead()); + public.update(paddingStream); + + return public; + end + + public.getOutputQueue = function() + return outputQueue; + end + + public.asHex = function() + return Stream.toHex(outputQueue.pop); + end + + public.asBytes = function() + return Stream.toArray(outputQueue.pop); + end + + return public; + +end + + + + +return CTR; + diff --git a/lockbox/cipher/mode/ofb.lua b/lockbox/cipher/mode/ofb.lua new file mode 100644 index 0000000..a824846 --- /dev/null +++ b/lockbox/cipher/mode/ofb.lua @@ -0,0 +1,164 @@ +local Array = require("lockbox.util.array"); +local Stream = require("lockbox.util.stream"); +local Queue = require("lockbox.util.queue"); + +local OFB = {}; + +OFB.Cipher = function() + + local public = {}; + + local key; + local blockCipher; + local padding; + local inputQueue; + local outputQueue; + local iv; + + public.setKey = function(keyBytes) + key = keyBytes; + return public; + end + + public.setBlockCipher = function(cipher) + blockCipher = cipher; + return public; + end + + public.setPadding = function(paddingMode) + padding = paddingMode; + return public; + end + + public.init = function() + inputQueue = Queue(); + outputQueue = Queue(); + iv = nil; + return public; + end + + public.update = function(messageStream) + local byte = messageStream(); + while (byte ~= nil) do + inputQueue.push(byte); + if(inputQueue.size() >= blockCipher.blockSize) then + local block = Array.readFromQueue(inputQueue, blockCipher.blockSize); + + if(iv == nil) then + iv = block; + else + local out = iv; + out = blockCipher.encrypt(key, out); + iv = out; + out = Array.XOR(out, block); + Array.writeToQueue(outputQueue, out); + end + end + byte = messageStream(); + end + return public; + end + + public.finish = function() + local paddingStream = padding(blockCipher.blockSize, inputQueue.getHead()); + public.update(paddingStream); + + return public; + end + + public.getOutputQueue = function() + return outputQueue; + end + + public.asHex = function() + return Stream.toHex(outputQueue.pop); + end + + public.asBytes = function() + return Stream.toArray(outputQueue.pop); + end + + return public; + +end + +OFB.Decipher = function() + + local public = {}; + + local key; + local blockCipher; + local padding; + local inputQueue; + local outputQueue; + local iv; + + public.setKey = function(keyBytes) + key = keyBytes; + return public; + end + + public.setBlockCipher = function(cipher) + blockCipher = cipher; + return public; + end + + public.setPadding = function(paddingMode) + padding = paddingMode; + return public; + end + + public.init = function() + inputQueue = Queue(); + outputQueue = Queue(); + iv = nil; + return public; + end + + public.update = function(messageStream) + local byte = messageStream(); + while (byte ~= nil) do + inputQueue.push(byte); + if(inputQueue.size() >= blockCipher.blockSize) then + local block = Array.readFromQueue(inputQueue, blockCipher.blockSize); + + if(iv == nil) then + iv = block; + else + local out = iv; + out = blockCipher.encrypt(key, out); + iv = out; + out = Array.XOR(out, block); + Array.writeToQueue(outputQueue, out); + end + end + byte = messageStream(); + end + return public; + end + + public.finish = function() + local paddingStream = padding(blockCipher.blockSize, inputQueue.getHead()); + public.update(paddingStream); + + return public; + end + + public.getOutputQueue = function() + return outputQueue; + end + + public.asHex = function() + return Stream.toHex(outputQueue.pop); + end + + public.asBytes = function() + return Stream.toArray(outputQueue.pop); + end + + return public; + +end + + +return OFB; diff --git a/lockbox/digest/sha1.lua b/lockbox/digest/sha1.lua new file mode 100644 index 0000000..fc38866 --- /dev/null +++ b/lockbox/digest/sha1.lua @@ -0,0 +1,173 @@ +require("lockbox").insecure(); + +local Bit = require("lockbox.util.bit"); +local String = require("string"); +local Math = require("math"); +local Queue = require("lockbox.util.queue"); + +local AND = Bit.band; +local OR = Bit.bor; +local XOR = Bit.bxor; +local LROT = Bit.lrotate; +local LSHIFT = Bit.lshift; +local RSHIFT = Bit.rshift; + +--SHA1 is big-endian +local bytes2word = function(b0, b1, b2, b3) + local i = b0; i = LSHIFT(i, 8); + i = OR(i, b1); i = LSHIFT(i, 8); + i = OR(i, b2); i = LSHIFT(i, 8); + i = OR(i, b3); + return i; +end + +local word2bytes = function(word) + local b0, b1, b2, b3; + b3 = AND(word, 0xFF); word = RSHIFT(word, 8); + b2 = AND(word, 0xFF); word = RSHIFT(word, 8); + b1 = AND(word, 0xFF); word = RSHIFT(word, 8); + b0 = AND(word, 0xFF); + return b0, b1, b2, b3; +end + +local dword2bytes = function(i) + local b4, b5, b6, b7 = word2bytes(i); + local b0, b1, b2, b3 = word2bytes(Math.floor(i / 0x100000000)); + return b0, b1, b2, b3, b4, b5, b6, b7; +end + +local F = function(x, y, z) return XOR(z, AND(x, XOR(y, z))); end +local G = function(x, y, z) return XOR(x, XOR(y, z)); end +local H = function(x, y, z) return OR(AND(x, OR(y, z)), AND(y, z)); end +local I = function(x, y, z) return XOR(x, XOR(y, z)); end + +local SHA1 = function() + + local queue = Queue(); + + local h0 = 0x67452301; + local h1 = 0xEFCDAB89; + local h2 = 0x98BADCFE; + local h3 = 0x10325476; + local h4 = 0xC3D2E1F0; + + local public = {}; + + local processBlock = function() + local a = h0; + local b = h1; + local c = h2; + local d = h3; + local e = h4; + local temp; + local k; + + local w = {}; + for i = 0, 15 do + w[i] = bytes2word(queue.pop(), queue.pop(), queue.pop(), queue.pop()); + end + + for i = 16, 79 do + w[i] = LROT((XOR(XOR(w[i - 3], w[i - 8]), XOR(w[i - 14], w[i - 16]))), 1); + end + + for i = 0, 79 do + if (i <= 19) then + temp = F(b, c, d); + k = 0x5A827999; + elseif (i <= 39) then + temp = G(b, c, d); + k = 0x6ED9EBA1; + elseif (i <= 59) then + temp = H(b, c, d); + k = 0x8F1BBCDC; + else + temp = I(b, c, d); + k = 0xCA62C1D6; + end + temp = LROT(a, 5) + temp + e + k + w[i]; + e = d; + d = c; + c = LROT(b, 30); + b = a; + a = temp; + end + + h0 = AND(h0 + a, 0xFFFFFFFF); + h1 = AND(h1 + b, 0xFFFFFFFF); + h2 = AND(h2 + c, 0xFFFFFFFF); + h3 = AND(h3 + d, 0xFFFFFFFF); + h4 = AND(h4 + e, 0xFFFFFFFF); + end + + public.init = function() + queue.reset(); + h0 = 0x67452301; + h1 = 0xEFCDAB89; + h2 = 0x98BADCFE; + h3 = 0x10325476; + h4 = 0xC3D2E1F0; + return public; + end + + + public.update = function(bytes) + for b in bytes do + queue.push(b); + if queue.size() >= 64 then processBlock(); end + end + + return public; + end + + public.finish = function() + local bits = queue.getHead() * 8; + + queue.push(0x80); + while ((queue.size() + 7) % 64) < 63 do + queue.push(0x00); + end + + local b0, b1, b2, b3, b4, b5, b6, b7 = dword2bytes(bits); + + queue.push(b0); + queue.push(b1); + queue.push(b2); + queue.push(b3); + queue.push(b4); + queue.push(b5); + queue.push(b6); + queue.push(b7); + + while queue.size() > 0 do + processBlock(); + end + + return public; + end + + public.asBytes = function() + local b0, b1, b2, b3 = word2bytes(h0); + local b4, b5, b6, b7 = word2bytes(h1); + local b8, b9, b10, b11 = word2bytes(h2); + local b12, b13, b14, b15 = word2bytes(h3); + local b16, b17, b18, b19 = word2bytes(h4); + + return {b0, b1, b2, b3, b4, b5, b6, b7, b8, b9, b10, b11, b12, b13, b14, b15, b16, b17, b18, b19}; + end + + public.asHex = function() + local b0, b1, b2, b3 = word2bytes(h0); + local b4, b5, b6, b7 = word2bytes(h1); + local b8, b9, b10, b11 = word2bytes(h2); + local b12, b13, b14, b15 = word2bytes(h3); + local b16, b17, b18, b19 = word2bytes(h4); + + return String.format("%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x", + b0, b1, b2, b3, b4, b5, b6, b7, b8, b9, b10, b11, b12, b13, b14, b15, b16, b17, b18, b19); + end + + return public; +end + +return SHA1; diff --git a/lockbox/digest/sha2_224.lua b/lockbox/digest/sha2_224.lua new file mode 100644 index 0000000..3bb536a --- /dev/null +++ b/lockbox/digest/sha2_224.lua @@ -0,0 +1,200 @@ +local Bit = require("lockbox.util.bit"); +local String = require("string"); +local Math = require("math"); +local Queue = require("lockbox.util.queue"); + +local CONSTANTS = { + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2 }; + +local fmt = "%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x" .. + "%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x" + +local AND = Bit.band; +local OR = Bit.bor; +local NOT = Bit.bnot; +local XOR = Bit.bxor; +local RROT = Bit.rrotate; +local LSHIFT = Bit.lshift; +local RSHIFT = Bit.rshift; + +--SHA2 is big-endian +local bytes2word = function(b0, b1, b2, b3) + local i = b0; i = LSHIFT(i, 8); + i = OR(i, b1); i = LSHIFT(i, 8); + i = OR(i, b2); i = LSHIFT(i, 8); + i = OR(i, b3); + return i; +end + +local word2bytes = function(word) + local b0, b1, b2, b3; + b3 = AND(word, 0xFF); word = RSHIFT(word, 8); + b2 = AND(word, 0xFF); word = RSHIFT(word, 8); + b1 = AND(word, 0xFF); word = RSHIFT(word, 8); + b0 = AND(word, 0xFF); + return b0, b1, b2, b3; +end + +local dword2bytes = function(i) + local b4, b5, b6, b7 = word2bytes(i); + local b0, b1, b2, b3 = word2bytes(Math.floor(i / 0x100000000)); + return b0, b1, b2, b3, b4, b5, b6, b7; +end + + + + +local SHA2_224 = function() + + local queue = Queue(); + + local h0 = 0xc1059ed8; + local h1 = 0x367cd507; + local h2 = 0x3070dd17; + local h3 = 0xf70e5939; + local h4 = 0xffc00b31; + local h5 = 0x68581511; + local h6 = 0x64f98fa7; + local h7 = 0xbefa4fa4; + + local public = {}; + + local processBlock = function() + local a = h0; + local b = h1; + local c = h2; + local d = h3; + local e = h4; + local f = h5; + local g = h6; + local h = h7; + + local w = {}; + + for i = 0, 15 do + w[i] = bytes2word(queue.pop(), queue.pop(), queue.pop(), queue.pop()); + end + + for i = 16, 63 do + local s0 = XOR(RROT(w[i - 15], 7), XOR(RROT(w[i - 15], 18), RSHIFT(w[i - 15], 3))); + local s1 = XOR(RROT(w[i - 2], 17), XOR(RROT(w[i - 2], 19), RSHIFT(w[i - 2], 10))); + w[i] = AND(w[i - 16] + s0 + w[i - 7] + s1, 0xFFFFFFFF); + end + + for i = 0, 63 do + local s1 = XOR(RROT(e, 6), XOR(RROT(e, 11), RROT(e, 25))); + local ch = XOR(AND(e, f), AND(NOT(e), g)); + local temp1 = h + s1 + ch + CONSTANTS[i + 1] + w[i]; + local s0 = XOR(RROT(a, 2), XOR(RROT(a, 13), RROT(a, 22))); + local maj = XOR(AND(a, b), XOR(AND(a, c), AND(b, c))); + local temp2 = s0 + maj; + + h = g; + g = f; + f = e; + e = d + temp1; + d = c; + c = b; + b = a; + a = temp1 + temp2; + end + + h0 = AND(h0 + a, 0xFFFFFFFF); + h1 = AND(h1 + b, 0xFFFFFFFF); + h2 = AND(h2 + c, 0xFFFFFFFF); + h3 = AND(h3 + d, 0xFFFFFFFF); + h4 = AND(h4 + e, 0xFFFFFFFF); + h5 = AND(h5 + f, 0xFFFFFFFF); + h6 = AND(h6 + g, 0xFFFFFFFF); + h7 = AND(h7 + h, 0xFFFFFFFF); + end + + public.init = function() + queue.reset(); + + h0 = 0xc1059ed8; + h1 = 0x367cd507; + h2 = 0x3070dd17; + h3 = 0xf70e5939; + h4 = 0xffc00b31; + h5 = 0x68581511; + h6 = 0x64f98fa7; + h7 = 0xbefa4fa4; + + return public; + end + + public.update = function(bytes) + for b in bytes do + queue.push(b); + if queue.size() >= 64 then processBlock(); end + end + + return public; + end + + public.finish = function() + local bits = queue.getHead() * 8; + + queue.push(0x80); + while ((queue.size() + 7) % 64) < 63 do + queue.push(0x00); + end + + local b0, b1, b2, b3, b4, b5, b6, b7 = dword2bytes(bits); + + queue.push(b0); + queue.push(b1); + queue.push(b2); + queue.push(b3); + queue.push(b4); + queue.push(b5); + queue.push(b6); + queue.push(b7); + + while queue.size() > 0 do + processBlock(); + end + + return public; + end + + public.asBytes = function() + local b0, b1, b2, b3 = word2bytes(h0); + local b4, b5, b6, b7 = word2bytes(h1); + local b8, b9, b10, b11 = word2bytes(h2); + local b12, b13, b14, b15 = word2bytes(h3); + local b16, b17, b18, b19 = word2bytes(h4); + local b20, b21, b22, b23 = word2bytes(h5); + local b24, b25, b26, b27 = word2bytes(h6); + + return { b0, b1, b2, b3, b4, b5, b6, b7, b8, b9, b10, b11, b12, b13, b14, b15 + , b16, b17, b18, b19, b20, b21, b22, b23, b24, b25, b26, b27}; + end + + public.asHex = function() + local b0, b1, b2, b3 = word2bytes(h0); + local b4, b5, b6, b7 = word2bytes(h1); + local b8, b9, b10, b11 = word2bytes(h2); + local b12, b13, b14, b15 = word2bytes(h3); + local b16, b17, b18, b19 = word2bytes(h4); + local b20, b21, b22, b23 = word2bytes(h5); + local b24, b25, b26, b27 = word2bytes(h6); + + return String.format(fmt, b0, b1, b2, b3, b4, b5, b6, b7, b8, b9, b10, b11, b12, b13, b14, b15 + , b16, b17, b18, b19, b20, b21, b22, b23, b24, b25, b26, b27); + end + + return public; + +end + +return SHA2_224; + diff --git a/lockbox/digest/sha2_256.lua b/lockbox/digest/sha2_256.lua new file mode 100644 index 0000000..1aafa5a --- /dev/null +++ b/lockbox/digest/sha2_256.lua @@ -0,0 +1,203 @@ +local Bit = require("lockbox.util.bit"); +local String = require("string"); +local Math = require("math"); +local Queue = require("lockbox.util.queue"); + +local CONSTANTS = { + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2 }; + +local fmt = "%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x" .. + "%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x" + +local AND = Bit.band; +local OR = Bit.bor; +local NOT = Bit.bnot; +local XOR = Bit.bxor; +local RROT = Bit.rrotate; +local LSHIFT = Bit.lshift; +local RSHIFT = Bit.rshift; + +--SHA2 is big-endian +local bytes2word = function(b0, b1, b2, b3) + local i = b0; i = LSHIFT(i, 8); + i = OR(i, b1); i = LSHIFT(i, 8); + i = OR(i, b2); i = LSHIFT(i, 8); + i = OR(i, b3); + return i; +end + +local word2bytes = function(word) + local b0, b1, b2, b3; + b3 = AND(word, 0xFF); word = RSHIFT(word, 8); + b2 = AND(word, 0xFF); word = RSHIFT(word, 8); + b1 = AND(word, 0xFF); word = RSHIFT(word, 8); + b0 = AND(word, 0xFF); + return b0, b1, b2, b3; +end + +local dword2bytes = function(i) + local b4, b5, b6, b7 = word2bytes(i); + local b0, b1, b2, b3 = word2bytes(Math.floor(i / 0x100000000)); + return b0, b1, b2, b3, b4, b5, b6, b7; +end + + + + +local SHA2_256 = function() + + local queue = Queue(); + + local h0 = 0x6a09e667; + local h1 = 0xbb67ae85; + local h2 = 0x3c6ef372; + local h3 = 0xa54ff53a; + local h4 = 0x510e527f; + local h5 = 0x9b05688c; + local h6 = 0x1f83d9ab; + local h7 = 0x5be0cd19; + + local public = {}; + + local processBlock = function() + local a = h0; + local b = h1; + local c = h2; + local d = h3; + local e = h4; + local f = h5; + local g = h6; + local h = h7; + + local w = {}; + + for i = 0, 15 do + w[i] = bytes2word(queue.pop(), queue.pop(), queue.pop(), queue.pop()); + end + + for i = 16, 63 do + local s0 = XOR(RROT(w[i - 15], 7), XOR(RROT(w[i - 15], 18), RSHIFT(w[i - 15], 3))); + local s1 = XOR(RROT(w[i - 2], 17), XOR(RROT(w[i - 2], 19), RSHIFT(w[i - 2], 10))); + w[i] = AND(w[i - 16] + s0 + w[i - 7] + s1, 0xFFFFFFFF); + end + + for i = 0, 63 do + local s1 = XOR(RROT(e, 6), XOR(RROT(e, 11), RROT(e, 25))); + local ch = XOR(AND(e, f), AND(NOT(e), g)); + local temp1 = h + s1 + ch + CONSTANTS[i + 1] + w[i]; + local s0 = XOR(RROT(a, 2), XOR(RROT(a, 13), RROT(a, 22))); + local maj = XOR(AND(a, b), XOR(AND(a, c), AND(b, c))); + local temp2 = s0 + maj; + + h = g; + g = f; + f = e; + e = d + temp1; + d = c; + c = b; + b = a; + a = temp1 + temp2; + end + + h0 = AND(h0 + a, 0xFFFFFFFF); + h1 = AND(h1 + b, 0xFFFFFFFF); + h2 = AND(h2 + c, 0xFFFFFFFF); + h3 = AND(h3 + d, 0xFFFFFFFF); + h4 = AND(h4 + e, 0xFFFFFFFF); + h5 = AND(h5 + f, 0xFFFFFFFF); + h6 = AND(h6 + g, 0xFFFFFFFF); + h7 = AND(h7 + h, 0xFFFFFFFF); + end + + public.init = function() + queue.reset(); + + h0 = 0x6a09e667; + h1 = 0xbb67ae85; + h2 = 0x3c6ef372; + h3 = 0xa54ff53a; + h4 = 0x510e527f; + h5 = 0x9b05688c; + h6 = 0x1f83d9ab; + h7 = 0x5be0cd19; + + return public; + end + + public.update = function(bytes) + for b in bytes do + queue.push(b); + if queue.size() >= 64 then processBlock(); end + end + + return public; + end + + public.finish = function() + local bits = queue.getHead() * 8; + + queue.push(0x80); + while ((queue.size() + 7) % 64) < 63 do + queue.push(0x00); + end + + local b0, b1, b2, b3, b4, b5, b6, b7 = dword2bytes(bits); + + queue.push(b0); + queue.push(b1); + queue.push(b2); + queue.push(b3); + queue.push(b4); + queue.push(b5); + queue.push(b6); + queue.push(b7); + + while queue.size() > 0 do + processBlock(); + end + + return public; + end + + public.asBytes = function() + local b0, b1, b2, b3 = word2bytes(h0); + local b4, b5, b6, b7 = word2bytes(h1); + local b8, b9, b10, b11 = word2bytes(h2); + local b12, b13, b14, b15 = word2bytes(h3); + local b16, b17, b18, b19 = word2bytes(h4); + local b20, b21, b22, b23 = word2bytes(h5); + local b24, b25, b26, b27 = word2bytes(h6); + local b28, b29, b30, b31 = word2bytes(h7); + + + return { b0, b1, b2, b3, b4, b5, b6, b7, b8, b9, b10, b11, b12, b13, b14, b15 + , b16, b17, b18, b19, b20, b21, b22, b23, b24, b25, b26, b27, b28, b29, b30, b31}; + end + + public.asHex = function() + local b0, b1, b2, b3 = word2bytes(h0); + local b4, b5, b6, b7 = word2bytes(h1); + local b8, b9, b10, b11 = word2bytes(h2); + local b12, b13, b14, b15 = word2bytes(h3); + local b16, b17, b18, b19 = word2bytes(h4); + local b20, b21, b22, b23 = word2bytes(h5); + local b24, b25, b26, b27 = word2bytes(h6); + local b28, b29, b30, b31 = word2bytes(h7); + + return String.format(fmt, b0, b1, b2, b3, b4, b5, b6, b7, b8, b9, b10, b11, b12, b13, b14, b15 + , b16, b17, b18, b19, b20, b21, b22, b23, b24, b25, b26, b27, b28, b29, b30, b31); + end + + return public; + +end + +return SHA2_256; + diff --git a/lockbox/init.lua b/lockbox/init.lua new file mode 100644 index 0000000..0031a50 --- /dev/null +++ b/lockbox/init.lua @@ -0,0 +1,22 @@ +local Lockbox = {}; + +--[[ +package.path = "./?.lua;" + .. "./cipher/?.lua;" + .. "./digest/?.lua;" + .. "./kdf/?.lua;" + .. "./mac/?.lua;" + .. "./padding/?.lua;" + .. "./test/?.lua;" + .. "./util/?.lua;" + .. package.path; +--]] +Lockbox.ALLOW_INSECURE = true; + +Lockbox.insecure = function() + assert(Lockbox.ALLOW_INSECURE, + "This module is insecure! It should not be used in production." .. + "If you really want to use it, set Lockbox.ALLOW_INSECURE to true before importing it"); +end + +return Lockbox; diff --git a/lockbox/kdf/pbkdf2.lua b/lockbox/kdf/pbkdf2.lua new file mode 100644 index 0000000..a05e42a --- /dev/null +++ b/lockbox/kdf/pbkdf2.lua @@ -0,0 +1,114 @@ +local Bit = require("lockbox.util.bit"); +local Array = require("lockbox.util.array"); +local Stream = require("lockbox.util.stream"); +local Math = require("math"); + +local AND = Bit.band; +local RSHIFT = Bit.rshift; + +local word2bytes = function(word) + local b0, b1, b2, b3; + b3 = AND(word, 0xFF); word = RSHIFT(word, 8); + b2 = AND(word, 0xFF); word = RSHIFT(word, 8); + b1 = AND(word, 0xFF); word = RSHIFT(word, 8); + b0 = AND(word, 0xFF); + return b0, b1, b2, b3; +end + +local PBKDF2 = function() + + local public = {}; + + local blockLen = 16; + local dKeyLen = 256; + local iterations = 4096; + + local salt; + local password; + + + local PRF; + + local dKey; + + + public.setBlockLen = function(len) + blockLen = len; + return public; + end + + public.setDKeyLen = function(len) + dKeyLen = len + return public; + end + + public.setIterations = function(iter) + iterations = iter; + return public; + end + + public.setSalt = function(saltBytes) + salt = saltBytes; + return public; + end + + public.setPassword = function(passwordBytes) + password = passwordBytes; + return public; + end + + public.setPRF = function(prf) + PRF = prf; + return public; + end + + local buildBlock = function(i) + local b0, b1, b2, b3 = word2bytes(i); + local ii = {b0, b1, b2, b3}; + local s = Array.concat(salt, ii); + + local out = {}; + + PRF.setKey(password); + for c = 1, iterations do + PRF.init() + .update(Stream.fromArray(s)); + + s = PRF.finish().asBytes(); + if(c > 1) then + out = Array.XOR(out, s); + else + out = s; + end + end + + return out; + end + + public.finish = function() + local blocks = Math.ceil(dKeyLen / blockLen); + + dKey = {}; + + for b = 1, blocks do + local block = buildBlock(b); + dKey = Array.concat(dKey, block); + end + + if(Array.size(dKey) > dKeyLen) then dKey = Array.truncate(dKey, dKeyLen); end + + return public; + end + + public.asBytes = function() + return dKey; + end + + public.asHex = function() + return Array.toHex(dKey); + end + + return public; +end + +return PBKDF2; diff --git a/lockbox/mac/hmac.lua b/lockbox/mac/hmac.lua new file mode 100644 index 0000000..a10b84c --- /dev/null +++ b/lockbox/mac/hmac.lua @@ -0,0 +1,85 @@ +local Bit = require("lockbox.util.bit"); +local Stream = require("lockbox.util.stream"); +local Array = require("lockbox.util.array"); + +local XOR = Bit.bxor; + +local HMAC = function() + + local public = {}; + local blockSize = 64; + local Digest = nil; + local outerPadding = {}; + local innerPadding = {} + local digest; + + public.setBlockSize = function(bytes) + blockSize = bytes; + return public; + end + + public.setDigest = function(digestModule) + Digest = digestModule; + digest = Digest(); + return public; + end + + public.setKey = function(key) + local keyStream; + + if(Array.size(key) > blockSize) then + keyStream = Stream.fromArray(Digest() + .update(Stream.fromArray(key)) + .finish() + .asBytes()); + else + keyStream = Stream.fromArray(key); + end + + outerPadding = {}; + innerPadding = {}; + + for i = 1, blockSize do + local byte = keyStream(); + if byte == nil then byte = 0x00; end + outerPadding[i] = XOR(0x5C, byte); + innerPadding[i] = XOR(0x36, byte); + end + + return public; + end + + public.init = function() + digest.init() + .update(Stream.fromArray(innerPadding)); + return public; + end + + public.update = function(messageStream) + digest.update(messageStream); + return public; + end + + public.finish = function() + local inner = digest.finish().asBytes(); + digest.init() + .update(Stream.fromArray(outerPadding)) + .update(Stream.fromArray(inner)) + .finish(); + + return public; + end + + public.asBytes = function() + return digest.asBytes(); + end + + public.asHex = function() + return digest.asHex(); + end + + return public; + +end + +return HMAC; diff --git a/lockbox/padding/ansix923.lua b/lockbox/padding/ansix923.lua new file mode 100644 index 0000000..83702c6 --- /dev/null +++ b/lockbox/padding/ansix923.lua @@ -0,0 +1,22 @@ +local ANSIX923Padding = function(blockSize, byteCount) + + local paddingCount = blockSize - (byteCount % blockSize); + local bytesLeft = paddingCount; + + local stream = function() + if bytesLeft > 1 then + bytesLeft = bytesLeft - 1; + return 0x00; + elseif bytesLeft > 0 then + bytesLeft = bytesLeft - 1; + return paddingCount; + else + return nil; + end + end + + return stream; + +end + +return ANSIX923Padding; diff --git a/lockbox/padding/isoiec7816.lua b/lockbox/padding/isoiec7816.lua new file mode 100644 index 0000000..3dc255d --- /dev/null +++ b/lockbox/padding/isoiec7816.lua @@ -0,0 +1,22 @@ +local ISOIEC7816Padding = function(blockSize, byteCount) + + local paddingCount = blockSize - (byteCount % blockSize); + local bytesLeft = paddingCount; + + local stream = function() + if bytesLeft == paddingCount then + bytesLeft = bytesLeft - 1; + return 0x80; + elseif bytesLeft > 0 then + bytesLeft = bytesLeft - 1; + return 0x00; + else + return nil; + end + end + + return stream; + +end + +return ISOIEC7816Padding; diff --git a/lockbox/padding/pkcs7.lua b/lockbox/padding/pkcs7.lua new file mode 100644 index 0000000..3b635ab --- /dev/null +++ b/lockbox/padding/pkcs7.lua @@ -0,0 +1,18 @@ +local PKCS7Padding = function(blockSize, byteCount) + + local paddingCount = blockSize - ((byteCount -1) % blockSize) + 1; + local bytesLeft = paddingCount; + + local stream = function() + if bytesLeft > 0 then + bytesLeft = bytesLeft - 1; + return paddingCount; + else + return nil; + end + end + + return stream; +end + +return PKCS7Padding; diff --git a/lockbox/padding/zero.lua b/lockbox/padding/zero.lua new file mode 100644 index 0000000..d42a9b7 --- /dev/null +++ b/lockbox/padding/zero.lua @@ -0,0 +1,19 @@ +local ZeroPadding = function(blockSize, byteCount) + + local paddingCount = blockSize - ((byteCount -1) % blockSize) + 1; + local bytesLeft = paddingCount; + + local stream = function() + if bytesLeft > 0 then + bytesLeft = bytesLeft - 1; + return 0x00; + else + return nil; + end + end + + return stream; + +end + +return ZeroPadding; diff --git a/lockbox/util/array.lua b/lockbox/util/array.lua new file mode 100644 index 0000000..bd9ed56 --- /dev/null +++ b/lockbox/util/array.lua @@ -0,0 +1,211 @@ + +local String = require("string"); +local Bit = require("lockbox.util.bit"); +local Queue = require("lockbox.util.queue"); + +local XOR = Bit.bxor; + +local Array = {}; + +Array.size = function(array) + return #array; +end + +Array.fromString = function(string) + local bytes = {}; + + local i = 1; + local byte = String.byte(string, i); + while byte ~= nil do + bytes[i] = byte; + i = i + 1; + byte = String.byte(string, i); + end + + return bytes; + +end + +Array.toString = function(bytes) + local chars = {}; + local i = 1; + + local byte = bytes[i]; + while byte ~= nil do + chars[i] = String.char(byte); + i = i + 1; + byte = bytes[i]; + end + + return table.concat(chars, ""); +end + +Array.fromStream = function(stream) + local array = {}; + local i = 1; + + local byte = stream(); + while byte ~= nil do + array[i] = byte; + i = i + 1; + byte = stream(); + end + + return array; +end + +Array.readFromQueue = function(queue, size) + local array = {}; + + for i = 1, size do + array[i] = queue.pop(); + end + + return array; +end + +Array.writeToQueue = function(queue, array) + local size = Array.size(array); + + for i = 1, size do + queue.push(array[i]); + end +end + +Array.toStream = function(array) + local queue = Queue(); + local i = 1; + + local byte = array[i]; + while byte ~= nil do + queue.push(byte); + i = i + 1; + byte = array[i]; + end + + return queue.pop; +end + + +local fromHexTable = {}; +for i = 0, 255 do + fromHexTable[String.format("%02X", i)] = i; + fromHexTable[String.format("%02x", i)] = i; +end + +Array.fromHex = function(hex) + local array = {}; + + for i = 1, String.len(hex) / 2 do + local h = String.sub(hex, i * 2 - 1, i * 2); + array[i] = fromHexTable[h]; + end + + return array; +end + + +local toHexTable = {}; +for i = 0, 255 do + toHexTable[i] = String.format("%02X", i); +end + +Array.toHex = function(array) + local hex = {}; + local i = 1; + + local byte = array[i]; + while byte ~= nil do + hex[i] = toHexTable[byte]; + i = i + 1; + byte = array[i]; + end + + return table.concat(hex, ""); + +end + +Array.concat = function(a, b) + local concat = {}; + local out = 1; + + local i = 1; + local byte = a[i]; + while byte ~= nil do + concat[out] = byte; + i = i + 1; + out = out + 1; + byte = a[i]; + end + + i = 1; + byte = b[i]; + while byte ~= nil do + concat[out] = byte; + i = i + 1; + out = out + 1; + byte = b[i]; + end + + return concat; +end + +Array.truncate = function(a, newSize) + local x = {}; + + for i = 1, newSize do + x[i] = a[i]; + end + + return x; +end + +Array.XOR = function(a, b) + local x = {}; + + for k, v in pairs(a) do + x[k] = XOR(v, b[k]); + end + + return x; +end + +Array.substitute = function(input, sbox) + local out = {}; + + for k, v in pairs(input) do + out[k] = sbox[v]; + end + + return out; +end + +Array.permute = function(input, pbox) + local out = {}; + + for k, v in pairs(pbox) do + out[k] = input[v]; + end + + return out; +end + +Array.copy = function(input) + local out = {}; + + for k, v in pairs(input) do + out[k] = v; + end + return out; +end + +Array.slice = function(input, start, stop) + local out = {}; + + for i = start, stop do + out[i - start + 1] = input[i]; + end + return out; +end + +return Array; diff --git a/lockbox/util/bit.lua b/lockbox/util/bit.lua new file mode 100644 index 0000000..b17238e --- /dev/null +++ b/lockbox/util/bit.lua @@ -0,0 +1,25 @@ +local ok, e +ok = nil +if not ok then + ok, e = pcall(require, "bit") -- the LuaJIT one ? +end +if not ok then + ok, e = pcall(require, "bit32") -- Lua 5.2 +end +if not ok then + ok, e = pcall(require, "bit.numberlua") -- for Lua 5.1, https://github.com/tst2005/lua-bit-numberlua/ +end +if not ok then + error("no bitwise support found", 2) +end +assert(type(e) == "table", "invalid bit module") + +-- Workaround to support Lua 5.2 bit32 API with the LuaJIT bit one +if e.rol and not e.lrotate then + e.lrotate = e.rol +end +if e.ror and not e.rrotate then + e.rrotate = e.ror +end + +return e diff --git a/lockbox/util/queue.lua b/lockbox/util/queue.lua new file mode 100644 index 0000000..4a4a345 --- /dev/null +++ b/lockbox/util/queue.lua @@ -0,0 +1,47 @@ +local Queue = function() + local queue = {}; + local tail = 0; + local head = 0; + + local public = {}; + + public.push = function(obj) + queue[head] = obj; + head = head + 1; + return; + end + + public.pop = function() + if tail < head + then + local obj = queue[tail]; + queue[tail] = nil; + tail = tail + 1; + return obj; + else + return nil; + end + end + + public.size = function() + return head - tail; + end + + public.getHead = function() + return head; + end + + public.getTail = function() + return tail; + end + + public.reset = function() + queue = {}; + head = 0; + tail = 0; + end + + return public; +end + +return Queue; diff --git a/lockbox/util/stream.lua b/lockbox/util/stream.lua new file mode 100644 index 0000000..f81a18c --- /dev/null +++ b/lockbox/util/stream.lua @@ -0,0 +1,99 @@ +local Queue = require("lockbox.util.queue"); +local String = require("string"); + +local Stream = {}; + + +Stream.fromString = function(string) + local i = 0; + return function() + i = i + 1; + return String.byte(string, i); + end +end + + +Stream.toString = function(stream) + local array = {}; + local i = 1; + + local byte = stream(); + while byte ~= nil do + array[i] = String.char(byte); + i = i + 1; + byte = stream(); + end + + return table.concat(array); +end + + +Stream.fromArray = function(array) + local queue = Queue(); + local i = 1; + + local byte = array[i]; + while byte ~= nil do + queue.push(byte); + i = i + 1; + byte = array[i]; + end + + return queue.pop; +end + + +Stream.toArray = function(stream) + local array = {}; + local i = 1; + + local byte = stream(); + while byte ~= nil do + array[i] = byte; + i = i + 1; + byte = stream(); + end + + return array; +end + + +local fromHexTable = {}; +for i = 0, 255 do + fromHexTable[String.format("%02X", i)] = i; + fromHexTable[String.format("%02x", i)] = i; +end + +Stream.fromHex = function(hex) + local queue = Queue(); + + for i = 1, String.len(hex) / 2 do + local h = String.sub(hex, i * 2 - 1, i * 2); + queue.push(fromHexTable[h]); + end + + return queue.pop; +end + + + +local toHexTable = {}; +for i = 0, 255 do + toHexTable[i] = String.format("%02X", i); +end + +Stream.toHex = function(stream) + local hex = {}; + local i = 1; + + local byte = stream(); + while byte ~= nil do + hex[i] = toHexTable[byte]; + i = i + 1; + byte = stream(); + end + + return table.concat(hex); +end + +return Stream; diff --git a/main/controller.lua b/main/controller.lua deleted file mode 100644 index b0e18b7..0000000 --- a/main/controller.lua +++ /dev/null @@ -1,135 +0,0 @@ --- mekanism reactor controller --- monitors and regulates mekanism reactors - -os.loadAPI("reactor.lua") -os.loadAPI("defs.lua") -os.loadAPI("log.lua") -os.loadAPI("render.lua") -os.loadAPI("server.lua") -os.loadAPI("regulator.lua") - --- constants, aliases, properties -local header = "MEKANISM REACTOR CONTROLLER - v" .. defs.CTRL_VERSION -local monitor_0 = peripheral.wrap(defs.MONITOR_0) -local monitor_1 = peripheral.wrap(defs.MONITOR_1) -local monitor_2 = peripheral.wrap(defs.MONITOR_2) -local monitor_3 = peripheral.wrap(defs.MONITOR_3) - -monitor_0.setBackgroundColor(colors.black) -monitor_0.setTextColor(colors.white) -monitor_0.clear() - -monitor_1.setBackgroundColor(colors.black) -monitor_1.setTextColor(colors.white) -monitor_1.clear() - -monitor_2.setBackgroundColor(colors.black) -monitor_2.setTextColor(colors.white) -monitor_2.clear() - -log.init(monitor_3) - -local main_w, main_h = monitor_0.getSize() -local view = window.create(monitor_0, 1, 1, main_w, main_h) -view.setBackgroundColor(colors.black) -view.clear() - -local stat_w, stat_h = monitor_1.getSize() -local stat_view = window.create(monitor_1, 1, 1, stat_w, stat_h) -stat_view.setBackgroundColor(colors.black) -stat_view.clear() - -local reactors = { - reactor.create(1, view, stat_view, 62, 3, 63, 2), - reactor.create(2, view, stat_view, 42, 3, 43, 2), - reactor.create(3, view, stat_view, 22, 3, 23, 2), - reactor.create(4, view, stat_view, 2, 3, 3, 2) -} -print("[debug] reactor tables created") - -server.init(reactors) -print("[debug] modem server started") - -regulator.init(reactors) -print("[debug] regulator started") - --- header -view.setBackgroundColor(colors.white) -view.setTextColor(colors.black) -view.setCursorPos(1, 1) -local header_pad_x = (main_w - string.len(header)) / 2 -view.write(string.rep(" ", header_pad_x) .. header .. string.rep(" ", header_pad_x)) - --- inital draw of each reactor -for key, rctr in pairs(reactors) do - render.draw_reactor_system(rctr) - render.draw_reactor_status(rctr) -end - --- inital draw of clock -monitor_2.setTextScale(2) -monitor_2.setCursorPos(1, 1) -monitor_2.write(os.date("%Y/%m/%d %H:%M:%S")) - -local clock_update_timer = os.startTimer(1) - -while true do - event, param1, param2, param3, param4, param5 = os.pullEvent() - - if event == "redstone" then - -- redstone state change - regulator.handle_redstone() - elseif event == "modem_message" then - -- received signal router packet - packet = { - side = param1, - sender = param2, - reply = param3, - message = param4, - distance = param5 - } - - server.handle_message(packet, reactors) - elseif event == "monitor_touch" then - if param1 == "monitor_5" then - local tap_x = param2 - local tap_y = param3 - - for key, rctr in pairs(reactors) do - if tap_x >= rctr.render.stat_x and tap_x <= (rctr.render.stat_x + 15) then - local old_val = rctr.waste_production - -- width in range - if tap_y == (rctr.render.stat_y + 12) then - rctr.waste_production = "plutonium" - elseif tap_y == (rctr.render.stat_y + 14) then - rctr.waste_production = "polonium" - elseif tap_y == (rctr.render.stat_y + 16) then - rctr.waste_production = "antimatter" - end - - -- notify reactor of changes - if old_val ~= rctr.waste_production then - server.send(rctr.id, rctr.waste_production) - end - end - end - end - elseif event == "timer" then - -- update the clock about every second - monitor_2.setCursorPos(1, 1) - monitor_2.write(os.date("%Y/%m/%d %H:%M:%S")) - clock_update_timer = os.startTimer(1) - - -- send keep-alive - server.broadcast(1) - end - - -- update reactor display - for key, rctr in pairs(reactors) do - render.draw_reactor_system(rctr) - render.draw_reactor_status(rctr) - end - - -- update system status monitor - render.update_system_monitor(monitor_2, regulator.is_scrammed(), reactors) -end diff --git a/main/defs.lua b/main/defs.lua deleted file mode 100644 index 13f6803..0000000 --- a/main/defs.lua +++ /dev/null @@ -1,23 +0,0 @@ --- configuration definitions - -CTRL_VERSION = "0.7" - --- monitors -MONITOR_0 = "monitor_6" -MONITOR_1 = "monitor_5" -MONITOR_2 = "monitor_7" -MONITOR_3 = "monitor_8" - --- modem server -LISTEN_PORT = 1000 - --- regulator (should match the number of reactors present) -BUNDLE_DEF = { colors.red, colors.orange, colors.yellow, colors.lime } - --- stats calculation -REACTOR_MB_T = 39 -TURBINE_MRF_T = 3.114 -PLUTONIUM_PER_WASTE = 0.1 -POLONIUM_PER_WASTE = 0.1 -SPENT_PER_BYPRODUCT = 1 -ANTIMATTER_PER_POLONIUM = 0.001 diff --git a/main/log.lua b/main/log.lua deleted file mode 100644 index c4e1cbb..0000000 --- a/main/log.lua +++ /dev/null @@ -1,52 +0,0 @@ -os.loadAPI("defs.lua") - -local out, out_w, out_h -local output_full = false - --- initialize the logger to the given monitor --- monitor: monitor to write to (in addition to calling print()) -function init(monitor) - out = monitor - out_w, out_h = out.getSize() - - out.clear() - out.setTextColor(colors.white) - out.setBackgroundColor(colors.black) - - out.setCursorPos(1, 1) - out.write("version " .. defs.CTRL_VERSION) - out.setCursorPos(1, 2) - out.write("system startup at " .. os.date("%Y/%m/%d %H:%M:%S")) - - print("server v" .. defs.CTRL_VERSION .. " started at " .. os.date("%Y/%m/%d %H:%M:%S")) -end - --- write a log message to the log screen and console --- msg: message to write --- color: (optional) color to print in, defaults to white -function write(msg, color) - color = color or colors.white - local _x, _y = out.getCursorPos() - - if output_full then - out.scroll(1) - out.setCursorPos(1, _y) - else - if _y == out_h then - output_full = true - out.scroll(1) - out.setCursorPos(1, _y) - else - out.setCursorPos(1, _y + 1) - end - end - - -- output to screen - out.setTextColor(colors.lightGray) - out.write(os.date("[%H:%M:%S] ")) - out.setTextColor(color) - out.write(msg) - - -- output to console - print(os.date("[%H:%M:%S] ") .. msg) -end diff --git a/main/reactor.lua b/main/reactor.lua deleted file mode 100644 index 137b46c..0000000 --- a/main/reactor.lua +++ /dev/null @@ -1,28 +0,0 @@ --- create a new reactor 'object' --- reactor_id: the ID for this reactor --- main_view: the parent window/monitor for the main display (components) --- status_view: the parent window/monitor for the status display --- main_x: where to create the main window, x coordinate --- main_y: where to create the main window, y coordinate --- status_x: where to create the status window, x coordinate --- status_y: where to create the status window, y coordinate -function create(reactor_id, main_view, status_view, main_x, main_y, status_x, status_y) - return { - id = reactor_id, - render = { - win_main = window.create(main_view, main_x, main_y, 20, 60, true), - win_stat = window.create(status_view, status_x, status_y, 20, 20, true), - stat_x = status_x, - stat_y = status_y - }, - control_state = false, - waste_production = "antimatter", -- "plutonium", "polonium", "antimatter" - state = { - run = false, - no_fuel = false, - full_waste = false, - high_temp = false, - damage_crit = false - } - } -end diff --git a/main/regulator.lua b/main/regulator.lua deleted file mode 100644 index e8acf55..0000000 --- a/main/regulator.lua +++ /dev/null @@ -1,128 +0,0 @@ -os.loadAPI("defs.lua") -os.loadAPI("log.lua") -os.loadAPI("server.lua") - -local reactors -local scrammed -local auto_scram - --- initialize the system regulator which provides safety measures, SCRAM functionality, and handles redstone --- _reactors: reactor table -function init(_reactors) - reactors = _reactors - scrammed = false - auto_scram = false - - -- scram all reactors - server.broadcast(false, reactors) - - -- check initial states - regulator.handle_redstone() -end - --- check if the system is scrammed -function is_scrammed() - return scrammed -end - --- handle redstone state changes -function handle_redstone() - -- check scram button - if not rs.getInput("right") then - if not scrammed then - log.write("user SCRAM", colors.red) - scram() - end - - -- toggling scram will release auto scram state - auto_scram = false - else - scrammed = false - end - - -- check individual control buttons - local input = rs.getBundledInput("left") - for key, rctr in pairs(reactors) do - if colors.test(input, defs.BUNDLE_DEF[key]) ~= rctr.control_state then - -- state changed - rctr.control_state = colors.test(input, defs.BUNDLE_DEF[key]) - if not scrammed then - local safe = true - - if rctr.control_state then - safe = check_enable_safety(reactors[key]) - if safe then - log.write("reactor " .. reactors[key].id .. " enabled", colors.lime) - end - else - log.write("reactor " .. reactors[key].id .. " disabled", colors.cyan) - end - - -- start/stop reactor - if safe then - server.send(rctr.id, rctr.control_state) - end - elseif colors.test(input, defs.BUNDLE_DEF[key]) then - log.write("scrammed: state locked off", colors.yellow) - end - end - end -end - --- make sure enabling the provided reactor is safe --- reactor: reactor to check -function check_enable_safety(reactor) - if reactor.state.no_fuel or reactor.state.full_waste or reactor.state.high_temp or reactor.state.damage_crit then - log.write("RCT-" .. reactor.id .. ": unsafe enable denied", colors.yellow) - return false - else - return true - end -end - --- make sure no running reactors are in a bad state -function enforce_safeties() - for key, reactor in pairs(reactors) do - local overridden = false - local state = reactor.state - - -- check for problems - if state.damage_crit and state.run then - reactor.control_state = false - log.write("RCT-" .. reactor.id .. ": shut down (damage)", colors.yellow) - - -- scram all, so ignore setting overridden - log.write("auto SCRAM all reactors", colors.red) - auto_scram = true - scram() - elseif state.high_temp and state.run then - reactor.control_state = false - overridden = true - log.write("RCT-" .. reactor.id .. ": shut down (temp)", colors.yellow) - elseif state.full_waste and state.run then - reactor.control_state = false - overridden = true - log.write("RCT-" .. reactor.id .. ": shut down (waste)", colors.yellow) - elseif state.no_fuel and state.run then - reactor.control_state = false - overridden = true - log.write("RCT-" .. reactor.id .. ": shut down (fuel)", colors.yellow) - end - - if overridden then - server.send(reactor.id, false) - end - end -end - --- shut down all reactors and prevent enabling them until the scram button is toggled/released -function scram() - scrammed = true - server.broadcast(false, reactors) - - for key, rctr in pairs(reactors) do - if rctr.control_state then - log.write("reactor " .. reactors[key].id .. " disabled", colors.cyan) - end - end -end diff --git a/main/render.lua b/main/render.lua deleted file mode 100644 index e10614d..0000000 --- a/main/render.lua +++ /dev/null @@ -1,370 +0,0 @@ -os.loadAPI("defs.lua") - --- draw pipes between machines --- win: window to render in --- x: starting x coord --- y: starting y coord --- spacing: spacing between the pipes --- color_out: output pipe contents color --- color_ret: return pipe contents color --- tick: tick the pipes for an animation -function draw_pipe(win, x, y, spacing, color_out, color_ret, tick) - local _color - local _off - tick = tick or 0 - - for i = 0, 4, 1 - do - _off = (i + tick) % 2 == 0 or (tick == 1 and i == 0) or (tick == 3 and i == 4) - - if _off then - _color = colors.lightGray - else - _color = color_out - end - - win.setBackgroundColor(_color) - win.setCursorPos(x, y + i) - win.write(" ") - - if not _off then - _color = color_ret - end - - win.setBackgroundColor(_color) - win.setCursorPos(x + spacing, y + i) - win.write(" ") - end -end - --- draw a reactor view consisting of the reactor, boiler, turbine, and pipes --- data: reactor table -function draw_reactor_system(data) - local win = data.render.win_main - local win_w, win_h = win.getSize() - - win.setBackgroundColor(colors.black) - win.setTextColor(colors.black) - win.clear() - win.setCursorPos(1, 1) - - -- draw header -- - - local header = "REACTOR " .. data.id - local header_pad_x = (win_w - string.len(header) - 2) / 2 - local header_color - if data.state.no_fuel then - if data.state.run then - header_color = colors.purple - else - header_color = colors.brown - end - elseif data.state.full_waste then - header_color = colors.yellow - elseif data.state.high_temp then - header_color = colors.orange - elseif data.state.damage_crit then - header_color = colors.red - elseif data.state.run then - header_color = colors.green - else - header_color = colors.lightGray - end - - local running = data.state.run and not data.state.no_fuel - - win.write(" ") - win.setBackgroundColor(header_color) - win.write(string.rep(" ", win_w - 2)) - win.setBackgroundColor(colors.black) - win.write(" ") - win.setCursorPos(1, 2) - win.write(" ") - win.setBackgroundColor(header_color) - win.write(string.rep(" ", header_pad_x) .. header .. string.rep(" ", header_pad_x)) - win.setBackgroundColor(colors.black) - win.write(" ") - - -- create strings for use in blit - local line_text = string.rep(" ", 14) - local line_text_color = string.rep("0", 14) - - -- draw components -- - - -- draw reactor - local rod = "88" - if data.state.high_temp then - rod = "11" - elseif running then - rod = "99" - end - - win.setCursorPos(4, 4) - win.setBackgroundColor(colors.gray) - win.write(line_text) - win.setCursorPos(4, 5) - win.blit(line_text, line_text_color, "77" .. rod .. "77" .. rod .. "77" .. rod .. "77") - win.setCursorPos(4, 6) - win.blit(line_text, line_text_color, "7777" .. rod .. "77" .. rod .. "7777") - win.setCursorPos(4, 7) - win.blit(line_text, line_text_color, "77" .. rod .. "77" .. rod .. "77" .. rod .. "77") - win.setCursorPos(4, 8) - win.blit(line_text, line_text_color, "7777" .. rod .. "77" .. rod .. "7777") - win.setCursorPos(4, 9) - win.blit(line_text, line_text_color, "77" .. rod .. "77" .. rod .. "77" .. rod .. "77") - win.setCursorPos(4, 10) - win.write(line_text) - - -- boiler - local steam = "ffffffffff" - if running then - steam = "0000000000" - end - - win.setCursorPos(4, 16) - win.setBackgroundColor(colors.gray) - win.write(line_text) - win.setCursorPos(4, 17) - win.blit(line_text, line_text_color, "77" .. steam .. "77") - win.setCursorPos(4, 18) - win.blit(line_text, line_text_color, "77" .. steam .. "77") - win.setCursorPos(4, 19) - win.blit(line_text, line_text_color, "77888888888877") - win.setCursorPos(4, 20) - win.blit(line_text, line_text_color, "77bbbbbbbbbb77") - win.setCursorPos(4, 21) - win.blit(line_text, line_text_color, "77bbbbbbbbbb77") - win.setCursorPos(4, 22) - win.blit(line_text, line_text_color, "77bbbbbbbbbb77") - win.setCursorPos(4, 23) - win.setBackgroundColor(colors.gray) - win.write(line_text) - - -- turbine - win.setCursorPos(4, 29) - win.setBackgroundColor(colors.gray) - win.write(line_text) - win.setCursorPos(4, 30) - if running then - win.blit(line_text, line_text_color, "77000000000077") - else - win.blit(line_text, line_text_color, "77ffffffffff77") - end - win.setCursorPos(4, 31) - if running then - win.blit(line_text, line_text_color, "77008000080077") - else - win.blit(line_text, line_text_color, "77ff8ffff8ff77") - end - win.setCursorPos(4, 32) - if running then - win.blit(line_text, line_text_color, "77000800800077") - else - win.blit(line_text, line_text_color, "77fff8ff8fff77") - end - win.setCursorPos(4, 33) - if running then - win.blit(line_text, line_text_color, "77000088000077") - else - win.blit(line_text, line_text_color, "77ffff88ffff77") - end - win.setCursorPos(4, 34) - if running then - win.blit(line_text, line_text_color, "77000800800077") - else - win.blit(line_text, line_text_color, "77fff8ff8fff77") - end - win.setCursorPos(4, 35) - if running then - win.blit(line_text, line_text_color, "77008000080077") - else - win.blit(line_text, line_text_color, "77ff8ffff8ff77") - end - win.setCursorPos(4, 36) - if running then - win.blit(line_text, line_text_color, "77000000000077") - else - win.blit(line_text, line_text_color, "77ffffffffff77") - end - win.setCursorPos(4, 37) - win.setBackgroundColor(colors.gray) - win.write(line_text) - - -- draw reactor coolant pipes - draw_pipe(win, 7, 11, 6, colors.orange, colors.lightBlue) - - -- draw turbine pipes - draw_pipe(win, 7, 24, 6, colors.white, colors.blue) -end - --- draw the reactor statuses on the status screen --- data: reactor table -function draw_reactor_status(data) - local win = data.render.win_stat - - win.setBackgroundColor(colors.black) - win.setTextColor(colors.white) - win.clear() - - -- show control state - win.setCursorPos(1, 1) - if data.control_state then - win.blit(" + ENABLED", "00000000000", "dddffffffff") - else - win.blit(" - DISABLED", "000000000000", "eeefffffffff") - end - - -- show run state - win.setCursorPos(1, 2) - if data.state.run then - win.blit(" + RUNNING", "00000000000", "dddffffffff") - else - win.blit(" - STOPPED", "00000000000", "888ffffffff") - end - - -- show fuel state - win.setCursorPos(1, 4) - if data.state.no_fuel then - win.blit(" - NO FUEL", "00000000000", "eeeffffffff") - else - win.blit(" + FUEL OK", "00000000000", "999ffffffff") - end - - -- show waste state - win.setCursorPos(1, 5) - if data.state.full_waste then - win.blit(" - WASTE FULL", "00000000000000", "eeefffffffffff") - else - win.blit(" + WASTE OK", "000000000000", "999fffffffff") - end - - -- show high temp state - win.setCursorPos(1, 6) - if data.state.high_temp then - win.blit(" - HIGH TEMP", "0000000000000", "eeeffffffffff") - else - win.blit(" + TEMP OK", "00000000000", "999ffffffff") - end - - -- show damage state - win.setCursorPos(1, 7) - if data.state.damage_crit then - win.blit(" - CRITICAL DAMAGE", "0000000000000000000", "eeeffffffffffffffff") - else - win.blit(" + CASING INTACT", "00000000000000000", "999ffffffffffffff") - end - - -- waste processing options -- - win.setTextColor(colors.black) - win.setBackgroundColor(colors.white) - - win.setCursorPos(1, 10) - win.write(" ") - win.setCursorPos(1, 11) - win.write(" WASTE OUTPUT ") - - win.setCursorPos(1, 13) - win.setBackgroundColor(colors.cyan) - if data.waste_production == "plutonium" then - win.write(" > plutonium ") - else - win.write(" plutonium ") - end - - win.setCursorPos(1, 15) - win.setBackgroundColor(colors.green) - if data.waste_production == "polonium" then - win.write(" > polonium ") - else - win.write(" polonium ") - end - - win.setCursorPos(1, 17) - win.setBackgroundColor(colors.purple) - if data.waste_production == "antimatter" then - win.write(" > antimatter ") - else - win.write(" antimatter ") - end -end - --- update the system monitor screen --- mon: monitor to update --- is_scrammed: -function update_system_monitor(mon, is_scrammed, reactors) - if is_scrammed then - -- display scram banner - mon.setTextColor(colors.white) - mon.setBackgroundColor(colors.black) - mon.setCursorPos(1, 2) - mon.clearLine() - mon.setBackgroundColor(colors.red) - mon.setCursorPos(1, 3) - mon.write(" ") - mon.setCursorPos(1, 4) - mon.write(" SCRAM ") - mon.setCursorPos(1, 5) - mon.write(" ") - mon.setBackgroundColor(colors.black) - mon.setCursorPos(1, 6) - mon.clearLine() - mon.setTextColor(colors.white) - else - -- clear where scram banner would be - mon.setCursorPos(1, 3) - mon.clearLine() - mon.setCursorPos(1, 4) - mon.clearLine() - mon.setCursorPos(1, 5) - mon.clearLine() - - -- show production statistics-- - - local mrf_t = 0 - local mb_t = 0 - local plutonium = 0 - local polonium = 0 - local spent_waste = 0 - local antimatter = 0 - - -- determine production values - for key, rctr in pairs(reactors) do - if rctr.state.run then - mrf_t = mrf_t + defs.TURBINE_MRF_T - mb_t = mb_t + defs.REACTOR_MB_T - - if rctr.waste_production == "plutonium" then - plutonium = plutonium + (defs.REACTOR_MB_T * defs.PLUTONIUM_PER_WASTE) - spent_waste = spent_waste + (defs.REACTOR_MB_T * defs.PLUTONIUM_PER_WASTE * defs.SPENT_PER_BYPRODUCT) - elseif rctr.waste_production == "polonium" then - polonium = polonium + (defs.REACTOR_MB_T * defs.POLONIUM_PER_WASTE) - spent_waste = spent_waste + (defs.REACTOR_MB_T * defs.POLONIUM_PER_WASTE * defs.SPENT_PER_BYPRODUCT) - elseif rctr.waste_production == "antimatter" then - antimatter = antimatter + (defs.REACTOR_MB_T * defs.POLONIUM_PER_WASTE * defs.ANTIMATTER_PER_POLONIUM) - end - end - end - - -- draw stats - mon.setTextColor(colors.lightGray) - mon.setCursorPos(1, 2) - mon.clearLine() - mon.write("ENERGY: " .. string.format("%0.2f", mrf_t) .. " MRF/t") - -- mon.setCursorPos(1, 3) - -- mon.clearLine() - -- mon.write("FUEL: " .. mb_t .. " mB/t") - mon.setCursorPos(1, 3) - mon.clearLine() - mon.write("Pu: " .. string.format("%0.2f", plutonium) .. " mB/t") - mon.setCursorPos(1, 4) - mon.clearLine() - mon.write("Po: " .. string.format("%0.2f", polonium) .. " mB/t") - mon.setCursorPos(1, 5) - mon.clearLine() - mon.write("SPENT: " .. string.format("%0.2f", spent_waste) .. " mB/t") - mon.setCursorPos(1, 6) - mon.clearLine() - mon.write("ANTI-M: " .. string.format("%0.2f", antimatter * 1000) .. " uB/t") - mon.setTextColor(colors.white) - end -end diff --git a/main/server.lua b/main/server.lua deleted file mode 100644 index 61ad386..0000000 --- a/main/server.lua +++ /dev/null @@ -1,109 +0,0 @@ -os.loadAPI("defs.lua") -os.loadAPI("log.lua") -os.loadAPI("regulator.lua") - -local modem -local reactors - --- initalize the listener running on the wireless modem --- _reactors: reactor table -function init(_reactors) - modem = peripheral.wrap("top") - reactors = _reactors - - -- open listening port - if not modem.isOpen(defs.LISTEN_PORT) then - modem.open(defs.LISTEN_PORT) - end - - -- send out a greeting to solicit responses for clients that are already running - broadcast(0, reactors) -end - --- handle an incoming message from the modem --- packet: table containing message fields -function handle_message(packet) - if type(packet.message) == "number" then - -- this is a greeting - log.write("reactor " .. packet.message .. " connected", colors.green) - - -- send current control command - for key, rctr in pairs(reactors) do - if rctr.id == packet.message then - send(rctr.id, rctr.control_state) - break - end - end - else - -- got reactor status - local eval_safety = false - - for key, value in pairs(reactors) do - if value.id == packet.message.id then - local tag = "RCT-" .. value.id .. ": " - - if value.state.run ~= packet.message.run then - value.state.run = packet.message.run - if value.state.run then - eval_safety = true - log.write(tag .. "running", colors.green) - end - end - - if value.state.no_fuel ~= packet.message.no_fuel then - value.state.no_fuel = packet.message.no_fuel - if value.state.no_fuel then - eval_safety = true - log.write(tag .. "insufficient fuel", colors.gray) - end - end - - if value.state.full_waste ~= packet.message.full_waste then - value.state.full_waste = packet.message.full_waste - if value.state.full_waste then - eval_safety = true - log.write(tag .. "waste tank full", colors.brown) - end - end - - if value.state.high_temp ~= packet.message.high_temp then - value.state.high_temp = packet.message.high_temp - if value.state.high_temp then - eval_safety = true - log.write(tag .. "high temperature", colors.orange) - end - end - - if value.state.damage_crit ~= packet.message.damage_crit then - value.state.damage_crit = packet.message.damage_crit - if value.state.damage_crit then - eval_safety = true - log.write(tag .. "critical damage", colors.red) - end - end - - break - end - end - - -- check to ensure safe operation - if eval_safety then - regulator.enforce_safeties() - end - end -end - --- send a message to a given reactor --- dest: reactor ID --- message: true or false for enable control or another value for other functionality, like 0 for greeting -function send(dest, message) - modem.transmit(dest + defs.LISTEN_PORT, defs.LISTEN_PORT, message) -end - --- broadcast a message to all reactors --- message: true or false for enable control or another value for other functionality, like 0 for greeting -function broadcast(message) - for key, value in pairs(reactors) do - modem.transmit(value.id + defs.LISTEN_PORT, defs.LISTEN_PORT, message) - end -end diff --git a/pocket/config.lua b/pocket/config.lua new file mode 100644 index 0000000..e69de29 diff --git a/pocket/startup.lua b/pocket/startup.lua new file mode 100644 index 0000000..032bba9 --- /dev/null +++ b/pocket/startup.lua @@ -0,0 +1,16 @@ +-- +-- SCADA Coordinator Access on a Pocket Computer +-- + +require("/initenv").init_env() + +local util = require("scada-common.util") + +local POCKET_VERSION = "alpha-v0.0.0" + +local print = util.print +local println = util.println +local print_ts = util.print_ts +local println_ts = util.println_ts + +println("Sorry, this isn't written yet :(") diff --git a/reactor-plc/config.lua b/reactor-plc/config.lua new file mode 100644 index 0000000..f3cf0f6 --- /dev/null +++ b/reactor-plc/config.lua @@ -0,0 +1,24 @@ +local config = {} + +-- set to false to run in offline mode (safety regulation only) +config.NETWORKED = true +-- unique reactor ID +config.REACTOR_ID = 1 + +-- port to send packets TO server +config.SERVER_PORT = 16000 +-- port to listen to incoming packets FROM server +config.LISTEN_PORT = 14001 +-- 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/reactor-plc/plc.lua b/reactor-plc/plc.lua new file mode 100644 index 0000000..dce6a73 --- /dev/null +++ b/reactor-plc/plc.lua @@ -0,0 +1,1006 @@ +local comms = require("scada-common.comms") +local const = require("scada-common.constants") +local log = require("scada-common.log") +local ppm = require("scada-common.ppm") +local types = require("scada-common.types") +local util = require("scada-common.util") + +local plc = {} + +local RPS_TRIP_CAUSE = types.RPS_TRIP_CAUSE + +local PROTOCOL = comms.PROTOCOL +local DEVICE_TYPE = comms.DEVICE_TYPE +local ESTABLISH_ACK = comms.ESTABLISH_ACK +local RPLC_TYPE = comms.RPLC_TYPE +local SCADA_MGMT_TYPE = comms.SCADA_MGMT_TYPE +local AUTO_ACK = comms.PLC_AUTO_ACK + +local RPS_LIMITS = const.RPS_LIMITS + +local print = util.print +local println = util.println +local print_ts = util.print_ts +local println_ts = util.println_ts + +-- I sure hope the devs don't change this error message, not that it would have safety implications +-- I wish they didn't change it to be like this +local PCALL_SCRAM_MSG = "pcall: Scram requires the reactor to be active." +local PCALL_START_MSG = "pcall: Reactor is already active." + +-- RPS: Reactor Protection System
+-- identifies dangerous states and SCRAMs reactor if warranted
+-- autonomous from main SCADA supervisor/coordinator control +---@nodiscard +---@param reactor table +---@param is_formed boolean +function plc.rps_init(reactor, is_formed) + local state_keys = { + high_dmg = 1, + high_temp = 2, + low_coolant = 3, + ex_waste = 4, + ex_hcoolant = 5, + no_fuel = 6, + fault = 7, + timeout = 8, + manual = 9, + automatic = 10, + sys_fail = 11, + force_disabled = 12 + } + + local self = { + state = { false, false, false, false, false, false, false, false, false, false, false, false }, + reactor_enabled = false, + enabled_at = 0, + formed = is_formed, + force_disabled = false, + tripped = false, + trip_cause = "ok" ---@type rps_trip_cause + } + + -- PRIVATE FUNCTIONS -- + + -- set reactor access fault flag + local function _set_fault() + if reactor.__p_last_fault() ~= "Terminated" then + self.state[state_keys.fault] = true + end + end + + -- clear reactor access fault flag + local function _clear_fault() + self.state[state_keys.fault] = false + end + + -- check if the reactor is formed + local function _is_formed() + local formed = reactor.isFormed() + if formed == ppm.ACCESS_FAULT then + -- lost the peripheral or terminated, handled later + _set_fault() + else + self.formed = formed + + if not self.state[state_keys.sys_fail] then + self.state[state_keys.sys_fail] = not formed + end + end + end + + -- check if the reactor is force disabled + local function _is_force_disabled() + local disabled = reactor.isForceDisabled() + if disabled == ppm.ACCESS_FAULT then + -- lost the peripheral or terminated, handled later + _set_fault() + else + self.force_disabled = disabled + + if not self.state[state_keys.force_disabled] then + self.state[state_keys.force_disabled] = disabled + end + end + end + + -- check for high damage + local function _high_damage() + local damage_percent = reactor.getDamagePercent() + if damage_percent == ppm.ACCESS_FAULT then + -- lost the peripheral or terminated, handled later + _set_fault() + elseif not self.state[state_keys.high_dmg] then + self.state[state_keys.high_dmg] = damage_percent >= RPS_LIMITS.MAX_DAMAGE_PERCENT + end + end + + -- check if the reactor is at a critically high temperature + local function _high_temp() + -- mekanism: MAX_DAMAGE_TEMPERATURE = 1_200 + local temp = reactor.getTemperature() + if temp == ppm.ACCESS_FAULT then + -- lost the peripheral or terminated, handled later + _set_fault() + elseif not self.state[state_keys.high_temp] then + self.state[state_keys.high_temp] = temp >= RPS_LIMITS.MAX_DAMAGE_TEMPERATURE + end + end + + -- check if there is very low coolant + local function _low_coolant() + local coolant_filled = reactor.getCoolantFilledPercentage() + if coolant_filled == ppm.ACCESS_FAULT then + -- lost the peripheral or terminated, handled later + _set_fault() + elseif not self.state[state_keys.low_coolant] then + self.state[state_keys.low_coolant] = coolant_filled < RPS_LIMITS.MIN_COOLANT_FILL + end + end + + -- check for excess waste (>80% filled) + local function _excess_waste() + local w_filled = reactor.getWasteFilledPercentage() + if w_filled == ppm.ACCESS_FAULT then + -- lost the peripheral or terminated, handled later + _set_fault() + elseif not self.state[state_keys.ex_waste] then + self.state[state_keys.ex_waste] = w_filled > RPS_LIMITS.MAX_WASTE_FILL + end + end + + -- check for heated coolant backup (>95% filled) + local function _excess_heated_coolant() + local hc_filled = reactor.getHeatedCoolantFilledPercentage() + if hc_filled == ppm.ACCESS_FAULT then + -- lost the peripheral or terminated, handled later + _set_fault() + elseif not self.state[state_keys.ex_hcoolant] then + self.state[state_keys.ex_hcoolant] = hc_filled > RPS_LIMITS.MAX_HEATED_COLLANT_FILL + end + end + + -- check if there is no fuel + local function _insufficient_fuel() + local fuel = reactor.getFuelFilledPercentage() + if fuel == ppm.ACCESS_FAULT then + -- lost the peripheral or terminated, handled later + _set_fault() + elseif not self.state[state_keys.no_fuel] then + self.state[state_keys.no_fuel] = fuel <= RPS_LIMITS.NO_FUEL_FILL + end + end + + -- PUBLIC FUNCTIONS -- + + ---@class rps + local public = {} + + -- re-link a reactor after a peripheral re-connect + ---@param new_reactor table reconnected reactor + function public.reconnect_reactor(new_reactor) + reactor = new_reactor + end + + -- trip for lost peripheral + function public.trip_fault() + _set_fault() + end + + -- trip for a PLC comms timeout + function public.trip_timeout() + self.state[state_keys.timeout] = true + end + + -- manually SCRAM the reactor + function public.trip_manual() + self.state[state_keys.manual] = true + end + + -- automatic SCRAM commanded by supervisor + function public.trip_auto() + self.state[state_keys.automatic] = true + end + + -- trip for unformed reactor + function public.trip_sys_fail() + self.state[state_keys.fault] = true + self.state[state_keys.sys_fail] = true + end + + -- SCRAM the reactor now (blocks waiting for server tick) + ---@return boolean success + function public.scram() + log.info("RPS: reactor SCRAM") + + reactor.scram() + if reactor.__p_is_faulted() and (reactor.__p_last_fault() ~= PCALL_SCRAM_MSG) then + log.error("RPS: failed reactor SCRAM") + return false + else + self.reactor_enabled = false + self.last_runtime = util.time_ms() - self.enabled_at + return true + end + end + + -- start the reactor now (blocks waiting for server tick) + ---@return boolean success + function public.activate() + if not self.tripped then + log.info("RPS: reactor start") + + reactor.activate() + if reactor.__p_is_faulted() and (reactor.__p_last_fault() ~= PCALL_START_MSG) then + log.error("RPS: failed reactor start") + else + self.reactor_enabled = true + self.enabled_at = util.time_ms() + return true + end + else + log.debug(util.c("RPS: failed start, RPS tripped: ", self.trip_cause)) + end + + return false + end + + -- automatic control activate/re-activate + ---@return boolean success + function public.auto_activate() + -- clear automatic SCRAM if it was the cause + if self.tripped and self.trip_cause == "automatic" then + self.state[state_keys.automatic] = true + self.trip_cause = RPS_TRIP_CAUSE.OK + self.tripped = false + + log.debug("RPS: cleared automatic SCRAM for re-activation") + end + + return public.activate() + end + + -- check all safety conditions + ---@nodiscard + ---@return boolean tripped, rps_trip_cause trip_status, boolean first_trip + function public.check() + local status = RPS_TRIP_CAUSE.OK + local was_tripped = self.tripped + local first_trip = false + + if self.formed then + -- update state + parallel.waitForAll( + _is_formed, + _is_force_disabled, + _high_damage, + _high_temp, + _low_coolant, + _excess_waste, + _excess_heated_coolant, + _insufficient_fuel + ) + else + -- check to see if its now formed + _is_formed() + end + + -- check system states in order of severity + if self.tripped then + status = self.trip_cause + elseif self.state[state_keys.sys_fail] then + log.warning("RPS: system failure, reactor not formed") + status = RPS_TRIP_CAUSE.SYS_FAIL + elseif self.state[state_keys.force_disabled] then + log.warning("RPS: reactor was force disabled") + status = RPS_TRIP_CAUSE.FORCE_DISABLED + elseif self.state[state_keys.high_dmg] then + log.warning("RPS: high damage") + status = RPS_TRIP_CAUSE.HIGH_DMG + elseif self.state[state_keys.high_temp] then + log.warning("RPS: high temperature") + status = RPS_TRIP_CAUSE.HIGH_TEMP + elseif self.state[state_keys.low_coolant] then + log.warning("RPS: low coolant") + status = RPS_TRIP_CAUSE.LOW_COOLANT + elseif self.state[state_keys.ex_waste] then + log.warning("RPS: full waste") + status = RPS_TRIP_CAUSE.EX_WASTE + elseif self.state[state_keys.ex_hcoolant] then + log.warning("RPS: heated coolant backup") + status = RPS_TRIP_CAUSE.EX_HCOOLANT + elseif self.state[state_keys.no_fuel] then + log.warning("RPS: no fuel") + status = RPS_TRIP_CAUSE.NO_FUEL + elseif self.state[state_keys.fault] then + log.warning("RPS: reactor access fault") + status = RPS_TRIP_CAUSE.FAULT + elseif self.state[state_keys.timeout] then + log.warning("RPS: supervisor connection timeout") + status = RPS_TRIP_CAUSE.TIMEOUT + elseif self.state[state_keys.manual] then + log.warning("RPS: manual SCRAM requested") + status = RPS_TRIP_CAUSE.MANUAL + elseif self.state[state_keys.automatic] then + log.warning("RPS: automatic SCRAM requested") + status = RPS_TRIP_CAUSE.AUTOMATIC + else + self.tripped = false + self.trip_cause = RPS_TRIP_CAUSE.OK + end + + -- if a new trip occured... + if (not was_tripped) and (status ~= RPS_TRIP_CAUSE.OK) then + first_trip = true + self.tripped = true + self.trip_cause = status + + -- in the case that the reactor is detected to be active, + -- it will be scrammed shortly after this in the main RPS loop if we don't here + if self.formed then + if not self.force_disabled then + public.scram() + else + log.warning("RPS: skipping SCRAM due to reactor being force disabled") + end + else + log.warning("RPS: skipping SCRAM due to not being formed") + end + end + + return self.tripped, status, first_trip + end + + ---@nodiscard + function public.status() return self.state end + + ---@nodiscard + function public.is_tripped() return self.tripped end + ---@nodiscard + function public.get_trip_cause() return self.trip_cause end + + ---@nodiscard + function public.is_active() return self.reactor_enabled end + ---@nodiscard + function public.is_formed() return self.formed end + ---@nodiscard + function public.is_force_disabled() return self.force_disabled end + + -- get the runtime of the reactor if active, or the last runtime if disabled + ---@nodiscard + ---@return integer runtime time since last enable + function public.get_runtime() return util.trinary(self.reactor_enabled, util.time_ms() - self.enabled_at, self.last_runtime) end + + -- reset the RPS + ---@param quiet? boolean true to suppress the info log message + function public.reset(quiet) + self.tripped = false + self.trip_cause = RPS_TRIP_CAUSE.OK + + for i = 1, #self.state do + self.state[i] = false + end + + if not quiet then log.info("RPS: reset") end + end + + -- reset the automatic and timeout trip flags, then clear trip if that was the trip cause + function public.auto_reset() + self.state[state_keys.automatic] = false + self.state[state_keys.timeout] = false + + if self.trip_cause == RPS_TRIP_CAUSE.AUTOMATIC or self.trip_cause == RPS_TRIP_CAUSE.TIMEOUT then + self.trip_cause = RPS_TRIP_CAUSE.OK + self.tripped = false + + log.info("RPS: auto reset") + end + end + + return public +end + +-- Reactor PLC Communications +---@nodiscard +---@param id integer reactor ID +---@param version string PLC version +---@param modem table modem device +---@param local_port integer local listening port +---@param server_port integer remote server port +---@param range integer trusted device connection range +---@param reactor table reactor device +---@param rps rps RPS reference +---@param conn_watchdog watchdog watchdog reference +function plc.comms(id, version, modem, local_port, server_port, range, reactor, rps, conn_watchdog) + local self = { + seq_num = 0, + r_seq_num = nil, + scrammed = false, + linked = false, + last_est_ack = ESTABLISH_ACK.ALLOW, + resend_build = false, + auto_ack_token = 0, + status_cache = nil, + max_burn_rate = nil + } + + comms.set_trusted_range(range) + + -- PRIVATE FUNCTIONS -- + + -- configure modem channels + local function _conf_channels() + modem.closeAll() + modem.open(local_port) + end + + _conf_channels() + + -- send an RPLC packet + ---@param msg_type RPLC_TYPE + ---@param msg table + local function _send(msg_type, msg) + local s_pkt = comms.scada_packet() + local r_pkt = comms.rplc_packet() + + r_pkt.make(id, msg_type, msg) + s_pkt.make(self.seq_num, PROTOCOL.RPLC, r_pkt.raw_sendable()) + + modem.transmit(server_port, local_port, s_pkt.raw_sendable()) + 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()) + + modem.transmit(server_port, local_port, s_pkt.raw_sendable()) + self.seq_num = self.seq_num + 1 + end + + -- variable reactor status information, excluding heating rate + ---@return table data_table, boolean faulted + local function _reactor_status() + local fuel = nil + local waste = nil + local coolant = nil + local hcoolant = nil + + local data_table = { + false, -- getStatus + 0, -- getBurnRate + 0, -- getActualBurnRate + 0, -- getTemperature + 0, -- getDamagePercent + 0, -- getBoilEfficiency + 0, -- getEnvironmentalLoss + 0, -- fuel_amnt + 0, -- getFuelFilledPercentage + 0, -- waste_amnt + 0, -- getWasteFilledPercentage + "", -- coolant_name + 0, -- coolant_amnt + 0, -- getCoolantFilledPercentage + "", -- hcoolant_name + 0, -- hcoolant_amnt + 0 -- getHeatedCoolantFilledPercentage + } + + local tasks = { + function () data_table[1] = reactor.getStatus() end, + function () data_table[2] = reactor.getBurnRate() end, + function () data_table[3] = reactor.getActualBurnRate() end, + function () data_table[4] = reactor.getTemperature() end, + function () data_table[5] = reactor.getDamagePercent() end, + function () data_table[6] = reactor.getBoilEfficiency() end, + function () data_table[7] = reactor.getEnvironmentalLoss() end, + function () fuel = reactor.getFuel() end, + function () data_table[9] = reactor.getFuelFilledPercentage() end, + function () waste = reactor.getWaste() end, + function () data_table[11] = reactor.getWasteFilledPercentage() end, + function () coolant = reactor.getCoolant() end, + function () data_table[14] = reactor.getCoolantFilledPercentage() end, + function () hcoolant = reactor.getHeatedCoolant() end, + function () data_table[17] = reactor.getHeatedCoolantFilledPercentage() end + } + + parallel.waitForAll(table.unpack(tasks)) + + if fuel ~= nil then + data_table[8] = fuel.amount + end + + if waste ~= nil then + data_table[10] = waste.amount + end + + if coolant ~= nil then + data_table[12] = coolant.name + data_table[13] = coolant.amount + end + + if hcoolant ~= nil then + data_table[15] = hcoolant.name + data_table[16] = hcoolant.amount + end + + return data_table, reactor.__p_is_faulted() + end + + -- update the status cache if changed + ---@return boolean changed + local function _update_status_cache() + local status, faulted = _reactor_status() + local changed = false + + if self.status_cache ~= nil then + if not faulted then + for i = 1, #status do + if status[i] ~= self.status_cache[i] then + changed = true + break + end + end + end + else + changed = true + end + + if changed and not faulted then + self.status_cache = status + end + + return changed + end + + -- keep alive ack + ---@param srv_time integer + local function _send_keep_alive_ack(srv_time) + _send_mgmt(SCADA_MGMT_TYPE.KEEP_ALIVE, { srv_time, util.time() }) + end + + -- general ack + ---@param msg_type RPLC_TYPE + ---@param status boolean|integer + local function _send_ack(msg_type, status) + _send(msg_type, { status }) + end + + -- send structure properties (these should not change, server will cache these) + local function _send_struct() + local min_pos = { x = 0, y = 0, z = 0 } + local max_pos = { x = 0, y = 0, z = 0 } + + local mek_data = { false, 0, 0, 0, min_pos, max_pos, 0, 0, 0, 0, 0, 0, 0, 0 } + + local tasks = { + function () mek_data[1] = reactor.getLength() end, + function () mek_data[2] = reactor.getWidth() end, + function () mek_data[3] = reactor.getHeight() end, + function () mek_data[4] = reactor.getMinPos() end, + function () mek_data[5] = reactor.getMaxPos() end, + function () mek_data[6] = reactor.getHeatCapacity() end, + function () mek_data[7] = reactor.getFuelAssemblies() end, + function () mek_data[8] = reactor.getFuelSurfaceArea() end, + function () mek_data[9] = reactor.getFuelCapacity() end, + function () mek_data[10] = reactor.getWasteCapacity() end, + function () mek_data[11] = reactor.getCoolantCapacity() end, + function () mek_data[12] = reactor.getHeatedCoolantCapacity() end, + function () mek_data[13] = reactor.getMaxBurnRate() end + } + + parallel.waitForAll(table.unpack(tasks)) + + if not reactor.__p_is_faulted() then + _send(RPLC_TYPE.MEK_STRUCT, mek_data) + self.resend_build = false + else + log.error("failed to send structure: PPM fault") + end + end + + -- PUBLIC FUNCTIONS -- + + ---@class plc_comms + local public = {} + + -- reconnect a newly connected modem + ---@param new_modem table + function public.reconnect_modem(new_modem) + modem = new_modem + _conf_channels() + end + + -- reconnect a newly connected reactor + ---@param new_reactor table + function public.reconnect_reactor(new_reactor) + reactor = new_reactor + self.status_cache = nil + self.resend_build = true + self.max_burn_rate = nil + end + + -- unlink from the server + function public.unlink() + self.linked = false + self.r_seq_num = nil + self.status_cache = nil + end + + -- close the connection to the server + function public.close() + conn_watchdog.cancel() + public.unlink() + _send_mgmt(SCADA_MGMT_TYPE.CLOSE, {}) + end + + -- attempt to establish link with supervisor + function public.send_link_req() + _send_mgmt(SCADA_MGMT_TYPE.ESTABLISH, { comms.version, version, DEVICE_TYPE.PLC, id }) + end + + -- send live status information + ---@param no_reactor boolean PLC lost reactor connection + ---@param formed boolean reactor formed (from PLC state) + function public.send_status(no_reactor, formed) + if self.linked then + local mek_data = nil ---@type table + local heating_rate = 0.0 ---@type number + + if (not no_reactor) and rps.is_formed() then + if _update_status_cache() then + mek_data = self.status_cache + end + + heating_rate = reactor.getHeatingRate() + end + + local sys_status = { + util.time(), -- timestamp + (not self.scrammed), -- requested control state + no_reactor, -- no reactor peripheral connected + formed, -- reactor formed + self.auto_ack_token, -- token to indicate auto command has been received before this status update + heating_rate, -- heating rate + mek_data -- mekanism status data + } + + _send(RPLC_TYPE.STATUS, sys_status) + + if self.resend_build then _send_struct() end + end + end + + -- send reactor protection system status + function public.send_rps_status() + if self.linked then + _send(RPLC_TYPE.RPS_STATUS, { rps.is_tripped(), rps.get_trip_cause(), table.unpack(rps.status()) }) + end + end + + -- send reactor protection system alarm + ---@param cause rps_trip_cause reactor protection system status + function public.send_rps_alarm(cause) + if self.linked then + _send(RPLC_TYPE.RPS_ALARM, { cause, table.unpack(rps.status()) }) + end + end + + -- parse an RPLC packet + ---@nodiscard + ---@param side string + ---@param sender integer + ---@param reply_to integer + ---@param message any + ---@param distance integer + ---@return rplc_frame|mgmt_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 RPLC packet + if s_pkt.protocol() == PROTOCOL.RPLC then + local rplc_pkt = comms.rplc_packet() + if rplc_pkt.decode(s_pkt) then + pkt = rplc_pkt.get() + end + -- get as SCADA management packet + elseif 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 + else + log.error("illegal packet type " .. s_pkt.protocol(), true) + end + end + + return pkt + end + + -- handle an RPLC packet + ---@param packet rplc_frame|mgmt_frame packet frame + ---@param plc_state plc_state PLC state + ---@param setpoints setpoints setpoint control table + function public.handle_packet(packet, plc_state, setpoints) + if packet.scada_frame.local_port() == local_port then + -- check sequence number + if self.r_seq_num == nil then + self.r_seq_num = packet.scada_frame.seq_num() + elseif self.linked and self.r_seq_num >= packet.scada_frame.seq_num() then + log.warning("sequence out-of-order: last = " .. self.r_seq_num .. ", new = " .. packet.scada_frame.seq_num()) + return + else + self.r_seq_num = packet.scada_frame.seq_num() + end + + -- feed the watchdog first so it doesn't uhh...eat our packets :) + conn_watchdog.feed() + + local protocol = packet.scada_frame.protocol() + + -- handle packet + if protocol == PROTOCOL.RPLC then + ---@cast packet rplc_frame + if self.linked then + if packet.type == RPLC_TYPE.STATUS then + -- request of full status, clear cache first + self.status_cache = nil + public.send_status(plc_state.no_reactor, plc_state.reactor_formed) + log.debug("sent out status cache again, did supervisor miss it?") + elseif packet.type == RPLC_TYPE.MEK_STRUCT then + -- request for physical structure + _send_struct() + log.debug("sent out structure again, did supervisor miss it?") + elseif packet.type == RPLC_TYPE.MEK_BURN_RATE then + -- set the burn rate + if (packet.length == 2) and (type(packet.data[1]) == "number") then + local success = false + local burn_rate = math.floor(packet.data[1] * 10) / 10 + local ramp = packet.data[2] + + -- if no known max burn rate, check again + if self.max_burn_rate == nil then + self.max_burn_rate = reactor.getMaxBurnRate() + end + + -- if we know our max burn rate, update current burn rate setpoint if in range + if self.max_burn_rate ~= ppm.ACCESS_FAULT then + if burn_rate > 0 and burn_rate <= self.max_burn_rate then + if ramp then + setpoints.burn_rate_en = true + setpoints.burn_rate = burn_rate + success = true + else + reactor.setBurnRate(burn_rate) + success = not reactor.__p_is_faulted() + end + else + log.debug(burn_rate .. " rate outside of 0 < x <= " .. self.max_burn_rate) + end + end + + _send_ack(packet.type, success) + else + log.debug("RPLC set burn rate packet length mismatch or non-numeric burn rate") + end + elseif packet.type == RPLC_TYPE.RPS_ENABLE then + -- enable the reactor + self.scrammed = false + _send_ack(packet.type, rps.activate()) + elseif packet.type == RPLC_TYPE.RPS_SCRAM then + -- disable the reactor per manual request + self.scrammed = true + rps.trip_manual() + _send_ack(packet.type, true) + elseif packet.type == RPLC_TYPE.RPS_ASCRAM then + -- disable the reactor per automatic request + self.scrammed = true + rps.trip_auto() + _send_ack(packet.type, true) + elseif packet.type == RPLC_TYPE.RPS_RESET then + -- reset the RPS status + rps.reset() + _send_ack(packet.type, true) + elseif packet.type == RPLC_TYPE.RPS_AUTO_RESET then + -- reset automatic SCRAM and timeout trips + rps.auto_reset() + _send_ack(packet.type, true) + elseif packet.type == RPLC_TYPE.AUTO_BURN_RATE then + -- automatic control requested a new burn rate + if (packet.length == 3) and (type(packet.data[1]) == "number") and (type(packet.data[3]) == "number") then + local ack = AUTO_ACK.FAIL + local burn_rate = math.floor(packet.data[1] * 100) / 100 + local ramp = packet.data[2] + self.auto_ack_token = packet.data[3] + + -- if no known max burn rate, check again + if self.max_burn_rate == nil then + self.max_burn_rate = reactor.getMaxBurnRate() + end + + -- if we know our max burn rate, update current burn rate setpoint if in range + if self.max_burn_rate ~= ppm.ACCESS_FAULT then + if burn_rate < 0.01 then + if rps.is_active() then + -- auto scram to disable + log.debug("AUTO: stopping the reactor to meet 0.0 burn rate") + if rps.scram() then + ack = AUTO_ACK.ZERO_DIS_OK + else + log.warning("AUTO: automatic reactor stop failed") + end + else + ack = AUTO_ACK.ZERO_DIS_OK + end + elseif burn_rate <= self.max_burn_rate then + if not rps.is_active() then + -- activate the reactor + log.debug("AUTO: activating the reactor") + + reactor.setBurnRate(0.01) + if reactor.__p_is_faulted() then + log.warning("AUTO: failed to reset burn rate for auto activation") + else + if not rps.auto_activate() then + log.warning("AUTO: automatic reactor activation failed") + end + end + end + + -- if active, set/ramp burn rate + if rps.is_active() then + if ramp then + log.debug(util.c("AUTO: setting burn rate ramp to ", burn_rate)) + setpoints.burn_rate_en = true + setpoints.burn_rate = burn_rate + ack = AUTO_ACK.RAMP_SET_OK + else + log.debug(util.c("AUTO: setting burn rate directly to ", burn_rate)) + reactor.setBurnRate(burn_rate) + ack = util.trinary(reactor.__p_is_faulted(), AUTO_ACK.FAIL, AUTO_ACK.DIRECT_SET_OK) + end + end + else + log.debug(util.c(burn_rate, " rate outside of 0 < x <= ", self.max_burn_rate)) + end + end + + _send_ack(packet.type, ack) + else + log.debug("RPLC set automatic burn rate packet length mismatch or non-numeric burn rate") + end + else + log.warning("received unknown RPLC packet type " .. packet.type) + end + else + log.debug("discarding RPLC packet before linked") + end + elseif protocol == PROTOCOL.SCADA_MGMT then + ---@cast packet mgmt_frame + if self.linked then + if packet.type == SCADA_MGMT_TYPE.ESTABLISH then + -- link request confirmation + if packet.length == 1 then + log.debug("received unsolicited establish response") + + local est_ack = packet.data[1] + + if est_ack == ESTABLISH_ACK.ALLOW then + self.status_cache = nil + _send_struct() + public.send_status(plc_state.no_reactor, plc_state.reactor_formed) + log.debug("re-sent initial status data") + elseif est_ack == ESTABLISH_ACK.DENY then + println_ts("received unsolicited link denial, unlinking") + log.info("unsolicited establish request denied") + elseif est_ack == ESTABLISH_ACK.COLLISION then + println_ts("received unsolicited link collision, unlinking") + log.warning("unsolicited establish request collision") + elseif est_ack == ESTABLISH_ACK.BAD_VERSION then + println_ts("received unsolicited link version mismatch, unlinking") + log.warning("unsolicited establish request version mismatch") + else + println_ts("invalid unsolicited link response") + log.error("unsolicited unknown establish request response") + end + + self.linked = est_ack == ESTABLISH_ACK.ALLOW + + -- clear this since this is for something that was unsolicited + self.last_est_ack = ESTABLISH_ACK.ALLOW + else + log.debug("SCADA_MGMT establish packet length mismatch") + end + elseif packet.type == SCADA_MGMT_TYPE.KEEP_ALIVE then + -- keep alive request received, echo back + if packet.length == 1 and type(packet.data[1]) == "number" then + local timestamp = packet.data[1] + local trip_time = util.time() - timestamp + + if trip_time > 750 then + log.warning("PLC KEEP_ALIVE trip time > 750ms (" .. trip_time .. "ms)") + end + + -- log.debug("RPLC RTT = " .. trip_time .. "ms") + + _send_keep_alive_ack(timestamp) + else + log.debug("SCADA_MGMT keep alive packet length/type mismatch") + end + elseif packet.type == SCADA_MGMT_TYPE.CLOSE then + -- handle session close + conn_watchdog.cancel() + public.unlink() + println_ts("server connection closed by remote host") + log.warning("server connection closed by remote host") + else + log.warning("received unsupported SCADA_MGMT packet type " .. packet.type) + end + elseif packet.type == SCADA_MGMT_TYPE.ESTABLISH then + -- link request confirmation + if packet.length == 1 then + local est_ack = packet.data[1] + + if est_ack == ESTABLISH_ACK.ALLOW then + println_ts("linked!") + log.info("supervisor establish request approved, PLC is linked") + + -- reset remote sequence number and cache + self.r_seq_num = nil + self.status_cache = nil + + if plc_state.reactor_formed then _send_struct() end + public.send_status(plc_state.no_reactor, plc_state.reactor_formed) + + log.debug("sent initial status data") + elseif self.last_est_ack ~= est_ack then + if est_ack == ESTABLISH_ACK.DENY then + println_ts("link request denied, retrying...") + log.info("supervisor establish request denied, retrying") + elseif est_ack == ESTABLISH_ACK.COLLISION then + println_ts("reactor PLC ID collision (check config), retrying...") + log.warning("establish request collision, retrying") + elseif est_ack == ESTABLISH_ACK.BAD_VERSION then + println_ts("supervisor version mismatch (try updating), retrying...") + log.warning("establish request version mismatch, retrying") + else + println_ts("invalid link response, bad channel? retrying...") + log.error("unknown establish request response, retrying") + end + end + + self.linked = est_ack == ESTABLISH_ACK.ALLOW + self.last_est_ack = est_ack + else + log.debug("SCADA_MGMT establish packet length mismatch") + end + else + log.debug("discarding non-link SCADA_MGMT packet before linked") + end + else + -- should be unreachable assuming packet is from parse_packet() + log.error("illegal packet type " .. protocol, true) + end + end + end + + ---@nodiscard + function public.is_scrammed() return self.scrammed end + ---@nodiscard + function public.is_linked() return self.linked end + + return public +end + +return plc diff --git a/reactor-plc/startup.lua b/reactor-plc/startup.lua new file mode 100644 index 0000000..5ad5ca4 --- /dev/null +++ b/reactor-plc/startup.lua @@ -0,0 +1,224 @@ +-- +-- Reactor Programmable Logic Controller +-- + +require("/initenv").init_env() + +local crash = require("scada-common.crash") +local log = require("scada-common.log") +local mqueue = require("scada-common.mqueue") +local ppm = require("scada-common.ppm") +local util = require("scada-common.util") + +local config = require("reactor-plc.config") +local plc = require("reactor-plc.plc") +local threads = require("reactor-plc.threads") + +local R_PLC_VERSION = "v1.0.0" + +local print = util.print +local println = util.println +local print_ts = util.print_ts +local println_ts = util.println_ts + +---------------------------------------- +-- config validation +---------------------------------------- + +local cfv = util.new_validator() + +cfv.assert_type_bool(config.NETWORKED) +cfv.assert_type_int(config.REACTOR_ID) +cfv.assert_port(config.SERVER_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 reactor-plc.startup " .. R_PLC_VERSION) +log.info("========================================") +println(">> Reactor PLC " .. R_PLC_VERSION .. " <<") + +crash.set_env("plc", R_PLC_VERSION) + +---------------------------------------- +-- main application +---------------------------------------- + +local function main() + ---------------------------------------- + -- startup + ---------------------------------------- + + -- mount connected devices + ppm.mount_all() + + -- shared memory across threads + ---@class plc_shared_memory + local __shared_memory = { + -- networked setting + networked = config.NETWORKED, ---@type boolean + + -- PLC system state flags + ---@class plc_state + plc_state = { + init_ok = true, + shutdown = false, + degraded = false, + reactor_formed = true, + no_reactor = false, + no_modem = false + }, + + -- control setpoints + ---@class setpoints + setpoints = { + burn_rate_en = false, + burn_rate = 0.0 + }, + + -- core PLC devices + plc_dev = { + reactor = ppm.get_fission_reactor(), + modem = ppm.get_wireless_modem() + }, + + -- system objects + plc_sys = { + rps = nil, ---@type rps + plc_comms = nil, ---@type plc_comms + conn_watchdog = nil ---@type watchdog + }, + + -- message queues + q = { + mq_rps = mqueue.new(), + mq_comms_tx = mqueue.new(), + mq_comms_rx = mqueue.new() + } + } + + local smem_dev = __shared_memory.plc_dev + local smem_sys = __shared_memory.plc_sys + + local plc_state = __shared_memory.plc_state + + -- we need a reactor, can at least do some things even if it isn't formed though + if smem_dev.reactor == nil then + println("init> fission reactor not found"); + log.warning("init> no reactor on startup") + + plc_state.init_ok = false + plc_state.degraded = true + plc_state.no_reactor = true + elseif not smem_dev.reactor.isFormed() then + println("init> fission reactor not formed"); + log.warning("init> reactor logic adapter present, but reactor is not formed") + + plc_state.degraded = true + plc_state.reactor_formed = false + end + + -- modem is required if networked + if __shared_memory.networked and smem_dev.modem == nil then + println("init> wireless modem not found") + log.warning("init> no wireless modem on startup") + + -- scram reactor if present and enabled + if (smem_dev.reactor ~= nil) and plc_state.reactor_formed and smem_dev.reactor.getStatus() then + smem_dev.reactor.scram() + end + + plc_state.init_ok = false + plc_state.degraded = true + plc_state.no_modem = true + end + + -- PLC init
+ --- EVENT_CONSUMER: this function consumes events + local function init() + if plc_state.init_ok then + -- just booting up, no fission allowed (neutrons stay put thanks) + if plc_state.reactor_formed and smem_dev.reactor.getStatus() then + smem_dev.reactor.scram() + end + + -- init reactor protection system + smem_sys.rps = plc.rps_init(smem_dev.reactor, plc_state.reactor_formed) + log.debug("init> rps init") + + if __shared_memory.networked then + -- comms watchdog + smem_sys.conn_watchdog = util.new_watchdog(config.COMMS_TIMEOUT) + log.debug("init> conn watchdog started") + + -- start comms + smem_sys.plc_comms = plc.comms(config.REACTOR_ID, R_PLC_VERSION, smem_dev.modem, config.LISTEN_PORT, config.SERVER_PORT, + config.TRUSTED_RANGE, smem_dev.reactor, smem_sys.rps, smem_sys.conn_watchdog) + log.debug("init> comms init") + else + println("init> starting in offline mode") + log.info("init> running without networking") + end + + util.push_event("clock_start") + + println("init> completed") + log.info("init> startup completed") + else + println("init> system in degraded state, awaiting devices...") + log.warning("init> started in a degraded state, awaiting peripheral connections...") + end + end + + ---------------------------------------- + -- start system + ---------------------------------------- + + -- initialize PLC + init() + + -- init threads + local main_thread = threads.thread__main(__shared_memory, init) + local rps_thread = threads.thread__rps(__shared_memory) + + if __shared_memory.networked then + -- init comms threads + local comms_thread_tx = threads.thread__comms_tx(__shared_memory) + local comms_thread_rx = threads.thread__comms_rx(__shared_memory) + + -- setpoint control only needed when networked + local sp_ctrl_thread = threads.thread__setpoint_control(__shared_memory) + + -- run threads + parallel.waitForAll(main_thread.p_exec, rps_thread.p_exec, comms_thread_tx.p_exec, comms_thread_rx.p_exec, sp_ctrl_thread.p_exec) + + if plc_state.init_ok then + -- send status one last time after RPS shutdown + smem_sys.plc_comms.send_status(plc_state.no_reactor, plc_state.reactor_formed) + smem_sys.plc_comms.send_rps_status() + + -- close connection + smem_sys.plc_comms.close() + end + else + -- run threads, excluding comms + parallel.waitForAll(main_thread.p_exec, rps_thread.p_exec) + end + + println_ts("exited") + log.info("exited") +end + +if not xpcall(main, crash.handler) then crash.exit() end diff --git a/reactor-plc/threads.lua b/reactor-plc/threads.lua new file mode 100644 index 0000000..d2708fd --- /dev/null +++ b/reactor-plc/threads.lua @@ -0,0 +1,705 @@ +local log = require("scada-common.log") +local mqueue = require("scada-common.mqueue") +local ppm = require("scada-common.ppm") +local util = require("scada-common.util") + +local threads = {} + +local print = util.print +local println = util.println +local print_ts = util.print_ts +local println_ts = util.println_ts + +local MAIN_CLOCK = 0.5 -- (2Hz, 10 ticks) +local RPS_SLEEP = 250 -- (250ms, 5 ticks) +local COMMS_SLEEP = 150 -- (150ms, 3 ticks) +local SP_CTRL_SLEEP = 250 -- (250ms, 5 ticks) + +local BURN_RATE_RAMP_mB_s = 5.0 + +local MQ__RPS_CMD = { + SCRAM = 1, + DEGRADED_SCRAM = 2, + TRIP_TIMEOUT = 3 +} + +local MQ__COMM_CMD = { + SEND_STATUS = 1 +} + +-- main thread +---@nodiscard +---@param smem plc_shared_memory +---@param init function +function threads.thread__main(smem, init) + ---@class parallel_thread + local public = {} + + -- execute thread + function public.exec() + log.debug("main thread init, clock inactive") + + -- send status updates at 2Hz (every 10 server ticks) (every loop tick) + -- send link requests at 0.5Hz (every 40 server ticks) (every 8 loop ticks) + local LINK_TICKS = 8 + local ticks_to_update = 0 + local loop_clock = util.new_clock(MAIN_CLOCK) + + -- load in from shared memory + local networked = smem.networked + local plc_state = smem.plc_state + local plc_dev = smem.plc_dev + + -- event loop + while true do + -- get plc_sys fields (may have been set late due to degraded boot) + local rps = smem.plc_sys.rps + local plc_comms = smem.plc_sys.plc_comms + local conn_watchdog = smem.plc_sys.conn_watchdog + + local event, param1, param2, param3, param4, param5 = util.pull_event() + + -- handle event + if event == "timer" and loop_clock.is_clock(param1) then + -- core clock tick + if networked then + -- start next clock timer + loop_clock.start() + + -- send updated data + if not plc_state.no_modem then + if plc_comms.is_linked() then + smem.q.mq_comms_tx.push_command(MQ__COMM_CMD.SEND_STATUS) + else + if ticks_to_update == 0 then + plc_comms.send_link_req() + ticks_to_update = LINK_TICKS + else + ticks_to_update = ticks_to_update - 1 + end + end + end + end + + -- are we now formed after waiting to be formed? + if (not plc_state.reactor_formed) and rps.is_formed() then + -- push a connect event and unmount it from the PPM + local iface = ppm.get_iface(plc_dev.reactor) + if iface then + log.info("unmounting and remounting unformed reactor") + ppm.unmount(plc_dev.reactor) + + local type, device = ppm.mount(iface) + + if type == "fissionReactorLogicAdapter" and device ~= nil then + -- reconnect reactor + plc_dev.reactor = device + + -- we need to assume formed here as we cannot check in this main loop + -- RPS will identify if it isn't and this will get set false later + plc_state.reactor_formed = true + + println_ts("reactor reconnected.") + log.info("reactor reconnected") + + -- SCRAM newly connected reactor + smem.q.mq_rps.push_command(MQ__RPS_CMD.SCRAM) + + -- determine if we are still in a degraded state + if not networked or not plc_state.no_modem then + plc_state.degraded = false + end + + rps.reconnect_reactor(plc_dev.reactor) + if networked then + plc_comms.reconnect_reactor(plc_dev.reactor) + end + + -- reset RPS for newly connected reactor + -- without this, is_formed will be out of date and cause it to think its no longer formed again + rps.reset() + else + -- fully lost the reactor now :( + println_ts("reactor lost (failed reconnect)!") + log.error("reactor lost (failed reconnect)") + + plc_state.no_reactor = true + plc_state.degraded = true + end + else + log.error("failed to get interface of previously connected reactor", true) + end + elseif not rps.is_formed() then + -- reactor no longer formed + plc_state.reactor_formed = false + end + elseif event == "modem_message" and networked and plc_state.init_ok and not plc_state.no_modem then + -- got a packet + local packet = plc_comms.parse_packet(param1, param2, param3, param4, param5) + if packet ~= nil then + -- pass the packet onto the comms message queue + smem.q.mq_comms_rx.push_packet(packet) + end + elseif event == "timer" and networked and plc_state.init_ok and conn_watchdog.is_timer(param1) then + -- haven't heard from server recently? shutdown reactor + plc_comms.unlink() + smem.q.mq_rps.push_command(MQ__RPS_CMD.TRIP_TIMEOUT) + elseif event == "peripheral_detach" then + -- peripheral disconnect + local type, device = ppm.handle_unmount(param1) + + if type ~= nil and device ~= nil then + if type == "fissionReactorLogicAdapter" then + println_ts("reactor disconnected!") + log.error("reactor logic adapter disconnected") + + plc_state.no_reactor = true + plc_state.degraded = true + elseif networked and type == "modem" then + -- we only care if this is our wireless modem + if device == plc_dev.modem then + println_ts("comms modem disconnected!") + log.error("comms modem disconnected") + + plc_state.no_modem = true + + if plc_state.init_ok then + -- try to scram reactor if it is still connected + smem.q.mq_rps.push_command(MQ__RPS_CMD.DEGRADED_SCRAM) + end + + plc_state.degraded = true + else + log.warning("non-comms modem disconnected") + end + end + end + elseif event == "peripheral" then + -- peripheral connect + local type, device = ppm.mount(param1) + + if type ~= nil and device ~= nil then + if type == "fissionReactorLogicAdapter" then + -- reconnected reactor + plc_dev.reactor = device + + println_ts("reactor reconnected.") + log.info("reactor reconnected") + + plc_state.no_reactor = false + + -- we need to assume formed here as we cannot check in this main loop + -- RPS will identify if it isn't and this will get set false later + plc_state.reactor_formed = true + + -- determine if we are still in a degraded state + if (not networked or not plc_state.no_modem) and plc_state.reactor_formed then + plc_state.degraded = false + end + + if plc_state.init_ok then + smem.q.mq_rps.push_command(MQ__RPS_CMD.SCRAM) + + rps.reconnect_reactor(plc_dev.reactor) + if networked then + plc_comms.reconnect_reactor(plc_dev.reactor) + end + + -- reset RPS for newly connected reactor + -- without this, is_formed will be out of date and cause it to think its no longer formed again + rps.reset() + end + elseif networked and type == "modem" then + if device.isWireless() then + -- reconnected modem + plc_dev.modem = device + + if plc_state.init_ok then + plc_comms.reconnect_modem(plc_dev.modem) + end + + println_ts("wireless modem reconnected.") + log.info("comms modem reconnected") + plc_state.no_modem = false + + -- determine if we are still in a degraded state + if not plc_state.no_reactor then + plc_state.degraded = false + end + else + log.info("wired modem reconnected") + end + end + end + + -- if not init'd and no longer degraded, proceed to init + if not plc_state.init_ok and not plc_state.degraded then + plc_state.init_ok = true + init() + end + elseif event == "clock_start" then + -- start loop clock + loop_clock.start() + log.debug("main thread clock started") + end + + -- check for termination request + if event == "terminate" or ppm.should_terminate() then + log.info("terminate requested, main thread exiting") + -- rps handles reactor shutdown + plc_state.shutdown = true + break + end + end + end + + -- execute the thread in a protected mode, retrying it on return if not shutting down + function public.p_exec() + local plc_state = smem.plc_state + + while not plc_state.shutdown do + local status, result = pcall(public.exec) + if status == false then + log.fatal(util.strval(result)) + end + + -- if status is true, then we are probably exiting, so this won't matter + -- if not, we need to restart the clock + -- this thread cannot be slept because it will miss events (namely "terminate" otherwise) + if not plc_state.shutdown then + log.info("main thread restarting now...") + util.push_event("clock_start") + end + end + end + + return public +end + +-- RPS operation thread +---@nodiscard +---@param smem plc_shared_memory +function threads.thread__rps(smem) + ---@class parallel_thread + local public = {} + + -- execute thread + function public.exec() + log.debug("rps thread start") + + -- load in from shared memory + local networked = smem.networked + local plc_state = smem.plc_state + local plc_dev = smem.plc_dev + + local rps_queue = smem.q.mq_rps + + local was_linked = false + local last_update = util.time() + + -- thread loop + while true do + -- get plc_sys fields (may have been set late due to degraded boot) + local rps = smem.plc_sys.rps + local plc_comms = smem.plc_sys.plc_comms + -- get reactor, may have changed do to disconnect/reconnect + local reactor = plc_dev.reactor + + -- RPS checks + if plc_state.init_ok then + -- SCRAM if no open connection + if networked and not plc_comms.is_linked() then + if was_linked then + was_linked = false + rps.trip_timeout() + end + else + -- would do elseif not networked but there is no reason to do that extra operation + was_linked = true + end + + -- if we tried to SCRAM but failed, keep trying + -- in that case, SCRAM won't be called until it reconnects (this is the expected use of this check) +---@diagnostic disable-next-line: need-check-nil + if (not plc_state.no_reactor) and rps.is_formed() and rps.is_tripped() and reactor.getStatus() then + rps.scram() + end + + -- if we are in standalone mode, continuously reset RPS + -- RPS will trip again if there are faults, but if it isn't cleared, the user can't re-enable + if not networked then rps.reset(true) end + + -- check safety (SCRAM occurs if tripped) + if not plc_state.no_reactor then + local rps_tripped, rps_status_string, rps_first = rps.check() + + if rps_tripped and rps_first then + println_ts("[RPS] SCRAM! safety trip: " .. rps_status_string) + if networked and not plc_state.no_modem then + plc_comms.send_rps_alarm(rps_status_string) + end + end + end + end + + -- check for messages in the message queue + while rps_queue.ready() and not plc_state.shutdown do + local msg = rps_queue.pop() + + if msg ~= nil then + if msg.qtype == mqueue.TYPE.COMMAND then + -- received a command + if plc_state.init_ok then + if msg.message == MQ__RPS_CMD.SCRAM then + -- SCRAM + rps.scram() + elseif msg.message == MQ__RPS_CMD.DEGRADED_SCRAM then + -- lost peripheral(s) + rps.trip_fault() + elseif msg.message == MQ__RPS_CMD.TRIP_TIMEOUT then + -- watchdog tripped + rps.trip_timeout() + println_ts("server timeout") + log.warning("server timeout") + end + end + elseif msg.qtype == mqueue.TYPE.DATA then + -- received data + elseif msg.qtype == mqueue.TYPE.PACKET then + -- received a packet + end + end + + -- quick yield + util.nop() + end + + -- check for termination request + if plc_state.shutdown then + -- safe exit + log.info("rps thread shutdown initiated") + if plc_state.init_ok then + if rps.scram() then + println_ts("reactor disabled") + log.info("rps thread reactor SCRAM OK") + else + println_ts("exiting, reactor failed to disable") + log.error("rps thread failed to SCRAM reactor on exit") + end + end + log.info("rps thread exiting") + break + end + + -- delay before next check + last_update = util.adaptive_delay(RPS_SLEEP, last_update) + end + end + + -- execute the thread in a protected mode, retrying it on return if not shutting down + function public.p_exec() + local plc_state = smem.plc_state + + while not plc_state.shutdown do + local status, result = pcall(public.exec) + if status == false then + log.fatal(util.strval(result)) + end + + if not plc_state.shutdown then + if plc_state.init_ok then smem.plc_sys.rps.scram() end + log.info("rps thread restarting in 5 seconds...") + util.psleep(5) + end + end + end + + return public +end + +-- communications sender thread +---@nodiscard +---@param smem plc_shared_memory +function threads.thread__comms_tx(smem) + ---@class parallel_thread + local public = {} + + -- execute thread + function public.exec() + log.debug("comms tx thread start") + + -- load in from shared memory + local plc_state = smem.plc_state + local comms_queue = smem.q.mq_comms_tx + + local last_update = util.time() + + -- thread loop + while true do + -- get plc_sys fields (may have been set late due to degraded boot) + local plc_comms = smem.plc_sys.plc_comms + + -- check for messages in the message queue + while comms_queue.ready() and not plc_state.shutdown do + local msg = comms_queue.pop() + + if msg ~= nil and plc_state.init_ok then + if msg.qtype == mqueue.TYPE.COMMAND then + -- received a command + if msg.message == MQ__COMM_CMD.SEND_STATUS then + -- send PLC/RPS status + plc_comms.send_status(plc_state.no_reactor, plc_state.reactor_formed) + plc_comms.send_rps_status() + end + elseif msg.qtype == mqueue.TYPE.DATA then + -- received data + elseif msg.qtype == mqueue.TYPE.PACKET then + -- received a packet + end + end + + -- quick yield + util.nop() + end + + -- check for termination request + if plc_state.shutdown then + log.info("comms tx thread exiting") + break + end + + -- delay before next check + last_update = util.adaptive_delay(COMMS_SLEEP, last_update) + end + end + + -- execute the thread in a protected mode, retrying it on return if not shutting down + function public.p_exec() + local plc_state = smem.plc_state + + while not plc_state.shutdown do + local status, result = pcall(public.exec) + if status == false then + log.fatal(util.strval(result)) + end + + if not plc_state.shutdown then + log.info("comms tx thread restarting in 5 seconds...") + util.psleep(5) + end + end + end + + return public +end + +-- communications handler thread +---@nodiscard +---@param smem plc_shared_memory +function threads.thread__comms_rx(smem) + ---@class parallel_thread + local public = {} + + -- execute thread + function public.exec() + log.debug("comms rx thread start") + + -- load in from shared memory + local plc_state = smem.plc_state + local setpoints = smem.setpoints + + local comms_queue = smem.q.mq_comms_rx + + local last_update = util.time() + + -- thread loop + while true do + -- get plc_sys fields (may have been set late due to degraded boot) + local plc_comms = smem.plc_sys.plc_comms + + -- check for messages in the message queue + while comms_queue.ready() and not plc_state.shutdown do + local msg = comms_queue.pop() + + if msg ~= nil and plc_state.init_ok then + if msg.qtype == mqueue.TYPE.COMMAND then + -- received a command + elseif msg.qtype == mqueue.TYPE.DATA then + -- received data + elseif msg.qtype == mqueue.TYPE.PACKET then + -- received a packet + -- handle the packet (setpoints passed to update burn rate setpoint) + -- (plc_state passed to check if degraded) + plc_comms.handle_packet(msg.message, plc_state, setpoints) + end + end + + -- quick yield + util.nop() + end + + -- check for termination request + if plc_state.shutdown then + log.info("comms rx thread exiting") + break + end + + -- delay before next check + last_update = util.adaptive_delay(COMMS_SLEEP, last_update) + end + end + + -- execute the thread in a protected mode, retrying it on return if not shutting down + function public.p_exec() + local plc_state = smem.plc_state + + while not plc_state.shutdown do + local status, result = pcall(public.exec) + if status == false then + log.fatal(util.strval(result)) + end + + if not plc_state.shutdown then + log.info("comms rx thread restarting in 5 seconds...") + util.psleep(5) + end + end + end + + return public +end + +-- ramp control outputs to desired setpoints +---@nodiscard +---@param smem plc_shared_memory +function threads.thread__setpoint_control(smem) + ---@class parallel_thread + local public = {} + + -- execute thread + function public.exec() + log.debug("setpoint control thread start") + + -- load in from shared memory + local plc_state = smem.plc_state + local setpoints = smem.setpoints + local plc_dev = smem.plc_dev + + local last_update = util.time() + local running = false + + local last_burn_sp = 0.0 + + -- do not use the actual elapsed time, it could spike + -- we do not want to have big jumps as that is what we are trying to avoid in the first place + local min_elapsed_s = SP_CTRL_SLEEP / 1000.0 + + -- thread loop + while true do + -- get plc_sys fields (may have been set late due to degraded boot) + local rps = smem.plc_sys.rps + -- get reactor, may have changed do to disconnect/reconnect + local reactor = plc_dev.reactor + + if plc_state.init_ok and (not plc_state.no_reactor) then + -- check if we should start ramping + if setpoints.burn_rate_en and (setpoints.burn_rate ~= last_burn_sp) then +---@diagnostic disable-next-line: need-check-nil + local cur_burn_rate = reactor.getBurnRate() + + if (type(cur_burn_rate) == "number") and (setpoints.burn_rate ~= cur_burn_rate) and rps.is_active() then + last_burn_sp = setpoints.burn_rate + + -- update without ramp if <= 2.5 mB/t change + running = math.abs(setpoints.burn_rate - cur_burn_rate) > 2.5 + + if running then + log.debug(util.c("SPCTL: starting burn rate ramp from ", cur_burn_rate, " mB/t to ", setpoints.burn_rate, " mB/t")) + else + log.debug(util.c("SPCTL: setting burn rate directly to ", setpoints.burn_rate, " mB/t")) +---@diagnostic disable-next-line: need-check-nil + reactor.setBurnRate(setpoints.burn_rate) + end + end + end + + -- only check I/O if active to save on processing time + if running then + -- clear so we can later evaluate if we should keep running + running = false + + -- adjust burn rate (setpoints.burn_rate) + if setpoints.burn_rate_en then + if rps.is_active() then +---@diagnostic disable-next-line: need-check-nil + local current_burn_rate = reactor.getBurnRate() + + -- we yielded, check enable again + if setpoints.burn_rate_en and (type(current_burn_rate) == "number") and (current_burn_rate ~= setpoints.burn_rate) then + -- calculate new burn rate + local new_burn_rate = current_burn_rate + + if setpoints.burn_rate > current_burn_rate then + -- need to ramp up + new_burn_rate = current_burn_rate + (BURN_RATE_RAMP_mB_s * min_elapsed_s) + if new_burn_rate > setpoints.burn_rate then new_burn_rate = setpoints.burn_rate end + else + -- need to ramp down + new_burn_rate = current_burn_rate - (BURN_RATE_RAMP_mB_s * min_elapsed_s) + if new_burn_rate < setpoints.burn_rate then new_burn_rate = setpoints.burn_rate end + end + + running = running or (new_burn_rate ~= setpoints.burn_rate) + + -- set the burn rate +---@diagnostic disable-next-line: need-check-nil + reactor.setBurnRate(new_burn_rate) + end + else + log.debug("SPCTL: ramping aborted (reactor inactive)") + setpoints.burn_rate_en = false + end + end + elseif setpoints.burn_rate_en then + log.debug(util.c("SPCTL: ramping completed (setpoint of ", setpoints.burn_rate, " mB/t)")) + setpoints.burn_rate_en = false + end + + -- if ramping completed or was aborted, reset last burn setpoint so that if it is requested again it will be re-attempted + if not setpoints.burn_rate_en then + last_burn_sp = 0 + end + end + + -- check for termination request + if plc_state.shutdown then + log.info("setpoint control thread exiting") + break + end + + -- delay before next check + last_update = util.adaptive_delay(SP_CTRL_SLEEP, last_update) + end + end + + -- execute the thread in a protected mode, retrying it on return if not shutting down + function public.p_exec() + local plc_state = smem.plc_state + + while not plc_state.shutdown do + local status, result = pcall(public.exec) + if status == false then + log.fatal(util.strval(result)) + end + + if not plc_state.shutdown then + log.info("setpoint control thread restarting in 5 seconds...") + util.psleep(5) + end + end + end + + return public +end + +return threads diff --git a/rtu/config.lua b/rtu/config.lua new file mode 100644 index 0000000..96b26ee --- /dev/null +++ b/rtu/config.lua @@ -0,0 +1,63 @@ +local rsio = require("scada-common.rsio") + +local config = {} + +-- port to send packets TO server +config.SERVER_PORT = 16000 +-- port to listen to incoming packets FROM server +config.LISTEN_PORT = 15001 +-- max trusted modem message distance (< 1 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 + +-- RTU peripheral devices (named: side/network device name) +config.RTU_DEVICES = { + { + name = "boilerValve_0", + index = 1, + for_reactor = 1 + }, + { + name = "turbineValve_0", + index = 1, + for_reactor = 1 + } +} +-- RTU redstone interface definitions +config.RTU_REDSTONE = { + -- { + -- for_reactor = 1, + -- io = { + -- { + -- port = rsio.IO.WASTE_PO, + -- side = "top", + -- bundled_color = colors.red + -- }, + -- { + -- port = rsio.IO.WASTE_PU, + -- side = "top", + -- bundled_color = colors.orange + -- }, + -- { + -- port = rsio.IO.WASTE_POPL, + -- side = "top", + -- bundled_color = colors.yellow + -- }, + -- { + -- port = rsio.IO.WASTE_AM, + -- side = "top", + -- bundled_color = colors.lime + -- } + -- } + -- } +} + +return config diff --git a/rtu/dev/boilerv_rtu.lua b/rtu/dev/boilerv_rtu.lua new file mode 100644 index 0000000..b93d412 --- /dev/null +++ b/rtu/dev/boilerv_rtu.lua @@ -0,0 +1,56 @@ +local rtu = require("rtu.rtu") + +local boilerv_rtu = {} + +-- create new boiler (mek 10.1+) device +---@nodiscard +---@param boiler table +function boilerv_rtu.new(boiler) + local unit = rtu.init_unit() + + -- discrete inputs -- + unit.connect_di(boiler.isFormed) + + -- coils -- + -- none + + -- input registers -- + -- multiblock properties + unit.connect_input_reg(boiler.getLength) + unit.connect_input_reg(boiler.getWidth) + unit.connect_input_reg(boiler.getHeight) + unit.connect_input_reg(boiler.getMinPos) + unit.connect_input_reg(boiler.getMaxPos) + -- build properties + unit.connect_input_reg(boiler.getBoilCapacity) + unit.connect_input_reg(boiler.getSteamCapacity) + unit.connect_input_reg(boiler.getWaterCapacity) + unit.connect_input_reg(boiler.getHeatedCoolantCapacity) + unit.connect_input_reg(boiler.getCooledCoolantCapacity) + unit.connect_input_reg(boiler.getSuperheaters) + unit.connect_input_reg(boiler.getMaxBoilRate) + -- current state + unit.connect_input_reg(boiler.getTemperature) + unit.connect_input_reg(boiler.getBoilRate) + unit.connect_input_reg(boiler.getEnvironmentalLoss) + -- tanks + unit.connect_input_reg(boiler.getSteam) + unit.connect_input_reg(boiler.getSteamNeeded) + unit.connect_input_reg(boiler.getSteamFilledPercentage) + unit.connect_input_reg(boiler.getWater) + unit.connect_input_reg(boiler.getWaterNeeded) + unit.connect_input_reg(boiler.getWaterFilledPercentage) + unit.connect_input_reg(boiler.getHeatedCoolant) + unit.connect_input_reg(boiler.getHeatedCoolantNeeded) + unit.connect_input_reg(boiler.getHeatedCoolantFilledPercentage) + unit.connect_input_reg(boiler.getCooledCoolant) + unit.connect_input_reg(boiler.getCooledCoolantNeeded) + unit.connect_input_reg(boiler.getCooledCoolantFilledPercentage) + + -- holding registers -- + -- none + + return unit.interface() +end + +return boilerv_rtu diff --git a/rtu/dev/envd_rtu.lua b/rtu/dev/envd_rtu.lua new file mode 100644 index 0000000..ba4758a --- /dev/null +++ b/rtu/dev/envd_rtu.lua @@ -0,0 +1,27 @@ +local rtu = require("rtu.rtu") + +local envd_rtu = {} + +-- create new environment detector device +---@nodiscard +---@param envd table +function envd_rtu.new(envd) + local unit = rtu.init_unit() + + -- discrete inputs -- + -- none + + -- coils -- + -- none + + -- input registers -- + unit.connect_input_reg(envd.getRadiation) + unit.connect_input_reg(envd.getRadiationRaw) + + -- holding registers -- + -- none + + return unit.interface() +end + +return envd_rtu diff --git a/rtu/dev/imatrix_rtu.lua b/rtu/dev/imatrix_rtu.lua new file mode 100644 index 0000000..29405b8 --- /dev/null +++ b/rtu/dev/imatrix_rtu.lua @@ -0,0 +1,43 @@ +local rtu = require("rtu.rtu") + +local imatrix_rtu = {} + +-- create new induction matrix (mek 10.1+) device +---@nodiscard +---@param imatrix table +function imatrix_rtu.new(imatrix) + local unit = rtu.init_unit() + + -- discrete inputs -- + unit.connect_di(imatrix.isFormed) + + -- coils -- + -- none + + -- input registers -- + -- multiblock properties + unit.connect_input_reg(imatrix.getLength) + unit.connect_input_reg(imatrix.getWidth) + unit.connect_input_reg(imatrix.getHeight) + unit.connect_input_reg(imatrix.getMinPos) + unit.connect_input_reg(imatrix.getMaxPos) + -- build properties + unit.connect_input_reg(imatrix.getMaxEnergy) + unit.connect_input_reg(imatrix.getTransferCap) + unit.connect_input_reg(imatrix.getInstalledCells) + unit.connect_input_reg(imatrix.getInstalledProviders) + -- I/O rates + unit.connect_input_reg(imatrix.getLastInput) + unit.connect_input_reg(imatrix.getLastOutput) + -- tanks + unit.connect_input_reg(imatrix.getEnergy) + unit.connect_input_reg(imatrix.getEnergyNeeded) + unit.connect_input_reg(imatrix.getEnergyFilledPercentage) + + -- holding registers -- + -- none + + return unit.interface() +end + +return imatrix_rtu diff --git a/rtu/dev/redstone_rtu.lua b/rtu/dev/redstone_rtu.lua new file mode 100644 index 0000000..da7db6b --- /dev/null +++ b/rtu/dev/redstone_rtu.lua @@ -0,0 +1,117 @@ +local rsio = require("scada-common.rsio") + +local rtu = require("rtu.rtu") + +local redstone_rtu = {} + +local IO_LVL = rsio.IO_LVL + +local digital_read = rsio.digital_read +local digital_write = rsio.digital_write + +-- create new redstone device +---@nodiscard +function redstone_rtu.new() + local unit = rtu.init_unit() + + -- get RTU interface + local interface = unit.interface() + + -- extends rtu_device; fields added manually to please Lua diagnostics + ---@class rtu_rs_device + local public = { + io_count = interface.io_count, + read_coil = interface.read_coil, + read_di = interface.read_di, + read_holding_reg = interface.read_holding_reg, + read_input_reg = interface.read_input_reg, + write_coil = interface.write_coil, + write_holding_reg = interface.write_holding_reg + } + + -- link digital input + ---@param side string + ---@param color integer + function public.link_di(side, color) + local f_read = nil + + if color then + f_read = function () + return digital_read(rs.testBundledInput(side, color)) + end + else + f_read = function () + return digital_read(rs.getInput(side)) + end + end + + unit.connect_di(f_read) + end + + -- link digital output + ---@param side string + ---@param color integer + function public.link_do(side, color) + local f_read = nil + local f_write = nil + + if color then + f_read = function () + return digital_read(colors.test(rs.getBundledOutput(side), color)) + end + + f_write = function (level) + if level ~= IO_LVL.FLOATING and level ~= IO_LVL.DISCONNECT then + local output = rs.getBundledOutput(side) + + if digital_write(level) then + output = colors.combine(output, color) + else + output = colors.subtract(output, color) + end + + rs.setBundledOutput(side, output) + end + end + else + f_read = function () + return digital_read(rs.getOutput(side)) + end + + f_write = function (level) + if level ~= IO_LVL.FLOATING and level ~= IO_LVL.DISCONNECT then + rs.setOutput(side, digital_write(level)) + end + end + end + + unit.connect_coil(f_read, f_write) + end + + -- link analog input + ---@param side string + function public.link_ai(side) + unit.connect_input_reg( + function () + return rs.getAnalogInput(side) + end + ) + end + + -- link analog output + ---@param side string + function public.link_ao(side) + unit.connect_holding_reg( + function () + return rs.getAnalogOutput(side) + end, + function (value) + rs.setAnalogOutput(side, value) + end + ) + end + + return public +end + +return redstone_rtu diff --git a/rtu/dev/sna_rtu.lua b/rtu/dev/sna_rtu.lua new file mode 100644 index 0000000..0339794 --- /dev/null +++ b/rtu/dev/sna_rtu.lua @@ -0,0 +1,38 @@ +local rtu = require("rtu.rtu") + +local sna_rtu = {} + +-- create new solar neutron activator (SNA) device +---@nodiscard +---@param sna table +function sna_rtu.new(sna) + local unit = rtu.init_unit() + + -- discrete inputs -- + -- none + + -- coils -- + -- none + + -- input registers -- + -- build properties + unit.connect_input_reg(sna.getInputCapacity) + unit.connect_input_reg(sna.getOutputCapacity) + -- current state + unit.connect_input_reg(sna.getProductionRate) + unit.connect_input_reg(sna.getPeakProductionRate) + -- tanks + unit.connect_input_reg(sna.getInput) + unit.connect_input_reg(sna.getInputNeeded) + unit.connect_input_reg(sna.getInputFilledPercentage) + unit.connect_input_reg(sna.getOutput) + unit.connect_input_reg(sna.getOutputNeeded) + unit.connect_input_reg(sna.getOutputFilledPercentage) + + -- holding registers -- + -- none + + return unit.interface() +end + +return sna_rtu diff --git a/rtu/dev/sps_rtu.lua b/rtu/dev/sps_rtu.lua new file mode 100644 index 0000000..ba0a18c --- /dev/null +++ b/rtu/dev/sps_rtu.lua @@ -0,0 +1,48 @@ +local rtu = require("rtu.rtu") + +local sps_rtu = {} + +-- create new super-critical phase shifter (SPS) device +---@nodiscard +---@param sps table +function sps_rtu.new(sps) + local unit = rtu.init_unit() + + -- discrete inputs -- + unit.connect_di(sps.isFormed) + + -- coils -- + -- none + + -- input registers -- + -- multiblock properties + unit.connect_input_reg(sps.getLength) + unit.connect_input_reg(sps.getWidth) + unit.connect_input_reg(sps.getHeight) + unit.connect_input_reg(sps.getMinPos) + unit.connect_input_reg(sps.getMaxPos) + -- build properties + unit.connect_input_reg(sps.getCoils) + unit.connect_input_reg(sps.getInputCapacity) + unit.connect_input_reg(sps.getOutputCapacity) + unit.connect_input_reg(sps.getMaxEnergy) + -- current state + unit.connect_input_reg(sps.getProcessRate) + -- tanks + unit.connect_input_reg(sps.getInput) + unit.connect_input_reg(sps.getInputNeeded) + unit.connect_input_reg(sps.getInputFilledPercentage) + unit.connect_input_reg(sps.getOutput) + unit.connect_input_reg(sps.getOutputNeeded) + unit.connect_input_reg(sps.getOutputFilledPercentage) + unit.connect_input_reg(sps.getEnergy) + unit.connect_input_reg(sps.getEnergyNeeded) + unit.connect_input_reg(sps.getEnergyFilledPercentage) + + -- holding registers -- + -- none + + return unit.interface() +end + +return sps_rtu diff --git a/rtu/dev/turbinev_rtu.lua b/rtu/dev/turbinev_rtu.lua new file mode 100644 index 0000000..89b3ae0 --- /dev/null +++ b/rtu/dev/turbinev_rtu.lua @@ -0,0 +1,55 @@ +local rtu = require("rtu.rtu") + +local turbinev_rtu = {} + +-- create new turbine (mek 10.1+) device +---@nodiscard +---@param turbine table +function turbinev_rtu.new(turbine) + local unit = rtu.init_unit() + + -- discrete inputs -- + unit.connect_di(turbine.isFormed) + + -- coils -- + unit.connect_coil(function () turbine.incrementDumpingMode() end, function () end) + unit.connect_coil(function () turbine.decrementDumpingMode() end, function () end) + + -- input registers -- + -- multiblock properties + unit.connect_input_reg(turbine.getLength) + unit.connect_input_reg(turbine.getWidth) + unit.connect_input_reg(turbine.getHeight) + unit.connect_input_reg(turbine.getMinPos) + unit.connect_input_reg(turbine.getMaxPos) + -- build properties + unit.connect_input_reg(turbine.getBlades) + unit.connect_input_reg(turbine.getCoils) + unit.connect_input_reg(turbine.getVents) + unit.connect_input_reg(turbine.getDispersers) + unit.connect_input_reg(turbine.getCondensers) + unit.connect_input_reg(turbine.getSteamCapacity) + unit.connect_input_reg(turbine.getMaxEnergy) + unit.connect_input_reg(turbine.getMaxFlowRate) + unit.connect_input_reg(turbine.getMaxProduction) + unit.connect_input_reg(turbine.getMaxWaterOutput) + -- current state + unit.connect_input_reg(turbine.getFlowRate) + unit.connect_input_reg(turbine.getProductionRate) + unit.connect_input_reg(turbine.getLastSteamInputRate) + unit.connect_input_reg(turbine.getDumpingMode) + -- tanks/containers + unit.connect_input_reg(turbine.getSteam) + unit.connect_input_reg(turbine.getSteamNeeded) + unit.connect_input_reg(turbine.getSteamFilledPercentage) + unit.connect_input_reg(turbine.getEnergy) + unit.connect_input_reg(turbine.getEnergyNeeded) + unit.connect_input_reg(turbine.getEnergyFilledPercentage) + + -- holding registers -- + unit.connect_holding_reg(turbine.getDumpingMode, turbine.setDumpingMode) + + return unit.interface() +end + +return turbinev_rtu diff --git a/rtu/modbus.lua b/rtu/modbus.lua new file mode 100644 index 0000000..20c5939 --- /dev/null +++ b/rtu/modbus.lua @@ -0,0 +1,463 @@ +local comms = require("scada-common.comms") +local types = require("scada-common.types") + +local modbus = {} + +local MODBUS_FCODE = types.MODBUS_FCODE +local MODBUS_EXCODE = types.MODBUS_EXCODE + +-- new modbus comms handler object +---@nodiscard +---@param rtu_dev rtu_device|rtu_rs_device RTU device +---@param use_parallel_read boolean whether or not to use parallel calls when reading +function modbus.new(rtu_dev, use_parallel_read) + local insert = table.insert + + -- read a span of coils (digital outputs)
+ -- returns a table of readings or a MODBUS_EXCODE error code + ---@nodiscard + ---@param c_addr_start integer + ---@param count integer + ---@return boolean ok, table|MODBUS_EXCODE readings + local function _1_read_coils(c_addr_start, count) + local tasks = {} + local readings = {} ---@type table|MODBUS_EXCODE + local access_fault = false + local _, coils, _, _ = rtu_dev.io_count() + local return_ok = ((c_addr_start + count) <= (coils + 1)) and (count > 0) + + if return_ok then + for i = 1, count do + local addr = c_addr_start + i - 1 + + if use_parallel_read then + insert(tasks, function () + local reading, fault = rtu_dev.read_coil(addr) + if fault then access_fault = true else readings[i] = reading end + end) + else + readings[i], access_fault = rtu_dev.read_coil(addr) + + if access_fault then + return_ok = false + readings = MODBUS_EXCODE.SERVER_DEVICE_FAIL + break + end + end + end + + -- run parallel tasks if configured + if use_parallel_read then + parallel.waitForAll(table.unpack(tasks)) + end + + if access_fault or #readings ~= count then + return_ok = false + readings = MODBUS_EXCODE.SERVER_DEVICE_FAIL + end + else + readings = MODBUS_EXCODE.ILLEGAL_DATA_ADDR + end + + return return_ok, readings + end + + -- read a span of discrete inputs (digital inputs)
+ -- returns a table of readings or a MODBUS_EXCODE error code + ---@nodiscard + ---@param di_addr_start integer + ---@param count integer + ---@return boolean ok, table|MODBUS_EXCODE readings + local function _2_read_discrete_inputs(di_addr_start, count) + local tasks = {} + local readings = {} ---@type table|MODBUS_EXCODE + local access_fault = false + local discrete_inputs, _, _, _ = rtu_dev.io_count() + local return_ok = ((di_addr_start + count) <= (discrete_inputs + 1)) and (count > 0) + + if return_ok then + for i = 1, count do + local addr = di_addr_start + i - 1 + + if use_parallel_read then + insert(tasks, function () + local reading, fault = rtu_dev.read_di(addr) + if fault then access_fault = true else readings[i] = reading end + end) + else + readings[i], access_fault = rtu_dev.read_di(addr) + + if access_fault then + return_ok = false + readings = MODBUS_EXCODE.SERVER_DEVICE_FAIL + break + end + end + end + + -- run parallel tasks if configured + if use_parallel_read then + parallel.waitForAll(table.unpack(tasks)) + end + + if access_fault or #readings ~= count then + return_ok = false + readings = MODBUS_EXCODE.SERVER_DEVICE_FAIL + end + else + readings = MODBUS_EXCODE.ILLEGAL_DATA_ADDR + end + + return return_ok, readings + end + + -- read a span of holding registers (analog outputs)
+ -- returns a table of readings or a MODBUS_EXCODE error code + ---@nodiscard + ---@param hr_addr_start integer + ---@param count integer + ---@return boolean ok, table|MODBUS_EXCODE readings + local function _3_read_multiple_holding_registers(hr_addr_start, count) + local tasks = {} + local readings = {} ---@type table|MODBUS_EXCODE + local access_fault = false + local _, _, _, hold_regs = rtu_dev.io_count() + local return_ok = ((hr_addr_start + count) <= (hold_regs + 1)) and (count > 0) + + if return_ok then + for i = 1, count do + local addr = hr_addr_start + i - 1 + + if use_parallel_read then + insert(tasks, function () + local reading, fault = rtu_dev.read_holding_reg(addr) + if fault then access_fault = true else readings[i] = reading end + end) + else + readings[i], access_fault = rtu_dev.read_holding_reg(addr) + + if access_fault then + return_ok = false + readings = MODBUS_EXCODE.SERVER_DEVICE_FAIL + break + end + end + end + + -- run parallel tasks if configured + if use_parallel_read then + parallel.waitForAll(table.unpack(tasks)) + end + + if access_fault or #readings ~= count then + return_ok = false + readings = MODBUS_EXCODE.SERVER_DEVICE_FAIL + end + else + readings = MODBUS_EXCODE.ILLEGAL_DATA_ADDR + end + + return return_ok, readings + end + + -- read a span of input registers (analog inputs)
+ -- returns a table of readings or a MODBUS_EXCODE error code + ---@nodiscard + ---@param ir_addr_start integer + ---@param count integer + ---@return boolean ok, table|MODBUS_EXCODE readings + local function _4_read_input_registers(ir_addr_start, count) + local tasks = {} + local readings = {} ---@type table|MODBUS_EXCODE + local access_fault = false + local _, _, input_regs, _ = rtu_dev.io_count() + local return_ok = ((ir_addr_start + count) <= (input_regs + 1)) and (count > 0) + + if return_ok then + for i = 1, count do + local addr = ir_addr_start + i - 1 + + if use_parallel_read then + insert(tasks, function () + local reading, fault = rtu_dev.read_input_reg(addr) + if fault then access_fault = true else readings[i] = reading end + end) + else + readings[i], access_fault = rtu_dev.read_input_reg(addr) + + if access_fault then + return_ok = false + readings = MODBUS_EXCODE.SERVER_DEVICE_FAIL + break + end + end + end + + -- run parallel tasks if configured + if use_parallel_read then + parallel.waitForAll(table.unpack(tasks)) + end + + if access_fault or #readings ~= count then + return_ok = false + readings = MODBUS_EXCODE.SERVER_DEVICE_FAIL + end + else + readings = MODBUS_EXCODE.ILLEGAL_DATA_ADDR + end + + return return_ok, readings + end + + -- write a single coil (digital output) + ---@nodiscard + ---@param c_addr integer + ---@param value any + ---@return boolean ok, MODBUS_EXCODE + local function _5_write_single_coil(c_addr, value) + local response = nil + local _, coils, _, _ = rtu_dev.io_count() + local return_ok = c_addr <= coils + + if return_ok then + local access_fault = rtu_dev.write_coil(c_addr, value) + + if access_fault then + return_ok = false + response = MODBUS_EXCODE.SERVER_DEVICE_FAIL + end + else + response = MODBUS_EXCODE.ILLEGAL_DATA_ADDR + end + + return return_ok, response + end + + -- write a single holding register (analog output) + ---@nodiscard + ---@param hr_addr integer + ---@param value any + ---@return boolean ok, MODBUS_EXCODE + local function _6_write_single_holding_register(hr_addr, value) + local response = nil + local _, _, _, hold_regs = rtu_dev.io_count() + local return_ok = hr_addr <= hold_regs + + if return_ok then + local access_fault = rtu_dev.write_holding_reg(hr_addr, value) + + if access_fault then + return_ok = false + response = MODBUS_EXCODE.SERVER_DEVICE_FAIL + end + else + response = MODBUS_EXCODE.ILLEGAL_DATA_ADDR + end + + return return_ok, response + end + + -- write multiple coils (digital outputs) + ---@nodiscard + ---@param c_addr_start integer + ---@param values any + ---@return boolean ok, MODBUS_EXCODE + local function _15_write_multiple_coils(c_addr_start, values) + local response = nil + local _, coils, _, _ = rtu_dev.io_count() + local count = #values + local return_ok = ((c_addr_start + count) <= (coils + 1)) and (count > 0) + + if return_ok then + for i = 1, count do + local addr = c_addr_start + i - 1 + local access_fault = rtu_dev.write_coil(addr, values[i]) + + if access_fault then + return_ok = false + response = MODBUS_EXCODE.SERVER_DEVICE_FAIL + break + end + end + else + response = MODBUS_EXCODE.ILLEGAL_DATA_ADDR + end + + return return_ok, response + end + + -- write multiple holding registers (analog outputs) + ---@nodiscard + ---@param hr_addr_start integer + ---@param values any + ---@return boolean ok, MODBUS_EXCODE + local function _16_write_multiple_holding_registers(hr_addr_start, values) + local response = nil + local _, _, _, hold_regs = rtu_dev.io_count() + local count = #values + local return_ok = ((hr_addr_start + count) <= (hold_regs + 1)) and (count > 0) + + if return_ok then + for i = 1, count do + local addr = hr_addr_start + i - 1 + local access_fault = rtu_dev.write_holding_reg(addr, values[i]) + + if access_fault then + return_ok = false + response = MODBUS_EXCODE.SERVER_DEVICE_FAIL + break + end + end + else + response = MODBUS_EXCODE.ILLEGAL_DATA_ADDR + end + + return return_ok, response + end + + ---@class modbus + local public = {} + + -- validate a request without actually executing it + ---@nodiscard + ---@param packet modbus_frame + ---@return boolean return_code, modbus_packet reply + function public.check_request(packet) + local return_code = true + local response = { MODBUS_EXCODE.ACKNOWLEDGE } + + if packet.length == 2 then + -- handle by function code + if packet.func_code == MODBUS_FCODE.READ_COILS then + elseif packet.func_code == MODBUS_FCODE.READ_DISCRETE_INPUTS then + elseif packet.func_code == MODBUS_FCODE.READ_MUL_HOLD_REGS then + elseif packet.func_code == MODBUS_FCODE.READ_INPUT_REGS then + elseif packet.func_code == MODBUS_FCODE.WRITE_SINGLE_COIL then + elseif packet.func_code == MODBUS_FCODE.WRITE_SINGLE_HOLD_REG then + elseif packet.func_code == MODBUS_FCODE.WRITE_MUL_COILS then + elseif packet.func_code == MODBUS_FCODE.WRITE_MUL_HOLD_REGS then + else + -- unknown function + return_code = false + response = { MODBUS_EXCODE.ILLEGAL_FUNCTION } + end + else + -- invalid length + return_code = false + response = { MODBUS_EXCODE.NEG_ACKNOWLEDGE } + end + + -- default is to echo back + local func_code = packet.func_code + + -- echo back with error flag, on success the "error" will be acknowledgement + func_code = bit.bor(packet.func_code, MODBUS_FCODE.ERROR_FLAG) + + -- create reply + local reply = comms.modbus_packet() + reply.make(packet.txn_id, packet.unit_id, func_code, response) + + return return_code, reply + end + + -- handle a MODBUS TCP packet and generate a reply + ---@nodiscard + ---@param packet modbus_frame + ---@return boolean return_code, modbus_packet reply + function public.handle_packet(packet) + local return_code = true + local response = nil + + if packet.length >= 2 then + -- handle by function code + if packet.func_code == MODBUS_FCODE.READ_COILS then + return_code, response = _1_read_coils(packet.data[1], packet.data[2]) + elseif packet.func_code == MODBUS_FCODE.READ_DISCRETE_INPUTS then + return_code, response = _2_read_discrete_inputs(packet.data[1], packet.data[2]) + elseif packet.func_code == MODBUS_FCODE.READ_MUL_HOLD_REGS then + return_code, response = _3_read_multiple_holding_registers(packet.data[1], packet.data[2]) + elseif packet.func_code == MODBUS_FCODE.READ_INPUT_REGS then + return_code, response = _4_read_input_registers(packet.data[1], packet.data[2]) + elseif packet.func_code == MODBUS_FCODE.WRITE_SINGLE_COIL then + return_code, response = _5_write_single_coil(packet.data[1], packet.data[2]) + elseif packet.func_code == MODBUS_FCODE.WRITE_SINGLE_HOLD_REG then + return_code, response = _6_write_single_holding_register(packet.data[1], packet.data[2]) + elseif packet.func_code == MODBUS_FCODE.WRITE_MUL_COILS then + return_code, response = _15_write_multiple_coils(packet.data[1], { table.unpack(packet.data, 2, packet.length) }) + elseif packet.func_code == MODBUS_FCODE.WRITE_MUL_HOLD_REGS then + return_code, response = _16_write_multiple_holding_registers(packet.data[1], { table.unpack(packet.data, 2, packet.length) }) + else + -- unknown function + return_code = false + response = MODBUS_EXCODE.ILLEGAL_FUNCTION + end + else + -- invalid length + return_code = false + response = MODBUS_EXCODE.NEG_ACKNOWLEDGE + end + + -- default is to echo back + local func_code = packet.func_code + if not return_code then + -- echo back with error flag + func_code = bit.bor(packet.func_code, MODBUS_FCODE.ERROR_FLAG) + end + + if type(response) == "table" then + elseif type(response) == "nil" then + response = {} + else + response = { response } + end + + -- create reply + local reply = comms.modbus_packet() + reply.make(packet.txn_id, packet.unit_id, func_code, response) + + return return_code, reply + end + + return public +end + +-- return a SERVER_DEVICE_BUSY error reply +---@nodiscard +---@param packet modbus_frame MODBUS packet frame +---@return modbus_packet reply +function modbus.reply__srv_device_busy(packet) + -- reply back with error flag and exception code + local reply = comms.modbus_packet() + local fcode = bit.bor(packet.func_code, MODBUS_FCODE.ERROR_FLAG) + local data = { MODBUS_EXCODE.SERVER_DEVICE_BUSY } + reply.make(packet.txn_id, packet.unit_id, fcode, data) + return reply +end + +-- return a NEG_ACKNOWLEDGE error reply +---@nodiscard +---@param packet modbus_frame MODBUS packet frame +---@return modbus_packet reply +function modbus.reply__neg_ack(packet) + -- reply back with error flag and exception code + local reply = comms.modbus_packet() + local fcode = bit.bor(packet.func_code, MODBUS_FCODE.ERROR_FLAG) + local data = { MODBUS_EXCODE.NEG_ACKNOWLEDGE } + reply.make(packet.txn_id, packet.unit_id, fcode, data) + return reply +end + +-- return a GATEWAY_PATH_UNAVAILABLE error reply +---@nodiscard +---@param packet modbus_frame MODBUS packet frame +---@return modbus_packet reply +function modbus.reply__gw_unavailable(packet) + -- reply back with error flag and exception code + local reply = comms.modbus_packet() + local fcode = bit.bor(packet.func_code, MODBUS_FCODE.ERROR_FLAG) + local data = { MODBUS_EXCODE.GATEWAY_PATH_UNAVAILABLE } + reply.make(packet.txn_id, packet.unit_id, fcode, data) + return reply +end + +return modbus diff --git a/rtu/rtu.lua b/rtu/rtu.lua new file mode 100644 index 0000000..a1b9fbd --- /dev/null +++ b/rtu/rtu.lua @@ -0,0 +1,468 @@ +local comms = require("scada-common.comms") +local ppm = require("scada-common.ppm") +local log = require("scada-common.log") +local types = require("scada-common.types") +local util = require("scada-common.util") + +local modbus = require("rtu.modbus") + +local rtu = {} + +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 RTU_UNIT_TYPE = types.RTU_UNIT_TYPE + +local print = util.print +local println = util.println +local print_ts = util.print_ts +local println_ts = util.println_ts + +-- create a new RTU unit +---@nodiscard +function rtu.init_unit() + local self = { + discrete_inputs = {}, + coils = {}, + input_regs = {}, + holding_regs = {}, + io_count_cache = { 0, 0, 0, 0 } + } + + local insert = table.insert + + ---@class rtu_device + local public = {} + + ---@class rtu + local protected = {} + + -- refresh IO count + local function _count_io() + self.io_count_cache = { #self.discrete_inputs, #self.coils, #self.input_regs, #self.holding_regs } + end + + -- return IO count + ---@return integer discrete_inputs, integer coils, integer input_regs, integer holding_regs + function public.io_count() + return self.io_count_cache[1], self.io_count_cache[2], self.io_count_cache[3], self.io_count_cache[4] + end + + -- discrete inputs: single bit read-only + + -- connect discrete input + ---@param f function + ---@return integer count count of discrete inputs + function protected.connect_di(f) + insert(self.discrete_inputs, { read = f }) + _count_io() + return #self.discrete_inputs + end + + -- read discrete input + ---@param di_addr integer + ---@return any value, boolean access_fault + function public.read_di(di_addr) + ppm.clear_fault() + local value = self.discrete_inputs[di_addr].read() + return value, ppm.is_faulted() + end + + -- coils: single bit read-write + + -- connect coil + ---@param f_read function + ---@param f_write function + ---@return integer count count of coils + function protected.connect_coil(f_read, f_write) + insert(self.coils, { read = f_read, write = f_write }) + _count_io() + return #self.coils + end + + -- read coil + ---@param coil_addr integer + ---@return any value, boolean access_fault + function public.read_coil(coil_addr) + ppm.clear_fault() + local value = self.coils[coil_addr].read() + return value, ppm.is_faulted() + end + + -- write coil + ---@param coil_addr integer + ---@param value any + ---@return boolean access_fault + function public.write_coil(coil_addr, value) + ppm.clear_fault() + self.coils[coil_addr].write(value) + return ppm.is_faulted() + end + + -- input registers: multi-bit read-only + + -- connect input register + ---@param f function + ---@return integer count count of input registers + function protected.connect_input_reg(f) + insert(self.input_regs, { read = f }) + _count_io() + return #self.input_regs + end + + -- read input register + ---@param reg_addr integer + ---@return any value, boolean access_fault + function public.read_input_reg(reg_addr) + ppm.clear_fault() + local value = self.input_regs[reg_addr].read() + return value, ppm.is_faulted() + end + + -- holding registers: multi-bit read-write + + -- connect holding register + ---@param f_read function + ---@param f_write function + ---@return integer count count of holding registers + function protected.connect_holding_reg(f_read, f_write) + insert(self.holding_regs, { read = f_read, write = f_write }) + _count_io() + return #self.holding_regs + end + + -- read holding register + ---@param reg_addr integer + ---@return any value, boolean access_fault + function public.read_holding_reg(reg_addr) + ppm.clear_fault() + local value = self.holding_regs[reg_addr].read() + return value, ppm.is_faulted() + end + + -- write holding register + ---@param reg_addr integer + ---@param value any + ---@return boolean access_fault + function public.write_holding_reg(reg_addr, value) + ppm.clear_fault() + self.holding_regs[reg_addr].write(value) + return ppm.is_faulted() + end + + -- public RTU device access + + -- get the public interface to this RTU + function protected.interface() return public end + + return protected +end + +-- RTU Communications +---@nodiscard +---@param version string RTU version +---@param modem table modem device +---@param local_port integer local listening port +---@param server_port integer remote server port +---@param range integer trusted device connection range +---@param conn_watchdog watchdog watchdog reference +function rtu.comms(version, modem, local_port, server_port, range, conn_watchdog) + local self = { + seq_num = 0, + r_seq_num = nil, + txn_id = 0, + last_est_ack = ESTABLISH_ACK.ALLOW + } + + local insert = table.insert + + 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 scada management packet + ---@param msg_type SCADA_MGMT_TYPE + ---@param msg table + local function _send(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()) + + modem.transmit(server_port, local_port, s_pkt.raw_sendable()) + self.seq_num = self.seq_num + 1 + end + + -- keep alive ack + ---@param srv_time integer + local function _send_keep_alive_ack(srv_time) + _send(SCADA_MGMT_TYPE.KEEP_ALIVE, { srv_time, util.time() }) + end + + -- generate device advertisement table + ---@nodiscard + ---@param units table + ---@return table advertisement + local function _generate_advertisement(units) + local advertisement = {} + + for i = 1, #units do + local unit = units[i] ---@type rtu_unit_registry_entry + + if unit.type ~= nil then + local advert = { unit.type, unit.index, unit.reactor } + + if unit.type == RTU_UNIT_TYPE.REDSTONE then + insert(advert, unit.device) + end + + insert(advertisement, advert) + end + end + + return advertisement + end + + -- PUBLIC FUNCTIONS -- + + ---@class rtu_comms + local public = {} + + -- send a MODBUS TCP packet + ---@param m_pkt modbus_packet + function public.send_modbus(m_pkt) + local s_pkt = comms.scada_packet() + s_pkt.make(self.seq_num, PROTOCOL.MODBUS_TCP, m_pkt.raw_sendable()) + modem.transmit(server_port, local_port, s_pkt.raw_sendable()) + self.seq_num = self.seq_num + 1 + end + + -- reconnect a newly connected modem + ---@param new_modem table + function public.reconnect_modem(new_modem) + modem = new_modem + _conf_channels() + end + + -- unlink from the server + ---@param rtu_state rtu_state + function public.unlink(rtu_state) + rtu_state.linked = false + self.r_seq_num = nil + end + + -- close the connection to the server + ---@param rtu_state rtu_state + function public.close(rtu_state) + conn_watchdog.cancel() + public.unlink(rtu_state) + _send(SCADA_MGMT_TYPE.CLOSE, {}) + end + + -- send establish request (includes advertisement) + ---@param units table + function public.send_establish(units) + _send(SCADA_MGMT_TYPE.ESTABLISH, { comms.version, version, DEVICE_TYPE.RTU, _generate_advertisement(units) }) + end + + -- send capability advertisement + ---@param units table + function public.send_advertisement(units) + _send(SCADA_MGMT_TYPE.RTU_ADVERT, _generate_advertisement(units)) + end + + -- notify that a peripheral was remounted + ---@param unit_index integer RTU unit ID + function public.send_remounted(unit_index) + _send(SCADA_MGMT_TYPE.RTU_DEV_REMOUNT, { unit_index }) + end + + -- parse a MODBUS/SCADA packet + ---@nodiscard + ---@param side string + ---@param sender integer + ---@param reply_to integer + ---@param message any + ---@param distance integer + ---@return modbus_frame|mgmt_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 MODBUS TCP packet + if s_pkt.protocol() == PROTOCOL.MODBUS_TCP then + local m_pkt = comms.modbus_packet() + if m_pkt.decode(s_pkt) then + pkt = m_pkt.get() + end + -- get as SCADA management packet + elseif 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 + else + log.error("illegal packet type " .. s_pkt.protocol(), true) + end + end + + return pkt + end + + -- handle a MODBUS/SCADA packet + ---@param packet modbus_frame|mgmt_frame + ---@param units table RTU units + ---@param rtu_state rtu_state + function public.handle_packet(packet, units, rtu_state) + if packet.scada_frame.local_port() == local_port then + -- check sequence number + if self.r_seq_num == nil then + self.r_seq_num = packet.scada_frame.seq_num() + elseif rtu_state.linked and self.r_seq_num >= packet.scada_frame.seq_num() then + log.warning("sequence out-of-order: last = " .. self.r_seq_num .. ", new = " .. packet.scada_frame.seq_num()) + return + else + self.r_seq_num = packet.scada_frame.seq_num() + end + + -- feed watchdog on valid sequence number + conn_watchdog.feed() + + local protocol = packet.scada_frame.protocol() + + if protocol == PROTOCOL.MODBUS_TCP then + ---@cast packet modbus_frame + if rtu_state.linked then + local return_code = false + local reply = modbus.reply__neg_ack(packet) + + -- handle MODBUS instruction + if packet.unit_id <= #units then + local unit = units[packet.unit_id] ---@type rtu_unit_registry_entry + local unit_dbg_tag = " (unit " .. packet.unit_id .. ")" + + if unit.name == "redstone_io" then + -- immediately execute redstone RTU requests + return_code, reply = unit.modbus_io.handle_packet(packet) + if not return_code then + log.warning("requested MODBUS operation failed" .. unit_dbg_tag) + end + else + -- check validity then pass off to unit comms thread + return_code, reply = unit.modbus_io.check_request(packet) + if return_code then + -- check if there are more than 3 active transactions + -- still queue the packet, but this may indicate a problem + if unit.pkt_queue.length() > 3 then + reply = modbus.reply__srv_device_busy(packet) + log.debug("queueing new request with " .. unit.pkt_queue.length() .. + " transactions already in the queue" .. unit_dbg_tag) + end + + -- always queue the command even if busy + unit.pkt_queue.push_packet(packet) + else + log.warning("cannot perform requested MODBUS operation" .. unit_dbg_tag) + end + end + else + -- unit ID out of range? + reply = modbus.reply__gw_unavailable(packet) + log.error("received MODBUS packet for non-existent unit") + end + + public.send_modbus(reply) + else + log.debug("discarding MODBUS packet before linked") + end + elseif protocol == PROTOCOL.SCADA_MGMT then + ---@cast packet mgmt_frame + -- SCADA management packet + if packet.type == SCADA_MGMT_TYPE.ESTABLISH then + if packet.length == 1 then + local est_ack = packet.data[1] + + if est_ack == ESTABLISH_ACK.ALLOW then + -- establish allowed + rtu_state.linked = true + self.r_seq_num = nil + println_ts("supervisor connection established") + log.info("supervisor connection established") + else + -- establish denied + if est_ack ~= self.last_est_ack then + if est_ack == ESTABLISH_ACK.BAD_VERSION then + -- version mismatch + println_ts("supervisor comms version mismatch (try updating), retrying...") + log.warning("supervisor connection denied due to comms version mismatch, retrying") + else + println_ts("supervisor connection denied, retrying...") + log.warning("supervisor connection denied, retrying") + end + end + + public.unlink(rtu_state) + end + + self.last_est_ack = est_ack + else + log.debug("SCADA_MGMT establish packet length mismatch") + end + elseif rtu_state.linked then + if packet.type == SCADA_MGMT_TYPE.KEEP_ALIVE then + -- keep alive request received, echo back + if packet.length == 1 and type(packet.data[1]) == "number" then + local timestamp = packet.data[1] + local trip_time = util.time() - timestamp + + if trip_time > 750 then + log.warning("RTU KEEP_ALIVE trip time > 750ms (" .. trip_time .. "ms)") + end + + -- log.debug("RTU RTT = " .. trip_time .. "ms") + + _send_keep_alive_ack(timestamp) + else + log.debug("SCADA_MGMT keep alive packet length/type mismatch") + end + elseif packet.type == SCADA_MGMT_TYPE.CLOSE then + -- close connection + conn_watchdog.cancel() + public.unlink(rtu_state) + println_ts("server connection closed by remote host") + log.warning("server connection closed by remote host") + elseif packet.type == SCADA_MGMT_TYPE.RTU_ADVERT then + -- request for capabilities again + public.send_advertisement(units) + else + -- not supported + log.warning("received unsupported SCADA_MGMT message type " .. packet.type) + end + else + log.debug("discarding non-link SCADA_MGMT packet before linked") + end + else + -- should be unreachable assuming packet is from parse_packet() + log.error("illegal packet type " .. protocol, true) + end + end + end + + return public +end + +return rtu diff --git a/rtu/startup.lua b/rtu/startup.lua new file mode 100644 index 0000000..df79496 --- /dev/null +++ b/rtu/startup.lua @@ -0,0 +1,452 @@ +-- +-- RTU: Remote Terminal Unit +-- + +require("/initenv").init_env() + +local crash = require("scada-common.crash") +local log = require("scada-common.log") +local mqueue = require("scada-common.mqueue") +local ppm = require("scada-common.ppm") +local rsio = require("scada-common.rsio") +local types = require("scada-common.types") +local util = require("scada-common.util") + +local config = require("rtu.config") +local modbus = require("rtu.modbus") +local rtu = require("rtu.rtu") +local threads = require("rtu.threads") + +local boilerv_rtu = require("rtu.dev.boilerv_rtu") +local envd_rtu = require("rtu.dev.envd_rtu") +local imatrix_rtu = require("rtu.dev.imatrix_rtu") +local redstone_rtu = require("rtu.dev.redstone_rtu") +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.0" + +local RTU_UNIT_TYPE = types.RTU_UNIT_TYPE + +local print = util.print +local println = util.println +local print_ts = util.print_ts +local println_ts = util.println_ts + +---------------------------------------- +-- config validation +---------------------------------------- + +local cfv = util.new_validator() + +cfv.assert_port(config.SERVER_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) +cfv.assert_type_table(config.RTU_DEVICES) +cfv.assert_type_table(config.RTU_REDSTONE) +assert(cfv.valid(), "bad config file: missing/invalid fields") + +---------------------------------------- +-- log init +---------------------------------------- + +log.init(config.LOG_PATH, config.LOG_MODE) + +log.info("========================================") +log.info("BOOTING rtu.startup " .. RTU_VERSION) +log.info("========================================") +println(">> RTU GATEWAY " .. RTU_VERSION .. " <<") + +crash.set_env("rtu", RTU_VERSION) + +---------------------------------------- +-- main application +---------------------------------------- + +local function main() + ---------------------------------------- + -- startup + ---------------------------------------- + + -- mount connected devices + ppm.mount_all() + + ---@class rtu_shared_memory + local __shared_memory = { + -- RTU system state flags + ---@class rtu_state + rtu_state = { + linked = false, + shutdown = false + }, + + -- core RTU devices + rtu_dev = { + modem = ppm.get_wireless_modem() + }, + + -- system objects + rtu_sys = { + rtu_comms = nil, ---@type rtu_comms + conn_watchdog = nil, ---@type watchdog + units = {} ---@type table + }, + + -- message queues + q = { + mq_comms = mqueue.new() + } + } + + local smem_dev = __shared_memory.rtu_dev + local smem_sys = __shared_memory.rtu_sys + + -- get modem + if smem_dev.modem == nil then + println("boot> wireless modem not found") + log.fatal("no wireless modem on startup") + return + end + + ---------------------------------------- + -- interpret config and init units + ---------------------------------------- + + local units = __shared_memory.rtu_sys.units + + local rtu_redstone = config.RTU_REDSTONE + local rtu_devices = config.RTU_DEVICES + + -- configure RTU gateway based on config file definitions + local function configure() + -- redstone interfaces + for entry_idx = 1, #rtu_redstone do + local rs_rtu = redstone_rtu.new() + local io_table = rtu_redstone[entry_idx].io ---@type table + local io_reactor = rtu_redstone[entry_idx].for_reactor ---@type integer + + -- CHECK: reactor ID must be >= to 1 + if (not util.is_int(io_reactor)) or (io_reactor < 0) then + local message = util.c("configure> redstone entry #", entry_idx, " : ", io_reactor, " isn't an integer >= 0") + println(message) + log.fatal(message) + return false + end + + -- CHECK: io table exists + if type(io_table) ~= "table" then + local message = util.c("configure> redstone entry #", entry_idx, " no IO table found") + println(message) + log.fatal(message) + return false + end + + local capabilities = {} + + log.debug(util.c("configure> starting redstone RTU I/O linking for reactor ", io_reactor, "...")) + + local continue = true + + -- CHECK: no duplicate entries + for i = 1, #units do + local unit = units[i] ---@type rtu_unit_registry_entry + if unit.reactor == io_reactor and unit.type == RTU_UNIT_TYPE.REDSTONE then + -- duplicate entry + local message = util.c("configure> skipping definition block #", entry_idx, " for reactor ", io_reactor, + " with already defined redstone I/O") + println(message) + log.warning(message) + continue = false + break + end + end + + -- not a duplicate + if continue then + for i = 1, #io_table do + local valid = false + local conf = io_table[i] + + -- verify configuration + if rsio.is_valid_port(conf.port) and rsio.is_valid_side(conf.side) then + if conf.bundled_color then + valid = rsio.is_color(conf.bundled_color) + else + valid = true + end + end + + if not valid then + local message = util.c("configure> invalid redstone definition at index ", i, " in definition block #", entry_idx, + " (for reactor ", io_reactor, ")") + println(message) + log.fatal(message) + return false + else + -- link redstone in RTU + local mode = rsio.get_io_mode(conf.port) + if mode == rsio.IO_MODE.DIGITAL_IN then + -- can't have duplicate inputs + if util.table_contains(capabilities, conf.port) then + local message = util.c("configure> skipping duplicate input for port ", rsio.to_string(conf.port), " on side ", conf.side) + println(message) + log.warning(message) + else + rs_rtu.link_di(conf.side, conf.bundled_color) + end + elseif mode == rsio.IO_MODE.DIGITAL_OUT then + rs_rtu.link_do(conf.side, conf.bundled_color) + elseif mode == rsio.IO_MODE.ANALOG_IN then + -- can't have duplicate inputs + if util.table_contains(capabilities, conf.port) then + local message = util.c("configure> skipping duplicate input for port ", rsio.to_string(conf.port), " on side ", conf.side) + println(message) + log.warning(message) + else + rs_rtu.link_ai(conf.side) + end + elseif mode == rsio.IO_MODE.ANALOG_OUT then + rs_rtu.link_ao(conf.side) + else + -- should be unreachable code, we already validated ports + log.error("configure> fell through if chain attempting to identify IO mode", true) + println("configure> encountered a software error, check logs") + return false + end + + table.insert(capabilities, conf.port) + + log.debug(util.c("configure> linked redstone ", #capabilities, ": ", rsio.to_string(conf.port), + " (", conf.side, ") for reactor ", io_reactor)) + end + end + + ---@class rtu_unit_registry_entry + local unit = { + uid = 0, ---@type integer + name = "redstone_io", ---@type string + type = RTU_UNIT_TYPE.REDSTONE, ---@type RTU_UNIT_TYPE + index = entry_idx, ---@type integer + reactor = io_reactor, ---@type integer + device = capabilities, ---@type table use device field for redstone ports + is_multiblock = false, ---@type boolean + formed = nil, ---@type boolean|nil + rtu = rs_rtu, ---@type rtu_device|rtu_rs_device + modbus_io = modbus.new(rs_rtu, false), + pkt_queue = nil, ---@type mqueue|nil + thread = nil ---@type parallel_thread|nil + } + + table.insert(units, unit) + + local for_message = "facility" + if io_reactor > 0 then + for_message = util.c("reactor ", io_reactor) + end + + log.info(util.c("configure> initialized RTU unit #", #units, ": redstone_io (redstone) [1] for ", for_message)) + + unit.uid = #units + end + end + + -- mounted peripherals + for i = 1, #rtu_devices do + local name = rtu_devices[i].name + local index = rtu_devices[i].index + local for_reactor = rtu_devices[i].for_reactor + + -- CHECK: name is a string + if type(name) ~= "string" then + local message = util.c("configure> device entry #", i, ": device ", name, " isn't a string") + println(message) + log.fatal(message) + return false + end + + -- CHECK: index is an integer >= 1 + if (not util.is_int(index)) or (index <= 0) then + local message = util.c("configure> device entry #", i, ": index ", index, " isn't an integer >= 1") + println(message) + log.fatal(message) + return false + end + + -- CHECK: reactor is an integer >= 0 + if (not util.is_int(for_reactor)) or (for_reactor < 0) then + local message = util.c("configure> device entry #", i, ": reactor ", for_reactor, " isn't an integer >= 0") + println(message) + log.fatal(message) + return false + end + + local device = ppm.get_periph(name) + + local type = nil ---@type string|nil + local rtu_iface = nil ---@type rtu_device + local rtu_type = nil ---@type RTU_UNIT_TYPE + local is_multiblock = false + local formed = nil ---@type boolean|nil + + if device == nil then + local message = util.c("configure> '", name, "' not found, using placeholder") + println(message) + log.warning(message) + + -- mount a virtual (placeholder) device + type, device = ppm.mount_virtual() + else + type = ppm.get_type(name) + end + + if type == "boilerValve" then + -- boiler multiblock + rtu_type = RTU_UNIT_TYPE.BOILER_VALVE + rtu_iface = boilerv_rtu.new(device) + is_multiblock = true + formed = device.isFormed() + + if formed == ppm.UNDEFINED_FIELD or formed == ppm.ACCESS_FAULT then + println_ts(util.c("configure> failed to check if '", name, "' is formed")) + log.fatal(util.c("configure> failed to check if '", name, "' is a formed boiler multiblock")) + return false + end + elseif type == "turbineValve" then + -- turbine multiblock + rtu_type = RTU_UNIT_TYPE.TURBINE_VALVE + rtu_iface = turbinev_rtu.new(device) + is_multiblock = true + formed = device.isFormed() + + if formed == ppm.UNDEFINED_FIELD or formed == ppm.ACCESS_FAULT then + println_ts(util.c("configure> failed to check if '", name, "' is formed")) + log.fatal(util.c("configure> failed to check if '", name, "' is a formed turbine multiblock")) + return false + end + elseif type == "inductionPort" then + -- induction matrix multiblock + rtu_type = RTU_UNIT_TYPE.IMATRIX + rtu_iface = imatrix_rtu.new(device) + is_multiblock = true + formed = device.isFormed() + + if formed == ppm.UNDEFINED_FIELD or formed == ppm.ACCESS_FAULT then + println_ts(util.c("configure> failed to check if '", name, "' is formed")) + log.fatal(util.c("configure> failed to check if '", name, "' is a formed induction matrix multiblock")) + return false + end + elseif type == "spsPort" then + -- SPS multiblock + rtu_type = RTU_UNIT_TYPE.SPS + rtu_iface = sps_rtu.new(device) + is_multiblock = true + formed = device.isFormed() + + if formed == ppm.UNDEFINED_FIELD or formed == ppm.ACCESS_FAULT then + println_ts(util.c("configure> failed to check if '", name, "' is formed")) + log.fatal(util.c("configure> failed to check if '", name, "' is a formed SPS multiblock")) + return false + end + elseif type == "solarNeutronActivator" then + -- SNA + rtu_type = RTU_UNIT_TYPE.SNA + rtu_iface = sna_rtu.new(device) + elseif type == "environmentDetector" then + -- advanced peripherals environment detector + rtu_type = RTU_UNIT_TYPE.ENV_DETECTOR + rtu_iface = envd_rtu.new(device) + elseif type == ppm.VIRTUAL_DEVICE_TYPE then + -- placeholder device + rtu_type = RTU_UNIT_TYPE.VIRTUAL + rtu_iface = rtu.init_unit().interface() + else + local message = util.c("configure> device '", name, "' is not a known type (", type, ")") + println_ts(message) + log.fatal(message) + return false + end + + ---@class rtu_unit_registry_entry + local rtu_unit = { + uid = 0, ---@type integer + name = name, ---@type string + type = rtu_type, ---@type RTU_UNIT_TYPE + index = index, ---@type integer + reactor = for_reactor, ---@type integer + device = device, ---@type table + is_multiblock = is_multiblock, ---@type boolean + formed = formed, ---@type boolean|nil + rtu = rtu_iface, ---@type rtu_device|rtu_rs_device + modbus_io = modbus.new(rtu_iface, true), + pkt_queue = mqueue.new(), ---@type mqueue|nil + thread = nil ---@type parallel_thread|nil + } + + rtu_unit.thread = threads.thread__unit_comms(__shared_memory, rtu_unit) + + table.insert(units, rtu_unit) + + if is_multiblock and not formed then + log.info(util.c("configure> device '", name, "' is not formed")) + end + + local for_message = "facility" + if for_reactor > 0 then + for_message = util.c("reactor ", for_reactor) + end + + log.info(util.c("configure> initialized RTU unit #", #units, ": ", name, " (", types.rtu_type_to_string(rtu_type), ") [", index, "] for ", for_message)) + + rtu_unit.uid = #units + end + + -- we made it through all that trusting-user-to-write-a-config-file chaos + return true + end + + ---------------------------------------- + -- start system + ---------------------------------------- + + log.debug("boot> running configure()") + + if configure() then + -- start connection watchdog + smem_sys.conn_watchdog = util.new_watchdog(config.COMMS_TIMEOUT) + log.debug("startup> conn watchdog started") + + -- setup comms + smem_sys.rtu_comms = rtu.comms(RTU_VERSION, smem_dev.modem, config.LISTEN_PORT, config.SERVER_PORT, + config.TRUSTED_RANGE, smem_sys.conn_watchdog) + log.debug("startup> comms init") + + -- init threads + local main_thread = threads.thread__main(__shared_memory) + local comms_thread = threads.thread__comms(__shared_memory) + + -- assemble thread list + local _threads = { main_thread.p_exec, comms_thread.p_exec } + for i = 1, #units do + if units[i].thread ~= nil then + table.insert(_threads, units[i].thread.p_exec) + end + end + + log.info("startup> completed") + + -- run threads + parallel.waitForAll(table.unpack(_threads)) + else + println("configuration failed, exiting...") + end + + println_ts("exited") + log.info("exited") +end + +if not xpcall(main, crash.handler) then crash.exit() end diff --git a/rtu/threads.lua b/rtu/threads.lua new file mode 100644 index 0000000..6b06eb0 --- /dev/null +++ b/rtu/threads.lua @@ -0,0 +1,454 @@ +local log = require("scada-common.log") +local mqueue = require("scada-common.mqueue") +local ppm = require("scada-common.ppm") +local types = require("scada-common.types") +local util = require("scada-common.util") + +local boilerv_rtu = require("rtu.dev.boilerv_rtu") +local envd_rtu = require("rtu.dev.envd_rtu") +local imatrix_rtu = require("rtu.dev.imatrix_rtu") +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 modbus = require("rtu.modbus") + +local threads = {} + +local RTU_UNIT_TYPE = types.RTU_UNIT_TYPE + +local print = util.print +local println = util.println +local print_ts = util.print_ts +local println_ts = util.println_ts + +local MAIN_CLOCK = 2 -- (2Hz, 40 ticks) +local COMMS_SLEEP = 100 -- (100ms, 2 ticks) + +-- main thread +---@nodiscard +---@param smem rtu_shared_memory +function threads.thread__main(smem) + ---@class parallel_thread + local public = {} + + -- execute thread + function public.exec() + log.debug("main thread start") + + -- main loop clock + local loop_clock = util.new_clock(MAIN_CLOCK) + + -- load in from shared memory + local rtu_state = smem.rtu_state + local rtu_dev = smem.rtu_dev + local rtu_comms = smem.rtu_sys.rtu_comms + local conn_watchdog = smem.rtu_sys.conn_watchdog + local units = smem.rtu_sys.units + + -- start unlinked (in case of restart) + rtu_comms.unlink(rtu_state) + + -- start clock + loop_clock.start() + + -- event loop + while true do + local event, param1, param2, param3, param4, param5 = util.pull_event() + + if event == "timer" and loop_clock.is_clock(param1) then + -- start next clock timer + loop_clock.start() + + -- period tick, if we are not linked send establish request + if not rtu_state.linked then + -- advertise units + rtu_comms.send_establish(units) + end + elseif event == "modem_message" then + -- got a packet + local packet = rtu_comms.parse_packet(param1, param2, param3, param4, param5) + if packet ~= nil then + -- pass the packet onto the comms message queue + smem.q.mq_comms.push_packet(packet) + end + elseif event == "timer" and conn_watchdog.is_timer(param1) then + -- haven't heard from server recently? unlink + rtu_comms.unlink(rtu_state) + elseif event == "peripheral_detach" then + -- handle loss of a device + local type, device = ppm.handle_unmount(param1) + + if type ~= nil and device ~= nil then + if type == "modem" then + -- we only care if this is our wireless modem + if device == rtu_dev.modem then + println_ts("wireless modem disconnected!") + log.warning("comms modem disconnected!") + else + log.warning("non-comms modem disconnected") + end + else + for i = 1, #units do + -- find disconnected device + if units[i].device == device then + -- we are going to let the PPM prevent crashes + -- return fault flags/codes to MODBUS queries + local unit = units[i] + local type_name = types.rtu_type_to_string(unit.type) + println_ts(util.c("lost the ", type_name, " on interface ", unit.name)) + log.warning(util.c("lost the ", type_name, " unit peripheral on interface ", unit.name)) + break + end + end + end + end + elseif event == "peripheral" then + -- peripheral connect + local type, device = ppm.mount(param1) + + if type ~= nil and device ~= nil then + if type == "modem" then + if device.isWireless() then + -- reconnected modem + rtu_dev.modem = device + rtu_comms.reconnect_modem(rtu_dev.modem) + + println_ts("wireless modem reconnected.") + log.info("comms modem reconnected") + else + log.info("wired modem reconnected") + end + else + -- relink lost peripheral to correct unit entry + for i = 1, #units do + local unit = units[i] ---@type rtu_unit_registry_entry + + -- find disconnected device to reconnect + -- note: cannot check isFormed as that would yield this coroutine and consume events + if unit.name == param1 then + local resend_advert = false + + -- found, re-link + unit.device = device + + if unit.type == RTU_UNIT_TYPE.VIRTUAL then + resend_advert = true + if type == "boilerValve" then + -- boiler multiblock + unit.type = RTU_UNIT_TYPE.BOILER_VALVE + elseif type == "turbineValve" then + -- turbine multiblock + unit.type = RTU_UNIT_TYPE.TURBINE_VALVE + elseif type == "inductionPort" then + -- induction matrix multiblock + unit.type = RTU_UNIT_TYPE.IMATRIX + elseif type == "spsPort" then + -- SPS multiblock + unit.type = RTU_UNIT_TYPE.SPS + elseif type == "solarNeutronActivator" then + -- SNA + unit.type = RTU_UNIT_TYPE.SNA + elseif type == "environmentDetector" then + -- advanced peripherals environment detector + unit.type = RTU_UNIT_TYPE.ENV_DETECTOR + else + resend_advert = false + log.error(util.c("virtual device '", unit.name, "' cannot init to an unknown type (", type, ")")) + end + end + + if unit.type == RTU_UNIT_TYPE.BOILER_VALVE then + unit.rtu = boilerv_rtu.new(device) + -- if not formed, indexing the multiblock functions would have resulted in a PPM fault + unit.formed = util.trinary(device.__p_is_faulted(), false, nil) + elseif unit.type == RTU_UNIT_TYPE.TURBINE_VALVE then + unit.rtu = turbinev_rtu.new(device) + -- if not formed, indexing the multiblock functions would have resulted in a PPM fault + unit.formed = util.trinary(device.__p_is_faulted(), false, nil) + elseif unit.type == RTU_UNIT_TYPE.IMATRIX then + unit.rtu = imatrix_rtu.new(device) + -- if not formed, indexing the multiblock functions would have resulted in a PPM fault + unit.formed = util.trinary(device.__p_is_faulted(), false, nil) + elseif unit.type == RTU_UNIT_TYPE.SPS then + unit.rtu = sps_rtu.new(device) + -- if not formed, indexing the multiblock functions would have resulted in a PPM fault + unit.formed = util.trinary(device.__p_is_faulted(), false, nil) + elseif unit.type == RTU_UNIT_TYPE.SNA then + unit.rtu = sna_rtu.new(device) + elseif unit.type == RTU_UNIT_TYPE.ENV_DETECTOR then + unit.rtu = envd_rtu.new(device) + else + log.error(util.c("failed to identify reconnected RTU unit type (", unit.name, ")"), true) + end + + if unit.is_multiblock and (unit.formed == false) then + log.info(util.c("assuming ", unit.name, " is not formed due to PPM faults while initializing")) + end + + unit.modbus_io = modbus.new(unit.rtu, true) + + local type_name = types.rtu_type_to_string(unit.type) + local message = util.c("reconnected the ", type_name, " on interface ", unit.name) + println_ts(message) + log.info(message) + + if resend_advert then + rtu_comms.send_advertisement(units) + else + rtu_comms.send_remounted(unit.uid) + end + end + end + end + end + end + + -- check for termination request + if event == "terminate" or ppm.should_terminate() then + rtu_state.shutdown = true + log.info("terminate requested, main thread exiting") + break + end + end + end + + -- execute the thread in a protected mode, retrying it on return if not shutting down + function public.p_exec() + local rtu_state = smem.rtu_state + + while not rtu_state.shutdown do + local status, result = pcall(public.exec) + if status == false then + log.fatal(util.strval(result)) + end + + if not rtu_state.shutdown then + log.info("main thread restarting in 5 seconds...") + util.psleep(5) + end + end + end + + return public +end + +-- communications handler thread +---@nodiscard +---@param smem rtu_shared_memory +function threads.thread__comms(smem) + ---@class parallel_thread + local public = {} + + -- execute thread + function public.exec() + log.debug("comms thread start") + + -- load in from shared memory + local rtu_state = smem.rtu_state + local rtu_comms = smem.rtu_sys.rtu_comms + local units = smem.rtu_sys.units + + local comms_queue = smem.q.mq_comms + + local last_update = util.time() + + -- thread loop + while true do + -- check for messages in the message queue + while comms_queue.ready() and not rtu_state.shutdown do + local msg = comms_queue.pop() + + if msg ~= nil then + if msg.qtype == mqueue.TYPE.COMMAND then + -- received a command + elseif msg.qtype == mqueue.TYPE.DATA then + -- received data + elseif msg.qtype == mqueue.TYPE.PACKET then + -- received a packet + -- handle the packet (rtu_state passed to allow setting link flag) + rtu_comms.handle_packet(msg.message, units, rtu_state) + end + end + + -- quick yield + util.nop() + end + + -- check for termination request + if rtu_state.shutdown then + rtu_comms.close(rtu_state) + log.info("comms thread exiting") + break + end + + -- delay before next check + last_update = util.adaptive_delay(COMMS_SLEEP, last_update) + end + end + + -- execute the thread in a protected mode, retrying it on return if not shutting down + function public.p_exec() + local rtu_state = smem.rtu_state + + while not rtu_state.shutdown do + local status, result = pcall(public.exec) + if status == false then + log.fatal(util.strval(result)) + end + + if not rtu_state.shutdown then + log.info("comms thread restarting in 5 seconds...") + util.psleep(5) + end + end + end + + return public +end + +-- per-unit communications handler thread +---@nodiscard +---@param smem rtu_shared_memory +---@param unit rtu_unit_registry_entry +function threads.thread__unit_comms(smem, unit) + ---@class parallel_thread + local public = {} + + -- execute thread + function public.exec() + log.debug(util.c("rtu unit thread start -> ", types.rtu_type_to_string(unit.type), "(", unit.name, ")")) + + -- load in from shared memory + local rtu_state = smem.rtu_state + local rtu_comms = smem.rtu_sys.rtu_comms + local packet_queue = unit.pkt_queue + + local last_update = util.time() + + local last_f_check = 0 + + local detail_name = util.c(types.rtu_type_to_string(unit.type), " (", unit.name, ") [", unit.index, "] for reactor ", unit.reactor) + local short_name = util.c(types.rtu_type_to_string(unit.type), " (", unit.name, ")") + + if packet_queue == nil then + log.error("rtu unit thread created without a message queue, exiting...", true) + return + end + + -- thread loop + while true do + -- check for messages in the message queue + while packet_queue.ready() and not rtu_state.shutdown do + local msg = packet_queue.pop() + + if msg ~= nil then + if msg.qtype == mqueue.TYPE.COMMAND then + -- received a command + elseif msg.qtype == mqueue.TYPE.DATA then + -- received data + elseif msg.qtype == mqueue.TYPE.PACKET then + -- received a packet + local _, reply = unit.modbus_io.handle_packet(msg.message) + rtu_comms.send_modbus(reply) + end + end + + -- quick yield + util.nop() + end + + -- check if multiblock is still formed if this is a multiblock + if unit.is_multiblock and (util.time_ms() - last_f_check > 250) then + local is_formed = unit.device.isFormed() + + last_f_check = util.time_ms() + + if unit.formed == nil then unit.formed = is_formed end + + if (not unit.formed) and is_formed then + -- newly re-formed + local iface = ppm.get_iface(unit.device) + if iface then + log.info(util.c("unmounting and remounting reformed RTU unit ", detail_name)) + + ppm.unmount(unit.device) + + local type, device = ppm.mount(iface) + + if device ~= nil then + if type == "boilerValve" and unit.type == RTU_UNIT_TYPE.BOILER_VALVE then + -- boiler multiblock + unit.device = device + unit.rtu = boilerv_rtu.new(device) + unit.formed = device.isFormed() + unit.modbus_io = modbus.new(unit.rtu, true) + elseif type == "turbineValve" and unit.type == RTU_UNIT_TYPE.TURBINE_VALVE then + -- turbine multiblock + unit.device = device + unit.rtu = turbinev_rtu.new(device) + unit.formed = device.isFormed() + unit.modbus_io = modbus.new(unit.rtu, true) + elseif type == "inductionPort" and unit.type == RTU_UNIT_TYPE.IMATRIX then + -- induction matrix multiblock + unit.device = device + unit.rtu = imatrix_rtu.new(device) + unit.formed = device.isFormed() + unit.modbus_io = modbus.new(unit.rtu, true) + elseif type == "spsPort" and unit.type == RTU_UNIT_TYPE.SPS then + -- SPS multiblock + unit.device = device + unit.rtu = sps_rtu.new(device) + unit.formed = device.isFormed() + unit.modbus_io = modbus.new(unit.rtu, true) + else + log.error("illegal remount of non-multiblock RTU attempted for " .. short_name, true) + end + + rtu_comms.send_remounted(unit.uid) + else + -- fully lost the peripheral now :( + log.error(util.c(unit.name, " lost (failed reconnect)")) + end + + log.info(util.c("reconnected the ", unit.type, " on interface ", unit.name)) + else + log.error("failed to get interface of previously connected RTU unit " .. detail_name, true) + end + end + + unit.formed = is_formed + end + + -- check for termination request + if rtu_state.shutdown then + log.info("rtu unit thread exiting -> " .. short_name) + break + end + + -- delay before next check + last_update = util.adaptive_delay(COMMS_SLEEP, last_update) + end + end + + -- execute the thread in a protected mode, retrying it on return if not shutting down + function public.p_exec() + local rtu_state = smem.rtu_state + + while not rtu_state.shutdown do + local status, result = pcall(public.exec) + if status == false then + log.fatal(util.strval(result)) + end + + if not rtu_state.shutdown then + log.info(util.c("rtu unit thread ", types.rtu_type_to_string(unit.type), "(", unit.name, " restarting in 5 seconds...")) + util.psleep(5) + end + end + end + + return public +end + +return threads diff --git a/scada-common/comms.lua b/scada-common/comms.lua new file mode 100644 index 0000000..497c3b3 --- /dev/null +++ b/scada-common/comms.lua @@ -0,0 +1,707 @@ +-- +-- Communications +-- + +local log = require("scada-common.log") + +---@class comms +local comms = {} + +local insert = table.insert + +local max_distance = nil + +comms.version = "1.4.0" + +---@enum PROTOCOL +local PROTOCOL = { + MODBUS_TCP = 0, -- our "MODBUS TCP"-esque protocol + RPLC = 1, -- reactor PLC protocol + SCADA_MGMT = 2, -- SCADA supervisor management, device advertisements, etc + SCADA_CRDN = 3, -- data/control packets for coordinators to/from supervisory controllers + COORD_API = 4 -- data/control packets for pocket computers to/from coordinators +} + +---@enum RPLC_TYPE +local RPLC_TYPE = { + STATUS = 0, -- reactor/system status + MEK_STRUCT = 1, -- mekanism build structure + MEK_BURN_RATE = 2, -- set burn rate + RPS_ENABLE = 3, -- enable reactor + RPS_SCRAM = 4, -- SCRAM reactor (manual request) + RPS_ASCRAM = 5, -- SCRAM reactor (automatic request) + RPS_STATUS = 6, -- RPS status + RPS_ALARM = 7, -- RPS alarm broadcast + RPS_RESET = 8, -- clear RPS trip (if in bad state, will trip immediately) + RPS_AUTO_RESET = 9, -- clear RPS trip if it is just a timeout or auto scram + AUTO_BURN_RATE = 10 -- set an automatic burn rate, PLC will respond with status, enable toggle speed limited +} + +---@enum SCADA_MGMT_TYPE +local SCADA_MGMT_TYPE = { + ESTABLISH = 0, -- establish new connection + KEEP_ALIVE = 1, -- keep alive packet w/ RTT + CLOSE = 2, -- close a connection + RTU_ADVERT = 3, -- RTU capability advertisement + RTU_DEV_REMOUNT = 4 -- RTU multiblock possbily changed (formed, unformed) due to PPM remount +} + +---@enum SCADA_CRDN_TYPE +local SCADA_CRDN_TYPE = { + INITIAL_BUILDS = 0, -- initial, complete builds packet to the coordinator + FAC_BUILDS = 1, -- facility RTU builds + FAC_STATUS = 2, -- state of facility and facility devices + FAC_CMD = 3, -- faility command + UNIT_BUILDS = 4, -- build of each reactor unit (reactor + RTUs) + UNIT_STATUSES = 5, -- state of each of the reactor units + UNIT_CMD = 6 -- command a reactor unit +} + +---@enum CAPI_TYPE +local CAPI_TYPE = { +} + +---@enum ESTABLISH_ACK +local ESTABLISH_ACK = { + ALLOW = 0, -- link approved + DENY = 1, -- link denied + COLLISION = 2, -- link denied due to existing active link + BAD_VERSION = 3 -- link denied due to comms version mismatch +} + +---@enum DEVICE_TYPE +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 +} + +---@enum PLC_AUTO_ACK +local PLC_AUTO_ACK = { + FAIL = 0, -- failed to set burn rate/burn rate invalid + DIRECT_SET_OK = 1, -- successfully set burn rate + RAMP_SET_OK = 2, -- successfully started burn rate ramping + ZERO_DIS_OK = 3 -- successfully disabled reactor with < 0.01 burn rate +} + +---@enum FAC_COMMAND +local FAC_COMMAND = { + SCRAM_ALL = 0, -- SCRAM all reactors + STOP = 1, -- stop automatic control + START = 2, -- start automatic control + ACK_ALL_ALARMS = 3 -- acknowledge all alarms on all units +} + +---@enum UNIT_COMMAND +local UNIT_COMMAND = { + SCRAM = 0, -- SCRAM the reactor + START = 1, -- start the reactor + RESET_RPS = 2, -- reset the RPS + SET_BURN = 3, -- set the burn rate + SET_WASTE = 4, -- set the waste processing mode + ACK_ALL_ALARMS = 5, -- ack all active alarms + ACK_ALARM = 6, -- ack a particular alarm + RESET_ALARM = 7, -- reset a particular alarm + SET_GROUP = 8 -- assign this unit to a group +} + +comms.PROTOCOL = PROTOCOL + +comms.RPLC_TYPE = RPLC_TYPE +comms.SCADA_MGMT_TYPE = SCADA_MGMT_TYPE +comms.SCADA_CRDN_TYPE = SCADA_CRDN_TYPE +comms.CAPI_TYPE = CAPI_TYPE + +comms.ESTABLISH_ACK = ESTABLISH_ACK +comms.DEVICE_TYPE = DEVICE_TYPE + +comms.PLC_AUTO_ACK = PLC_AUTO_ACK + +comms.UNIT_COMMAND = UNIT_COMMAND +comms.FAC_COMMAND = FAC_COMMAND + +---@alias packet scada_packet|modbus_packet|rplc_packet|mgmt_packet|crdn_packet|capi_packet +---@alias frame modbus_frame|rplc_frame|mgmt_frame|crdn_frame|capi_frame + +-- configure the maximum allowable message receive distance
+-- packets received with distances greater than this will be silently discarded +---@param distance integer max modem message distance (less than 1 disables the limit) +function comms.set_trusted_range(distance) + if distance < 1 then + max_distance = nil + else + max_distance = distance + end +end + +-- generic SCADA packet object +---@nodiscard +function comms.scada_packet() + local self = { + modem_msg_in = nil, + valid = false, + raw = { -1, PROTOCOL.SCADA_MGMT, {} }, + seq_num = -1, + protocol = PROTOCOL.SCADA_MGMT, + length = 0, + payload = {} + } + + ---@class scada_packet + local public = {} + + -- make a SCADA packet + ---@param seq_num integer + ---@param protocol PROTOCOL + ---@param payload table + function public.make(seq_num, protocol, payload) + self.valid = true + self.seq_num = seq_num + self.protocol = protocol + self.length = #payload + self.payload = payload + self.raw = { self.seq_num, self.protocol, self.payload } + end + + -- parse in a modem message as a SCADA packet + ---@param side string modem side + ---@param sender integer sender port + ---@param reply_to integer reply port + ---@param message any message body + ---@param distance integer transmission distance + ---@return boolean valid valid message received + function public.receive(side, sender, reply_to, message, distance) + self.modem_msg_in = { + iface = side, + s_port = sender, + r_port = reply_to, + msg = message, + dist = distance + } + + self.raw = self.modem_msg_in.msg + + if (type(max_distance) == "number") and (distance > max_distance) then + -- outside of maximum allowable transmission distance + -- log.debug("comms.scada_packet.receive(): discarding packet with distance " .. distance .. " outside of trusted range") + else + if type(self.raw) == "table" then + if #self.raw >= 3 then + self.seq_num = self.raw[1] + self.protocol = self.raw[2] + + -- element 3 must be a table + if type(self.raw[3]) == "table" then + self.length = #self.raw[3] + self.payload = self.raw[3] + end + end + + self.valid = type(self.seq_num) == "number" and + type(self.protocol) == "number" and + type(self.payload) == "table" + end + end + + return self.valid + end + + -- public accessors -- + + ---@nodiscard + function public.modem_event() return self.modem_msg_in end + ---@nodiscard + function public.raw_sendable() return self.raw end + + ---@nodiscard + function public.local_port() return self.modem_msg_in.s_port end + ---@nodiscard + function public.remote_port() return self.modem_msg_in.r_port end + + ---@nodiscard + function public.is_valid() return self.valid end + + ---@nodiscard + function public.seq_num() return self.seq_num end + ---@nodiscard + function public.protocol() return self.protocol end + ---@nodiscard + function public.length() return self.length end + ---@nodiscard + function public.data() return self.payload end + + return public +end + +-- MODBUS packet
+-- modeled after MODBUS TCP packet +---@nodiscard +function comms.modbus_packet() + local self = { + frame = nil, + raw = {}, + txn_id = -1, + length = 0, + unit_id = -1, + func_code = 0x80, + data = {} + } + + ---@class modbus_packet + local public = {} + + -- make a MODBUS packet + ---@param txn_id integer + ---@param unit_id integer + ---@param func_code MODBUS_FCODE + ---@param data table + function public.make(txn_id, unit_id, func_code, data) + if type(data) == "table" then + self.txn_id = txn_id + self.length = #data + self.unit_id = unit_id + self.func_code = func_code + self.data = data + + -- populate raw array + self.raw = { self.txn_id, self.unit_id, self.func_code } + for i = 1, self.length do + insert(self.raw, data[i]) + end + else + log.error("comms.modbus_packet.make(): data not table") + end + end + + -- decode a MODBUS packet from a SCADA frame + ---@param frame scada_packet + ---@return boolean success + function public.decode(frame) + if frame then + self.frame = frame + + if frame.protocol() == PROTOCOL.MODBUS_TCP then + local size_ok = frame.length() >= 3 + + if size_ok then + local data = frame.data() + public.make(data[1], data[2], data[3], { table.unpack(data, 4, #data) }) + end + + local valid = type(self.txn_id) == "number" and + type(self.unit_id) == "number" and + type(self.func_code) == "number" + + return size_ok and valid + else + log.debug("attempted MODBUS_TCP parse of incorrect protocol " .. frame.protocol(), true) + return false + end + else + log.debug("nil frame encountered", true) + return false + end + end + + -- get raw to send + ---@nodiscard + function public.raw_sendable() return self.raw end + + -- get this packet as a frame with an immutable relation to this object + ---@nodiscard + function public.get() + ---@class modbus_frame + local frame = { + scada_frame = self.frame, + txn_id = self.txn_id, + length = self.length, + unit_id = self.unit_id, + func_code = self.func_code, + data = self.data + } + + return frame + end + + return public +end + +-- reactor PLC packet +---@nodiscard +function comms.rplc_packet() + local self = { + frame = nil, + raw = {}, + id = 0, + type = 0, ---@type RPLC_TYPE + length = 0, + data = {} + } + + ---@class rplc_packet + local public = {} + + -- check that type is known + local function _rplc_type_valid() + return self.type == RPLC_TYPE.STATUS or + self.type == RPLC_TYPE.MEK_STRUCT or + self.type == RPLC_TYPE.MEK_BURN_RATE or + self.type == RPLC_TYPE.RPS_ENABLE or + self.type == RPLC_TYPE.RPS_SCRAM or + self.type == RPLC_TYPE.RPS_ASCRAM or + self.type == RPLC_TYPE.RPS_STATUS or + self.type == RPLC_TYPE.RPS_ALARM or + self.type == RPLC_TYPE.RPS_RESET or + self.type == RPLC_TYPE.RPS_AUTO_RESET or + self.type == RPLC_TYPE.AUTO_BURN_RATE + end + + -- make an RPLC packet + ---@param id integer + ---@param packet_type RPLC_TYPE + ---@param data table + function public.make(id, packet_type, data) + if type(data) == "table" then + -- packet accessor properties + self.id = id + self.type = packet_type + self.length = #data + self.data = data + + -- populate raw array + self.raw = { self.id, self.type } + for i = 1, #data do + insert(self.raw, data[i]) + end + else + log.error("comms.rplc_packet.make(): data not table") + end + end + + -- decode an RPLC packet from a SCADA frame + ---@param frame scada_packet + ---@return boolean success + function public.decode(frame) + if frame then + self.frame = frame + + if frame.protocol() == PROTOCOL.RPLC then + local ok = frame.length() >= 2 + + if ok then + local data = frame.data() + public.make(data[1], data[2], { table.unpack(data, 3, #data) }) + ok = _rplc_type_valid() + end + + ok = ok and type(self.id) == "number" + + return ok + else + log.debug("attempted RPLC parse of incorrect protocol " .. frame.protocol(), true) + return false + end + else + log.debug("nil frame encountered", true) + return false + end + end + + -- get raw to send + ---@nodiscard + function public.raw_sendable() return self.raw end + + -- get this packet as a frame with an immutable relation to this object + ---@nodiscard + function public.get() + ---@class rplc_frame + local frame = { + scada_frame = self.frame, + id = self.id, + type = self.type, + length = self.length, + data = self.data + } + + return frame + end + + return public +end + +-- SCADA management packet +---@nodiscard +function comms.mgmt_packet() + local self = { + frame = nil, + raw = {}, + type = 0, ---@type SCADA_MGMT_TYPE + length = 0, + data = {} + } + + ---@class mgmt_packet + local public = {} + + -- check that type is known + local function _scada_type_valid() + return self.type == SCADA_MGMT_TYPE.ESTABLISH or + self.type == SCADA_MGMT_TYPE.KEEP_ALIVE or + self.type == SCADA_MGMT_TYPE.CLOSE or + self.type == SCADA_MGMT_TYPE.REMOTE_LINKED or + self.type == SCADA_MGMT_TYPE.RTU_ADVERT or + self.type == SCADA_MGMT_TYPE.RTU_DEV_REMOUNT + end + + -- make a SCADA management packet + ---@param packet_type SCADA_MGMT_TYPE + ---@param data table + function public.make(packet_type, data) + if type(data) == "table" then + -- packet accessor properties + self.type = packet_type + self.length = #data + self.data = data + + -- populate raw array + self.raw = { self.type } + for i = 1, #data do + insert(self.raw, data[i]) + end + else + log.error("comms.mgmt_packet.make(): data not table") + end + end + + -- decode a SCADA management packet from a SCADA frame + ---@param frame scada_packet + ---@return boolean success + function public.decode(frame) + if frame then + self.frame = frame + + if frame.protocol() == PROTOCOL.SCADA_MGMT then + local ok = frame.length() >= 1 + + if ok then + local data = frame.data() + public.make(data[1], { table.unpack(data, 2, #data) }) + ok = _scada_type_valid() + end + + return ok + else + log.debug("attempted SCADA_MGMT parse of incorrect protocol " .. frame.protocol(), true) + return false + end + else + log.debug("nil frame encountered", true) + return false + end + end + + -- get raw to send + ---@nodiscard + function public.raw_sendable() return self.raw end + + -- get this packet as a frame with an immutable relation to this object + ---@nodiscard + function public.get() + ---@class mgmt_frame + local frame = { + scada_frame = self.frame, + type = self.type, + length = self.length, + data = self.data + } + + return frame + end + + return public +end + +-- SCADA coordinator packet +---@nodiscard +function comms.crdn_packet() + local self = { + frame = nil, + raw = {}, + type = 0, ---@type SCADA_CRDN_TYPE + length = 0, + data = {} + } + + ---@class crdn_packet + local public = {} + + -- check that type is known + ---@nodiscard + local function _crdn_type_valid() + return self.type == SCADA_CRDN_TYPE.INITIAL_BUILDS or + self.type == SCADA_CRDN_TYPE.FAC_BUILDS or + self.type == SCADA_CRDN_TYPE.FAC_STATUS or + self.type == SCADA_CRDN_TYPE.FAC_CMD or + self.type == SCADA_CRDN_TYPE.UNIT_BUILDS or + self.type == SCADA_CRDN_TYPE.UNIT_STATUSES or + self.type == SCADA_CRDN_TYPE.UNIT_CMD + end + + -- make a coordinator packet + ---@param packet_type SCADA_CRDN_TYPE + ---@param data table + function public.make(packet_type, data) + if type(data) == "table" then + -- packet accessor properties + self.type = packet_type + self.length = #data + self.data = data + + -- populate raw array + self.raw = { self.type } + for i = 1, #data do + insert(self.raw, data[i]) + end + else + log.error("comms.crdn_packet.make(): data not table") + end + end + + -- decode a coordinator packet from a SCADA frame + ---@param frame scada_packet + ---@return boolean success + function public.decode(frame) + if frame then + self.frame = frame + + if frame.protocol() == PROTOCOL.SCADA_CRDN then + local ok = frame.length() >= 1 + + if ok then + local data = frame.data() + public.make(data[1], { table.unpack(data, 2, #data) }) + ok = _crdn_type_valid() + end + + return ok + else + log.debug("attempted SCADA_CRDN parse of incorrect protocol " .. frame.protocol(), true) + return false + end + else + log.debug("nil frame encountered", true) + return false + end + end + + -- get raw to send + ---@nodiscard + function public.raw_sendable() return self.raw end + + -- get this packet as a frame with an immutable relation to this object + ---@nodiscard + function public.get() + ---@class crdn_frame + local frame = { + scada_frame = self.frame, + type = self.type, + length = self.length, + data = self.data + } + + return frame + end + + return public +end + +-- coordinator API (CAPI) packet +---@todo implement for pocket access, set enum type for self.type +---@nodiscard +function comms.capi_packet() + local self = { + frame = nil, + raw = {}, + type = 0, + length = 0, + data = {} + } + + ---@class capi_packet + local public = {} + + local function _capi_type_valid() + ---@todo + return false + end + + -- make a coordinator API packet + ---@param packet_type CAPI_TYPE + ---@param data table + function public.make(packet_type, data) + if type(data) == "table" then + -- packet accessor properties + self.type = packet_type + self.length = #data + self.data = data + + -- populate raw array + self.raw = { self.type } + for i = 1, #data do + insert(self.raw, data[i]) + end + else + log.error("comms.capi_packet.make(): data not table") + end + end + + -- decode a coordinator API packet from a SCADA frame + ---@param frame scada_packet + ---@return boolean success + function public.decode(frame) + if frame then + self.frame = frame + + if frame.protocol() == PROTOCOL.COORD_API then + local ok = frame.length() >= 1 + + if ok then + local data = frame.data() + public.make(data[1], { table.unpack(data, 2, #data) }) + ok = _capi_type_valid() + end + + return ok + else + log.debug("attempted COORD_API parse of incorrect protocol " .. frame.protocol(), true) + return false + end + else + log.debug("nil frame encountered", true) + return false + end + end + + -- get raw to send + ---@nodiscard + function public.raw_sendable() return self.raw end + + -- get this packet as a frame with an immutable relation to this object + ---@nodiscard + function public.get() + ---@class capi_frame + local frame = { + scada_frame = self.frame, + type = self.type, + length = self.length, + data = self.data + } + + return frame + end + + return public +end + +return comms diff --git a/scada-common/constants.lua b/scada-common/constants.lua new file mode 100644 index 0000000..7c661ee --- /dev/null +++ b/scada-common/constants.lua @@ -0,0 +1,83 @@ +-- +-- System and Safety Constants +-- + +local constants = {} + +--#region Reactor Protection System (on the PLC) Limits + +local rps = {} + +rps.MAX_DAMAGE_PERCENT = 90 -- damage >= 90% +rps.MAX_DAMAGE_TEMPERATURE = 1200 -- temp >= 1200K +rps.MIN_COOLANT_FILL = 0.10 -- fill < 10% +rps.MAX_WASTE_FILL = 0.95 -- fill > 95% +rps.MAX_HEATED_COLLANT_FILL = 0.95 -- fill > 95% +rps.NO_FUEL_FILL = 0.0 -- fill <= 0% + +constants.RPS_LIMITS = rps + +--#endregion + +--#region Annunciator Limits + +local annunc = {} + +annunc.RCSFlowLow_H2O = -3.2 -- flow < -3.2 mB/s +annunc.RCSFlowLow_NA = -2.0 -- flow < -2.0 mB/s +annunc.CoolantLevelLow = 0.4 -- fill < 40% +annunc.ReactorTempHigh = 1000 -- temp > 1000K +annunc.ReactorHighDeltaT = 50 -- rate > 50K/s +annunc.FuelLevelLow = 0.05 -- fill <= 5% +annunc.WasteLevelHigh = 0.80 -- fill >= 80% +annunc.WaterLevelLow = 0.4 -- fill < 40% +annunc.SteamFeedMismatch = 10 -- ±10mB difference between total coolant flow and total steam input rate +annunc.SFM_MaxSteamDT_H20 = 2.0 -- flow > 2.0 mB/s +annunc.SFM_MinWaterDT_H20 = -3.0 -- flow < -3.0 mB/s +annunc.SFM_MaxSteamDT_NA = 2.0 -- flow > 2.0 mB/s +annunc.SFM_MinWaterDT_NA = -2.0 -- flow < -2.0 mB/s +annunc.RadiationWarning = 0.00001 -- 10 uSv/h + +constants.ANNUNCIATOR_LIMITS = annunc + +--#endregion + +--#region Supervisor Alarm Limits + +local alarms = {} + +-- unit alarms + +alarms.HIGH_TEMP = 1150 -- temp >= 1150K +alarms.HIGH_WASTE = 0.85 -- fill > 85% +alarms.HIGH_RADIATION = 0.00005 -- 50 uSv/h, not yet damaging but this isn't good + +-- facility alarms + +alarms.CHARGE_HIGH = 1.0 -- once at or above 100% charge +alarms.CHARGE_RE_ENABLE = 0.95 -- once below 95% charge +alarms.FAC_HIGH_RAD = 0.00001 -- 10 uSv/h + +constants.ALARM_LIMITS = alarms + +--#endregion + +--#region Supervisor Constants + +-- milliseconds until turbine flow is assumed to be stable enough to enable coolant checks +constants.FLOW_STABILITY_DELAY_MS = 15000 + +-- Notes on Radiation +-- - background radiation 0.0000001 Sv/h (99.99 nSv/h) +-- - "green tint" radiation 0.00001 Sv/h (10 uSv/h) +-- - damaging radiation 0.00006 Sv/h (60 uSv/h) +constants.LOW_RADIATION = 0.00001 +constants.HAZARD_RADIATION = 0.00006 +constants.HIGH_RADIATION = 0.001 +constants.VERY_HIGH_RADIATION = 0.1 +constants.SEVERE_RADIATION = 8.0 +constants.EXTREME_RADIATION = 100.0 + +--#endregion + +return constants diff --git a/scada-common/crash.lua b/scada-common/crash.lua new file mode 100644 index 0000000..66bfda1 --- /dev/null +++ b/scada-common/crash.lua @@ -0,0 +1,46 @@ +-- +-- Crash Handler +-- + +local comms = require("scada-common.comms") +local log = require("scada-common.log") +local util = require("scada-common.util") + +local crash = {} + +local app = "unknown" +local ver = "v0.0.0" +local err = "" + +-- set crash environment +---@param application string app name +---@param version string version +function crash.set_env(application, version) + app = application + ver = version +end + +-- handle a crash error +---@param error string error message +function crash.handler(error) + err = error + log.info("=====> FATAL SOFTWARE FAULT <=====") + log.fatal(error) + log.info("----------------------------------") + log.info(util.c("RUNTIME: ", _HOST)) + log.info(util.c("LUA VERSION: ", _VERSION)) + log.info(util.c("APPLICATION: ", app)) + log.info(util.c("FIRMWARE VERSION: ", ver)) + log.info(util.c("COMMS VERSION: ", comms.version)) + log.info("----------------------------------") + log.info(debug.traceback("--- begin debug trace ---", 1)) + log.info("--- end debug trace ---") +end + +-- final error print on failed xpcall, app exits here +function crash.exit() + util.println("fatal error occured in main application:") + error(err, 0) +end + +return crash diff --git a/scada-common/crypto.lua b/scada-common/crypto.lua new file mode 100644 index 0000000..a1053bf --- /dev/null +++ b/scada-common/crypto.lua @@ -0,0 +1,249 @@ +-- +-- Cryptographic Communications Engine +-- + +local aes128 = require("lockbox.cipher.aes128") +local ctr_mode = require("lockbox.cipher.mode.ctr") +local sha1 = require("lockbox.digest.sha1") +local sha2_224 = require("lockbox.digest.sha2_224") +local sha2_256 = require("lockbox.digest.sha2_256") +local pbkdf2 = require("lockbox.kdf.pbkdf2") +local hmac = require("lockbox.mac.hmac") +local zero_pad = require("lockbox.padding.zero") +local stream = require("lockbox.util.stream") +local array = require("lockbox.util.array") + +local log = require("scada-common.log") +local util = require("scada-common.util") + +local crypto = {} + +local c_eng = { + key = nil, + cipher = nil, + decipher = nil, + hmac = nil +} + +---@alias hex string + +-- initialize cryptographic system +function crypto.init(password, server_port) + local key_deriv = pbkdf2() + + -- setup PBKDF2 + -- the primary goal is to just turn our password into a 16 byte key + key_deriv.setPassword(password) + key_deriv.setSalt("salty_salt_at_" .. server_port) + key_deriv.setIterations(32) + key_deriv.setBlockLen(8) + key_deriv.setDKeyLen(16) + + local start = util.time() + + key_deriv.setPRF(hmac().setBlockSize(64).setDigest(sha2_256)) + key_deriv.finish() + + log.dmesg("pbkdf2: key derivation took " .. (util.time() - start) .. "ms", "CRYPTO", colors.yellow) + + c_eng.key = array.fromHex(key_deriv.asHex()) + + -- initialize cipher + c_eng.cipher = ctr_mode.Cipher() + c_eng.cipher.setKey(c_eng.key) + c_eng.cipher.setBlockCipher(aes128) + c_eng.cipher.setPadding(zero_pad) + + -- initialize decipher + c_eng.decipher = ctr_mode.Decipher() + c_eng.decipher.setKey(c_eng.key) + c_eng.decipher.setBlockCipher(aes128) + c_eng.decipher.setPadding(zero_pad) + + -- initialize HMAC + c_eng.hmac = hmac() + c_eng.hmac.setBlockSize(64) + c_eng.hmac.setDigest(sha1) + c_eng.hmac.setKey(c_eng.key) + + log.dmesg("init: completed in " .. (util.time() - start) .. "ms", "CRYPTO", colors.yellow) +end + +-- encrypt plaintext +---@nodiscard +---@param plaintext string +---@return table initial_value, string ciphertext +function crypto.encrypt(plaintext) + local start = util.time() + + -- initial value + local iv = { + math.random(0, 255), + math.random(0, 255), + math.random(0, 255), + math.random(0, 255), + math.random(0, 255), + math.random(0, 255), + math.random(0, 255), + math.random(0, 255), + math.random(0, 255), + math.random(0, 255), + math.random(0, 255), + math.random(0, 255), + math.random(0, 255), + math.random(0, 255), + math.random(0, 255), + math.random(0, 255) + } + + log.debug("crypto.random: iv random took " .. (util.time() - start) .. "ms") + + start = util.time() + + c_eng.cipher.init() + c_eng.cipher.update(stream.fromArray(iv)) + c_eng.cipher.update(stream.fromString(plaintext)) + c_eng.cipher.finish() + + local ciphertext = c_eng.cipher.asHex() ---@type hex + + log.debug("crypto.encrypt: aes128-ctr-mode took " .. (util.time() - start) .. "ms") + log.debug("ciphertext: " .. util.strval(ciphertext)) + + return iv, ciphertext +end + +-- decrypt ciphertext +---@nodiscard +---@param iv string CTR initial value +---@param ciphertext string ciphertext hex +---@return string plaintext +function crypto.decrypt(iv, ciphertext) + local start = util.time() + + c_eng.decipher.init() + c_eng.decipher.update(stream.fromArray(iv)) + c_eng.decipher.update(stream.fromHex(ciphertext)) + c_eng.decipher.finish() + + local plaintext_hex = c_eng.decipher.asHex() ---@type hex + + local plaintext = stream.toString(stream.fromHex(plaintext_hex)) + + log.debug("crypto.decrypt: aes128-ctr-mode took " .. (util.time() - start) .. "ms") + log.debug("plaintext: " .. util.strval(plaintext)) + + return plaintext +end + +-- generate HMAC of message +---@nodiscard +---@param message_hex string initial value concatenated with ciphertext +function crypto.hmac(message_hex) + local start = util.time() + + c_eng.hmac.init() + c_eng.hmac.update(stream.fromHex(message_hex)) + c_eng.hmac.finish() + + local hash = c_eng.hmac.asHex() ---@type hex + + log.debug("crypto.hmac: hmac-sha1 took " .. (util.time() - start) .. "ms") + log.debug("hmac: " .. util.strval(hash)) + + return hash +end + +-- wrap a modem as a secure modem to send encrypted traffic +---@param modem table modem to wrap +function crypto.secure_modem(modem) + local self = { + modem = modem + } + + ---@class secure_modem + ---@field open function + ---@field isOpen function + ---@field close function + ---@field closeAll function + ---@field isWireless function + ---@field getNamesRemote function + ---@field isPresentRemote function + ---@field getTypeRemote function + ---@field hasTypeRemote function + ---@field getMethodsRemote function + ---@field callRemote function + ---@field getNameLocal function + local public = {} + + -- wrap a modem + ---@param modem table +---@diagnostic disable-next-line: redefined-local + function public.wrap(modem) + self.modem = modem + for key, func in pairs(self.modem) do + public[key] = func + end + end + + -- wrap modem functions, then we replace transmit + public.wrap(self.modem) + + -- send a packet with encryption + ---@param channel integer + ---@param reply_channel integer + ---@param payload table packet raw_sendable + function public.transmit(channel, reply_channel, payload) + local plaintext = textutils.serialize(payload, { allow_repetitions = true, compact = true }) + + local iv, ciphertext = crypto.encrypt(plaintext) +---@diagnostic disable-next-line: redefined-local + local hmac = crypto.hmac(iv .. ciphertext) + + self.modem.transmit(channel, reply_channel, { hmac, iv, ciphertext }) + end + + -- parse in a modem message as a network packet + ---@nodiscard + ---@param side string modem side + ---@param sender integer sender port + ---@param reply_to integer reply port + ---@param message any encrypted packet sent with secure_modem.transmit + ---@param distance integer transmission distance + ---@return string side, integer sender, integer reply_to, any plaintext_message, integer distance + function public.receive(side, sender, reply_to, message, distance) + local body = "" + + if type(message) == "table" then + if #message == 3 then +---@diagnostic disable-next-line: redefined-local + local hmac = message[1] + local iv = message[2] + local ciphertext = message[3] + + local computed_hmac = crypto.hmac(iv .. ciphertext) + + if hmac == computed_hmac then + -- message intact + local plaintext = crypto.decrypt(iv, ciphertext) + body = textutils.unserialize(plaintext) + + if body == nil then + -- failed decryption + log.debug("crypto.secure_modem: decryption failed") + body = "" + end + else + -- something went wrong + log.debug("crypto.secure_modem: hmac mismatch violation") + end + end + end + + return side, sender, reply_to, body, distance + end + + return public +end + +return crypto diff --git a/scada-common/log.lua b/scada-common/log.lua new file mode 100644 index 0000000..424bf55 --- /dev/null +++ b/scada-common/log.lua @@ -0,0 +1,321 @@ +-- +-- File System Logger +-- + +local util = require("scada-common.util") + +---@class log +local log = {} + +---@alias MODE integer +local MODE = { + APPEND = 0, + NEW = 1 +} + +log.MODE = MODE + +-- whether to log debug messages or not +local LOG_DEBUG = false + +local log_sys = { + path = "/log.txt", + mode = MODE.APPEND, + file = nil, + dmesg_out = nil +} + +---@type function +local free_space = fs.getFreeSpace + +-- initialize logger +---@param path string file path +---@param write_mode MODE +---@param dmesg_redirect? table terminal/window to direct dmesg to +function log.init(path, write_mode, dmesg_redirect) + log_sys.path = path + log_sys.mode = write_mode + + if log_sys.mode == MODE.APPEND then + log_sys.file = fs.open(path, "a") + else + log_sys.file = fs.open(path, "w") + end + + if dmesg_redirect then + log_sys.dmesg_out = dmesg_redirect + else + log_sys.dmesg_out = term.current() + end +end + +-- direct dmesg output to a monitor/window +---@param window table window or terminal reference +function log.direct_dmesg(window) log_sys.dmesg_out = window end + +-- private log write function +---@param msg string +local function _log(msg) + local out_of_space = false + local time_stamp = os.date("[%c] ") + local stamped = time_stamp .. util.strval(msg) + + -- attempt to write log + local status, result = pcall(function () + log_sys.file.writeLine(stamped) + log_sys.file.flush() + end) + + -- if we don't have space, we need to create a new log file + + if (not status) and (result ~= nil) then + out_of_space = string.find(result, "Out of space") ~= nil + + if out_of_space then + -- will delete log file + else + util.println("unknown error writing to logfile: " .. result) + end + end + + if out_of_space or (free_space(log_sys.path) < 100) then + -- delete the old log file before opening a new one + log_sys.file.close() + fs.delete(log_sys.path) + + -- re-init logger and pass dmesg_out so that it doesn't change + log.init(log_sys.path, log_sys.mode, log_sys.dmesg_out) + + -- leave a message + log_sys.file.writeLine(time_stamp .. "recycled log file") + log_sys.file.writeLine(stamped) + log_sys.file.flush() + end +end + +-- dmesg style logging for boot because I like linux-y things +---@param msg string message +---@param tag? string log tag +---@param tag_color? integer log tag color +---@return dmesg_ts_coord coordinates line area to place working indicator +function log.dmesg(msg, tag, tag_color) + ---@class dmesg_ts_coord + local ts_coord = { x1 = 2, x2 = 3, y = 1 } + + msg = util.strval(msg) + tag = tag or "" + tag = util.strval(tag) + + local t_stamp = string.format("%12.2f", os.clock()) + local out = log_sys.dmesg_out + + if out ~= nil then + local out_w, out_h = out.getSize() + + local lines = { msg } + + -- wrap if needed + if string.len(msg) > out_w then + local remaining = true + local s_start = 1 + local s_end = out_w + local i = 1 + + lines = {} + + while remaining do + local line = string.sub(msg, s_start, s_end) + + if line == "" then + remaining = false + else + lines[i] = line + + s_start = s_end + 1 + s_end = s_end + out_w + i = i + 1 + end + end + end + + -- start output with tag and time, assuming we have enough width for this to be on one line + local cur_x, cur_y = out.getCursorPos() + + if cur_x > 1 then + if cur_y == out_h then + out.scroll(1) + out.setCursorPos(1, cur_y) + else + out.setCursorPos(1, cur_y + 1) + end + end + + -- colored time + local initial_color = out.getTextColor() + out.setTextColor(colors.white) + out.write("[") + out.setTextColor(colors.lightGray) + out.write(t_stamp) + ts_coord.x2, ts_coord.y = out.getCursorPos() + ts_coord.x2 = ts_coord.x2 - 1 + out.setTextColor(colors.white) + out.write("] ") + + -- print optionally colored tag + if tag ~= "" then + out.write("[") + if tag_color then out.setTextColor(tag_color) end + out.write(tag) + out.setTextColor(colors.white) + out.write("] ") + end + + out.setTextColor(initial_color) + + -- output message + for i = 1, #lines do + cur_x, cur_y = out.getCursorPos() + + if i > 1 and cur_x > 1 then + if cur_y == out_h then + out.scroll(1) + out.setCursorPos(1, cur_y) + else + out.setCursorPos(1, cur_y + 1) + end + end + + out.write(lines[i]) + end + + _log(util.c("[", t_stamp, "] [", tag, "] ", msg)) + end + + return ts_coord +end + +-- print a dmesg message, but then show remaining seconds instead of timestamp +---@nodiscard +---@param msg string message +---@param tag? string log tag +---@param tag_color? integer log tag color +---@return function update, function done +function log.dmesg_working(msg, tag, tag_color) + local ts_coord = log.dmesg(msg, tag, tag_color) + + local out = log_sys.dmesg_out + local width = (ts_coord.x2 - ts_coord.x1) + 1 + + if out ~= nil then + local initial_color = out.getTextColor() + + local counter = 0 + + local function update(sec_remaining) + local time = util.sprintf("%ds", sec_remaining) + local available = width - (string.len(time) + 2) + local progress = "" + + out.setCursorPos(ts_coord.x1, ts_coord.y) + out.write(" ") + + if counter % 4 == 0 then + progress = "|" + elseif counter % 4 == 1 then + progress = "/" + elseif counter % 4 == 2 then + progress = "-" + elseif counter % 4 == 3 then + progress = "\\" + end + + out.setTextColor(colors.blue) + out.write(progress) + out.setTextColor(colors.lightGray) + out.write(util.spaces(available) .. time) + out.setTextColor(initial_color) + + counter = counter + 1 + end + + local function done(ok) + out.setCursorPos(ts_coord.x1, ts_coord.y) + + if ok or ok == nil then + out.setTextColor(colors.green) + out.write(util.pad("DONE", width)) + else + out.setTextColor(colors.red) + out.write(util.pad("FAIL", width)) + end + + out.setTextColor(initial_color) + end + + return update, done + else + return function () end, function () end + end +end + +-- log debug messages +---@param msg string message +---@param trace? boolean include file trace +function log.debug(msg, trace) + if LOG_DEBUG then + local dbg_info = "" + + if trace then + local info = debug.getinfo(2) + local name = "" + + if info.name ~= nil then + name = ":" .. info.name .. "():" + end + + dbg_info = info.short_src .. ":" .. name .. info.currentline .. " > " + end + + _log("[DBG] " .. dbg_info .. util.strval(msg)) + end +end + +-- log info messages +---@param msg string message +function log.info(msg) + _log("[INF] " .. util.strval(msg)) +end + +-- log warning messages +---@param msg string message +function log.warning(msg) + _log("[WRN] " .. util.strval(msg)) +end + +-- log error messages +---@param msg string message +---@param trace? boolean include file trace +function log.error(msg, trace) + local dbg_info = "" + + if trace then + local info = debug.getinfo(2) + local name = "" + + if info.name ~= nil then + name = ":" .. info.name .. "():" + end + + dbg_info = info.short_src .. ":" .. name .. info.currentline .. " > " + end + + _log("[ERR] " .. dbg_info .. util.strval(msg)) +end + +-- log fatal errors +---@param msg string message +function log.fatal(msg) + _log("[FTL] " .. util.strval(msg)) +end + +return log diff --git a/scada-common/mqueue.lua b/scada-common/mqueue.lua new file mode 100644 index 0000000..b48e4ad --- /dev/null +++ b/scada-common/mqueue.lua @@ -0,0 +1,88 @@ +-- +-- Message Queue +-- + +local mqueue = {} + +---@enum MQ_TYPE +local TYPE = { + COMMAND = 0, + DATA = 1, + PACKET = 2 +} + +mqueue.TYPE = TYPE + +-- create a new message queue +---@nodiscard +function mqueue.new() + local queue = {} + + local insert = table.insert + local remove = table.remove + + ---@class queue_item + ---@field qtype MQ_TYPE + ---@field message any + + ---@class queue_data + ---@field key any + ---@field val any + + ---@class mqueue + local public = {} + + -- get queue length + function public.length() return #queue end + + -- check if queue is empty + ---@nodiscard + ---@return boolean is_empty + function public.empty() return #queue == 0 end + + -- check if queue has contents + ---@nodiscard + ---@return boolean has_contents + function public.ready() return #queue ~= 0 end + + -- push a new item onto the queue + ---@param qtype MQ_TYPE + ---@param message any + local function _push(qtype, message) + insert(queue, { qtype = qtype, message = message }) + end + + -- push a command onto the queue + ---@param message any + function public.push_command(message) + _push(TYPE.COMMAND, message) + end + + -- push data onto the queue + ---@param key any + ---@param value any + function public.push_data(key, value) + _push(TYPE.DATA, { key = key, val = value }) + end + + -- push a packet onto the queue + ---@param packet packet|frame + function public.push_packet(packet) + _push(TYPE.PACKET, packet) + end + + -- get an item off the queue + ---@nodiscard + ---@return queue_item|nil + function public.pop() + if #queue > 0 then + return remove(queue, 1) + else + return nil + end + end + + return public +end + +return mqueue diff --git a/scada-common/ppm.lua b/scada-common/ppm.lua new file mode 100644 index 0000000..fe9e026 --- /dev/null +++ b/scada-common/ppm.lua @@ -0,0 +1,421 @@ +-- +-- Protected Peripheral Manager +-- + +local log = require("scada-common.log") +local util = require("scada-common.util") + +---@class ppm +local ppm = {} + +local ACCESS_FAULT = nil ---@type nil + +local UNDEFINED_FIELD = "undefined field" + +local VIRTUAL_DEVICE_TYPE = "ppm_vdev" + +ppm.ACCESS_FAULT = ACCESS_FAULT +ppm.UNDEFINED_FIELD = UNDEFINED_FIELD +ppm.VIRTUAL_DEVICE_TYPE = VIRTUAL_DEVICE_TYPE + +---------------------------- +-- PRIVATE DATA/FUNCTIONS -- +---------------------------- + +local REPORT_FREQUENCY = 20 -- log every 20 faults per function + +local ppm_sys = { + mounts = {}, + next_vid = 0, + auto_cf = false, + faulted = false, + last_fault = "", + terminate = false, + mute = false +} + +-- wrap peripheral calls with lua protected call as we don't want a disconnect to crash a program
+-- also provides peripheral-specific fault checks (auto-clear fault defaults to true)
+-- assumes iface is a valid peripheral +---@param iface string CC peripheral interface +local function peri_init(iface) + local self = { + faulted = false, + last_fault = "", + fault_counts = {}, + auto_cf = true, + type = VIRTUAL_DEVICE_TYPE, + device = {} + } + + if iface ~= "__virtual__" then + self.type = peripheral.getType(iface) + self.device = peripheral.wrap(iface) + end + + -- initialization process (re-map) + + for key, func in pairs(self.device) do + self.fault_counts[key] = 0 + self.device[key] = function (...) + local return_table = table.pack(pcall(func, ...)) + + local status = return_table[1] + table.remove(return_table, 1) + + if status then + -- auto fault clear + if self.auto_cf then self.faulted = false end + if ppm_sys.auto_cf then ppm_sys.faulted = false end + + self.fault_counts[key] = 0 + + return table.unpack(return_table) + else + local result = return_table[1] + + -- function failed + self.faulted = true + self.last_fault = result + + ppm_sys.faulted = true + ppm_sys.last_fault = result + + if not ppm_sys.mute and (self.fault_counts[key] % REPORT_FREQUENCY == 0) then + local count_str = "" + if self.fault_counts[key] > 0 then + count_str = " [" .. self.fault_counts[key] .. " total faults]" + end + + log.error(util.c("PPM: protected ", key, "() -> ", result, count_str)) + end + + self.fault_counts[key] = self.fault_counts[key] + 1 + + if result == "Terminated" then + ppm_sys.terminate = true + end + + return ACCESS_FAULT + end + end + end + + -- fault management functions + + local function clear_fault() self.faulted = false end + local function get_last_fault() return self.last_fault end + local function is_faulted() return self.faulted end + local function is_ok() return not self.faulted end + + local function enable_afc() self.auto_cf = true end + local function disable_afc() self.auto_cf = false end + + -- append to device functions + + self.device.__p_clear_fault = clear_fault + self.device.__p_last_fault = get_last_fault + self.device.__p_is_faulted = is_faulted + self.device.__p_is_ok = is_ok + self.device.__p_enable_afc = enable_afc + self.device.__p_disable_afc = disable_afc + + -- add default index function to catch undefined indicies + + local mt = { + __index = function (_, key) + -- this will continuously be counting calls here as faults + -- unlike other functions, faults here can't be cleared as it is just not defined + if self.fault_counts[key] == nil then + self.fault_counts[key] = 0 + end + + -- function failed + self.faulted = true + self.last_fault = UNDEFINED_FIELD + + ppm_sys.faulted = true + ppm_sys.last_fault = UNDEFINED_FIELD + + if not ppm_sys.mute and (self.fault_counts[key] % REPORT_FREQUENCY == 0) then + local count_str = "" + if self.fault_counts[key] > 0 then + count_str = " [" .. self.fault_counts[key] .. " total calls]" + end + + log.error(util.c("PPM: caught undefined function ", key, "()", count_str)) + end + + self.fault_counts[key] = self.fault_counts[key] + 1 + + return (function () return ACCESS_FAULT end) + end + } + + setmetatable(self.device, mt) + + return { + type = self.type, + dev = self.device + } +end + +---------------------- +-- PUBLIC FUNCTIONS -- +---------------------- + +-- REPORTING -- + +-- silence error prints +function ppm.disable_reporting() ppm_sys.mute = true end + +-- allow error prints +function ppm.enable_reporting() ppm_sys.mute = false end + +-- FAULT MEMORY -- + +-- enable automatically clearing fault flag +function ppm.enable_afc() ppm_sys.auto_cf = true end + +-- disable automatically clearing fault flag +function ppm.disable_afc() ppm_sys.auto_cf = false end + +-- clear fault flag +function ppm.clear_fault() ppm_sys.faulted = false end + +-- check fault flag +---@nodiscard +function ppm.is_faulted() return ppm_sys.faulted end + +-- get the last fault message +---@nodiscard +function ppm.get_last_fault() return ppm_sys.last_fault end + +-- TERMINATION -- + +-- if a caught error was a termination request +---@nodiscard +function ppm.should_terminate() return ppm_sys.terminate end + +-- MOUNTING -- + +-- mount all available peripherals (clears mounts first) +function ppm.mount_all() + local ifaces = peripheral.getNames() + + ppm_sys.mounts = {} + + for i = 1, #ifaces do + ppm_sys.mounts[ifaces[i]] = peri_init(ifaces[i]) + + log.info(util.c("PPM: found a ", ppm_sys.mounts[ifaces[i]].type, " (", ifaces[i], ")")) + end + + if #ifaces == 0 then + log.warning("PPM: mount_all() -> no devices found") + end +end + +-- mount a particular device +---@nodiscard +---@param iface string CC peripheral interface +---@return string|nil type, table|nil device +function ppm.mount(iface) + local ifaces = peripheral.getNames() + local pm_dev = nil + local pm_type = nil + + for i = 1, #ifaces do + if iface == ifaces[i] then + ppm_sys.mounts[iface] = peri_init(iface) + + pm_type = ppm_sys.mounts[iface].type + pm_dev = ppm_sys.mounts[iface].dev + + log.info(util.c("PPM: mount(", iface, ") -> found a ", pm_type)) + break + end + end + + return pm_type, pm_dev +end + +-- mount a virtual, placeholder device (specifically designed for RTU startup with missing devices) +---@nodiscard +---@return string type, table device +function ppm.mount_virtual() + local iface = "ppm_vdev_" .. ppm_sys.next_vid + + ppm_sys.mounts[iface] = peri_init("__virtual__") + ppm_sys.next_vid = ppm_sys.next_vid + 1 + + log.info(util.c("PPM: mount_virtual() -> allocated new virtual device ", iface)) + + return ppm_sys.mounts[iface].type, ppm_sys.mounts[iface].dev +end + +-- manually unmount a peripheral from the PPM +---@param device table device table +function ppm.unmount(device) + if device then + for side, data in pairs(ppm_sys.mounts) do + if data.dev == device then + log.warning(util.c("PPM: manually unmounted ", data.type, " mounted to ", side)) + ppm_sys.mounts[side] = nil + break + end + end + end +end + +-- handle peripheral_detach event +---@nodiscard +---@param iface string CC peripheral interface +---@return string|nil type, table|nil device +function ppm.handle_unmount(iface) + local pm_dev = nil + local pm_type = nil + + -- what got disconnected? + local lost_dev = ppm_sys.mounts[iface] + + if lost_dev then + pm_type = lost_dev.type + pm_dev = lost_dev.dev + + log.warning(util.c("PPM: lost device ", pm_type, " mounted to ", iface)) + else + log.error(util.c("PPM: lost device unknown to the PPM mounted to ", iface)) + end + + ppm_sys.mounts[iface] = nil + + return pm_type, pm_dev +end + +-- GENERAL ACCESSORS -- + +-- list all available peripherals +---@nodiscard +---@return table names +function ppm.list_avail() + return peripheral.getNames() +end + +-- list mounted peripherals +---@nodiscard +---@return table mounts +function ppm.list_mounts() + return ppm_sys.mounts +end + +-- get a mounted peripheral side/interface by device table +---@nodiscard +---@param device table device table +---@return string|nil iface CC peripheral interface +function ppm.get_iface(device) + if device then + for side, data in pairs(ppm_sys.mounts) do + if data.dev == device then return side end + end + end + + return nil +end + +-- get a mounted peripheral by side/interface +---@nodiscard +---@param iface string CC peripheral interface +---@return table|nil device function table +function ppm.get_periph(iface) + if ppm_sys.mounts[iface] then + return ppm_sys.mounts[iface].dev + else return nil end +end + +-- get a mounted peripheral type by side/interface +---@nodiscard +---@param iface string CC peripheral interface +---@return string|nil type +function ppm.get_type(iface) + if ppm_sys.mounts[iface] then + return ppm_sys.mounts[iface].type + else return nil end +end + +-- get all mounted peripherals by type +---@nodiscard +---@param name string type name +---@return table devices device function tables +function ppm.get_all_devices(name) + local devices = {} + + for _, data in pairs(ppm_sys.mounts) do + if data.type == name then + table.insert(devices, data.dev) + end + end + + return devices +end + +-- get a mounted peripheral by type (if multiple, returns the first) +---@nodiscard +---@param name string type name +---@return table|nil device function table +function ppm.get_device(name) + local device = nil + + for _, data in pairs(ppm_sys.mounts) do + if data.type == name then + device = data.dev + break + end + end + + return device +end + +-- SPECIFIC DEVICE ACCESSORS -- + +-- get the fission reactor (if multiple, returns the first) +---@nodiscard +---@return table|nil reactor function table +function ppm.get_fission_reactor() + return ppm.get_device("fissionReactorLogicAdapter") +end + +-- get the wireless modem (if multiple, returns the first)
+-- if this is in a CraftOS emulated environment, wired modems will be used instead +---@nodiscard +---@return table|nil modem function table +function ppm.get_wireless_modem() + local w_modem = nil + local emulated_env = periphemu ~= nil + + for _, device in pairs(ppm_sys.mounts) do + if device.type == "modem" and (emulated_env or device.dev.isWireless()) then + w_modem = device.dev + break + end + end + + return w_modem +end + +-- list all connected monitors +---@nodiscard +---@return table monitors +function ppm.get_monitor_list() + local list = {} + + for iface, device in pairs(ppm_sys.mounts) do + if device.type == "monitor" then + list[iface] = device + end + end + + return list +end + +return ppm diff --git a/scada-common/psil.lua b/scada-common/psil.lua new file mode 100644 index 0000000..c21b2cf --- /dev/null +++ b/scada-common/psil.lua @@ -0,0 +1,57 @@ +-- +-- Publisher-Subscriber Interconnect Layer +-- + +local psil = {} + +-- instantiate a new PSI layer +---@nodiscard +function psil.create() + local self = { + ic = {} + } + + -- allocate a new interconnect field + ---@key string data key + local function alloc(key) + self.ic[key] = { subscribers = {}, value = nil } + end + + ---@class psil + local public = {} + + -- subscribe to a data object in the interconnect
+ -- will call func() right away if a value is already avaliable + ---@param key string data key + ---@param func function function to call on change + function public.subscribe(key, func) + -- allocate new key if not found or notify if value is found + if self.ic[key] == nil then + alloc(key) + elseif self.ic[key].value ~= nil then + func(self.ic[key].value) + end + + -- subscribe to key + table.insert(self.ic[key].subscribers, { notify = func }) + end + + -- publish data to a given key, passing it to all subscribers if it has changed + ---@param key string data key + ---@param value any data value + function public.publish(key, value) + if self.ic[key] == nil then alloc(key) end + + if self.ic[key].value ~= value then + for i = 1, #self.ic[key].subscribers do + self.ic[key].subscribers[i].notify(value) + end + end + + self.ic[key].value = value + end + + return public +end + +return psil diff --git a/scada-common/rsio.lua b/scada-common/rsio.lua new file mode 100644 index 0000000..29acfd2 --- /dev/null +++ b/scada-common/rsio.lua @@ -0,0 +1,346 @@ +-- +-- Redstone I/O +-- + +local util = require("scada-common.util") + +---@class rsio +local rsio = {} + +---------------------- +-- RS I/O CONSTANTS -- +---------------------- + +---@enum IO_LVL I/O logic level +local IO_LVL = { + DISCONNECT = -1, -- use for RTU session to indicate this RTU is not connected to this port + LOW = 0, + HIGH = 1, + FLOATING = 2 -- use for RTU session to indicate this RTU is connected but not yet read +} + +---@enum IO_DIR I/O direction +local IO_DIR = { + IN = 0, + OUT = 1 +} + +---@enum IO_MODE I/O mode (digital/analog input/output) +local IO_MODE = { + DIGITAL_IN = 0, + DIGITAL_OUT = 1, + ANALOG_IN = 2, + ANALOG_OUT = 3 +} + +---@enum IO_PORT redstone I/O logic port +local IO_PORT = { + -- digital inputs -- + + -- facility + F_SCRAM = 1, -- active low, facility-wide scram + F_ACK = 2, -- active high, facility alarm acknowledge + + -- reactor + R_SCRAM = 3, -- active low, reactor scram + R_RESET = 4, -- active high, reactor RPS reset + R_ENABLE = 5, -- active high, reactor enable + + -- unit + U_ACK = 6, -- active high, unit alarm acknowledge + + -- digital outputs -- + + -- facility + F_ALARM = 7, -- active high, facility-wide alarm (any high priority unit alarm) + + -- waste + WASTE_PU = 8, -- active low, waste -> plutonium -> pellets route + WASTE_PO = 9, -- active low, waste -> polonium route + WASTE_POPL = 10, -- active low, polonium -> pellets route + WASTE_AM = 11, -- active low, polonium -> anti-matter route + + -- reactor + R_ACTIVE = 12, -- active high, if the reactor is active + R_AUTO_CTRL = 13, -- active high, if the reactor burn rate is automatic + R_SCRAMMED = 14, -- active high, if the reactor is scrammed + R_AUTO_SCRAM = 15, -- active high, if the reactor was automatically scrammed + R_HIGH_DMG = 16, -- active high, if the reactor damage is high + R_HIGH_TEMP = 17, -- active high, if the reactor is at a high temperature + R_LOW_COOLANT = 18, -- active high, if the reactor has very low coolant + R_EXCESS_HC = 19, -- active high, if the reactor has excess heated coolant + R_EXCESS_WS = 20, -- active high, if the reactor has excess waste + R_INSUFF_FUEL = 21, -- active high, if the reactor has insufficent fuel + R_PLC_FAULT = 22, -- active high, if the reactor PLC reports a device access fault + R_PLC_TIMEOUT = 23, -- active high, if the reactor PLC has not been heard from + + -- unit outputs + U_ALARM = 24, -- active high, unit alarm + U_EMER_COOL = 25 -- active low, emergency coolant control +} + +rsio.IO_LVL = IO_LVL +rsio.IO_DIR = IO_DIR +rsio.IO_MODE = IO_MODE +rsio.IO = IO_PORT + +----------------------- +-- UTILITY FUNCTIONS -- +----------------------- + +-- port to string +---@nodiscard +---@param port IO_PORT +function rsio.to_string(port) + local names = { + "F_SCRAM", + "F_ACK", + "R_SCRAM", + "R_RESET", + "R_ENABLE", + "U_ACK", + "F_ALARM", + "WASTE_PU", + "WASTE_PO", + "WASTE_POPL", + "WASTE_AM", + "R_ACTIVE", + "R_AUTO_CTRL", + "R_SCRAMMED", + "R_AUTO_SCRAM", + "R_HIGH_DMG", + "R_HIGH_TEMP", + "R_LOW_COOLANT", + "R_EXCESS_HC", + "R_EXCESS_WS", + "R_INSUFF_FUEL", + "R_PLC_FAULT", + "R_PLC_TIMEOUT", + "U_ALARM", + "U_EMER_COOL" + } + + if util.is_int(port) and port > 0 and port <= #names then + return names[port] + else + return "" + end +end + +local _B_AND = bit.band + +local function _I_ACTIVE_HIGH(level) return level == IO_LVL.HIGH end +local function _I_ACTIVE_LOW(level) return level == IO_LVL.LOW end +local function _O_ACTIVE_HIGH(active) if active then return IO_LVL.HIGH else return IO_LVL.LOW end end +local function _O_ACTIVE_LOW(active) if active then return IO_LVL.LOW else return IO_LVL.HIGH end end + +-- I/O mappings to I/O function and I/O mode +local RS_DIO_MAP = { + -- F_SCRAM + { _in = _I_ACTIVE_LOW, _out = _O_ACTIVE_LOW, mode = IO_DIR.IN }, + -- F_ACK + { _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.IN }, + + -- R_SCRAM + { _in = _I_ACTIVE_LOW, _out = _O_ACTIVE_LOW, mode = IO_DIR.IN }, + -- R_RESET + { _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.IN }, + -- R_ENABLE + { _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.IN }, + + -- U_ACK + { _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.IN }, + + -- F_ALARM + { _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.OUT }, + + -- WASTE_PU + { _in = _I_ACTIVE_LOW, _out = _O_ACTIVE_LOW, mode = IO_DIR.OUT }, + -- WASTE_PO + { _in = _I_ACTIVE_LOW, _out = _O_ACTIVE_LOW, mode = IO_DIR.OUT }, + -- WASTE_POPL + { _in = _I_ACTIVE_LOW, _out = _O_ACTIVE_LOW, mode = IO_DIR.OUT }, + -- WASTE_AM + { _in = _I_ACTIVE_LOW, _out = _O_ACTIVE_LOW, mode = IO_DIR.OUT }, + + -- R_ACTIVE + { _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.OUT }, + -- R_AUTO_CTRL + { _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.OUT }, + -- R_SCRAMMED + { _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.OUT }, + -- R_AUTO_SCRAM + { _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.OUT }, + -- R_HIGH_DMG + { _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.OUT }, + -- R_HIGH_TEMP + { _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.OUT }, + -- R_LOW_COOLANT + { _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.OUT }, + -- R_EXCESS_HC + { _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.OUT }, + -- R_EXCESS_WS + { _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.OUT }, + -- R_INSUFF_FUEL + { _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.OUT }, + -- R_PLC_FAULT + { _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.OUT }, + -- R_PLC_TIMEOUT + { _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.OUT }, + + -- U_ALARM + { _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.OUT }, + -- U_EMER_COOL + { _in = _I_ACTIVE_LOW, _out = _O_ACTIVE_LOW, mode = IO_DIR.OUT } +} + +-- get the mode of a port +---@nodiscard +---@param port IO_PORT +---@return IO_MODE +function rsio.get_io_mode(port) + local modes = { + IO_MODE.DIGITAL_IN, -- F_SCRAM + IO_MODE.DIGITAL_IN, -- F_ACK + IO_MODE.DIGITAL_IN, -- R_SCRAM + IO_MODE.DIGITAL_IN, -- R_RESET + IO_MODE.DIGITAL_IN, -- R_ENABLE + IO_MODE.DIGITAL_IN, -- U_ACK + IO_MODE.DIGITAL_OUT, -- F_ALARM + IO_MODE.DIGITAL_OUT, -- WASTE_PU + IO_MODE.DIGITAL_OUT, -- WASTE_PO + IO_MODE.DIGITAL_OUT, -- WASTE_POPL + IO_MODE.DIGITAL_OUT, -- WASTE_AM + IO_MODE.DIGITAL_OUT, -- R_ACTIVE + IO_MODE.DIGITAL_OUT, -- R_AUTO_CTRL + IO_MODE.DIGITAL_OUT, -- R_SCRAMMED + IO_MODE.DIGITAL_OUT, -- R_AUTO_SCRAM + IO_MODE.DIGITAL_OUT, -- R_HIGH_DMG + IO_MODE.DIGITAL_OUT, -- R_HIGH_TEMP + IO_MODE.DIGITAL_OUT, -- R_LOW_COOLANT + IO_MODE.DIGITAL_OUT, -- R_EXCESS_HC + IO_MODE.DIGITAL_OUT, -- R_EXCESS_WS + IO_MODE.DIGITAL_OUT, -- R_INSUFF_FUEL + IO_MODE.DIGITAL_OUT, -- R_PLC_FAULT + IO_MODE.DIGITAL_OUT, -- R_PLC_TIMEOUT + IO_MODE.DIGITAL_OUT, -- U_ALARM + IO_MODE.DIGITAL_OUT -- U_EMER_COOL + } + + if util.is_int(port) and port > 0 and port <= #modes then + return modes[port] + else + return IO_MODE.ANALOG_IN + end +end + +-------------------- +-- GENERIC CHECKS -- +-------------------- + +local RS_SIDES = rs.getSides() + +-- check if a port is valid +---@nodiscard +---@param port IO_PORT +---@return boolean valid +function rsio.is_valid_port(port) + return util.is_int(port) and (port > 0) and (port <= IO_PORT.U_EMER_COOL) +end + +-- check if a side is valid +---@nodiscard +---@param side string +---@return boolean valid +function rsio.is_valid_side(side) + if side ~= nil then + for i = 0, #RS_SIDES do + if RS_SIDES[i] == side then return true end + end + end + return false +end + +-- check if a color is a valid single color +---@nodiscard +---@param color integer +---@return boolean valid +function rsio.is_color(color) + return util.is_int(color) and (color > 0) and (_B_AND(color, (color - 1)) == 0) +end + +----------------- +-- DIGITAL I/O -- +----------------- + +-- get digital I/O level reading from a redstone boolean input value +---@nodiscard +---@param rs_value boolean raw value from redstone +---@return IO_LVL +function rsio.digital_read(rs_value) + if rs_value then return IO_LVL.HIGH else return IO_LVL.LOW end +end + +-- get redstone boolean output value corresponding to a digital I/O level +---@nodiscard +---@param level IO_LVL logic level +---@return boolean +function rsio.digital_write(level) + return level == IO_LVL.HIGH +end + +-- returns the level corresponding to active +---@nodiscard +---@param port IO_PORT port (to determine active high/low) +---@param active boolean state to convert to logic level +---@return IO_LVL|false +function rsio.digital_write_active(port, active) + if (not util.is_int(port)) or (port < IO_PORT.F_ALARM) or (port > IO_PORT.U_EMER_COOL) then + return false + else + return RS_DIO_MAP[port]._out(active) + end +end + +-- returns true if the level corresponds to active +---@nodiscard +---@param port IO_PORT port (to determine active low/high) +---@param level IO_LVL logic level +---@return boolean|nil state true for active, false for inactive, or nil if invalid port or level provided +function rsio.digital_is_active(port, level) + if not util.is_int(port) then + return nil + elseif level == IO_LVL.FLOATING or level == IO_LVL.DISCONNECT then + return nil + else + return RS_DIO_MAP[port]._in(level) + end +end + +---------------- +-- ANALOG I/O -- +---------------- + +-- read an analog value scaled from min to max +---@nodiscard +---@param rs_value number redstone reading (0 to 15) +---@param min number minimum of range +---@param max number maximum of range +---@return number value scaled reading (min to max) +function rsio.analog_read(rs_value, min, max) + local value = rs_value / 15 + return (value * (max - min)) + min +end + +-- write an analog value from the provided scale range +---@nodiscard +---@param value number value to write (from min to max range) +---@param min number minimum of range +---@param max number maximum of range +---@return number rs_value scaled redstone reading (0 to 15) +function rsio.analog_write(value, min, max) + local scaled_value = (value - min) / (max - min) + return math.floor(scaled_value * 15) +end + +return rsio diff --git a/scada-common/tcallbackdsp.lua b/scada-common/tcallbackdsp.lua new file mode 100644 index 0000000..3f8f07a --- /dev/null +++ b/scada-common/tcallbackdsp.lua @@ -0,0 +1,85 @@ +-- +-- Timer Callback Dispatcher +-- + +local log = require("scada-common.log") +local util = require("scada-common.util") + +local tcallbackdsp = {} + +local registry = {} + +-- request a function to be called after the specified time +---@param time number seconds +---@param f function callback function +function tcallbackdsp.dispatch(time, f) + local timer = util.start_timer(time) + registry[timer] = { + callback = f, + duration = time, + expiry = time + util.time_s() + } +end + +-- request a function to be called after the specified time, aborting any registered instances of that function reference +---@param time number seconds +---@param f function callback function +function tcallbackdsp.dispatch_unique(time, f) + -- cancel if already registered + for timer, entry in pairs(registry) do + if entry.callback == f then + -- found an instance of this function reference, abort it + log.debug(util.c("TCD: aborting duplicate timer callback [timer: ", timer, ", ", f, "]")) + + -- cancel event and remove from registry (even if it fires it won't call) + util.cancel_timer(timer) + registry[timer] = nil + end + end + + local timer = util.start_timer(time) + registry[timer] = { + callback = f, + duration = time, + expiry = time + util.time_s() + } +end + +-- abort a requested callback +---@param f function callback function +function tcallbackdsp.abort(f) + for timer, entry in pairs(registry) do + if entry.callback == f then + -- cancel event and remove from registry (even if it fires it won't call) + util.cancel_timer(timer) + registry[timer] = nil + end + end +end + +-- lookup a timer event and execute the callback if found +---@param event integer timer event timer ID +function tcallbackdsp.handle(event) + if registry[event] ~= nil then + local callback = registry[event].callback + -- clear first so that dispatch_unique call from inside callback won't throw a debug message + registry[event] = nil + callback() + end +end + +-- identify any overdo callbacks
+-- prints to log debug output +function tcallbackdsp.diagnostics() + for timer, entry in pairs(registry) do + if entry.expiry < util.time_s() then + local overtime = util.time_s() - entry.expiry + log.debug(util.c("TCD: unserviced timer ", timer, " for callback ", entry.callback, " is at least ", overtime, "s late")) + else + local time = entry.expiry - util.time_s() + log.debug(util.c("TCD: pending timer ", timer, " for callback ", entry.callback, " (call after ", entry.duration, "s, expires ", time, ")")) + end + end +end + +return tcallbackdsp diff --git a/scada-common/types.lua b/scada-common/types.lua new file mode 100644 index 0000000..9beb1e6 --- /dev/null +++ b/scada-common/types.lua @@ -0,0 +1,352 @@ +-- +-- Global Types +-- + +---@class types +local types = {} + +-- CLASSES -- + +---@class tank_fluid +---@field name fluid +---@field amount integer + +-- create a new tank fluid +---@nodiscard +---@param n string name +---@param a integer amount +---@return radiation_reading +function types.new_tank_fluid(n, a) return { name = n, amount = a } end + +-- create a new empty tank fluid +---@nodiscard +---@return tank_fluid +function types.new_empty_gas() return { type = "mekanism:empty_gas", amount = 0 } end + +---@class radiation_reading +---@field radiation number +---@field unit string + +-- create a new radiation reading +---@nodiscard +---@param r number radiaiton level +---@param u string radiation unit +---@return radiation_reading +function types.new_radiation_reading(r, u) return { radiation = r, unit = u } end + +-- create a new zeroed radiation reading +---@nodiscard +---@return radiation_reading +function types.new_zero_radiation_reading() return { radiation = 0, unit = "nSv" } end + +---@class coordinate +---@field x integer +---@field y integer +---@field z integer + +-- create a new coordinate +---@nodiscard +---@param x integer +---@param y integer +---@param z integer +---@return coordinate +function types.new_coordinate(x, y, z) return { x = x, y = y, z = z } end + +-- create a new zero coordinate +---@nodiscard +---@return coordinate +function types.new_zero_coordinate() return { x = 0, y = 0, z = 0 } end + +---@class rtu_advertisement +---@field type RTU_UNIT_TYPE +---@field index integer +---@field reactor integer +---@field rsio table|nil + +-- ALIASES -- + +---@alias color integer + +-- ENUMERATION TYPES -- +--#region + +---@enum RTU_UNIT_TYPE +types.RTU_UNIT_TYPE = { + VIRTUAL = 0, -- virtual device + REDSTONE = 1, -- redstone I/O + BOILER_VALVE = 2, -- boiler mekanism 10.1+ + TURBINE_VALVE = 3, -- turbine, mekanism 10.1+ + IMATRIX = 4, -- induction matrix + SPS = 5, -- SPS + SNA = 6, -- SNA + ENV_DETECTOR = 7 -- environment detector +} + +types.RTU_UNIT_NAMES = { + "redstone", + "boiler_valve", + "turbine_valve", + "induction_matrix", + "sps", + "sna", + "environment_detector" +} + +-- safe conversion of RTU UNIT TYPE to string +---@nodiscard +---@param utype RTU_UNIT_TYPE +---@return string +function types.rtu_type_to_string(utype) + if utype == types.RTU_UNIT_TYPE.VIRTUAL then + return "virtual" + elseif utype == types.RTU_UNIT_TYPE.REDSTONE or + utype == types.RTU_UNIT_TYPE.BOILER_VALVE or + utype == types.RTU_UNIT_TYPE.TURBINE_VALVE or + utype == types.RTU_UNIT_TYPE.IMATRIX or + utype == types.RTU_UNIT_TYPE.SPS or + utype == types.RTU_UNIT_TYPE.SNA or + utype == types.RTU_UNIT_TYPE.ENV_DETECTOR then + return types.RTU_UNIT_NAMES[utype] + else + return "" + end +end + +---@enum TRI_FAIL +types.TRI_FAIL = { + OK = 1, + PARTIAL = 2, + FULL = 3 +} + +---@enum PROCESS +types.PROCESS = { + INACTIVE = 0, + MAX_BURN = 1, + BURN_RATE = 2, + CHARGE = 3, + GEN_RATE = 4, + MATRIX_FAULT_IDLE = 5, + SYSTEM_ALARM_IDLE = 6, + GEN_RATE_FAULT_IDLE = 7 +} + +types.PROCESS_NAMES = { + "INACTIVE", + "MAX_BURN", + "BURN_RATE", + "CHARGE", + "GEN_RATE", + "MATRIX_FAULT_IDLE", + "SYSTEM_ALARM_IDLE", + "GEN_RATE_FAULT_IDLE" +} + +---@enum WASTE_MODE +types.WASTE_MODE = { + AUTO = 1, + PLUTONIUM = 2, + POLONIUM = 3, + ANTI_MATTER = 4 +} + +types.WASTE_MODE_NAMES = { + "AUTO", + "PLUTONIUM", + "POLONIUM", + "ANTI_MATTER" +} + +---@enum ALARM +types.ALARM = { + ContainmentBreach = 1, + ContainmentRadiation = 2, + ReactorLost = 3, + CriticalDamage = 4, + ReactorDamage = 5, + ReactorOverTemp = 6, + ReactorHighTemp = 7, + ReactorWasteLeak = 8, + ReactorHighWaste = 9, + RPSTransient = 10, + RCSTransient = 11, + TurbineTrip = 12 +} + +types.ALARM_NAMES = { + "ContainmentBreach", + "ContainmentRadiation", + "ReactorLost", + "CriticalDamage", + "ReactorDamage", + "ReactorOverTemp", + "ReactorHighTemp", + "ReactorWasteLeak", + "ReactorHighWaste", + "RPSTransient", + "RCSTransient", + "TurbineTrip" +} + +---@enum ALARM_PRIORITY +types.ALARM_PRIORITY = { + CRITICAL = 1, + EMERGENCY = 2, + URGENT = 3, + TIMELY = 4 +} + +types.ALARM_PRIORITY_NAMES = { + "CRITICAL", + "EMERGENCY", + "URGENT", + "TIMELY" +} + +---@enum ALARM_STATE +types.ALARM_STATE = { + INACTIVE = 1, + TRIPPED = 2, + ACKED = 3, + RING_BACK = 4 +} + +types.ALARM_STATE_NAMES = { + "INACTIVE", + "TRIPPED", + "ACKED", + "RING_BACK" +} + +--#endregion + +-- STRING TYPES -- +--#region + +---@alias os_event +---| "alarm" +---| "char" +---| "computer_command" +---| "disk" +---| "disk_eject" +---| "http_check" +---| "http_failure" +---| "http_success" +---| "key" +---| "key_up" +---| "modem_message" +---| "monitor_resize" +---| "monitor_touch" +---| "mouse_click" +---| "mouse_drag" +---| "mouse_scroll" +---| "mouse_up" +---| "paste" +---| "peripheral" +---| "peripheral_detach" +---| "rednet_message" +---| "redstone" +---| "speaker_audio_empty" +---| "task_complete" +---| "term_resize" +---| "terminate" +---| "timer" +---| "turtle_inventory" +---| "websocket_closed" +---| "websocket_failure" +---| "websocket_message" +---| "websocket_success" +---| "clock_start" custom, added for reactor PLC + +---@alias fluid +---| "mekanism:empty_gas" +---| "minecraft:water" +---| "mekanism:sodium" +---| "mekanism:superheated_sodium" + +types.FLUID = { + EMPTY_GAS = "mekanism:empty_gas", + WATER = "minecraft:water", + SODIUM = "mekanism:sodium", + SUPERHEATED_SODIUM = "mekanism:superheated_sodium" +} + +---@alias rps_trip_cause +---| "ok" +---| "high_dmg" +---| "high_temp" +---| "low_coolant" +---| "ex_waste" +---| "ex_heated_coolant" +---| "no_fuel" +---| "fault" +---| "timeout" +---| "manual" +---| "automatic" +---| "sys_fail" +---| "force_disabled" + +types.RPS_TRIP_CAUSE = { + OK = "ok", + HIGH_DMG = "high_dmg", + HIGH_TEMP = "high_temp", + LOW_COOLANT = "low_coolant", + EX_WASTE = "ex_waste", + EX_HCOOLANT = "ex_heated_coolant", + NO_FUEL = "no_fuel", + FAULT = "fault", + TIMEOUT = "timeout", + MANUAL = "manual", + AUTOMATIC = "automatic", + SYS_FAIL = "sys_fail", + FORCE_DISABLED = "force_disabled" +} + +---@alias dumping_mode +---| "IDLE" +---| "DUMPING" +---| "DUMPING_EXCESS" + +types.DUMPING_MODE = { + IDLE = "IDLE", + DUMPING = "DUMPING", + DUMPING_EXCESS = "DUMPING_EXCESS" +} + +--#endregion + +-- MODBUS -- +--#region + +-- MODBUS function codes +---@enum MODBUS_FCODE +types.MODBUS_FCODE = { + READ_COILS = 0x01, + READ_DISCRETE_INPUTS = 0x02, + READ_MUL_HOLD_REGS = 0x03, + READ_INPUT_REGS = 0x04, + WRITE_SINGLE_COIL = 0x05, + WRITE_SINGLE_HOLD_REG = 0x06, + WRITE_MUL_COILS = 0x0F, + WRITE_MUL_HOLD_REGS = 0x10, + ERROR_FLAG = 0x80 +} + +-- MODBUS exception codes +---@enum MODBUS_EXCODE +types.MODBUS_EXCODE = { + ILLEGAL_FUNCTION = 0x01, + ILLEGAL_DATA_ADDR = 0x02, + ILLEGAL_DATA_VALUE = 0x03, + SERVER_DEVICE_FAIL = 0x04, + ACKNOWLEDGE = 0x05, + SERVER_DEVICE_BUSY = 0x06, + NEG_ACKNOWLEDGE = 0x07, + MEMORY_PARITY_ERROR = 0x08, + GATEWAY_PATH_UNAVAILABLE = 0x0A, + GATEWAY_TARGET_TIMEOUT = 0x0B +} + +--#endregion + +return types diff --git a/scada-common/util.lua b/scada-common/util.lua new file mode 100644 index 0000000..2913e9f --- /dev/null +++ b/scada-common/util.lua @@ -0,0 +1,586 @@ +-- +-- Utility Functions +-- + +---@class util +local util = {} + +-- ENVIRONMENT CONSTANTS -- + +util.TICK_TIME_S = 0.05 +util.TICK_TIME_MS = 50 + +-- OPERATORS -- +--#region + +-- trinary operator +---@nodiscard +---@param cond boolean|nil condition +---@param a any return if true +---@param b any return if false +---@return any value +function util.trinary(cond, a, b) + if cond then return a else return b end +end + +--#endregion + +-- PRINT -- +--#region + +-- print +---@param message any +function util.print(message) + term.write(tostring(message)) +end + +-- print line +---@param message any +function util.println(message) + print(tostring(message)) +end + +-- timestamped print +---@param message any +function util.print_ts(message) + term.write(os.date("[%H:%M:%S] ") .. tostring(message)) +end + +-- timestamped print line +---@param message any +function util.println_ts(message) + print(os.date("[%H:%M:%S] ") .. tostring(message)) +end + +--#endregion + +-- STRING TOOLS -- +--#region + +-- get a value as a string +---@nodiscard +---@param val any +---@return string +function util.strval(val) + local t = type(val) + if t == "table" or t == "function" then + return "[" .. tostring(val) .. "]" + else + return tostring(val) + end +end + +-- repeat a string n times +---@nodiscard +---@param str string +---@param n integer +---@return string +function util.strrep(str, n) + local repeated = "" + for _ = 1, n do + repeated = repeated .. str + end + return repeated +end + +-- repeat a space n times +---@nodiscard +---@param n integer +---@return string +function util.spaces(n) + return util.strrep(" ", n) +end + +-- pad text to a minimum width +---@nodiscard +---@param str string text +---@param n integer minimum width +---@return string +function util.pad(str, n) + local len = string.len(str) + local lpad = math.floor((n - len) / 2) + local rpad = (n - len) - lpad + + return util.spaces(lpad) .. str .. util.spaces(rpad) +end + +-- wrap a string into a table of lines, supporting single dash splits +---@nodiscard +---@param str string +---@param limit integer line limit +---@return table lines +function util.strwrap(str, limit) + local lines = {} + local ln_start = 1 + + local first_break = str:find("([%-%s]+)") + + if first_break ~= nil then + lines[1] = string.sub(str, 1, first_break - 1) + else + lines[1] = str + end + +---@diagnostic disable-next-line: discard-returns + str:gsub("(%s+)()(%S+)()", + function(space, start, word, stop) + -- support splitting SINGLE DASH words + word:gsub("(%S+)(%-)()(%S+)()", + function (pre, dash, d_start, post, d_stop) + if (stop + d_stop) - ln_start <= limit then + -- do nothing, it will entirely fit + elseif ((start + d_start) + 1) - ln_start <= limit then + -- we can fit including the dash + lines[#lines] = lines[#lines] .. space .. pre .. dash + -- drop the space and replace the word with the post + space = "" + word = post + -- force a wrap + stop = limit + 1 + ln_start + -- change start position for new line start + start = start + d_start - 1 + end + end) + -- can we append this or do we have to start a new line? + if stop - ln_start > limit then + -- starting new line + ln_start = start + lines[#lines + 1] = word + else lines[#lines] = lines[#lines] .. space .. word end + end) + + return lines +end + +-- concatenation with built-in to string +---@nodiscard +---@vararg any +---@return string +function util.concat(...) + local str = "" + for _, v in ipairs(arg) do str = str .. util.strval(v) end + return str +end + +-- alias +util.c = util.concat + +-- sprintf implementation +---@nodiscard +---@param format string +---@vararg any +function util.sprintf(format, ...) + return string.format(format, table.unpack(arg)) +end + +-- format a number string with commas as the thousands separator
+-- subtracts from spaces at the start if present for each comma used +---@nodiscard +---@param num string number string +---@return string +function util.comma_format(num) + local formatted = num + local commas = 0 + local i = 1 + + while i > 0 do + formatted, i = formatted:gsub("^(%s-%d+)(%d%d%d)", '%1,%2') + if i > 0 then commas = commas + 1 end + end + + local _, num_spaces = formatted:gsub(" %s-", "") + local remove = math.min(num_spaces, commas) + + formatted = string.sub(formatted, remove + 1) + + return formatted +end + +--#endregion + +-- MATH -- +--#region + +-- is a value an integer +---@nodiscard +---@param x any value +---@return boolean is_integer if the number is an integer +function util.is_int(x) + return type(x) == "number" and x == math.floor(x) +end + +-- get the sign of a number +---@nodiscard +---@param x number value +---@return integer sign (-1 for < 0, 1 otherwise) +function util.sign(x) + return util.trinary(x < 0, -1, 1) +end + +-- round a number to an integer +---@nodiscard +---@return integer rounded +function util.round(x) + return math.floor(x + 0.5) +end + +-- get a new moving average object +---@nodiscard +---@param length integer history length +---@param default number value to fill history with for first call to compute() +function util.mov_avg(length, default) + local data = {} + local index = 1 + local last_t = 0 ---@type number|nil + + ---@class moving_average + local public = {} + + -- reset all to a given value + ---@param x number value + function public.reset(x) + data = {} + for _ = 1, length do table.insert(data, x) end + end + + -- record a new value + ---@param x number new value + ---@param t number? optional last update time to prevent duplicated entries + function public.record(x, t) + if type(t) == "number" and last_t == t then + return + end + + data[index] = x + last_t = t + + index = index + 1 + if index > length then index = 1 end + end + + -- compute the moving average + ---@nodiscard + ---@return number average + function public.compute() + local sum = 0 + for i = 1, length do sum = sum + data[i] end + return sum / length + end + + public.reset(default) + + return public +end + +-- TIME -- + +-- current time +---@nodiscard +---@return integer milliseconds +function util.time_ms() +---@diagnostic disable-next-line: undefined-field + return os.epoch('local') +end + +-- current time +---@nodiscard +---@return number seconds +function util.time_s() +---@diagnostic disable-next-line: undefined-field + return os.epoch('local') / 1000.0 +end + +-- current time +---@nodiscard +---@return integer milliseconds +function util.time() return util.time_ms() end + +--#endregion + +-- OS -- +--#region + +-- OS pull event raw wrapper with types +---@nodiscard +---@param target_event? string event to wait for +---@return os_event event, any param1, any param2, any param3, any param4, any param5 +function util.pull_event(target_event) +---@diagnostic disable-next-line: undefined-field + return os.pullEventRaw(target_event) +end + +-- OS queue event raw wrapper with types +---@param event os_event +---@param param1 any +---@param param2 any +---@param param3 any +---@param param4 any +---@param param5 any +function util.push_event(event, param1, param2, param3, param4, param5) +---@diagnostic disable-next-line: undefined-field + return os.queueEvent(event, param1, param2, param3, param4, param5) +end + +-- start an OS timer +---@nodiscard +---@param t number timer duration in seconds +---@return integer timer ID +function util.start_timer(t) +---@diagnostic disable-next-line: undefined-field + return os.startTimer(t) +end + +-- cancel an OS timer +---@param timer integer timer ID +function util.cancel_timer(timer) +---@diagnostic disable-next-line: undefined-field + os.cancelTimer(timer) +end + +--#endregion + +-- PARALLELIZATION -- +--#region + +-- protected sleep call so we still are in charge of catching termination +---@param t integer seconds +--- EVENT_CONSUMER: this function consumes events +function util.psleep(t) +---@diagnostic disable-next-line: undefined-field + pcall(os.sleep, t) +end + +-- no-op to provide a brief pause (1 tick) to yield
+--- EVENT_CONSUMER: this function consumes events +function util.nop() util.psleep(0.05) end + +-- attempt to maintain a minimum loop timing (duration of execution) +---@nodiscard +---@param target_timing integer minimum amount of milliseconds to wait for +---@param last_update integer millisecond time of last update +---@return integer time_now +--- EVENT_CONSUMER: this function consumes events +function util.adaptive_delay(target_timing, last_update) + local sleep_for = target_timing - (util.time() - last_update) + -- only if >50ms since worker loops already yield 0.05s + if sleep_for >= 50 then util.psleep(sleep_for / 1000.0) end + return util.time() +end + +--#endregion + +-- TABLE UTILITIES -- +--#region + +-- delete elements from a table if the passed function returns false when passed a table element
+-- put briefly: deletes elements that return false, keeps elements that return true +---@param t table table to remove elements from +---@param f function should return false to delete an element when passed the element: f(elem) = true|false +---@param on_delete? function optional function to execute on deletion, passed the table element to be deleted as the parameter +function util.filter_table(t, f, on_delete) + local move_to = 1 + for i = 1, #t do + local element = t[i] + if element ~= nil then + if f(element) then + if t[move_to] == nil then + t[move_to] = element + t[i] = nil + end + move_to = move_to + 1 + else + if on_delete then on_delete(element) end + t[i] = nil + end + end + end +end + +-- check if a table contains the provided element +---@nodiscard +---@param t table table to check +---@param element any element to check for +function util.table_contains(t, element) + for i = 1, #t do + if t[i] == element then return true end + end + + return false +end + +--#endregion + +-- MEKANISM POWER -- +--#region + +-- convert Joules to FE +---@nodiscard +---@param J number Joules +---@return number FE Forge Energy +function util.joules_to_fe(J) return (J * 0.4) end + +-- convert FE to Joules +---@nodiscard +---@param FE number Forge Energy +---@return number J Joules +function util.fe_to_joules(FE) return (FE * 2.5) end + +local function kFE(fe) return fe / 1000.0 end +local function MFE(fe) return fe / 1000000.0 end +local function GFE(fe) return fe / 1000000000.0 end +local function TFE(fe) return fe / 1000000000000.0 end +local function PFE(fe) return fe / 1000000000000000.0 end +local function EFE(fe) return fe / 1000000000000000000.0 end -- if you accomplish this please touch grass +local function ZFE(fe) return fe / 1000000000000000000000.0 end -- please stop + +-- format a power value into XXX.XX UNIT format (FE, kFE, MFE, GFE, TFE, PFE, EFE, ZFE) +---@nodiscard +---@param fe number forge energy value +---@param combine_label? boolean if a label should be included in the string itself +---@param format? string format override +---@return string str, string? unit +function util.power_format(fe, combine_label, format) + local unit + local value + + if type(format) ~= "string" then format = "%.2f" end + + if fe < 1000.0 then + unit = "FE" + value = fe + elseif fe < 1000000.0 then + unit = "kFE" + value = kFE(fe) + elseif fe < 1000000000.0 then + unit = "MFE" + value = MFE(fe) + elseif fe < 1000000000000.0 then + unit = "GFE" + value = GFE(fe) + elseif fe < 1000000000000000.0 then + unit = "TFE" + value = TFE(fe) + elseif fe < 1000000000000000000.0 then + unit = "PFE" + value = PFE(fe) + elseif fe < 1000000000000000000000.0 then + unit = "EFE" + value = EFE(fe) + else + unit = "ZFE" + value = ZFE(fe) + end + + if combine_label then + return util.sprintf(util.c(format, " %s"), value, unit) + else + return util.sprintf(format, value), unit + end +end + +--#endregion + +-- UTILITY CLASSES -- +--#region + +-- WATCHDOG -- + +-- OS timer based watchdog
+-- triggers a timer event if not fed within 'timeout' seconds +---@nodiscard +---@param timeout number timeout duration +function util.new_watchdog(timeout) + local self = { + timeout = timeout, + wd_timer = util.start_timer(timeout) + } + + ---@class watchdog + local public = {} + + -- check if a timer is this watchdog + ---@nodiscard + ---@param timer number timer event timer ID + function public.is_timer(timer) return self.wd_timer == timer end + + -- satiate the beast + function public.feed() + if self.wd_timer ~= nil then + util.cancel_timer(self.wd_timer) + end + self.wd_timer = util.start_timer(self.timeout) + end + + -- cancel the watchdog + function public.cancel() + if self.wd_timer ~= nil then + util.cancel_timer(self.wd_timer) + end + end + + return public +end + +-- LOOP CLOCK -- + +-- OS timer based loop clock
+-- fires a timer event at the specified period, does not start at construct time +---@nodiscard +---@param period number clock period +function util.new_clock(period) + local self = { + period = period, + timer = nil + } + + ---@class clock + local public = {} + + -- check if a timer is this clock + ---@nodiscard + ---@param timer number timer event timer ID + function public.is_clock(timer) return self.timer == timer end + + -- start the clock + function public.start() self.timer = util.start_timer(self.period) end + + return public +end + +-- FIELD VALIDATOR -- + +-- create a new type validator
+-- can execute sequential checks and check valid() to see if it is still valid +---@nodiscard +function util.new_validator() + local valid = true + + ---@class validator + local public = {} + + function public.assert_type_bool(value) valid = valid and type(value) == "boolean" end + function public.assert_type_num(value) valid = valid and type(value) == "number" end + function public.assert_type_int(value) valid = valid and util.is_int(value) end + function public.assert_type_str(value) valid = valid and type(value) == "string" end + function public.assert_type_table(value) valid = valid and type(value) == "table" end + + function public.assert_eq(check, expect) valid = valid and check == expect end + function public.assert_min(check, min) valid = valid and check >= min end + function public.assert_min_ex(check, min) valid = valid and check > min end + function public.assert_max(check, max) valid = valid and check <= max end + function public.assert_max_ex(check, max) valid = valid and check < max end + function public.assert_range(check, min, max) valid = valid and check >= min and check <= max end + function public.assert_range_ex(check, min, max) valid = valid and check > min and check < max end + + function public.assert_port(port) valid = valid and type(port) == "number" and port >= 0 and port <= 65535 end + + -- check if all assertions passed successfully + ---@nodiscard + function public.valid() return valid end + + return public +end + +--#endregion + +return util diff --git a/signal-router.lua b/signal-router.lua deleted file mode 100644 index 37a7610..0000000 --- a/signal-router.lua +++ /dev/null @@ -1,159 +0,0 @@ --- reactor signal router --- transmits status information and controls enable state - --- bundeled redstone key --- top: --- black (in): insufficent fuel --- brown (in): excess waste --- orange (in): overheat --- red (in): damage critical --- right: --- cyan (out): plutonium/plutonium pellet pipe --- green (out): polonium pipe --- magenta (out): polonium pellet pipe --- purple (out): antimatter pipe --- white (out): reactor enable - --- constants -REACTOR_ID = 1 -DEST_PORT = 1000 - -local state = { - id = REACTOR_ID, - run = false, - no_fuel = false, - full_waste = false, - high_temp = false, - damage_crit = false -} - -local waste_production = "antimatter" - -local listen_port = 1000 + REACTOR_ID -local modem = peripheral.wrap("left") - -print("Reactor Signal Router v1.0") -print("Configured for Reactor #" .. REACTOR_ID) - -if not modem.isOpen(listen_port) then - modem.open(listen_port) -end - --- greeting -modem.transmit(DEST_PORT, listen_port, REACTOR_ID) - --- queue event to read initial state and make sure reactor starts off -os.queueEvent("redstone") -rs.setBundledOutput("right", colors.white) -rs.setBundledOutput("right", 0) -re_eval_output = true - -local connection_timeout = os.startTimer(3) - --- event loop -while true do - local event, param1, param2, param3, param4, param5 = os.pullEvent() - - if event == "redstone" then - -- redstone state change - input = rs.getBundledInput("top") - - if state.no_fuel ~= colors.test(input, colors.black) then - state.no_fuel = colors.test(input, colors.black) - if state.no_fuel then - print("insufficient fuel") - end - end - - if state.full_waste ~= colors.test(input, colors.brown) then - state.full_waste = colors.test(input, colors.brown) - if state.full_waste then - print("waste tank full") - end - end - - if state.high_temp ~= colors.test(input, colors.orange) then - state.high_temp = colors.test(input, colors.orange) - if state.high_temp then - print("high temperature") - end - end - - if state.damage_crit ~= colors.test(input, colors.red) then - state.damage_crit = colors.test(input, colors.red) - if state.damage_crit then - print("damage critical") - end - end - elseif event == "modem_message" then - -- got data, reset timer - if connection_timeout ~= nil then - os.cancelTimer(connection_timeout) - end - connection_timeout = os.startTimer(3) - - if type(param4) == "number" and param4 == 0 then - print("[info] controller server startup detected") - modem.transmit(DEST_PORT, listen_port, REACTOR_ID) - elseif type(param4) == "number" and param4 == 1 then - -- keep-alive, do nothing, just had to reset timer - elseif type(param4) == "boolean" then - state.run = param4 - - if state.run then - print("[alert] reactor enabled") - else - print("[alert] reactor disabled") - end - - re_eval_output = true - elseif type(param4) == "string" then - if param4 == "plutonium" then - print("[alert] switching to plutonium production") - waste_production = param4 - re_eval_output = true - elseif param4 == "polonium" then - print("[alert] switching to polonium production") - waste_production = param4 - re_eval_output = true - elseif param4 == "antimatter" then - print("[alert] switching to antimatter production") - waste_production = param4 - re_eval_output = true - end - else - print("[error] got unknown packet (" .. param4 .. ")") - end - elseif event == "timer" and param1 == connection_timeout then - -- haven't heard from server in 3 seconds? shutdown - -- timer won't be restarted until next packet, so no need to do anything with it - print("[alert] server timeout, reactor disabled") - state.run = false - re_eval_output = true - end - - -- check for control state changes - if re_eval_output then - re_eval_output = false - - local run_color = 0 - if state.run then - run_color = colors.white - end - - -- values are swapped, as on disables and off enables - local waste_color - if waste_production == "plutonium" then - waste_color = colors.green - elseif waste_production == "polonium" then - waste_color = colors.cyan + colors.purple - else - -- antimatter (default) - waste_color = colors.cyan + colors.magenta - end - - rs.setBundledOutput("right", run_color + waste_color) - end - - modem.transmit(DEST_PORT, listen_port, state) -end diff --git a/startup.lua b/startup.lua new file mode 100644 index 0000000..482c919 --- /dev/null +++ b/startup.lua @@ -0,0 +1,50 @@ +local util = require("scada-common.util") + +local BOOTLOADER_VERSION = "0.2" + +local println = util.println +local println_ts = util.println_ts + +println("SCADA BOOTLOADER V" .. BOOTLOADER_VERSION) + +local exit_code = false + +println_ts("BOOT> SCANNING FOR APPLICATIONS...") + +if fs.exists("reactor-plc/startup.lua") then + -- found reactor-plc application + println("BOOT> FOUND REACTOR PLC APPLICATION") + println("BOOT> EXEC STARTUP") + exit_code = shell.execute("reactor-plc/startup") +elseif fs.exists("rtu/startup.lua") then + -- found rtu application + println("BOOT> FOUND RTU APPLICATION") + println("BOOT> EXEC STARTUP") + exit_code = shell.execute("rtu/startup") +elseif fs.exists("supervisor/startup.lua") then + -- found supervisor application + println("BOOT> FOUND SUPERVISOR APPLICATION") + println("BOOT> EXEC STARTUP") + exit_code = shell.execute("supervisor/startup") +elseif fs.exists("coordinator/startup.lua") then + -- found coordinator application + println("BOOT> FOUND COORDINATOR APPLICATION") + println("BOOT> EXEC STARTUP") + exit_code = shell.execute("coordinator/startup") +elseif fs.exists("pocket/startup.lua") then + -- found pocket application + println("BOOT> FOUND POCKET APPLICATION") + println("BOOT> EXEC STARTUP") + exit_code = shell.execute("pocket/startup") +else + -- no known applications found + println("BOOT> NO SCADA STARTUP APPLICATION FOUND") + println("BOOT> EXIT") + return false +end + +if not exit_code then + println_ts("BOOT> APPLICATION CRASHED") +end + +return exit_code diff --git a/supervisor/config.lua b/supervisor/config.lua new file mode 100644 index 0000000..47d530d --- /dev/null +++ b/supervisor/config.lua @@ -0,0 +1,31 @@ +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 +-- 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 + +-- expected number of reactors +config.NUM_REACTORS = 4 +-- expected number of boilers/turbines for each reactor +config.REACTOR_COOLING = { + { BOILERS = 1, TURBINES = 1 }, -- reactor unit 1 + { BOILERS = 1, TURBINES = 1 }, -- reactor unit 2 + { BOILERS = 1, TURBINES = 1 }, -- reactor unit 3 + { BOILERS = 1, TURBINES = 1 } -- reactor unit 4 +} + +-- 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/supervisor/facility.lua b/supervisor/facility.lua new file mode 100644 index 0000000..f37de26 --- /dev/null +++ b/supervisor/facility.lua @@ -0,0 +1,897 @@ +local const = require("scada-common.constants") +local log = require("scada-common.log") +local rsio = require("scada-common.rsio") +local types = require("scada-common.types") +local util = require("scada-common.util") + +local unit = require("supervisor.unit") + +local rsctl = require("supervisor.session.rsctl") + +local PROCESS = types.PROCESS +local PROCESS_NAMES = types.PROCESS_NAMES +local PRIO = types.ALARM_PRIORITY + +local IO = rsio.IO + +-- 7.14 kJ per blade for 1 mB of fissile fuel
+-- 2856 FE per blade per 1 mB, 285.6 FE per blade per 0.1 mB (minimum) +local POWER_PER_BLADE = util.joules_to_fe(7140) + +local FLOW_STABILITY_DELAY_S = const.FLOW_STABILITY_DELAY_MS / 1000 + +local ALARM_LIMS = const.ALARM_LIMITS + +local AUTO_SCRAM = { + NONE = 0, + MATRIX_DC = 1, + MATRIX_FILL = 2, + CRIT_ALARM = 3, + RADIATION = 4, + GEN_FAULT = 5 +} + +local START_STATUS = { + OK = 0, + NO_UNITS = 1, + BLADE_MISMATCH = 2 +} + +local charge_Kp = 0.275 +local charge_Ki = 0.0 +local charge_Kd = 4.5 + +local rate_Kp = 2.45 +local rate_Ki = 0.4825 +local rate_Kd = -1.0 + +---@class facility_management +local facility = {} + +-- create a new facility management object +---@nodiscard +---@param num_reactors integer number of reactor units +---@param cooling_conf table cooling configurations of reactor units +function facility.new(num_reactors, cooling_conf) + local self = { + units = {}, + status_text = { "START UP", "initializing..." }, + all_sys_ok = false, + -- rtus + rtu_conn_count = 0, + redstone = {}, + induction = {}, + envd = {}, + -- redstone I/O control + io_ctl = nil, ---@type rs_controller + -- process control + units_ready = false, + mode = PROCESS.INACTIVE, + last_mode = PROCESS.INACTIVE, + return_mode = PROCESS.INACTIVE, + mode_set = PROCESS.MAX_BURN, + start_fail = START_STATUS.OK, + max_burn_combined = 0.0, -- maximum burn rate to clamp at + burn_target = 0.1, -- burn rate target for aggregate burn mode + charge_setpoint = 0, -- FE charge target setpoint + gen_rate_setpoint = 0, -- FE/t charge rate target setpoint + group_map = { 0, 0, 0, 0 }, -- units -> group IDs + prio_defs = { {}, {}, {}, {} }, -- priority definitions (each level is a table of units) + at_max_burn = false, + ascram = false, + ascram_reason = AUTO_SCRAM.NONE, + ---@class ascram_status + ascram_status = { + matrix_dc = false, + matrix_fill = false, + crit_alarm = false, + radiation = false, + gen_fault = false + }, + -- closed loop control + charge_conversion = 1.0, + time_start = 0.0, + initial_ramp = true, + waiting_on_ramp = false, + waiting_on_stable = false, + accumulator = 0.0, + saturated = false, + last_update = 0, + last_error = 0.0, + last_time = 0.0, + -- statistics + im_stat_init = false, + avg_charge = util.mov_avg(3, 0.0), + avg_inflow = util.mov_avg(6, 0.0), + avg_outflow = util.mov_avg(6, 0.0) + } + + -- create units + for i = 1, num_reactors do + table.insert(self.units, unit.new(i, cooling_conf[i].BOILERS, cooling_conf[i].TURBINES)) + end + + -- init redstone RTU I/O controller + self.io_ctl = rsctl.new(self.redstone) + + -- unlink disconnected units + ---@param sessions table + local function _unlink_disconnected_units(sessions) + util.filter_table(sessions, function (u) return u.is_connected() end) + end + + -- check if all auto-controlled units completed ramping + ---@nodiscard + local function _all_units_ramped() + local all_ramped = true + + for i = 1, #self.prio_defs do + local units = self.prio_defs[i] + for u = 1, #units do + all_ramped = all_ramped and units[u].a_ramp_complete() + end + end + + return all_ramped + end + + -- split a burn rate among the reactors + ---@param burn_rate number burn rate assignment + ---@param ramp boolean true to ramp, false to set right away + ---@param abort_on_fault boolean? true to exit if one device has an effective burn rate different than its limit + ---@return integer unallocated_br100, boolean? aborted + local function _allocate_burn_rate(burn_rate, ramp, abort_on_fault) + local unallocated = math.floor(burn_rate * 100) + + -- go through all priority groups + for i = 1, #self.prio_defs do + local units = self.prio_defs[i] + + if #units > 0 then + local split = math.floor(unallocated / #units) + + local splits = {} + for u = 1, #units do splits[u] = split end + splits[#units] = splits[#units] + (unallocated % #units) + + -- go through all reactor units in this group + for id = 1, #units do + local u = units[id] ---@type reactor_unit + + local ctl = u.get_control_inf() + local lim_br100 = u.a_get_effective_limit() + + if abort_on_fault and (lim_br100 ~= ctl.lim_br100) then + -- effective limit differs from set limit, unit is degraded + return unallocated, true + end + + local last = ctl.br100 + + if splits[id] <= lim_br100 then + ctl.br100 = splits[id] + else + ctl.br100 = lim_br100 + + if id < #units then + local remaining = #units - id + split = math.floor(unallocated / remaining) + for x = (id + 1), #units do splits[x] = split end + splits[#units] = splits[#units] + (unallocated % remaining) + end + end + + unallocated = math.max(0, unallocated - ctl.br100) + + if last ~= ctl.br100 then u.a_commit_br100(ramp) end + end + end + end + + return unallocated, false + end + + -- PUBLIC FUNCTIONS -- + + ---@class facility + local public = {} + + -- ADD/LINK DEVICES -- + + -- link a redstone RTU session + ---@param rs_unit unit_session + function public.add_redstone(rs_unit) + table.insert(self.redstone, rs_unit) + end + + -- link an imatrix RTU session + ---@param imatrix unit_session + function public.add_imatrix(imatrix) + table.insert(self.induction, imatrix) + end + + -- link an environment detector RTU session + ---@param envd unit_session + function public.add_envd(envd) + table.insert(self.envd, envd) + end + + -- purge devices associated with the given RTU session ID + ---@param session integer RTU session ID + function public.purge_rtu_devices(session) + util.filter_table(self.redstone, function (s) return s.get_session_id() ~= session end) + util.filter_table(self.induction, function (s) return s.get_session_id() ~= session end) + util.filter_table(self.envd, function (s) return s.get_session_id() ~= session end) + end + + -- UPDATE -- + + -- supervisor sessions reporting the list of active RTU sessions + ---@param rtu_sessions table session list of all connected RTUs + function public.report_rtus(rtu_sessions) + self.rtu_conn_count = #rtu_sessions + end + + -- update (iterate) the facility management + function public.update() + -- unlink RTU unit sessions if they are closed + _unlink_disconnected_units(self.redstone) + _unlink_disconnected_units(self.induction) + _unlink_disconnected_units(self.envd) + + -- current state for process control + local charge_update = 0 + local rate_update = 0 + + -- calculate moving averages for induction matrix + if self.induction[1] ~= nil then + local matrix = self.induction[1] ---@type unit_session + local db = matrix.get_db() ---@type imatrix_session_db + + charge_update = db.tanks.last_update + rate_update = db.state.last_update + + if (charge_update > 0) and (rate_update > 0) then + if self.im_stat_init then + self.avg_charge.record(util.joules_to_fe(db.tanks.energy), charge_update) + self.avg_inflow.record(util.joules_to_fe(db.state.last_input), rate_update) + self.avg_outflow.record(util.joules_to_fe(db.state.last_output), rate_update) + else + self.im_stat_init = true + self.avg_charge.reset(util.joules_to_fe(db.tanks.energy)) + self.avg_inflow.reset(util.joules_to_fe(db.state.last_input)) + self.avg_outflow.reset(util.joules_to_fe(db.state.last_output)) + end + end + else + self.im_stat_init = false + end + + self.all_sys_ok = true + for i = 1, #self.units do + self.all_sys_ok = self.all_sys_ok and not self.units[i].get_control_inf().degraded + end + + ------------------------- + -- Run Process Control -- + ------------------------- + + local avg_charge = self.avg_charge.compute() + local avg_inflow = self.avg_inflow.compute() + + local now = util.time_s() + + local state_changed = self.mode ~= self.last_mode + local next_mode = self.mode + + -- once auto control is started, sort the priority sublists by limits + if state_changed then + self.saturated = false + + log.debug("FAC: state changed from " .. PROCESS_NAMES[self.last_mode + 1] .. " to " .. PROCESS_NAMES[self.mode + 1]) + + if (self.last_mode == PROCESS.INACTIVE) or (self.last_mode == PROCESS.GEN_RATE_FAULT_IDLE) then + self.start_fail = START_STATUS.OK + + if (self.mode ~= PROCESS.MATRIX_FAULT_IDLE) and (self.mode ~= PROCESS.SYSTEM_ALARM_IDLE) then + -- auto clear ASCRAM + self.ascram = false + self.ascram_reason = AUTO_SCRAM.NONE + end + + local blade_count = nil + self.max_burn_combined = 0.0 + + for i = 1, #self.prio_defs do + table.sort(self.prio_defs[i], + ---@param a reactor_unit + ---@param b reactor_unit + function (a, b) return a.get_control_inf().lim_br100 < b.get_control_inf().lim_br100 end + ) + + for _, u in pairs(self.prio_defs[i]) do + local u_blade_count = u.get_control_inf().blade_count + + if blade_count == nil then + blade_count = u_blade_count + elseif (u_blade_count ~= blade_count) and (self.mode == PROCESS.GEN_RATE) then + log.warning("FAC: cannot start GEN_RATE process with inconsistent unit blade counts") + next_mode = PROCESS.INACTIVE + self.start_fail = START_STATUS.BLADE_MISMATCH + end + + if self.start_fail == START_STATUS.OK then u.a_engage() end + + self.max_burn_combined = self.max_burn_combined + (u.get_control_inf().lim_br100 / 100.0) + end + end + + if blade_count == nil then + -- no units + log.warning("FAC: cannot start process control with 0 units assigned") + next_mode = PROCESS.INACTIVE + self.start_fail = START_STATUS.NO_UNITS + else + self.charge_conversion = blade_count * POWER_PER_BLADE + end + elseif self.mode == PROCESS.INACTIVE then + for i = 1, #self.prio_defs do + -- SCRAM reactors and disengage auto control + -- use manual SCRAM since inactive was requested, and automatic SCRAM trips an alarm + for _, u in pairs(self.prio_defs[i]) do + u.scram() + u.a_disengage() + end + end + + log.info("FAC: disengaging auto control (now inactive)") + end + + self.initial_ramp = true + self.waiting_on_ramp = false + self.waiting_on_stable = false + else + self.initial_ramp = false + end + + -- update unit ready state + local assign_count = 0 + self.units_ready = true + for i = 1, #self.prio_defs do + for _, u in pairs(self.prio_defs[i]) do + assign_count = assign_count + 1 + self.units_ready = self.units_ready and u.get_control_inf().ready + end + end + + -- perform mode-specific operations + if self.mode == PROCESS.INACTIVE then + if not self.units_ready then + self.status_text = { "NOT READY", "assigned units not ready" } + else + -- clear ASCRAM once ready + self.ascram = false + self.ascram_reason = AUTO_SCRAM.NONE + + if self.start_fail == START_STATUS.NO_UNITS and assign_count == 0 then + self.status_text = { "START FAILED", "no units were assigned" } + elseif self.start_fail == START_STATUS.BLADE_MISMATCH then + self.status_text = { "START FAILED", "turbine blade count mismatch" } + else + self.status_text = { "IDLE", "control disengaged" } + end + end + elseif self.mode == PROCESS.MAX_BURN then + -- run units at their limits + if state_changed then + self.time_start = now + self.saturated = true + + self.status_text = { "MONITORED MODE", "running reactors at limit" } + log.info(util.c("FAC: MAX_BURN process mode started")) + end + + _allocate_burn_rate(self.max_burn_combined, true) + elseif self.mode == PROCESS.BURN_RATE then + -- a total aggregate burn rate + if state_changed then + self.time_start = now + self.status_text = { "BURN RATE MODE", "running" } + log.info(util.c("FAC: BURN_RATE process mode started")) + end + + local unallocated = _allocate_burn_rate(self.burn_target, true) + self.saturated = self.burn_target == self.max_burn_combined or unallocated > 0 + elseif self.mode == PROCESS.CHARGE then + -- target a level of charge + if state_changed then + self.time_start = now + self.last_time = now + self.last_error = 0 + self.accumulator = 0 + + self.status_text = { "CHARGE MODE", "running control loop" } + log.info(util.c("FAC: CHARGE mode starting PID control")) + elseif self.last_update ~= charge_update then + -- convert to kFE to make constants not microscopic + local error = util.round((self.charge_setpoint - avg_charge) / 1000) / 1000 + + -- stop accumulator when saturated to avoid windup + if not self.saturated then + self.accumulator = self.accumulator + (error * (now - self.last_time)) + end + + -- local runtime = now - self.time_start + local integral = self.accumulator + local derivative = (error - self.last_error) / (now - self.last_time) + + local P = (charge_Kp * error) + local I = (charge_Ki * integral) + local D = (charge_Kd * derivative) + + local output = P + I + D + + -- clamp at range -> output clamped (out_c) + local out_c = math.max(0, math.min(output, self.max_burn_combined)) + + self.saturated = output ~= out_c + + -- log.debug(util.sprintf("CHARGE[%f] { CHRG[%f] ERR[%f] INT[%f] => OUT[%f] OUT_C[%f] <= P[%f] I[%f] D[%d] }", + -- runtime, avg_charge, error, integral, output, out_c, P, I, D)) + + _allocate_burn_rate(out_c, true) + + self.last_time = now + self.last_error = error + end + + self.last_update = charge_update + elseif self.mode == PROCESS.GEN_RATE then + -- target a rate of generation + if state_changed then + -- estimate an initial output + local output = self.gen_rate_setpoint / self.charge_conversion + + local unallocated = _allocate_burn_rate(output, true) + + self.saturated = output >= self.max_burn_combined or unallocated > 0 + self.waiting_on_ramp = true + + self.status_text = { "GENERATION MODE", "starting up" } + log.info(util.c("FAC: GEN_RATE process mode initial ramp started (initial target is ", output, " mB/t)")) + elseif self.waiting_on_ramp then + if _all_units_ramped() then + self.waiting_on_ramp = false + self.waiting_on_stable = true + + self.time_start = now + + self.status_text = { "GENERATION MODE", "holding ramped rate" } + log.info("FAC: GEN_RATE process mode initial ramp completed, holding for stablization time") + end + elseif self.waiting_on_stable then + if (now - self.time_start) > FLOW_STABILITY_DELAY_S then + self.waiting_on_stable = false + + self.time_start = now + self.last_time = now + self.last_error = 0 + self.accumulator = 0 + + self.status_text = { "GENERATION MODE", "running control loop" } + log.info("FAC: GEN_RATE process mode initial hold completed, starting PID control") + end + elseif self.last_update ~= rate_update then + -- convert to MFE (in rounded kFE) to make constants not microscopic + local error = util.round((self.gen_rate_setpoint - avg_inflow) / 1000) / 1000 + + -- stop accumulator when saturated to avoid windup + if not self.saturated then + self.accumulator = self.accumulator + (error * (now - self.last_time)) + end + + -- local runtime = now - self.time_start + local integral = self.accumulator + local derivative = (error - self.last_error) / (now - self.last_time) + + local P = (rate_Kp * error) + local I = (rate_Ki * integral) + local D = (rate_Kd * derivative) + + -- velocity (rate) (derivative of charge level => rate) feed forward + local FF = self.gen_rate_setpoint / self.charge_conversion + + local output = P + I + D + FF + + -- clamp at range -> output clamped (sp_c) + local out_c = math.max(0, math.min(output, self.max_burn_combined)) + + self.saturated = output ~= out_c + + -- log.debug(util.sprintf("GEN_RATE[%f] { RATE[%f] ERR[%f] INT[%f] => OUT[%f] OUT_C[%f] <= P[%f] I[%f] D[%f] }", + -- runtime, avg_inflow, error, integral, output, out_c, P, I, D)) + + _allocate_burn_rate(out_c, false) + + self.last_time = now + self.last_error = error + end + + self.last_update = rate_update + elseif self.mode == PROCESS.MATRIX_FAULT_IDLE then + -- exceeded charge, wait until condition clears + if self.ascram_reason == AUTO_SCRAM.NONE then + next_mode = self.return_mode + log.info("FAC: exiting matrix fault idle state due to fault resolution") + elseif self.ascram_reason == AUTO_SCRAM.CRIT_ALARM then + next_mode = PROCESS.SYSTEM_ALARM_IDLE + log.info("FAC: exiting matrix fault idle state due to critical unit alarm") + end + elseif self.mode == PROCESS.SYSTEM_ALARM_IDLE then + -- do nothing, wait for user to confirm (stop and reset) + elseif self.mode == PROCESS.GEN_RATE_FAULT_IDLE then + -- system faulted (degraded/not ready) while running generation rate mode + -- mode will need to be fully restarted once everything is OK to re-ramp to feed-forward + if self.units_ready then + log.info("FAC: system ready after faulting out of GEN_RATE process mode, switching back...") + next_mode = PROCESS.GEN_RATE + end + elseif self.mode ~= PROCESS.INACTIVE then + log.error(util.c("FAC: unsupported process mode ", self.mode, ", switching to inactive")) + next_mode = PROCESS.INACTIVE + end + + ------------------------------ + -- Evaluate Automatic SCRAM -- + ------------------------------ + + local astatus = self.ascram_status + + if self.induction[1] ~= nil then + local matrix = self.induction[1] ---@type unit_session + local db = matrix.get_db() ---@type imatrix_session_db + + -- clear matrix disconnected + if astatus.matrix_dc then + astatus.matrix_dc = false + log.info("FAC: induction matrix reconnected, clearing ASCRAM condition") + end + + -- check matrix fill too high + local was_fill = astatus.matrix_fill + astatus.matrix_fill = (db.tanks.energy_fill >= ALARM_LIMS.CHARGE_HIGH) or (astatus.matrix_fill and db.tanks.energy_fill > ALARM_LIMS.CHARGE_RE_ENABLE) + + if was_fill and not astatus.matrix_fill then + log.info("FAC: charge state of induction matrix entered acceptable range <= " .. (ALARM_LIMS.CHARGE_RE_ENABLE * 100) .. "%") + end + + -- check for critical unit alarms + astatus.crit_alarm = false + for i = 1, #self.units do + local u = self.units[i] ---@type reactor_unit + + if u.has_alarm_min_prio(PRIO.CRITICAL) then + astatus.crit_alarm = true + break + end + end + + -- check for facility radiation + if self.envd[1] ~= nil then + local envd = self.envd[1] ---@type unit_session + local e_db = envd.get_db() ---@type envd_session_db + + astatus.radiation = e_db.radiation_raw > ALARM_LIMS.FAC_HIGH_RAD + else + -- don't clear, if it is true then we lost it with high radiation, so just keep alarming + -- operator can restart the system or hit the stop/reset button + end + + -- system not ready, will need to restart GEN_RATE mode + -- clears when we enter the fault waiting state + astatus.gen_fault = self.mode == PROCESS.GEN_RATE and not self.units_ready + else + astatus.matrix_dc = true + end + + if (self.mode ~= PROCESS.INACTIVE) and (self.mode ~= PROCESS.SYSTEM_ALARM_IDLE) then + local scram = astatus.matrix_dc or astatus.matrix_fill or astatus.crit_alarm or astatus.gen_fault + + if scram and not self.ascram then + -- SCRAM all units + for i = 1, #self.prio_defs do + for _, u in pairs(self.prio_defs[i]) do + u.a_scram() + end + end + + if astatus.crit_alarm then + -- highest priority alarm + next_mode = PROCESS.SYSTEM_ALARM_IDLE + self.ascram_reason = AUTO_SCRAM.CRIT_ALARM + self.status_text = { "AUTOMATIC SCRAM", "critical unit alarm tripped" } + + log.info("FAC: automatic SCRAM due to critical unit alarm") + log.warning("FAC: emergency exit of process control due to critical unit alarm") + elseif astatus.radiation then + next_mode = PROCESS.SYSTEM_ALARM_IDLE + self.ascram_reason = AUTO_SCRAM.RADIATION + self.status_text = { "AUTOMATIC SCRAM", "facility radiation high" } + + log.info("FAC: automatic SCRAM due to high facility radiation") + elseif astatus.matrix_dc then + next_mode = PROCESS.MATRIX_FAULT_IDLE + self.ascram_reason = AUTO_SCRAM.MATRIX_DC + self.status_text = { "AUTOMATIC SCRAM", "induction matrix disconnected" } + + if self.mode ~= PROCESS.MATRIX_FAULT_IDLE then self.return_mode = self.mode end + + log.info("FAC: automatic SCRAM due to induction matrix disconnection") + elseif astatus.matrix_fill then + next_mode = PROCESS.MATRIX_FAULT_IDLE + self.ascram_reason = AUTO_SCRAM.MATRIX_FILL + self.status_text = { "AUTOMATIC SCRAM", "induction matrix fill high" } + + if self.mode ~= PROCESS.MATRIX_FAULT_IDLE then self.return_mode = self.mode end + + log.info("FAC: automatic SCRAM due to induction matrix high charge") + elseif astatus.gen_fault then + -- lowest priority alarm + next_mode = PROCESS.GEN_RATE_FAULT_IDLE + self.ascram_reason = AUTO_SCRAM.GEN_FAULT + self.status_text = { "GENERATION MODE IDLE", "paused: system not ready" } + + log.info("FAC: automatic SCRAM due to unit problem while in GEN_RATE mode, will resume once all units are ready") + end + end + + self.ascram = scram + + if not self.ascram then + self.ascram_reason = AUTO_SCRAM.NONE + + -- reset PLC RPS trips if we should + for i = 1, #self.units do + local u = self.units[i] ---@type reactor_unit + u.a_cond_rps_reset() + end + end + end + + -- update last mode and set next mode + self.last_mode = self.mode + self.mode = next_mode + + ------------------------- + -- Handle Redstone I/O -- + ------------------------- + + if #self.redstone > 0 then + -- handle facility SCRAM + if self.io_ctl.digital_read(IO.F_SCRAM) then + for i = 1, #self.units do + local u = self.units[i] ---@type reactor_unit + u.cond_scram() + end + end + + -- handle facility ack + if self.io_ctl.digital_read(IO.F_ACK) then public.ack_all() end + + -- update facility alarm output (check if emergency+ alarms are active) + local has_alarm = false + for i = 1, #self.units do + local u = self.units[i] ---@type reactor_unit + + if u.has_alarm_min_prio(PRIO.EMERGENCY) then + has_alarm = true + break + end + end + + self.io_ctl.digital_write(IO.F_ALARM, has_alarm) + end + end + + -- call the update function of all units in the facility + function public.update_units() + for i = 1, #self.units do + local u = self.units[i] ---@type reactor_unit + u.update() + end + end + + -- COMMANDS -- + + -- SCRAM all reactor units + function public.scram_all() + for i = 1, #self.units do + local u = self.units[i] ---@type reactor_unit + u.scram() + end + end + + -- ack all alarms on all reactor units + function public.ack_all() + for i = 1, #self.units do + local u = self.units[i] ---@type reactor_unit + u.ack_all() + end + end + + -- stop auto control + function public.auto_stop() + self.mode = PROCESS.INACTIVE + end + + -- set automatic control configuration and start the process + ---@param config coord_auto_config configuration + ---@return table response ready state (successfully started) and current configuration (after updating) + function public.auto_start(config) + local ready = false + + -- load up current limits + local limits = {} + for i = 1, num_reactors do + local u = self.units[i] ---@type reactor_unit + limits[i] = u.get_control_inf().lim_br100 * 100 + end + + -- only allow changes if not running + if self.mode == PROCESS.INACTIVE then + if (type(config.mode) == "number") and (config.mode > PROCESS.INACTIVE) and (config.mode <= PROCESS.GEN_RATE) then + self.mode_set = config.mode + end + + if (type(config.burn_target) == "number") and config.burn_target >= 0.1 then + self.burn_target = config.burn_target + end + + if (type(config.charge_target) == "number") and config.charge_target >= 0 then + self.charge_setpoint = config.charge_target * 1000000 -- convert MFE to FE + end + + if (type(config.gen_target) == "number") and config.gen_target >= 0 then + self.gen_rate_setpoint = config.gen_target * 1000 -- convert kFE to FE + end + + if (type(config.limits) == "table") and (#config.limits == num_reactors) then + for i = 1, num_reactors do + local limit = config.limits[i] + + if (type(limit) == "number") and (limit >= 0.1) then + limits[i] = limit + self.units[i].set_burn_limit(limit) + end + end + end + + ready = self.mode_set > 0 + + if (self.mode_set == PROCESS.CHARGE) and (self.charge_setpoint <= 0) then + ready = false + elseif (self.mode_set == PROCESS.GEN_RATE) and (self.gen_rate_setpoint <= 0) then + ready = false + elseif (self.mode_set == PROCESS.BURN_RATE) and (self.burn_target < 0.1) then + ready = false + end + + ready = ready and self.units_ready + + if ready then self.mode = self.mode_set end + end + + return { ready, self.mode_set, self.burn_target, self.charge_setpoint, self.gen_rate_setpoint, limits } + end + + -- SETTINGS -- + + -- set the automatic control group of a unit + ---@param unit_id integer unit ID + ---@param group integer group ID or 0 for independent + function public.set_group(unit_id, group) + if group >= 0 and group <= 4 and self.mode == PROCESS.INACTIVE then + -- remove from old group if previously assigned + local old_group = self.group_map[unit_id] + if old_group ~= 0 then + util.filter_table(self.prio_defs[old_group], function (u) return u.get_id() ~= unit_id end) + end + + self.group_map[unit_id] = group + + -- add to group if not independent + if group > 0 then + table.insert(self.prio_defs[group], self.units[unit_id]) + end + end + end + + -- READ STATES/PROPERTIES -- + + -- get build properties of all machines + ---@nodiscard + ---@param inc_imatrix boolean? true/nil to include induction matrix build, false to exclude + function public.get_build(inc_imatrix) + local build = {} + + if inc_imatrix ~= false then + build.induction = {} + for i = 1, #self.induction do + local matrix = self.induction[i] ---@type unit_session + build.induction[matrix.get_device_idx()] = { matrix.get_db().formed, matrix.get_db().build } + end + end + + return build + end + + -- get automatic process control status + ---@nodiscard + function public.get_control_status() + local astat = self.ascram_status + return { + self.all_sys_ok, + self.units_ready, + self.mode, + self.waiting_on_ramp or self.waiting_on_stable, + self.at_max_burn or self.saturated, + self.ascram, + astat.matrix_dc, + astat.matrix_fill, + astat.crit_alarm, + astat.radiation, + astat.gen_fault or self.mode == PROCESS.GEN_RATE_FAULT_IDLE, + self.status_text[1], + self.status_text[2], + self.group_map + } + end + + -- get RTU statuses + ---@nodiscard + function public.get_rtu_statuses() + local status = {} + + -- total count of all connected RTUs in the facility + status.count = self.rtu_conn_count + + -- power averages from induction matricies + status.power = { + self.avg_charge.compute(), + self.avg_inflow.compute(), + self.avg_outflow.compute() + } + + -- status of induction matricies (including tanks) + status.induction = {} + for i = 1, #self.induction do + local matrix = self.induction[i] ---@type unit_session + status.induction[matrix.get_device_idx()] = { + matrix.is_faulted(), + matrix.get_db().formed, + matrix.get_db().state, + matrix.get_db().tanks + } + end + + -- radiation monitors (environment detectors) + status.rad_mon = {} + for i = 1, #self.envd do + local envd = self.envd[i] ---@type unit_session + status.rad_mon[envd.get_device_idx()] = { + envd.is_faulted(), + envd.get_db().radiation + } + end + + return status + end + + -- get the units in this facility + ---@nodiscard + function public.get_units() return self.units end + + return public +end + +return facility diff --git a/supervisor/session/coordinator.lua b/supervisor/session/coordinator.lua new file mode 100644 index 0000000..44dbefe --- /dev/null +++ b/supervisor/session/coordinator.lua @@ -0,0 +1,499 @@ +local comms = require("scada-common.comms") +local log = require("scada-common.log") +local mqueue = require("scada-common.mqueue") +local types = require("scada-common.types") +local util = require("scada-common.util") + +local svqtypes = require("supervisor.session.svqtypes") + +local coordinator = {} + +local PROTOCOL = comms.PROTOCOL +local SCADA_MGMT_TYPE = comms.SCADA_MGMT_TYPE +local SCADA_CRDN_TYPE = comms.SCADA_CRDN_TYPE +local UNIT_COMMAND = comms.UNIT_COMMAND +local FAC_COMMAND = comms.FAC_COMMAND + +local RTU_UNIT_TYPE = types.RTU_UNIT_TYPE + +local SV_Q_CMDS = svqtypes.SV_Q_CMDS +local SV_Q_DATA = svqtypes.SV_Q_DATA + +local print = util.print +local println = util.println +local print_ts = util.print_ts +local println_ts = util.println_ts + +-- retry time constants in ms +local INITIAL_WAIT = 1500 +local RETRY_PERIOD = 1000 +local PARTIAL_RETRY_PERIOD = 2000 + +local CRD_S_CMDS = { +} + +local CRD_S_DATA = { + CMD_ACK = 1, + RESEND_PLC_BUILD = 2, + RESEND_RTU_BUILD = 3 +} + +coordinator.CRD_S_CMDS = CRD_S_CMDS +coordinator.CRD_S_DATA = CRD_S_DATA + +local PERIODICS = { + KEEP_ALIVE = 2000, + STATUS = 500 +} + +-- coordinator supervisor 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 +---@param facility facility facility data table +function coordinator.new_session(id, in_queue, out_queue, timeout, facility) + local log_header = "crdn_session(" .. id .. "): " + + local self = { + units = facility.get_units(), + -- 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, + status_packet = 0 + }, + -- when to next retry one of these messages + retry_times = { + builds_packet = 0, + f_builds_packet = 0, + u_builds_packet = 0 + }, + -- message acknowledgements + acks = { + builds = false, + fac_builds = false, + unit_builds = false + } + } + + -- mark this coordinator session as closed, stop watchdog + local function _close() + self.conn_watchdog.cancel() + self.connected = false + end + + -- send a CRDN packet + ---@param msg_type SCADA_CRDN_TYPE + ---@param msg table + local function _send(msg_type, msg) + local s_pkt = comms.scada_packet() + local c_pkt = comms.crdn_packet() + + c_pkt.make(msg_type, msg) + s_pkt.make(self.seq_num, PROTOCOL.SCADA_CRDN, 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 + + -- send both facility and unit builds + local function _send_all_builds() + local unit_builds = {} + + for i = 1, #self.units do + local unit = self.units[i] ---@type reactor_unit + unit_builds[unit.get_id()] = unit.get_build() + end + + _send(SCADA_CRDN_TYPE.INITIAL_BUILDS, { facility.get_build(), unit_builds }) + end + + -- send facility builds + local function _send_fac_builds() + _send(SCADA_CRDN_TYPE.FAC_BUILDS, { facility.get_build() }) + end + + -- send unit builds + local function _send_unit_builds() + local builds = {} + + for i = 1, #self.units do + local unit = self.units[i] ---@type reactor_unit + builds[unit.get_id()] = unit.get_build() + end + + _send(SCADA_CRDN_TYPE.UNIT_BUILDS, { builds }) + end + + -- send facility status + local function _send_fac_status() + local status = { + facility.get_control_status(), + facility.get_rtu_statuses() + } + + _send(SCADA_CRDN_TYPE.FAC_STATUS, status) + end + + -- send unit statuses + local function _send_unit_statuses() + local status = {} + + for i = 1, #self.units do + local unit = self.units[i] ---@type reactor_unit + + status[unit.get_id()] = { + unit.get_reactor_status(), + unit.get_rtu_statuses(), + unit.get_annunciator(), + unit.get_alarms(), + unit.get_state() + } + end + + _send(SCADA_CRDN_TYPE.UNIT_STATUSES, status) + end + + -- handle a packet + ---@param pkt crdn_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 + if pkt.type == SCADA_MGMT_TYPE.KEEP_ALIVE then + -- keep alive reply + if pkt.length == 2 then + local srv_start = pkt.data[1] + local coord_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 .. "COORD KEEP_ALIVE round trip time > 750ms (" .. self.last_rtt .. "ms)") + end + + -- log.debug(log_header .. "COORD RTT = " .. self.last_rtt .. "ms") + -- log.debug(log_header .. "COORD TT = " .. (srv_now - coord_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 + elseif pkt.scada_frame.protocol() == PROTOCOL.SCADA_CRDN then + if pkt.type == SCADA_CRDN_TYPE.INITIAL_BUILDS then + -- acknowledgement to coordinator receiving builds + self.acks.builds = true + elseif pkt.type == SCADA_CRDN_TYPE.FAC_BUILDS then + -- acknowledgement to coordinator receiving builds + self.acks.fac_builds = true + elseif pkt.type == SCADA_CRDN_TYPE.FAC_CMD then + if pkt.length >= 1 then + local cmd = pkt.data[1] + + if cmd == FAC_COMMAND.SCRAM_ALL then + facility.scram_all() + _send(SCADA_CRDN_TYPE.FAC_CMD, { cmd, true }) + elseif cmd == FAC_COMMAND.STOP then + facility.auto_stop() + _send(SCADA_CRDN_TYPE.FAC_CMD, { cmd, true }) + elseif cmd == FAC_COMMAND.START then + if pkt.length == 6 then + ---@type coord_auto_config + local config = { + mode = pkt.data[2], + burn_target = pkt.data[3], + charge_target = pkt.data[4], + gen_target = pkt.data[5], + limits = pkt.data[6] + } + + _send(SCADA_CRDN_TYPE.FAC_CMD, { cmd, table.unpack(facility.auto_start(config)) }) + else + log.debug(log_header .. "CRDN auto start (with configuration) packet length mismatch") + end + elseif cmd == FAC_COMMAND.ACK_ALL_ALARMS then + facility.ack_all() + _send(SCADA_CRDN_TYPE.FAC_CMD, { cmd, true }) + else + log.debug(log_header .. "CRDN facility command unknown") + end + else + log.debug(log_header .. "CRDN facility command packet length mismatch") + end + elseif pkt.type == SCADA_CRDN_TYPE.UNIT_BUILDS then + -- acknowledgement to coordinator receiving builds + self.acks.unit_builds = true + elseif pkt.type == SCADA_CRDN_TYPE.UNIT_CMD then + if pkt.length >= 2 then + -- get command and unit id + local cmd = pkt.data[1] + local uid = pkt.data[2] + + -- pkt.data[3] will be nil except for some commands + local data = { uid, pkt.data[3] } + + -- continue if valid unit id + if util.is_int(uid) and uid > 0 and uid <= #self.units then + local unit = self.units[uid] ---@type reactor_unit + + if cmd == UNIT_COMMAND.START then + out_queue.push_data(SV_Q_DATA.START, data) + elseif cmd == UNIT_COMMAND.SCRAM then + out_queue.push_data(SV_Q_DATA.SCRAM, data) + elseif cmd == UNIT_COMMAND.RESET_RPS then + out_queue.push_data(SV_Q_DATA.RESET_RPS, data) + elseif cmd == UNIT_COMMAND.SET_BURN then + if pkt.length == 3 then + out_queue.push_data(SV_Q_DATA.SET_BURN, data) + else + log.debug(log_header .. "CRDN unit command burn rate missing option") + end + elseif cmd == UNIT_COMMAND.SET_WASTE then + if (pkt.length == 3) and (type(pkt.data[3]) == "number") and (pkt.data[3] > 0) and (pkt.data[3] <= 4) then + unit.set_waste(pkt.data[3]) + else + log.debug(log_header .. "CRDN unit command set waste missing option") + end + elseif cmd == UNIT_COMMAND.ACK_ALL_ALARMS then + unit.ack_all() + _send(SCADA_CRDN_TYPE.UNIT_CMD, { cmd, uid, true }) + elseif cmd == UNIT_COMMAND.ACK_ALARM then + if pkt.length == 3 then + unit.ack_alarm(pkt.data[3]) + else + log.debug(log_header .. "CRDN unit command ack alarm missing alarm id") + end + elseif cmd == UNIT_COMMAND.RESET_ALARM then + if pkt.length == 3 then + unit.reset_alarm(pkt.data[3]) + else + log.debug(log_header .. "CRDN unit command reset alarm missing alarm id") + end + elseif cmd == UNIT_COMMAND.SET_GROUP then + if (pkt.length == 3) and (type(pkt.data[3]) == "number") and (pkt.data[3] >= 0) and (pkt.data[3] <= 4) then + facility.set_group(unit.get_id(), pkt.data[3]) + _send(SCADA_CRDN_TYPE.UNIT_CMD, { cmd, uid, pkt.data[3] }) + else + log.debug(log_header .. "CRDN unit command set group missing group id") + end + else + log.debug(log_header .. "CRDN unit command unknown") + end + else + log.debug(log_header .. "CRDN unit command invalid") + end + else + log.debug(log_header .. "CRDN unit command packet length mismatch") + end + else + log.debug(log_header .. "handler received unexpected SCADA_CRDN packet type " .. pkt.type) + end + end + end + + ---@class coord_session + local public = {} + + -- get the session ID + ---@nodiscard + function public.get_id() return id 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 coordinator " .. 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 + local cmd = message.message ---@type queue_data + + if cmd.key == CRD_S_DATA.CMD_ACK then + local ack = cmd.val ---@type coord_ack + _send(SCADA_CRDN_TYPE.UNIT_CMD, { ack.cmd, ack.unit, ack.ack }) + elseif cmd.key == CRD_S_DATA.RESEND_PLC_BUILD then + -- re-send PLC build + -- retry logic will be kept as-is, so as long as no retry is needed, this will be a small update + self.retry_times.builds_packet = util.time() + PARTIAL_RETRY_PERIOD + self.acks.unit_builds = false + + local unit_id = cmd.val + local builds = {} + + local unit = self.units[unit_id] ---@type reactor_unit + builds[unit_id] = unit.get_build(true, false, false) + + _send(SCADA_CRDN_TYPE.UNIT_BUILDS, { builds }) + elseif cmd.key == CRD_S_DATA.RESEND_RTU_BUILD then + local unit_id = cmd.val.unit + if unit_id > 0 then + -- re-send unit RTU builds + -- retry logic will be kept as-is, so as long as no retry is needed, this will be a small update + self.retry_times.u_builds_packet = util.time() + PARTIAL_RETRY_PERIOD + self.acks.unit_builds = false + + local builds = {} + + local unit = self.units[unit_id] ---@type reactor_unit + builds[unit_id] = unit.get_build(false, cmd.val.type == RTU_UNIT_TYPE.BOILER_VALVE, cmd.val.type == RTU_UNIT_TYPE.TURBINE_VALVE) + + _send(SCADA_CRDN_TYPE.UNIT_BUILDS, { builds }) + else + -- re-send facility RTU builds + -- retry logic will be kept as-is, so as long as no retry is needed, this will be a small update + self.retry_times.f_builds_packet = util.time() + PARTIAL_RETRY_PERIOD + self.acks.fac_builds = false + + _send(SCADA_CRDN_TYPE.FAC_BUILDS, { facility.get_build(cmd.val.type == RTU_UNIT_TYPE.IMATRIX) }) + end + else + log.warning(log_header .. "unsupported data command received in in_queue (this is a bug)") + end + 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 coordinator 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 + + -- statuses to coordinator + + periodics.status_packet = periodics.status_packet + elapsed + if periodics.status_packet >= PERIODICS.STATUS then + _send_fac_status() + _send_unit_statuses() + periodics.status_packet = 0 + end + + self.periodics.last_update = util.time() + + --------------------- + -- attempt retries -- + --------------------- + + local rtimes = self.retry_times + + -- builds packet retries + + if not self.acks.builds then + if rtimes.builds_packet - util.time() <= 0 then + _send_all_builds() + rtimes.builds_packet = util.time() + RETRY_PERIOD + end + end + + if not self.acks.fac_builds then + if rtimes.f_builds_packet - util.time() <= 0 then + _send_fac_builds() + rtimes.f_builds_packet = util.time() + RETRY_PERIOD + end + end + + if not self.acks.unit_builds then + if rtimes.u_builds_packet - util.time() <= 0 then + _send_unit_builds() + rtimes.u_builds_packet = util.time() + RETRY_PERIOD + end + end + end + + return self.connected + end + + return public +end + +return coordinator diff --git a/supervisor/session/plc.lua b/supervisor/session/plc.lua new file mode 100644 index 0000000..3bba325 --- /dev/null +++ b/supervisor/session/plc.lua @@ -0,0 +1,805 @@ +local comms = require("scada-common.comms") +local log = require("scada-common.log") +local mqueue = require("scada-common.mqueue") +local types = require("scada-common.types") +local util = require("scada-common.util") + +local svqtypes = require("supervisor.session.svqtypes") + +local plc = {} + +local PROTOCOL = comms.PROTOCOL +local RPLC_TYPE = comms.RPLC_TYPE +local SCADA_MGMT_TYPE = comms.SCADA_MGMT_TYPE +local PLC_AUTO_ACK = comms.PLC_AUTO_ACK +local UNIT_COMMAND = comms.UNIT_COMMAND + +local print = util.print +local println = util.println +local print_ts = util.print_ts +local println_ts = util.println_ts + +-- retry time constants in ms +local INITIAL_WAIT = 1500 +local INITIAL_AUTO_WAIT = 1000 +local RETRY_PERIOD = 1000 + +local PLC_S_CMDS = { + SCRAM = 1, + ASCRAM = 2, + ENABLE = 3, + RPS_RESET = 4, + RPS_AUTO_RESET = 5 +} + +local PLC_S_DATA = { + BURN_RATE = 1, + RAMP_BURN_RATE = 2, + AUTO_BURN_RATE = 3 +} + +plc.PLC_S_CMDS = PLC_S_CMDS +plc.PLC_S_DATA = PLC_S_DATA + +local PERIODICS = { + KEEP_ALIVE = 2000 +} + +-- PLC supervisor session +---@nodiscard +---@param id integer session ID +---@param reactor_id integer reactor ID +---@param in_queue mqueue in message queue +---@param out_queue mqueue out message queue +---@param timeout number communications timeout +function plc.new_session(id, reactor_id, in_queue, out_queue, timeout) + local log_header = "plc_session(" .. id .. "): " + + local self = { + commanded_state = false, + commanded_burn_rate = 0.0, + auto_cmd_token = 0, + ramping_rate = false, + auto_lock = false, + -- connection properties + seq_num = 0, + r_seq_num = nil, + connected = true, + received_struct = false, + received_status_cache = false, + plc_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 = { + struct_req = (util.time() + 500), + status_req = (util.time() + 500), + scram_req = 0, + ascram_req = 0, + burn_rate_req = 0, + rps_reset_req = 0 + }, + -- command acknowledgements + acks = { + scram = true, + ascram = true, + burn_rate = true, + rps_reset = true + }, + -- session database + ---@class reactor_db + sDB = { + auto_ack_token = 0, + last_status_update = 0, + control_state = false, + no_reactor = false, + formed = false, + rps_tripped = false, + rps_trip_cause = "ok", ---@type rps_trip_cause + ---@class rps_status + rps_status = { + high_dmg = false, + high_temp = false, + low_cool = false, + ex_waste = false, + ex_hcool = false, + no_fuel = false, + fault = false, + timeout = false, + manual = false, + automatic = false, + sys_fail = false, + force_dis = false + }, + ---@class mek_status + mek_status = { + heating_rate = 0.0, + + status = false, + burn_rate = 0.0, + act_burn_rate = 0.0, + temp = 0.0, + damage = 0.0, + boil_eff = 0.0, + env_loss = 0.0, + + fuel = 0, + fuel_need = 0, + fuel_fill = 0.0, + waste = 0, + waste_need = 0, + waste_fill = 0.0, + ccool_type = "?", + ccool_amnt = 0, + ccool_need = 0, + ccool_fill = 0.0, + hcool_type = "?", + hcool_amnt = 0, + hcool_need = 0, + hcool_fill = 0.0 + }, + ---@class mek_struct + mek_struct = { + length = 0, + width = 0, + height = 0, + min_pos = types.new_zero_coordinate(), + max_pos = types.new_zero_coordinate(), + heat_cap = 0, + fuel_asm = 0, + fuel_sa = 0, + fuel_cap = 0, + waste_cap = 0, + ccool_cap = 0, + hcool_cap = 0, + max_burn = 0.0 + } + } + } + + ---@class plc_session + local public = {} + + -- copy in the RPS status + ---@param rps_status table + local function _copy_rps_status(rps_status) + self.sDB.rps_tripped = rps_status[1] + self.sDB.rps_trip_cause = rps_status[2] + self.sDB.rps_status.high_dmg = rps_status[3] + self.sDB.rps_status.high_temp = rps_status[4] + self.sDB.rps_status.low_cool = rps_status[5] + self.sDB.rps_status.ex_waste = rps_status[6] + self.sDB.rps_status.ex_hcool = rps_status[7] + self.sDB.rps_status.no_fuel = rps_status[8] + self.sDB.rps_status.fault = rps_status[9] + self.sDB.rps_status.timeout = rps_status[10] + self.sDB.rps_status.manual = rps_status[11] + self.sDB.rps_status.automatic = rps_status[12] + self.sDB.rps_status.sys_fail = rps_status[13] + self.sDB.rps_status.force_dis = rps_status[14] + end + + -- copy in the reactor status + ---@param mek_data table + local function _copy_status(mek_data) + -- copy status information + self.sDB.mek_status.status = mek_data[1] + self.sDB.mek_status.burn_rate = mek_data[2] + self.sDB.mek_status.act_burn_rate = mek_data[3] + self.sDB.mek_status.temp = mek_data[4] + self.sDB.mek_status.damage = mek_data[5] + self.sDB.mek_status.boil_eff = mek_data[6] + self.sDB.mek_status.env_loss = mek_data[7] + + -- copy container information + self.sDB.mek_status.fuel = mek_data[8] + self.sDB.mek_status.fuel_fill = mek_data[9] + self.sDB.mek_status.waste = mek_data[10] + self.sDB.mek_status.waste_fill = mek_data[11] + self.sDB.mek_status.ccool_type = mek_data[12] + self.sDB.mek_status.ccool_amnt = mek_data[13] + self.sDB.mek_status.ccool_fill = mek_data[14] + self.sDB.mek_status.hcool_type = mek_data[15] + self.sDB.mek_status.hcool_amnt = mek_data[16] + self.sDB.mek_status.hcool_fill = mek_data[17] + + -- update computable fields if we have our structure + if self.received_struct then + self.sDB.mek_status.fuel_need = self.sDB.mek_struct.fuel_cap - self.sDB.mek_status.fuel_fill + self.sDB.mek_status.waste_need = self.sDB.mek_struct.waste_cap - self.sDB.mek_status.waste_fill + self.sDB.mek_status.cool_need = self.sDB.mek_struct.ccool_cap - self.sDB.mek_status.ccool_fill + self.sDB.mek_status.hcool_need = self.sDB.mek_struct.hcool_cap - self.sDB.mek_status.hcool_fill + end + end + + -- copy in the reactor structure + ---@param mek_data table + local function _copy_struct(mek_data) + self.sDB.mek_struct.length = mek_data[1] + self.sDB.mek_struct.width = mek_data[2] + self.sDB.mek_struct.height = mek_data[3] + self.sDB.mek_struct.min_pos = mek_data[4] + self.sDB.mek_struct.max_pos = mek_data[5] + self.sDB.mek_struct.heat_cap = mek_data[6] + self.sDB.mek_struct.fuel_asm = mek_data[7] + self.sDB.mek_struct.fuel_sa = mek_data[8] + self.sDB.mek_struct.fuel_cap = mek_data[9] + self.sDB.mek_struct.waste_cap = mek_data[10] + self.sDB.mek_struct.ccool_cap = mek_data[11] + self.sDB.mek_struct.hcool_cap = mek_data[12] + self.sDB.mek_struct.max_burn = mek_data[13] + end + + -- mark this PLC session as closed, stop watchdog + local function _close() + self.plc_conn_watchdog.cancel() + self.connected = false + end + + -- send an RPLC packet + ---@param msg_type RPLC_TYPE + ---@param msg table + local function _send(msg_type, msg) + local s_pkt = comms.scada_packet() + local r_pkt = comms.rplc_packet() + + r_pkt.make(reactor_id, msg_type, msg) + s_pkt.make(self.seq_num, PROTOCOL.RPLC, r_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 + + -- get an ACK status + ---@nodiscard + ---@param pkt rplc_frame + ---@return boolean|nil ack + local function _get_ack(pkt) + if pkt.length == 1 then + return pkt.data[1] + else + log.warning(log_header .. "RPLC ACK length mismatch") + return nil + end + end + + -- handle a packet + ---@param pkt rplc_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 + + -- process packet + if pkt.scada_frame.protocol() == PROTOCOL.RPLC then + -- check reactor ID + if pkt.id ~= reactor_id then + log.warning(log_header .. "RPLC packet with ID not matching reactor ID: reactor " .. reactor_id .. " != " .. pkt.id) + return + end + + -- feed watchdog + self.plc_conn_watchdog.feed() + + -- handle packet by type + if pkt.type == RPLC_TYPE.STATUS then + -- status packet received, update data + if pkt.length >= 5 then + self.sDB.last_status_update = pkt.data[1] + self.sDB.control_state = pkt.data[2] + self.sDB.no_reactor = pkt.data[3] + self.sDB.formed = pkt.data[4] + self.sDB.auto_ack_token = pkt.data[5] + + if not self.sDB.no_reactor and self.sDB.formed then + self.sDB.mek_status.heating_rate = pkt.data[6] or 0.0 + + -- attempt to read mek_data table + if pkt.data[7] ~= nil then + local status = pcall(_copy_status, pkt.data[7]) + if status then + -- copied in status data OK + self.received_status_cache = true + else + -- error copying status data + log.error(log_header .. "failed to parse status packet data") + end + end + end + else + log.debug(log_header .. "RPLC status packet length mismatch") + end + elseif pkt.type == RPLC_TYPE.MEK_STRUCT then + -- received reactor structure, record it + if pkt.length == 14 then + local status = pcall(_copy_struct, pkt.data) + if status then + -- copied in structure data OK + self.received_struct = true + out_queue.push_data(svqtypes.SV_Q_DATA.PLC_BUILD_CHANGED, reactor_id) + else + -- error copying structure data + log.error(log_header .. "failed to parse struct packet data") + end + else + log.debug(log_header .. "RPLC struct packet length mismatch") + end + elseif pkt.type == RPLC_TYPE.MEK_BURN_RATE then + -- burn rate acknowledgement + local ack = _get_ack(pkt) + if ack then + self.acks.burn_rate = true + elseif ack == false then + log.debug(log_header .. "burn rate update failed!") + end + + -- send acknowledgement to coordinator + out_queue.push_data(svqtypes.SV_Q_DATA.CRDN_ACK, { + unit = reactor_id, + cmd = UNIT_COMMAND.SET_BURN, + ack = ack + }) + elseif pkt.type == RPLC_TYPE.RPS_ENABLE then + -- enable acknowledgement + local ack = _get_ack(pkt) + if ack then + self.sDB.control_state = true + elseif ack == false then + log.debug(log_header .. "enable failed!") + end + + -- send acknowledgement to coordinator + out_queue.push_data(svqtypes.SV_Q_DATA.CRDN_ACK, { + unit = reactor_id, + cmd = UNIT_COMMAND.START, + ack = ack + }) + elseif pkt.type == RPLC_TYPE.RPS_SCRAM then + -- manual SCRAM acknowledgement + local ack = _get_ack(pkt) + if ack then + self.acks.scram = true + self.sDB.control_state = false + elseif ack == false then + log.debug(log_header .. "manual SCRAM failed!") + end + + -- send acknowledgement to coordinator + out_queue.push_data(svqtypes.SV_Q_DATA.CRDN_ACK, { + unit = reactor_id, + cmd = UNIT_COMMAND.SCRAM, + ack = ack + }) + elseif pkt.type == RPLC_TYPE.RPS_ASCRAM then + -- automatic SCRAM acknowledgement + local ack = _get_ack(pkt) + if ack then + self.acks.ascram = true + self.sDB.control_state = false + elseif ack == false then + log.debug(log_header .. " automatic SCRAM failed!") + end + elseif pkt.type == RPLC_TYPE.RPS_STATUS then + -- RPS status packet received, copy data + if pkt.length == 14 then + local status = pcall(_copy_rps_status, pkt.data) + if status then + -- copied in RPS status data OK + else + -- error copying RPS status data + log.error(log_header .. "failed to parse RPS status packet data") + end + else + log.debug(log_header .. "RPLC RPS status packet length mismatch") + end + elseif pkt.type == RPLC_TYPE.RPS_ALARM then + -- RPS alarm + if pkt.length == 13 then + local status = pcall(_copy_rps_status, { true, table.unpack(pkt.data) }) + if status then + -- copied in RPS status data OK + else + -- error copying RPS status data + log.error(log_header .. "failed to parse RPS alarm status data") + end + else + log.debug(log_header .. "RPLC RPS alarm packet length mismatch") + end + elseif pkt.type == RPLC_TYPE.RPS_RESET then + -- RPS reset acknowledgement + local ack = _get_ack(pkt) + if ack then + self.acks.rps_reset = true + self.sDB.rps_tripped = false + self.sDB.rps_trip_cause = "ok" + elseif ack == false then + log.debug(log_header .. "RPS reset failed") + end + + -- send acknowledgement to coordinator + out_queue.push_data(svqtypes.SV_Q_DATA.CRDN_ACK, { + unit = reactor_id, + cmd = UNIT_COMMAND.RESET_RPS, + ack = ack + }) + elseif pkt.type == RPLC_TYPE.RPS_AUTO_RESET then + -- RPS auto control reset acknowledgement + local ack = _get_ack(pkt) + if not ack then + log.debug(log_header .. "RPS auto reset failed") + end + elseif pkt.type == RPLC_TYPE.AUTO_BURN_RATE then + if pkt.length == 1 then + local ack = pkt.data[1] + + if ack == PLC_AUTO_ACK.FAIL then + self.acks.burn_rate = false + log.debug(log_header .. "RPLC automatic burn rate set fail") + elseif ack == PLC_AUTO_ACK.DIRECT_SET_OK or ack == PLC_AUTO_ACK.RAMP_SET_OK or ack == PLC_AUTO_ACK.ZERO_DIS_OK then + self.acks.burn_rate = true + else + self.acks.burn_rate = false + log.debug(log_header .. "RPLC automatic burn rate ack unknown") + end + else + log.debug(log_header .. "RPLC automatic burn rate ack packet length mismatch") + end + else + log.debug(log_header .. "handler received unsupported RPLC packet type " .. pkt.type) + end + elseif pkt.scada_frame.protocol() == PROTOCOL.SCADA_MGMT then + if pkt.type == SCADA_MGMT_TYPE.KEEP_ALIVE then + -- keep alive reply + if pkt.length == 2 then + local srv_start = pkt.data[1] + local plc_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 .. "PLC KEEP_ALIVE round trip time > 750ms (" .. self.last_rtt .. "ms)") + end + + -- log.debug(log_header .. "PLC RTT = " .. self.last_rtt .. "ms") + -- log.debug(log_header .. "PLC TT = " .. (srv_now - plc_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 ramping is completed by first verifying auto command token ack + ---@nodiscard + function public.is_ramp_complete() + return (self.sDB.auto_ack_token == self.auto_cmd_token) and (self.commanded_burn_rate == self.sDB.mek_status.act_burn_rate) + end + + -- get the reactor structure + ---@nodiscard + ---@return mek_struct|table struct struct or empty table + function public.get_struct() + if self.received_struct then + return self.sDB.mek_struct + else + return {} + end + end + + -- get the reactor status + ---@nodiscard + ---@return mek_status|table struct status or empty table + function public.get_status() + if self.received_status_cache then + return self.sDB.mek_status + else + return {} + end + end + + -- get the reactor RPS status + ---@nodiscard + function public.get_rps() + return self.sDB.rps_status + end + + -- get the general status information + ---@nodiscard + function public.get_general_status() + return { + self.sDB.last_status_update, + self.sDB.control_state, + self.sDB.rps_tripped, + self.sDB.rps_trip_cause, + self.sDB.no_reactor, + self.sDB.formed + } + end + + -- lock out some manual operator actions during automatic control + ---@param engage boolean true to engage the lockout + function public.auto_lock(engage) + self.auto_lock = engage + + -- stop retrying a burn rate command + if engage then + self.acks.burn_rate = true + end + end + + -- set the burn rate on behalf of automatic control + ---@param rate number burn rate + ---@param ramp boolean true to ramp, false to not + function public.auto_set_burn(rate, ramp) + self.ramping_rate = ramp + in_queue.push_data(PLC_S_DATA.AUTO_BURN_RATE, rate) + end + + -- 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 + end + + -- close the connection + function public.close() + _close() + _send_mgmt(SCADA_MGMT_TYPE.CLOSE, {}) + println("connection to reactor " .. reactor_id .. " PLC 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 + local cmd = message.message + if cmd == PLC_S_CMDS.ENABLE then + -- enable reactor + if not self.auto_lock then + _send(RPLC_TYPE.RPS_ENABLE, {}) + end + elseif cmd == PLC_S_CMDS.SCRAM then + -- SCRAM reactor + self.acks.scram = false + self.retry_times.scram_req = util.time() + INITIAL_WAIT + _send(RPLC_TYPE.RPS_SCRAM, {}) + elseif cmd == PLC_S_CMDS.ASCRAM then + -- SCRAM reactor + self.acks.ascram = false + self.retry_times.ascram_req = util.time() + INITIAL_WAIT + _send(RPLC_TYPE.RPS_ASCRAM, {}) + elseif cmd == PLC_S_CMDS.RPS_RESET then + -- reset RPS + self.acks.ascram = true + self.acks.rps_reset = false + self.retry_times.rps_reset_req = util.time() + INITIAL_WAIT + _send(RPLC_TYPE.RPS_RESET, {}) + elseif cmd == PLC_S_CMDS.RPS_AUTO_RESET then + if self.sDB.rps_status.automatic or self.sDB.rps_status.timeout then + _send(RPLC_TYPE.RPS_AUTO_RESET, {}) + end + else + log.warning(log_header .. "unsupported command received in in_queue (this is a bug)") + end + elseif message.qtype == mqueue.TYPE.DATA then + -- instruction with body + local cmd = message.message ---@type queue_data + if cmd.key == PLC_S_DATA.BURN_RATE then + -- update burn rate + if not self.auto_lock then + cmd.val = math.floor(cmd.val * 10) / 10 -- round to 10ths place + if cmd.val > 0 and cmd.val <= self.sDB.mek_struct.max_burn then + self.commanded_burn_rate = cmd.val + self.auto_cmd_token = 0 + self.ramping_rate = false + self.acks.burn_rate = false + self.retry_times.burn_rate_req = util.time() + INITIAL_WAIT + _send(RPLC_TYPE.MEK_BURN_RATE, { self.commanded_burn_rate, self.ramping_rate }) + end + end + elseif cmd.key == PLC_S_DATA.RAMP_BURN_RATE then + -- ramp to burn rate + if not self.auto_lock then + cmd.val = math.floor(cmd.val * 10) / 10 -- round to 10ths place + if cmd.val > 0 and cmd.val <= self.sDB.mek_struct.max_burn then + self.commanded_burn_rate = cmd.val + self.auto_cmd_token = 0 + self.ramping_rate = true + self.acks.burn_rate = false + self.retry_times.burn_rate_req = util.time() + INITIAL_WAIT + _send(RPLC_TYPE.MEK_BURN_RATE, { self.commanded_burn_rate, self.ramping_rate }) + end + end + elseif cmd.key == PLC_S_DATA.AUTO_BURN_RATE then + -- set automatic burn rate + if self.auto_lock then + cmd.val = math.floor(cmd.val * 100) / 100 -- round to 100ths place + if cmd.val >= 0 and cmd.val <= self.sDB.mek_struct.max_burn then + self.auto_cmd_token = util.time_ms() + self.commanded_burn_rate = cmd.val + + -- this is only for manual control, only retry auto ramps + self.acks.burn_rate = not self.ramping_rate + self.retry_times.burn_rate_req = util.time() + INITIAL_AUTO_WAIT + + _send(RPLC_TYPE.AUTO_BURN_RATE, { self.commanded_burn_rate, self.ramping_rate, self.auto_cmd_token }) + end + end + else + log.warning(log_header .. "unsupported data command received in in_queue (this is a bug)") + end + 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 reactor " .. reactor_id .. " PLC 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 + + if (not self.sDB.no_reactor) and self.sDB.formed then + -- struct request retry + + if not self.received_struct then + if rtimes.struct_req - util.time() <= 0 then + _send(RPLC_TYPE.MEK_STRUCT, {}) + rtimes.struct_req = util.time() + RETRY_PERIOD + end + end + + -- status cache request retry + + if not self.received_status_cache then + if rtimes.status_req - util.time() <= 0 then + _send(RPLC_TYPE.MEK_STATUS, {}) + rtimes.status_req = util.time() + RETRY_PERIOD + end + end + + -- burn rate request retry + + if not self.acks.burn_rate then + if rtimes.burn_rate_req - util.time() <= 0 then + if self.auto_cmd_token > 0 then + if self.auto_lock then + _send(RPLC_TYPE.AUTO_BURN_RATE, { self.commanded_burn_rate, self.ramping_rate, self.auto_cmd_token }) + else + -- would have been an auto command, but disengaged, so stop retrying + self.acks.burn_rate = true + end + elseif not self.auto_lock then + _send(RPLC_TYPE.MEK_BURN_RATE, { self.commanded_burn_rate, self.ramping_rate }) + else + -- shouldn't be in this state, just pretend it was acknowledged + self.acks.burn_rate = true + end + + rtimes.burn_rate_req = util.time() + RETRY_PERIOD + end + end + end + + -- SCRAM request retry + + if not self.acks.scram then + if rtimes.scram_req - util.time() <= 0 then + _send(RPLC_TYPE.RPS_SCRAM, {}) + rtimes.scram_req = util.time() + RETRY_PERIOD + end + end + + -- automatic SCRAM request retry + + if not self.acks.ascram then + if rtimes.ascram_req - util.time() <= 0 then + _send(RPLC_TYPE.RPS_ASCRAM, {}) + rtimes.ascram_req = util.time() + RETRY_PERIOD + end + end + + -- RPS reset request retry + + if not self.acks.rps_reset then + if rtimes.rps_reset_req - util.time() <= 0 then + _send(RPLC_TYPE.RPS_RESET, {}) + rtimes.rps_reset_req = util.time() + RETRY_PERIOD + end + end + end + + return self.connected + end + + return public +end + +return plc diff --git a/supervisor/session/rsctl.lua b/supervisor/session/rsctl.lua new file mode 100644 index 0000000..fb17efe --- /dev/null +++ b/supervisor/session/rsctl.lua @@ -0,0 +1,40 @@ +-- +-- Redstone RTU Session I/O Controller +-- + +local rsctl = {} + +-- create a new redstone RTU I/O controller +---@nodiscard +---@param redstone_rtus table redstone RTU sessions +function rsctl.new(redstone_rtus) + ---@class rs_controller + local public = {} + + -- write to a digital redstone port (applies to all RTUs) + ---@param port IO_PORT + ---@param value boolean + function public.digital_write(port, value) + for i = 1, #redstone_rtus do + local db = redstone_rtus[i].get_db() ---@type redstone_session_db + local io = db.io[port] ---@type rs_db_dig_io|nil + if io ~= nil then io.write(value) end + end + end + + -- read a digital redstone port
+ -- this will read from the first one encountered if there are multiple, because there should not be multiple + ---@param port IO_PORT + ---@return boolean|nil + function public.digital_read(port) + for i = 1, #redstone_rtus do + local db = redstone_rtus[i].get_db() ---@type redstone_session_db + local io = db.io[port] ---@type rs_db_dig_io|nil + if io ~= nil then return io.read() end + end + end + + return public +end + +return rsctl diff --git a/supervisor/session/rtu.lua b/supervisor/session/rtu.lua new file mode 100644 index 0000000..d265cad --- /dev/null +++ b/supervisor/session/rtu.lua @@ -0,0 +1,406 @@ +local comms = require("scada-common.comms") +local log = require("scada-common.log") +local mqueue = require("scada-common.mqueue") +local types = require("scada-common.types") +local util = require("scada-common.util") + +local svqtypes = require("supervisor.session.svqtypes") + +-- supervisor rtu sessions (svrs) +local unit_session = require("supervisor.session.rtu.unit_session") +local svrs_boilerv = require("supervisor.session.rtu.boilerv") +local svrs_envd = require("supervisor.session.rtu.envd") +local svrs_imatrix = require("supervisor.session.rtu.imatrix") +local svrs_redstone = require("supervisor.session.rtu.redstone") +local svrs_sna = require("supervisor.session.rtu.sna") +local svrs_sps = require("supervisor.session.rtu.sps") +local svrs_turbinev = require("supervisor.session.rtu.turbinev") + +local rtu = {} + +local PROTOCOL = comms.PROTOCOL +local SCADA_MGMT_TYPE = comms.SCADA_MGMT_TYPE +local RTU_UNIT_TYPE = types.RTU_UNIT_TYPE + +local print = util.print +local println = util.println +local print_ts = util.print_ts +local println_ts = util.println_ts + +local PERIODICS = { + KEEP_ALIVE = 2000 +} + +-- create a new RTU 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 +---@param advertisement table RTU device advertisement +---@param facility facility facility data table +function rtu.new_session(id, in_queue, out_queue, timeout, advertisement, facility) + local log_header = "rtu_session(" .. id .. "): " + + local self = { + modbus_q = mqueue.new(), + advert = advertisement, + fac_units = facility.get_units(), + -- connection properties + seq_num = 0, + r_seq_num = nil, + connected = true, + rtu_conn_watchdog = util.new_watchdog(timeout), + last_rtt = 0, + -- periodic messages + periodics = { + last_update = 0, + keep_alive = 0 + }, + units = {} + } + + ---@class rtu_session + local public = {} + + local function _reset_config() + self.units = {} + end + + -- parse the recorded advertisement and create unit sub-sessions + local function _handle_advertisement() + _reset_config() + + for i = 1, #self.fac_units do + local unit = self.fac_units[i] ---@type reactor_unit + unit.purge_rtu_devices(id) + facility.purge_rtu_devices(id) + end + + for i = 1, #self.advert do + local unit = nil ---@type unit_session|nil + local rs_in_q = nil ---@type mqueue|nil + local tbv_in_q = nil ---@type mqueue|nil + + ---@type rtu_advertisement + local unit_advert = { + type = self.advert[i][1], + index = self.advert[i][2], + reactor = self.advert[i][3], + rsio = self.advert[i][4] + } + + local u_type = unit_advert.type ---@type integer|boolean + + -- validate unit advertisement + + local advert_validator = util.new_validator() + advert_validator.assert_type_int(unit_advert.index) + advert_validator.assert_type_int(unit_advert.reactor) + + if u_type == RTU_UNIT_TYPE.REDSTONE then + advert_validator.assert_type_table(unit_advert.rsio) + end + + if advert_validator.valid() then + advert_validator.assert_min(unit_advert.index, 1) + advert_validator.assert_min(unit_advert.reactor, 0) + advert_validator.assert_max(unit_advert.reactor, #self.fac_units) + if not advert_validator.valid() then u_type = false end + else + u_type = false + end + + local type_string = util.strval(u_type) + if type(u_type) == "number" then type_string = types.rtu_type_to_string(u_type) end + + -- create unit by type + + if u_type == false then + -- validation fail + log.debug(log_header .. "advertisement unit validation failure") + else + if unit_advert.reactor > 0 then + local target_unit = self.fac_units[unit_advert.reactor] ---@type reactor_unit + + if u_type == RTU_UNIT_TYPE.REDSTONE then + -- redstone + unit = svrs_redstone.new(id, i, unit_advert, self.modbus_q) + if type(unit) ~= "nil" then target_unit.add_redstone(unit) end + elseif u_type == RTU_UNIT_TYPE.BOILER_VALVE then + -- boiler (Mekanism 10.1+) + unit = svrs_boilerv.new(id, i, unit_advert, self.modbus_q) + if type(unit) ~= "nil" then target_unit.add_boiler(unit) end + elseif u_type == RTU_UNIT_TYPE.TURBINE_VALVE then + -- turbine (Mekanism 10.1+) + unit = svrs_turbinev.new(id, i, unit_advert, self.modbus_q) + if type(unit) ~= "nil" then target_unit.add_turbine(unit) end + elseif u_type == RTU_UNIT_TYPE.ENV_DETECTOR then + -- environment detector + unit = svrs_envd.new(id, i, unit_advert, self.modbus_q) + if type(unit) ~= "nil" then target_unit.add_envd(unit) end + else + log.error(util.c(log_header, "bad advertisement: encountered unsupported reactor-specific RTU type ", type_string)) + end + else + if u_type == RTU_UNIT_TYPE.REDSTONE then + -- redstone + unit = svrs_redstone.new(id, i, unit_advert, self.modbus_q) + if type(unit) ~= "nil" then facility.add_redstone(unit) end + elseif u_type == RTU_UNIT_TYPE.IMATRIX then + -- induction matrix + unit = svrs_imatrix.new(id, i, unit_advert, self.modbus_q) + if type(unit) ~= "nil" then facility.add_imatrix(unit) end + elseif u_type == RTU_UNIT_TYPE.SPS then + -- super-critical phase shifter + unit = svrs_sps.new(id, i, unit_advert, self.modbus_q) + elseif u_type == RTU_UNIT_TYPE.SNA then + -- solar neutron activator + unit = svrs_sna.new(id, i, unit_advert, self.modbus_q) + elseif u_type == RTU_UNIT_TYPE.ENV_DETECTOR then + -- environment detector + unit = svrs_envd.new(id, i, unit_advert, self.modbus_q) + if type(unit) ~= "nil" then facility.add_envd(unit) end + else + log.error(util.c(log_header, "bad advertisement: encountered unsupported reactor-independent RTU type ", type_string)) + end + end + end + + if unit ~= nil then + table.insert(self.units, unit) + else + _reset_config() + log.error(util.c(log_header, "bad advertisement: error occured while creating a unit (type is ", type_string, ")")) + break + end + end + end + + -- mark this RTU session as closed, stop watchdog + local function _close() + self.rtu_conn_watchdog.cancel() + self.connected = false + + -- mark all RTU unit sessions as closed so the reactor unit knows + for i = 1, #self.units do + self.units[i].close() + end + end + + -- send a MODBUS packet + ---@param m_pkt modbus_packet MODBUS packet + local function _send_modbus(m_pkt) + local s_pkt = comms.scada_packet() + + s_pkt.make(self.seq_num, PROTOCOL.MODBUS_TCP, m_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 modbus_frame|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.rtu_conn_watchdog.feed() + + -- process packet + if pkt.scada_frame.protocol() == PROTOCOL.MODBUS_TCP then + 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 + -- handle management packet + if pkt.type == SCADA_MGMT_TYPE.KEEP_ALIVE then + -- keep alive reply + if pkt.length == 2 then + local srv_start = pkt.data[1] + local rtu_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 .. "RTU KEEP_ALIVE round trip time > 750ms (" .. self.last_rtt .. "ms)") + end + + -- log.debug(log_header .. "RTU RTT = " .. self.last_rtt .. "ms") + -- log.debug(log_header .. "RTU TT = " .. (srv_now - rtu_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() + elseif pkt.type == SCADA_MGMT_TYPE.RTU_ADVERT then + -- RTU unit advertisement + log.debug(log_header .. "received updated advertisement") + self.advert = pkt.data + + -- handle advertisement; this will re-create all unit sub-sessions + _handle_advertisement() + elseif pkt.type == SCADA_MGMT_TYPE.RTU_DEV_REMOUNT then + if pkt.length == 1 then + local unit_id = pkt.data[1] + if self.units[unit_id] ~= nil then + local unit = self.units[unit_id] ---@type unit_session + unit.invalidate_cache() + end + else + log.debug(log_header .. "SCADA RTU device re-mount packet length mismatch") + end + else + log.debug(log_header .. "handler received unsupported SCADA_MGMT packet type " .. pkt.type) + end + end + end + + -- PUBLIC FUNCTIONS -- + + -- get the session ID + function public.get_id() return id end + + -- check if a timer matches this session's watchdog + ---@nodiscard + ---@param timer number + function public.check_wd(timer) + return self.rtu_conn_watchdog.is_timer(timer) and self.connected + end + + -- close the connection + function public.close() + _close() + _send_mgmt(SCADA_MGMT_TYPE.CLOSE, {}) + println(log_header .. "connection to RTU 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 msg = in_queue.pop() + + if msg ~= nil then + if msg.qtype == mqueue.TYPE.PACKET then + -- handle a packet + _handle_packet(msg.message) + elseif msg.qtype == mqueue.TYPE.COMMAND then + -- handle instruction + elseif msg.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("RTU connection " .. id .. " closed by remote host") + log.info(log_header .. "session closed by remote host") + return self.connected + end + + ------------------ + -- update units -- + ------------------ + + local time_now = util.time() + + for i = 1, #self.units do + self.units[i].update(time_now) + 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() + + -------------------------------------------- + -- process RTU session handler out queues -- + -------------------------------------------- + + for _ = 1, self.modbus_q.length() do + -- get the next message + local msg = self.modbus_q.pop() + + if msg ~= nil then + if msg.qtype == mqueue.TYPE.PACKET then + -- handle a packet + _send_modbus(msg.message) + elseif msg.qtype == mqueue.TYPE.COMMAND then + -- handle instruction + elseif msg.qtype == mqueue.TYPE.DATA then + -- instruction with body + local cmd = msg.message ---@type queue_data + if cmd.key == unit_session.RTU_US_DATA.BUILD_CHANGED then + out_queue.push_data(svqtypes.SV_Q_DATA.RTU_BUILD_CHANGED, cmd.val) + end + end + end + end + end + + return self.connected + end + + -- handle initial advertisement + _handle_advertisement() + + return public +end + +return rtu diff --git a/supervisor/session/rtu/boilerv.lua b/supervisor/session/rtu/boilerv.lua new file mode 100644 index 0000000..2f3c231 --- /dev/null +++ b/supervisor/session/rtu/boilerv.lua @@ -0,0 +1,248 @@ +local log = require("scada-common.log") +local types = require("scada-common.types") +local util = require("scada-common.util") + +local unit_session = require("supervisor.session.rtu.unit_session") + +local boilerv = {} + +local RTU_UNIT_TYPE = types.RTU_UNIT_TYPE +local MODBUS_FCODE = types.MODBUS_FCODE + +local TXN_TYPES = { + FORMED = 1, + BUILD = 2, + STATE = 3, + TANKS = 4 +} + +local TXN_TAGS = { + "boilerv.formed", + "boilerv.build", + "boilerv.state", + "boilerv.tanks" +} + +local PERIODICS = { + FORMED = 2000, + BUILD = 1000, + STATE = 500, + TANKS = 1000 +} + +-- create a new boilerv rtu session runner +---@nodiscard +---@param session_id integer RTU session ID +---@param unit_id integer RTU unit ID +---@param advert rtu_advertisement RTU advertisement table +---@param out_queue mqueue RTU unit message out queue +function boilerv.new(session_id, unit_id, advert, out_queue) + -- type check + if advert.type ~= RTU_UNIT_TYPE.BOILER_VALVE then + log.error("attempt to instantiate boilerv RTU for type '" .. types.rtu_type_to_string(advert.type) .. "'. this is a bug.") + return nil + end + + local log_tag = "session.rtu(" .. session_id .. ").boilerv(" .. advert.index .. "): " + + local self = { + session = unit_session.new(session_id, unit_id, advert, out_queue, log_tag, TXN_TAGS), + has_build = false, + periodics = { + next_formed_req = 0, + next_build_req = 0, + next_state_req = 0, + next_tanks_req = 0 + }, + ---@class boilerv_session_db + db = { + formed = false, + build = { + last_update = 0, + length = 0, + width = 0, + height = 0, + min_pos = types.new_zero_coordinate(), + max_pos = types.new_zero_coordinate(), + boil_cap = 0.0, + steam_cap = 0, + water_cap = 0, + hcoolant_cap = 0, + ccoolant_cap = 0, + superheaters = 0, + max_boil_rate = 0.0, + }, + state = { + last_update = 0, + temperature = 0.0, + boil_rate = 0.0, + env_loss = 0.0 + }, + tanks = { + last_update = 0, + steam = types.new_empty_gas(), + steam_need = 0, + steam_fill = 0.0, + water = types.new_empty_gas(), + water_need = 0, + water_fill = 0.0, + hcool = types.new_empty_gas(), + hcool_need = 0, + hcool_fill = 0.0, + ccool = types.new_empty_gas(), + ccool_need = 0, + ccool_fill = 0.0 + } + } + } + + local public = self.session.get() + + -- PRIVATE FUNCTIONS -- + + -- query if the multiblock is formed + local function _request_formed() + -- read discrete input 1 (start = 1, count = 1) + self.session.send_request(TXN_TYPES.FORMED, MODBUS_FCODE.READ_DISCRETE_INPUTS, { 1, 1 }) + end + + -- query the build of the device + local function _request_build() + -- read input registers 1 through 12 (start = 1, count = 12) + self.session.send_request(TXN_TYPES.BUILD, MODBUS_FCODE.READ_INPUT_REGS, { 1, 12 }) + end + + -- query the state of the device + local function _request_state() + -- read input registers 13 through 15 (start = 13, count = 3) + self.session.send_request(TXN_TYPES.STATE, MODBUS_FCODE.READ_INPUT_REGS, { 13, 3 }) + end + + -- query the tanks of the device + local function _request_tanks() + -- read input registers 16 through 27 (start = 16, count = 12) + self.session.send_request(TXN_TYPES.TANKS, MODBUS_FCODE.READ_INPUT_REGS, { 16, 12 }) + end + + -- PUBLIC FUNCTIONS -- + + -- handle a packet + ---@param m_pkt modbus_frame + function public.handle_packet(m_pkt) + local txn_type = self.session.try_resolve(m_pkt) + if txn_type == false then + -- nothing to do + elseif txn_type == TXN_TYPES.FORMED then + -- formed response + -- load in data if correct length + if m_pkt.length == 1 then + self.db.formed = m_pkt.data[1] + + if not self.db.formed then self.has_build = false end + else + log.debug(log_tag .. "MODBUS transaction reply length mismatch (" .. TXN_TAGS[txn_type] .. ")") + end + elseif txn_type == TXN_TYPES.BUILD then + -- build response + -- load in data if correct length + if m_pkt.length == 12 then + self.db.build.last_update = util.time_ms() + self.db.build.length = m_pkt.data[1] + self.db.build.width = m_pkt.data[2] + self.db.build.height = m_pkt.data[3] + self.db.build.min_pos = m_pkt.data[4] + self.db.build.max_pos = m_pkt.data[5] + self.db.build.boil_cap = m_pkt.data[6] + self.db.build.steam_cap = m_pkt.data[7] + self.db.build.water_cap = m_pkt.data[8] + self.db.build.hcoolant_cap = m_pkt.data[9] + self.db.build.ccoolant_cap = m_pkt.data[10] + self.db.build.superheaters = m_pkt.data[11] + self.db.build.max_boil_rate = m_pkt.data[12] + self.has_build = true + + out_queue.push_data(unit_session.RTU_US_DATA.BUILD_CHANGED, { unit = advert.reactor, type = advert.type }) + else + log.debug(log_tag .. "MODBUS transaction reply length mismatch (" .. TXN_TAGS[txn_type] .. ")") + end + elseif txn_type == TXN_TYPES.STATE then + -- state response + -- load in data if correct length + if m_pkt.length == 3 then + self.db.state.last_update = util.time_ms() + self.db.state.temperature = m_pkt.data[1] + self.db.state.boil_rate = m_pkt.data[2] + self.db.state.env_loss = m_pkt.data[3] + else + log.debug(log_tag .. "MODBUS transaction reply length mismatch (" .. TXN_TAGS[txn_type] .. ")") + end + elseif txn_type == TXN_TYPES.TANKS then + -- tanks response + -- load in data if correct length + if m_pkt.length == 12 then + self.db.tanks.last_update = util.time_ms() + self.db.tanks.steam = m_pkt.data[1] + self.db.tanks.steam_need = m_pkt.data[2] + self.db.tanks.steam_fill = m_pkt.data[3] + self.db.tanks.water = m_pkt.data[4] + self.db.tanks.water_need = m_pkt.data[5] + self.db.tanks.water_fill = m_pkt.data[6] + self.db.tanks.hcool = m_pkt.data[7] + self.db.tanks.hcool_need = m_pkt.data[8] + self.db.tanks.hcool_fill = m_pkt.data[9] + self.db.tanks.ccool = m_pkt.data[10] + self.db.tanks.ccool_need = m_pkt.data[11] + self.db.tanks.ccool_fill = m_pkt.data[12] + else + log.debug(log_tag .. "MODBUS transaction reply length mismatch (" .. TXN_TAGS[txn_type] .. ")") + end + elseif txn_type == nil then + log.error(log_tag .. "unknown transaction reply") + else + log.error(log_tag .. "unknown transaction type " .. txn_type) + end + end + + -- update this runner + ---@param time_now integer milliseconds + function public.update(time_now) + if self.periodics.next_formed_req <= time_now then + _request_formed() + self.periodics.next_formed_req = time_now + PERIODICS.FORMED + end + + if self.db.formed then + if not self.has_build and self.periodics.next_build_req <= time_now then + _request_build() + self.periodics.next_build_req = time_now + PERIODICS.BUILD + end + + if self.periodics.next_state_req <= time_now then + _request_state() + self.periodics.next_state_req = time_now + PERIODICS.STATE + end + + if self.periodics.next_tanks_req <= time_now then + _request_tanks() + self.periodics.next_tanks_req = time_now + PERIODICS.TANKS + end + end + + self.session.post_update() + end + + -- invalidate build cache + function public.invalidate_cache() + self.periodics.next_formed_req = 0 + self.periodics.next_build_req = 0 + self.has_build = false + end + + -- get the unit session database + ---@nodiscard + function public.get_db() return self.db end + + return public +end + +return boilerv diff --git a/supervisor/session/rtu/envd.lua b/supervisor/session/rtu/envd.lua new file mode 100644 index 0000000..3b4b666 --- /dev/null +++ b/supervisor/session/rtu/envd.lua @@ -0,0 +1,109 @@ +local log = require("scada-common.log") +local types = require("scada-common.types") +local util = require("scada-common.util") + +local unit_session = require("supervisor.session.rtu.unit_session") + +local envd = {} + +local RTU_UNIT_TYPE = types.RTU_UNIT_TYPE +local MODBUS_FCODE = types.MODBUS_FCODE + +local TXN_TYPES = { + RAD = 1 +} + +local TXN_TAGS = { + "envd.radiation" +} + +local PERIODICS = { + RAD = 500 +} + +-- create a new environment detector rtu session runner +---@nodiscard +---@param session_id integer +---@param unit_id integer +---@param advert rtu_advertisement +---@param out_queue mqueue +function envd.new(session_id, unit_id, advert, out_queue) + -- type check + if advert.type ~= RTU_UNIT_TYPE.ENV_DETECTOR then + log.error("attempt to instantiate envd RTU for type '" .. types.rtu_type_to_string(advert.type) .. "'. this is a bug.") + return nil + end + + local log_tag = "session.rtu(" .. session_id .. ").envd(" .. advert.index .. "): " + + local self = { + session = unit_session.new(session_id, unit_id, advert, out_queue, log_tag, TXN_TAGS), + periodics = { + next_rad_req = 0 + }, + ---@class envd_session_db + db = { + last_update = 0, + radiation = types.new_zero_radiation_reading(), + radiation_raw = 0 + } + } + + local public = self.session.get() + + -- PRIVATE FUNCTIONS -- + + -- query the radiation readings of the device + local function _request_radiation() + -- read input registers 1 and 2 (start = 1, count = 2) + self.session.send_request(TXN_TYPES.RAD, MODBUS_FCODE.READ_INPUT_REGS, { 1, 2 }) + end + + -- PUBLIC FUNCTIONS -- + + -- handle a packet + ---@param m_pkt modbus_frame + function public.handle_packet(m_pkt) + local txn_type = self.session.try_resolve(m_pkt) + if txn_type == false then + -- nothing to do + elseif txn_type == TXN_TYPES.RAD then + -- radiation status response + if m_pkt.length == 2 then + self.db.last_update = util.time_ms() + self.db.radiation = m_pkt.data[1] + self.db.radiation_raw = m_pkt.data[2] + else + log.debug(log_tag .. "MODBUS transaction reply length mismatch (" .. TXN_TAGS[txn_type] .. ")") + end + elseif txn_type == nil then + log.error(log_tag .. "unknown transaction reply") + else + log.error(log_tag .. "unknown transaction type " .. txn_type) + end + end + + -- update this runner + ---@param time_now integer milliseconds + function public.update(time_now) + if self.periodics.next_rad_req <= time_now then + _request_radiation() + self.periodics.next_rad_req = time_now + PERIODICS.RAD + end + + self.session.post_update() + end + + -- invalidate build cache + function public.invalidate_cache() + -- no build cache for this device + end + + -- get the unit session database + ---@nodiscard + function public.get_db() return self.db end + + return public +end + +return envd diff --git a/supervisor/session/rtu/imatrix.lua b/supervisor/session/rtu/imatrix.lua new file mode 100644 index 0000000..0b120b4 --- /dev/null +++ b/supervisor/session/rtu/imatrix.lua @@ -0,0 +1,222 @@ +local log = require("scada-common.log") +local types = require("scada-common.types") +local util = require("scada-common.util") + +local unit_session = require("supervisor.session.rtu.unit_session") + +local imatrix = {} + +local RTU_UNIT_TYPE = types.RTU_UNIT_TYPE +local MODBUS_FCODE = types.MODBUS_FCODE + +local TXN_TYPES = { + FORMED = 1, + BUILD = 2, + STATE = 3, + TANKS = 4 +} + +local TXN_TAGS = { + "imatrix.formed", + "imatrix.build", + "imatrix.state", + "imatrix.tanks" +} + +local PERIODICS = { + FORMED = 2000, + BUILD = 1000, + STATE = 500, + TANKS = 1000 +} + +-- create a new imatrix rtu session runner +---@nodiscard +---@param session_id integer RTU session ID +---@param unit_id integer RTU unit ID +---@param advert rtu_advertisement RTU advertisement table +---@param out_queue mqueue RTU unit message out queue +function imatrix.new(session_id, unit_id, advert, out_queue) + -- type check + if advert.type ~= RTU_UNIT_TYPE.IMATRIX then + log.error("attempt to instantiate imatrix RTU for type '" .. types.rtu_type_to_string(advert.type) .. "'. this is a bug.") + return nil + end + + local log_tag = "session.rtu(" .. session_id .. ").imatrix(" .. advert.index .. "): " + + local self = { + session = unit_session.new(session_id, unit_id, advert, out_queue, log_tag, TXN_TAGS), + has_build = false, + periodics = { + next_formed_req = 0, + next_build_req = 0, + next_state_req = 0, + next_tanks_req = 0 + }, + ---@class imatrix_session_db + db = { + formed = false, + build = { + last_update = 0, + length = 0, + width = 0, + height = 0, + min_pos = types.new_zero_coordinate(), + max_pos = types.new_zero_coordinate(), + max_energy = 0, + transfer_cap = 0, + cells = 0, + providers = 0 + }, + state = { + last_update = 0, + last_input = 0, + last_output = 0 + }, + tanks = { + last_update = 0, + energy = 0, + energy_need = 0, + energy_fill = 0.0 + } + } + } + + local public = self.session.get() + + -- PRIVATE FUNCTIONS -- + + -- query if the multiblock is formed + local function _request_formed() + -- read discrete input 1 (start = 1, count = 1) + self.session.send_request(TXN_TYPES.FORMED, MODBUS_FCODE.READ_DISCRETE_INPUTS, { 1, 1 }) + end + + -- query the build of the device + local function _request_build() + -- read input registers 1 through 9 (start = 1, count = 9) + self.session.send_request(TXN_TYPES.BUILD, MODBUS_FCODE.READ_INPUT_REGS, { 1, 9 }) + end + + -- query the state of the device + local function _request_state() + -- read input register 10 through 11 (start = 10, count = 2) + self.session.send_request(TXN_TYPES.STATE, MODBUS_FCODE.READ_INPUT_REGS, { 10, 2 }) + end + + -- query the tanks of the device + local function _request_tanks() + -- read input registers 12 through 15 (start = 12, count = 3) + self.session.send_request(TXN_TYPES.TANKS, MODBUS_FCODE.READ_INPUT_REGS, { 12, 3 }) + end + + -- PUBLIC FUNCTIONS -- + + -- handle a packet + ---@param m_pkt modbus_frame + function public.handle_packet(m_pkt) + local txn_type = self.session.try_resolve(m_pkt) + if txn_type == false then + -- nothing to do + elseif txn_type == TXN_TYPES.FORMED then + -- formed response + -- load in data if correct length + if m_pkt.length == 1 then + self.db.formed = m_pkt.data[1] + + if not self.db.formed then self.has_build = false end + else + log.debug(log_tag .. "MODBUS transaction reply length mismatch (" .. TXN_TAGS[txn_type] .. ")") + end + elseif txn_type == TXN_TYPES.BUILD then + -- build response + -- load in data if correct length + if m_pkt.length == 9 then + self.db.build.last_update = util.time_ms() + self.db.build.length = m_pkt.data[1] + self.db.build.width = m_pkt.data[2] + self.db.build.height = m_pkt.data[3] + self.db.build.min_pos = m_pkt.data[4] + self.db.build.max_pos = m_pkt.data[5] + self.db.build.max_energy = m_pkt.data[6] + self.db.build.transfer_cap = m_pkt.data[7] + self.db.build.cells = m_pkt.data[8] + self.db.build.providers = m_pkt.data[9] + self.has_build = true + + out_queue.push_data(unit_session.RTU_US_DATA.BUILD_CHANGED, { unit = advert.reactor, type = advert.type }) + else + log.debug(log_tag .. "MODBUS transaction reply length mismatch (" .. TXN_TAGS[txn_type] .. ")") + end + elseif txn_type == TXN_TYPES.STATE then + -- state response + -- load in data if correct length + if m_pkt.length == 2 then + self.db.state.last_update = util.time_ms() + self.db.state.last_input = m_pkt.data[1] + self.db.state.last_output = m_pkt.data[2] + else + log.debug(log_tag .. "MODBUS transaction reply length mismatch (" .. TXN_TAGS[txn_type] .. ")") + end + elseif txn_type == TXN_TYPES.TANKS then + -- tanks response + -- load in data if correct length + if m_pkt.length == 3 then + self.db.tanks.last_update = util.time_ms() + self.db.tanks.energy = m_pkt.data[1] + self.db.tanks.energy_need = m_pkt.data[2] + self.db.tanks.energy_fill = m_pkt.data[3] + else + log.debug(log_tag .. "MODBUS transaction reply length mismatch (" .. TXN_TAGS[txn_type] .. ")") + end + elseif txn_type == nil then + log.error(log_tag .. "unknown transaction reply") + else + log.error(log_tag .. "unknown transaction type " .. txn_type) + end + end + + -- update this runner + ---@param time_now integer milliseconds + function public.update(time_now) + if self.periodics.next_formed_req <= time_now then + _request_formed() + self.periodics.next_formed_req = time_now + PERIODICS.FORMED + end + + if self.db.formed then + if not self.has_build and self.periodics.next_build_req <= time_now then + _request_build() + self.periodics.next_build_req = time_now + PERIODICS.BUILD + end + + if self.periodics.next_state_req <= time_now then + _request_state() + self.periodics.next_state_req = time_now + PERIODICS.STATE + end + + if self.periodics.next_tanks_req <= time_now then + _request_tanks() + self.periodics.next_tanks_req = time_now + PERIODICS.TANKS + end + end + + self.session.post_update() + end + + -- invalidate build cache + function public.invalidate_cache() + self.periodics.next_formed_req = 0 + self.periodics.next_build_req = 0 + self.has_build = false + end + + -- get the unit session database + ---@nodiscard + function public.get_db() return self.db end + + return public +end + +return imatrix diff --git a/supervisor/session/rtu/qtypes.lua b/supervisor/session/rtu/qtypes.lua new file mode 100644 index 0000000..92a927f --- /dev/null +++ b/supervisor/session/rtu/qtypes.lua @@ -0,0 +1,16 @@ +---@class rtu_unit_qtypes +local qtypes = {} + +local TBV_RTU_S_CMDS = { + INC_DUMP_MODE = 1, + DEC_DUMP_MODE = 2 +} + +local TBV_RTU_S_DATA = { + SET_DUMP_MODE = 1 +} + +qtypes.TBV_RTU_S_CMDS = TBV_RTU_S_CMDS +qtypes.TBV_RTU_S_DATA = TBV_RTU_S_DATA + +return qtypes diff --git a/supervisor/session/rtu/redstone.lua b/supervisor/session/rtu/redstone.lua new file mode 100644 index 0000000..7c813a2 --- /dev/null +++ b/supervisor/session/rtu/redstone.lua @@ -0,0 +1,392 @@ +local log = require("scada-common.log") +local rsio = require("scada-common.rsio") +local types = require("scada-common.types") +local util = require("scada-common.util") + +local unit_session = require("supervisor.session.rtu.unit_session") + +local redstone = {} + +local RTU_UNIT_TYPE = types.RTU_UNIT_TYPE +local MODBUS_FCODE = types.MODBUS_FCODE + +local IO_PORT = rsio.IO +local IO_LVL = rsio.IO_LVL +local IO_DIR = rsio.IO_DIR +local IO_MODE = rsio.IO_MODE + +local TXN_READY = -1 + +local TXN_TYPES = { + DI_READ = 1, + COIL_WRITE = 2, + COIL_READ = 3, + INPUT_REG_READ = 4, + HOLD_REG_WRITE = 5, + HOLD_REG_READ = 6 +} + +local TXN_TAGS = { + "redstone.di_read", + "redstone.coil_write", + "redstone.coil_read", + "redstone.input_reg_read", + "redstone.hold_reg_write", + "redstone.hold_reg_read" +} + +local PERIODICS = { + INPUT_READ = 200, + OUTPUT_SYNC = 200 +} + +---@class phy_entry +---@field phy IO_LVL +---@field req IO_LVL + +-- create a new redstone rtu session runner +---@nodiscard +---@param session_id integer +---@param unit_id integer +---@param advert rtu_advertisement +---@param out_queue mqueue +function redstone.new(session_id, unit_id, advert, out_queue) + -- type check + if advert.type ~= RTU_UNIT_TYPE.REDSTONE then + log.error("attempt to instantiate redstone RTU for type '" .. types.rtu_type_to_string(advert.type) .. "'. this is a bug.") + return nil + end + + -- for redstone, use unit ID not device index + local log_tag = "session.rtu(" .. session_id .. ").redstone(" .. unit_id .. "): " + + local self = { + session = unit_session.new(session_id, unit_id, advert, out_queue, log_tag, TXN_TAGS), + has_di = false, + has_do = false, + has_ai = false, + has_ao = false, + periodics = { + next_di_req = 0, + next_cl_sync = 0, + next_ir_req = 0, + next_hr_sync = 0 + }, + ---@class rs_io_list + io_list = { + digital_in = {}, -- discrete inputs + digital_out = {}, -- coils + analog_in = {}, -- input registers + analog_out = {} -- holding registers + }, + phy_trans = { coils = -1, hold_regs = -1 }, + -- last set/read ports (reflecting the current state of the RTU) + ---@class rs_io_states + phy_io = { + digital_in = {}, -- discrete inputs + digital_out = {}, -- coils + analog_in = {}, -- input registers + analog_out = {} -- holding registers + }, + ---@class redstone_session_db + db = { + -- read/write functions for connected I/O + io = {} + } + } + + local public = self.session.get() + + -- INITIALIZE -- + + -- create all ports as disconnected + for _ = 1, #IO_PORT do + table.insert(self.db, IO_LVL.DISCONNECT) + end + + -- setup I/O + for i = 1, #advert.rsio do + local port = advert.rsio[i] + + if rsio.is_valid_port(port) then + local mode = rsio.get_io_mode(port) + + if mode == IO_MODE.DIGITAL_IN then + self.has_di = true + table.insert(self.io_list.digital_in, port) + + self.phy_io.digital_in[port] = { phy = IO_LVL.FLOATING, req = IO_LVL.FLOATING } + + ---@class rs_db_dig_io + local io_f = { + ---@nodiscard + read = function () return rsio.digital_is_active(port, self.phy_io.digital_in[port].phy) end, + ---@param active boolean + write = function (active) end + } + + self.db.io[port] = io_f + elseif mode == IO_MODE.DIGITAL_OUT then + self.has_do = true + table.insert(self.io_list.digital_out, port) + + self.phy_io.digital_out[port] = { phy = IO_LVL.FLOATING, req = IO_LVL.FLOATING } + + ---@class rs_db_dig_io + local io_f = { + ---@nodiscard + read = function () return rsio.digital_is_active(port, self.phy_io.digital_out[port].phy) end, + ---@param active boolean + write = function (active) + local level = rsio.digital_write_active(port, active) + if level ~= nil then self.phy_io.digital_out[port].req = level end + end + } + + self.db.io[port] = io_f + elseif mode == IO_MODE.ANALOG_IN then + self.has_ai = true + table.insert(self.io_list.analog_in, port) + + self.phy_io.analog_in[port] = { phy = 0, req = 0 } + + ---@class rs_db_ana_io + local io_f = { + ---@nodiscard + ---@return integer + read = function () return self.phy_io.analog_in[port].phy end, + ---@param value integer + write = function (value) end + } + + self.db.io[port] = io_f + elseif mode == IO_MODE.ANALOG_OUT then + self.has_ao = true + table.insert(self.io_list.analog_out, port) + + self.phy_io.analog_out[port] = { phy = 0, req = 0 } + + ---@class rs_db_ana_io + local io_f = { + ---@nodiscard + ---@return integer + read = function () return self.phy_io.analog_out[port].phy end, + ---@param value integer + write = function (value) + if value >= 0 and value <= 15 then + self.phy_io.analog_out[port].req = value + end + end + } + + self.db.io[port] = io_f + else + -- should be unreachable code, we already validated ports + log.error(util.c(log_tag, "failed to identify advertisement port IO mode (", port, ")"), true) + return nil + end + else + log.error(util.c(log_tag, "invalid advertisement port (", port, ")"), true) + return nil + end + end + + -- PRIVATE FUNCTIONS -- + + -- query discrete inputs + local function _request_discrete_inputs() + self.session.send_request(TXN_TYPES.DI_READ, MODBUS_FCODE.READ_DISCRETE_INPUTS, { 1, #self.io_list.digital_in }) + end + + -- query input registers + local function _request_input_registers() + self.session.send_request(TXN_TYPES.INPUT_REG_READ, MODBUS_FCODE.READ_INPUT_REGS, { 1, #self.io_list.analog_in }) + end + + -- write all coil outputs + local function _write_coils() + local params = { 1 } + + local outputs = self.phy_io.digital_out + for i = 1, #self.io_list.digital_out do + local port = self.io_list.digital_out[i] + table.insert(params, outputs[port].req) + end + + self.phy_trans.coils = self.session.send_request(TXN_TYPES.COIL_WRITE, MODBUS_FCODE.WRITE_MUL_COILS, params) + end + + -- read all coil outputs + local function _read_coils() + self.session.send_request(TXN_TYPES.COIL_READ, MODBUS_FCODE.READ_COILS, { 1, #self.io_list.digital_out }) + end + + -- write all holding register outputs + local function _write_holding_registers() + local params = { 1 } + + local outputs = self.phy_io.analog_out + for i = 1, #self.io_list.analog_out do + local port = self.io_list.analog_out[i] + table.insert(params, outputs[port].req) + end + + self.phy_trans.hold_regs = self.session.send_request(TXN_TYPES.HOLD_REG_WRITE, MODBUS_FCODE.WRITE_MUL_HOLD_REGS, params) + end + + -- read all holding register outputs + local function _read_holding_registers() + self.session.send_request(TXN_TYPES.HOLD_REG_READ, MODBUS_FCODE.READ_MUL_HOLD_REGS, { 1, #self.io_list.analog_out }) + end + + -- PUBLIC FUNCTIONS -- + + -- handle a packet + ---@param m_pkt modbus_frame + function public.handle_packet(m_pkt) + local txn_type = self.session.try_resolve(m_pkt) + if txn_type == false then + -- check if this is a failed write request + -- redstone operations are always immediately executed, so this would not be from an ACK or BUSY + if m_pkt.txn_id == self.phy_trans.coils then + self.phy_trans.coils = TXN_READY + log.debug(log_tag .. "failed to write coils, retrying soon") + elseif m_pkt.txn_id == self.phy_trans.hold_regs then + self.phy_trans.hold_regs = TXN_READY + log.debug(log_tag .. "failed to write holding registers, retrying soon") + end + elseif txn_type == TXN_TYPES.DI_READ then + -- discrete input read response + if m_pkt.length == #self.io_list.digital_in then + for i = 1, m_pkt.length do + local port = self.io_list.digital_in[i] + local value = m_pkt.data[i] + + self.phy_io.digital_in[port].phy = value + end + else + log.debug(log_tag .. "MODBUS transaction reply length mismatch (" .. TXN_TAGS[txn_type] .. ")") + end + elseif txn_type == TXN_TYPES.INPUT_REG_READ then + -- input register read response + if m_pkt.length == #self.io_list.analog_in then + for i = 1, m_pkt.length do + local port = self.io_list.analog_in[i] + local value = m_pkt.data[i] + + self.phy_io.analog_in[port].phy = value + end + else + log.debug(log_tag .. "MODBUS transaction reply length mismatch (" .. TXN_TAGS[txn_type] .. ")") + end + elseif txn_type == TXN_TYPES.COIL_WRITE then + -- successful acknowledgement, read back + _read_coils() + elseif txn_type == TXN_TYPES.COIL_READ then + -- update phy I/O table + -- if there are multiple outputs for the same port, they will overwrite eachother (but *should* be identical) + -- given these are redstone outputs, if one worked they all should have, so no additional verification will be done + if m_pkt.length == #self.io_list.digital_out then + for i = 1, m_pkt.length do + local port = self.io_list.digital_out[i] + local value = m_pkt.data[i] + + self.phy_io.digital_out[port].phy = value + if self.phy_io.digital_out[port].req == IO_LVL.FLOATING then + self.phy_io.digital_out[port].req = value + end + end + + self.phy_trans.coils = TXN_READY + else + log.debug(log_tag .. "MODBUS transaction reply length mismatch (" .. TXN_TAGS[txn_type] .. ")") + end + elseif txn_type == TXN_TYPES.HOLD_REG_WRITE then + -- successful acknowledgement, read back + _read_holding_registers() + elseif txn_type == TXN_TYPES.HOLD_REG_READ then + -- update phy I/O table + -- if there are multiple outputs for the same port, they will overwrite eachother (but *should* be identical) + -- given these are redstone outputs, if one worked they all should have, so no additional verification will be done + if m_pkt.length == #self.io_list.analog_out then + for i = 1, m_pkt.length do + local port = self.io_list.analog_out[i] + local value = m_pkt.data[i] + + self.phy_io.analog_out[port].phy = value + end + else + log.debug(log_tag .. "MODBUS transaction reply length mismatch (" .. TXN_TAGS[txn_type] .. ")") + end + + self.phy_trans.hold_regs = TXN_READY + elseif txn_type == nil then + log.error(log_tag .. "unknown transaction reply") + else + log.error(log_tag .. "unknown transaction type " .. txn_type) + end + end + + -- update this runner + ---@param time_now integer milliseconds + function public.update(time_now) + -- poll digital inputs + if self.has_di then + if self.periodics.next_di_req <= time_now then + _request_discrete_inputs() + self.periodics.next_di_req = time_now + PERIODICS.INPUT_READ + end + end + + -- sync digital outputs + if self.has_do then + if (self.periodics.next_cl_sync <= time_now) and (self.phy_trans.coils == TXN_READY) then + for _, entry in pairs(self.phy_io.digital_out) do + if entry.phy ~= entry.req then + _write_coils() + break + end + end + + self.periodics.next_cl_sync = time_now + PERIODICS.OUTPUT_SYNC + end + end + + -- poll analog inputs + if self.has_ai then + if self.periodics.next_ir_req <= time_now then + _request_input_registers() + self.periodics.next_ir_req = time_now + PERIODICS.INPUT_READ + end + end + + -- sync analog outputs + if self.has_ao then + if (self.periodics.next_hr_sync <= time_now) and (self.phy_trans.hold_regs == TXN_READY) then + for _, entry in pairs(self.phy_io.analog_out) do + if entry.phy ~= entry.req then + _write_holding_registers() + break + end + end + + self.periodics.next_hr_sync = time_now + PERIODICS.OUTPUT_SYNC + end + end + + self.session.post_update() + end + + -- invalidate build cache + function public.invalidate_cache() + -- no build cache for this device + end + + -- get the unit session database + ---@nodiscard + function public.get_db() return self.db end + + return public +end + +return redstone diff --git a/supervisor/session/rtu/sna.lua b/supervisor/session/rtu/sna.lua new file mode 100644 index 0000000..006222b --- /dev/null +++ b/supervisor/session/rtu/sna.lua @@ -0,0 +1,185 @@ +local log = require("scada-common.log") +local types = require("scada-common.types") +local util = require("scada-common.util") + +local unit_session = require("supervisor.session.rtu.unit_session") + +local sna = {} + +local RTU_UNIT_TYPE = types.RTU_UNIT_TYPE +local MODBUS_FCODE = types.MODBUS_FCODE + +local TXN_TYPES = { + BUILD = 1, + STATE = 2, + TANKS = 3 +} + +local TXN_TAGS = { + "sna.build", + "sna.state", + "sna.tanks" +} + +local PERIODICS = { + BUILD = 1000, + STATE = 500, + TANKS = 1000 +} + +-- create a new sna rtu session runner +---@nodiscard +---@param session_id integer RTU session ID +---@param unit_id integer RTU unit ID +---@param advert rtu_advertisement RTU advertisement table +---@param out_queue mqueue RTU unit message out queue +function sna.new(session_id, unit_id, advert, out_queue) + -- type check + if advert.type ~= RTU_UNIT_TYPE.SNA then + log.error("attempt to instantiate sna RTU for type '" .. types.rtu_type_to_string(advert.type) .. "'. this is a bug.") + return nil + end + + local log_tag = "session.rtu(" .. session_id .. ").sna(" .. advert.index .. "): " + + local self = { + session = unit_session.new(session_id, unit_id, advert, out_queue, log_tag, TXN_TAGS), + has_build = false, + periodics = { + next_build_req = 0, + next_state_req = 0, + next_tanks_req = 0 + }, + ---@class sna_session_db + db = { + build = { + last_update = 0, + input_cap = 0, + output_cap = 0 + }, + state = { + last_update = 0, + production_rate = 0.0, + peak_production = 0.0 + }, + tanks = { + last_update = 0, + input = types.new_empty_gas(), + input_need = 0, + input_fill = 0.0, + output = types.new_empty_gas(), + output_need = 0, + output_fill = 0.0 + } + } + } + + local public = self.session.get() + + -- PRIVATE FUNCTIONS -- + + -- query the build of the device + local function _request_build() + -- read input registers 1 through 2 (start = 1, count = 2) + self.session.send_request(TXN_TYPES.BUILD, MODBUS_FCODE.READ_INPUT_REGS, { 1, 2 }) + end + + -- query the state of the device + local function _request_state() + -- read input registers 3 through 4 (start = 3, count = 2) + self.session.send_request(TXN_TYPES.STATE, MODBUS_FCODE.READ_INPUT_REGS, { 3, 2 }) + end + + -- query the tanks of the device + local function _request_tanks() + -- read input registers 5 through 10 (start = 5, count = 6) + self.session.send_request(TXN_TYPES.TANKS, MODBUS_FCODE.READ_INPUT_REGS, { 5, 6 }) + end + + -- PUBLIC FUNCTIONS -- + + -- handle a packet + ---@param m_pkt modbus_frame + function public.handle_packet(m_pkt) + local txn_type = self.session.try_resolve(m_pkt) + if txn_type == false then + -- nothing to do + elseif txn_type == TXN_TYPES.BUILD then + -- build response + -- load in data if correct length + if m_pkt.length == 2 then + self.db.build.last_update = util.time_ms() + self.db.build.input_cap = m_pkt.data[1] + self.db.build.output_cap = m_pkt.data[2] + self.has_build = true + + out_queue.push_data(unit_session.RTU_US_DATA.BUILD_CHANGED, { unit = advert.reactor, type = advert.type }) + else + log.debug(log_tag .. "MODBUS transaction reply length mismatch (" .. TXN_TAGS[txn_type] .. ")") + end + elseif txn_type == TXN_TYPES.STATE then + -- state response + -- load in data if correct length + if m_pkt.length == 2 then + self.db.state.last_update = util.time_ms() + self.db.state.production_rate = m_pkt.data[1] + self.db.state.peak_production = m_pkt.data[2] + else + log.debug(log_tag .. "MODBUS transaction reply length mismatch (" .. TXN_TAGS[txn_type] .. ")") + end + elseif txn_type == TXN_TYPES.TANKS then + -- tanks response + -- load in data if correct length + if m_pkt.length == 6 then + self.db.tanks.last_update = util.time_ms() + self.db.tanks.input = m_pkt.data[1] + self.db.tanks.input_need = m_pkt.data[2] + self.db.tanks.input_fill = m_pkt.data[3] + self.db.tanks.output = m_pkt.data[4] + self.db.tanks.output_need = m_pkt.data[5] + self.db.tanks.output_fill = m_pkt.data[6] + else + log.debug(log_tag .. "MODBUS transaction reply length mismatch (" .. TXN_TAGS[txn_type] .. ")") + end + elseif txn_type == nil then + log.error(log_tag .. "unknown transaction reply") + else + log.error(log_tag .. "unknown transaction type " .. txn_type) + end + end + + -- update this runner + ---@param time_now integer milliseconds + function public.update(time_now) + if not self.has_build and self.periodics.next_build_req <= time_now then + _request_build() + self.periodics.next_build_req = time_now + PERIODICS.BUILD + end + + if self.periodics.next_state_req <= time_now then + _request_state() + self.periodics.next_state_req = time_now + PERIODICS.STATE + end + + if self.periodics.next_tanks_req <= time_now then + _request_tanks() + self.periodics.next_tanks_req = time_now + PERIODICS.TANKS + end + + self.session.post_update() + end + + -- invalidate build cache + function public.invalidate_cache() + self.periodics.next_build_req = 0 + self.has_build = false + end + + -- get the unit session database + ---@nodiscard + function public.get_db() return self.db end + + return public +end + +return sna diff --git a/supervisor/session/rtu/sps.lua b/supervisor/session/rtu/sps.lua new file mode 100644 index 0000000..da036cd --- /dev/null +++ b/supervisor/session/rtu/sps.lua @@ -0,0 +1,232 @@ +local log = require("scada-common.log") +local types = require("scada-common.types") +local util = require("scada-common.util") + +local unit_session = require("supervisor.session.rtu.unit_session") + +local sps = {} + +local RTU_UNIT_TYPE = types.RTU_UNIT_TYPE +local MODBUS_FCODE = types.MODBUS_FCODE + +local TXN_TYPES = { + FORMED = 1, + BUILD = 2, + STATE = 3, + TANKS = 4 +} + +local TXN_TAGS = { + "sps.formed", + "sps.build", + "sps.state", + "sps.tanks" +} + +local PERIODICS = { + FORMED = 2000, + BUILD = 1000, + STATE = 500, + TANKS = 1000 +} + +-- create a new sps rtu session runner +---@nodiscard +---@param session_id integer RTU session ID +---@param unit_id integer RTU unit ID +---@param advert rtu_advertisement RTU advertisement table +---@param out_queue mqueue RTU unit message out queue +function sps.new(session_id, unit_id, advert, out_queue) + -- type check + if advert.type ~= RTU_UNIT_TYPE.SPS then + log.error("attempt to instantiate sps RTU for type '" .. types.rtu_type_to_string(advert.type) .. "'. this is a bug.") + return nil + end + + local log_tag = "session.rtu(" .. session_id .. ").sps(" .. advert.index .. "): " + + local self = { + session = unit_session.new(session_id, unit_id, advert, out_queue, log_tag, TXN_TAGS), + has_build = false, + periodics = { + next_formed_req = 0, + next_build_req = 0, + next_state_req = 0, + next_tanks_req = 0 + }, + ---@class sps_session_db + db = { + formed = false, + build = { + last_update = 0, + length = 0, + width = 0, + height = 0, + min_pos = types.new_zero_coordinate(), + max_pos = types.new_zero_coordinate(), + coils = 0, + input_cap = 0, + output_cap = 0, + max_energy = 0 + }, + state = { + last_update = 0, + process_rate = 0.0 + }, + tanks = { + last_update = 0, + input = types.new_empty_gas(), + input_need = 0, + input_fill = 0.0, + output = types.new_empty_gas(), + output_need = 0, + output_fill = 0.0, + energy = 0, + energy_need = 0, + energy_fill = 0.0 + } + } + } + + local public = self.session.get() + + -- PRIVATE FUNCTIONS -- + + -- query if the multiblock is formed + local function _request_formed() + -- read discrete input 1 (start = 1, count = 1) + self.session.send_request(TXN_TYPES.FORMED, MODBUS_FCODE.READ_DISCRETE_INPUTS, { 1, 1 }) + end + + -- query the build of the device + local function _request_build() + -- read input registers 1 through 9 (start = 1, count = 9) + self.session.send_request(TXN_TYPES.BUILD, MODBUS_FCODE.READ_INPUT_REGS, { 1, 9 }) + end + + -- query the state of the device + local function _request_state() + -- read input register 10 (start = 10, count = 1) + self.session.send_request(TXN_TYPES.STATE, MODBUS_FCODE.READ_INPUT_REGS, { 10, 1 }) + end + + -- query the tanks of the device + local function _request_tanks() + -- read input registers 11 through 19 (start = 11, count = 9) + self.session.send_request(TXN_TYPES.TANKS, MODBUS_FCODE.READ_INPUT_REGS, { 11, 9 }) + end + + -- PUBLIC FUNCTIONS -- + + -- handle a packet + ---@param m_pkt modbus_frame + function public.handle_packet(m_pkt) + local txn_type = self.session.try_resolve(m_pkt) + if txn_type == false then + -- nothing to do + elseif txn_type == TXN_TYPES.FORMED then + -- formed response + -- load in data if correct length + if m_pkt.length == 1 then + self.db.formed = m_pkt.data[1] + + if not self.db.formed then self.has_build = false end + else + log.debug(log_tag .. "MODBUS transaction reply length mismatch (" .. TXN_TAGS[txn_type] .. ")") + end + elseif txn_type == TXN_TYPES.BUILD then + -- build response + -- load in data if correct length + if m_pkt.length == 9 then + self.db.build.last_update = util.time_ms() + self.db.build.length = m_pkt.data[1] + self.db.build.width = m_pkt.data[2] + self.db.build.height = m_pkt.data[3] + self.db.build.min_pos = m_pkt.data[4] + self.db.build.max_pos = m_pkt.data[5] + self.db.build.coils = m_pkt.data[6] + self.db.build.input_cap = m_pkt.data[7] + self.db.build.output_cap = m_pkt.data[8] + self.db.build.max_energy = m_pkt.data[9] + self.has_build = true + + out_queue.push_data(unit_session.RTU_US_DATA.BUILD_CHANGED, { unit = advert.reactor, type = advert.type }) + else + log.debug(log_tag .. "MODBUS transaction reply length mismatch (" .. TXN_TAGS[txn_type] .. ")") + end + elseif txn_type == TXN_TYPES.STATE then + -- state response + -- load in data if correct length + if m_pkt.length == 1 then + self.db.state.last_update = util.time_ms() + self.db.state.process_rate = m_pkt.data[1] + else + log.debug(log_tag .. "MODBUS transaction reply length mismatch (" .. TXN_TAGS[txn_type] .. ")") + end + elseif txn_type == TXN_TYPES.TANKS then + -- tanks response + -- load in data if correct length + if m_pkt.length == 9 then + self.db.tanks.last_update = util.time_ms() + self.db.tanks.input = m_pkt.data[1] + self.db.tanks.input_need = m_pkt.data[2] + self.db.tanks.input_fill = m_pkt.data[3] + self.db.tanks.output = m_pkt.data[4] + self.db.tanks.output_need = m_pkt.data[5] + self.db.tanks.output_fill = m_pkt.data[6] + self.db.tanks.energy = m_pkt.data[7] + self.db.tanks.energy_need = m_pkt.data[8] + self.db.tanks.energy_fill = m_pkt.data[9] + else + log.debug(log_tag .. "MODBUS transaction reply length mismatch (" .. TXN_TAGS[txn_type] .. ")") + end + elseif txn_type == nil then + log.error(log_tag .. "unknown transaction reply") + else + log.error(log_tag .. "unknown transaction type " .. txn_type) + end + end + + -- update this runner + ---@param time_now integer milliseconds + function public.update(time_now) + if self.periodics.next_formed_req <= time_now then + _request_formed() + self.periodics.next_formed_req = time_now + PERIODICS.FORMED + end + + if self.db.formed then + if not self.has_build and self.periodics.next_build_req <= time_now then + _request_build() + self.periodics.next_build_req = time_now + PERIODICS.BUILD + end + + if self.periodics.next_state_req <= time_now then + _request_state() + self.periodics.next_state_req = time_now + PERIODICS.STATE + end + + if self.periodics.next_tanks_req <= time_now then + _request_tanks() + self.periodics.next_tanks_req = time_now + PERIODICS.TANKS + end + end + + self.session.post_update() + end + + -- invalidate build cache + function public.invalidate_cache() + self.periodics.next_formed_req = 0 + self.periodics.next_build_req = 0 + self.has_build = false + end + + -- get the unit session database + ---@nodiscard + function public.get_db() return self.db end + + return public +end + +return sps diff --git a/supervisor/session/rtu/turbinev.lua b/supervisor/session/rtu/turbinev.lua new file mode 100644 index 0000000..4cf32c4 --- /dev/null +++ b/supervisor/session/rtu/turbinev.lua @@ -0,0 +1,319 @@ +local log = require("scada-common.log") +local mqueue = require("scada-common.mqueue") +local types = require("scada-common.types") +local util = require("scada-common.util") + +local qtypes = require("supervisor.session.rtu.qtypes") +local unit_session = require("supervisor.session.rtu.unit_session") + +local turbinev = {} + +local RTU_UNIT_TYPE = types.RTU_UNIT_TYPE +local DUMPING_MODE = types.DUMPING_MODE +local MODBUS_FCODE = types.MODBUS_FCODE + +local TBV_RTU_S_CMDS = qtypes.TBV_RTU_S_CMDS +local TBV_RTU_S_DATA = qtypes.TBV_RTU_S_DATA + +local TXN_TYPES = { + FORMED = 1, + BUILD = 2, + STATE = 3, + TANKS = 4, + INC_DUMP = 5, + DEC_DUMP = 6, + SET_DUMP = 7 +} + +local TXN_TAGS = { + "turbinev.formed", + "turbinev.build", + "turbinev.state", + "turbinev.tanks", + "turbinev.inc_dump", + "turbinev.dec_dump", + "turbinev.set_dump" +} + +local PERIODICS = { + FORMED = 2000, + BUILD = 1000, + STATE = 500, + TANKS = 1000 +} + +-- create a new turbinev rtu session runner +---@nodiscard +---@param session_id integer RTU session ID +---@param unit_id integer RTU unit ID +---@param advert rtu_advertisement RTU advertisement table +---@param out_queue mqueue RTU unit message out queue +function turbinev.new(session_id, unit_id, advert, out_queue) + -- type check + if advert.type ~= RTU_UNIT_TYPE.TURBINE_VALVE then + log.error("attempt to instantiate turbinev RTU for type '" .. types.rtu_type_to_string(advert.type) .. "'. this is a bug.") + return nil + end + + local log_tag = "session.rtu(" .. session_id .. ").turbinev(" .. advert.index .. "): " + + local self = { + session = unit_session.new(session_id, unit_id, advert, out_queue, log_tag, TXN_TAGS), + has_build = false, + periodics = { + next_formed_req = 0, + next_build_req = 0, + next_state_req = 0, + next_tanks_req = 0 + }, + ---@class turbinev_session_db + db = { + formed = false, + build = { + last_update = 0, + length = 0, + width = 0, + height = 0, + min_pos = types.new_zero_coordinate(), + max_pos = types.new_zero_coordinate(), + blades = 0, + coils = 0, + vents = 0, + dispersers = 0, + condensers = 0, + steam_cap = 0, + max_energy = 0, + max_flow_rate = 0, + max_production = 0, + max_water_output = 0 + }, + state = { + last_update = 0, + flow_rate = 0, + prod_rate = 0, + steam_input_rate = 0, + dumping_mode = DUMPING_MODE.IDLE ---@type dumping_mode + }, + tanks = { + last_update = 0, + steam = types.new_empty_gas(), + steam_need = 0, + steam_fill = 0.0, + energy = 0, + energy_need = 0, + energy_fill = 0.0 + } + } + } + + local public = self.session.get() + + -- PRIVATE FUNCTIONS -- + + -- increment the dumping mode + local function _inc_dump_mode() + -- write coil 1 with unused value 0 + self.session.send_request(TXN_TYPES.INC_DUMP, MODBUS_FCODE.WRITE_SINGLE_COIL, { 1, 0 }) + end + + -- decrement the dumping mode + local function _dec_dump_mode() + -- write coil 2 with unused value 0 + self.session.send_request(TXN_TYPES.DEC_DUMP, MODBUS_FCODE.WRITE_SINGLE_COIL, { 2, 0 }) + end + + -- set the dumping mode + ---@param mode dumping_mode + local function _set_dump_mode(mode) + -- write holding register 1 + self.session.send_request(TXN_TYPES.SET_DUMP, MODBUS_FCODE.WRITE_SINGLE_HOLD_REG, { 1, mode }) + end + + -- query if the multiblock is formed + local function _request_formed() + -- read discrete input 1 (start = 1, count = 1) + self.session.send_request(TXN_TYPES.FORMED, MODBUS_FCODE.READ_DISCRETE_INPUTS, { 1, 1 }) + end + + -- query the build of the device + local function _request_build() + -- read input registers 1 through 15 (start = 1, count = 15) + self.session.send_request(TXN_TYPES.BUILD, MODBUS_FCODE.READ_INPUT_REGS, { 1, 15 }) + end + + -- query the state of the device + local function _request_state() + -- read input registers 16 through 19 (start = 16, count = 4) + self.session.send_request(TXN_TYPES.STATE, MODBUS_FCODE.READ_INPUT_REGS, { 16, 4 }) + end + + -- query the tanks of the device + local function _request_tanks() + -- read input registers 20 through 25 (start = 20, count = 6) + self.session.send_request(TXN_TYPES.TANKS, MODBUS_FCODE.READ_INPUT_REGS, { 20, 6 }) + end + + -- PUBLIC FUNCTIONS -- + + -- handle a packet + ---@param m_pkt modbus_frame + function public.handle_packet(m_pkt) + local txn_type = self.session.try_resolve(m_pkt) + if txn_type == false then + -- nothing to do + elseif txn_type == TXN_TYPES.FORMED then + -- formed response + -- load in data if correct length + if m_pkt.length == 1 then + self.db.formed = m_pkt.data[1] + + if not self.db.formed then self.has_build = false end + else + log.debug(log_tag .. "MODBUS transaction reply length mismatch (" .. TXN_TAGS[txn_type] .. ")") + end + elseif txn_type == TXN_TYPES.BUILD then + -- build response + if m_pkt.length == 15 then + self.db.build.last_update = util.time_ms() + self.db.build.length = m_pkt.data[1] + self.db.build.width = m_pkt.data[2] + self.db.build.height = m_pkt.data[3] + self.db.build.min_pos = m_pkt.data[4] + self.db.build.max_pos = m_pkt.data[5] + self.db.build.blades = m_pkt.data[6] + self.db.build.coils = m_pkt.data[7] + self.db.build.vents = m_pkt.data[8] + self.db.build.dispersers = m_pkt.data[9] + self.db.build.condensers = m_pkt.data[10] + self.db.build.steam_cap = m_pkt.data[11] + self.db.build.max_energy = m_pkt.data[12] + self.db.build.max_flow_rate = m_pkt.data[13] + self.db.build.max_production = m_pkt.data[14] + self.db.build.max_water_output = m_pkt.data[15] + self.has_build = true + + out_queue.push_data(unit_session.RTU_US_DATA.BUILD_CHANGED, { unit = advert.reactor, type = advert.type }) + else + log.debug(log_tag .. "MODBUS transaction reply length mismatch (" .. TXN_TAGS[txn_type] .. ")") + end + elseif txn_type == TXN_TYPES.STATE then + -- state response + if m_pkt.length == 4 then + self.db.state.last_update = util.time_ms() + self.db.state.flow_rate = m_pkt.data[1] + self.db.state.prod_rate = m_pkt.data[2] + self.db.state.steam_input_rate = m_pkt.data[3] + self.db.state.dumping_mode = m_pkt.data[4] + else + log.debug(log_tag .. "MODBUS transaction reply length mismatch (" .. TXN_TAGS[txn_type] .. ")") + end + elseif txn_type == TXN_TYPES.TANKS then + -- tanks response + if m_pkt.length == 6 then + self.db.tanks.last_update = util.time_ms() + self.db.tanks.steam = m_pkt.data[1] + self.db.tanks.steam_need = m_pkt.data[2] + self.db.tanks.steam_fill = m_pkt.data[3] + self.db.tanks.energy = m_pkt.data[4] + self.db.tanks.energy_need = m_pkt.data[5] + self.db.tanks.energy_fill = m_pkt.data[6] + else + log.debug(log_tag .. "MODBUS transaction reply length mismatch (" .. TXN_TAGS[txn_type] .. ")") + end + elseif txn_type == TXN_TYPES.INC_DUMP or txn_type == TXN_TYPES.DEC_DUMP or txn_type == TXN_TYPES.SET_DUMP then + -- successful acknowledgement + elseif txn_type == nil then + log.error(log_tag .. "unknown transaction reply") + else + log.error(log_tag .. "unknown transaction type " .. txn_type) + end + end + + -- update this runner + ---@param time_now integer milliseconds + function public.update(time_now) + -- check command queue + while self.session.in_q.ready() do + -- get a new message to process + local msg = self.session.in_q.pop() + + if msg ~= nil then + if msg.qtype == mqueue.TYPE.COMMAND then + -- instruction + local cmd = msg.message + + if cmd == TBV_RTU_S_CMDS.INC_DUMP_MODE then + _inc_dump_mode() + elseif cmd == TBV_RTU_S_CMDS.DEC_DUMP_MODE then + _dec_dump_mode() + else + log.debug(util.c(log_tag, "unrecognized in-queue command ", cmd)) + end + elseif msg.qtype == mqueue.TYPE.DATA then + -- instruction with body + local cmd = msg.message ---@type queue_data + if cmd.key == TBV_RTU_S_DATA.SET_DUMP_MODE then + if cmd.val == types.DUMPING_MODE.IDLE or + cmd.val == types.DUMPING_MODE.DUMPING_EXCESS or + cmd.val == types.DUMPING_MODE.DUMPING then + _set_dump_mode(cmd.val) + else + log.debug(util.c(log_tag, "unrecognized dumping mode \"", cmd.val, "\"")) + end + else + log.debug(util.c(log_tag, "unrecognized in-queue data ", cmd.key)) + end + end + end + + -- max 100ms spent processing queue + if util.time() - time_now > 100 then + log.warning(log_tag .. "exceeded 100ms queue process limit") + break + end + end + + time_now = util.time() + + -- handle periodics + + if self.periodics.next_formed_req <= time_now then + _request_formed() + self.periodics.next_formed_req = time_now + PERIODICS.FORMED + end + + if self.db.formed then + if not self.has_build and self.periodics.next_build_req <= time_now then + _request_build() + self.periodics.next_build_req = time_now + PERIODICS.BUILD + end + + if self.periodics.next_state_req <= time_now then + _request_state() + self.periodics.next_state_req = time_now + PERIODICS.STATE + end + + if self.periodics.next_tanks_req <= time_now then + _request_tanks() + self.periodics.next_tanks_req = time_now + PERIODICS.TANKS + end + end + + self.session.post_update() + end + + -- invalidate build cache + function public.invalidate_cache() + self.periodics.next_formed_req = 0 + self.periodics.next_build_req = 0 + self.has_build = false + end + + -- get the unit session database + ---@nodiscard + function public.get_db() return self.db end + + return public +end + +return turbinev diff --git a/supervisor/session/rtu/txnctrl.lua b/supervisor/session/rtu/txnctrl.lua new file mode 100644 index 0000000..9766bab --- /dev/null +++ b/supervisor/session/rtu/txnctrl.lua @@ -0,0 +1,98 @@ +-- +-- MODBUS Transaction Controller +-- + +local util = require("scada-common.util") + +local txnctrl = {} + +local TIMEOUT = 2000 -- 2000ms max wait + +-- create a new transaction controller +---@nodiscard +function txnctrl.new() + local self = { + list = {}, + next_id = 0 + } + + ---@class transaction_controller + local public = {} + + local insert = table.insert + local remove = table.remove + + -- get the length of the transaction list + ---@nodiscard + function public.length() + return #self.list + end + + -- check if there are no active transactions + ---@nodiscard + function public.empty() + return #self.list == 0 + end + + -- create a new transaction of the given type + ---@nodiscard + ---@param txn_type integer + ---@return integer txn_id + function public.create(txn_type) + local txn_id = self.next_id + + insert(self.list, { + txn_id = txn_id, + txn_type = txn_type, + expiry = util.time() + TIMEOUT + }) + + self.next_id = self.next_id + 1 + + return txn_id + end + + -- mark a transaction as resolved to get its transaction type + ---@nodiscard + ---@param txn_id integer + ---@return integer txn_type + function public.resolve(txn_id) + local txn_type = nil + + for i = 1, public.length() do + if self.list[i].txn_id == txn_id then + local entry = remove(self.list, i) + txn_type = entry.txn_type + break + end + end + + return txn_type + end + + -- renew a transaction by re-inserting it with its ID and type + ---@param txn_id integer + ---@param txn_type integer + function public.renew(txn_id, txn_type) + insert(self.list, { + txn_id = txn_id, + txn_type = txn_type, + expiry = util.time() + TIMEOUT + }) + end + + -- close timed-out transactions + function public.cleanup() + local now = util.time() + util.filter_table(self.list, function (txn) return txn.expiry > now end) + end + + -- clear the transaction list + function public.clear() + self.list = {} + end + + return public +end + +return txnctrl diff --git a/supervisor/session/rtu/unit_session.lua b/supervisor/session/rtu/unit_session.lua new file mode 100644 index 0000000..700f9b1 --- /dev/null +++ b/supervisor/session/rtu/unit_session.lua @@ -0,0 +1,198 @@ +local comms = require("scada-common.comms") +local log = require("scada-common.log") +local mqueue = require("scada-common.mqueue") +local types = require("scada-common.types") +local util = require("scada-common.util") + +local txnctrl = require("supervisor.session.rtu.txnctrl") + +local unit_session = {} + +local PROTOCOL = comms.PROTOCOL +local MODBUS_FCODE = types.MODBUS_FCODE +local MODBUS_EXCODE = types.MODBUS_EXCODE + +local RTU_US_CMDS = { +} + +local RTU_US_DATA = { + BUILD_CHANGED = 1 +} + +unit_session.RTU_US_CMDS = RTU_US_CMDS +unit_session.RTU_US_DATA = RTU_US_DATA + +-- create a new unit session runner +---@nodiscard +---@param session_id integer RTU session ID +---@param unit_id integer MODBUS unit ID +---@param advert rtu_advertisement RTU advertisement for this unit +---@param out_queue mqueue send queue +---@param log_tag string logging tag +---@param txn_tags table transaction log tags +function unit_session.new(session_id, unit_id, advert, out_queue, log_tag, txn_tags) + local self = { + device_index = advert.index, + reactor = advert.reactor, + transaction_controller = txnctrl.new(), + connected = true, + device_fail = false + } + + ---@class _unit_session + local protected = { + in_q = mqueue.new() + } + + ---@class unit_session + local public = {} + + -- PROTECTED FUNCTIONS -- + + -- send a MODBUS message, creating a transaction in the process + ---@param txn_type integer transaction type + ---@param f_code MODBUS_FCODE function code + ---@param register_param table register range or register and values + ---@return integer txn_id transaction ID of this transaction + function protected.send_request(txn_type, f_code, register_param) + local m_pkt = comms.modbus_packet() + local txn_id = self.transaction_controller.create(txn_type) + + m_pkt.make(txn_id, unit_id, f_code, register_param) + + out_queue.push_packet(m_pkt) + + return txn_id + end + + -- try to resolve a MODBUS transaction + ---@nodiscard + ---@param m_pkt modbus_frame MODBUS packet + ---@return integer|false txn_type, integer txn_id transaction type or false on error/busy, transaction ID + function protected.try_resolve(m_pkt) + if m_pkt.scada_frame.protocol() == PROTOCOL.MODBUS_TCP then + if m_pkt.unit_id == unit_id then + local txn_type = self.transaction_controller.resolve(m_pkt.txn_id) + local txn_tag = " (" .. util.strval(txn_tags[txn_type]) .. ")" + + if bit.band(m_pkt.func_code, MODBUS_FCODE.ERROR_FLAG) ~= 0 then + -- transaction incomplete or failed + local ex = m_pkt.data[1] + if ex == MODBUS_EXCODE.ILLEGAL_FUNCTION then + log.error(log_tag .. "MODBUS: illegal function" .. txn_tag) + elseif ex == MODBUS_EXCODE.ILLEGAL_DATA_ADDR then + log.error(log_tag .. "MODBUS: illegal data address" .. txn_tag) + elseif ex == MODBUS_EXCODE.SERVER_DEVICE_FAIL then + if self.device_fail then + log.debug(log_tag .. "MODBUS: repeated device failure" .. txn_tag) + else + self.device_fail = true + log.warning(log_tag .. "MODBUS: device failure" .. txn_tag) + end + elseif ex == MODBUS_EXCODE.ACKNOWLEDGE then + -- will have to wait on reply, renew the transaction + self.transaction_controller.renew(m_pkt.txn_id, txn_type) + elseif ex == MODBUS_EXCODE.SERVER_DEVICE_BUSY then + -- will have to wait on reply, renew the transaction + self.transaction_controller.renew(m_pkt.txn_id, txn_type) + log.debug(log_tag .. "MODBUS: device busy" .. txn_tag) + elseif ex == MODBUS_EXCODE.NEG_ACKNOWLEDGE then + -- general failure + log.error(log_tag .. "MODBUS: negative acknowledge (bad request)" .. txn_tag) + elseif ex == MODBUS_EXCODE.GATEWAY_PATH_UNAVAILABLE then + -- RTU gateway has no known unit with the given ID + log.error(log_tag .. "MODBUS: gateway path unavailable (unknown unit)" .. txn_tag) + elseif ex ~= nil then + -- unsupported exception code + log.debug(log_tag .. "MODBUS: unsupported error " .. ex .. txn_tag) + else + -- nil exception code + log.debug(log_tag .. "MODBUS: nil exception code" .. txn_tag) + end + else + -- clear device fail flag + self.device_fail = false + + -- no error, return the transaction type + return txn_type, m_pkt.txn_id + end + else + log.error(log_tag .. "wrong unit ID: " .. m_pkt.unit_id, true) + end + else + log.error(log_tag .. "illegal packet type " .. m_pkt.scada_frame.protocol(), true) + end + + -- error or transaction in progress, return false + return false, m_pkt.txn_id + end + + -- post update tasks + function protected.post_update() + self.transaction_controller.cleanup() + end + + -- get the public interface + ---@nodiscard + function protected.get() return public end + + -- PUBLIC FUNCTIONS -- + + -- get the unit ID + ---@nodiscard + function public.get_session_id() return session_id end + -- get the unit ID + ---@nodiscard + function public.get_unit_id() return unit_id end + -- get the device index + ---@nodiscard + function public.get_device_idx() return self.device_index end + -- get the reactor ID + ---@nodiscard + function public.get_reactor() return self.reactor end + -- get the command queue + ---@nodiscard + function public.get_cmd_queue() return protected.in_q end + + -- close this unit + ---@nodiscard + function public.close() self.connected = false end + -- check if this unit is connected + ---@nodiscard + function public.is_connected() return self.connected end + -- check if this unit is faulted + ---@nodiscard + function public.is_faulted() return self.device_fail end + + -- PUBLIC TEMPLATE FUNCTIONS -- + + -- handle a packet + ---@param m_pkt modbus_frame +---@diagnostic disable-next-line: unused-local + function public.handle_packet(m_pkt) + log.debug("template unit_session.handle_packet() called", true) + end + + -- update this runner + ---@param time_now integer milliseconds +---@diagnostic disable-next-line: unused-local + function public.update(time_now) + log.debug("template unit_session.update() called", true) + end + + -- invalidate build cache + function public.invalidate_cache() + log.debug("template unit_session.invalidate_cache() called", true) + end + + -- get the unit session database + ---@nodiscard + function public.get_db() + log.debug("template unit_session.get_db() called", true) + return {} + end + + return protected +end + +return unit_session diff --git a/supervisor/session/svqtypes.lua b/supervisor/session/svqtypes.lua new file mode 100644 index 0000000..6503d59 --- /dev/null +++ b/supervisor/session/svqtypes.lua @@ -0,0 +1,25 @@ +local svqtypes = {} + +local SV_Q_CMDS = { +} + +local SV_Q_DATA = { + START = 1, + SCRAM = 2, + RESET_RPS = 3, + SET_BURN = 4, + __END_PLC_CMDS__ = 5, + CRDN_ACK = 6, + PLC_BUILD_CHANGED = 7, + RTU_BUILD_CHANGED = 8 +} + +---@class coord_ack +---@field unit integer +---@field cmd integer +---@field ack boolean + +svqtypes.SV_Q_CMDS = SV_Q_CMDS +svqtypes.SV_Q_DATA = SV_Q_DATA + +return svqtypes diff --git a/supervisor/session/svsessions.lua b/supervisor/session/svsessions.lua new file mode 100644 index 0000000..76fb6d1 --- /dev/null +++ b/supervisor/session/svsessions.lua @@ -0,0 +1,450 @@ +local log = require("scada-common.log") +local mqueue = require("scada-common.mqueue") +local util = require("scada-common.util") + +local config = require("supervisor.config") +local facility = require("supervisor.facility") + +local svqtypes = require("supervisor.session.svqtypes") + +local coordinator = require("supervisor.session.coordinator") +local plc = require("supervisor.session.plc") +local rtu = require("supervisor.session.rtu") + +-- Supervisor Sessions Handler + +local SV_Q_CMDS = svqtypes.SV_Q_CMDS +local SV_Q_DATA = svqtypes.SV_Q_DATA + +local PLC_S_CMDS = plc.PLC_S_CMDS +local PLC_S_DATA = plc.PLC_S_DATA +local CRD_S_CMDS = coordinator.CRD_S_CMDS +local CRD_S_DATA = coordinator.CRD_S_DATA + +local svsessions = {} + +local SESSION_TYPE = { + RTU_SESSION = 0, + PLC_SESSION = 1, + COORD_SESSION = 2 +} + +svsessions.SESSION_TYPE = SESSION_TYPE + +local self = { + modem = 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 +} + +-- PRIVATE FUNCTIONS -- + +-- handle a session output queue +---@param session plc_session_struct|rtu_session_struct|coord_session_struct +local function _sv_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 + local cmd = msg.message ---@type queue_data + + if cmd.key < SV_Q_DATA.__END_PLC_CMDS__ then + -- PLC commands from coordinator + local plc_s = svsessions.get_reactor_session(cmd.val[1]) + + if plc_s ~= nil then + if cmd.key == SV_Q_DATA.START then + plc_s.in_queue.push_command(PLC_S_CMDS.ENABLE) + elseif cmd.key == SV_Q_DATA.SCRAM then + plc_s.in_queue.push_command(PLC_S_CMDS.SCRAM) + elseif cmd.key == SV_Q_DATA.RESET_RPS then + plc_s.in_queue.push_command(PLC_S_CMDS.RPS_RESET) + elseif cmd.key == SV_Q_DATA.SET_BURN and type(cmd.val) == "table" and #cmd.val == 2 then + plc_s.in_queue.push_data(PLC_S_DATA.BURN_RATE, cmd.val[2]) + else + log.debug(util.c("unknown PLC SV queue command ", cmd.key)) + end + end + else + local crd_s = svsessions.get_coord_session() + if crd_s ~= nil then + if cmd.key == SV_Q_DATA.CRDN_ACK then + -- ack to be sent to coordinator + crd_s.in_queue.push_data(CRD_S_DATA.CMD_ACK, cmd.val) + elseif cmd.key == SV_Q_DATA.PLC_BUILD_CHANGED then + -- a PLC build has changed + crd_s.in_queue.push_data(CRD_S_DATA.RESEND_PLC_BUILD, cmd.val) + elseif cmd.key == SV_Q_DATA.RTU_BUILD_CHANGED then + -- an RTU build has changed + crd_s.in_queue.push_data(CRD_S_DATA.RESEND_RTU_BUILD, cmd.val) + end + end + end + end + end + + -- max 100ms spent processing queue + if util.time() - handle_start > 100 then + log.warning("supervisor out queue handler exceeded 100ms queue process limit") + log.warning(util.c("offending session: port ", session.r_port, " type '", session.s_type, "'")) + break + end + end +end + +-- iterate all the given sessions +---@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 + + if session.open and session.instance.iterate() then + _sv_handle_outq(session) + else + session.open = false + end + end +end + +-- cleanly close a session +---@param session plc_session_struct|rtu_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 ", session.s_type, " session ", session.instance.get_id(), " on remote port ", session.r_port)) +end + +-- close connections +---@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 + end +end + +-- check if a watchdog timer event matches that of one of the provided sessions +---@param sessions table +---@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 + if session.open then + local triggered = session.instance.check_wd(timer_event) + if triggered then + log.debug(util.c("watchdog closing ", session.s_type, " session ", session.instance.get_id(), + " on remote port ", session.r_port, "...")) + _shutdown(session) + end + end + end +end + +-- delete any closed sessions +---@param sessions table +local function _free_closed(sessions) + local f = function (session) return session.open end + + 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)) + end + + util.filter_table(sessions, f, on_delete) +end + +-- find a session by remote port +---@nodiscard +---@param list table +---@param port integer +---@return plc_session_struct|rtu_session_struct|coord_session_struct|nil +local function _find_session(list, port) + for i = 1, #list do + if list[i].r_port == port then return list[i] end + end + return nil +end + +-- PUBLIC FUNCTIONS -- + +-- initialize svsessions +---@param modem table +---@param num_reactors integer +---@param cooling_conf table +function svsessions.init(modem, num_reactors, cooling_conf) + self.modem = modem + self.num_reactors = num_reactors + self.facility = facility.new(num_reactors, cooling_conf) +end + +-- re-link the modem +---@param modem table +function svsessions.relink_modem(modem) + self.modem = modem +end + +-- find an RTU session by the remote port +---@nodiscard +---@param remote_port integer +---@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 + return session +end + +-- find a PLC session by the remote port +---@nodiscard +---@param remote_port integer +---@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 + return session +end + +-- find a PLC/RTU session by the remote port +---@nodiscard +---@param remote_port integer +---@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) + + -- check PLC sessions + if session == nil then session = _find_session(self.plc_sessions, 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 +---@nodiscard +---@param remote_port integer +---@return coord_session_struct|nil +function svsessions.find_coord_session(remote_port) + -- check coordinator sessions + local session = _find_session(self.coord_sessions, remote_port) + ---@cast session coord_session_struct + return session +end + +-- get the a coordinator session if exists +---@nodiscard +---@return coord_session_struct|nil +function svsessions.get_coord_session() + return self.coord_sessions[1] +end + +-- get a session by reactor ID +---@nodiscard +---@param reactor integer +---@return plc_session_struct|nil session +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] + end + end + + return session +end + +-- establish a new PLC session +---@nodiscard +---@param local_port integer +---@param remote_port integer +---@param for_reactor integer +---@param version string +---@return integer|false session_id +function svsessions.establish_plc_session(local_port, remote_port, for_reactor, version) + if svsessions.get_reactor_session(for_reactor) == nil and for_reactor >= 1 and for_reactor <= self.num_reactors then + ---@class plc_session_struct + local plc_s = { + s_type = "plc", + open = true, + reactor = for_reactor, + version = version, + l_port = local_port, + r_port = remote_port, + in_queue = mqueue.new(), + out_queue = mqueue.new(), + 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) + + 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)) + + self.next_plc_id = self.next_plc_id + 1 + + -- success + return plc_s.instance.get_id() + else + -- reactor already assigned to a PLC or ID out of range + return false + end +end + +-- establish a new RTU session +---@nodiscard +---@param local_port integer +---@param remote_port integer +---@param advertisement table +---@param version string +---@return integer session_id +function svsessions.establish_rtu_session(local_port, remote_port, advertisement, version) + ---@class rtu_session_struct + local rtu_s = { + s_type = "rtu", + open = true, + version = version, + l_port = local_port, + r_port = remote_port, + in_queue = mqueue.new(), + out_queue = mqueue.new(), + 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) + + log.debug("established new RTU session to " .. remote_port .. " with ID " .. self.next_rtu_id) + + self.next_rtu_id = self.next_rtu_id + 1 + + -- success + return rtu_s.instance.get_id() +end + +-- establish a new coordinator session +---@nodiscard +---@param local_port integer +---@param remote_port integer +---@param version string +---@return integer|false session_id +function svsessions.establish_coord_session(local_port, remote_port, version) + if svsessions.get_coord_session() == nil then + ---@class coord_session_struct + local coord_s = { + s_type = "crd", + open = true, + version = version, + l_port = local_port, + r_port = remote_port, + in_queue = mqueue.new(), + out_queue = mqueue.new(), + 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) + + log.debug("established new coordinator session to " .. remote_port .. " with ID " .. self.next_coord_id) + + self.next_coord_id = self.next_coord_id + 1 + + -- success + return coord_s.instance.get_id() + else + -- we already have a coordinator linked + return false + end +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) +end + +-- iterate all sessions +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) + + -- report RTU sessions to facility + self.facility.report_rtus(self.rtu_sessions) + + -- iterate facility + self.facility.update() + + -- iterate units + self.facility.update_units() +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) +end + +-- close all open connections +function svsessions.close_all() + -- close sessions + _close(self.rtu_sessions) + _close(self.plc_sessions) + _close(self.coord_sessions) + + -- free sessions + svsessions.free_all_closed() +end + +return svsessions diff --git a/supervisor/startup.lua b/supervisor/startup.lua new file mode 100644 index 0000000..2d0c577 --- /dev/null +++ b/supervisor/startup.lua @@ -0,0 +1,172 @@ +-- +-- Nuclear Generation Facility SCADA Supervisor +-- + +require("/initenv").init_env() + +local crash = require("scada-common.crash") +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.0" + +local print = util.print +local println = util.println +local print_ts = util.print_ts +local println_ts = util.println_ts + +---------------------------------------- +-- config validation +---------------------------------------- + +local cfv = util.new_validator() + +cfv.assert_port(config.SCADA_DEV_LISTEN) +cfv.assert_port(config.SCADA_SV_LISTEN) +cfv.assert_type_int(config.TRUSTED_RANGE) +cfv.assert_type_num(config.PLC_TIMEOUT) +cfv.assert_min(config.PLC_TIMEOUT, 2) +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_int(config.NUM_REACTORS) +cfv.assert_type_table(config.REACTOR_COOLING) +cfv.assert_type_str(config.LOG_PATH) +cfv.assert_type_int(config.LOG_MODE) + +assert(cfv.valid(), "bad config file: missing/invalid fields") + +cfv.assert_eq(#config.REACTOR_COOLING, config.NUM_REACTORS) +assert(cfv.valid(), "config: number of cooling configs different than number of units") + +for i = 1, config.NUM_REACTORS do + cfv.assert_type_table(config.REACTOR_COOLING[i]) + assert(cfv.valid(), "config: missing cooling entry for reactor " .. i) + cfv.assert_type_int(config.REACTOR_COOLING[i].BOILERS) + cfv.assert_type_int(config.REACTOR_COOLING[i].TURBINES) + assert(cfv.valid(), "config: missing boilers/turbines for reactor " .. i) + cfv.assert_min(config.REACTOR_COOLING[i].BOILERS, 0) + cfv.assert_min(config.REACTOR_COOLING[i].TURBINES, 1) + assert(cfv.valid(), "config: bad number of boilers/turbines for reactor " .. i) +end + +---------------------------------------- +-- log init +---------------------------------------- + +log.init(config.LOG_PATH, config.LOG_MODE) + +log.info("========================================") +log.info("BOOTING supervisor.startup " .. SUPERVISOR_VERSION) +log.info("========================================") +println(">> SCADA Supervisor " .. SUPERVISOR_VERSION .. " <<") + +crash.set_env("supervisor", SUPERVISOR_VERSION) + +---------------------------------------- +-- main application +---------------------------------------- + +local function main() + ---------------------------------------- + -- startup + ---------------------------------------- + + -- mount connected devices + ppm.mount_all() + + local modem = ppm.get_wireless_modem() + if modem == nil then + println("startup> wireless modem not found") + log.fatal("no wireless modem on startup") + return + end + + -- 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) + + -- base loop clock (6.67Hz, 3 ticks) + local MAIN_CLOCK = 0.15 + local loop_clock = util.new_clock(MAIN_CLOCK) + + -- start clock + loop_clock.start() + + -- event loop + while true do + local event, param1, param2, param3, param4, param5 = util.pull_event() + + -- handle event + if event == "peripheral_detach" then + local type, device = ppm.handle_unmount(param1) + + if type ~= nil and device ~= nil then + if type == "modem" then + -- we only care if this is our wireless modem + if device == modem then + println_ts("wireless modem disconnected!") + log.warning("comms modem disconnected") + else + log.warning("non-comms modem disconnected") + end + end + end + elseif event == "peripheral" then + local type, device = ppm.mount(param1) + + if type ~= nil and device ~= nil then + if type == "modem" then + if device.isWireless() then + -- reconnected modem + modem = device + superv_comms.reconnect_modem(modem) + + println_ts("wireless modem reconnected.") + log.info("comms modem reconnected") + else + log.info("wired modem reconnected") + end + end + end + elseif event == "timer" and loop_clock.is_clock(param1) then + -- main loop tick + + -- iterate sessions + svsessions.iterate_all() + + -- free any closed sessions + svsessions.free_all_closed() + + loop_clock.start() + elseif event == "timer" then + -- a non-clock timer event, check watchdogs + svsessions.check_all_watchdogs(param1) + elseif event == "modem_message" then + -- got a packet + local packet = superv_comms.parse_packet(param1, param2, param3, param4, param5) + superv_comms.handle_packet(packet) + end + + -- check for termination request + if event == "terminate" or ppm.should_terminate() then + println_ts("closing sessions...") + log.info("terminate requested, closing sessions...") + svsessions.close_all() + log.info("sessions closed") + break + end + end + + println_ts("exited") + log.info("exited") +end + +if not xpcall(main, crash.handler) then crash.exit() end diff --git a/supervisor/supervisor.lua b/supervisor/supervisor.lua new file mode 100644 index 0000000..848dfc3 --- /dev/null +++ b/supervisor/supervisor.lua @@ -0,0 +1,341 @@ +local comms = require("scada-common.comms") +local log = require("scada-common.log") +local util = require("scada-common.util") + +local svsessions = require("supervisor.session.svsessions") + +local supervisor = {} + +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 print = util.print +local println = util.println +local print_ts = util.print_ts +local println_ts = util.println_ts + +-- supervisory controller communications +---@nodiscard +---@param version string supervisor version +---@param num_reactors integer number of reactors +---@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 range integer trusted device connection range +function supervisor.comms(version, num_reactors, cooling_conf, modem, dev_listen, coord_listen, range) + local self = { + last_est_acks = {} + } + + comms.set_trusted_range(range) + + -- PRIVATE FUNCTIONS -- + + -- configure modem channels + local function _conf_channels() + modem.closeAll() + modem.open(dev_listen) + modem.open(coord_listen) + end + + _conf_channels() + + -- link modem to svsessions + svsessions.init(modem, num_reactors, cooling_conf) + + -- send an establish request response to a PLC/RTU + ---@param dest integer + ---@param msg table + local function _send_dev_establish(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, dev_listen, s_pkt.raw_sendable()) + end + + -- send coordinator connection establish response + ---@param seq_id integer + ---@param dest integer + ---@param msg table + local function _send_crdn_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()) + end + + -- PUBLIC FUNCTIONS -- + + ---@class superv_comms + local public = {} + + -- reconnect a newly connected modem + ---@param new_modem table + function public.reconnect_modem(new_modem) + modem = new_modem + svsessions.relink_modem(new_modem) + _conf_channels() + end + + -- parse a packet + ---@nodiscard + ---@param side string + ---@param sender integer + ---@param reply_to integer + ---@param message any + ---@param distance integer + ---@return modbus_frame|rplc_frame|mgmt_frame|crdn_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 MODBUS TCP packet + if s_pkt.protocol() == PROTOCOL.MODBUS_TCP then + local m_pkt = comms.modbus_packet() + if m_pkt.decode(s_pkt) then + pkt = m_pkt.get() + end + -- get as RPLC packet + elseif s_pkt.protocol() == PROTOCOL.RPLC then + local rplc_pkt = comms.rplc_packet() + if rplc_pkt.decode(s_pkt) then + pkt = rplc_pkt.get() + end + -- get as SCADA management packet + elseif 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 packet + elseif s_pkt.protocol() == PROTOCOL.SCADA_CRDN then + local crdn_pkt = comms.crdn_packet() + if crdn_pkt.decode(s_pkt) then + pkt = crdn_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 modbus_frame|rplc_frame|mgmt_frame|crdn_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() + + -- device (RTU/PLC) listening channel + if l_port == dev_listen then + if protocol == PROTOCOL.MODBUS_TCP then + ---@cast packet modbus_frame + -- look for an associated session + local session = svsessions.find_rtu_session(r_port) + + -- MODBUS response + 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 MODBUS_TCP packet without a known session") + end + elseif protocol == PROTOCOL.RPLC then + ---@cast packet rplc_frame + -- look for an associated session + local session = svsessions.find_plc_session(r_port) + + -- reactor PLC packet + if session ~= nil then + -- pass the packet onto the session handler + session.in_queue.push_packet(packet) + else + -- unknown session, force a re-link + log.debug("PLC_ESTABLISH: no session but not an establish, forcing relink") + _send_dev_establish(packet.scada_frame.seq_num() + 1, r_port, { ESTABLISH_ACK.DENY }) + end + elseif protocol == PROTOCOL.SCADA_MGMT then + ---@cast packet mgmt_frame + -- look for an associated session + local session = svsessions.find_device_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_est_acks[r_port] ~= ESTABLISH_ACK.BAD_VERSION then + log.info(util.c("dropping device establish packet with incorrect comms version v", comms_v, " (expected v", comms.version, ")")) + self.last_est_acks[r_port] = ESTABLISH_ACK.BAD_VERSION + end + + _send_dev_establish(next_seq_id, r_port, { ESTABLISH_ACK.BAD_VERSION }) + elseif dev_type == DEVICE_TYPE.PLC then + -- PLC linking request + if packet.length == 4 and type(packet.data[4]) == "number" then + local reactor_id = packet.data[4] + local plc_id = svsessions.establish_plc_session(l_port, r_port, reactor_id, firmware_v) + + if plc_id == false then + -- reactor already has a PLC assigned + if self.last_est_acks[r_port] ~= ESTABLISH_ACK.COLLISION then + log.warning(util.c("PLC_ESTABLISH: assignment collision with reactor ", reactor_id)) + self.last_est_acks[r_port] = ESTABLISH_ACK.COLLISION + end + + _send_dev_establish(next_seq_id, r_port, { ESTABLISH_ACK.COLLISION }) + else + -- got an ID; assigned to a reactor successfully + println(util.c("PLC (", firmware_v, ") [:", r_port, "] \xbb reactor ", reactor_id, " connected")) + log.info(util.c("PLC_ESTABLISH: PLC (", firmware_v, ") [:", r_port, "] reactor unit ", reactor_id, " PLC connected with session ID ", plc_id)) + + _send_dev_establish(next_seq_id, r_port, { ESTABLISH_ACK.ALLOW }) + self.last_est_acks[r_port] = ESTABLISH_ACK.ALLOW + end + else + log.debug("PLC_ESTABLISH: packet length mismatch/bad parameter type") + _send_dev_establish(next_seq_id, r_port, { ESTABLISH_ACK.DENY }) + end + elseif dev_type == DEVICE_TYPE.RTU then + if packet.length == 4 then + -- this is an RTU advertisement for a new session + local rtu_advert = packet.data[4] + local s_id = svsessions.establish_rtu_session(l_port, r_port, rtu_advert, firmware_v) + + println(util.c("RTU (", firmware_v, ") [:", r_port, "] \xbb connected")) + log.info(util.c("RTU_ESTABLISH: RTU (",firmware_v, ") [:", r_port, "] connected with session ID ", s_id)) + + _send_dev_establish(next_seq_id, r_port, { ESTABLISH_ACK.ALLOW }) + else + log.debug("RTU_ESTABLISH: packet length mismatch") + _send_dev_establish(next_seq_id, r_port, { ESTABLISH_ACK.DENY }) + end + else + log.debug(util.c("illegal establish packet for device ", dev_type, " on PLC/RTU listening channel")) + _send_dev_establish(next_seq_id, r_port, { ESTABLISH_ACK.DENY }) + end + else + log.debug("invalid establish packet (on PLC/RTU listening channel)") + _send_dev_establish(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 device listening channel") + end + -- coordinator listening channel + elseif l_port == coord_listen then + -- look for an associated session + local session = svsessions.find_coord_session(r_port) + + if protocol == PROTOCOL.SCADA_MGMT then + ---@cast packet mgmt_frame + -- 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_est_acks[r_port] ~= ESTABLISH_ACK.BAD_VERSION then + log.info(util.c("dropping coordinator establish packet with incorrect comms version v", comms_v, " (expected v", comms.version, ")")) + 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 + local s_id = svsessions.establish_coord_session(l_port, r_port, firmware_v) + + if s_id ~= false then + local config = { num_reactors } + for i = 1, #cooling_conf do + table.insert(config, cooling_conf[i].BOILERS) + 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)) + + _send_crdn_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") + self.last_est_acks[r_port] = ESTABLISH_ACK.COLLISION + end + + _send_crdn_establish(next_seq_id, r_port, { ESTABLISH_ACK.COLLISION }) + end + end + else + log.debug("CRDN_ESTABLISH: establish packet length mismatch") + _send_crdn_establish(next_seq_id, r_port, { ESTABLISH_ACK.DENY }) + end + else + -- any other packet should be session related, discard it + log.debug(r_port .. "->" .. l_port .. ": discarding SCADA_MGMT packet without a known session") + end + elseif protocol == PROTOCOL.SCADA_CRDN then + ---@cast packet crdn_frame + -- coordinator 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(r_port .. "->" .. l_port .. ": discarding SCADA_CRDN packet without a known session") + end + else + log.debug("illegal packet type " .. protocol .. " on coordinator listening channel") + end + else + log.warning("received packet on unconfigured channel " .. l_port) + end + end + end + + return public +end + +return supervisor diff --git a/supervisor/unit.lua b/supervisor/unit.lua new file mode 100644 index 0000000..abdcbb0 --- /dev/null +++ b/supervisor/unit.lua @@ -0,0 +1,800 @@ +local log = require("scada-common.log") +local rsio = require("scada-common.rsio") +local types = require("scada-common.types") +local util = require("scada-common.util") + +local logic = require("supervisor.unitlogic") + +local plc = require("supervisor.session.plc") +local rsctl = require("supervisor.session.rsctl") + +---@class reactor_control_unit +local unit = {} + +local WASTE_MODE = types.WASTE_MODE +local ALARM = types.ALARM +local PRIO = types.ALARM_PRIORITY +local ALARM_STATE = types.ALARM_STATE +local TRI_FAIL = types.TRI_FAIL + +local PLC_S_CMDS = plc.PLC_S_CMDS + +local IO = rsio.IO + +local DT_KEYS = { + ReactorBurnR = "RBR", + ReactorTemp = "RTP", + ReactorFuel = "RFL", + ReactorWaste = "RWS", + ReactorCCool = "RCC", + ReactorHCool = "RHC", + BoilerWater = "BWR", + BoilerSteam = "BST", + BoilerCCool = "BCC", + BoilerHCool = "BHC", + TurbineSteam = "TST", + TurbinePower = "TPR" +} + +---@enum ALARM_INT_STATE +local AISTATE = { + INACTIVE = 1, + TRIPPING = 2, + TRIPPED = 3, + ACKED = 4, + RING_BACK = 5, + RING_BACK_TRIPPING = 6 +} + +---@class alarm_def +---@field state ALARM_INT_STATE internal alarm state +---@field trip_time integer time (ms) when first tripped +---@field hold_time integer time (s) to hold before tripping +---@field id ALARM alarm ID +---@field tier integer alarm urgency tier (0 = highest) + +-- create a new reactor unit +---@nodiscard +---@param reactor_id integer reactor unit number +---@param num_boilers integer number of boilers expected +---@param num_turbines integer number of turbines expected +function unit.new(reactor_id, num_boilers, num_turbines) + ---@class _unit_self + local self = { + r_id = reactor_id, + plc_s = nil, ---@class plc_session_struct + plc_i = nil, ---@class plc_session + num_boilers = num_boilers, + num_turbines = num_turbines, + types = { DT_KEYS = DT_KEYS, AISTATE = AISTATE }, + -- rtus + redstone = {}, + boilers = {}, + turbines = {}, + envd = {}, + -- redstone control + io_ctl = nil, ---@type rs_controller + valves = {}, ---@type unit_valves + emcool_opened = false, + -- auto control + auto_engaged = false, + auto_was_alarmed = false, + ramp_target_br100 = 0, + -- state tracking + deltas = {}, + last_heartbeat = 0, + last_radiation = 0, + damage_decreasing = false, + damage_initial = 0, + damage_start = 0, + damage_last = 0, + damage_est_last = 0, + waste_mode = WASTE_MODE.AUTO, + status_text = { "UNKNOWN", "awaiting connection..." }, + -- logic for alarms + had_reactor = false, + last_rate_change_ms = 0, + ---@type rps_status + last_rps_trips = { + high_dmg = false, + high_temp = false, + low_cool = false, + ex_waste = false, + ex_hcool = false, + no_fuel = false, + fault = false, + timeout = false, + manual = false, + automatic = false, + sys_fail = false, + force_dis = false + }, + plc_cache = { + active = false, + ok = false, + rps_trip = false, + ---@type rps_status + rps_status = { + high_dmg = false, + high_temp = false, + low_cool = false, + ex_waste = false, + ex_hcool = false, + no_fuel = false, + fault = false, + timeout = false, + manual = false, + automatic = false, + sys_fail = false, + force_dis = false + }, + damage = 0, + temp = 0, + waste = 0 + }, + ---@class alarm_monitors + alarms = { + -- reactor lost under the condition of meltdown imminent + ContainmentBreach = { state = AISTATE.INACTIVE, trip_time = 0, hold_time = 0, id = ALARM.ContainmentBreach, tier = PRIO.CRITICAL }, + -- radiation monitor alarm for this unit + ContainmentRadiation = { state = AISTATE.INACTIVE, trip_time = 0, hold_time = 0, id = ALARM.ContainmentRadiation, tier = PRIO.CRITICAL }, + -- reactor offline after being online + ReactorLost = { state = AISTATE.INACTIVE, trip_time = 0, hold_time = 0, id = ALARM.ReactorLost, tier = PRIO.TIMELY }, + -- damage >100% + CriticalDamage = { state = AISTATE.INACTIVE, trip_time = 0, hold_time = 0, id = ALARM.CriticalDamage, tier = PRIO.CRITICAL }, + -- reactor damage increasing + ReactorDamage = { state = AISTATE.INACTIVE, trip_time = 0, hold_time = 0, id = ALARM.ReactorDamage, tier = PRIO.EMERGENCY }, + -- reactor >1200K + ReactorOverTemp = { state = AISTATE.INACTIVE, trip_time = 0, hold_time = 0, id = ALARM.ReactorOverTemp, tier = PRIO.URGENT }, + -- reactor >=1150K + ReactorHighTemp = { state = AISTATE.INACTIVE, trip_time = 0, hold_time = 1, id = ALARM.ReactorHighTemp, tier = PRIO.TIMELY }, + -- waste = 100% + ReactorWasteLeak = { state = AISTATE.INACTIVE, trip_time = 0, hold_time = 0, id = ALARM.ReactorWasteLeak, tier = PRIO.EMERGENCY }, + -- waste >85% + ReactorHighWaste = { state = AISTATE.INACTIVE, trip_time = 0, hold_time = 2, id = ALARM.ReactorHighWaste, tier = PRIO.URGENT }, + -- RPS trip occured + RPSTransient = { state = AISTATE.INACTIVE, trip_time = 0, hold_time = 2, id = ALARM.RPSTransient, tier = PRIO.TIMELY }, + -- BoilRateMismatch, CoolantFeedMismatch, SteamFeedMismatch, MaxWaterReturnFeed + RCSTransient = { state = AISTATE.INACTIVE, trip_time = 0, hold_time = 5, id = ALARM.RCSTransient, tier = PRIO.TIMELY }, + -- "It's just a routine turbin' trip!" -Bill Gibson, "The China Syndrome" + TurbineTrip = { state = AISTATE.INACTIVE, trip_time = 0, hold_time = 2, id = ALARM.TurbineTrip, tier = PRIO.URGENT } + }, + ---@class unit_db + db = { + ---@class annunciator + annunciator = { + -- reactor + PLCOnline = false, + PLCHeartbeat = false, -- alternate true/false to blink, each time there is a keep_alive + RadiationMonitor = 1, + AutoControl = false, + ReactorSCRAM = false, + ManualReactorSCRAM = false, + AutoReactorSCRAM = false, + RadiationWarning = false, + RCPTrip = false, + RCSFlowLow = false, + CoolantLevelLow = false, + ReactorTempHigh = false, + ReactorHighDeltaT = false, + FuelInputRateLow = false, + WasteLineOcclusion = false, + HighStartupRate = false, + -- cooling + RCSFault = false, + EmergencyCoolant = 1, + CoolantFeedMismatch = false, + BoilRateMismatch = false, + SteamFeedMismatch = false, + MaxWaterReturnFeed = false, + -- boilers + BoilerOnline = {}, + HeatingRateLow = {}, + WaterLevelLow = {}, + -- turbines + TurbineOnline = {}, + SteamDumpOpen = {}, + TurbineOverSpeed = {}, + TurbineTrip = {} + }, + ---@class alarms + alarm_states = { + ALARM_STATE.INACTIVE, + ALARM_STATE.INACTIVE, + ALARM_STATE.INACTIVE, + ALARM_STATE.INACTIVE, + ALARM_STATE.INACTIVE, + ALARM_STATE.INACTIVE, + ALARM_STATE.INACTIVE, + ALARM_STATE.INACTIVE, + ALARM_STATE.INACTIVE, + ALARM_STATE.INACTIVE, + ALARM_STATE.INACTIVE, + ALARM_STATE.INACTIVE + }, + -- fields for facility control + ---@class unit_control + control = { + ready = false, + degraded = false, + blade_count = 0, + br100 = 0, + lim_br100 = 0 + } + } + } + + -- init redstone RTU I/O controller + self.io_ctl = rsctl.new(self.redstone) + + -- init boiler table fields + for _ = 1, num_boilers do + table.insert(self.db.annunciator.BoilerOnline, false) + table.insert(self.db.annunciator.HeatingRateLow, false) + end + + -- init turbine table fields + for _ = 1, num_turbines do + table.insert(self.db.annunciator.TurbineOnline, false) + table.insert(self.db.annunciator.SteamDumpOpen, TRI_FAIL.OK) + table.insert(self.db.annunciator.TurbineOverSpeed, false) + table.insert(self.db.annunciator.TurbineTrip, false) + end + + -- PRIVATE FUNCTIONS -- + + --#region time derivative utility functions + + -- compute a change with respect to time of the given value + ---@param key string value key + ---@param value number value + ---@param time number timestamp for value + local function _compute_dt(key, value, time) + if self.deltas[key] then + local data = self.deltas[key] + + if time > data.last_t then + data.dt = (value - data.last_v) / (time - data.last_t) + + data.last_v = value + data.last_t = time + end + else + self.deltas[key] = { + last_t = time, + last_v = value, + dt = 0.0 + } + end + end + + -- clear a delta + ---@param key string value key + local function _reset_dt(key) self.deltas[key] = nil end + + -- get the delta t of a value + ---@nodiscard + ---@param key string value key + ---@return number value value or 0 if not known + function self._get_dt(key) if self.deltas[key] then return self.deltas[key].dt else return 0.0 end end + + -- update all delta computations + local function _dt__compute_all() + if self.plc_i ~= nil then + local plc_db = self.plc_i.get_db() + + local last_update_s = plc_db.last_status_update / 1000.0 + + _compute_dt(DT_KEYS.ReactorBurnR, plc_db.mek_status.act_burn_rate, last_update_s) + _compute_dt(DT_KEYS.ReactorTemp, plc_db.mek_status.temp, last_update_s) + _compute_dt(DT_KEYS.ReactorFuel, plc_db.mek_status.fuel, last_update_s) + _compute_dt(DT_KEYS.ReactorWaste, plc_db.mek_status.waste, last_update_s) + _compute_dt(DT_KEYS.ReactorCCool, plc_db.mek_status.ccool_amnt, last_update_s) + _compute_dt(DT_KEYS.ReactorHCool, plc_db.mek_status.hcool_amnt, last_update_s) + end + + for i = 1, #self.boilers do + local boiler = self.boilers[i] ---@type unit_session + local db = boiler.get_db() ---@type boilerv_session_db + + local last_update_s = db.tanks.last_update / 1000.0 + + _compute_dt(DT_KEYS.BoilerWater .. boiler.get_device_idx(), db.tanks.water.amount, last_update_s) + _compute_dt(DT_KEYS.BoilerSteam .. boiler.get_device_idx(), db.tanks.steam.amount, last_update_s) + _compute_dt(DT_KEYS.BoilerCCool .. boiler.get_device_idx(), db.tanks.ccool.amount, last_update_s) + _compute_dt(DT_KEYS.BoilerHCool .. boiler.get_device_idx(), db.tanks.hcool.amount, last_update_s) + end + + for i = 1, #self.turbines do + local turbine = self.turbines[i] ---@type unit_session + local db = turbine.get_db() ---@type turbinev_session_db + + local last_update_s = db.tanks.last_update / 1000.0 + + _compute_dt(DT_KEYS.TurbineSteam .. turbine.get_device_idx(), db.tanks.steam.amount, last_update_s) + ---@todo unused currently? + _compute_dt(DT_KEYS.TurbinePower .. turbine.get_device_idx(), db.tanks.energy, last_update_s) + end + end + + --#endregion + + --#region redstone I/O + + local __rs_w = self.io_ctl.digital_write + + -- valves + local waste_pu = { open = function () __rs_w(IO.WASTE_PU, true) end, close = function () __rs_w(IO.WASTE_PU, false) end } + local waste_sna = { open = function () __rs_w(IO.WASTE_PO, true) end, close = function () __rs_w(IO.WASTE_PO, false) end } + local waste_po = { open = function () __rs_w(IO.WASTE_POPL, true) end, close = function () __rs_w(IO.WASTE_POPL, false) end } + local waste_sps = { open = function () __rs_w(IO.WASTE_AM, true) end, close = function () __rs_w(IO.WASTE_AM, false) end } + local emer_cool = { open = function () __rs_w(IO.U_EMER_COOL, true) end, close = function () __rs_w(IO.U_EMER_COOL, false) end } + + ---@class unit_valves + self.valves = { + waste_pu = waste_pu, + waste_sna = waste_sna, + waste_po = waste_po, + waste_sps = waste_sps, + emer_cool = emer_cool + } + + --#endregion + + -- unlink disconnected units + ---@param sessions table + local function _unlink_disconnected_units(sessions) + util.filter_table(sessions, function (u) return u.is_connected() end) + end + + -- PUBLIC FUNCTIONS -- + + ---@class reactor_unit + local public = {} + + -- ADD/LINK DEVICES -- + --#region + + -- link the PLC + ---@param plc_session plc_session_struct + function public.link_plc_session(plc_session) + self.had_reactor = true + self.plc_s = plc_session + self.plc_i = plc_session.instance + + -- reset deltas + _reset_dt(DT_KEYS.ReactorTemp) + _reset_dt(DT_KEYS.ReactorFuel) + _reset_dt(DT_KEYS.ReactorWaste) + _reset_dt(DT_KEYS.ReactorCCool) + _reset_dt(DT_KEYS.ReactorHCool) + end + + -- link a redstone RTU session + ---@param rs_unit unit_session + function public.add_redstone(rs_unit) + table.insert(self.redstone, rs_unit) + + -- send or re-send waste settings + public.set_waste(self.waste_mode) + end + + -- link a turbine RTU session + ---@param turbine unit_session + function public.add_turbine(turbine) + if #self.turbines < num_turbines and turbine.get_device_idx() <= num_turbines then + table.insert(self.turbines, turbine) + + -- reset deltas + _reset_dt(DT_KEYS.TurbineSteam .. turbine.get_device_idx()) + _reset_dt(DT_KEYS.TurbinePower .. turbine.get_device_idx()) + + return true + else + return false + end + end + + -- link a boiler RTU session + ---@param boiler unit_session + function public.add_boiler(boiler) + if #self.boilers < num_boilers and boiler.get_device_idx() <= num_boilers then + table.insert(self.boilers, boiler) + + -- reset deltas + _reset_dt(DT_KEYS.BoilerWater .. boiler.get_device_idx()) + _reset_dt(DT_KEYS.BoilerSteam .. boiler.get_device_idx()) + _reset_dt(DT_KEYS.BoilerCCool .. boiler.get_device_idx()) + _reset_dt(DT_KEYS.BoilerHCool .. boiler.get_device_idx()) + + return true + else + return false + end + end + + -- link an environment detector RTU session + ---@param envd unit_session + function public.add_envd(envd) + table.insert(self.envd, envd) + end + + -- purge devices associated with the given RTU session ID + ---@param session integer RTU session ID + function public.purge_rtu_devices(session) + util.filter_table(self.redstone, function (s) return s.get_session_id() ~= session end) + util.filter_table(self.boilers, function (s) return s.get_session_id() ~= session end) + util.filter_table(self.turbines, function (s) return s.get_session_id() ~= session end) + util.filter_table(self.envd, function (s) return s.get_session_id() ~= session end) + end + + --#endregion + + -- UPDATE SESSION -- + + -- update (iterate) this unit + function public.update() + -- unlink PLC if session was closed + if self.plc_s ~= nil and not self.plc_s.open then + self.plc_s = nil + self.plc_i = nil + self.db.control.br100 = 0 + self.db.control.lim_br100 = 0 + end + + -- unlink RTU unit sessions if they are closed + _unlink_disconnected_units(self.redstone) + _unlink_disconnected_units(self.boilers) + _unlink_disconnected_units(self.turbines) + _unlink_disconnected_units(self.envd) + + -- update degraded state for auto control + self.db.control.degraded = (#self.boilers ~= num_boilers) or (#self.turbines ~= num_turbines) or (self.plc_i == nil) + + -- check boilers formed/faulted + for i = 1, #self.boilers do + local sess = self.boilers[i] ---@type unit_session + local boiler = sess.get_db() ---@type boilerv_session_db + if sess.is_faulted() or not boiler.formed then + self.db.control.degraded = true + end + end + + -- check turbines formed/faulted + for i = 1, #self.turbines do + local sess = self.turbines[i] ---@type unit_session + local turbine = sess.get_db() ---@type turbinev_session_db + if sess.is_faulted() or not turbine.formed then + self.db.control.degraded = true + end + end + + -- check plc formed/faulted + if self.plc_i ~= nil then + local rps = self.plc_i.get_rps() + if rps.fault or rps.sys_fail then + self.db.control.degraded = true + end + end + + -- update deltas + _dt__compute_all() + + -- update annunciator logic + logic.update_annunciator(self) + + -- update alarm status + logic.update_alarms(self) + + -- if in auto mode, SCRAM on certain alarms + logic.update_auto_safety(public, self) + + -- update status text + logic.update_status_text(self) + + -- handle redstone I/O + if #self.redstone > 0 then + logic.handle_redstone(self) + elseif not self.plc_cache.rps_trip then + self.emcool_opened = false + end + end + + -- AUTO CONTROL OPERATIONS -- + --#region + + -- engage automatic control + function public.a_engage() + self.auto_engaged = true + if self.plc_i ~= nil then + self.plc_i.auto_lock(true) + end + end + + -- disengage automatic control + function public.a_disengage() + self.auto_engaged = false + if self.plc_i ~= nil then + self.plc_i.auto_lock(false) + self.db.control.br100 = 0 + end + end + + -- get the actual limit of this unit
+ -- if it is degraded or not ready, the limit will be 0 + ---@nodiscard + ---@return integer lim_br100 + function public.a_get_effective_limit() + if (not self.db.control.ready) or self.db.control.degraded or self.plc_cache.rps_trip then + self.db.control.br100 = 0 + return 0 + else + return self.db.control.lim_br100 + end + end + + -- set the automatic burn rate based on the last set burn rate in 100ths + ---@param ramp boolean true to ramp to rate, false to set right away + function public.a_commit_br100(ramp) + if self.auto_engaged then + if self.plc_i ~= nil then + self.plc_i.auto_set_burn(self.db.control.br100 / 100, ramp) + + if ramp then self.ramp_target_br100 = self.db.control.br100 end + end + end + end + + -- check if ramping is complete (burn rate is same as target) + ---@nodiscard + ---@return boolean complete + function public.a_ramp_complete() + if self.plc_i ~= nil then + return self.plc_i.is_ramp_complete() or + (self.plc_i.get_status().act_burn_rate == 0 and self.db.control.br100 == 0) or + public.a_get_effective_limit() == 0 + else return true end + end + + -- perform an automatic SCRAM + function public.a_scram() + if self.plc_s ~= nil then + self.db.control.br100 = 0 + self.plc_s.in_queue.push_command(PLC_S_CMDS.ASCRAM) + end + end + + -- queue a command to clear timeout/auto-scram if set + function public.a_cond_rps_reset() + if self.plc_s ~= nil and self.plc_i ~= nil and (not self.auto_was_alarmed) and (not self.emcool_opened) then + local rps = self.plc_i.get_rps() + if rps.timeout or rps.automatic then + self.plc_i.auto_lock(true) -- if it timed out/restarted, auto lock was lost, so re-lock it + self.plc_s.in_queue.push_command(PLC_S_CMDS.RPS_AUTO_RESET) + end + end + end + + --#endregion + + -- OPERATIONS -- + --#region + + -- queue a command to SCRAM the reactor + function public.scram() + if self.plc_s ~= nil then + self.plc_s.in_queue.push_command(PLC_S_CMDS.SCRAM) + end + end + + -- queue a SCRAM command only if a manual SCRAM has not already occured + function public.cond_scram() + if self.plc_s ~= nil and not self.plc_cache.rps_status.manual then + self.plc_s.in_queue.push_command(PLC_S_CMDS.SCRAM) + end + end + + -- acknowledge all alarms (if possible) + function public.ack_all() + for i = 1, #self.db.alarm_states do + if self.db.alarm_states[i] == ALARM_STATE.TRIPPED then + self.db.alarm_states[i] = ALARM_STATE.ACKED + end + end + end + + -- acknowledge an alarm (if possible) + ---@param id ALARM alarm ID + function public.ack_alarm(id) + if type(id) == "number" and self.db.alarm_states[id] == ALARM_STATE.TRIPPED then + self.db.alarm_states[id] = ALARM_STATE.ACKED + end + end + + -- reset an alarm (if possible) + ---@param id ALARM alarm ID + function public.reset_alarm(id) + if type(id) == "number" and self.db.alarm_states[id] == ALARM_STATE.RING_BACK then + self.db.alarm_states[id] = ALARM_STATE.INACTIVE + end + end + + -- route reactor waste + ---@param mode WASTE_MODE waste handling mode + function public.set_waste(mode) + if mode == WASTE_MODE.AUTO then + ---@todo automatic waste routing + self.waste_mode = mode + elseif mode == WASTE_MODE.PLUTONIUM then + -- route through plutonium generation + self.waste_mode = mode + waste_pu.open() + waste_sna.close() + waste_po.close() + waste_sps.close() + elseif mode == WASTE_MODE.POLONIUM then + -- route through polonium generation into pellets + self.waste_mode = mode + waste_pu.close() + waste_sna.open() + waste_po.open() + waste_sps.close() + elseif mode == WASTE_MODE.ANTI_MATTER then + -- route through polonium generation into SPS + self.waste_mode = mode + waste_pu.close() + waste_sna.open() + waste_po.close() + waste_sps.open() + else + log.debug(util.c("invalid waste mode setting ", mode)) + end + end + + -- set the automatic control max burn rate for this unit + ---@param limit number burn rate limit for auto control + function public.set_burn_limit(limit) + if limit > 0 then + self.db.control.lim_br100 = math.floor(limit * 100) + + if self.plc_i ~= nil then + if limit > self.plc_i.get_struct().max_burn then + self.db.control.lim_br100 = math.floor(self.plc_i.get_struct().max_burn * 100) + end + end + end + end + + --#endregion + + -- READ STATES/PROPERTIES -- + --#region + + -- check if an alarm of at least a certain priority level is tripped + ---@nodiscard + ---@param min_prio ALARM_PRIORITY alarms with this priority or higher will be checked + ---@return boolean tripped + function public.has_alarm_min_prio(min_prio) + for _, alarm in pairs(self.alarms) do + if alarm.tier <= min_prio and (alarm.state == AISTATE.TRIPPED or alarm.state == AISTATE.ACKED) then + return true + end + end + + return false + end + + -- get build properties of all machines + ---@nodiscard + ---@param inc_plc boolean? true/nil to include PLC build, false to exclude + ---@param inc_boilers boolean? true/nil to include boiler builds, false to exclude + ---@param inc_turbines boolean? true/nil to include turbine builds, false to exclude + function public.get_build(inc_plc, inc_boilers, inc_turbines) + local build = {} + + if inc_plc ~= false then + if self.plc_i ~= nil then + build.reactor = self.plc_i.get_struct() + end + end + + if inc_boilers ~= false then + build.boilers = {} + for i = 1, #self.boilers do + local boiler = self.boilers[i] ---@type unit_session + build.boilers[boiler.get_device_idx()] = { boiler.get_db().formed, boiler.get_db().build } + end + end + + if inc_turbines ~= false then + build.turbines = {} + for i = 1, #self.turbines do + local turbine = self.turbines[i] ---@type unit_session + build.turbines[turbine.get_device_idx()] = { turbine.get_db().formed, turbine.get_db().build } + end + end + + return build + end + + -- get reactor status + ---@nodiscard + function public.get_reactor_status() + local status = {} + if self.plc_i ~= nil then + status = { self.plc_i.get_status(), self.plc_i.get_rps(), self.plc_i.get_general_status() } + end + + return status + end + + -- get RTU statuses + ---@nodiscard + function public.get_rtu_statuses() + local status = {} + + -- status of boilers (including tanks) + status.boilers = {} + for i = 1, #self.boilers do + local boiler = self.boilers[i] ---@type unit_session + status.boilers[boiler.get_device_idx()] = { + boiler.is_faulted(), + boiler.get_db().formed, + boiler.get_db().state, + boiler.get_db().tanks + } + end + + -- status of turbines (including tanks) + status.turbines = {} + for i = 1, #self.turbines do + local turbine = self.turbines[i] ---@type unit_session + status.turbines[turbine.get_device_idx()] = { + turbine.is_faulted(), + turbine.get_db().formed, + turbine.get_db().state, + turbine.get_db().tanks + } + end + + -- radiation monitors (environment detectors) + status.rad_mon = {} + for i = 1, #self.envd do + local envd = self.envd[i] ---@type unit_session + status.rad_mon[envd.get_device_idx()] = { + envd.is_faulted(), + envd.get_db().radiation + } + end + + return status + end + + -- get the annunciator status + ---@nodiscard + function public.get_annunciator() return self.db.annunciator end + + -- get the alarm states + ---@nodiscard + function public.get_alarms() return self.db.alarm_states end + + -- get information required for automatic reactor control + ---@nodiscard + function public.get_control_inf() return self.db.control end + + -- get unit state + ---@nodiscard + function public.get_state() + return { self.status_text[1], self.status_text[2], self.waste_mode, self.db.control.ready, self.db.control.degraded } + end + + -- get the reactor ID + ---@nodiscard + function public.get_id() return self.r_id end + + --#endregion + + return public +end + +return unit diff --git a/supervisor/unitlogic.lua b/supervisor/unitlogic.lua new file mode 100644 index 0000000..4aa41d2 --- /dev/null +++ b/supervisor/unitlogic.lua @@ -0,0 +1,847 @@ +local const = require("scada-common.constants") +local log = require("scada-common.log") +local rsio = require("scada-common.rsio") +local types = require("scada-common.types") +local util = require("scada-common.util") + +local plc = require("supervisor.session.plc") + +local qtypes = require("supervisor.session.rtu.qtypes") + +local RPS_TRIP_CAUSE = types.RPS_TRIP_CAUSE +local TRI_FAIL = types.TRI_FAIL +local DUMPING_MODE = types.DUMPING_MODE +local PRIO = types.ALARM_PRIORITY +local ALARM_STATE = types.ALARM_STATE + +local TBV_RTU_S_DATA = qtypes.TBV_RTU_S_DATA + +local IO = rsio.IO + +local PLC_S_CMDS = plc.PLC_S_CMDS + +local AISTATE_NAMES = { + "INACTIVE", + "TRIPPING", + "TRIPPED", + "ACKED", + "RING_BACK", + "RING_BACK_TRIPPING" +} + +local FLOW_STABILITY_DELAY_MS = const.FLOW_STABILITY_DELAY_MS + +local ANNUNC_LIMS = const.ANNUNCIATOR_LIMITS +local ALARM_LIMS = const.ALARM_LIMITS + +---@class unit_logic_extension +local logic = {} + +-- update the annunciator +---@param self _unit_self +function logic.update_annunciator(self) + local DT_KEYS = self.types.DT_KEYS + local _get_dt = self._get_dt + + local num_boilers = self.num_boilers + local num_turbines = self.num_turbines + + self.db.annunciator.RCSFault = false + + -- variables for boiler, or reactor if no boilers used + local total_boil_rate = 0.0 + + ------------- + -- REACTOR -- + ------------- + + self.db.annunciator.AutoControl = self.auto_engaged + + -- check PLC status + self.db.annunciator.PLCOnline = self.plc_i ~= nil + + local plc_ready = self.db.annunciator.PLCOnline + + if self.db.annunciator.PLCOnline then + local plc_db = self.plc_i.get_db() + + -- update ready state + -- - can't be tripped + -- - must have received status at least once + -- - must have received struct at least once + plc_ready = plc_db.formed and (not plc_db.no_reactor) and (not plc_db.rps_tripped) and + (next(self.plc_i.get_status()) ~= nil) and (next(self.plc_i.get_struct()) ~= nil) + + -- update auto control limit + if (self.db.control.lim_br100 == 0) or ((self.db.control.lim_br100 / 100) > plc_db.mek_struct.max_burn) then + self.db.control.lim_br100 = math.floor(plc_db.mek_struct.max_burn * 100) + end + + -- some alarms wait until the burn rate has stabilized, so keep track of that + if math.abs(_get_dt(DT_KEYS.ReactorBurnR)) > 0 then + self.last_rate_change_ms = util.time_ms() + end + + -- record reactor stats + self.plc_cache.active = plc_db.mek_status.status + self.plc_cache.ok = not (plc_db.rps_status.fault or plc_db.rps_status.sys_fail or plc_db.rps_status.force_dis) + self.plc_cache.rps_trip = plc_db.rps_tripped + self.plc_cache.rps_status = plc_db.rps_status + self.plc_cache.damage = plc_db.mek_status.damage + self.plc_cache.temp = plc_db.mek_status.temp + self.plc_cache.waste = plc_db.mek_status.waste_fill + + -- track damage + if plc_db.mek_status.damage > 0 then + if self.damage_start == 0 then + self.damage_decreasing = false + self.damage_start = util.time_s() + self.damage_initial = plc_db.mek_status.damage + end + else + self.damage_decreasing = false + self.damage_start = 0 + self.damage_initial = 0 + self.damage_last = 0 + self.damage_est_last = 0 + end + + -- heartbeat blink about every second + if self.last_heartbeat + 1000 < plc_db.last_status_update then + self.db.annunciator.PLCHeartbeat = not self.db.annunciator.PLCHeartbeat + self.last_heartbeat = plc_db.last_status_update + end + + local flow_low = util.trinary(plc_db.mek_status.ccool_type == types.FLUID.SODIUM, ANNUNC_LIMS.RCSFlowLow_NA, ANNUNC_LIMS.RCSFlowLow_H2O) + + -- update other annunciator fields + self.db.annunciator.ReactorSCRAM = plc_db.rps_tripped + self.db.annunciator.ManualReactorSCRAM = plc_db.rps_trip_cause == types.RPS_TRIP_CAUSE.MANUAL + self.db.annunciator.AutoReactorSCRAM = plc_db.rps_trip_cause == types.RPS_TRIP_CAUSE.AUTOMATIC + self.db.annunciator.RCPTrip = plc_db.rps_tripped and (plc_db.rps_status.ex_hcool or plc_db.rps_status.low_cool) + self.db.annunciator.RCSFlowLow = _get_dt(DT_KEYS.ReactorCCool) < flow_low + self.db.annunciator.CoolantLevelLow = plc_db.mek_status.ccool_fill < ANNUNC_LIMS.CoolantLevelLow + self.db.annunciator.ReactorTempHigh = plc_db.mek_status.temp > ANNUNC_LIMS.ReactorTempHigh + self.db.annunciator.ReactorHighDeltaT = _get_dt(DT_KEYS.ReactorTemp) > ANNUNC_LIMS.ReactorHighDeltaT + self.db.annunciator.FuelInputRateLow = _get_dt(DT_KEYS.ReactorFuel) < -1.0 or plc_db.mek_status.fuel_fill <= ANNUNC_LIMS.FuelLevelLow + self.db.annunciator.WasteLineOcclusion = _get_dt(DT_KEYS.ReactorWaste) > 1.0 or plc_db.mek_status.waste_fill >= ANNUNC_LIMS.WasteLevelHigh + + -- this warning applies when no coolant is buffered (which we can't easily determine without running) + --[[ + logic is that each tick, the heating rate worth of coolant steps between: + reactor tank + reactor heated coolant outflow tube + boiler/turbine tank + reactor cooled coolant return tube + so if there is a tick where coolant is no longer present in the reactor, then bad things happen. + such as when a burn rate consumes half the coolant in the tank, meaning that: + 50% at some point will be in the boiler, and 50% in a tube, so that leaves 0% in the reactor + ]]-- + local heating_rate_conv = util.trinary(plc_db.mek_status.ccool_type == types.FLUID.SODIUM, 200000, 20000) + local high_rate = (plc_db.mek_status.ccool_amnt / (plc_db.mek_status.burn_rate * heating_rate_conv)) < 4 + self.db.annunciator.HighStartupRate = not plc_db.mek_status.status and high_rate + + -- if no boilers, use reactor heating rate to check for boil rate mismatch + if num_boilers == 0 then + total_boil_rate = plc_db.mek_status.heating_rate + end + else + self.plc_cache.ok = false + end + + --------------- + -- MISC RTUs -- + --------------- + + self.db.annunciator.RadiationMonitor = 1 + self.db.annunciator.RadiationWarning = false + for i = 1, #self.envd do + local envd = self.envd[i] ---@type unit_session + self.db.annunciator.RadiationMonitor = util.trinary(envd.is_faulted(), 2, 3) + self.db.annunciator.RadiationWarning = envd.get_db().radiation_raw >= ANNUNC_LIMS.RadiationWarning + break + end + + self.db.annunciator.EmergencyCoolant = 1 + for i = 1, #self.redstone do + local db = self.redstone[i].get_db() ---@type redstone_session_db + local io = db.io[IO.U_EMER_COOL] ---@type rs_db_dig_io|nil + if io ~= nil then + self.db.annunciator.EmergencyCoolant = util.trinary(io.read(), 3, 2) + break + end + end + + ------------- + -- BOILERS -- + ------------- + + local boilers_ready = num_boilers == #self.boilers + + -- clear boiler online flags + for i = 1, num_boilers do self.db.annunciator.BoilerOnline[i] = false end + + -- aggregated statistics + local boiler_steam_dt_sum = 0.0 + local boiler_water_dt_sum = 0.0 + + if num_boilers > 0 then + -- go through boilers for stats and online + for i = 1, #self.boilers do + local session = self.boilers[i] ---@type unit_session + local boiler = session.get_db() ---@type boilerv_session_db + local idx = session.get_device_idx() + + self.db.annunciator.RCSFault = self.db.annunciator.RCSFault or (not boiler.formed) or session.is_faulted() + + -- update ready state + -- - must be formed + -- - must have received build, state, and tanks at least once + boilers_ready = boilers_ready and boiler.formed and + (boiler.build.last_update > 0) and + (boiler.state.last_update > 0) and + (boiler.tanks.last_update > 0) + + total_boil_rate = total_boil_rate + boiler.state.boil_rate + boiler_steam_dt_sum = _get_dt(DT_KEYS.BoilerSteam .. idx) + boiler_water_dt_sum = _get_dt(DT_KEYS.BoilerWater .. idx) + + self.db.annunciator.BoilerOnline[idx] = true + self.db.annunciator.WaterLevelLow[idx] = boiler.tanks.water_fill < ANNUNC_LIMS.WaterLevelLow + end + + -- check heating rate low + if self.plc_i ~= nil and #self.boilers > 0 then + local r_db = self.plc_i.get_db() + + -- check for inactive boilers while reactor is active + for i = 1, #self.boilers do + local boiler = self.boilers[i] ---@type unit_session + local idx = boiler.get_device_idx() + local db = boiler.get_db() ---@type boilerv_session_db + + if r_db.mek_status.status then + self.db.annunciator.HeatingRateLow[idx] = db.state.boil_rate == 0 + else + self.db.annunciator.HeatingRateLow[idx] = false + end + end + end + else + boiler_steam_dt_sum = _get_dt(DT_KEYS.ReactorHCool) + boiler_water_dt_sum = _get_dt(DT_KEYS.ReactorCCool) + end + + --------------------------- + -- COOLANT FEED MISMATCH -- + --------------------------- + + -- check coolant feed mismatch if using boilers, otherwise calculate with reactor + local cfmismatch = false + + if num_boilers > 0 then + for i = 1, #self.boilers do + local boiler = self.boilers[i] ---@type unit_session + local idx = boiler.get_device_idx() + local db = boiler.get_db() ---@type boilerv_session_db + + local gaining_hc = _get_dt(DT_KEYS.BoilerHCool .. idx) > 10.0 or db.tanks.hcool_fill == 1 + + -- gaining heated coolant + cfmismatch = cfmismatch or gaining_hc + -- losing cooled coolant + cfmismatch = cfmismatch or _get_dt(DT_KEYS.BoilerCCool .. idx) < -10.0 or (gaining_hc and db.tanks.ccool_fill == 0) + end + elseif self.plc_i ~= nil then + local r_db = self.plc_i.get_db() + + local gaining_hc = _get_dt(DT_KEYS.ReactorHCool) > 10.0 or r_db.mek_status.hcool_fill == 1 + + -- gaining heated coolant (steam) + cfmismatch = cfmismatch or gaining_hc + -- losing cooled coolant (water) + cfmismatch = cfmismatch or _get_dt(DT_KEYS.ReactorCCool) < -10.0 or (gaining_hc and r_db.mek_status.ccool_fill == 0) + end + + self.db.annunciator.CoolantFeedMismatch = cfmismatch + + -------------- + -- TURBINES -- + -------------- + + local turbines_ready = num_turbines == #self.turbines + + -- clear turbine online flags + for i = 1, num_turbines do self.db.annunciator.TurbineOnline[i] = false end + + -- aggregated statistics + local total_flow_rate = 0 + local total_input_rate = 0 + local max_water_return_rate = 0 + + -- recompute blade count on the chance that it may have changed + self.db.control.blade_count = 0 + + -- go through turbines for stats and online + for i = 1, #self.turbines do + local session = self.turbines[i] ---@type unit_session + local turbine = session.get_db() ---@type turbinev_session_db + + self.db.annunciator.RCSFault = self.db.annunciator.RCSFault or (not turbine.formed) or session.is_faulted() + + -- update ready state + -- - must be formed + -- - must have received build, state, and tanks at least once + turbines_ready = turbines_ready and turbine.formed and + (turbine.build.last_update > 0) and + (turbine.state.last_update > 0) and + (turbine.tanks.last_update > 0) + + total_flow_rate = total_flow_rate + turbine.state.flow_rate + total_input_rate = total_input_rate + turbine.state.steam_input_rate + max_water_return_rate = max_water_return_rate + turbine.build.max_water_output + self.db.control.blade_count = self.db.control.blade_count + turbine.build.blades + + self.db.annunciator.TurbineOnline[session.get_device_idx()] = true + end + + -- check for boil rate mismatch (> 4% error) either between reactor and turbine or boiler and turbine + self.db.annunciator.BoilRateMismatch = math.abs(total_boil_rate - total_input_rate) > (0.04 * total_boil_rate) + + -- check for steam feed mismatch and max return rate + local steam_dt_max = util.trinary(num_boilers == 0, ANNUNC_LIMS.SFM_MaxSteamDT_H20, ANNUNC_LIMS.SFM_MaxSteamDT_NA) + local water_dt_min = util.trinary(num_boilers == 0, ANNUNC_LIMS.SFM_MinWaterDT_H20, ANNUNC_LIMS.SFM_MinWaterDT_NA) + local sfmismatch = math.abs(total_flow_rate - total_input_rate) > ANNUNC_LIMS.SteamFeedMismatch + sfmismatch = sfmismatch or boiler_steam_dt_sum > steam_dt_max or boiler_water_dt_sum < water_dt_min + self.db.annunciator.SteamFeedMismatch = sfmismatch + self.db.annunciator.MaxWaterReturnFeed = max_water_return_rate == total_flow_rate and total_flow_rate ~= 0 + + -- check if steam dumps are open + for i = 1, #self.turbines do + local turbine = self.turbines[i] ---@type unit_session + local db = turbine.get_db() ---@type turbinev_session_db + local idx = turbine.get_device_idx() + + if db.state.dumping_mode == DUMPING_MODE.IDLE then + self.db.annunciator.SteamDumpOpen[idx] = TRI_FAIL.OK + elseif db.state.dumping_mode == DUMPING_MODE.DUMPING_EXCESS then + self.db.annunciator.SteamDumpOpen[idx] = TRI_FAIL.PARTIAL + else + self.db.annunciator.SteamDumpOpen[idx] = TRI_FAIL.FULL + end + end + + -- check if turbines are at max speed but not keeping up + for i = 1, #self.turbines do + local turbine = self.turbines[i] ---@type unit_session + local db = turbine.get_db() ---@type turbinev_session_db + local idx = turbine.get_device_idx() + + self.db.annunciator.TurbineOverSpeed[idx] = (db.state.flow_rate == db.build.max_flow_rate) and (_get_dt(DT_KEYS.TurbineSteam .. idx) > 0.0) + end + + --[[ + Turbine Trip + a turbine trip is when the turbine stops, which means we are no longer receiving water and lose the ability to cool. + this can be identified by these conditions: + - the current flow rate is 0 mB/t and it should not be + - can initially catch this by detecting a 0 flow rate with a non-zero input rate, but eventually the steam will fill up + - can later identified by presence of steam in tank with a 0 flow rate + ]]-- + for i = 1, #self.turbines do + local turbine = self.turbines[i] ---@type unit_session + local db = turbine.get_db() ---@type turbinev_session_db + + local has_steam = db.state.steam_input_rate > 0 or db.tanks.steam_fill > 0.01 + self.db.annunciator.TurbineTrip[turbine.get_device_idx()] = has_steam and db.state.flow_rate == 0 + end + + -- update auto control ready state for this unit + self.db.control.ready = plc_ready and boilers_ready and turbines_ready +end + +-- update an alarm state given conditions +---@param self _unit_self unit instance +---@param tripped boolean if the alarm condition is still active +---@param alarm alarm_def alarm table +local function _update_alarm_state(self, tripped, alarm) + local AISTATE = self.types.AISTATE + local int_state = alarm.state + local ext_state = self.db.alarm_states[alarm.id] + + -- alarm inactive + if int_state == AISTATE.INACTIVE then + if tripped then + alarm.trip_time = util.time_ms() + if alarm.hold_time > 0 then + alarm.state = AISTATE.TRIPPING + self.db.alarm_states[alarm.id] = ALARM_STATE.INACTIVE + else + alarm.state = AISTATE.TRIPPED + self.db.alarm_states[alarm.id] = ALARM_STATE.TRIPPED + log.info(util.c("UNIT ", self.r_id, " ALARM ", alarm.id, " (", types.ALARM_NAMES[alarm.id], "): TRIPPED [PRIORITY ", + types.ALARM_PRIORITY_NAMES[alarm.tier],"]")) + end + else + alarm.trip_time = util.time_ms() + self.db.alarm_states[alarm.id] = ALARM_STATE.INACTIVE + end + -- alarm condition met, but not yet for required hold time + elseif (int_state == AISTATE.TRIPPING) or (int_state == AISTATE.RING_BACK_TRIPPING) then + if tripped then + local elapsed = util.time_ms() - alarm.trip_time + if elapsed > (alarm.hold_time * 1000) then + alarm.state = AISTATE.TRIPPED + self.db.alarm_states[alarm.id] = ALARM_STATE.TRIPPED + log.info(util.c("UNIT ", self.r_id, " ALARM ", alarm.id, " (", types.ALARM_NAMES[alarm.id], "): TRIPPED [PRIORITY ", + types.ALARM_PRIORITY_NAMES[alarm.tier],"]")) + end + elseif int_state == AISTATE.RING_BACK_TRIPPING then + alarm.trip_time = 0 + alarm.state = AISTATE.RING_BACK + self.db.alarm_states[alarm.id] = ALARM_STATE.RING_BACK + else + alarm.trip_time = 0 + alarm.state = AISTATE.INACTIVE + self.db.alarm_states[alarm.id] = ALARM_STATE.INACTIVE + end + -- alarm tripped and alarming + elseif int_state == AISTATE.TRIPPED then + if tripped then + if ext_state == ALARM_STATE.ACKED then + -- was acked by coordinator + alarm.state = AISTATE.ACKED + end + else + alarm.state = AISTATE.RING_BACK + self.db.alarm_states[alarm.id] = ALARM_STATE.RING_BACK + end + -- alarm acknowledged but still tripped + elseif int_state == AISTATE.ACKED then + if not tripped then + alarm.state = AISTATE.RING_BACK + self.db.alarm_states[alarm.id] = ALARM_STATE.RING_BACK + end + -- alarm no longer tripped, operator must reset to clear + elseif int_state == AISTATE.RING_BACK then + if tripped then + alarm.trip_time = util.time_ms() + if alarm.hold_time > 0 then + alarm.state = AISTATE.RING_BACK_TRIPPING + else + alarm.state = AISTATE.TRIPPED + self.db.alarm_states[alarm.id] = ALARM_STATE.TRIPPED + end + elseif ext_state == ALARM_STATE.INACTIVE then + -- was reset by coordinator + alarm.state = AISTATE.INACTIVE + alarm.trip_time = 0 + end + else + log.error(util.c("invalid alarm state for unit ", self.r_id, " alarm ", alarm.id), true) + end + + -- check for state change + if alarm.state ~= int_state then + local change_str = util.c(AISTATE_NAMES[int_state], " -> ", AISTATE_NAMES[alarm.state]) + log.debug(util.c("UNIT ", self.r_id, " ALARM ", alarm.id, " (", types.ALARM_NAMES[alarm.id], "): ", change_str)) + end +end + +-- evaluate alarm conditions +---@param self _unit_self unit instance +function logic.update_alarms(self) + local annunc = self.db.annunciator + local plc_cache = self.plc_cache + + -- Containment Breach + -- lost plc with critical damage (rip plc, you will be missed) + _update_alarm_state(self, (not plc_cache.ok) and (plc_cache.damage > 99), self.alarms.ContainmentBreach) + + -- Containment Radiation + local rad_alarm = false + for i = 1, #self.envd do + self.last_radiation = self.envd[i].get_db().radiation_raw + rad_alarm = self.last_radiation >= ALARM_LIMS.HIGH_RADIATION + break + end + _update_alarm_state(self, rad_alarm, self.alarms.ContainmentRadiation) + + -- Reactor Lost + _update_alarm_state(self, self.had_reactor and self.plc_i == nil, self.alarms.ReactorLost) + + -- Critical Damage + _update_alarm_state(self, plc_cache.damage >= 100, self.alarms.CriticalDamage) + + -- Reactor Damage + local rps_dmg_90 = plc_cache.rps_status.high_dmg and not self.last_rps_trips.high_dmg + _update_alarm_state(self, (plc_cache.damage > 0) or rps_dmg_90, self.alarms.ReactorDamage) + + -- Over-Temperature + local rps_high_temp = plc_cache.rps_status.high_temp and not self.last_rps_trips.high_temp + _update_alarm_state(self, (plc_cache.temp >= 1200) or rps_high_temp, self.alarms.ReactorOverTemp) + + -- High Temperature + _update_alarm_state(self, plc_cache.temp >= ALARM_LIMS.HIGH_TEMP, self.alarms.ReactorHighTemp) + + -- Waste Leak + _update_alarm_state(self, plc_cache.waste >= 1.0, self.alarms.ReactorWasteLeak) + + -- High Waste + local rps_high_waste = plc_cache.rps_status.ex_waste and not self.last_rps_trips.ex_waste + _update_alarm_state(self, (plc_cache.waste > ALARM_LIMS.HIGH_WASTE) or rps_high_waste, self.alarms.ReactorHighWaste) + + -- RPS Transient (excludes timeouts and manual trips) + local rps_alarm = false + if plc_cache.rps_status.manual ~= nil then + if plc_cache.rps_trip then + for key, val in pairs(plc_cache.rps_status) do + if key ~= "manual" and key ~= "timeout" then rps_alarm = rps_alarm or val end + end + end + end + + _update_alarm_state(self, rps_alarm, self.alarms.RPSTransient) + + -- RCS Transient + local any_low = annunc.CoolantLevelLow + local any_over = false + for i = 1, #annunc.WaterLevelLow do any_low = any_low or annunc.WaterLevelLow[i] end + for i = 1, #annunc.TurbineOverSpeed do any_over = any_over or annunc.TurbineOverSpeed[i] end + + local rcs_trans = any_low or any_over or annunc.RCPTrip or annunc.MaxWaterReturnFeed + + -- only care about RCS flow low early with boilers + if self.num_boilers > 0 then rcs_trans = rcs_trans or annunc.RCSFlowLow end + + -- annunciator indicators for these states may not indicate a real issue when: + -- > flow is ramping up right after reactor start + -- > flow is ramping down after reactor shutdown + if ((util.time_ms() - self.last_rate_change_ms) > FLOW_STABILITY_DELAY_MS) and plc_cache.active then + rcs_trans = rcs_trans or annunc.RCSFlowLow or annunc.BoilRateMismatch or annunc.CoolantFeedMismatch or annunc.SteamFeedMismatch + end + + _update_alarm_state(self, rcs_trans, self.alarms.RCSTransient) + + -- Turbine Trip + local any_trip = false + for i = 1, #annunc.TurbineTrip do any_trip = any_trip or annunc.TurbineTrip[i] end + _update_alarm_state(self, any_trip, self.alarms.TurbineTrip) + + -- update last trips table + for key, val in pairs(plc_cache.rps_status) do + self.last_rps_trips[key] = val + end +end + +-- update the internal automatic safety control performed while in auto control mode +---@param public reactor_unit reactor unit public functions +---@param self _unit_self unit instance +function logic.update_auto_safety(public, self) + local AISTATE = self.types.AISTATE + + if self.auto_engaged then + local alarmed = false + + for _, alarm in pairs(self.alarms) do + if alarm.tier <= PRIO.URGENT and (alarm.state == AISTATE.TRIPPED or alarm.state == AISTATE.ACKED) then + if not self.auto_was_alarmed then + log.info(util.c("UNIT ", self.r_id, " AUTO SCRAM due to ALARM ", alarm.id, " (", types.ALARM_NAMES[alarm.id], ") [PRIORITY ", + types.ALARM_PRIORITY_NAMES[alarm.tier],"]")) + end + + alarmed = true + break + end + end + + if alarmed and not self.plc_cache.rps_status.automatic then + public.a_scram() + end + + self.auto_was_alarmed = alarmed + else + self.auto_was_alarmed = false + end +end + +-- update the two unit status text messages +---@param self _unit_self unit instance +function logic.update_status_text(self) + local AISTATE = self.types.AISTATE + + -- check if an alarm is active (tripped or ack'd) + ---@nodiscard + ---@param alarm table alarm entry + ---@return boolean active + local function is_active(alarm) + return alarm.state == AISTATE.TRIPPED or alarm.state == AISTATE.ACKED + end + + -- update status text (what the reactor doin?) + if is_active(self.alarms.ContainmentBreach) then + -- boom? or was boom disabled + if self.plc_i ~= nil and self.plc_i.get_rps().force_dis then + self.status_text = { "REACTOR FORCE DISABLED", "meltdown would have occurred" } + else + self.status_text = { "CORE MELTDOWN", "reactor destroyed" } + end + elseif is_active(self.alarms.CriticalDamage) then + -- so much for it being a "routine turbin' trip"... + self.status_text = { "MELTDOWN IMMINENT", "evacuate facility immediately" } + elseif is_active(self.alarms.ReactorDamage) then + -- attempt to determine when a chance of a meltdown will occur + self.status_text[1] = "CONTAINMENT TAKING DAMAGE" + if self.plc_cache.damage >= 100 then + self.status_text[2] = "damage critical" + elseif (self.plc_cache.damage < self.damage_last) or ((self.plc_cache.damage - self.damage_initial) < 0) then + self.damage_decreasing = true + self.status_text = { "CONTAINMENT TOOK DAMAGE", "damage level lowering..." } + + -- reset damage estimation data in case it goes back up again + self.damage_initial = self.plc_cache.damage + self.damage_start = util.time_s() + self.damage_est_last = 0 + elseif (not self.damage_decreasing) or (self.plc_cache.damage > self.damage_last) then + self.damage_decreasing = false + + if (self.plc_cache.damage - self.damage_initial) > 0 then + if self.plc_cache.damage > self.damage_last then + local rate = (self.plc_cache.damage - self.damage_initial) / (util.time_s() - self.damage_start) + self.damage_est_last = (100 - self.plc_cache.damage) / rate + end + + self.status_text[2] = util.c("damage critical in ", util.sprintf("%.1f", self.damage_est_last), "s") + else + self.status_text[2] = "estimating time to critical..." + end + else + self.status_text = { "CONTAINMENT TOOK DAMAGE", "damage level lowering..." } + end + + self.damage_last = self.plc_cache.damage + elseif is_active(self.alarms.ContainmentRadiation) then + self.status_text[1] = "RADIATION DETECTED" + + if self.last_radiation >= const.EXTREME_RADIATION then + self.status_text[2] = "extremely high radiation level" + elseif self.last_radiation >= const.SEVERE_RADIATION then + self.status_text[2] = "severely high radiation level" + elseif self.last_radiation >= const.VERY_HIGH_RADIATION then + self.status_text[2] = "very high level of radiation" + elseif self.last_radiation >= const.HIGH_RADIATION then + self.status_text[2] = "high level of radiation" + elseif self.last_radiation >= const.HAZARD_RADIATION then + self.status_text[2] = "hazardous level of radiation" + else + self.status_text[2] = "elevated level of radiation" + end + elseif is_active(self.alarms.ReactorOverTemp) then + self.status_text = { "CORE OVER TEMP", "reactor core temperature >=1200K" } + elseif is_active(self.alarms.ReactorWasteLeak) then + self.status_text = { "WASTE LEAK", "radioactive waste leak detected" } + elseif is_active(self.alarms.ReactorHighTemp) then + self.status_text = { "CORE TEMP HIGH", "reactor core temperature >1150K" } + elseif is_active(self.alarms.ReactorHighWaste) then + self.status_text = { "WASTE LEVEL HIGH", "waste accumulating in reactor" } + elseif is_active(self.alarms.TurbineTrip) then + self.status_text = { "TURBINE TRIP", "turbine stall occurred" } + elseif is_active(self.alarms.RCSTransient) then + self.status_text = { "RCS TRANSIENT", "check coolant system" } + -- elseif is_active(self.alarms.RPSTransient) then + -- RPS status handled when checking reactor status + elseif self.emcool_opened then + self.status_text = { "EMERGENCY COOLANT OPENED", "reset RPS to close valve" } + -- connection dependent states + elseif self.plc_i ~= nil then + local plc_db = self.plc_i.get_db() + if plc_db.mek_status.status then + self.status_text[1] = "ACTIVE" + + if self.db.annunciator.ReactorHighDeltaT then + self.status_text[2] = "core temperature rising" + elseif self.db.annunciator.ReactorTempHigh then + self.status_text[2] = "core temp high, system nominal" + elseif self.db.annunciator.FuelInputRateLow then + self.status_text[2] = "insufficient fuel input rate" + elseif self.db.annunciator.WasteLineOcclusion then + self.status_text[2] = "insufficient waste output rate" + elseif (util.time_ms() - self.last_rate_change_ms) <= FLOW_STABILITY_DELAY_MS then + self.status_text[2] = "awaiting flow stability" + else + self.status_text[2] = "system nominal" + end + elseif plc_db.rps_tripped then + local cause = "unknown" + + if plc_db.rps_trip_cause == RPS_TRIP_CAUSE.OK then + -- hmm... + elseif plc_db.rps_trip_cause == RPS_TRIP_CAUSE.HIGH_DMG then + cause = "core damage high" + elseif plc_db.rps_trip_cause == RPS_TRIP_CAUSE.HIGH_TEMP then + cause = "core temperature high" + elseif plc_db.rps_trip_cause == RPS_TRIP_CAUSE.LOW_COOLANT then + cause = "insufficient coolant" + elseif plc_db.rps_trip_cause == RPS_TRIP_CAUSE.EX_WASTE then + cause = "excess waste" + elseif plc_db.rps_trip_cause == RPS_TRIP_CAUSE.EX_HCOOLANT then + cause = "excess heated coolant" + elseif plc_db.rps_trip_cause == RPS_TRIP_CAUSE.NO_FUEL then + cause = "insufficient fuel" + elseif plc_db.rps_trip_cause == RPS_TRIP_CAUSE.FAULT then + cause = "hardware fault" + elseif plc_db.rps_trip_cause == RPS_TRIP_CAUSE.TIMEOUT then + cause = "connection timed out" + elseif plc_db.rps_trip_cause == RPS_TRIP_CAUSE.MANUAL then + cause = "manual operator SCRAM" + elseif plc_db.rps_trip_cause == RPS_TRIP_CAUSE.AUTOMATIC then + cause = "automated system SCRAM" + elseif plc_db.rps_trip_cause == RPS_TRIP_CAUSE.SYS_FAIL then + cause = "PLC system failure" + elseif plc_db.rps_trip_cause == RPS_TRIP_CAUSE.FORCE_DISABLED then + cause = "reactor force disabled" + end + + self.status_text = { "RPS SCRAM", cause } + elseif self.db.annunciator.RadiationWarning then + -- elevated, non-hazardous level of radiation is low priority, so display it now if everything else was fine + self.status_text = { "RADIATION DETECTED", "elevated level of radiation" } + else + self.status_text[1] = "IDLE" + + local temp = plc_db.mek_status.temp + if temp < 350 then + self.status_text[2] = "core cold" + elseif temp < 600 then + self.status_text[2] = "core warm" + else + self.status_text[2] = "core hot" + end + end + elseif self.db.annunciator.RadiationWarning then + -- in case PLC was disconnected but radiation is present + self.status_text = { "RADIATION DETECTED", "elevated level of radiation" } + else + self.status_text = { "REACTOR OFF-LINE", "awaiting connection..." } + end +end + +-- handle unit redstone I/O +---@param self _unit_self unit instance +function logic.handle_redstone(self) + local AISTATE = self.types.AISTATE + + -- check if an alarm is active (tripped or ack'd) + ---@nodiscard + ---@param alarm table alarm entry + ---@return boolean active + local function is_active(alarm) + return alarm.state == AISTATE.TRIPPED or alarm.state == AISTATE.ACKED + end + + -- reactor controls + if self.plc_s ~= nil then + if (not self.plc_cache.rps_status.manual) and self.io_ctl.digital_read(IO.R_SCRAM) then + -- reactor SCRAM requested but not yet done; perform it + self.plc_s.in_queue.push_command(PLC_S_CMDS.SCRAM) + end + + if self.plc_cache.rps_trip and self.io_ctl.digital_read(IO.R_RESET) then + -- reactor RPS reset requested but not yet done; perform it + self.plc_s.in_queue.push_command(PLC_S_CMDS.RPS_RESET) + end + + if (not self.auto_engaged) and (not self.plc_cache.active) and + (not self.plc_cache.rps_trip) and self.io_ctl.digital_read(IO.R_ENABLE) then + -- reactor enable requested and allowable, but not yet done; perform it + self.plc_s.in_queue.push_command(PLC_S_CMDS.ENABLE) + end + end + + -- check for request to ack all alarms + if self.io_ctl.digital_read(IO.U_ACK) then + for i = 1, #self.db.alarm_states do + if self.db.alarm_states[i] == ALARM_STATE.TRIPPED then + self.db.alarm_states[i] = ALARM_STATE.ACKED + end + end + end + + -- write reactor status outputs + self.io_ctl.digital_write(IO.R_ACTIVE, self.plc_cache.active) + self.io_ctl.digital_write(IO.R_AUTO_CTRL, self.auto_engaged) + self.io_ctl.digital_write(IO.R_SCRAMMED, self.plc_cache.rps_trip) + self.io_ctl.digital_write(IO.R_AUTO_SCRAM, self.plc_cache.rps_status.automatic) + self.io_ctl.digital_write(IO.R_HIGH_DMG, self.plc_cache.rps_status.high_dmg) + self.io_ctl.digital_write(IO.R_HIGH_TEMP, self.plc_cache.rps_status.high_temp) + self.io_ctl.digital_write(IO.R_LOW_COOLANT, self.plc_cache.rps_status.low_cool) + self.io_ctl.digital_write(IO.R_EXCESS_HC, self.plc_cache.rps_status.ex_hcool) + self.io_ctl.digital_write(IO.R_EXCESS_WS, self.plc_cache.rps_status.ex_waste) + self.io_ctl.digital_write(IO.R_INSUFF_FUEL, self.plc_cache.rps_status.no_fuel) + self.io_ctl.digital_write(IO.R_PLC_FAULT, self.plc_cache.rps_status.fault) + self.io_ctl.digital_write(IO.R_PLC_TIMEOUT, self.plc_cache.rps_status.timeout) + + -- write unit outputs + + local has_alarm = false + for i = 1, #self.db.alarm_states do + if self.db.alarm_states[i] == ALARM_STATE.TRIPPED or self.db.alarm_states[i] == ALARM_STATE.ACKED then + has_alarm = true + break + end + end + + self.io_ctl.digital_write(IO.U_ALARM, has_alarm) + + ----------------------- + -- Emergency Coolant -- + ----------------------- + + local enable_emer_cool = self.plc_cache.rps_status.low_cool or + (self.auto_engaged and self.db.annunciator.CoolantLevelLow and is_active(self.alarms.ReactorOverTemp)) + + if not self.plc_cache.rps_trip then + -- can't turn off on sufficient coolant level since it might drop again + -- turn off once system is OK again + -- if auto control is engaged, alarm check will SCRAM on reactor over temp so that's covered + self.valves.emer_cool.close() + + -- set turbines to not dump steam + for i = 1, #self.turbines do + local session = self.turbines[i] ---@type unit_session + local turbine = session.get_db() ---@type turbinev_session_db + + if turbine.state.dumping_mode ~= DUMPING_MODE.IDLE then + session.get_cmd_queue().push_data(TBV_RTU_S_DATA.SET_DUMP_MODE, DUMPING_MODE.IDLE) + end + end + + if self.db.annunciator.EmergencyCoolant > 1 and self.emcool_opened then + log.info(util.c("UNIT ", self.r_id, " emergency coolant valve closed")) + log.info(util.c("UNIT ", self.r_id, " turbines set to not dump steam")) + end + + self.emcool_opened = false + elseif enable_emer_cool or self.emcool_opened then + self.valves.emer_cool.open() + + -- set turbines to dump excess steam + for i = 1, #self.turbines do + local session = self.turbines[i] ---@type unit_session + local turbine = session.get_db() ---@type turbinev_session_db + + if turbine.state.dumping_mode ~= DUMPING_MODE.DUMPING_EXCESS then + session.get_cmd_queue().push_data(TBV_RTU_S_DATA.SET_DUMP_MODE, DUMPING_MODE.DUMPING_EXCESS) + end + end + + if self.db.annunciator.EmergencyCoolant > 1 and not self.emcool_opened then + log.info(util.c("UNIT ", self.r_id, " emergency coolant valve opened")) + log.info(util.c("UNIT ", self.r_id, " turbines set to dump excess steam")) + end + + self.emcool_opened = true + end +end + +return logic diff --git a/test/lockbox-benchmark.lua b/test/lockbox-benchmark.lua new file mode 100644 index 0000000..0191c2a --- /dev/null +++ b/test/lockbox-benchmark.lua @@ -0,0 +1,104 @@ +require("/initenv").init_env() + +local pbkdf2 = require("lockbox.kdf.pbkdf2") +local AES128Cipher = require("lockbox.cipher.aes128") +local HMAC = require("lockbox.mac.hmac") +local SHA1 = require("lockbox.digest.sha1") +local SHA2_224 = require("lockbox.digest.sha2_224") +local SHA2_256 = require("lockbox.digest.sha2_256") +local Stream = require("lockbox.util.stream") +local Array = require("lockbox.util.array") + +local CBCMode = require("lockbox.cipher.mode.cbc") +local CFBMode = require("lockbox.cipher.mode.cfb") +local OFBMode = require("lockbox.cipher.mode.ofb") +local CTRMode = require("lockbox.cipher.mode.ctr") + +local ZeroPadding = require("lockbox.padding.zero") + +local comms = require("scada-common.comms") +local util = require("scada-common.util") + +local start = util.time() + +local keyd = pbkdf2() + +keyd.setPassword("mypassword") +keyd.setSalt("no_salt_thanks") +keyd.setIterations(16) +keyd.setBlockLen(4) +keyd.setDKeyLen(16) +keyd.setPRF(HMAC().setBlockSize(64).setDigest(SHA2_256)) +keyd.finish() + +util.println("pbkdf2: took " .. (util.time() - start) .. "ms") +util.println(keyd.asHex()) + +local pkt = comms.modbus_packet() +pkt.make(1, 2, 7, {0, 1, 2, 3, 4, 5, 6, 7, 8, 9}) +local spkt = comms.scada_packet() +spkt.make(1, 1, pkt.raw_sendable()) + +start = util.time() +local data = textutils.serialize(spkt.raw_sendable(), { allow_repetitions = true, compact = true }) + +util.println("packet serialize: took " .. (util.time() - start) .. "ms") +util.println("message: " .. data) + +start = util.time() +local v = { + cipher = CTRMode.Cipher, + decipher = CTRMode.Decipher, + iv = Array.fromHex("000102030405060708090A0B0C0D0E0F"), + key = Array.fromHex(keyd.asHex()), + padding = ZeroPadding +} +util.println("v init: took " .. (util.time() - start) .. "ms") + +start = util.time() +local cipher = v.cipher() +.setKey(v.key) +.setBlockCipher(AES128Cipher) +.setPadding(v.padding); +util.println("cipher init: took " .. (util.time() - start) .. "ms") + +start = util.time() +local cipherOutput = cipher + .init() + .update(Stream.fromArray(v.iv)) + .update(Stream.fromString(data)) + .asHex(); +util.println("encrypt: took " .. (util.time() - start) .. "ms") +util.println("ciphertext: " .. cipherOutput) + +start = util.time() +local decipher = v.decipher() + .setKey(v.key) + .setBlockCipher(AES128Cipher) + .setPadding(v.padding); +util.println("decipher init: took " .. (util.time() - start) .. "ms") + +start = util.time() +local plainOutput = decipher + .init() + .update(Stream.fromArray(v.iv)) + .update(Stream.fromHex(cipherOutput)) + .asHex(); +util.println("decrypt: took " .. (util.time() - start) .. "ms") +local a = Stream.fromHex(plainOutput) +local b = Stream.toString(a) +util.println("plaintext: " .. b) + +local msg = "000102030405060708090A0B0C0D0E0F" .. cipherOutput + +start = util.time() +local hash = HMAC() + .setBlockSize(64) + .setDigest(SHA1) + .setKey(keyd) + .init() + .update(Stream.fromHex(msg)) + .finish() + .asHex(); +util.println("hmac: took " .. (util.time() - start) .. "ms") +util.println("hash: " .. hash) diff --git a/test/modbustest.lua b/test/modbustest.lua new file mode 100644 index 0000000..40d2b2f --- /dev/null +++ b/test/modbustest.lua @@ -0,0 +1,236 @@ +require("/initenv").init_env() + +local types = require("scada-common.types") +local util = require("scada-common.util") + +local testutils = require("test.testutils") + +local modbus = require("rtu.modbus") +local redstone_rtu = require("rtu.dev.redstone_rtu") + +local rsio = require("scada-common.rsio") + +local print = util.print +local println = util.println + +local MODBUS_FCODE = types.MODBUS_FCODE +local MODBUS_EXCODE = types.MODBUS_EXCODE + +println("starting redstone RTU and MODBUS tester") +println("") + +-- RTU init -- + +print(">>> init redstone RTU: ") + +local rs_rtu = redstone_rtu.new() + +local di, c, ir, hr = rs_rtu.io_count() +assert(di == 0 and c == 0 and ir == 0 and hr == 0, "IOCOUNT_0") + +rs_rtu.link_di("back", colors.black) +rs_rtu.link_di("back", colors.blue) + +rs_rtu.link_do("back", colors.red) +rs_rtu.link_do("back", colors.purple) + +rs_rtu.link_ai("right") +rs_rtu.link_ao("left") + +di, c, ir, hr = rs_rtu.io_count() +assert(di == 2, "IOCOUNT_DI") +assert(c == 2, "IOCOUNT_C") +assert(ir == 1, "IOCOUNT_IR") +assert(hr == 1, "IOCOUNT_HR") + +println("OK") + +-- MODBUS testing -- + +local rs_modbus = modbus.new(rs_rtu, false) + +local mbt = testutils.modbus_tester(rs_modbus, MODBUS_FCODE.ERROR_FLAG) + +------------------------- +--- CHECKING REQUESTS --- +------------------------- + +println(">>> checking MODBUS requests:") + +print("read c {0}: ") +mbt.pkt_set(MODBUS_FCODE.READ_COILS, {0}) +mbt.test_error__check_request(MODBUS_EXCODE.NEG_ACKNOWLEDGE) +println("PASS") + +print("99 {1,2}: ") +mbt.pkt_set(99, {1, 2}) +mbt.test_error__check_request(MODBUS_EXCODE.ILLEGAL_FUNCTION) +println("PASS") + +print("read c {1,2}: ") +mbt.pkt_set(MODBUS_FCODE.READ_COILS, {1, 2}) +mbt.test_success__check_request(MODBUS_EXCODE.ACKNOWLEDGE) +println("PASS") + +testutils.pause() + +-------------------- +--- BAD REQUESTS --- +-------------------- + +println(">>> trying bad requests:") + +print("read di {1,10}: ") +mbt.pkt_set(MODBUS_FCODE.READ_DISCRETE_INPUTS, {1, 10}) +mbt.test_error__handle_packet(MODBUS_EXCODE.ILLEGAL_DATA_ADDR) +println("PASS") + +print("read di {5,1}: ") +mbt.pkt_set(MODBUS_FCODE.READ_DISCRETE_INPUTS, {5, 1}) +mbt.test_error__handle_packet(MODBUS_EXCODE.ILLEGAL_DATA_ADDR) +println("PASS") + +print("read di {1,0}: ") +mbt.pkt_set(MODBUS_FCODE.READ_DISCRETE_INPUTS, {1, 0}) +mbt.test_error__handle_packet(MODBUS_EXCODE.ILLEGAL_DATA_ADDR) +println("PASS") + +print("read c {5,1}: ") +mbt.pkt_set(MODBUS_FCODE.READ_COILS, {5, 1}) +mbt.test_error__handle_packet(MODBUS_EXCODE.ILLEGAL_DATA_ADDR) +println("PASS") + +print("read c {1,0}: ") +mbt.pkt_set(MODBUS_FCODE.READ_COILS, {1, 0}) +mbt.test_error__handle_packet(MODBUS_EXCODE.ILLEGAL_DATA_ADDR) +println("PASS") + +print("read ir {5,1}: ") +mbt.pkt_set(MODBUS_FCODE.READ_INPUT_REGS, {5, 1}) +mbt.test_error__handle_packet(MODBUS_EXCODE.ILLEGAL_DATA_ADDR) +println("PASS") + +print("read ir {1,0}: ") +mbt.pkt_set(MODBUS_FCODE.READ_INPUT_REGS, {1, 0}) +mbt.test_error__handle_packet(MODBUS_EXCODE.ILLEGAL_DATA_ADDR) +println("PASS") + +print("read hr {5,1}: ") +mbt.pkt_set(MODBUS_FCODE.READ_MUL_HOLD_REGS, {5, 1}) +mbt.test_error__handle_packet(MODBUS_EXCODE.ILLEGAL_DATA_ADDR) +println("PASS") + +print("write c {5,1}: ") +mbt.pkt_set(MODBUS_FCODE.WRITE_SINGLE_COIL, {5, 1}) +mbt.test_error__handle_packet(MODBUS_EXCODE.ILLEGAL_DATA_ADDR) +println("PASS") + +print("write mul c {5,1}: ") +mbt.pkt_set(MODBUS_FCODE.WRITE_SINGLE_COIL, {5, 1}) +mbt.test_error__handle_packet(MODBUS_EXCODE.ILLEGAL_DATA_ADDR) +println("PASS") + +print("write mul c {5,{1}}: ") +mbt.pkt_set(MODBUS_FCODE.WRITE_SINGLE_COIL, {5, {1}}) +mbt.test_error__handle_packet(MODBUS_EXCODE.ILLEGAL_DATA_ADDR) +println("PASS") + +print("write hr {5,1}: ") +mbt.pkt_set(MODBUS_FCODE.WRITE_SINGLE_HOLD_REG, {5, 1}) +mbt.test_error__handle_packet(MODBUS_EXCODE.ILLEGAL_DATA_ADDR) +println("PASS") + +print("write mul hr {5,{1}}: ") +mbt.pkt_set(MODBUS_FCODE.WRITE_SINGLE_HOLD_REG, {5, {1}}) +mbt.test_error__handle_packet(MODBUS_EXCODE.ILLEGAL_DATA_ADDR) +println("PASS") + +testutils.pause() + +---------------------- +--- READING INPUTS --- +---------------------- + +println(">>> reading inputs:") + +print("read di {1,1}: ") +mbt.pkt_set(MODBUS_FCODE.READ_DISCRETE_INPUTS, {1, 1}) +mbt.test_success__handle_packet() + +print("read di {2,1}: ") +mbt.pkt_set(MODBUS_FCODE.READ_DISCRETE_INPUTS, {2, 1}) +mbt.test_success__handle_packet() + +print("read di {1,2}: ") +mbt.pkt_set(MODBUS_FCODE.READ_DISCRETE_INPUTS, {1, 2}) +mbt.test_success__handle_packet() + +print("read ir {1,1}: ") +mbt.pkt_set(MODBUS_FCODE.READ_INPUT_REGS, {1, 1}) +mbt.test_success__handle_packet() + +testutils.pause() + +----------------------- +--- WRITING OUTPUTS --- +----------------------- + +println(">>> writing outputs:") + +print("write mul c {1,{LOW,LOW}}: ") +mbt.pkt_set(MODBUS_FCODE.WRITE_MUL_COILS, {1, {rsio.IO_LVL.LOW, rsio.IO_LVL.LOW}}) +mbt.test_success__handle_packet() + +testutils.pause() + +print("write c {1,HIGH}: ") +mbt.pkt_set(MODBUS_FCODE.WRITE_SINGLE_COIL, {1, rsio.IO_LVL.HIGH}) +mbt.test_success__handle_packet() + +testutils.pause() + +print("write c {2,HIGH}: ") +mbt.pkt_set(MODBUS_FCODE.WRITE_SINGLE_COIL, {2, rsio.IO_LVL.HIGH}) +mbt.test_success__handle_packet() + +testutils.pause() + +print("write hr {1,7}: ") +mbt.pkt_set(MODBUS_FCODE.WRITE_SINGLE_HOLD_REG, {1, 7}) +mbt.test_success__handle_packet() + +testutils.pause() + +print("write mul hr {1,{4}}: ") +mbt.pkt_set(MODBUS_FCODE.WRITE_MUL_HOLD_REGS, {1, {4}}) +mbt.test_success__handle_packet() + +println("PASS") + +testutils.pause() + +----------------------- +--- READING OUTPUTS --- +----------------------- + +println(">>> reading outputs:") + +print("read c {1,1}: ") +mbt.pkt_set(MODBUS_FCODE.READ_COILS, {1, 1}) +mbt.test_success__handle_packet() + +print("read c {2,1}: ") +mbt.pkt_set(MODBUS_FCODE.READ_COILS, {2, 1}) +mbt.test_success__handle_packet() + +print("read c {1,2}: ") +mbt.pkt_set(MODBUS_FCODE.READ_COILS, {1, 2}) +mbt.test_success__handle_packet() + +print("read hr {1,1}: ") +mbt.pkt_set(MODBUS_FCODE.READ_MUL_HOLD_REGS, {1, 1}) +mbt.test_success__handle_packet() + +println("PASS") + +println("TEST COMPLETE") diff --git a/test/rstest.lua b/test/rstest.lua new file mode 100644 index 0000000..d195fc6 --- /dev/null +++ b/test/rstest.lua @@ -0,0 +1,147 @@ +require("/initenv").init_env() + +local rsio = require("scada-common.rsio") +local util = require("scada-common.util") + +local testutils = require("test.testutils") + +local print = util.print +local println = util.println + +local IO = rsio.IO +local IO_LVL = rsio.IO_LVL +local IO_DIR = rsio.IO_DIR +local IO_MODE = rsio.IO_MODE + +println("starting RSIO tester") +println("") + +println(">>> checking valid ports:") + +-- port function tests +local cid = 0 +local max_value = 1 +for key, value in pairs(IO) do + if value > max_value then max_value = value end + cid = cid + 1 + + local c_name = rsio.to_string(value) + local io_mode = rsio.get_io_mode(value) + local mode = "" + + if io_mode == IO_MODE.DIGITAL_IN then + mode = " (DIGITAL_IN)" + elseif io_mode == IO_MODE.DIGITAL_OUT then + mode = " (DIGITAL_OUT)" + elseif io_mode == IO_MODE.ANALOG_IN then + mode = " (ANALOG_IN)" + elseif io_mode == IO_MODE.ANALOG_OUT then + mode = " (ANALOG_OUT)" + else + error("unknown mode for port " .. key) + end + + assert(key == c_name, c_name .. " != " .. key .. ": " .. value .. mode) + println(c_name .. ": " .. value .. mode) +end + +assert(max_value == cid, "IO_PORT last IDx out-of-sync with count: " .. max_value .. " (count " .. cid .. ")") + +testutils.pause() + +println(">>> checking invalid ports:") + +testutils.test_func("rsio.to_string", rsio.to_string, { -1, 100, false }, "") +testutils.test_func_nil("rsio.to_string", rsio.to_string, "") +testutils.test_func("rsio.get_io_mode", rsio.get_io_mode, { -1, 100, false }, IO_MODE.ANALOG_IN) +testutils.test_func_nil("rsio.get_io_mode", rsio.get_io_mode, IO_MODE.ANALOG_IN) + +testutils.pause() + +println(">>> checking validity checks:") + +local ivc_t_list = { 0, -1, 100 } +testutils.test_func("rsio.is_valid_port", rsio.is_valid_port, ivc_t_list, false) +testutils.test_func_nil("rsio.is_valid_port", rsio.is_valid_port, false) + +local ivs_t_list = rs.getSides() +testutils.test_func("rsio.is_valid_side", rsio.is_valid_side, ivs_t_list, true) +testutils.test_func("rsio.is_valid_side", rsio.is_valid_side, { "" }, false) +testutils.test_func_nil("rsio.is_valid_side", rsio.is_valid_side, false) + +local ic_t_list = { colors.white, colors.purple, colors.blue, colors.cyan, colors.black } +testutils.test_func("rsio.is_color", rsio.is_color, ic_t_list, true) +testutils.test_func("rsio.is_color", rsio.is_color, { 0, 999999, colors.combine(colors.red, colors.blue, colors.black) }, false) +testutils.test_func_nil("rsio.is_color", rsio.is_color, false) + +testutils.pause() + +println(">>> checking port-independent I/O wrappers:") + +testutils.test_func("rsio.digital_read", rsio.digital_read, { true, false }, { IO_LVL.HIGH, IO_LVL.LOW }) + +print("rsio.analog_read(): ") +assert(rsio.analog_read(0, 0, 100) == 0, "RS_READ_0_100") +assert(rsio.analog_read(7.5, 0, 100) == 50, "RS_READ_7_5_100") +assert(rsio.analog_read(15, 0, 100) == 100, "RS_READ_15_100") +assert(rsio.analog_read(4, 0, 15) == 4, "RS_READ_4_15") +assert(rsio.analog_read(12, 0, 15) == 12, "RS_READ_12_15") +println("PASS") + +print("rsio.analog_write(): ") +assert(rsio.analog_write(0, 0, 100) == 0, "RS_WRITE_0_100") +assert(rsio.analog_write(100, 0, 100) == 15, "RS_WRITE_100_100") +assert(rsio.analog_write(4, 0, 15) == 4, "RS_WRITE_4_15") +assert(rsio.analog_write(12, 0, 15) == 12, "RS_WRITE_12_15") +println("PASS") + +testutils.pause() + +println(">>> checking port I/O:") + +print("rsio.digital_is_active(...): ") + +-- check input ports +assert(rsio.digital_is_active(IO.F_SCRAM, IO_LVL.LOW) == true, "IO_F_SCRAM_HIGH") +assert(rsio.digital_is_active(IO.F_SCRAM, IO_LVL.HIGH) == false, "IO_F_SCRAM_LOW") +assert(rsio.digital_is_active(IO.R_SCRAM, IO_LVL.LOW) == true, "IO_R_SCRAM_HIGH") +assert(rsio.digital_is_active(IO.R_SCRAM, IO_LVL.HIGH) == false, "IO_R_SCRAM_LOW") +assert(rsio.digital_is_active(IO.R_ENABLE, IO_LVL.LOW) == false, "IO_R_ENABLE_HIGH") +assert(rsio.digital_is_active(IO.R_ENABLE, IO_LVL.HIGH) == true, "IO_R_ENABLE_LOW") + +-- non-inputs should always return LOW +assert(rsio.digital_is_active(IO.F_ALARM, IO_LVL.LOW) == false, "IO_OUT_READ_LOW") +assert(rsio.digital_is_active(IO.F_ALARM, IO_LVL.HIGH) == false, "IO_OUT_READ_HIGH") + +println("PASS") + +-- check output ports + +print("rsio.digital_write(...): ") + +-- check output ports +assert(rsio.digital_write_active(IO.F_ALARM, true) == IO_LVL.LOW, "IO_F_ALARM_LOW") +assert(rsio.digital_write_active(IO.F_ALARM, true) == IO_LVL.HIGH, "IO_F_ALARM_HIGH") +assert(rsio.digital_write_active(IO.WASTE_PU, true) == IO_LVL.HIGH, "IO_WASTE_PU_HIGH") +assert(rsio.digital_write_active(IO.WASTE_PU, true) == IO_LVL.LOW, "IO_WASTE_PU_LOW") +assert(rsio.digital_write_active(IO.WASTE_PO, true) == IO_LVL.HIGH, "IO_WASTE_PO_HIGH") +assert(rsio.digital_write_active(IO.WASTE_PO, true) == IO_LVL.LOW, "IO_WASTE_PO_LOW") +assert(rsio.digital_write_active(IO.WASTE_POPL, true) == IO_LVL.HIGH, "IO_WASTE_POPL_HIGH") +assert(rsio.digital_write_active(IO.WASTE_POPL, true) == IO_LVL.LOW, "IO_WASTE_POPL_LOW") +assert(rsio.digital_write_active(IO.WASTE_AM, true) == IO_LVL.HIGH, "IO_WASTE_AM_HIGH") +assert(rsio.digital_write_active(IO.WASTE_AM, true) == IO_LVL.LOW, "IO_WASTE_AM_LOW") + +-- check all reactor output ports (all are active high) +for i = IO.R_ALARM, (IO.R_PLC_TIMEOUT - IO.R_ALARM + 1) do + assert(rsio.to_string(i) ~= "", "REACTOR_IO_BAD_PORT") + assert(rsio.digital_write_active(i, false) == IO_LVL.LOW, "IO_" .. rsio.to_string(i) .. "_LOW") + assert(rsio.digital_write_active(i, true) == IO_LVL.HIGH, "IO_" .. rsio.to_string(i) .. "_HIGH") +end + +-- non-outputs should always return false +assert(rsio.digital_write_active(IO.F_SCRAM, false) == IO_LVL.LOW, "IO_IN_WRITE_FALSE") +assert(rsio.digital_write_active(IO.F_SCRAM, true) == IO_LVL.LOW, "IO_IN_WRITE_TRUE") + +println("PASS") + +println("TEST COMPLETE") diff --git a/test/testutils.lua b/test/testutils.lua new file mode 100644 index 0000000..aa9f45f --- /dev/null +++ b/test/testutils.lua @@ -0,0 +1,122 @@ +local util = require("scada-common.util") + +local print = util.print +local println = util.println + +local testutils = {} + +-- test a function +---@param name string function name +---@param f function function +---@param values table input values, one per function call +---@param results any table of values or a single value for all tests +function testutils.test_func(name, f, values, results) + -- if only one value was given, use that for all checks + if type(results) ~= "table" then + local _r = {} + for _ = 1, #values do + table.insert(_r, results) + end + results = _r + end + + assert(#values == #results, "test_func(" .. name .. ") #values ~= #results") + + for i = 1, #values do + local check = values[i] + local expect = results[i] + print(name .. "(" .. util.strval(check) .. ") => ") + assert(f(check) == expect, "FAIL") + println("PASS") + end +end + +-- test a function with nil as a parameter +---@param name string function name +---@param f function function +---@param result any expected result +function testutils.test_func_nil(name, f, result) + print(name .. "(nil) => ") + assert(f(nil) == result, "FAIL") + println("PASS") +end + +-- get something as a string +---@param result any +---@return string +function testutils.stringify(result) + return textutils.serialize(result, { allow_repetitions = true, compact = true }) +end + +-- pause for 1 second, or the provided seconds +---@param seconds? number +function testutils.pause(seconds) + seconds = seconds or 1.0 +---@diagnostic disable-next-line: undefined-field + os.sleep(seconds) +end + +-- create a new MODBUS tester +---@param modbus modbus modbus object +---@param error_flag MODBUS_FCODE MODBUS_FCODE.ERROR_FLAG +function testutils.modbus_tester(modbus, error_flag) + -- test packet + ---@type modbus_frame + local packet = { + txn_id = 0, + length = 0, + unit_id = 0, + func_code = 0, + data = {}, + scada_frame = nil + } + + ---@class modbus_tester + local public = {} + + -- set the packet function and data for the next test + ---@param func MODBUS_FCODE function code + ---@param data table + function public.pkt_set(func, data) + packet.length = #data + packet.data = data + packet.func_code = func + end + + -- check the current packet, expecting an error + ---@param excode MODBUS_EXCODE exception code to expect + function public.test_error__check_request(excode) + local rcode, reply = modbus.check_request(packet) + assert(rcode == false, "CHECK_NOT_FAIL") + assert(reply.get().func_code == bit.bor(packet.func_code, error_flag), "WRONG_FCODE") + assert(reply.get().data[1] == excode, "EXCODE_MISMATCH") + end + + -- test the current packet, expecting an error + ---@param excode MODBUS_EXCODE exception code to expect + function public.test_error__handle_packet(excode) + local rcode, reply = modbus.handle_packet(packet) + assert(rcode == false, "CHECK_NOT_FAIL") + assert(reply.get().func_code == bit.bor(packet.func_code, error_flag), "WRONG_FCODE") + assert(reply.get().data[1] == excode, "EXCODE_MISMATCH") + end + + -- check the current packet, expecting success + ---@param excode MODBUS_EXCODE exception code to expect + function public.test_success__check_request(excode) + local rcode, reply = modbus.check_request(packet) + assert(rcode, "CHECK_NOT_OK") + assert(reply.get().data[1] == excode, "EXCODE_MISMATCH") + end + + -- test the current packet, expecting success + function public.test_success__handle_packet() + local rcode, reply = modbus.handle_packet(packet) + assert(rcode, "CHECK_NOT_OK") + println(testutils.stringify(reply.get().data)) + end + + return public +end + +return testutils diff --git a/test/turbine_modbustest.lua b/test/turbine_modbustest.lua new file mode 100644 index 0000000..fe167d7 --- /dev/null +++ b/test/turbine_modbustest.lua @@ -0,0 +1,68 @@ +require("/initenv").init_env() + +local log = require("scada-common.log") +local ppm = require("scada-common.ppm") +local types = require("scada-common.types") +local util = require("scada-common.util") + +local testutils = require("test.testutils") + +local modbus = require("rtu.modbus") +local turbine_rtu = require("rtu.dev.turbine_rtu") + +local print = util.print +local println = util.println + +local MODBUS_FCODE = types.MODBUS_FCODE + +println("starting turbine RTU MODBUS tester") +println("note: use rs_modbustest to fully test RTU/MODBUS") +println(" this only tests a turbine/parallel read") +println("") + +-- RTU init -- + +log.init("/log.txt", log.MODE.NEW) + +print(">>> init turbine RTU: ") + +ppm.mount_all() + +local dev = ppm.get_device("turbine") +assert(dev ~= nil, "NO_TURBINE") + +local t_rtu = turbine_rtu.new(dev) + +local di, c, ir, hr = t_rtu.io_count() +assert(di == 0, "IOCOUNT_DI") +assert(c == 0, "IOCOUNT_C") +assert(ir == 16, "IOCOUNT_IR") +assert(hr == 0, "IOCOUNT_HR") + +println("OK") + +local t_modbus = modbus.new(t_rtu, true) + +local mbt = testutils.modbus_tester(t_modbus, MODBUS_FCODE.ERROR_FLAG) + +---------------------- +--- READING INPUTS --- +---------------------- + +println(">>> reading inputs:") + +print("read ir {1,1}: ") +mbt.pkt_set(MODBUS_FCODE.READ_INPUT_REGS, {1, 1}) +mbt.test_success__handle_packet() + +print("read ir {2,1}: ") +mbt.pkt_set(MODBUS_FCODE.READ_INPUT_REGS, {2, 1}) +mbt.test_success__handle_packet() + +print("read ir {1,16}: ") +mbt.pkt_set(MODBUS_FCODE.READ_INPUT_REGS, {1, 16}) +mbt.test_success__handle_packet() + +println("PASS") + +println("TEST COMPLETE")