Compare commits

...

93 Commits

Author SHA1 Message Date
Mikayla
f59f484e7b Merge pull request #317 from MikaylaFischler/devel
2023.08.22 Release
2023-08-22 18:42:21 -04:00
Mikayla Fischler
0fe9b391d8 #313 installer self-update fix, added update command for it 2023-08-21 22:47:00 -04:00
Mikayla
97f0191875 #313 installer self update 2023-08-22 02:18:25 +00:00
Mikayla Fischler
70db8d782c fixed unit dynamic tank state indicator 2023-08-21 22:05:02 -04:00
Mikayla
2acd166c3e Merge pull request #316 from MikaylaFischler/232-waste-valve-and-flow-monitoring-display
232 Waste Valve and Flow Monitoring Display
2023-08-21 21:54:22 -04:00
Mikayla Fischler
c78f7e173a #232 cleanup, changed antimatter rate to be integer on main display 2023-08-21 21:53:31 -04:00
Mikayla Fischler
99a0b0a55a #232 documentation and refactor 2023-08-21 21:44:15 -04:00
Mikayla Fischler
6e51e70b62 #232 cleanup and fixes 2023-08-21 21:37:56 -04:00
Mikayla Fischler
fd2abad5cf changed some green/red indicators to be green/gray for contrast 2023-08-21 21:35:32 -04:00
Mikayla Fischler
b93c6b7c6e fixes per luacheck 2023-08-20 23:53:49 -04:00
Mikayla Fischler
8b3f558f68 Merge branch 'devel' into 232-waste-valve-and-flow-monitoring-display 2023-08-20 23:43:07 -04:00
Mikayla Fischler
8c5289867c #232 updated coordinator monitor disconnect/reconnect handling for changes 2023-08-20 23:28:48 -04:00
Mikayla Fischler
d179920565 #232 option to disable flow view screen for legacy setups 2023-08-20 23:23:23 -04:00
Mikayla Fischler
504ee0594f #315 switch off dynamic tank fill mode if emergency coolant is required 2023-08-20 22:56:51 -04:00
Mikayla Fischler
a92f182156 #232 fixed incorrect arrows on turbine flow view 2023-08-20 22:53:14 -04:00
Mikayla Fischler
c5d38a5584 #232 added container mode indicators for tanks 2023-08-20 17:30:34 -04:00
Mikayla Fischler
9bf07e6c3e completed work on updated pipenet 2023-08-20 17:04:14 -04:00
Mikayla Fischler
7656936982 #232 cleanup, added general stats 2023-08-20 16:52:12 -04:00
Mikayla Fischler
f477ad9426 #232 re-indexed valve IDs 2023-08-19 23:28:03 -04:00
Mikayla Fischler
59950e9d15 #232 connected valve indicators 2023-08-19 23:24:20 -04:00
Mikayla Fischler
11d86d92eb #232 bugfixes and linked up indicators to data 2023-08-19 20:06:37 -04:00
Mikayla Fischler
1275f61113 #232 refactor and fixed sv config verify 2023-08-19 13:42:07 -04:00
Mikayla Fischler
d17e2b8321 #232 completed display of flow/dynamic tank/sps, dynamically sized 2023-08-19 13:38:05 -04:00
Mikayla Fischler
ce780c3d72 added common color pairs to coordinator style 2023-08-13 00:51:37 -04:00
Mikayla Fischler
76ab4e17bf #232 WIP full flow view drawn out 2023-08-13 00:11:58 -04:00
Mikayla Fischler
ac1733c46e #314 20s grace period for coordinator render to finish to prevent timeouts 2023-08-12 15:16:37 -04:00
Mikayla
17731de61b #312 improved reactor peripheral handling 2023-08-11 14:20:13 +00:00
Mikayla Fischler
d85385c1fe #232 continued work on flow monitor, added SPS display 2023-08-10 23:31:38 -04:00
Mikayla Fischler
e0809f52a6 #232 WIP coordinator flow view 2023-08-09 23:26:06 -04:00
Mikayla Fischler
b2c55f9d4b #303 check modem message distance for nil 2023-08-02 10:13:54 -04:00
Mikayla
ba896ea163 Merge pull request #302 from MikaylaFischler/common-cleanup
Common Cleanup
2023-07-30 21:38:18 -04:00
Mikayla Fischler
1a64591256 #282 version the common directory 2023-07-30 20:46:04 -04:00
Mikayla Fischler
9ce75eb4bd #283 common cleanup, added lockbox version to crash dump, changed crash handler to pcall requires for graphics/lockbox 2023-07-30 12:24:54 -04:00
Mikayla
451f804f87 Merge pull request #301 from MikaylaFischler/rtu-speaker-system
RTU Speaker System and Pocket Diagnostics
2023-07-30 00:14:49 -04:00
Mikayla Fischler
724d13510d optimizations and cleanup for pull request 2023-07-30 00:13:26 -04:00
Mikayla Fischler
3f01ce7ec5 lowered SVS queue process time limit warning to debug level 2023-07-29 18:44:17 -04:00
Mikayla Fischler
df67795239 #290 pocket page management and alarm test tool, supervisor pocket diagnostics system 2023-07-29 18:16:59 -04:00
Mikayla Fischler
775d4dc95b #264 improvements to RTU speaker sounder 2023-07-29 17:57:51 -04:00
Mikayla Fischler
b3c7263bc4 #299 fixed mouse events passing to hidden elements 2023-07-29 00:25:20 -04:00
Mikayla Fischler
9f8732830c #264, #280 fixed sounder issues 2023-07-26 22:37:25 -04:00
Mikayla Fischler
1c87ef18a1 #297 added tone packet to valid MGMT packet types 2023-07-26 21:33:43 -04:00
Mikayla Fischler
f111b711c5 #264, #280 send tones to RTUs 2023-07-26 21:02:34 -04:00
Mikayla Fischler
92d1945bea #264 WIP RTU alarm sounders 2023-07-26 20:48:44 -04:00
Mikayla Fischler
4192ea426c #280 moved alarm sounder logic to supervisor and tone control to common 2023-07-26 20:48:11 -04:00
Mikayla
7bd8f34773 update README.md 2023-07-19 20:06:13 -04:00
Mikayla
bdbb3071b3 Merge pull request #294 from MikaylaFischler/devel
2023.07.19 Hotfix
2023-07-19 19:18:48 -04:00
Mikayla Fischler
def02a94d2 #293 fixed race condition with graphics element IDs 2023-07-19 11:27:33 -04:00
Mikayla Fischler
681bb0963e #291 RTU comms thread no longer yields every packet 2023-07-18 22:28:43 -04:00
Mikayla
8f7d7c3ead Merge pull request #288 from MikaylaFischler/devel
2023.07.17 Hotfix
2023-07-17 22:48:03 -04:00
Mikayla Fischler
c0f45cfb8b updated comments 2023-07-17 22:47:19 -04:00
Mikayla Fischler
455653074a #287 fixed coordinator not notifying supervisor of auto waste config 2023-07-17 22:09:21 -04:00
Mikayla Fischler
1202289fab #285 #286 mitigated false trips 2023-07-17 20:59:45 -04:00
Mikayla
acb7b5b4cb update README.md with new installer pastebin 2023-07-16 21:29:41 -04:00
Mikayla
9bd79dacad Merge pull request #281 from MikaylaFischler/devel
2023.07.16 Release
2023-07-16 21:25:00 -04:00
Mikayla Fischler
c544d140bf installer key handling improvements 2023-07-16 21:21:33 -04:00
Mikayla Fischler
353cb3622b improved installer any key detection 2023-07-16 21:11:27 -04:00
Mikayla Fischler
b54f15bad6 #274 bugfixes and optimizations 2023-07-16 21:07:37 -04:00
Mikayla Fischler
4d9783beca fixed installer clean bug 2023-07-16 20:58:34 -04:00
Mikayla Fischler
5529774b0e changed installer press enter to continue to any key, fixed some text colors 2023-07-16 20:53:39 -04:00
Mikayla Fischler
2a541ef3fe #274 cleanup functionality added to installer 2023-07-16 19:42:20 -04:00
Mikayla Fischler
e1b4d72ef8 updated legacy install manifest 2023-07-15 13:33:51 -04:00
Mikayla Fischler
6a0992c7a4 removed unused variable 2023-07-15 13:33:18 -04:00
Mikayla Fischler
cff7c724be #272 fixed bug with transmitting unit dynamic tank table 2023-07-15 13:31:48 -04:00
Mikayla Fischler
47bda73afe #272 basic dynamic tank data in supervisor and coordinator 2023-07-15 13:16:36 -04:00
Mikayla
8daedc109c added discord info to readme 2023-07-13 13:50:17 -04:00
Mikayla Fischler
a164c18a50 removed unused fields from dynamic tank rtu 2023-07-13 12:19:25 -04:00
Mikayla Fischler
4d663ada8d added high contrast yellow to rtu/plc/coord front panels 2023-07-12 13:38:37 -04:00
Mikayla Fischler
084a153a79 #268 fixed incorrect info print on extra wireless modem connection 2023-07-11 21:06:47 -04:00
Mikayla Fischler
4ed6ec1c63 correctly print new messages without overwrites in dmesg even if a prior message is a progress one 2023-07-11 21:01:24 -04:00
Mikayla Fischler
d3c2ba7bee update install manifest 2023-07-11 20:32:37 -04:00
Mikayla Fischler
55ff9dad4b #249 coordinator handle monitor disconnects/reconnects 2023-07-11 20:32:10 -04:00
Mikayla Fischler
0d6022f5e3 fixed always reporting failure to connect to supervisor even when inaccurate 2023-07-11 18:31:53 -04:00
Mikayla Fischler
8b136d78a8 #268 better handling of wireless modem peripherals 2023-07-11 18:22:09 -04:00
Mikayla Fischler
a5214730ef #260 added dynamic tank RTU 2023-07-11 17:27:03 -04:00
Mikayla Fischler
9f3ad3caf0 removed PLC establish packet handling when already linked 2023-07-11 16:17:24 -04:00
Mikayla
9bb2a99be5 Merge pull request #279 from MikaylaFischler/265-coordinator-front-panel
265 coordinator front panel
2023-07-11 15:37:17 -04:00
Mikayla Fischler
65ace26258 corrected comments 2023-07-11 15:36:41 -04:00
Mikayla Fischler
61d975d13f updated error messages for consistency 2023-07-11 15:15:44 -04:00
Mikayla Fischler
1d7d6e9817 update legacy install manifest 2023-07-11 13:38:21 -04:00
Mikayla Fischler
a2e0999cea combine coordinator supervisor connection event loop with main loop 2023-07-11 13:32:26 -04:00
Mikayla Fischler
1edee7f64b updated graphics comments 2023-07-09 23:42:44 -04:00
Mikayla Fischler
df61ec2c62 #265 coordinator front panel 2023-07-09 23:31:56 -04:00
Mikayla Fischler
bf7a316b04 don't start flasher if already started 2023-07-09 23:24:41 -04:00
Mikayla Fischler
96c4444184 corrected some comments 2023-07-09 23:22:24 -04:00
Mikayla Fischler
59eac62c33 #270 validate reactor PLC status packet types 2023-07-08 18:07:40 -04:00
Mikayla
ab193db153 Merge pull request #277 from MikaylaFischler/25-process-waste-control
25 process waste control
2023-07-08 17:12:23 -04:00
Mikayla Fischler
7d65bba589 fixes/cleanups for pull request 2023-07-08 17:11:51 -04:00
Mikayla Fischler
dcef5a96f0 removed unused function 2023-07-08 16:57:41 -04:00
Mikayla Fischler
ba0900ac65 #25 sna/sps integration, plutonium fallback, waste rate reporting 2023-07-08 16:57:13 -04:00
Mikayla Fischler
8f54e95519 #25 continued WIP waste control, main view updated and unit fields modified 2023-07-06 01:36:06 -04:00
Mikayla Fischler
7b9824b6f9 added checkbox graphics element 2023-07-01 19:40:33 -04:00
Mikayla Fischler
b6835fc7d1 #276 updated readme 2023-06-29 16:54:46 -04:00
Mikayla
bc5a94cd3b Update README.md 2023-06-29 12:57:25 -04:00
111 changed files with 5573 additions and 1908 deletions

View File

@@ -7,6 +7,29 @@ Configurable ComputerCraft SCADA system for multi-reactor control of Mekanism fi
![GitHub Workflow Status (with branch)](https://img.shields.io/github/actions/workflow/status/MikaylaFischler/cc-mek-scada/check.yml?branch=latest&label=latest)
![GitHub Workflow Status (with branch)](https://img.shields.io/github/actions/workflow/status/MikaylaFischler/cc-mek-scada/check.yml?branch=devel&label=devel)
### [Join](https://discord.gg/R9NSCkhcwt) the Discord!
![Discord](https://img.shields.io/discord/1129075839288496259)
## Released Component Versions
![Installer](https://img.shields.io/endpoint?url=https%3A%2F%2Fmikaylafischler.github.io%2Fcc-mek-scada%2Finstaller.json)
![Bootloader](https://img.shields.io/endpoint?url=https%3A%2F%2Fmikaylafischler.github.io%2Fcc-mek-scada%2Fbootloader.json)
![Comms](https://img.shields.io/endpoint?url=https%3A%2F%2Fmikaylafischler.github.io%2Fcc-mek-scada%2Fcomms.json)
![Graphics](https://img.shields.io/endpoint?url=https%3A%2F%2Fmikaylafischler.github.io%2Fcc-mek-scada%2Fgraphics.json)
![Lockbox](https://img.shields.io/endpoint?url=https%3A%2F%2Fmikaylafischler.github.io%2Fcc-mek-scada%2Flockbox.json)
![Reactor PLC](https://img.shields.io/endpoint?url=https%3A%2F%2Fmikaylafischler.github.io%2Fcc-mek-scada%2Freactor-plc.json)
![RTU](https://img.shields.io/endpoint?url=https%3A%2F%2Fmikaylafischler.github.io%2Fcc-mek-scada%2Frtu.json)
![Supervisor](https://img.shields.io/endpoint?url=https%3A%2F%2Fmikaylafischler.github.io%2Fcc-mek-scada%2Fsupervisor.json)
![Coordinator](https://img.shields.io/endpoint?url=https%3A%2F%2Fmikaylafischler.github.io%2Fcc-mek-scada%2Fcoordinator.json)
![Pocket](https://img.shields.io/endpoint?url=https%3A%2F%2Fmikaylafischler.github.io%2Fcc-mek-scada%2Fpocket.json)
## Requirements
Mod Requirements:
- CC: Tweaked
- Mekanism v10.1+
@@ -16,32 +39,11 @@ Mod Recommendations:
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.
## Released Component Versions
### Core
![Bootloader](https://img.shields.io/endpoint?url=https%3A%2F%2Fmikaylafischler.github.io%2Fcc-mek-scada%2Fbootloader.json)
![Comms](https://img.shields.io/endpoint?url=https%3A%2F%2Fmikaylafischler.github.io%2Fcc-mek-scada%2Fcomms.json)
### Utilities
![Installer](https://img.shields.io/endpoint?url=https%3A%2F%2Fmikaylafischler.github.io%2Fcc-mek-scada%2Finstaller.json)
### Applications
![Reactor PLC](https://img.shields.io/endpoint?url=https%3A%2F%2Fmikaylafischler.github.io%2Fcc-mek-scada%2Freactor-plc.json)
![RTU](https://img.shields.io/endpoint?url=https%3A%2F%2Fmikaylafischler.github.io%2Fcc-mek-scada%2Frtu.json)
![Supervisor](https://img.shields.io/endpoint?url=https%3A%2F%2Fmikaylafischler.github.io%2Fcc-mek-scada%2Fsupervisor.json)
![Coordinator](https://img.shields.io/endpoint?url=https%3A%2F%2Fmikaylafischler.github.io%2Fcc-mek-scada%2Fcoordinator.json)
![Pocket](https://img.shields.io/endpoint?url=https%3A%2F%2Fmikaylafischler.github.io%2Fcc-mek-scada%2Fpocket.json)
## Installation
You can install this on a ComputerCraft computer using either:
* `wget https://raw.githubusercontent.com/MikaylaFischler/cc-mek-scada/main/ccmsi.lua`
* `pastebin get eRz6cUNM ccmsi.lua`
* `pastebin get RGasyTM4 ccmsi.lua`
## [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.
@@ -86,14 +88,8 @@ A vaguely-modbus [modbus](https://en.wikipedia.org/wiki/Modbus) communication pr
- Input Registers: Multi-Byte Read-Only (analog inputs)
- Holding Registers: Multi-Byte Read/Write (analog I/O)
### Security and Encryption
### Security
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.
HMAC message authentication is available as a configuration option to prevent replay attacks and generally prevent control or false data reporting within a system's network. This is done utilizing the [lua-lockbox](https://github.com/somesocks/lua-lockbox) project.
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+!
The other, simpler security feature is to enforce a maximum authorized transmission range, which is also a configurable feature on each device.

278
ccmsi.lua
View File

@@ -1,8 +1,6 @@
--
-- ComputerCraft Mekanism SCADA System Installer Utility
--
--[[
CC-MEK-SCADA Installer Utility
Copyright (c) 2023 Mikayla Fischler
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
@@ -20,7 +18,7 @@ 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.5a"
local CCMSI_VERSION = "v1.9"
local install_dir = "/.install-cache"
local manifest_path = "https://mikaylafischler.github.io/cc-mek-scada/manifests/"
@@ -44,11 +42,15 @@ local function get_opt(opt, options)
return nil
end
-- wait for any key to be pressed
---@diagnostic disable-next-line: undefined-field
local function any_key() os.pullEvent("key_up") end
-- ask the user yes or no
local function ask_y_n(question, default)
print(question)
if default == true then print(" (Y/n)? ") else print(" (y/N)? ") end
local response = read()
local response = read();any_key()
if response == "" then return default
elseif response == "Y" or response == "y" then return true
elseif response == "N" or response == "n" then return false
@@ -56,19 +58,18 @@ local function ask_y_n(question, default)
end
-- print out a white + blue text message
local function pkg_message(message, package) white(); print(message .. " "); blue(); println(package); white() end
local function pkg_message(message, package) white();print(message .. " ");blue();println(package);white() end
-- indicate actions to be taken based on package differences for installs/updates
local function show_pkg_change(name, v_local, v_remote)
if v_local ~= nil then
if v_local ~= v_remote then
print("[" .. name .. "] updating "); blue(); print(v_local); white(); print(" \xbb "); blue(); println(v_remote); white()
local function show_pkg_change(name, v)
if v.v_local ~= nil then
if v.v_local ~= v.v_remote then
print("[" .. name .. "] updating ");blue();print(v.v_local);white();print(" \xbb ");blue();println(v.v_remote);white()
elseif mode == "install" then
pkg_message("[" .. name .. "] reinstalling", v_local)
pkg_message("[" .. name .. "] reinstalling", v.v_local)
end
else
pkg_message("[" .. name .. "] new install of", v_remote)
end
else pkg_message("[" .. name .. "] new install of", v.v_remote) end
return v.v_local ~= v.v_remote
end
-- read the local manifest file
@@ -87,15 +88,13 @@ end
local function get_remote_manifest()
local response, error = http.get(install_manifest)
if response == nil then
orange(); println("failed to get installation manifest from GitHub, cannot update or install")
red(); println("HTTP error: " .. error); white()
orange();println("Failed to get installation manifest from GitHub, cannot update or install.")
red();println("HTTP error: " .. error);white()
return false, {}
end
local ok, manifest = pcall(function () return textutils.unserializeJSON(response.readAll()) end)
if not ok then
red(); println("error parsing remote installation manifest"); white()
end
if not ok then red();println("error parsing remote installation manifest");white() end
return ok, manifest
end
@@ -107,7 +106,7 @@ local function write_install_manifest(manifest, dependencies)
local is_dependency = false
for _, dependency in pairs(dependencies) do
if (key == "bootloader" and dependency == "system") or key == dependency then
is_dependency = true; break
is_dependency = true;break
end
end
if key == app or key == "comms" or is_dependency then versions[key] = value end
@@ -120,6 +119,78 @@ local function write_install_manifest(manifest, dependencies)
imfile.close()
end
-- recursively build a tree out of the file manifest
local function gen_tree(manifest)
local function _tree_add(tree, split)
if #split > 1 then
local name = table.remove(split, 1)
if tree[name] == nil then tree[name] = {} end
table.insert(tree[name], _tree_add(tree[name], split))
else return split[1] end
return nil
end
local list, tree = {}, {}
-- make a list of each and every file
for _, files in pairs(manifest.files) do for i = 1, #files do table.insert(list, files[i]) end end
for i = 1, #list do
local split = {}
string.gsub(list[i], "([^/]+)", function(c) split[#split + 1] = c end)
if #split == 1 then table.insert(tree, list[i])
else table.insert(tree, _tree_add(tree, split)) end
end
return tree
end
local function _in_array(val, array)
for _, v in pairs(array) do if v == val then return true end end
return false
end
local function _clean_dir(dir, tree)
if tree == nil then tree = {} end
local ls = fs.list(dir)
for _, val in pairs(ls) do
local path = dir .. "/" .. val
if fs.isDir(path) then
_clean_dir(path, tree[val])
if #fs.list(path) == 0 then fs.delete(path);println("deleted " .. path) end
elseif not _in_array(val, tree) then
fs.delete(path)
println("deleted " .. path)
end
end
end
-- go through app/common directories to delete unused files
local function clean(manifest)
local root_ext = false
local tree = gen_tree(manifest)
table.insert(tree, "install_manifest.json")
table.insert(tree, "ccmsi.lua")
table.insert(tree, "log.txt")
lgray()
local ls = fs.list("/")
for _, val in pairs(ls) do
if fs.isDir(val) then
if tree[val] ~= nil then _clean_dir("/" .. val, tree[val]) end
if #fs.list(val) == 0 then fs.delete(val);println("deleted " .. val) end
elseif not _in_array(val, tree) then
root_ext = true
yellow();println(val .. " not used")
end
end
white()
if root_ext then println("Files in root directory won't be automatically deleted.") end
end
-- get and validate command line options
println("-- CC Mekanism SCADA Installer " .. CCMSI_VERSION .. " --")
@@ -136,33 +207,36 @@ if #opts == 0 or opts[1] == "help" then
println(" update - update files EXCEPT for config/logs")
println(" remove - delete files EXCEPT for config/logs")
println(" purge - delete files INCLUDING config/logs")
white(); println("<app>"); lgray()
white();println("<app>");lgray()
println(" reactor-plc - reactor PLC firmware")
println(" rtu - RTU firmware")
println(" supervisor - supervisor server application")
println(" coordinator - coordinator application")
println(" pocket - pocket application")
white(); println("<branch>"); yellow()
println(" second parameter when used with check")
lgray(); println(" main (default) | latest | devel"); white()
println(" installer - ccmsi installer (update only)")
white();println("<branch>")
lgray();println(" main (default) | latest | devel");white()
return
else
mode = get_opt(opts[1], { "check", "install", "update", "remove", "purge" })
if mode == nil then
red(); println("Unrecognized mode."); white()
red();println("Unrecognized mode.");white()
return
end
app = get_opt(opts[2], { "reactor-plc", "rtu", "supervisor", "coordinator", "pocket" })
app = get_opt(opts[2], { "reactor-plc", "rtu", "supervisor", "coordinator", "pocket", "installer" })
if app == nil and mode ~= "check" then
red(); println("Unrecognized application."); white()
red();println("Unrecognized application.");white()
return
elseif app == "installer" and mode ~= "update" then
red();println("Installer app only supports 'update' option.");white()
return
end
-- determine target
if mode == "check" then target = opts[2] else target = opts[3] end
if (target ~= "main") and (target ~= "latest") and (target ~= "devel") then
if (target and target ~= "") then yellow(); println("Unknown target, defaulting to 'main'"); white() end
if (target and target ~= "") then yellow();println("Unknown target, defaulting to 'main'");white() end
target = "main"
end
@@ -179,7 +253,7 @@ if mode == "check" then
local local_ok, local_manifest = read_local_manifest()
if not local_ok then
yellow(); println("failed to load local installation information"); white()
yellow();println("failed to load local installation information");white()
local_manifest = { versions = { installer = CCMSI_VERSION } }
else
local_manifest.versions.installer = CCMSI_VERSION
@@ -190,19 +264,24 @@ if mode == "check" then
term.setTextColor(colors.purple)
print(string.format("%-14s", "[" .. key .. "]"))
if key == "installer" or (local_ok and (local_manifest.versions[key] ~= nil)) then
blue(); print(local_manifest.versions[key])
blue();print(local_manifest.versions[key])
if value ~= local_manifest.versions[key] then
white(); print(" (")
white();print(" (")
term.setTextColor(colors.cyan)
print(value); white(); println(" available)")
else green(); println(" (up to date)") end
print(value);white();println(" available)")
else green();println(" (up to date)") end
else
lgray(); print("not installed"); white(); print(" (latest ")
lgray();print("not installed");white();print(" (latest ")
term.setTextColor(colors.cyan)
print(value); white(); println(")")
print(value);white();println(")")
end
end
if manifest.versions.installer ~= local_manifest.versions.installer then
yellow();println("\nA newer version of the installer is available, it is recommended to update (use 'ccmsi update installer').");white()
end
elseif mode == "install" or mode == "update" then
local update_installer = app == "installer"
local ok, manifest = get_remote_manifest()
if not ok then return end
@@ -210,38 +289,60 @@ elseif mode == "install" or mode == "update" then
app = { v_local = nil, v_remote = nil, changed = false },
boot = { v_local = nil, v_remote = nil, changed = false },
comms = { v_local = nil, v_remote = nil, changed = false },
common = { v_local = nil, v_remote = nil, changed = false },
graphics = { v_local = nil, v_remote = nil, changed = false },
lockbox = { v_local = nil, v_remote = nil, changed = false }
}
-- try to find local versions
local local_ok, local_manifest = read_local_manifest()
local local_ok, lmnf = read_local_manifest()
if not local_ok then
if mode == "update" then
red(); println("failed to load local installation information, cannot update"); white()
red();println("Failed to load local installation information, cannot update.");white()
return
end
else
ver.boot.v_local = local_manifest.versions.bootloader
ver.app.v_local = local_manifest.versions[app]
ver.comms.v_local = local_manifest.versions.comms
ver.graphics.v_local = local_manifest.versions.graphics
ver.lockbox.v_local = local_manifest.versions.lockbox
elseif not update_installer then
ver.boot.v_local = lmnf.versions.bootloader
ver.app.v_local = lmnf.versions[app]
ver.comms.v_local = lmnf.versions.comms
ver.common.v_local = lmnf.versions.common
ver.graphics.v_local = lmnf.versions.graphics
ver.lockbox.v_local = lmnf.versions.lockbox
if local_manifest.versions[app] == nil then
red(); println("another application is already installed, please purge it before installing a new application"); white()
if lmnf.versions[app] == nil then
red();println("Another application is already installed, please purge it before installing a new application.");white()
return
end
end
local_manifest.versions.installer = CCMSI_VERSION
if manifest.versions.installer ~= CCMSI_VERSION then
yellow(); println("a newer version of the installer is available, it is recommended to download it"); white()
lmnf.versions.installer = CCMSI_VERSION
if manifest.versions.installer ~= CCMSI_VERSION then
if not update_installer then yellow();println("A newer version of the installer is available, it is recommended to update to it.");white() end
if update_installer or ask_y_n("Would you like to update now") then
lgray();println("GET ccmsi.lua")
local dl, err = http.get(repo_path .. "ccmsi.lua")
if dl == nil then
red();println("HTTP Error " .. err)
println("Installer download failed.");white()
else
local handle = fs.open(debug.getinfo(1, "S").source:sub(2), "w") -- this file, regardless of name or location
handle.write(dl.readAll())
handle.close()
green();println("Installer updated successfully.");white()
end
return
end
elseif update_installer then
green();println("Installer already up-to-date.");white()
return
end
ver.boot.v_remote = manifest.versions.bootloader
ver.app.v_remote = manifest.versions[app]
ver.comms.v_remote = manifest.versions.comms
ver.common.v_remote = manifest.versions.common
ver.graphics.v_remote = manifest.versions.graphics
ver.lockbox.v_remote = manifest.versions.lockbox
@@ -253,31 +354,18 @@ elseif mode == "install" or mode == "update" then
end
white()
-- display bootloader version change information
show_pkg_change("bootldr", ver.boot.v_local, ver.boot.v_remote)
ver.boot.changed = ver.boot.v_local ~= ver.boot.v_remote
-- display app version change information
show_pkg_change(app, ver.app.v_local, ver.app.v_remote)
ver.app.changed = ver.app.v_local ~= ver.app.v_remote
-- display comms version change information
show_pkg_change("comms", ver.comms.v_local, ver.comms.v_remote)
ver.comms.changed = ver.comms.v_local ~= ver.comms.v_remote
ver.boot.changed = show_pkg_change("bootldr", ver.boot)
ver.common.changed = show_pkg_change("common", ver.common)
ver.comms.changed = show_pkg_change("comms", ver.comms)
if ver.comms.changed and ver.comms.v_local ~= nil then
print("[comms] "); yellow(); println("other devices on the network will require an update"); white()
print("[comms] ");yellow();println("other devices on the network will require an update");white()
end
-- display graphics version change information
show_pkg_change("graphics", ver.graphics.v_local, ver.graphics.v_remote)
ver.graphics.changed = ver.graphics.v_local ~= ver.graphics.v_remote
-- display lockbox version change information
show_pkg_change("lockbox", ver.lockbox.v_local, ver.lockbox.v_remote)
ver.lockbox.changed = ver.lockbox.v_local ~= ver.lockbox.v_remote
ver.app.changed = show_pkg_change(app, ver.app)
ver.graphics.changed = show_pkg_change("graphics", ver.graphics)
ver.lockbox.changed = show_pkg_change("lockbox", ver.lockbox)
-- ask for confirmation
if not ask_y_n("Continue?", false) then return end
if not ask_y_n("Continue", false) then return end
--------------------------
-- START INSTALL/UPDATE --
@@ -302,10 +390,10 @@ elseif mode == "install" or mode == "update" then
-- check space constraints
if space_available < space_required then
single_file_mode = true
yellow(); println("WARNING: Insufficient space available for a full download!"); white()
yellow();println("WARNING: Insufficient space available for a full download!");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.")
if mode == "update" then println("If installation still fails, delete this device's log file or uninstall the app (not purge) and try again.") end
if not ask_y_n("Do you wish to continue?", false) then
if not ask_y_n("Do you wish to continue", false) then
println("Operation cancelled.")
return
end
@@ -318,16 +406,13 @@ elseif mode == "install" or mode == "update" then
if dependency == "system" then return not ver.boot.changed
elseif dependency == "graphics" then return not ver.graphics.changed
elseif dependency == "lockbox" then return not ver.lockbox.changed
elseif dependency == "common" then return not (ver.app.changed or ver.comms.changed)
elseif dependency == "common" then return not (ver.common.changed or ver.comms.changed)
elseif dependency == app then return not ver.app.changed
else return true end
end
if not single_file_mode then
if fs.exists(install_dir) then
fs.delete(install_dir)
fs.makeDir(install_dir)
end
if fs.exists(install_dir) then fs.delete(install_dir);fs.makeDir(install_dir) end
-- download all dependencies
for _, dependency in pairs(dependencies) do
@@ -343,7 +428,7 @@ elseif mode == "install" or mode == "update" then
local dl, err = http.get(repo_path .. file)
if dl == nil then
red(); println("GET HTTP Error " .. err)
red();println("HTTP Error " .. err)
success = false
break
else
@@ -384,10 +469,13 @@ elseif mode == "install" or mode == "update" then
if mode == "install" then
println("Installation completed successfully.")
else println("Update completed successfully.") end
white();println("Ready to clean up unused files, press any key to continue...")
any_key();clean(manifest)
white();println("Done.")
else
if mode == "install" then
red(); println("Installation failed.")
else orange(); println("Update failed, existing files unmodified.") end
red();println("Installation failed.")
else orange();println("Update failed, existing files unmodified.") end
end
else
-- go through all files and replace one by one
@@ -405,7 +493,7 @@ elseif mode == "install" or mode == "update" then
local dl, err = http.get(repo_path .. file)
if dl == nil then
red(); println("GET HTTP Error " .. err)
red();println("HTTP Error " .. err)
success = false
break
else
@@ -424,6 +512,9 @@ elseif mode == "install" or mode == "update" then
if mode == "install" then
println("Installation completed successfully.")
else println("Update completed successfully.") end
white();println("Ready to clean up unused files, press any key to continue...")
any_key();clean(manifest)
white();println("Done.")
else
red()
if mode == "install" then
@@ -434,10 +525,10 @@ elseif mode == "install" or mode == "update" then
elseif mode == "remove" or mode == "purge" then
local ok, manifest = read_local_manifest()
if not ok then
red(); println("Error parsing local installation manifest."); white()
red();println("Error parsing local installation manifest.");white()
return
elseif mode == "remove" and manifest.versions[app] == nil then
red(); println(app .. " is not installed, cannot remove."); white()
red();println(app .. " is not installed, cannot remove.");white()
return
end
@@ -449,7 +540,10 @@ elseif mode == "remove" or mode == "purge" then
end
-- ask for confirmation
if not ask_y_n("Continue?", false) then return end
if not ask_y_n("Continue", false) then return end
-- delete unused files first
clean(manifest)
local file_list = manifest.files
local dependencies = manifest.depends[app]
@@ -469,9 +563,9 @@ elseif mode == "remove" or mode == "purge" then
end)
if not log_deleted then
red(); println("failed to delete log file")
white(); println("press enter to continue...")
read(); lgray()
red();println("Failed to delete log file.")
white();println("press any key to continue...")
any_key();lgray()
end
end
@@ -480,10 +574,7 @@ elseif mode == "remove" or mode == "purge" then
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
if fs.exists(file) then fs.delete(file);println("deleted " .. file) end
end
end
@@ -508,8 +599,7 @@ elseif mode == "remove" or mode == "purge" then
end
if folder ~= app and fs.isDir(folder) then
fs.delete(folder)
println("deleted app subdirectory " .. folder)
fs.delete(folder);println("deleted app subdirectory " .. folder)
end
end
end
@@ -527,7 +617,7 @@ elseif mode == "remove" or mode == "purge" then
imfile.close()
end
green(); println("Done!")
green();println("Done!")
end
white()

View File

@@ -26,6 +26,9 @@ config.SOUNDER_VOLUME = 1.0
-- true for 24 hour time on main view screen
config.TIME_24_HOUR = true
-- disable flow view (for legacy layouts)
config.DISABLE_FLOW_VIEW = false
-- log path
config.LOG_PATH = "/log.txt"
-- log mode

View File

@@ -2,6 +2,7 @@ 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 types = require("scada-common.types")
local iocontrol = require("coordinator.iocontrol")
local process = require("coordinator.process")
@@ -12,7 +13,6 @@ local dialog = require("coordinator.ui.dialog")
local print = util.print
local println = util.println
local println_ts = util.println_ts
local PROTOCOL = comms.PROTOCOL
local DEVICE_TYPE = comms.DEVICE_TYPE
@@ -22,6 +22,8 @@ local SCADA_CRDN_TYPE = comms.SCADA_CRDN_TYPE
local UNIT_COMMAND = comms.UNIT_COMMAND
local FAC_COMMAND = comms.FAC_COMMAND
local LINK_TIMEOUT = 60.0
local coordinator = {}
-- request the user to select a monitor
@@ -47,12 +49,15 @@ end
-- configure monitor layout
---@param num_units integer number of units expected
---@param disable_flow_view boolean disable flow view (legacy)
---@return boolean success, monitors_struct? monitors
function coordinator.configure_monitors(num_units)
function coordinator.configure_monitors(num_units, disable_flow_view)
---@class monitors_struct
local monitors = {
primary = nil,
primary_name = "",
flow = nil,
flow_name = "",
unit_displays = {},
unit_name_map = {}
}
@@ -67,8 +72,8 @@ function coordinator.configure_monitors(num_units)
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
-- we need a certain number of monitors (1 per unit + 1 primary display + 1 flow display)
local num_displays_needed = num_units + util.trinary(disable_flow_view, 1, 2)
if #names < num_displays_needed then
local message = "not enough monitors connected (need " .. num_displays_needed .. ")"
println(message)
@@ -81,10 +86,12 @@ function coordinator.configure_monitors(num_units)
log.warning("configure_monitors(): failed to load coordinator settings file (may not exist yet)")
else
local _primary = settings.get("PRIMARY_DISPLAY")
local _flow = settings.get("FLOW_DISPLAY")
local _unitd = settings.get("UNIT_DISPLAYS")
-- filter out already assigned monitors
util.filter_table(available, function (x) return x ~= _primary end)
util.filter_table(available, function (x) return x ~= _flow end)
if type(_unitd) == "table" then
util.filter_table(available, function (x) return not util.table_contains(_unitd, x) end)
end
@@ -104,7 +111,6 @@ function coordinator.configure_monitors(num_units)
end
while iface_primary_display == nil and #available > 0 do
-- lets get a monitor
iface_primary_display = ask_monitor(available)
end
@@ -116,6 +122,33 @@ function coordinator.configure_monitors(num_units)
monitors.primary = ppm.get_periph(iface_primary_display)
monitors.primary_name = iface_primary_display
--------------------------
-- FLOW MONITOR DISPLAY --
--------------------------
if not disable_flow_view then
local iface_flow_display = settings.get("FLOW_DISPLAY") ---@type boolean|string|nil
if not util.table_contains(names, iface_flow_display) then
println("flow monitor 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_flow_display = nil
end
while iface_flow_display == nil and #available > 0 do
iface_flow_display = ask_monitor(available)
end
if type(iface_flow_display) ~= "string" then return false end
settings.set("FLOW_DISPLAY", iface_flow_display)
util.filter_table(available, function (x) return x ~= iface_flow_display end)
monitors.flow = ppm.get_periph(iface_flow_display)
monitors.flow_name = iface_flow_display
end
-------------------
-- UNIT DISPLAYS --
-------------------
@@ -128,7 +161,6 @@ function coordinator.configure_monitors(num_units)
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
@@ -150,7 +182,6 @@ function coordinator.configure_monitors(num_units)
end
while display == nil and #available > 0 do
-- lets get a monitor
display = ask_monitor(available)
end
@@ -215,21 +246,25 @@ end
---@nodiscard
---@param version string coordinator version
---@param nic nic network interface device
---@param num_units integer number of configured units for number of monitors, checked against SV
---@param crd_channel integer port of configured supervisor
---@param svr_channel integer listening port for supervisor replys
---@param pkt_channel integer listening port for pocket API
---@param range integer trusted device connection range
---@param sv_watchdog watchdog
function coordinator.comms(version, nic, crd_channel, svr_channel, pkt_channel, range, sv_watchdog)
function coordinator.comms(version, nic, num_units, crd_channel, svr_channel, pkt_channel, range, sv_watchdog)
local self = {
sv_linked = false,
sv_addr = comms.BROADCAST,
sv_seq_num = 0,
sv_r_seq_num = nil,
sv_config_err = false,
connected = false,
last_est_ack = ESTABLISH_ACK.ALLOW,
last_api_est_acks = {}
last_api_est_acks = {},
est_start = 0,
est_last = 0,
est_tick_waiting = nil,
est_task_done = nil
}
comms.set_trusted_range(range)
@@ -295,77 +330,78 @@ function coordinator.comms(version, nic, crd_channel, svr_channel, pkt_channel,
---@class coord_comms
local public = {}
-- try to connect to the supervisor if not already linked
---@param abort boolean? true to print out cancel info if not linked (use on program terminate)
---@return boolean ok, boolean start_ui
function public.try_connect(abort)
local ok = true
local start_ui = false
if not self.sv_linked then
if self.est_tick_waiting == nil then
self.est_start = util.time_s()
self.est_last = self.est_start
self.est_tick_waiting, self.est_task_done =
coordinator.log_comms_connecting("attempting to connect to configured supervisor on channel " .. svr_channel)
_send_establish()
else
self.est_tick_waiting(math.max(0, LINK_TIMEOUT - (util.time_s() - self.est_start)))
end
if abort or (util.time_s() - self.est_start) >= LINK_TIMEOUT then
self.est_task_done(false)
if abort 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
ok = false
elseif self.sv_config_err then
coordinator.log_comms("supervisor cooling configuration invalid, check supervisor config file")
ok = false
elseif (util.time_s() - self.est_last) > 1.0 then
_send_establish()
self.est_last = util.time_s()
end
elseif self.est_tick_waiting ~= nil then
self.est_task_done(true)
self.est_tick_waiting = nil
self.est_task_done = nil
start_ui = true
end
return ok, start_ui
end
-- close the connection to the server
function public.close()
sv_watchdog.cancel()
self.sv_addr = comms.BROADCAST
self.sv_linked = false
self.sv_r_seq_num = nil
iocontrol.fp_link_state(types.PANEL_LINK_STATE.DISCONNECTED)
_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 == "timer" then
-- keep checking watchdog timers
apisessions.check_all_watchdogs(p1)
elseif event == "modem_message" then
-- handle message
local packet = public.parse_packet(p1, p2, p3, p4, p5)
public.handle_packet(packet)
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 })
---@param option any? optional option options for the optional options (like waste mode)
function public.send_fac_command(cmd, option)
_send_sv(PROTOCOL.SCADA_CRDN, SCADA_CRDN_TYPE.FAC_CMD, { cmd, option })
end
-- send the auto process control configuration with a start command
@@ -379,7 +415,7 @@ function coordinator.comms(version, nic, crd_channel, svr_channel, pkt_channel,
-- 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?)
---@param option any? optional option options for the optional options (like burn rate)
function public.send_unit_command(cmd, unit, option)
_send_sv(PROTOCOL.SCADA_CRDN, SCADA_CRDN_TYPE.UNIT_CMD, { cmd, unit, option })
end
@@ -424,7 +460,10 @@ function coordinator.comms(version, nic, crd_channel, svr_channel, pkt_channel,
-- handle a packet
---@param packet mgmt_frame|crdn_frame|capi_frame|nil
---@return boolean close_ui
function public.handle_packet(packet)
local was_linked = self.sv_linked
if packet ~= nil then
local l_chan = packet.scada_frame.local_channel()
local r_chan = packet.scada_frame.remote_channel()
@@ -434,7 +473,9 @@ function coordinator.comms(version, nic, crd_channel, svr_channel, pkt_channel,
if l_chan ~= crd_channel then
log.debug("received packet on unconfigured channel " .. l_chan, true)
elseif r_chan == pkt_channel then
if protocol == PROTOCOL.COORD_API then
if not self.sv_linked then
log.debug("discarding pocket API packet before linked to supervisor")
elseif protocol == PROTOCOL.COORD_API then
---@cast packet capi_frame
-- look for an associated session
local session = apisessions.find_session(src_addr)
@@ -473,7 +514,6 @@ function coordinator.comms(version, nic, crd_channel, svr_channel, pkt_channel,
elseif dev_type == DEVICE_TYPE.PKT then
-- pocket linking request
local id = apisessions.establish_session(src_addr, firmware_v)
println(util.c("[API] pocket (", firmware_v, ") [@", src_addr, "] \xbb connected"))
coordinator.log_comms(util.c("API_ESTABLISH: pocket (", firmware_v, ") [@", src_addr, "] connected with session ID ", id))
_send_api_establish_ack(packet.scada_frame, ESTABLISH_ACK.ALLOW)
@@ -496,12 +536,12 @@ function coordinator.comms(version, nic, crd_channel, svr_channel, pkt_channel,
-- 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 + 1) ~= packet.scada_frame.seq_num()) then
elseif self.sv_linked and ((self.sv_r_seq_num + 1) ~= 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
return false
elseif self.sv_linked and src_addr ~= self.sv_addr then
log.debug("received packet from unknown computer " .. src_addr .. " while linked; channel in use by another system?")
return
return false
else
self.sv_r_seq_num = packet.scada_frame.seq_num()
end
@@ -563,6 +603,10 @@ function coordinator.comms(version, nic, crd_channel, svr_channel, pkt_channel,
end
elseif cmd == FAC_COMMAND.ACK_ALL_ALARMS then
iocontrol.get_db().facility.ack_alarms_ack(ack)
elseif cmd == FAC_COMMAND.SET_WASTE_MODE then
process.waste_ack_handle(packet.data[2])
elseif cmd == FAC_COMMAND.SET_PU_FB then
process.pu_fb_ack_handle(packet.data[2])
else
log.debug(util.c("received facility command ack with unknown command ", cmd))
end
@@ -627,70 +671,7 @@ function coordinator.comms(version, nic, crd_channel, svr_channel, pkt_channel,
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_addr = src_addr
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.warning("supervisor connection denied due to collision")
end
elseif est_ack == ESTABLISH_ACK.BAD_VERSION then
if self.last_est_ack ~= est_ack then
log.warning("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 self.sv_linked then
if packet.type == SCADA_MGMT_TYPE.KEEP_ALIVE then
-- keep alive request received, echo back
if packet.length == 1 then
@@ -715,11 +696,78 @@ function coordinator.comms(version, nic, crd_channel, svr_channel, pkt_channel,
self.sv_addr = comms.BROADCAST
self.sv_linked = false
self.sv_r_seq_num = nil
println_ts("server connection closed by remote host")
iocontrol.fp_link_state(types.PANEL_LINK_STATE.DISCONNECTED)
log.info("server connection closed by remote host")
else
log.debug("received unknown SCADA_MGMT packet type " .. packet.type)
end
elseif 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
-- reset to disconnected before validating
iocontrol.fp_link_state(types.PANEL_LINK_STATE.DISCONNECTED)
if type(config) == "table" and #config == 2 then
-- get configuration
---@class facility_conf
local conf = {
num_units = config[1], ---@type integer
cooling = config[2] ---@type sv_cooling_conf
}
if conf.num_units == num_units then
-- init io controller
iocontrol.init(conf, public)
self.sv_addr = src_addr
self.sv_linked = true
self.sv_r_seq_num = nil
self.sv_config_err = false
iocontrol.fp_link_state(types.PANEL_LINK_STATE.LINKED)
else
self.sv_config_err = true
log.warning("supervisor config's number of units don't match coordinator's config, 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
iocontrol.fp_link_state(types.PANEL_LINK_STATE.DENIED)
log.info("supervisor connection denied")
end
elseif est_ack == ESTABLISH_ACK.COLLISION then
if self.last_est_ack ~= est_ack then
iocontrol.fp_link_state(types.PANEL_LINK_STATE.COLLISION)
log.warning("supervisor connection denied due to collision")
end
elseif est_ack == ESTABLISH_ACK.BAD_VERSION then
if self.last_est_ack ~= est_ack then
iocontrol.fp_link_state(types.PANEL_LINK_STATE.BAD_VERSION)
log.warning("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
else
log.debug("discarding non-link SCADA_MGMT packet before linked")
end
@@ -730,6 +778,8 @@ function coordinator.comms(version, nic, crd_channel, svr_channel, pkt_channel,
log.debug("received packet for unknown channel " .. r_chan, true)
end
end
return was_linked and not self.sv_linked
end
-- check if the coordinator is still linked to the supervisor

View File

@@ -10,9 +10,15 @@ local util = require("scada-common.util")
local process = require("coordinator.process")
local sounder = require("coordinator.sounder")
local pgi = require("coordinator.ui.pgi")
local ALARM_STATE = types.ALARM_STATE
local PROCESS = types.PROCESS
-- nominal RTT is ping (0ms to 10ms usually) + 500ms for CRD main loop tick
local WARN_RTT = 1000 -- 2x as long as expected w/ 0 ping
local HIGH_RTT = 1500 -- 3.33x as long as expected w/ 0 ping
local iocontrol = {}
---@class ioctl
@@ -27,13 +33,27 @@ local function __generic_ack(success) end
-- luacheck: unused args
-- initialize front panel PSIL
---@param firmware_v string coordinator version
---@param comms_v string comms version
function iocontrol.init_fp(firmware_v, comms_v)
---@class ioctl_front_panel
io.fp = { ps = psil.create() }
io.fp.ps.publish("version", firmware_v)
io.fp.ps.publish("comms_version", comms_v)
end
-- initialize the coordinator IO controller
---@param conf facility_conf configuration
---@param comms coord_comms comms reference
function iocontrol.init(conf, comms)
-- facility data structure
---@class ioctl_facility
io.facility = {
num_units = conf.num_units, ---@type integer
num_units = conf.num_units,
tank_mode = conf.cooling.fac_tank_mode,
tank_defs = conf.cooling.fac_tank_defs,
all_sys_ok = false,
rtu_count = 0,
@@ -52,6 +72,10 @@ function iocontrol.init(conf, comms)
gen_fault = false
},
---@type WASTE_PRODUCT
auto_current_waste_product = types.WASTE_PRODUCT.PLUTONIUM,
auto_pu_fallback_active = false,
radiation = types.new_zero_radiation_reading(),
save_cfg_ack = __generic_ack,
@@ -60,22 +84,124 @@ function iocontrol.init(conf, comms)
scram_ack = __generic_ack,
ack_alarms_ack = __generic_ack,
alarm_tones = { false, false, false, false, false, false, false, false },
ps = psil.create(),
induction_ps_tbl = {},
induction_data_tbl = {},
sps_ps_tbl = {},
sps_data_tbl = {},
tank_ps_tbl = {},
tank_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)
-- create induction and SPS tables (currently only 1 of each is supported)
table.insert(io.facility.induction_ps_tbl, psil.create())
table.insert(io.facility.induction_data_tbl, {})
table.insert(io.facility.sps_ps_tbl, psil.create())
table.insert(io.facility.sps_data_tbl, {})
-- determine tank information
if io.facility.tank_mode == 0 then
io.facility.tank_defs = {}
-- on facility tank mode 0, setup tank defs to match unit TANK option
for i = 1, conf.num_units do
io.facility.tank_defs[i] = util.trinary(conf.cooling.r_cool[i].TANK, 1, 0)
end
io.facility.tank_list = { table.unpack(io.facility.tank_defs) }
else
-- decode the layout of tanks from the connections definitions
local tank_mode = io.facility.tank_mode
local tank_defs = io.facility.tank_defs
local tank_list = { table.unpack(tank_defs) }
local function calc_fdef(start_idx, end_idx)
local first = 4
for i = start_idx, end_idx do
if io.facility.tank_defs[i] == 2 then
if i < first then first = i end
end
end
return first
end
if tank_mode == 1 then
-- (1) 1 total facility tank (A A A A)
local first_fdef = calc_fdef(1, #tank_defs)
for i = 1, #tank_defs do
if i > first_fdef and tank_defs[i] == 2 then
tank_list[i] = 0
end
end
elseif tank_mode == 2 then
-- (2) 2 total facility tanks (A A A B)
local first_fdef = calc_fdef(1, math.min(3, #tank_defs))
for i = 1, #tank_defs do
if (i ~= 4) and (i > first_fdef) and (tank_defs[i] == 2) then
tank_list[i] = 0
end
end
elseif tank_mode == 3 then
-- (3) 2 total facility tanks (A A B B)
for _, a in pairs({ 1, 3 }) do
local b = a + 1
if (tank_defs[a] == 2) and (tank_defs[b] == 2) then
tank_list[b] = 0
end
end
elseif tank_mode == 4 then
-- (4) 2 total facility tanks (A B B B)
local first_fdef = calc_fdef(2, #tank_defs)
for i = 1, #tank_defs do
if (i ~= 1) and (i > first_fdef) and (tank_defs[i] == 2) then
tank_list[i] = 0
end
end
elseif tank_mode == 5 then
-- (5) 3 total facility tanks (A A B C)
local first_fdef = calc_fdef(1, math.min(2, #tank_defs))
for i = 1, #tank_defs do
if (not (i == 3 or i == 4)) and (i > first_fdef) and (tank_defs[i] == 2) then
tank_list[i] = 0
end
end
elseif tank_mode == 6 then
-- (6) 3 total facility tanks (A B B C)
local first_fdef = calc_fdef(2, math.min(3, #tank_defs))
for i = 1, #tank_defs do
if (not (i == 1 or i == 4)) and (i > first_fdef) and (tank_defs[i] == 2) then
tank_list[i] = 0
end
end
elseif tank_mode == 7 then
-- (7) 3 total facility tanks (A B C C)
local first_fdef = calc_fdef(3, #tank_defs)
for i = 1, #tank_defs do
if (not (i == 1 or i == 2)) and (i > first_fdef) and (tank_defs[i] == 2) then
tank_list[i] = 0
end
end
end
io.facility.tank_list = tank_list
end
-- create facility tank tables
for i = 1, #io.facility.tank_list do
if io.facility.tank_list[i] == 2 then
table.insert(io.facility.tank_ps_tbl, psil.create())
table.insert(io.facility.tank_data_tbl, {})
end
end
-- create unit data structures
io.units = {}
for i = 1, conf.num_units do
local function ack(alarm) process.ack_alarm(i, alarm) end
@@ -87,11 +213,16 @@ function iocontrol.init(conf, comms)
num_boilers = 0,
num_turbines = 0,
num_snas = 0,
has_tank = conf.cooling.r_cool[i].TANK,
control_state = false,
burn_rate_cmd = 0.0,
waste_control = 0,
radiation = types.new_zero_radiation_reading(),
sna_prod_rate = 0.0,
waste_mode = types.WASTE_MODE.MANUAL_PLUTONIUM,
waste_product = types.WASTE_PRODUCT.PLUTONIUM,
-- auto control group
a_group = 0,
@@ -100,10 +231,10 @@ function iocontrol.init(conf, comms)
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_burn = function (rate) process.set_rate(i, rate) end, ---@param rate number burn rate
set_waste = function (mode) process.set_unit_waste(i, mode) end, ---@param mode WASTE_MODE waste processing mode
set_group = function (grp) process.set_group(i, grp) end, ---@param grp integer|0 group ID or 0
set_group = function (grp) process.set_group(i, grp) end, ---@param grp integer|0 group ID or 0 for manual
start_ack = __generic_ack,
scram_ack = __generic_ack,
@@ -152,21 +283,33 @@ function iocontrol.init(conf, comms)
boiler_data_tbl = {},
turbine_ps_tbl = {},
turbine_data_tbl = {}
turbine_data_tbl = {},
tank_ps_tbl = {},
tank_data_tbl = {}
}
-- on other facility modes, overwrite unit TANK option with facility tank defs
if io.facility.tank_mode ~= 0 then
entry.has_tank = conf.cooling.fac_tank_defs[i] > 0
end
-- create boiler tables
for _ = 1, conf.defs[(i * 2) - 1] do
local data = {} ---@type boilerv_session_db
for _ = 1, conf.cooling.r_cool[i].BOILERS do
table.insert(entry.boiler_ps_tbl, psil.create())
table.insert(entry.boiler_data_tbl, data)
table.insert(entry.boiler_data_tbl, {})
end
-- create turbine tables
for _ = 1, conf.defs[i * 2] do
local data = {} ---@type turbinev_session_db
for _ = 1, conf.cooling.r_cool[i].TURBINES do
table.insert(entry.turbine_ps_tbl, psil.create())
table.insert(entry.turbine_data_tbl, data)
table.insert(entry.turbine_data_tbl, {})
end
-- create tank tables
if io.facility.tank_defs[i] == 1 then
table.insert(entry.tank_ps_tbl, psil.create())
table.insert(entry.tank_data_tbl, {})
end
entry.num_boilers = #entry.boiler_data_tbl
@@ -179,6 +322,102 @@ function iocontrol.init(conf, comms)
process.init(io, comms)
end
--#region Front Panel PSIL
-- toggle heartbeat indicator
function iocontrol.heartbeat() io.fp.ps.toggle("heartbeat") end
-- report presence of the wireless modem
---@param has_modem boolean
function iocontrol.fp_has_modem(has_modem) io.fp.ps.publish("has_modem", has_modem) end
-- report presence of the speaker
---@param has_speaker boolean
function iocontrol.fp_has_speaker(has_speaker) io.fp.ps.publish("has_speaker", has_speaker) end
-- report supervisor link state
---@param state integer
function iocontrol.fp_link_state(state) io.fp.ps.publish("link_state", state) end
-- report monitor connection state
---@param id string|integer unit ID for unit monitor, "main" for main monitor, or "flow" for flow monitor
function iocontrol.fp_monitor_state(id, connected)
local name = nil
if id == "main" then
name = "main_monitor"
elseif id == "flow" then
name = "flow_monitor"
elseif type(id) == "number" then
name = "unit_monitor_" .. id
end
if name ~= nil then
io.fp.ps.publish(name, connected)
end
end
-- report PKT firmware version and PKT session connection state
---@param session_id integer PKT session
---@param fw string firmware version
---@param s_addr integer PKT computer ID
function iocontrol.fp_pkt_connected(session_id, fw, s_addr)
io.fp.ps.publish("pkt_" .. session_id .. "_fw", fw)
io.fp.ps.publish("pkt_" .. session_id .. "_addr", util.sprintf("@ C% 3d", s_addr))
pgi.create_pkt_entry(session_id)
end
-- report PKT session disconnected
---@param session_id integer PKT session
function iocontrol.fp_pkt_disconnected(session_id)
pgi.delete_pkt_entry(session_id)
end
-- transmit PKT session RTT
---@param session_id integer PKT session
---@param rtt integer round trip time
function iocontrol.fp_pkt_rtt(session_id, rtt)
io.fp.ps.publish("pkt_" .. session_id .. "_rtt", rtt)
if rtt > HIGH_RTT then
io.fp.ps.publish("pkt_" .. session_id .. "_rtt_color", colors.red)
elseif rtt > WARN_RTT then
io.fp.ps.publish("pkt_" .. session_id .. "_rtt_color", colors.yellow_hc)
else
io.fp.ps.publish("pkt_" .. session_id .. "_rtt_color", colors.green)
end
end
--#endregion
--#region Builds
-- record and publish multiblock RTU build data
---@param id integer
---@param entry table
---@param data_tbl table
---@param ps_tbl table
---@param create boolean? true to create an entry if non exists, false to fail on missing
---@return boolean ok true if data saved, false if invalid ID
local function _record_multiblock_build(id, entry, data_tbl, ps_tbl, create)
local exists = type(data_tbl[id]) == "table"
if exists or create then
if not exists then
ps_tbl[id] = psil.create()
data_tbl[id] = {}
end
data_tbl[id].formed = entry[1] ---@type boolean
data_tbl[id].build = entry[2] ---@type table
ps_tbl[id].publish("formed", entry[1])
for key, val in pairs(data_tbl[id].build) do ps_tbl[id].publish(key, val) end
end
return exists or (create == true)
end
-- populate facility structure builds
---@param build table
---@return boolean valid
@@ -191,21 +430,29 @@ function iocontrol.record_facility_builds(build)
-- 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
if not _record_multiblock_build(id, matrix, fac.induction_data_tbl, fac.induction_ps_tbl) then
log.debug(util.c("iocontrol.record_facility_builds: invalid induction matrix id ", id))
valid = false
end
end
end
-- SPS
if type(build.sps) == "table" then
for id, sps in pairs(build.sps) do
if not _record_multiblock_build(id, sps, fac.sps_data_tbl, fac.sps_ps_tbl) then
log.debug(util.c("iocontrol.record_facility_builds: invalid SPS id ", id))
valid = false
end
end
end
-- dynamic tanks
if type(build.tanks) == "table" then
for id, tank in pairs(build.tanks) do
_record_multiblock_build(id, tank, fac.tank_data_tbl, fac.tank_ps_tbl, true)
end
end
else
log.debug("facility builds not a table")
valid = false
@@ -249,16 +496,7 @@ function iocontrol.record_unit_builds(builds)
-- 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
if not _record_multiblock_build(b_id, boiler, unit.boiler_data_tbl, unit.boiler_ps_tbl) then
log.debug(util.c(log_header, "invalid boiler id ", b_id))
valid = false
end
@@ -268,27 +506,49 @@ function iocontrol.record_unit_builds(builds)
-- 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
if not _record_multiblock_build(t_id, turbine, unit.turbine_data_tbl, unit.turbine_ps_tbl) then
log.debug(util.c(log_header, "invalid turbine id ", t_id))
valid = false
end
end
end
-- dynamic tank builds
if type(build.tanks) == "table" then
for d_id, d_tank in pairs(build.tanks) do
_record_multiblock_build(d_id, d_tank, unit.tank_data_tbl, unit.tank_ps_tbl, true)
end
end
end
end
return valid
end
--#endregion
--#region Statuses
-- record and publish multiblock status data
---@param entry any
---@param data imatrix_session_db|sps_session_db|dynamicv_session_db|turbinev_session_db|boilerv_session_db
---@param ps psil
---@return boolean is_faulted
local function _record_multiblock_status(entry, data, ps)
local is_faulted = entry[1] ---@type boolean
data.formed = entry[2] ---@type boolean
data.state = entry[3] ---@type table
data.tanks = entry[4] ---@type table
ps.publish("formed", data.formed)
ps.publish("faulted", is_faulted)
for key, val in pairs(data.state) do ps.publish(key, val) end
for key, val in pairs(data.tanks) do ps.publish(key, val) end
return is_faulted
end
-- update facility status
---@param status table
---@return boolean valid
@@ -306,7 +566,7 @@ function iocontrol.update_facility_status(status)
local ctl_status = status[1]
if type(ctl_status) == "table" and #ctl_status == 14 then
if type(ctl_status) == "table" and #ctl_status == 16 then
fac.all_sys_ok = ctl_status[1]
fac.auto_ready = ctl_status[2]
@@ -354,6 +614,12 @@ function iocontrol.update_facility_status(status)
io.units[i].unit_ps.publish("auto_group", names[group_map[i] + 1])
end
end
fac.auto_current_waste_product = ctl_status[15]
fac.auto_pu_fallback_active = ctl_status[16]
fac.ps.publish("current_waste_product", fac.auto_current_waste_product)
fac.ps.publish("pu_fallback_active", fac.auto_pu_fallback_active)
else
log.debug(log_header .. "control status not a table or length mismatch")
valid = false
@@ -390,36 +656,23 @@ function iocontrol.update_facility_status(status)
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
local ps = fac.induction_ps_tbl[id] ---@type psil
local data = fac.induction_data_tbl[id] ---@type imatrix_session_db
local rtu_faulted = _record_multiblock_status(matrix, data, ps)
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
if rtu_faulted then
ps.publish("computed_status", 3) -- faulted
elseif data.formed then
if data.tanks.energy_fill >= 0.99 then
ps.publish("computed_status", 6) -- full
elseif data.tanks.energy_fill <= 0.01 then
fac.induction_ps_tbl[id].publish("computed_status", 5) -- empty
ps.publish("computed_status", 5) -- empty
else
fac.induction_ps_tbl[id].publish("computed_status", 4) -- on-line
ps.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)
ps.publish("computed_status", 2) -- not formed
end
else
log.debug(util.c(log_header, "invalid induction matrix id ", id))
@@ -430,6 +683,82 @@ function iocontrol.update_facility_status(status)
valid = false
end
-- SPS statuses
if type(rtu_statuses.sps) == "table" then
for id = 1, #fac.sps_ps_tbl do
if rtu_statuses.sps[id] == nil then
-- disconnected
fac.sps_ps_tbl[id].publish("computed_status", 1)
end
end
for id, sps in pairs(rtu_statuses.sps) do
if type(fac.sps_data_tbl[id]) == "table" then
local data = fac.sps_data_tbl[id] ---@type sps_session_db
local ps = fac.sps_ps_tbl[id] ---@type psil
local rtu_faulted = _record_multiblock_status(sps, data, ps)
if rtu_faulted then
ps.publish("computed_status", 3) -- faulted
elseif data.formed then
if data.state.process_rate > 0 then
ps.publish("computed_status", 5) -- active
else
ps.publish("computed_status", 4) -- idle
end
else
ps.publish("computed_status", 2) -- not formed
end
io.facility.ps.publish("am_rate", data.state.process_rate * 1000)
else
log.debug(util.c(log_header, "invalid sps id ", id))
end
end
else
log.debug(log_header .. "sps list not a table")
valid = false
end
-- dynamic tank statuses
if type(rtu_statuses.tanks) == "table" then
for id = 1, #fac.tank_ps_tbl do
if rtu_statuses.tanks[id] == nil then
-- disconnected
fac.tank_ps_tbl[id].publish("computed_status", 1)
end
end
for id, tank in pairs(rtu_statuses.tanks) do
if type(fac.tank_data_tbl[id]) == "table" then
local data = fac.tank_data_tbl[id] ---@type dynamicv_session_db
local ps = fac.tank_ps_tbl[id] ---@type psil
local rtu_faulted = _record_multiblock_status(tank, data, ps)
if rtu_faulted then
ps.publish("computed_status", 3) -- faulted
elseif data.formed then
if data.tanks.fill >= 0.99 then
ps.publish("computed_status", 6) -- full
elseif data.tanks.fill < 0.20 then
ps.publish("computed_status", 5) -- low
else
ps.publish("computed_status", 4) -- on-line
end
else
ps.publish("computed_status", 2) -- not formed
end
else
log.debug(util.c(log_header, "invalid dynamic tank id ", id))
end
end
else
log.debug(log_header .. "dyanmic tank 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
@@ -453,6 +782,16 @@ function iocontrol.update_facility_status(status)
end
fac.ps.publish("rtu_count", fac.rtu_count)
-- alarm tone commands
if (type(status[3]) == "table") and (#status[3] == 8) then
fac.alarm_tones = status[3]
sounder.set(fac.alarm_tones)
else
log.debug(log_header .. "alarm tones not a table or length mismatch")
valid = false
end
end
return valid
@@ -472,6 +811,8 @@ function iocontrol.update_unit_statuses(statuses)
valid = false
else
local burn_rate_sum = 0.0
local sna_count_sum = 0
local pu_rate, po_rate, po_pl_rate, po_am_rate, spent_rate = 0.0, 0.0, 0.0, 0.0, 0.0
-- get all unit statuses
for i = 1, #statuses do
@@ -480,7 +821,9 @@ function iocontrol.update_unit_statuses(statuses)
local unit = io.units[i] ---@type ioctl_unit
local status = statuses[i]
if type(status) ~= "table" or #status ~= 5 then
local burn_rate = 0.0
if type(status) ~= "table" or #status ~= 6 then
log.debug(log_header .. "invalid status entry in unit statuses (not a table or invalid length)")
valid = false
else
@@ -515,7 +858,8 @@ function iocontrol.update_unit_statuses(statuses)
-- 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
burn_rate = unit.reactor_data.mek_status.act_burn_rate
burn_rate_sum = burn_rate_sum + burn_rate
end
if unit.reactor_data.mek_status.status then
@@ -562,6 +906,8 @@ function iocontrol.update_unit_statuses(statuses)
if type(rtu_statuses) == "table" then
-- boiler statuses
if type(rtu_statuses.boilers) == "table" then
local boil_sum = 0
for id = 1, #unit.boiler_ps_tbl do
if rtu_statuses.boilers[i] == nil then
-- disconnected
@@ -571,40 +917,31 @@ function iocontrol.update_unit_statuses(statuses)
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
local ps = unit.boiler_ps_tbl[id] ---@type psil
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)
local rtu_faulted = _record_multiblock_status(boiler, data, ps)
if rtu_faulted then
unit.boiler_ps_tbl[id].publish("computed_status", 3) -- faulted
ps.publish("computed_status", 3) -- faulted
elseif data.formed then
boil_sum = boil_sum + data.state.boil_rate
if data.state.boil_rate > 0 then
unit.boiler_ps_tbl[id].publish("computed_status", 5) -- active
ps.publish("computed_status", 5) -- active
else
unit.boiler_ps_tbl[id].publish("computed_status", 4) -- idle
ps.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)
ps.publish("computed_status", 2) -- not formed
end
else
log.debug(util.c(log_header, "invalid boiler id ", id))
valid = false
end
end
unit.unit_ps.publish("boiler_boil_sum", boil_sum)
else
log.debug(log_header .. "boiler list not a table")
valid = false
@@ -612,6 +949,8 @@ function iocontrol.update_unit_statuses(statuses)
-- turbine statuses
if type(rtu_statuses.turbines) == "table" then
local flow_sum = 0
for id = 1, #unit.turbine_ps_tbl do
if rtu_statuses.turbines[i] == nil then
-- disconnected
@@ -621,47 +960,93 @@ function iocontrol.update_unit_statuses(statuses)
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
local ps = unit.turbine_ps_tbl[id] ---@type psil
unit.turbine_ps_tbl[id].publish("formed", data.formed)
unit.turbine_ps_tbl[id].publish("faulted", rtu_faulted)
local rtu_faulted = _record_multiblock_status(turbine, data, ps)
if rtu_faulted then
unit.turbine_ps_tbl[id].publish("computed_status", 3) -- faulted
ps.publish("computed_status", 3) -- faulted
elseif data.formed then
flow_sum = flow_sum + data.state.flow_rate
if data.tanks.energy_fill >= 0.99 then
unit.turbine_ps_tbl[id].publish("computed_status", 6) -- trip
ps.publish("computed_status", 6) -- trip
elseif data.state.flow_rate < 100 then
unit.turbine_ps_tbl[id].publish("computed_status", 4) -- idle
ps.publish("computed_status", 4) -- idle
else
unit.turbine_ps_tbl[id].publish("computed_status", 5) -- active
ps.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)
ps.publish("computed_status", 2) -- not formed
end
else
log.debug(util.c(log_header, "invalid turbine id ", id))
valid = false
end
end
unit.unit_ps.publish("turbine_flow_sum", flow_sum)
else
log.debug(log_header .. "turbine list not a table")
valid = false
end
-- dynamic tank statuses
if type(rtu_statuses.tanks) == "table" then
for id = 1, #unit.tank_ps_tbl do
if rtu_statuses.tanks[i] == nil then
-- disconnected
unit.tank_ps_tbl[id].publish("computed_status", 1)
end
end
for id, tank in pairs(rtu_statuses.tanks) do
if type(unit.tank_data_tbl[id]) == "table" then
local data = unit.tank_data_tbl[id] ---@type dynamicv_session_db
local ps = unit.tank_ps_tbl[id] ---@type psil
local rtu_faulted = _record_multiblock_status(tank, data, ps)
if rtu_faulted then
ps.publish("computed_status", 3) -- faulted
elseif data.formed then
if data.tanks.fill >= 0.99 then
ps.publish("computed_status", 6) -- full
elseif data.tanks.fill < 0.20 then
ps.publish("computed_status", 5) -- low
else
ps.publish("computed_status", 4) -- on-line
end
else
ps.publish("computed_status", 2) -- not formed
end
else
log.debug(util.c(log_header, "invalid dynamic tank id ", id))
valid = false
end
end
else
log.debug(log_header .. "dynamic tank list not a table")
valid = false
end
-- solar neutron activator status info
if type(rtu_statuses.sna) == "table" then
unit.num_snas = rtu_statuses.sna[1] ---@type integer
unit.sna_prod_rate = rtu_statuses.sna[2] ---@type number
unit.sna_peak_rate = rtu_statuses.sna[3] ---@type number
unit.unit_ps.publish("sna_count", unit.num_snas)
unit.unit_ps.publish("sna_prod_rate", unit.sna_prod_rate)
unit.unit_ps.publish("sna_peak_rate", unit.sna_peak_rate)
sna_count_sum = sna_count_sum + unit.num_snas
else
log.debug(log_header .. "sna statistic 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
@@ -739,12 +1124,17 @@ function iocontrol.update_unit_statuses(statuses)
local unit_state = status[5]
if type(unit_state) == "table" then
if #unit_state == 5 then
if #unit_state == 6 then
unit.waste_mode = unit_state[5]
unit.waste_product = unit_state[6]
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])
unit.unit_ps.publish("U_AutoReady", unit_state[3])
unit.unit_ps.publish("U_AutoDegraded", unit_state[4])
unit.unit_ps.publish("U_AutoWaste", unit.waste_mode == types.WASTE_MODE.AUTO)
unit.unit_ps.publish("U_WasteMode", unit.waste_mode)
unit.unit_ps.publish("U_WasteProduct", unit.waste_product)
else
log.debug(log_header .. "unit state length mismatch")
valid = false
@@ -753,18 +1143,81 @@ function iocontrol.update_unit_statuses(statuses)
log.debug(log_header .. "unit state not a table")
valid = false
end
-- valve states
local valve_states = status[6]
if type(valve_states) == "table" then
if #valve_states == 5 then
unit.unit_ps.publish("V_pu_conn", valve_states[1] > 0)
unit.unit_ps.publish("V_pu_state", valve_states[1] == 2)
unit.unit_ps.publish("V_po_conn", valve_states[2] > 0)
unit.unit_ps.publish("V_po_state", valve_states[2] == 2)
unit.unit_ps.publish("V_pl_conn", valve_states[3] > 0)
unit.unit_ps.publish("V_pl_state", valve_states[3] == 2)
unit.unit_ps.publish("V_am_conn", valve_states[4] > 0)
unit.unit_ps.publish("V_am_state", valve_states[4] == 2)
unit.unit_ps.publish("V_emc_conn", valve_states[5] > 0)
unit.unit_ps.publish("V_emc_state", valve_states[5] == 2)
else
log.debug(log_header .. "valve states length mismatch")
valid = false
end
else
log.debug(log_header .. "valve states not a table")
valid = false
end
-- determine waste production for this unit, add to statistics
local is_pu = unit.waste_product == types.WASTE_PRODUCT.PLUTONIUM
local waste_rate = burn_rate / 10.0
local u_spent_rate = waste_rate
local u_pu_rate = util.trinary(is_pu, waste_rate, 0.0)
local u_po_rate = util.trinary(not is_pu, math.min(waste_rate, unit.sna_prod_rate), 0.0)
unit.unit_ps.publish("pu_rate", u_pu_rate)
unit.unit_ps.publish("po_rate", u_po_rate)
unit.unit_ps.publish("sna_in", util.trinary(is_pu, 0, burn_rate))
if unit.waste_product == types.WASTE_PRODUCT.POLONIUM then
unit.unit_ps.publish("po_pl_rate", u_po_rate)
unit.unit_ps.publish("po_am_rate", 0)
po_pl_rate = po_pl_rate + u_po_rate
elseif unit.waste_product == types.WASTE_PRODUCT.ANTI_MATTER then
unit.unit_ps.publish("po_pl_rate", 0)
unit.unit_ps.publish("po_am_rate", u_po_rate)
po_am_rate = po_am_rate + u_po_rate
u_spent_rate = 0
else
unit.unit_ps.publish("po_pl_rate", 0)
unit.unit_ps.publish("po_am_rate", 0)
end
unit.unit_ps.publish("ws_rate", u_spent_rate)
pu_rate = pu_rate + u_pu_rate
po_rate = po_rate + u_po_rate
spent_rate = spent_rate + u_spent_rate
end
end
io.facility.ps.publish("burn_sum", burn_rate_sum)
-- update alarm sounder
sounder.eval(io.units)
io.facility.ps.publish("sna_count", sna_count_sum)
io.facility.ps.publish("pu_rate", pu_rate)
io.facility.ps.publish("po_rate", po_rate)
io.facility.ps.publish("po_pl_rate", po_pl_rate)
io.facility.ps.publish("po_am_rate", po_am_rate)
io.facility.ps.publish("spent_waste_rate", spent_rate)
end
return valid
end
--#endregion
-- get the IO controller database
function iocontrol.get_db() return io end

View File

@@ -11,6 +11,7 @@ local FAC_COMMAND = comms.FAC_COMMAND
local UNIT_COMMAND = comms.UNIT_COMMAND
local PROCESS = types.PROCESS
local PRODUCT = types.WASTE_PRODUCT
---@class process_controller
local process = {}
@@ -24,7 +25,9 @@ local self = {
burn_target = 0.0,
charge_target = 0.0,
gen_target = 0.0,
limits = {}
limits = {},
waste_product = PRODUCT.PLUTONIUM,
pu_fallback = false
}
}
@@ -48,19 +51,23 @@ function process.init(iocontrol, coord_comms)
log.error("process.init(): failed to load coordinator settings file")
end
-- facility auto control configuration
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.config.waste_product = config.waste_product
self.config.pu_fallback = config.pu_fallback
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)
self.io.facility.ps.publish("process_waste_product", self.config.waste_product)
self.io.facility.ps.publish("process_pu_fallback", self.config.pu_fallback)
for id = 1, math.min(#self.config.limits, self.io.facility.num_units) do
local unit = self.io.units[id] ---@type ioctl_unit
@@ -68,20 +75,24 @@ function process.init(iocontrol, coord_comms)
end
log.info("PROCESS: loaded auto control settings from coord.settings")
-- notify supervisor of auto waste config
self.comms.send_fac_command(FAC_COMMAND.SET_WASTE_MODE, self.config.waste_product)
self.comms.send_fac_command(FAC_COMMAND.SET_PU_FB, self.config.pu_fallback)
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
-- unit waste states
local waste_modes = settings.get("WASTE_MODES") ---@type table|nil
if type(waste_modes) == "table" then
for id, mode in pairs(waste_modes) do
self.comms.send_unit_command(UNIT_COMMAND.SET_WASTE, id, mode)
end
log.info("PROCESS: loaded waste mode settings from coord.settings")
log.info("PROCESS: loaded unit waste mode settings from coord.settings")
end
-- unit priority groups
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)
@@ -137,7 +148,7 @@ end
-- set waste mode
---@param id integer unit ID
---@param mode integer waste mode
function process.set_waste(id, mode)
function process.set_unit_waste(id, mode)
-- publish so that if it fails then it gets reset
self.io.units[id].unit_ps.publish("U_WasteMode", mode)
@@ -153,7 +164,7 @@ function process.set_waste(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")
log.error("process.set_unit_waste(): failed to save coordinator settings file")
end
end
@@ -204,6 +215,24 @@ end
-- AUTO PROCESS CONTROL --
--------------------------
-- write auto process control to config file
local function _write_auto_config()
-- attempt to load settings
if not settings.load("/coord.settings") then
log.warning("process._write_auto_config(): failed to load coordinator settings file")
end
-- save config
settings.set("PROCESS", self.config)
local saved = settings.save("/coord.settings")
if not saved then
log.warning("process._write_auto_config(): failed to save coordinator settings file")
end
return not not saved
end
-- stop automatic process control
function process.stop_auto()
self.comms.send_fac_command(FAC_COMMAND.STOP)
@@ -216,6 +245,30 @@ function process.start_auto()
log.debug("PROCESS: START AUTO CTL")
end
-- set automatic process control waste mode
---@param product WASTE_PRODUCT waste product for auto control
function process.set_process_waste(product)
self.comms.send_fac_command(FAC_COMMAND.SET_WASTE_MODE, product)
log.debug(util.c("PROCESS: SET WASTE ", product))
-- update config table and save
self.config.waste_product = product
_write_auto_config()
end
-- set automatic process control plutonium fallback
---@param enabled boolean whether to enable plutonium fallback
function process.set_pu_fallback(enabled)
self.comms.send_fac_command(FAC_COMMAND.SET_PU_FB, enabled)
log.debug(util.c("PROCESS: SET PU FALLBACK ", enabled))
-- update config table and save
self.config.pu_fallback = enabled
_write_auto_config()
end
-- save process control settings
---@param mode PROCESS control mode
---@param burn_target number burn rate target
@@ -223,29 +276,17 @@ end
---@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
log.debug("PROCESS: SAVE")
-- config table
self.config = {
mode = mode,
burn_target = burn_target,
charge_target = charge_target,
gen_target = gen_target,
limits = limits
}
-- update config table
self.config.mode = mode
self.config.burn_target = burn_target
self.config.charge_target = charge_target
self.config.gen_target = gen_target
self.config.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)
self.io.facility.save_cfg_ack(_write_auto_config())
end
-- handle a start command acknowledgement
@@ -258,16 +299,33 @@ function process.start_ack_handle(response)
self.config.charge_target = response[4]
self.config.gen_target = response[5]
for i = 1, #response[6] do
for i = 1, math.min(#response[6], self.io.facility.num_units) do
self.config.limits[i] = response[6][i]
local unit = self.io.units[i] ---@type ioctl_unit
unit.unit_ps.publish("burn_limit", self.config.limits[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.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)
self.io.facility.start_ack(ack)
end
-- record waste product state after attempting to change it
---@param response WASTE_PRODUCT supervisor waste product state
function process.waste_ack_handle(response)
self.config.waste_product = response
self.io.facility.ps.publish("process_waste_product", response)
end
-- record plutonium fallback state after attempting to change it
---@param response boolean supervisor plutonium fallback state
function process.pu_fb_ack_handle(response)
self.config.pu_fallback = response
self.io.facility.ps.publish("process_pu_fallback", response)
end
return process

View File

@@ -5,8 +5,13 @@
local log = require("scada-common.log")
local util = require("scada-common.util")
local style = require("coordinator.ui.style")
local iocontrol = require("coordinator.iocontrol")
local style = require("coordinator.ui.style")
local pgi = require("coordinator.ui.pgi")
local flow_view = require("coordinator.ui.layout.flow_view")
local panel_view = require("coordinator.ui.layout.front_panel")
local main_view = require("coordinator.ui.layout.main_view")
local unit_view = require("coordinator.ui.layout.unit_view")
@@ -21,10 +26,14 @@ local engine = {
monitors = nil, ---@type monitors_struct|nil
dmesg_window = nil, ---@type table|nil
ui_ready = false,
fp_ready = false,
ui = {
front_panel = nil, ---@type graphics_element|nil
main_display = nil, ---@type graphics_element|nil
flow_display = nil, ---@type graphics_element|nil
unit_displays = {}
}
},
disable_flow_view = false
}
-- init a display to the "default", but set text scale to 0.5
@@ -42,39 +51,44 @@ local function _init_display(monitor)
end
end
-- disable the flow view
---@param disable boolean
function renderer.legacy_disable_flow_view(disable)
engine.disable_flow_view = disable
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 _, monitor in ipairs(engine.monitors.unit_displays) do
if monitor == periph then return true end
end
end
end
return false
-- report to front panel as connected
iocontrol.fp_monitor_state("main", engine.monitors.primary ~= nil)
iocontrol.fp_monitor_state("flow", engine.monitors.flow ~= nil)
for i = 1, #engine.monitors.unit_displays do iocontrol.fp_monitor_state(i, true) end
end
-- init all displays in use by the renderer
function renderer.init_displays()
-- init primary monitor
-- init primary and flow monitors
_init_display(engine.monitors.primary)
if not engine.disable_flow_view then _init_display(engine.monitors.flow) end
-- init unit displays
for _, monitor in ipairs(engine.monitors.unit_displays) do
_init_display(monitor)
end
-- init terminal
term.setTextColor(colors.white)
term.setBackgroundColor(colors.black)
term.clear()
term.setCursorPos(1, 1)
-- set overridden colors
for i = 1, #style.fp.colors do
term.setPaletteColor(style.fp.colors[i].c, style.fp.colors[i].hex)
end
end
-- check main display width
@@ -85,6 +99,14 @@ function renderer.validate_main_display_width()
return w == 164
end
-- check flow display width
---@nodiscard
---@return boolean width_okay
function renderer.validate_flow_display_width()
local w, _ = engine.monitors.flow.getSize()
return w == 164
end
-- check display sizes
---@nodiscard
---@return boolean valid all unit display dimensions OK
@@ -109,6 +131,51 @@ function renderer.init_dmesg()
log.direct_dmesg(engine.dmesg_window)
end
-- start the coordinator front panel
function renderer.start_fp()
if not engine.fp_ready then
-- show front panel view on terminal
engine.ui.front_panel = DisplayBox{window=term.native(),fg_bg=style.fp.root}
panel_view(engine.ui.front_panel, #engine.monitors.unit_displays)
-- start flasher callback task
flasher.run()
-- report front panel as ready
engine.fp_ready = true
end
end
-- close out the front panel
function renderer.close_fp()
if engine.fp_ready then
if not engine.ui_ready then
-- stop blinking indicators
flasher.clear()
end
-- disable PGI
pgi.unlink()
-- hide to stop animation callbacks and clear root UI elements
engine.ui.front_panel.hide()
engine.ui.front_panel = nil
engine.fp_ready = false
-- restore colors
for i = 1, #style.colors do
local r, g, b = term.nativePaletteColor(style.colors[i].c)
term.setPaletteColor(style.colors[i].c, r, g, b)
end
-- reset terminal
term.setTextColor(colors.white)
term.setBackgroundColor(colors.black)
term.clear()
term.setCursorPos(1, 1)
end
end
-- start the coordinator GUI
function renderer.start_ui()
if not engine.ui_ready then
@@ -116,13 +183,21 @@ function renderer.start_ui()
engine.dmesg_window.setVisible(false)
-- show main view on main monitor
engine.ui.main_display = DisplayBox{window=engine.monitors.primary,fg_bg=style.root}
main_view(engine.ui.main_display)
if engine.monitors.primary ~= nil then
engine.ui.main_display = DisplayBox{window=engine.monitors.primary,fg_bg=style.root}
main_view(engine.ui.main_display)
end
-- show flow view on flow monitor
if engine.monitors.flow ~= nil then
engine.ui.flow_display = DisplayBox{window=engine.monitors.flow,fg_bg=style.root}
flow_view(engine.ui.flow_display)
end
-- show unit views on unit displays
for i = 1, #engine.monitors.unit_displays do
engine.ui.unit_displays[i] = DisplayBox{window=engine.monitors.unit_displays[i],fg_bg=style.root}
unit_view(engine.ui.unit_displays[i], i)
for idx, display in pairs(engine.monitors.unit_displays) do
engine.ui.unit_displays[idx] = DisplayBox{window=display,fg_bg=style.root}
unit_view(engine.ui.unit_displays[idx], idx)
end
-- start flasher callback task
@@ -135,18 +210,22 @@ end
-- close out the UI
function renderer.close_ui()
-- stop blinking indicators
flasher.clear()
if not engine.fp_ready then
-- stop blinking indicators
flasher.clear()
end
-- delete element trees
if engine.ui.main_display ~= nil then engine.ui.main_display.delete() end
for _, display in ipairs(engine.ui.unit_displays) do display.delete() end
if engine.ui.flow_display ~= nil then engine.ui.flow_display.delete() end
for _, display in pairs(engine.ui.unit_displays) do display.delete() end
-- report ui as not ready
engine.ui_ready = false
-- clear root UI elements
engine.ui.main_display = nil
engine.ui.flow_display = nil
engine.ui.unit_displays = {}
-- clear unit monitors
@@ -157,22 +236,145 @@ function renderer.close_ui()
engine.dmesg_window.redraw()
end
-- is the front panel ready?
---@nodiscard
---@return boolean ready
function renderer.fp_ready() return engine.fp_ready end
-- is the UI ready?
---@nodiscard
---@return boolean ready
function renderer.ui_ready() return engine.ui_ready end
-- handle a monitor peripheral being disconnected
---@param device table monitor
---@return boolean is_used if the monitor is one of the configured monitors
function renderer.handle_disconnect(device)
local is_used = false
if engine.monitors ~= nil then
if engine.monitors.primary == device then
if engine.ui.main_display ~= nil then
-- delete element tree and clear root UI elements
engine.ui.main_display.delete()
end
is_used = true
engine.monitors.primary = nil
engine.ui.main_display = nil
iocontrol.fp_monitor_state("main", false)
elseif engine.monitors.flow == device then
if engine.ui.flow_display ~= nil then
-- delete element tree and clear root UI elements
engine.ui.flow_display.delete()
end
is_used = true
engine.monitors.flow = nil
engine.ui.flow_display = nil
iocontrol.fp_monitor_state("flow", false)
else
for idx, monitor in pairs(engine.monitors.unit_displays) do
if monitor == device then
if engine.ui.unit_displays[idx] ~= nil then
engine.ui.unit_displays[idx].delete()
end
is_used = true
engine.monitors.unit_displays[idx] = nil
engine.ui.unit_displays[idx] = nil
iocontrol.fp_monitor_state(idx, false)
break
end
end
end
end
return is_used
end
-- handle a monitor peripheral being reconnected
---@param name string monitor name
---@param device table monitor
---@return boolean is_used if the monitor is one of the configured monitors
function renderer.handle_reconnect(name, device)
local is_used = false
if engine.monitors ~= nil then
if engine.monitors.primary_name == name then
is_used = true
_init_display(device)
engine.monitors.primary = device
local disp_x, disp_y = engine.monitors.primary.getSize()
engine.dmesg_window.reposition(1, 1, disp_x, disp_y, engine.monitors.primary)
if engine.ui_ready and (engine.ui.main_display == nil) then
engine.dmesg_window.setVisible(false)
engine.ui.main_display = DisplayBox{window=device,fg_bg=style.root}
main_view(engine.ui.main_display)
else
engine.dmesg_window.setVisible(true)
engine.dmesg_window.redraw()
end
iocontrol.fp_monitor_state("main", true)
elseif engine.monitors.flow_name == name then
is_used = true
_init_display(device)
engine.monitors.flow = device
if engine.ui_ready and (engine.ui.flow_display == nil) then
engine.ui.flow_display = DisplayBox{window=device,fg_bg=style.root}
flow_view(engine.ui.flow_display)
end
iocontrol.fp_monitor_state("flow", true)
else
for idx, monitor in ipairs(engine.monitors.unit_name_map) do
if monitor == name then
is_used = true
_init_display(device)
engine.monitors.unit_displays[idx] = device
if engine.ui_ready and (engine.ui.unit_displays[idx] == nil) then
engine.ui.unit_displays[idx] = DisplayBox{window=device,fg_bg=style.root}
unit_view(engine.ui.unit_displays[idx], idx)
end
iocontrol.fp_monitor_state(idx, true)
break
end
end
end
end
return is_used
end
-- handle a touch event
---@param event mouse_interaction|nil
function renderer.handle_mouse(event)
if engine.ui_ready and event ~= nil then
if event.monitor == engine.monitors.primary_name then
engine.ui.main_display.handle_mouse(event)
else
for id, monitor in ipairs(engine.monitors.unit_name_map) do
if event.monitor == monitor then
local layout = engine.ui.unit_displays[id] ---@type graphics_element
layout.handle_mouse(event)
if event ~= nil then
if engine.fp_ready and event.monitor == "terminal" then
engine.ui.front_panel.handle_mouse(event)
elseif engine.ui_ready then
if event.monitor == engine.monitors.primary_name then
engine.ui.main_display.handle_mouse(event)
elseif event.monitor == engine.monitors.flow_name then
engine.ui.flow_display.handle_mouse(event)
else
for id, monitor in ipairs(engine.monitors.unit_name_map) do
if event.monitor == monitor then
local layout = engine.ui.unit_displays[id] ---@type graphics_element
layout.handle_mouse(event)
break
end
end
end
end

View File

@@ -1,11 +1,12 @@
local log = require("scada-common.log")
local mqueue = require("scada-common.mqueue")
local util = require("scada-common.util")
local log = require("scada-common.log")
local mqueue = require("scada-common.mqueue")
local util = require("scada-common.util")
local config = require("coordinator.config")
local config = require("coordinator.config")
local iocontrol = require("coordinator.iocontrol")
local pocket = require("coordinator.session.pocket")
local pocket = require("coordinator.session.pocket")
local apisessions = {}
@@ -112,6 +113,7 @@ function apisessions.establish_session(source_addr, version)
setmetatable(pkt_s, mt)
iocontrol.fp_pkt_connected(id, version, source_addr)
log.debug(util.c("[API] established new session: ", pkt_s))
self.next_id = id + 1

View File

@@ -1,7 +1,9 @@
local comms = require("scada-common.comms")
local log = require("scada-common.log")
local mqueue = require("scada-common.mqueue")
local util = require("scada-common.util")
local comms = require("scada-common.comms")
local log = require("scada-common.log")
local mqueue = require("scada-common.mqueue")
local util = require("scada-common.util")
local iocontrol = require("coordinator.iocontrol")
local pocket = {}
@@ -9,8 +11,6 @@ local PROTOCOL = comms.PROTOCOL
-- local CAPI_TYPE = comms.CAPI_TYPE
local SCADA_MGMT_TYPE = comms.SCADA_MGMT_TYPE
local println = util.println
-- retry time constants in ms
-- local INITIAL_WAIT = 1500
-- local RETRY_PERIOD = 1000
@@ -69,6 +69,7 @@ function pocket.new_session(id, s_addr, in_queue, out_queue, timeout)
local function _close()
self.conn_watchdog.cancel()
self.connected = false
iocontrol.fp_pkt_disconnected(id)
end
-- send a CAPI packet
@@ -140,6 +141,8 @@ function pocket.new_session(id, s_addr, in_queue, out_queue, timeout)
-- log.debug(log_header .. "PKT RTT = " .. self.last_rtt .. "ms")
-- log.debug(log_header .. "PKT TT = " .. (srv_now - api_send) .. "ms")
iocontrol.fp_pkt_rtt(id, self.last_rtt)
else
log.debug(log_header .. "SCADA keep alive packet length mismatch")
end
@@ -172,7 +175,6 @@ function pocket.new_session(id, s_addr, in_queue, out_queue, timeout)
function public.close()
_close()
_send_mgmt(SCADA_MGMT_TYPE.CLOSE, {})
println("connection to pocket session " .. id .. " closed by server")
log.info(log_header .. "session closed by server")
end
@@ -211,7 +213,6 @@ function pocket.new_session(id, s_addr, in_queue, out_queue, timeout)
-- exit if connection was closed
if not self.connected then
println("connection to pocket session " .. id .. " closed by remote host")
log.info(log_header .. "session closed by remote host")
return self.connected
end

View File

@@ -2,269 +2,25 @@
-- Alarm Sounder
--
local audio = require("scada-common.audio")
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 = {}
-- note: max samples = 0x20000 (128 * 1024 samples)
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 _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 = { {}, {}, {}, {} }
stream = audio.new_stream()
}
-- 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
else return true end
end
-- initialize the annunciator alarm system
@@ -273,23 +29,10 @@ end
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
alarm_ctl.stream.stop()
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()
audio.generate_tones()
end
-- reconnect the speaker peripheral
@@ -297,173 +40,40 @@ end
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
alarm_ctl.stream.stop()
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 }
-- set alarm tones
---@param states table alarm tone commands from supervisor
function sounder.set(states)
-- set tone states
for id = 1, #states do alarm_ctl.stream.set_active(id, states[id]) end
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
-- re-compute output if needed, then play audio if available
if alarm_ctl.stream.is_recompute_needed() then alarm_ctl.stream.compute_buffer() end
if alarm_ctl.stream.has_next_block() 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()
alarm_ctl.stream.stop()
end
-- continue audio on buffer empty
---@return boolean success successfully added buffer to audio output
function sounder.continue()
local success = false
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
if alarm_ctl.speaker ~= nil and alarm_ctl.stream.has_next_block() then
success = alarm_ctl.speaker.playAudio(alarm_ctl.stream.get_next_block(), alarm_ctl.volume)
if not success then log.error("SOUNDER: error playing audio") end
end
end
log.debug("SOUNDER: power rescale test took " .. (util.time_ms() - start) .. "ms")
return success
end
--#endregion
return sounder

View File

@@ -4,6 +4,7 @@
require("/initenv").init_env()
local comms = require("scada-common.comms")
local crash = require("scada-common.crash")
local log = require("scada-common.log")
local network = require("scada-common.network")
@@ -21,7 +22,7 @@ local sounder = require("coordinator.sounder")
local apisessions = require("coordinator.session.apisessions")
local COORDINATOR_VERSION = "v0.17.1"
local COORDINATOR_VERSION = "v1.0.1"
local println = util.println
local println_ts = util.println_ts
@@ -30,7 +31,6 @@ 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
local log_crypto = coordinator.log_crypto
----------------------------------------
@@ -80,8 +80,11 @@ local function main()
-- mount connected devices
ppm.mount_all()
-- report versions/init fp PSIL
iocontrol.init_fp(COORDINATOR_VERSION, comms.version)
-- setup monitors
local configured, monitors = coordinator.configure_monitors(config.NUM_UNITS)
local configured, monitors = coordinator.configure_monitors(config.NUM_UNITS, config.DISABLE_FLOW_VIEW == true)
if not configured or monitors == nil then
println("startup> monitor setup failed")
log.fatal("monitor configuration failed")
@@ -89,6 +92,7 @@ local function main()
end
-- init renderer
renderer.legacy_disable_flow_view(config.DISABLE_FLOW_VIEW == true)
renderer.set_displays(monitors)
renderer.init_displays()
@@ -96,6 +100,10 @@ local function main()
println("startup> main display must be 8 blocks wide")
log.fatal("main display not wide enough")
return
elseif (config.DISABLE_FLOW_VIEW ~= true) and not renderer.validate_flow_display_width() then
println("startup> flow display must be 8 blocks wide")
log.fatal("flow 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")
@@ -127,6 +135,7 @@ local function main()
sounder.init(speaker, config.SOUNDER_VOLUME)
log_boot("tone generation took " .. (util.time_ms() - sounder_start) .. "ms")
log_sys("annunciator alarm configured")
iocontrol.fp_has_speaker(true)
end
----------------------------------------
@@ -148,6 +157,7 @@ local function main()
return
else
log_comms("wireless modem connected")
iocontrol.fp_has_modem(true)
end
-- create connection watchdog
@@ -157,8 +167,8 @@ local function main()
-- create network interface then setup comms
local nic = network.nic(modem)
local coord_comms = coordinator.comms(COORDINATOR_VERSION, nic, config.CRD_CHANNEL, config.SVR_CHANNEL,
config.PKT_CHANNEL, config.TRUSTED_RANGE, conn_watchdog)
local coord_comms = coordinator.comms(COORDINATOR_VERSION, nic, config.NUM_UNITS, config.CRD_CHANNEL,
config.SVR_CHANNEL, config.PKT_CHANNEL, config.TRUSTED_RANGE, conn_watchdog)
log.debug("startup> comms init")
log_comms("comms initialized")
@@ -167,76 +177,54 @@ local function main()
local loop_clock = util.new_clock(MAIN_CLOCK)
----------------------------------------
-- connect to the supervisor
-- start front panel & UI start function
----------------------------------------
-- 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.SVR_CHANNEL)
log_graphics("starting front panel UI...")
-- 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")
local fp_ok, fp_message = pcall(renderer.start_fp)
if not fp_ok then
renderer.close_fp()
log_graphics(util.c("front panel UI error: ", fp_message))
println_ts("front panel UI creation failed")
log.fatal(util.c("front panel GUI render failed with error ", fp_message))
return
else
log_sys("supervisor connected, proceeding to UI start")
end
else log_graphics("front panel ready") end
----------------------------------------
-- start the UI
----------------------------------------
-- start up the UI
-- start up the main UI
---@return boolean ui_ok started ok
local function init_start_ui()
log_graphics("starting UI...")
local function start_main_ui()
log_graphics("starting main UI...")
local draw_start = util.time_ms()
local ui_ok, message = pcall(renderer.start_ui)
local ui_ok, ui_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))
log_graphics(util.c("main UI error: ", ui_message))
log.fatal(util.c("main GUI render failed with error ", ui_message))
else
log_graphics("first UI draw took " .. (util.time_ms() - draw_start) .. "ms")
-- start clock
loop_clock.start()
log_graphics("main UI draw took " .. (util.time_ms() - draw_start) .. "ms")
end
return ui_ok
end
local ui_ok = init_start_ui()
----------------------------------------
-- main event loop
----------------------------------------
local link_failed = false
local ui_ok = true
local date_format = util.trinary(config.TIME_24_HOUR, "%X \x04 %A, %B %d %Y", "%r \x04 %A, %B %d %Y")
if ui_ok then
-- start connection watchdog
conn_watchdog.feed()
log.debug("startup> conn watchdog started")
-- start clock
loop_clock.start()
log_sys("system started successfully")
end
log_sys("system started successfully")
-- main event loop
while ui_ok do
while true do
local event, param1, param2, param3, param4, param5 = util.pull_event()
-- handle event
@@ -250,31 +238,32 @@ local function main()
if nic.is_modem(device) then
nic.disconnect()
log_sys("comms modem disconnected")
println_ts("wireless modem disconnected!")
-- close out UI
renderer.close_ui()
local other_modem = ppm.get_wireless_modem()
if other_modem then
log_sys("found another wireless modem, using it for comms")
nic.connect(other_modem)
else
-- close out main UI
renderer.close_ui()
-- alert user to status
log_sys("awaiting comms modem reconnect...")
-- alert user to status
log_sys("awaiting comms modem reconnect...")
iocontrol.fp_has_modem(false)
end
else
log_sys("non-comms modem disconnected")
end
elseif type == "monitor" then
if renderer.is_monitor_used(device) then
---@todo will be handled properly in #249
-- "halt and catch fire" style handling
local msg = "lost a configured monitor, system will now exit"
println_ts(msg)
log_sys(msg)
break
if renderer.handle_disconnect(device) then
log_sys("lost a configured monitor")
else
log_sys("lost unused monitor, ignoring")
log_sys("lost an unused monitor")
end
elseif type == "speaker" then
local msg = "lost alarm sounder speaker"
println_ts(msg)
log_sys(msg)
log_sys("lost alarm sounder speaker")
iocontrol.fp_has_speaker(false)
end
end
elseif event == "peripheral" then
@@ -282,33 +271,50 @@ local function main()
if type ~= nil and device ~= nil then
if type == "modem" then
if device.isWireless() then
if device.isWireless() and not nic.is_connected() then
-- reconnected modem
nic.connect(device)
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()
nic.connect(device)
iocontrol.fp_has_modem(true)
elseif device.isWireless() then
log.info("unused wireless modem reconnected")
else
log_sys("wired modem reconnected")
end
-- elseif type == "monitor" then
---@todo will be handled properly in #249
-- not supported, system will exit on loss of in-use monitors
elseif type == "monitor" then
if renderer.handle_reconnect(param1, device) then
log_sys(util.c("configured monitor ", param1, " reconnected"))
else
log_sys(util.c("unused monitor ", param1, " connected"))
end
elseif type == "speaker" then
local msg = "alarm sounder speaker reconnected"
println_ts(msg)
log_sys(msg)
log_sys("alarm sounder speaker reconnected")
sounder.reconnect(device)
iocontrol.fp_has_speaker(true)
end
end
elseif event == "timer" then
if loop_clock.is_clock(param1) then
-- main loop tick
-- toggle heartbeat
iocontrol.heartbeat()
-- maintain connection
if nic.is_connected() then
local ok, start_ui = coord_comms.try_connect()
if not ok then
link_failed = true
log_sys("supervisor connection failed, shutting down...")
log.fatal("failed to connect to supervisor")
break
elseif start_ui then
log_sys("supervisor connected, proceeding to main UI start")
ui_ok = start_main_ui()
if not ui_ok then break end
end
end
-- iterate sessions
apisessions.iterate_all()
@@ -316,25 +322,19 @@ local function main()
apisessions.free_all_closed()
-- update date and time string for main display
iocontrol.get_db().facility.ps.publish("date_time", os.date(date_format))
if coord_comms.is_linked() then
iocontrol.get_db().facility.ps.publish("date_time", os.date(date_format))
end
loop_clock.start()
elseif conn_watchdog.is_timer(param1) then
-- supervisor watchdog timeout
local msg = "supervisor server timeout"
log_comms(msg)
println_ts(msg)
log_comms("supervisor server timeout")
-- close connection, UI, and stop sounder
-- close connection, main UI, and stop sounder
coord_comms.close()
renderer.close_ui()
sounder.stop()
if nic.connected() 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
@@ -347,25 +347,19 @@ local function main()
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
-- handle then check if it was a disconnect
if coord_comms.handle_packet(packet) then
log_comms("supervisor closed connection")
-- close connection, UI, and stop sounder
-- close connection, main UI, and stop sounder
coord_comms.close()
renderer.close_ui()
sounder.stop()
if nic.connected() 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
elseif event == "monitor_touch" or event == "mouse_click" or event == "mouse_up" or
event == "mouse_drag" or event == "mouse_scroll" then
-- handle a mouse event
renderer.handle_mouse(core.events.new_mouse_event(event, param1, param2, param3))
elseif event == "speaker_audio_empty" then
-- handle speaker buffer emptied
@@ -374,10 +368,17 @@ local function main()
-- 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...")
-- handle supervisor connection
coord_comms.try_connect(true)
if coord_comms.is_linked() then
log_comms("terminate requested, closing supervisor connection...")
else link_failed = true end
coord_comms.close()
log_comms("supervisor connection closed")
-- handle API sessions
log_comms("closing api sessions...")
apisessions.close_all()
log_comms("api sessions closed")
@@ -386,15 +387,20 @@ local function main()
end
renderer.close_ui()
renderer.close_fp()
sounder.stop()
log_sys("system shutdown")
if link_failed then println_ts("failed to connect to supervisor") end
if not ui_ok then println_ts("main UI creation failed") end
println_ts("exited")
log.info("exited")
end
if not xpcall(main, crash.handler) then
pcall(renderer.close_ui)
pcall(renderer.close_fp)
pcall(sounder.stop)
crash.exit()
else

View File

@@ -83,9 +83,7 @@ local function new_view(root, x, y, data, ps, id)
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
else return 0 end
end
charge.register(ps, "energy_fill", charge.update)

View File

@@ -0,0 +1,48 @@
--
-- Pocket Connection Entry
--
local iocontrol = require("coordinator.iocontrol")
local core = require("graphics.core")
local Div = require("graphics.elements.div")
local TextBox = require("graphics.elements.textbox")
local DataIndicator = require("graphics.elements.indicators.data")
local TEXT_ALIGN = core.TEXT_ALIGN
local cpair = core.cpair
-- create a pocket list entry
---@param parent graphics_element parent
---@param id integer PKT session ID
local function init(parent, id)
local ps = iocontrol.get_db().fp.ps
-- root div
local root = Div{parent=parent,x=2,y=2,height=4,width=parent.get_width()-2,hidden=true}
local entry = Div{parent=root,x=2,y=1,height=3,fg_bg=cpair(colors.black,colors.white)}
local ps_prefix = "pkt_" .. id .. "_"
TextBox{parent=entry,x=1,y=1,text="",width=8,height=1,fg_bg=cpair(colors.black,colors.lightGray)}
local pkt_addr = TextBox{parent=entry,x=1,y=2,text="@ C ??",alignment=TEXT_ALIGN.CENTER,width=8,height=1,fg_bg=cpair(colors.black,colors.lightGray),nav_active=cpair(colors.gray,colors.black)}
TextBox{parent=entry,x=1,y=3,text="",width=8,height=1,fg_bg=cpair(colors.black,colors.lightGray)}
pkt_addr.register(ps, ps_prefix .. "addr", pkt_addr.set_value)
TextBox{parent=entry,x=10,y=2,text="FW:",width=3,height=1}
local pkt_fw_v = TextBox{parent=entry,x=14,y=2,text=" ------- ",width=20,height=1,fg_bg=cpair(colors.lightGray,colors.white)}
pkt_fw_v.register(ps, ps_prefix .. "fw", pkt_fw_v.set_value)
TextBox{parent=entry,x=35,y=2,text="RTT:",width=4,height=1}
local pkt_rtt = DataIndicator{parent=entry,x=40,y=2,label="",unit="",format="%5d",value=0,width=5,fg_bg=cpair(colors.lightGray,colors.white)}
TextBox{parent=entry,x=46,y=2,text="ms",width=4,height=1,fg_bg=cpair(colors.lightGray,colors.white)}
pkt_rtt.register(ps, ps_prefix .. "rtt", pkt_rtt.update)
pkt_rtt.register(ps, ps_prefix .. "rtt_color", pkt_rtt.recolor)
return root
end
return init

View File

@@ -15,8 +15,10 @@ 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 StateIndicator = require("graphics.elements.indicators.state")
local TriIndicatorLight = require("graphics.elements.indicators.trilight")
local Checkbox = require("graphics.elements.controls.checkbox")
local HazardButton = require("graphics.elements.controls.hazard_button")
local RadioButton = require("graphics.elements.controls.radio_button")
local SpinboxNumeric = require("graphics.elements.controls.spinbox_numeric")
@@ -26,6 +28,16 @@ local TEXT_ALIGN = core.TEXT_ALIGN
local cpair = core.cpair
local border = core.border
local bw_fg_bg = style.bw_fg_bg
local lu_cpair = style.lu_colors
local hzd_fg_bg = style.hzd_fg_bg
local dis_colors = style.dis_colors
local ind_grn = style.ind_grn
local ind_yel = style.ind_yel
local ind_red = style.ind_red
local ind_wht = style.ind_wht
local period = core.flasher.PERIOD
-- new process control view
@@ -38,12 +50,7 @@ local function new_view(root, x, y)
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 main = Div{parent=root,width=128,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}
@@ -51,20 +58,22 @@ local function new_view(root, x, y)
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 all_ok = IndicatorLight{parent=main,y=5,label="Unit Systems Online",colors=ind_grn}
local rad_mon = TriIndicatorLight{parent=main,label="Radiation Monitor",c1=colors.gray,c2=colors.yellow,c3=colors.green}
local ind_mat = IndicatorLight{parent=main,label="Induction Matrix",colors=ind_grn}
local sps = IndicatorLight{parent=main,label="SPS Connected",colors=ind_grn}
all_ok.register(facility.ps, "all_sys_ok", all_ok.update)
ind_mat.register(facility.induction_ps_tbl[1], "computed_status", function (status) ind_mat.update(status > 1) end)
rad_mon.register(facility.ps, "rad_computed_status", rad_mon.update)
ind_mat.register(facility.induction_ps_tbl[1], "computed_status", function (status) ind_mat.update(status > 1) end)
sps.register(facility.sps_ps_tbl[1], "computed_status", function (status) sps.update(status > 1) end)
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)}
local auto_ready = IndicatorLight{parent=main,label="Configured Units Ready",colors=ind_grn}
local auto_act = IndicatorLight{parent=main,label="Process Active",colors=ind_grn}
local auto_ramp = IndicatorLight{parent=main,label="Process Ramping",colors=ind_wht,flash=true,period=period.BLINK_250_MS}
local auto_sat = IndicatorLight{parent=main,label="Min/Max Burn Rate",colors=ind_yel}
auto_ready.register(facility.ps, "auto_ready", auto_ready.update)
auto_act.register(facility.ps, "auto_active", auto_act.update)
@@ -73,12 +82,12 @@ local function new_view(root, x, y)
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}
local auto_scram = IndicatorLight{parent=main,label="Automatic SCRAM",colors=ind_red,flash=true,period=period.BLINK_250_MS}
local matrix_dc = IndicatorLight{parent=main,label="Matrix Disconnected",colors=ind_yel,flash=true,period=period.BLINK_500_MS}
local matrix_fill = IndicatorLight{parent=main,label="Matrix Charge High",colors=ind_red,flash=true,period=period.BLINK_500_MS}
local unit_crit = IndicatorLight{parent=main,label="Unit Critical Alarm",colors=ind_red,flash=true,period=period.BLINK_250_MS}
local fac_rad_h = IndicatorLight{parent=main,label="Facility Radiation High",colors=ind_red,flash=true,period=period.BLINK_250_MS}
local gen_fault = IndicatorLight{parent=main,label="Gen. Control Fault",colors=ind_yel,flash=true,period=period.BLINK_500_MS}
auto_scram.register(facility.ps, "auto_scram", auto_scram.update)
matrix_dc.register(facility.ps, "as_matrix_dc", matrix_dc.update)
@@ -99,7 +108,7 @@ local function new_view(root, x, y)
-- process control --
---------------------
local proc = Div{parent=main,width=78,height=24,x=27,y=1}
local proc = Div{parent=main,width=103,height=24,x=27,y=1}
-----------------------------
-- process control targets --
@@ -148,46 +157,77 @@ local function new_view(root, x, y)
local rate_limits = {}
for i = 1, facility.num_units do
local unit = units[i] ---@type ioctl_unit
for i = 1, 4 do
local unit
local tag_fg_bg = cpair(colors.gray,colors.white)
local lim_fg_bg = cpair(colors.lightGray,colors.white)
local ctl_fg = colors.lightGray
local cur_fg_bg = cpair(colors.lightGray,colors.white)
local cur_lu = colors.lightGray
if i <= facility.num_units then
unit = units[i] ---@type ioctl_unit
tag_fg_bg = cpair(colors.black,colors.lightBlue)
lim_fg_bg = bw_fg_bg
ctl_fg = colors.gray
cur_fg_bg = cpair(colors.black,colors.brown)
cur_lu = colors.black
end
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)}
local unit_tag = Div{parent=limit_div,x=1,y=_y,width=8,height=4,fg_bg=tag_fg_bg}
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}
local lim_ctl = Div{parent=limit_div,x=9,y=_y,width=14,height=3,fg_bg=cpair(ctl_fg,colors.white)}
local lim = 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=lim_fg_bg}
TextBox{parent=lim_ctl,x=9,y=2,text="mB/t",width=4,height=1}
rate_limits[i].register(unit.unit_ps, "max_burn", rate_limits[i].set_max)
rate_limits[i].register(unit.unit_ps, "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(cur_lu,cur_lu),width=14,fg_bg=cur_fg_bg}
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)}
if i <= facility.num_units then
rate_limits[i] = lim
rate_limits[i].register(unit.unit_ps, "max_burn", rate_limits[i].set_max)
rate_limits[i].register(unit.unit_ps, "burn_limit", rate_limits[i].set_value)
cur_burn.register(unit.unit_ps, "act_burn_rate", cur_burn.update)
cur_burn.register(unit.unit_ps, "act_burn_rate", cur_burn.update)
else
lim.disable()
end
end
-------------------
-- unit statuses --
-------------------
local stat_div = Div{parent=proc,width=38,height=19,x=57,y=6}
local stat_div = Div{parent=proc,width=22,height=24,x=57,y=6}
for i = 1, facility.num_units do
local unit = units[i] ---@type ioctl_unit
for i = 1, 4 do
local tag_fg_bg = cpair(colors.gray, colors.white)
local ind_fg_bg = cpair(colors.lightGray, colors.white)
local ind_off = colors.lightGray
if i <= facility.num_units then
tag_fg_bg = cpair(colors.black, colors.cyan)
ind_fg_bg = bw_fg_bg
ind_off = colors.gray
end
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)}
local unit_tag = Div{parent=stat_div,x=1,y=_y,width=8,height=4,fg_bg=tag_fg_bg}
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}
local lights = Div{parent=stat_div,x=9,y=_y,width=14,height=4,fg_bg=ind_fg_bg}
local ready = IndicatorLight{parent=lights,x=2,y=2,label="Ready",colors=cpair(colors.green,ind_off)}
local degraded = IndicatorLight{parent=lights,x=2,y=3,label="Degraded",colors=cpair(colors.red,ind_off),flash=true,period=period.BLINK_250_MS}
ready.register(unit.unit_ps, "U_AutoReady", ready.update)
degraded.register(unit.unit_ps, "U_AutoDegraded", degraded.update)
if i <= facility.num_units then
local unit = units[i] ---@type ioctl_unit
ready.register(unit.unit_ps, "U_AutoReady", ready.update)
degraded.register(unit.unit_ps, "U_AutoDegraded", degraded.update)
end
end
-------------------------
@@ -195,7 +235,7 @@ local function new_view(root, x, y)
-------------------------
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}
local mode = RadioButton{parent=proc,x=34,y=1,options=ctl_opts,callback=function()end,radio_colors=cpair(colors.white,colors.black),radio_bg=colors.purple}
mode.register(facility.ps, "process_mode", mode.set_value)
@@ -261,6 +301,60 @@ local function new_view(root, x, y)
for i = 1, #rate_limits do rate_limits[i].enable() end
end
end)
------------------------------
-- waste production control --
------------------------------
local waste_status = Div{parent=proc,width=24,height=4,x=57,y=1,}
for i = 1, facility.num_units do
local unit = units[i] ---@type ioctl_unit
TextBox{parent=waste_status,y=i,text="U"..i.." Waste",width=8,height=1}
local a_waste = IndicatorLight{parent=waste_status,x=10,y=i,label="Auto",colors=ind_wht}
local waste_m = StateIndicator{parent=waste_status,x=17,y=i,states=style.waste.states_abbrv,value=1,min_width=6}
a_waste.register(unit.unit_ps, "U_AutoWaste", a_waste.update)
waste_m.register(unit.unit_ps, "U_WasteProduct", waste_m.update)
end
local waste_sel = Div{parent=proc,width=21,height=24,x=81,y=1}
TextBox{parent=waste_sel,text=" ",width=21,height=1,x=1,y=1,fg_bg=cpair(colors.black,colors.brown)}
TextBox{parent=waste_sel,text="WASTE PRODUCTION",alignment=TEXT_ALIGN.CENTER,width=21,height=1,x=1,y=2,fg_bg=cpair(colors.lightGray,colors.brown)}
local rect = Rectangle{parent=waste_sel,border=border(1,colors.brown,true),width=21,height=22,x=1,y=3}
local status = StateIndicator{parent=rect,x=2,y=1,states=style.waste.states,value=1,min_width=17}
status.register(facility.ps, "current_waste_product", status.update)
local waste_prod = RadioButton{parent=rect,x=2,y=3,options=style.waste.options,callback=process.set_process_waste,radio_colors=cpair(colors.white,colors.black),radio_bg=colors.brown}
local pu_fallback = Checkbox{parent=rect,x=2,y=7,label="Pu Fallback",callback=process.set_pu_fallback,box_fg_bg=cpair(colors.green,colors.black)}
waste_prod.register(facility.ps, "process_waste_product", waste_prod.set_value)
pu_fallback.register(facility.ps, "process_pu_fallback", pu_fallback.set_value)
local fb_active = IndicatorLight{parent=rect,x=2,y=9,label="Fallback Active",colors=ind_wht}
fb_active.register(facility.ps, "pu_fallback_active", fb_active.update)
TextBox{parent=rect,x=2,y=11,text="Plutonium Rate",height=1,width=17,fg_bg=style.label}
local pu_rate = DataIndicator{parent=rect,x=2,label="",unit="mB/t",format="%12.2f",value=0,lu_colors=lu_cpair,fg_bg=bw_fg_bg,width=17}
TextBox{parent=rect,x=2,y=14,text="Polonium Rate",height=1,width=17,fg_bg=style.label}
local po_rate = DataIndicator{parent=rect,x=2,label="",unit="mB/t",format="%12.2f",value=0,lu_colors=lu_cpair,fg_bg=bw_fg_bg,width=17}
TextBox{parent=rect,x=2,y=17,text="Antimatter Rate",height=1,width=17,fg_bg=style.label}
local am_rate = DataIndicator{parent=rect,x=2,label="",unit="\xb5B/t",format="%12d",value=0,lu_colors=lu_cpair,fg_bg=bw_fg_bg,width=17}
pu_rate.register(facility.ps, "pu_rate", pu_rate.update)
po_rate.register(facility.ps, "po_rate", po_rate.update)
am_rate.register(facility.ps, "am_rate", am_rate.update)
local sna_count = DataIndicator{parent=rect,x=2,y=20,label="Linked SNAs:",format="%4d",value=0,lu_colors=lu_cpair,width=17}
sna_count.register(facility.ps, "sna_count", sna_count.update)
end
return new_view

View File

@@ -31,7 +31,7 @@ local function new_view(root, x, y, ps)
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}
status.register(ps, "computed_status", status.update)
prod_rate.register(ps, "prod_rate", function (val) prod_rate.update(util.joules_to_fe(val)) end)
prod_rate.register(ps, "steam_input_rate", function (val) prod_rate.update(util.joules_to_fe(val)) end)
flow_rate.register(ps, "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}

View File

@@ -31,30 +31,16 @@ local TEXT_ALIGN = core.TEXT_ALIGN
local cpair = core.cpair
local border = core.border
local period = core.flasher.PERIOD
local bw_fg_bg = style.bw_fg_bg
local lu_cpair = style.lu_colors
local hzd_fg_bg = style.hzd_fg_bg
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)
}
}
local ind_grn = style.ind_grn
local ind_yel = style.ind_yel
local ind_red = style.ind_red
local ind_wht = style.ind_wht
local period = core.flasher.PERIOD
-- create a unit view
---@param parent graphics_element parent
@@ -62,18 +48,17 @@ local waste_opts = {
local function init(parent, id)
local unit = iocontrol.get_db().units[id] ---@type ioctl_unit
local f_ps = iocontrol.get_db().facility.ps
local main = Div{parent=parent,x=1,y=1}
if unit == nil then return main end
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 --
-----------------------------
@@ -160,7 +145,7 @@ local function init(parent, id)
-- 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 plc_hbeat = IndicatorLight{parent=annunciator,label="PLC Heartbeat",colors=ind_wht}
local rad_mon = TriIndicatorLight{parent=annunciator,label="Radiation Monitor",c1=colors.gray,c2=colors.yellow,c3=colors.green}
plc_online.register(u_ps, "PLCOnline", plc_online.update)
@@ -170,25 +155,25 @@ local function init(parent, id)
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)}
local r_active = IndicatorLight{parent=annunciator,label="Active",colors=ind_grn}
local r_auto = IndicatorLight{parent=annunciator,label="Automatic Control",colors=ind_wht}
r_active.register(u_ps, "status", r_active.update)
r_auto.register(u_ps, "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)}
local r_scram = IndicatorLight{parent=annunciator,label="Reactor SCRAM",colors=ind_red}
local r_mscrm = IndicatorLight{parent=annunciator,label="Manual Reactor SCRAM",colors=ind_red}
local r_ascrm = IndicatorLight{parent=annunciator,label="Auto Reactor SCRAM",colors=ind_red}
local rad_wrn = IndicatorLight{parent=annunciator,label="Radiation Warning",colors=ind_yel}
local r_rtrip = IndicatorLight{parent=annunciator,label="RCP Trip",colors=ind_red}
local r_cflow = IndicatorLight{parent=annunciator,label="RCS Flow Low",colors=ind_yel}
local r_clow = IndicatorLight{parent=annunciator,label="Coolant Level Low",colors=ind_yel}
local r_temp = IndicatorLight{parent=annunciator,label="Reactor Temp. High",colors=ind_red}
local r_rhdt = IndicatorLight{parent=annunciator,label="Reactor High Delta T",colors=ind_yel}
local r_firl = IndicatorLight{parent=annunciator,label="Fuel Input Rate Low",colors=ind_yel}
local r_wloc = IndicatorLight{parent=annunciator,label="Waste Line Occlusion",colors=ind_yel}
local r_hsrt = IndicatorLight{parent=annunciator,label="Startup Rate High",colors=ind_yel}
r_scram.register(u_ps, "ReactorSCRAM", r_scram.update)
r_mscrm.register(u_ps, "ManualReactorSCRAM", r_mscrm.update)
@@ -209,15 +194,15 @@ local function init(parent, id)
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_trp = IndicatorLight{parent=rps_annunc,label="RPS Trip",colors=ind_red,flash=true,period=period.BLINK_250_MS}
local rps_dmg = IndicatorLight{parent=rps_annunc,label="Damage Level High",colors=ind_red,flash=true,period=period.BLINK_250_MS}
local rps_exh = IndicatorLight{parent=rps_annunc,label="Excess Heated Coolant",colors=ind_yel}
local rps_exw = IndicatorLight{parent=rps_annunc,label="Excess Waste",colors=ind_yel}
local rps_tmp = IndicatorLight{parent=rps_annunc,label="Core Temperature High",colors=ind_red,flash=true,period=period.BLINK_250_MS}
local rps_nof = IndicatorLight{parent=rps_annunc,label="No Fuel",colors=ind_yel}
local rps_loc = IndicatorLight{parent=rps_annunc,label="Coolant Level Low Low",colors=ind_yel}
local rps_flt = IndicatorLight{parent=rps_annunc,label="PPM Fault",colors=ind_yel,flash=true,period=period.BLINK_500_MS}
local rps_tmo = IndicatorLight{parent=rps_annunc,label="Connection Timeout",colors=ind_yel,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}
rps_trp.register(u_ps, "rps_tripped", rps_trp.update)
@@ -238,12 +223,12 @@ local function init(parent, id)
local rcs_annunc = Div{parent=rcs,width=27,height=22,x=3,y=1}
local rcs_tags = Div{parent=rcs,width=2,height=16,x=1,y=7}
local c_flt = IndicatorLight{parent=rcs_annunc,label="RCS Hardware Fault",colors=cpair(colors.yellow,colors.gray)}
local c_flt = IndicatorLight{parent=rcs_annunc,label="RCS Hardware Fault",colors=ind_yel}
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_cfm = IndicatorLight{parent=rcs_annunc,label="Coolant Feed Mismatch",colors=ind_yel}
local c_brm = IndicatorLight{parent=rcs_annunc,label="Boil Rate Mismatch",colors=ind_yel}
local c_sfm = IndicatorLight{parent=rcs_annunc,label="Steam Feed Mismatch",colors=ind_yel}
local c_mwrf = IndicatorLight{parent=rcs_annunc,label="Max Water Return Feed",colors=ind_yel}
c_flt.register(u_ps, "RCSFault", c_flt.update)
c_emg.register(u_ps, "EmergencyCoolant", c_emg.update)
@@ -266,11 +251,11 @@ local function init(parent, id)
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)}
local b1_wll = IndicatorLight{parent=rcs_annunc,label="Water Level Low",colors=ind_red}
b1_wll.register(b_ps[1], "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)}
local b1_hr = IndicatorLight{parent=rcs_annunc,label="Heating Rate Low",colors=ind_yel}
b1_hr.register(b_ps[1], "HeatingRateLow", b1_hr.update)
end
if unit.num_boilers > 1 then
@@ -282,11 +267,11 @@ local function init(parent, id)
end
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)}
local b2_wll = IndicatorLight{parent=rcs_annunc,label="Water Level Low",colors=ind_red}
b2_wll.register(b_ps[2], "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)}
local b2_hr = IndicatorLight{parent=rcs_annunc,label="Heating Rate Low",colors=ind_yel}
b2_hr.register(b_ps[2], "HeatingRateLow", b2_hr.update)
end
@@ -299,15 +284,15 @@ local function init(parent, id)
t1_sdo.register(t_ps[1], "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)}
local t1_tos = IndicatorLight{parent=rcs_annunc,label="Turbine Over Speed",colors=ind_red}
t1_tos.register(t_ps[1], "TurbineOverSpeed", t1_tos.update)
TextBox{parent=rcs_tags,text="T1",width=2,height=1,fg_bg=bw_fg_bg}
local t1_gtrp = IndicatorLight{parent=rcs_annunc,label="Generator Trip",colors=cpair(colors.yellow,colors.gray),flash=true,period=period.BLINK_250_MS}
local t1_gtrp = IndicatorLight{parent=rcs_annunc,label="Generator Trip",colors=ind_yel,flash=true,period=period.BLINK_250_MS}
t1_gtrp.register(t_ps[1], "GeneratorTrip", t1_gtrp.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}
local t1_trp = IndicatorLight{parent=rcs_annunc,label="Turbine Trip",colors=ind_red,flash=true,period=period.BLINK_250_MS}
t1_trp.register(t_ps[1], "TurbineTrip", t1_trp.update)
if unit.num_turbines > 1 then
@@ -320,15 +305,15 @@ local function init(parent, id)
t2_sdo.register(t_ps[2], "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)}
local t2_tos = IndicatorLight{parent=rcs_annunc,label="Turbine Over Speed",colors=ind_red}
t2_tos.register(t_ps[2], "TurbineOverSpeed", t2_tos.update)
TextBox{parent=rcs_tags,text="T2",width=2,height=1,fg_bg=bw_fg_bg}
local t2_gtrp = IndicatorLight{parent=rcs_annunc,label="Generator Trip",colors=cpair(colors.yellow,colors.gray),flash=true,period=period.BLINK_250_MS}
local t2_gtrp = IndicatorLight{parent=rcs_annunc,label="Generator Trip",colors=ind_yel,flash=true,period=period.BLINK_250_MS}
t2_gtrp.register(t_ps[2], "GeneratorTrip", t2_gtrp.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}
local t2_trp = IndicatorLight{parent=rcs_annunc,label="Turbine Trip",colors=ind_red,flash=true,period=period.BLINK_250_MS}
t2_trp.register(t_ps[2], "TurbineTrip", t2_trp.update)
end
@@ -340,15 +325,15 @@ local function init(parent, id)
t3_sdo.register(t_ps[3], "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)}
local t3_tos = IndicatorLight{parent=rcs_annunc,label="Turbine Over Speed",colors=ind_red}
t3_tos.register(t_ps[3], "TurbineOverSpeed", t3_tos.update)
TextBox{parent=rcs_tags,text="T3",width=2,height=1,fg_bg=bw_fg_bg}
local t3_gtrp = IndicatorLight{parent=rcs_annunc,label="Generator Trip",colors=cpair(colors.yellow,colors.gray),flash=true,period=period.BLINK_250_MS}
local t3_gtrp = IndicatorLight{parent=rcs_annunc,label="Generator Trip",colors=ind_yel,flash=true,period=period.BLINK_250_MS}
t3_gtrp.register(t_ps[3], "GeneratorTrip", t3_gtrp.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}
local t3_trp = IndicatorLight{parent=rcs_annunc,label="Turbine Trip",colors=ind_red,flash=true,period=period.BLINK_250_MS}
t3_trp.register(t_ps[3], "TurbineTrip", t3_trp.update)
end
@@ -363,7 +348,7 @@ local function init(parent, id)
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}
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=style.wh_gray,dis_fg_bg=dis_colors,callback=set_burn}
burn_rate.register(u_ps, "burn_rate", burn_rate.set_value)
burn_rate.register(u_ps, "max_burn", burn_rate.set_max)
@@ -398,7 +383,7 @@ local function init(parent, id)
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}
local waste_mode = MultiButton{parent=waste_div,x=1,y=1,options=style.waste.unit_opts,callback=unit.set_waste,min_width=6}
waste_mode.register(u_ps, "U_WasteMode", waste_mode.set_value)
@@ -495,7 +480,7 @@ local function init(parent, id)
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}
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=style.wh_gray,dis_fg_bg=cpair(colors.gray,colors.white),callback=set_group}
auto_div.line_break()
@@ -506,8 +491,8 @@ local function init(parent, id)
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}
local a_rdy = IndicatorLight{parent=auto_div,label="Ready",x=2,colors=ind_grn}
local a_stb = IndicatorLight{parent=auto_div,label="Standby",x=2,colors=ind_wht,flash=true,period=period.BLINK_1000_MS}
a_rdy.register(u_ps, "U_AutoReady", a_rdy.update)

View File

@@ -0,0 +1,225 @@
--
-- Basic Unit Flow Overview
--
local util = require("scada-common.util")
local style = require("coordinator.ui.style")
local core = require("graphics.core")
local Div = require("graphics.elements.div")
local PipeNetwork = require("graphics.elements.pipenet")
local TextBox = require("graphics.elements.textbox")
local Rectangle = require("graphics.elements.rectangle")
local DataIndicator = require("graphics.elements.indicators.data")
local IndicatorLight = require("graphics.elements.indicators.light")
local TriIndicatorLight = require("graphics.elements.indicators.trilight")
local TEXT_ALIGN = core.TEXT_ALIGN
local sprintf = util.sprintf
local cpair = core.cpair
local border = core.border
local pipe = core.pipe
local wh_gray = style.wh_gray
local bw_fg_bg = style.bw_fg_bg
local text_c = style.text_colors
local lu_c = style.lu_colors
local ind_grn = style.ind_grn
local ind_wht = style.ind_wht
-- make a new unit flow window
---@param parent graphics_element parent
---@param x integer top left x
---@param y integer top left y
---@param wide boolean whether to render wide version
---@param unit ioctl_unit unit database entry
local function make(parent, x, y, wide, unit)
local height = 16
local v_start = 1 + ((unit.unit_id - 1) * 5)
local prv_start = 1 + ((unit.unit_id - 1) * 3)
local v_fields = { "pu", "po", "pl", "am" }
local v_names = {
sprintf("PV%02d-PU", v_start),
sprintf("PV%02d-PO", v_start + 1),
sprintf("PV%02d-PL", v_start + 2),
sprintf("PV%02d-AM", v_start + 3),
sprintf("PRV%02d", prv_start),
sprintf("PRV%02d", prv_start + 1),
sprintf("PRV%02d", prv_start + 2)
}
assert(parent.get_height() >= (y + height), "flow display not of sufficient vertical resolution (add an additional row of monitors) " .. y .. "," .. parent.get_height())
local function _wide(a, b) return util.trinary(wide, a, b) end
-- bounding box div
local root = Div{parent=parent,x=x,y=y,width=_wide(136, 114),height=height}
local lg_gray = cpair(colors.lightGray, colors.gray)
------------------
-- COOLING LOOP --
------------------
local reactor = Rectangle{parent=root,x=1,y=1,border=border(1,colors.gray,true),width=19,height=5,fg_bg=wh_gray}
TextBox{parent=reactor,y=1,text="FISSION REACTOR",alignment=TEXT_ALIGN.CENTER,height=1}
TextBox{parent=reactor,y=3,text="UNIT #"..unit.unit_id,alignment=TEXT_ALIGN.CENTER,height=1}
TextBox{parent=root,x=19,y=2,text="\x1b \x80 \x1a",width=1,height=3,fg_bg=lg_gray}
TextBox{parent=root,x=3,y=5,text="\x19",width=1,height=1,fg_bg=lg_gray}
local rc_pipes = {}
local emc_x = 42 -- emergency coolant connection x point
if unit.num_boilers > 0 then
table.insert(rc_pipes, pipe(0, 1, _wide(28, 19), 1, colors.lightBlue, true))
table.insert(rc_pipes, pipe(0, 3, _wide(28, 19), 3, colors.orange, true))
table.insert(rc_pipes, pipe(_wide(46 ,39), 1, _wide(72,58), 1, colors.blue, true))
table.insert(rc_pipes, pipe(_wide(46,39), 3, _wide(72,58), 3, colors.white, true))
else
emc_x = 3
table.insert(rc_pipes, pipe(0, 1, _wide(72,58), 1, colors.blue, true))
table.insert(rc_pipes, pipe(0, 3, _wide(72,58), 3, colors.white, true))
end
if unit.has_tank then
table.insert(rc_pipes, pipe(emc_x, 1, emc_x, 0, colors.blue, true, true))
end
local prv_yo = math.max(3 - unit.num_turbines, 0)
for i = 1, unit.num_turbines do
local py = 2 * (i - 1) + prv_yo
table.insert(rc_pipes, pipe(_wide(92, 78), py, _wide(104, 83), py, colors.white, true))
end
PipeNetwork{parent=root,x=20,y=1,pipes=rc_pipes,bg=colors.lightGray}
if unit.num_boilers > 0 then
local cc_rate = DataIndicator{parent=root,x=_wide(25,22),y=5,lu_colors=lu_c,label="",unit="mB/t",format="%11.0f",value=0,commas=true,width=16,fg_bg=bw_fg_bg}
local hc_rate = DataIndicator{parent=root,x=_wide(25,22),y=3,lu_colors=lu_c,label="",unit="mB/t",format="%11.0f",value=0,commas=true,width=16,fg_bg=bw_fg_bg}
cc_rate.register(unit.unit_ps, "boil_sum", cc_rate.update)
hc_rate.register(unit.unit_ps, "heating_rate", hc_rate.update)
local boiler = Rectangle{parent=root,x=_wide(47,40),y=1,border=border(1, colors.gray, true),width=19,height=5,fg_bg=wh_gray}
TextBox{parent=boiler,y=1,text="THERMO-ELECTRIC",alignment=TEXT_ALIGN.CENTER,height=1}
TextBox{parent=boiler,y=3,text="BOILERS",alignment=TEXT_ALIGN.CENTER,height=1}
TextBox{parent=root,x=_wide(47,40),y=2,text="\x1b \x80 \x1a",width=1,height=3,fg_bg=lg_gray}
TextBox{parent=root,x=_wide(65,58),y=2,text="\x1b \x80 \x1a",width=1,height=3,fg_bg=lg_gray}
local wt_rate = DataIndicator{parent=root,x=_wide(71,61),y=3,lu_colors=lu_c,label="",unit="mB/t",format="%11.0f",value=0,commas=true,width=16,fg_bg=bw_fg_bg}
local st_rate = DataIndicator{parent=root,x=_wide(71,61),y=5,lu_colors=lu_c,label="",unit="mB/t",format="%11.0f",value=0,commas=true,width=16,fg_bg=bw_fg_bg}
wt_rate.register(unit.unit_ps, "turbine_flow_sum", wt_rate.update)
st_rate.register(unit.unit_ps, "boil_sum", st_rate.update)
else
local wt_rate = DataIndicator{parent=root,x=28,y=3,lu_colors=lu_c,label="",unit="mB/t",format="%11.0f",value=0,commas=true,width=16,fg_bg=bw_fg_bg}
local st_rate = DataIndicator{parent=root,x=28,y=5,lu_colors=lu_c,label="",unit="mB/t",format="%11.0f",value=0,commas=true,width=16,fg_bg=bw_fg_bg}
wt_rate.register(unit.unit_ps, "turbine_flow_sum", wt_rate.update)
st_rate.register(unit.unit_ps, "heating_rate", st_rate.update)
end
local turbine = Rectangle{parent=root,x=_wide(93,79),y=1,border=border(1, colors.gray, true),width=19,height=5,fg_bg=wh_gray}
TextBox{parent=turbine,y=1,text="STEAM TURBINE",alignment=TEXT_ALIGN.CENTER,height=1}
TextBox{parent=turbine,y=3,text=util.trinary(unit.num_turbines>1,"GENERATORS","GENERATOR"),alignment=TEXT_ALIGN.CENTER,height=1}
TextBox{parent=root,x=_wide(93,79),y=2,text="\x1b \x80 \x1a",width=1,height=3,fg_bg=lg_gray}
for i = 1, unit.num_turbines do
local ry = 1 + (2 * (i - 1)) + prv_yo
TextBox{parent=root,x=_wide(125,103),y=ry,text="\x10\x11\x7f",fg_bg=text_c,width=3,height=1}
local state = TriIndicatorLight{parent=root,x=_wide(129,107),y=ry,label=v_names[i+4],c1=colors.gray,c2=colors.yellow,c3=colors.red}
state.register(unit.turbine_ps_tbl[i], "SteamDumpOpen", state.update)
end
----------------------
-- WASTE PROCESSING --
----------------------
local waste = Div{parent=root,x=3,y=6}
local waste_pipes = {
pipe(0, 0, _wide(19, 16), 1, colors.brown, true),
pipe(_wide(14, 13), 1, _wide(19, 17), 5, colors.brown, true),
pipe(_wide(22, 19), 1, _wide(49, 45), 1, colors.brown, true),
pipe(_wide(22, 19), 5, _wide(28, 24), 5, colors.brown, true),
pipe(_wide(64, 53), 1, _wide(95, 81), 1, colors.green, true),
pipe(_wide(48, 43), 4, _wide(71, 61), 4, colors.cyan, true),
pipe(_wide(66, 57), 4, _wide(71, 61), 8, colors.cyan, true),
pipe(_wide(74, 63), 4, _wide(95, 81), 4, colors.cyan, true),
pipe(_wide(74, 63), 8, _wide(133, 111), 8, colors.cyan, true),
pipe(_wide(108, 94), 1, _wide(132, 110), 6, colors.black, true, true),
pipe(_wide(108, 94), 4, _wide(111, 95), 1, colors.black, true, true),
pipe(_wide(132, 110), 6, _wide(130, 108), 6, colors.black, true, true)
}
PipeNetwork{parent=waste,x=1,y=1,pipes=waste_pipes,bg=colors.lightGray}
local function _valve(vx, vy, n)
TextBox{parent=waste,x=vx,y=vy,text="\x10\x11",fg_bg=text_c,width=2,height=1}
local conn = IndicatorLight{parent=waste,x=vx-3,y=vy+1,label=v_names[n],colors=ind_grn}
local open = IndicatorLight{parent=waste,x=vx-3,y=vy+2,label="OPEN",colors=ind_wht}
conn.register(unit.unit_ps, util.c("V_", v_fields[n], "_conn"), conn.update)
open.register(unit.unit_ps, util.c("V_", v_fields[n], "_state"), open.update)
end
local function _machine(mx, my, name)
local l = string.len(name) + 2
TextBox{parent=waste,x=mx,y=my,text=string.rep("\x8f",l),alignment=TEXT_ALIGN.CENTER,fg_bg=lg_gray,width=l,height=1}
TextBox{parent=waste,x=mx,y=my+1,text=name,alignment=TEXT_ALIGN.CENTER,fg_bg=wh_gray,width=l,height=1}
end
local waste_rate = DataIndicator{parent=waste,x=1,y=3,lu_colors=lu_c,label="",unit="mB/t",format="%7.2f",value=0,width=12,fg_bg=bw_fg_bg}
local pu_rate = DataIndicator{parent=waste,x=_wide(82,70),y=3,lu_colors=lu_c,label="",unit="mB/t",format="%7.3f",value=0,width=12,fg_bg=bw_fg_bg}
local po_rate = DataIndicator{parent=waste,x=_wide(52,45),y=6,lu_colors=lu_c,label="",unit="mB/t",format="%7.3f",value=0,width=12,fg_bg=bw_fg_bg}
local popl_rate = DataIndicator{parent=waste,x=_wide(82,70),y=6,lu_colors=lu_c,label="",unit="mB/t",format="%7.3f",value=0,width=12,fg_bg=bw_fg_bg}
local poam_rate = DataIndicator{parent=waste,x=_wide(82,70),y=10,lu_colors=lu_c,label="",unit="mB/t",format="%7.3f",value=0,width=12,fg_bg=bw_fg_bg}
local spent_rate = DataIndicator{parent=waste,x=_wide(117,99),y=3,lu_colors=lu_c,label="",unit="mB/t",format="%7.3f",value=0,width=12,fg_bg=bw_fg_bg}
waste_rate.register(unit.unit_ps, "act_burn_rate", waste_rate.update)
pu_rate.register(unit.unit_ps, "pu_rate", pu_rate.update)
po_rate.register(unit.unit_ps, "po_rate", po_rate.update)
popl_rate.register(unit.unit_ps, "po_pl_rate", popl_rate.update)
poam_rate.register(unit.unit_ps, "po_am_rate", poam_rate.update)
spent_rate.register(unit.unit_ps, "ws_rate", spent_rate.update)
_valve(_wide(21, 18), 2, 1)
_valve(_wide(21, 18), 6, 2)
_valve(_wide(73, 62), 5, 3)
_valve(_wide(73, 62), 9, 4)
_machine(_wide(51, 45), 1, "CENTRIFUGE \x1a");
_machine(_wide(97, 83), 1, "PRC [Pu] \x1a");
_machine(_wide(97, 83), 4, "PRC [Po] \x1a");
_machine(_wide(116, 94), 6, "SPENT WASTE \x1b")
TextBox{parent=waste,x=_wide(30,25),y=3,text="SNAs [Po]",alignment=TEXT_ALIGN.CENTER,width=19,height=1,fg_bg=wh_gray}
local sna_po = Rectangle{parent=waste,x=_wide(30,25),y=4,border=border(1,colors.gray,true),width=19,height=7,thin=true,fg_bg=bw_fg_bg}
local sna_act = IndicatorLight{parent=sna_po,label="ACTIVE",colors=ind_grn}
local sna_cnt = DataIndicator{parent=sna_po,x=12,y=1,lu_colors=lu_c,label="CNT",unit="",format="%2d",value=0,width=7}
local sna_pk = DataIndicator{parent=sna_po,y=3,lu_colors=lu_c,label="PEAK",unit="mB/t",format="%7.2f",value=0,width=17}
local sna_max = DataIndicator{parent=sna_po,lu_colors=lu_c,label="MAX",unit="mB/t",format="%8.2f",value=0,width=17}
local sna_in = DataIndicator{parent=sna_po,lu_colors=lu_c,label="IN",unit="mB/t",format="%9.2f",value=0,width=17}
sna_act.register(unit.unit_ps, "po_rate", function (r) sna_act.update(r > 0) end)
sna_cnt.register(unit.unit_ps, "sna_count", sna_cnt.update)
sna_pk.register(unit.unit_ps, "sna_peak_rate", sna_pk.update)
sna_max.register(unit.unit_ps, "sna_prod_rate", sna_max.update)
sna_in.register(unit.unit_ps, "sna_in", sna_in.update)
return root
end
return make

View File

@@ -0,0 +1,381 @@
--
-- Flow Monitor GUI
--
local types = require("scada-common.types")
local util = require("scada-common.util")
local iocontrol = require("coordinator.iocontrol")
local style = require("coordinator.ui.style")
local unit_flow = require("coordinator.ui.components.unit_flow")
local core = require("graphics.core")
local Div = require("graphics.elements.div")
local PipeNetwork = require("graphics.elements.pipenet")
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 IndicatorLight = require("graphics.elements.indicators.light")
local StateIndicator = require("graphics.elements.indicators.state")
local CONTAINER_MODE = types.CONTAINER_MODE
local TEXT_ALIGN = core.TEXT_ALIGN
local cpair = core.cpair
local border = core.border
local pipe = core.pipe
local wh_gray = style.wh_gray
local bw_fg_bg = style.bw_fg_bg
local text_col = style.text_colors
local lu_col = style.lu_colors
-- create new flow view
---@param main graphics_element main displaybox
local function init(main)
local facility = iocontrol.get_db().facility
local units = iocontrol.get_db().units
local tank_defs = facility.tank_defs
local tank_list = facility.tank_list
-- window header message
local header = TextBox{parent=main,y=1,text="Facility Coolant and Waste Flow Monitor",alignment=TEXT_ALIGN.CENTER,height=1,fg_bg=style.header}
-- max length example: "01:23:45 AM - Wednesday, September 28 2022"
local datetime = TextBox{parent=main,x=(header.get_width()-42),y=1,text="",alignment=TEXT_ALIGN.RIGHT,width=42,height=1,fg_bg=style.header}
datetime.register(facility.ps, "date_time", datetime.set_value)
local po_pipes = {}
local water_pipes = {}
-- get the y offset for this unit index
---@param idx integer unit index
local function y_ofs(idx) return ((idx - 1) * 20) end
-- determinte facility tank start/end from the definitions list
---@param start_idx integer start index of table iteration
---@param end_idx integer end index of table iteration
local function find_fdef(start_idx, end_idx)
local first, last = 4, 0
for i = start_idx, end_idx do
if tank_defs[i] == 2 then
last = i
if i < first then first = i end
end
end
return first, last
end
if facility.tank_mode == 0 or facility.tank_mode == 8 then
-- (0) tanks belong to reactor units OR (8) 4 total facility tanks (A B C D)
for i = 1, facility.num_units do
if units[i].has_tank then
local y = y_ofs(i)
table.insert(water_pipes, pipe(2, y, 2, y + 3, colors.blue, true))
table.insert(water_pipes, pipe(2, y, 21, y, colors.blue, true))
local u = units[i] ---@type ioctl_unit
local x = util.trinary(u.num_boilers == 0, 45, 84)
table.insert(water_pipes, pipe(21, y, x, y + 2, colors.blue, true, true))
end
end
else
-- setup connections for units with emergency coolant, always the same
for i = 1, #tank_defs do
if tank_defs[i] > 0 then
local y = y_ofs(i)
if tank_defs[i] == 2 then
table.insert(water_pipes, pipe(1, y, 21, y, colors.blue, true))
else
table.insert(water_pipes, pipe(2, y, 2, y + 3, colors.blue, true))
table.insert(water_pipes, pipe(2, y, 21, y, colors.blue, true))
end
local u = units[i] ---@type ioctl_unit
local x = util.trinary(u.num_boilers == 0, 45, 84)
table.insert(water_pipes, pipe(21, y, x, y + 2, colors.blue, true, true))
end
end
if facility.tank_mode == 1 then
-- (1) 1 total facility tank (A A A A)
local first_fdef, last_fdef = find_fdef(1, #tank_defs)
for i = 1, #tank_defs do
local y = y_ofs(i)
if i == first_fdef then
table.insert(water_pipes, pipe(0, y, 1, y + 5, colors.blue, true))
elseif i > first_fdef then
if i == last_fdef then
table.insert(water_pipes, pipe(0, y - 14, 0, y, colors.blue, true))
elseif i < last_fdef then
table.insert(water_pipes, pipe(0, y - 14, 0, y + 5, colors.blue, true))
end
end
end
elseif facility.tank_mode == 2 then
-- (2) 2 total facility tanks (A A A B)
local first_fdef, last_fdef = find_fdef(1, math.min(3, #tank_defs))
for i = 1, #tank_defs do
local y = y_ofs(i)
if i == 4 then
if tank_defs[i] == 2 then
table.insert(water_pipes, pipe(0, y, 1, y + 5, colors.blue, true))
end
elseif i == first_fdef then
table.insert(water_pipes, pipe(0, y, 1, y + 5, colors.blue, true))
elseif i > first_fdef then
if i == last_fdef then
table.insert(water_pipes, pipe(0, y - 14, 0, y, colors.blue, true))
elseif i < last_fdef then
table.insert(water_pipes, pipe(0, y - 14, 0, y + 5, colors.blue, true))
end
end
end
elseif facility.tank_mode == 3 then
-- (3) 2 total facility tanks (A A B B)
for _, a in pairs({ 1, 3 }) do
local b = a + 1
if tank_defs[a] == 2 then
table.insert(water_pipes, pipe(0, y_ofs(a), 1, y_ofs(a) + 6, colors.blue, true))
if tank_defs[b] == 2 then
table.insert(water_pipes, pipe(0, y_ofs(b) - 13, 1, y_ofs(b), colors.blue, true))
end
elseif tank_defs[b] == 2 then
table.insert(water_pipes, pipe(0, y_ofs(b), 1, y_ofs(b) + 6, colors.blue, true))
end
end
elseif facility.tank_mode == 4 then
-- (4) 2 total facility tanks (A B B B)
local first_fdef, last_fdef = find_fdef(2, #tank_defs)
for i = 1, #tank_defs do
local y = y_ofs(i)
if i == 1 then
if tank_defs[i] == 2 then
table.insert(water_pipes, pipe(0, y, 1, y + 5, colors.blue, true))
end
elseif i == first_fdef then
table.insert(water_pipes, pipe(0, y, 1, y + 5, colors.blue, true))
elseif i > first_fdef then
if i == last_fdef then
table.insert(water_pipes, pipe(0, y - 14, 0, y, colors.blue, true))
elseif i < last_fdef then
table.insert(water_pipes, pipe(0, y - 14, 0, y + 5, colors.blue, true))
end
end
end
elseif facility.tank_mode == 5 then
-- (5) 3 total facility tanks (A A B C)
local first_fdef, last_fdef = find_fdef(1, math.min(2, #tank_defs))
for i = 1, #tank_defs do
local y = y_ofs(i)
if i == 3 or i == 4 then
if tank_defs[i] == 2 then
table.insert(water_pipes, pipe(0, y, 1, y + 5, colors.blue, true))
end
elseif i == first_fdef then
table.insert(water_pipes, pipe(0, y, 1, y + 5, colors.blue, true))
elseif i > first_fdef then
if i == last_fdef then
table.insert(water_pipes, pipe(0, y - 14, 0, y, colors.blue, true))
elseif i < last_fdef then
table.insert(water_pipes, pipe(0, y - 14, 0, y + 5, colors.blue, true))
end
end
end
elseif facility.tank_mode == 6 then
-- (6) 3 total facility tanks (A B B C)
local first_fdef, last_fdef = find_fdef(2, math.min(3, #tank_defs))
for i = 1, #tank_defs do
local y = y_ofs(i)
if i == 1 or i == 4 then
if tank_defs[i] == 2 then
table.insert(water_pipes, pipe(0, y, 1, y + 5, colors.blue, true))
end
elseif i == first_fdef then
table.insert(water_pipes, pipe(0, y, 1, y + 5, colors.blue, true))
elseif i > first_fdef then
if i == last_fdef then
table.insert(water_pipes, pipe(0, y - 14, 0, y, colors.blue, true))
elseif i < last_fdef then
table.insert(water_pipes, pipe(0, y - 14, 0, y + 5, colors.blue, true))
end
end
end
elseif facility.tank_mode == 7 then
-- (7) 3 total facility tanks (A B C C)
local first_fdef, last_fdef = find_fdef(3, #tank_defs)
for i = 1, #tank_defs do
local y = y_ofs(i)
if i == 1 or i == 2 then
if tank_defs[i] == 2 then
table.insert(water_pipes, pipe(0, y, 1, y + 5, colors.blue, true))
end
elseif i == first_fdef then
table.insert(water_pipes, pipe(0, y, 1, y + 5, colors.blue, true))
elseif i > first_fdef then
if i == last_fdef then
table.insert(water_pipes, pipe(0, y - 14, 0, y, colors.blue, true))
elseif i < last_fdef then
table.insert(water_pipes, pipe(0, y - 14, 0, y + 5, colors.blue, true))
end
end
end
end
end
local flow_x = 3
if #water_pipes > 0 then
flow_x = 25
PipeNetwork{parent=main,x=2,y=3,pipes=water_pipes,bg=colors.lightGray}
end
for i = 1, facility.num_units do
local y_offset = y_ofs(i)
unit_flow(main, flow_x, 5 + y_offset, #water_pipes == 0, units[i])
table.insert(po_pipes, pipe(0, 3 + y_offset, 4, 0, colors.cyan, true, true))
end
PipeNetwork{parent=main,x=139,y=15,pipes=po_pipes,bg=colors.lightGray}
-----------------
-- tank valves --
-----------------
local next_f_id = 1
for i = 1, #tank_defs do
if tank_defs[i] > 0 then
local vy = 3 + y_ofs(i)
TextBox{parent=main,x=12,y=vy,text="\x10\x11",fg_bg=cpair(colors.black,colors.lightGray),width=2,height=1}
local conn = IndicatorLight{parent=main,x=9,y=vy+1,label=util.sprintf("PV%02d-EMC", i * 5),colors=style.ind_grn}
local open = IndicatorLight{parent=main,x=9,y=vy+2,label="OPEN",colors=style.ind_wht}
conn.register(units[i].unit_ps, "V_emc_conn", conn.update)
open.register(units[i].unit_ps, "V_emc_state", open.update)
end
end
-------------------
-- dynamic tanks --
-------------------
for i = 1, #tank_list do
if tank_list[i] > 0 then
local id = "U-" .. i
local f_id = next_f_id
if tank_list[i] == 2 then
id = "F-" .. next_f_id
next_f_id = next_f_id + 1
end
local y_offset = y_ofs(i)
local tank = Div{parent=main,x=3,y=7+y_offset,width=20,height=14}
TextBox{parent=tank,text=" ",height=1,x=1,y=1,fg_bg=cpair(colors.lightGray,colors.gray)}
TextBox{parent=tank,text="DYNAMIC TANK "..id,alignment=TEXT_ALIGN.CENTER,height=1,fg_bg=style.wh_gray}
local tank_box = Rectangle{parent=tank,border=border(1,colors.gray,true),width=20,height=12}
local status = StateIndicator{parent=tank_box,x=3,y=1,states=style.dtank.states,value=1,min_width=14}
TextBox{parent=tank_box,x=2,y=3,text="Fill",height=1,width=10,fg_bg=style.label}
local tank_pcnt = DataIndicator{parent=tank_box,x=10,y=3,label="",format="%5.2f",value=100,unit="%",lu_colors=lu_col,width=8,fg_bg=text_col}
local tank_amnt = DataIndicator{parent=tank_box,x=2,label="",format="%13d",value=0,unit="mB",lu_colors=lu_col,width=16,fg_bg=bw_fg_bg}
TextBox{parent=tank_box,x=2,y=6,text="Water Level",height=1,width=11,fg_bg=style.label}
local level = HorizontalBar{parent=tank_box,x=2,y=7,bar_fg_bg=cpair(colors.blue,colors.gray),height=1,width=16}
TextBox{parent=tank_box,x=2,y=9,text="In/Out Mode",height=1,width=11,fg_bg=style.label}
local can_fill = IndicatorLight{parent=tank_box,x=2,y=10,label="FILL",colors=style.ind_wht}
local can_empty = IndicatorLight{parent=tank_box,x=10,y=10,label="EMPTY",colors=style.ind_wht}
local function _can_fill(mode)
can_fill.update((mode == CONTAINER_MODE.BOTH) or (mode == CONTAINER_MODE.FILL))
end
local function _can_empty(mode)
can_empty.update((mode == CONTAINER_MODE.BOTH) or (mode == CONTAINER_MODE.EMPTY))
end
if tank_list[i] == 1 then
status.register(units[i].tank_ps_tbl[1], "computed_status", status.update)
tank_pcnt.register(units[i].tank_ps_tbl[1], "fill", function (f) tank_pcnt.update(f * 100) end)
tank_amnt.register(units[i].tank_ps_tbl[1], "stored", function (sto) tank_amnt.update(sto.amount) end)
level.register(units[i].tank_ps_tbl[1], "fill", level.update)
can_fill.register(units[i].tank_ps_tbl[1], "container_mode", _can_fill)
can_empty.register(units[i].tank_ps_tbl[1], "container_mode", _can_empty)
else
status.register(facility.tank_ps_tbl[f_id], "computed_status", status.update)
tank_pcnt.register(facility.tank_ps_tbl[f_id], "fill", function (f) tank_pcnt.update(f * 100) end)
tank_amnt.register(facility.tank_ps_tbl[f_id], "stored", function (sto) tank_amnt.update(sto.amount) end)
level.register(facility.tank_ps_tbl[f_id], "fill", level.update)
can_fill.register(facility.tank_ps_tbl[f_id], "container_mode", _can_fill)
can_empty.register(facility.tank_ps_tbl[f_id], "container_mode", _can_empty)
end
end
end
---------
-- SPS --
---------
local sps = Div{parent=main,x=140,y=3,height=12}
TextBox{parent=sps,text=" ",width=24,height=1,x=1,y=1,fg_bg=cpair(colors.lightGray,colors.gray)}
TextBox{parent=sps,text="SPS",alignment=TEXT_ALIGN.CENTER,width=24,height=1,fg_bg=wh_gray}
local sps_box = Rectangle{parent=sps,border=border(1,colors.gray,true),width=24,height=10}
local status = StateIndicator{parent=sps_box,x=5,y=1,states=style.sps.states,value=1,min_width=14}
status.register(facility.sps_ps_tbl[1], "computed_status", status.update)
TextBox{parent=sps_box,x=2,y=3,text="Input Rate",height=1,width=10,fg_bg=style.label}
local sps_in = DataIndicator{parent=sps_box,x=2,label="",format="%15.3f",value=0,unit="mB/t",lu_colors=lu_col,width=20,fg_bg=bw_fg_bg}
sps_in.register(facility.ps, "po_am_rate", sps_in.update)
TextBox{parent=sps_box,x=2,y=6,text="Production Rate",height=1,width=15,fg_bg=style.label}
local sps_rate = DataIndicator{parent=sps_box,x=2,label="",format="%15d",value=0,unit="\xb5B/t",lu_colors=lu_col,width=20,fg_bg=bw_fg_bg}
sps_rate.register(facility.sps_ps_tbl[1], "process_rate", function (r) sps_rate.update(r * 1000) end)
----------------
-- statistics --
----------------
TextBox{parent=main,x=145,y=16,text="PROC. WASTE",alignment=TEXT_ALIGN.CENTER,width=19,height=1,fg_bg=wh_gray}
local pr_waste = Rectangle{parent=main,x=145,y=17,border=border(1,colors.gray,true),width=19,height=5,thin=true,fg_bg=bw_fg_bg}
local pu = DataIndicator{parent=pr_waste,lu_colors=lu_col,label="Pu",unit="mB/t",format="%9.3f",value=0,width=17}
local po = DataIndicator{parent=pr_waste,lu_colors=lu_col,label="Po",unit="mB/t",format="%9.3f",value=0,width=17}
local popl = DataIndicator{parent=pr_waste,lu_colors=lu_col,label="PoPl",unit="mB/t",format="%7.3f",value=0,width=17}
pu.register(facility.ps, "pu_rate", pu.update)
po.register(facility.ps, "po_rate", po.update)
popl.register(facility.ps, "po_pl_rate", popl.update)
TextBox{parent=main,x=145,y=23,text="SPENT WASTE",alignment=TEXT_ALIGN.CENTER,width=19,height=1,fg_bg=wh_gray}
local sp_waste = Rectangle{parent=main,x=145,y=24,border=border(1,colors.gray,true),width=19,height=3,thin=true,fg_bg=bw_fg_bg}
local sum_sp_waste = DataIndicator{parent=sp_waste,lu_colors=lu_col,label="SUM",unit="mB/t",format="%8.3f",value=0,width=17}
sum_sp_waste.register(facility.ps, "spent_waste_rate", sum_sp_waste.update)
end
return init

View File

@@ -0,0 +1,124 @@
--
-- Coordinator Front Panel GUI
--
local types = require("scada-common.types")
local util = require("scada-common.util")
local iocontrol = require("coordinator.iocontrol")
local pgi = require("coordinator.ui.pgi")
local style = require("coordinator.ui.style")
local pkt_entry = require("coordinator.ui.components.pkt_entry")
local core = require("graphics.core")
local Div = require("graphics.elements.div")
local ListBox = require("graphics.elements.listbox")
local MultiPane = require("graphics.elements.multipane")
local TextBox = require("graphics.elements.textbox")
local TabBar = require("graphics.elements.controls.tabbar")
local LED = require("graphics.elements.indicators.led")
local RGBLED = require("graphics.elements.indicators.ledrgb")
local TEXT_ALIGN = core.TEXT_ALIGN
local cpair = core.cpair
-- create new front panel view
---@param panel graphics_element main displaybox
---@param num_units integer number of units (number of unit monitors)
local function init(panel, num_units)
local ps = iocontrol.get_db().fp.ps
TextBox{parent=panel,y=1,text="SCADA COORDINATOR",alignment=TEXT_ALIGN.CENTER,height=1,fg_bg=style.fp.header}
local page_div = Div{parent=panel,x=1,y=3}
--
-- system indicators
--
local main_page = Div{parent=page_div,x=1,y=1}
local system = Div{parent=main_page,width=14,height=17,x=2,y=2}
local status = LED{parent=system,label="STATUS",colors=cpair(colors.green,colors.red)}
local heartbeat = LED{parent=system,label="HEARTBEAT",colors=cpair(colors.green,colors.green_off)}
status.update(true)
system.line_break()
heartbeat.register(ps, "heartbeat", heartbeat.update)
local modem = LED{parent=system,label="MODEM",colors=cpair(colors.green,colors.green_off)}
local network = RGBLED{parent=system,label="NETWORK",colors={colors.green,colors.red,colors.orange,colors.yellow,colors.gray}}
network.update(types.PANEL_LINK_STATE.DISCONNECTED)
system.line_break()
modem.register(ps, "has_modem", modem.update)
network.register(ps, "link_state", network.update)
local speaker = LED{parent=system,label="SPEAKER",colors=cpair(colors.green,colors.green_off)}
speaker.register(ps, "has_speaker", speaker.update)
---@diagnostic disable-next-line: undefined-field
local comp_id = util.sprintf("(%d)", os.getComputerID())
TextBox{parent=system,x=9,y=4,width=6,height=1,text=comp_id,fg_bg=cpair(colors.lightGray,colors.ivory)}
local monitors = Div{parent=main_page,width=16,height=17,x=18,y=2}
local main_monitor = LED{parent=monitors,label="MAIN MONITOR",colors=cpair(colors.green,colors.green_off)}
main_monitor.register(ps, "main_monitor", main_monitor.update)
local flow_monitor = LED{parent=monitors,label="FLOW MONITOR",colors=cpair(colors.green,colors.green_off)}
flow_monitor.register(ps, "flow_monitor", flow_monitor.update)
monitors.line_break()
for i = 1, num_units do
local unit_monitor = LED{parent=monitors,label="UNIT "..i.." MONITOR",colors=cpair(colors.green,colors.green_off)}
unit_monitor.register(ps, "unit_monitor_" .. i, unit_monitor.update)
end
--
-- about footer
--
local about = Div{parent=main_page,width=15,height=3,x=1,y=16,fg_bg=cpair(colors.lightGray,colors.ivory)}
local fw_v = TextBox{parent=about,x=1,y=1,text="FW: v00.00.00",alignment=TEXT_ALIGN.LEFT,height=1}
local comms_v = TextBox{parent=about,x=1,y=2,text="NT: v00.00.00",alignment=TEXT_ALIGN.LEFT,height=1}
fw_v.register(ps, "version", function (version) fw_v.set_value(util.c("FW: ", version)) end)
comms_v.register(ps, "comms_version", function (version) comms_v.set_value(util.c("NT: v", version)) end)
--
-- page handling
--
-- API page
local api_page = Div{parent=page_div,x=1,y=1,hidden=true}
local api_list = ListBox{parent=api_page,x=1,y=1,height=17,width=51,scroll_height=1000,fg_bg=cpair(colors.black,colors.ivory),nav_fg_bg=cpair(colors.gray,colors.lightGray),nav_active=cpair(colors.black,colors.gray)}
local _ = Div{parent=api_list,height=1,hidden=true} -- padding
-- assemble page panes
local panes = { main_page, api_page }
local page_pane = MultiPane{parent=page_div,x=1,y=1,panes=panes}
local tabs = {
{ name = "CRD", color = cpair(colors.black, colors.ivory) },
{ name = "API", color = cpair(colors.black, colors.ivory) },
}
TabBar{parent=panel,y=2,tabs=tabs,min_width=9,callback=page_pane.set_value,fg_bg=cpair(colors.black,colors.white)}
-- link pocket API list management to PGI
pgi.link_elements(api_list, pkt_entry)
end
return init

View File

@@ -9,7 +9,7 @@ local iocontrol = require("coordinator.iocontrol")
local style = require("coordinator.ui.style")
local imatrix = require("coordinator.ui.components.imatrix")
local process_ctl = require("coordinator.ui.components.processctl")
local process_ctl = require("coordinator.ui.components.process_ctl")
local unit_overview = require("coordinator.ui.components.unit_overview")
local core = require("graphics.core")

60
coordinator/ui/pgi.lua Normal file
View File

@@ -0,0 +1,60 @@
--
-- Protected Graphics Interface
--
local log = require("scada-common.log")
local util = require("scada-common.util")
local pgi = {}
local data = {
pkt_list = nil, ---@type nil|graphics_element
pkt_entry = nil, ---@type function
-- session entries
s_entries = { pkt = {} }
}
-- link list boxes
---@param pkt_list graphics_element pocket list element
---@param pkt_entry function pocket entry constructor
function pgi.link_elements(pkt_list, pkt_entry)
data.pkt_list = pkt_list
data.pkt_entry = pkt_entry
end
-- unlink all fields, disabling the PGI
function pgi.unlink()
data.pkt_list = nil
data.pkt_entry = nil
end
-- add a PKT entry to the PKT list
---@param session_id integer pocket session
function pgi.create_pkt_entry(session_id)
if data.pkt_list ~= nil and data.pkt_entry ~= nil then
local success, result = pcall(data.pkt_entry, data.pkt_list, session_id)
if success then
data.s_entries.pkt[session_id] = result
else
log.error(util.c("PGI: failed to create PKT entry (", result, ")"), true)
end
end
end
-- delete a PKT entry from the PKT list
---@param session_id integer pocket session
function pgi.delete_pkt_entry(session_id)
if data.s_entries.pkt[session_id] ~= nil then
local success, result = pcall(data.s_entries.pkt[session_id].delete)
data.s_entries.pkt[session_id] = nil
if not success then
log.error(util.c("PGI: failed to delete PKT entry (", result, ")"), true)
end
else
log.debug(util.c("PGI: tried to delete unknown PKT entry ", session_id))
end
end
return pgi

View File

@@ -10,6 +10,41 @@ local cpair = core.cpair
-- GLOBAL --
-- add color mappings for front panel
colors.ivory = colors.pink
colors.yellow_hc = colors.purple
colors.red_off = colors.brown
colors.yellow_off = colors.magenta
colors.green_off = colors.lime
-- front panel styling
style.fp = {}
style.fp.root = cpair(colors.black, colors.ivory)
style.fp.header = cpair(colors.black, colors.lightGray)
style.fp.colors = {
{ c = colors.red, hex = 0xdf4949 }, -- RED ON
{ c = colors.orange, hex = 0xffb659 },
{ c = colors.yellow, hex = 0xf9fb53 }, -- YELLOW ON
{ c = colors.lime, hex = 0x16665a }, -- GREEN OFF
{ c = colors.green, hex = 0x6be551 }, -- GREEN ON
{ c = colors.cyan, hex = 0x34bac8 },
{ c = colors.lightBlue, hex = 0x6cc0f2 },
{ c = colors.blue, hex = 0x0096ff },
{ c = colors.purple, hex = 0xb156ee }, -- YELLOW HIGH CONTRAST
{ c = colors.pink, hex = 0xdcd9ca }, -- IVORY
{ c = colors.magenta, hex = 0x85862c }, -- YELLOW OFF
-- { c = colors.white, hex = 0xdcd9ca },
{ c = colors.lightGray, hex = 0xb1b8b3 },
{ c = colors.gray, hex = 0x575757 },
-- { c = colors.black, hex = 0x191919 },
{ c = colors.brown, hex = 0x672223 } -- RED OFF
}
-- main GUI styling
style.root = cpair(colors.black, colors.lightGray)
style.header = cpair(colors.white, colors.gray)
style.label = cpair(colors.gray, colors.lightGray)
@@ -33,7 +68,22 @@ style.colors = {
-- { c = colors.brown, hex = 0x7f664c }
}
-- MAIN LAYOUT --
-- COMMON COLOR PAIRS --
style.wh_gray = cpair(colors.white, colors.gray)
style.bw_fg_bg = cpair(colors.black, colors.white)
style.text_colors = cpair(colors.black, colors.lightGray)
style.lu_colors = cpair(colors.gray, colors.gray)
style.hzd_fg_bg = style.wh_gray
style.dis_colors = cpair(colors.white, colors.lightGray)
style.ind_grn = cpair(colors.green, colors.gray)
style.ind_yel = cpair(colors.yellow, colors.gray)
style.ind_red = cpair(colors.red, colors.gray)
style.ind_wht = style.wh_gray
-- UI COMPONENTS --
style.reactor = {
-- reactor states
@@ -151,8 +201,121 @@ style.imatrix = {
{
color = cpair(colors.black, colors.yellow),
text = "HIGH CHARGE"
}
}
}
style.sps = {
-- SPS 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.dtank = {
-- dynamic tank 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 FILL"
},
{
color = cpair(colors.black, colors.green),
text = "FILLED"
},
}
}
style.waste = {
-- auto waste processing states
states = {
{
color = cpair(colors.black, colors.green),
text = "PLUTONIUM"
},
{
color = cpair(colors.black, colors.cyan),
text = "POLONIUM"
},
{
color = cpair(colors.black, colors.purple),
text = "ANTI MATTER"
}
},
states_abbrv = {
{
color = cpair(colors.black, colors.green),
text = "Pu"
},
{
color = cpair(colors.black, colors.cyan),
text = "Po"
},
{
color = cpair(colors.black, colors.purple),
text = "AM"
}
},
-- process radio button options
options = { "Plutonium", "Polonium", "Antimatter" },
-- unit waste selection
unit_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)
}
}
}
return style

View File

@@ -7,7 +7,7 @@ local flasher = require("graphics.flasher")
local core = {}
core.version = "1.0.0"
core.version = "1.1.1"
core.flasher = flasher
core.events = events

View File

@@ -20,6 +20,8 @@ local element = {}
---@alias graphics_args graphics_args_generic
---|waiting_args
---|app_button_args
---|checkbox_args
---|hazard_button_args
---|multi_button_args
---|push_button_args
@@ -70,7 +72,8 @@ function element.new(args, child_offset_x, child_offset_y)
p_window = nil, ---@type table
position = { x = 1, y = 1 }, ---@type coordinate_2d
bounds = { x1 = 1, y1 = 1, x2 = 1, y2 = 1 }, ---@class element_bounds
next_y = 1,
next_y = 1, -- next child y coordinate
next_id = 0, -- next child ID
subscriptions = {},
mt = {}
}
@@ -351,13 +354,14 @@ function element.new(args, child_offset_x, child_offset_y)
local child_element = child.get()
if key == nil then
table.insert(protected.children, child_element)
return #protected.children
else
protected.children[key] = child_element
return key
local id = key ---@type string|integer|nil
if id == nil then
id = self.next_id
self.next_id = self.next_id + 1
end
protected.children[id] = child_element
return id
end
-- remove a child element
@@ -512,19 +516,21 @@ function element.new(args, child_offset_x, child_offset_y)
-- FUNCTION CALLBACKS --
-- handle a monitor touch or mouse click
-- handle a monitor touch or mouse click if this element is visible
---@param event mouse_interaction mouse interaction event
function public.handle_mouse(event)
local x_ini, y_ini = event.initial.x, event.initial.y
if protected.window.isVisible() then
local x_ini, y_ini = event.initial.x, event.initial.y
local ini_in = protected.in_window_bounds(x_ini, y_ini)
local ini_in = protected.in_window_bounds(x_ini, y_ini)
if ini_in then
local event_T = core.events.mouse_transposed(event, self.position.x, self.position.y)
if ini_in then
local event_T = core.events.mouse_transposed(event, self.position.x, self.position.y)
-- handle the mouse event then pass to children
protected.handle_mouse(event_T)
for _, child in pairs(protected.children) do child.handle_mouse(event_T) end
-- handle the mouse event then pass to children
protected.handle_mouse(event_T)
for _, child in pairs(protected.children) do child.handle_mouse(event_T) end
end
end
end

View File

@@ -8,7 +8,7 @@ local element = require("graphics.element")
---@field parent graphics_element
---@field id? string element id
---@field x? integer 1 if omitted
---@field y? integer 1 if omitted
---@field y? integer auto incremented if omitted
---@field fg_bg? cpair foreground/background colors
---@field hidden? boolean true to hide on initial draw

View File

@@ -8,7 +8,7 @@ local element = require("graphics.element")
---@field parent graphics_element
---@field id? string element id
---@field x? integer 1 if omitted
---@field y? integer 1 if omitted
---@field y? integer auto incremented if omitted
---@field hidden? boolean true to hide on initial draw
-- new color map

View File

@@ -0,0 +1,130 @@
-- App Button Graphics Element
local tcd = require("scada-common.tcd")
local core = require("graphics.core")
local element = require("graphics.element")
local CLICK_TYPE = core.events.CLICK_TYPE
---@class app_button_args
---@field text string app icon text
---@field title string app title text
---@field callback function function to call on touch
---@field app_fg_bg cpair app icon foreground/background colors
---@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 auto incremented if omitted
---@field fg_bg? cpair foreground/background colors
---@field hidden? boolean true to hide on initial draw
-- new app button
---@param args app_button_args
---@return graphics_element element, element_id id
local function app_button(args)
assert(type(args.text) == "string", "graphics.elements.controls.app: text is a required field")
assert(type(args.title) == "string", "graphics.elements.controls.app: title is a required field")
assert(type(args.callback) == "function", "graphics.elements.controls.app: callback is a required field")
assert(type(args.app_fg_bg) == "table", "graphics.elements.controls.app: app_fg_bg is a required field")
args.height = 4
args.width = 5
-- create new graphics element base object
local e = element.new(args)
-- write app title, centered
e.window.setCursorPos(1, 4)
e.window.setCursorPos(math.floor((e.frame.w - string.len(args.title)) / 2) + 1, 4)
e.window.write(args.title)
-- draw the app button
local function draw()
local fgd = args.app_fg_bg.fgd
local bkg = args.app_fg_bg.bkg
if e.value then
fgd = args.active_fg_bg.fgd
bkg = args.active_fg_bg.bkg
end
-- draw icon
e.window.setCursorPos(1, 1)
e.window.setTextColor(fgd)
e.window.setBackgroundColor(bkg)
e.window.write("\x9f\x83\x83\x83")
e.window.setTextColor(bkg)
e.window.setBackgroundColor(fgd)
e.window.write("\x90")
e.window.setTextColor(fgd)
e.window.setBackgroundColor(bkg)
e.window.setCursorPos(1, 2)
e.window.write("\x95 ")
e.window.setTextColor(bkg)
e.window.setBackgroundColor(fgd)
e.window.write("\x95")
e.window.setCursorPos(1, 3)
e.window.write("\x82\x8f\x8f\x8f\x81")
-- write the icon text
e.window.setCursorPos(3, 2)
e.window.setTextColor(fgd)
e.window.setBackgroundColor(bkg)
e.window.write(args.text)
end
-- draw the app button as pressed (if active_fg_bg set)
local function show_pressed()
if e.enabled and args.active_fg_bg ~= nil then
e.value = true
e.window.setTextColor(args.active_fg_bg.fgd)
e.window.setBackgroundColor(args.active_fg_bg.bkg)
draw()
end
end
-- draw the app button as unpressed (if active_fg_bg set)
local function show_unpressed()
if e.enabled and args.active_fg_bg ~= nil then
e.value = false
e.window.setTextColor(e.fg_bg.fgd)
e.window.setBackgroundColor(e.fg_bg.bkg)
draw()
end
end
-- handle mouse interaction
---@param event mouse_interaction mouse event
function e.handle_mouse(event)
if e.enabled then
if event.type == CLICK_TYPE.TAP then
show_pressed()
-- show as unpressed in 0.25 seconds
if args.active_fg_bg ~= nil then tcd.dispatch(0.25, show_unpressed) end
args.callback()
elseif event.type == CLICK_TYPE.DOWN then
show_pressed()
elseif event.type == CLICK_TYPE.UP then
show_unpressed()
if e.in_frame_bounds(event.current.x, event.current.y) then
args.callback()
end
end
end
end
-- set the value (true simulates pressing the app button)
---@param val boolean new value
function e.set_value(val)
if val then e.handle_mouse(core.events.mouse_generic(core.events.CLICK_TYPE.UP, 1, 1)) end
end
-- initial draw
draw()
return e.complete()
end
return app_button

View File

@@ -0,0 +1,85 @@
-- Checkbox Graphics Element
local core = require("graphics.core")
local element = require("graphics.element")
---@class checkbox_args
---@field label string checkbox text
---@field box_fg_bg cpair colors for checkbox
---@field callback function function to call on press
---@field parent graphics_element
---@field id? string element id
---@field x? integer 1 if omitted
---@field y? integer auto incremented if omitted
---@field fg_bg? cpair foreground/background colors
---@field hidden? boolean true to hide on initial draw
-- new checkbox control
---@param args checkbox_args
---@return graphics_element element, element_id id
local function checkbox(args)
assert(type(args.label) == "string", "graphics.elements.controls.checkbox: label is a required field")
assert(type(args.box_fg_bg) == "table", "graphics.elements.controls.checkbox: box_fg_bg is a required field")
assert(type(args.callback) == "function", "graphics.elements.controls.checkbox: callback is a required field")
args.height = 1
args.width = 3 + string.len(args.label)
-- create new graphics element base object
local e = element.new(args)
e.value = false
-- show the button state
local function draw()
e.window.setCursorPos(1, 1)
if e.value then
-- show as selected
e.window.setTextColor(args.box_fg_bg.bkg)
e.window.setBackgroundColor(args.box_fg_bg.fgd)
e.window.write("\x88")
e.window.setTextColor(args.box_fg_bg.fgd)
e.window.setBackgroundColor(e.fg_bg.bkg)
e.window.write("\x95")
else
-- show as unselected
e.window.setTextColor(e.fg_bg.bkg)
e.window.setBackgroundColor(args.box_fg_bg.bkg)
e.window.write("\x88")
e.window.setTextColor(args.box_fg_bg.bkg)
e.window.setBackgroundColor(e.fg_bg.bkg)
e.window.write("\x95")
end
end
-- handle mouse interaction
---@param event mouse_interaction mouse event
function e.handle_mouse(event)
if e.enabled and core.events.was_clicked(event.type) then
e.value = not e.value
draw()
args.callback(e.value)
end
end
-- set the value
---@param val integer new value
function e.set_value(val)
e.value = val
draw()
end
-- write label text
e.window.setCursorPos(3, 1)
e.window.setTextColor(e.fg_bg.fgd)
e.window.setBackgroundColor(e.fg_bg.bkg)
e.window.write(args.label)
-- initial draw
draw()
return e.complete()
end
return checkbox

View File

@@ -14,7 +14,7 @@ local element = require("graphics.element")
---@field parent graphics_element
---@field id? string element id
---@field x? integer 1 if omitted
---@field y? integer 1 if omitted
---@field y? integer auto incremented if omitted
---@field fg_bg? cpair foreground/background colors
---@field hidden? boolean true to hide on initial draw

View File

@@ -20,7 +20,7 @@ local element = require("graphics.element")
---@field parent graphics_element
---@field id? string element id
---@field x? integer 1 if omitted
---@field y? integer 1 if omitted
---@field y? integer auto incremented if omitted
---@field height? integer parent height if omitted
---@field fg_bg? cpair foreground/background colors
---@field hidden? boolean true to hide on initial draw

View File

@@ -16,7 +16,7 @@ local CLICK_TYPE = core.events.CLICK_TYPE
---@field parent graphics_element
---@field id? string element id
---@field x? integer 1 if omitted
---@field y? integer 1 if omitted
---@field y? integer auto incremented if omitted
---@field height? integer parent height if omitted
---@field fg_bg? cpair foreground/background colors
---@field hidden? boolean true to hide on initial draw

View File

@@ -13,7 +13,7 @@ local element = require("graphics.element")
---@field parent graphics_element
---@field id? string element id
---@field x? integer 1 if omitted
---@field y? integer 1 if omitted
---@field y? integer auto incremented if omitted
---@field fg_bg? cpair foreground/background colors
---@field hidden? boolean true to hide on initial draw

View File

@@ -17,7 +17,7 @@ local CLICK_TYPE = core.events.CLICK_TYPE
---@field parent graphics_element
---@field id? string element id
---@field x? integer 1 if omitted
---@field y? integer 1 if omitted
---@field y? integer auto incremented if omitted
---@field height? integer parent height if omitted
---@field fg_bg? cpair foreground/background colors
---@field hidden? boolean true to hide on initial draw

View File

@@ -16,7 +16,7 @@ local element = require("graphics.element")
---@field parent graphics_element
---@field id? string element id
---@field x? integer 1 if omitted
---@field y? integer 1 if omitted
---@field y? integer auto incremented if omitted
---@field fg_bg? cpair foreground/background colors
---@field hidden? boolean true to hide on initial draw

View File

@@ -12,7 +12,7 @@ local element = require("graphics.element")
---@field parent graphics_element
---@field id? string element id
---@field x? integer 1 if omitted
---@field y? integer 1 if omitted
---@field y? integer auto incremented if omitted
---@field height? integer parent height if omitted
---@field fg_bg? cpair foreground/background colors
---@field hidden? boolean true to hide on initial draw

View File

@@ -18,7 +18,7 @@ local element = require("graphics.element")
---@field parent graphics_element
---@field id? string element id
---@field x? integer 1 if omitted
---@field y? integer 1 if omitted
---@field y? integer auto incremented if omitted
---@field width? integer parent width if omitted
---@field fg_bg? cpair foreground/background colors
---@field hidden? boolean true to hide on initial draw

View File

@@ -6,7 +6,7 @@ local element = require("graphics.element")
---@field parent graphics_element
---@field id? string element id
---@field x? integer 1 if omitted
---@field y? integer 1 if omitted
---@field y? integer auto incremented 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

View File

@@ -16,7 +16,7 @@ local flasher = require("graphics.flasher")
---@field parent graphics_element
---@field id? string element id
---@field x? integer 1 if omitted
---@field y? integer 1 if omitted
---@field y? integer auto incremented if omitted
---@field fg_bg? cpair foreground/background colors
---@field hidden? boolean true to hide on initial draw

View File

@@ -11,7 +11,7 @@ local element = require("graphics.element")
---@field parent graphics_element
---@field id? string element id
---@field x? integer 1 if omitted
---@field y? integer 1 if omitted
---@field y? integer auto incremented if omitted
-- new core map box
---@nodiscard

View File

@@ -14,7 +14,7 @@ local element = require("graphics.element")
---@field parent graphics_element
---@field id? string element id
---@field x? integer 1 if omitted
---@field y? integer 1 if omitted
---@field y? integer auto incremented if omitted
---@field width integer length
---@field fg_bg? cpair foreground/background colors
---@field hidden? boolean true to hide on initial draw

View File

@@ -10,7 +10,7 @@ local element = require("graphics.element")
---@field parent graphics_element
---@field id? string element id
---@field x? integer 1 if omitted
---@field y? integer 1 if omitted
---@field y? integer auto incremented 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

View File

@@ -16,7 +16,7 @@ local element = require("graphics.element")
---@field parent graphics_element
---@field id? string element id
---@field x? integer 1 if omitted
---@field y? integer 1 if omitted
---@field y? integer auto incremented if omitted
---@field fg_bg? cpair foreground/background colors
---@field hidden? boolean true to hide on initial draw

View File

@@ -14,7 +14,7 @@ local flasher = require("graphics.flasher")
---@field parent graphics_element
---@field id? string element id
---@field x? integer 1 if omitted
---@field y? integer 1 if omitted
---@field y? integer auto incremented if omitted
---@field fg_bg? cpair foreground/background colors
---@field hidden? boolean true to hide on initial draw

View File

@@ -16,7 +16,7 @@ local flasher = require("graphics.flasher")
---@field parent graphics_element
---@field id? string element id
---@field x? integer 1 if omitted
---@field y? integer 1 if omitted
---@field y? integer auto incremented if omitted
---@field fg_bg? cpair foreground/background colors
---@field hidden? boolean true to hide on initial draw

View File

@@ -9,7 +9,7 @@ local element = require("graphics.element")
---@field parent graphics_element
---@field id? string element id
---@field x? integer 1 if omitted
---@field y? integer 1 if omitted
---@field y? integer auto incremented if omitted
---@field fg_bg? cpair foreground/background colors
---@field hidden? boolean true to hide on initial draw

View File

@@ -14,7 +14,7 @@ local flasher = require("graphics.flasher")
---@field parent graphics_element
---@field id? string element id
---@field x? integer 1 if omitted
---@field y? integer 1 if omitted
---@field y? integer auto incremented if omitted
---@field fg_bg? cpair foreground/background colors
---@field hidden? boolean true to hide on initial draw

View File

@@ -13,7 +13,7 @@ local element = require("graphics.element")
---@field parent graphics_element
---@field id? string element id
---@field x? integer 1 if omitted
---@field y? integer 1 if omitted
---@field y? integer auto incremented if omitted
---@field width integer length
---@field fg_bg? cpair foreground/background colors
---@field hidden? boolean true to hide on initial draw

View File

@@ -14,7 +14,7 @@ local element = require("graphics.element")
---@field parent graphics_element
---@field id? string element id
---@field x? integer 1 if omitted
---@field y? integer 1 if omitted
---@field y? integer auto incremented if omitted
---@field width integer length
---@field fg_bg? cpair foreground/background colors
---@field hidden? boolean true to hide on initial draw

View File

@@ -15,7 +15,7 @@ local element = require("graphics.element")
---@field parent graphics_element
---@field id? string element id
---@field x? integer 1 if omitted
---@field y? integer 1 if omitted
---@field y? integer auto incremented if omitted
---@field height? integer 1 if omitted, must be an odd number
---@field fg_bg? cpair foreground/background colors
---@field hidden? boolean true to hide on initial draw

View File

@@ -16,7 +16,7 @@ local flasher = require("graphics.flasher")
---@field parent graphics_element
---@field id? string element id
---@field x? integer 1 if omitted
---@field y? integer 1 if omitted
---@field y? integer auto incremented if omitted
---@field fg_bg? cpair foreground/background colors
---@field hidden? boolean true to hide on initial draw

View File

@@ -8,7 +8,7 @@ local element = require("graphics.element")
---@field parent graphics_element
---@field id? string element id
---@field x? integer 1 if omitted
---@field y? integer 1 if omitted
---@field y? integer auto incremented 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

View File

@@ -15,7 +15,7 @@ local CLICK_TYPE = core.events.CLICK_TYPE
---@field parent graphics_element
---@field id? string element id
---@field x? integer 1 if omitted
---@field y? integer 1 if omitted
---@field y? integer auto incremented 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

View File

@@ -7,7 +7,7 @@ local element = require("graphics.element")
---@field parent graphics_element
---@field id? string element id
---@field x? integer 1 if omitted
---@field y? integer 1 if omitted
---@field y? integer auto incremented 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

View File

@@ -11,9 +11,15 @@ local element = require("graphics.element")
---@field parent graphics_element
---@field id? string element id
---@field x? integer 1 if omitted
---@field y? integer 1 if omitted
---@field y? integer auto incremented if omitted
---@field hidden? boolean true to hide on initial draw
---@class _pipe_map_entry
---@field atr boolean align top right (or bottom left for false)
---@field thin boolean thin pipe or not
---@field fg string foreground blit
---@field bg string background blit
-- new pipe network
---@param args pipenet_args
---@return graphics_element element, element_id id
@@ -44,102 +50,264 @@ local function pipenet(args)
-- create new graphics element base object
local e = element.new(args)
-- draw all pipes
-- determine if there are any thin pipes involved
local any_thin = false
for p = 1, #args.pipes do
local pipe = args.pipes[p] ---@type pipe
any_thin = args.pipes[p].thin
if any_thin then break end
end
local x = 1 + pipe.x1
local y = 1 + pipe.y1
if not any_thin then
-- draw all pipes
for p = 1, #args.pipes do
local pipe = args.pipes[p] ---@type pipe
local x_step = util.trinary(pipe.x1 >= pipe.x2, -1, 1)
local y_step = util.trinary(pipe.y1 >= pipe.y2, -1, 1)
local x = 1 + pipe.x1
local y = 1 + pipe.y1
e.window.setCursorPos(x, y)
local x_step = util.trinary(pipe.x1 >= pipe.x2, -1, 1)
local y_step = util.trinary(pipe.y1 >= pipe.y2, -1, 1)
local c = core.cpair(pipe.color, e.fg_bg.bkg)
if pipe.thin then
x_step = util.trinary(pipe.x1 == pipe.x2, 0, x_step)
y_step = util.trinary(pipe.y1 == pipe.y2, 0, y_step)
end
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)
e.window.setCursorPos(x, y)
local c = core.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("\x8e", c.blit_fgd, c.blit_bkg)
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)
elseif y_step > 0 then
e.window.blit("\x8d", c.blit_fgd, c.blit_bkg)
else
e.window.blit("\x8c", 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)
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
end
else
-- build map if using thin pipes, easist way to check adjacent blocks (cannot 'cheat' like with standard width)
local map = {}
-- back up one
y = y - y_step
-- allocate map
for x = 1, args.width do
table.insert(map, {})
for _ = 1, args.height do table.insert(map[x], false) end
end
for _ = 1, pipe.w - 1 do
x = x + x_step
e.window.setCursorPos(x, y)
-- build map
for p = 1, #args.pipes do
local pipe = args.pipes[p] ---@type pipe
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)
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)
local entry = { atr = pipe.align_tr, thin = pipe.thin, fg = colors.toBlit(pipe.color), bg = e.fg_bg.blit_bkg }
if pipe.align_tr then
-- cross width then height
for _ = 1, pipe.w do
map[x][y] = entry
x = x + x_step
end
x = x - x_step -- back up one
for _ = 1, pipe.h do
map[x][y] = entry
y = y + y_step
end
else
-- cross height then width
for _ = 1, pipe.h do
map[x][y] = entry
y = y + y_step
end
y = y - y_step -- back up one
for _ = 1, pipe.w do
map[x][y] = entry
x = x + x_step
end
end
end
-- render
for x = 1, args.width do
for y = 1, args.height do
local entry = map[x][y] ---@type _pipe_map_entry|false
local char
local invert = false
if entry ~= false then
local function check(cx, cy)
return (map[cx] ~= nil) and (map[cx][cy] ~= nil) and (map[cx][cy] ~= false) and (map[cx][cy].fg == entry.fg)
end
if entry.thin then
if check(x - 1, y) then -- if left
if check(x, y - 1) then -- if above
if check(x + 1, y) then -- if right
if check(x, y + 1) then -- if below
char = util.trinary(entry.atr, "\x91", "\x9d")
invert = entry.atr
else -- not below
char = util.trinary(entry.atr, "\x8e", "\x8d")
end
else -- not right
if check(x, y + 1) then -- if below
char = util.trinary(entry.atr, "\x91", "\x95")
invert = entry.atr
else -- not below
char = util.trinary(entry.atr, "\x8e", "\x85")
end
end
elseif check(x, y + 1) then-- not above, if below
if check(x + 1, y) then -- if right
char = util.trinary(entry.atr, "\x93", "\x9c")
invert = entry.atr
else -- not right
char = util.trinary(entry.atr, "\x93", "\x94")
invert = entry.atr
end
else -- not above, not below
char = "\x8c"
end
elseif check(x + 1, y) then -- not left, if right
if check(x, y - 1) then -- if above
if check(x, y + 1) then -- if below
char = util.trinary(entry.atr, "\x95", "\x9d")
invert = entry.atr
else -- not below
char = util.trinary(entry.atr, "\x8a", "\x8d")
end
else -- not above
if check(x, y + 1) then -- if below
char = util.trinary(entry.atr, "\x97", "\x9c")
invert = entry.atr
else -- not below
char = "\x8c"
end
end
else -- not left, not right
char = "\x95"
invert = entry.atr
end
else
if check(x, y - 1) then -- above
-- not below and (if left or right)
if (not check(x, y + 1)) and (check(x - 1, y) or check(x + 1, y)) then
char = util.trinary(entry.atr, "\x8f", " ")
invert = not entry.atr
else -- not below w/ sides only
char = " "
invert = true
end
elseif check(x, y + 1) then -- not above, if below
-- if left or right
if (check(x - 1, y) or check(x + 1, y)) then
char = "\x83"
invert = true
else -- not left or right
char = " "
invert = true
end
else -- not above, not below
char = util.trinary(entry.atr, "\x8f", "\x83")
invert = not entry.atr
end
end
e.window.setCursorPos(x, y)
if invert then
e.window.blit(char, entry.bg, entry.fg)
else
e.window.blit(char, entry.fg, entry.bg)
end
end
end
end
end
return e.complete()

View File

@@ -11,7 +11,7 @@ local element = require("graphics.element")
---@field parent graphics_element
---@field id? string element id
---@field x? integer 1 if omitted
---@field y? integer 1 if omitted
---@field y? integer auto incremented 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

View File

@@ -13,7 +13,7 @@ local TEXT_ALIGN = core.TEXT_ALIGN
---@field parent graphics_element
---@field id? string element id
---@field x? integer 1 if omitted
---@field y? integer 1 if omitted
---@field y? integer auto incremented 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

View File

@@ -11,7 +11,7 @@ local element = require("graphics.element")
---@field parent graphics_element
---@field id? string element id
---@field x? integer 1 if omitted
---@field y? integer 1 if omitted
---@field y? integer auto incremented 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

View File

@@ -43,8 +43,10 @@ end
-- start/resume the flasher periodic
function flasher.run()
active = true
callback_250ms()
if not active then
active = true
callback_250ms()
end
end
-- clear all blinking indicators and stop the flasher periodic

View File

@@ -48,6 +48,7 @@ def make_manifest(size):
"versions" : {
"installer" : get_version("./ccmsi.lua"),
"bootloader" : get_version("./startup.lua"),
"common" : get_version("./scada-common/util.lua", True),
"comms" : get_version("./scada-common/comms.lua", True),
"graphics" : get_version("./graphics/core.lua", True),
"lockbox" : get_version("./lockbox/init.lua", True),

File diff suppressed because one or more lines are too long

View File

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

106
pocket/iocontrol.lua Normal file
View File

@@ -0,0 +1,106 @@
--
-- I/O Control for Pocket Integration with Supervisor & Coordinator
--
local psil = require("scada-common.psil")
local types = require("scada-common.types")
local ALARM = types.ALARM
local iocontrol = {}
---@class pocket_ioctl
local io = {
ps = psil.create()
}
---@enum POCKET_LINK_STATE
local LINK_STATE = {
UNLINKED = 0,
SV_LINK_ONLY = 1,
API_LINK_ONLY = 2,
LINKED = 3
}
---@enum NAV_PAGE
local NAV_PAGE = {
HOME = 1,
UNITS = 2,
REACTORS = 3,
BOILERS = 4,
TURBINES = 5,
DIAG = 6,
D_ALARMS = 7
}
iocontrol.LINK_STATE = LINK_STATE
iocontrol.NAV_PAGE = NAV_PAGE
-- initialize facility-independent components of pocket iocontrol
---@param comms pocket_comms
function iocontrol.init_core(comms)
---@class pocket_ioctl_diag
io.diag = {}
-- alarm testing
io.diag.tone_test = {
test_1 = function (state) comms.diag__set_alarm_tone(1, state) end,
test_2 = function (state) comms.diag__set_alarm_tone(2, state) end,
test_3 = function (state) comms.diag__set_alarm_tone(3, state) end,
test_4 = function (state) comms.diag__set_alarm_tone(4, state) end,
test_5 = function (state) comms.diag__set_alarm_tone(5, state) end,
test_6 = function (state) comms.diag__set_alarm_tone(6, state) end,
test_7 = function (state) comms.diag__set_alarm_tone(7, state) end,
test_8 = function (state) comms.diag__set_alarm_tone(8, state) end,
stop_tones = function () comms.diag__set_alarm_tone(0, false) end,
test_breach = function (state) comms.diag__set_alarm(ALARM.ContainmentBreach, state) end,
test_rad = function (state) comms.diag__set_alarm(ALARM.ContainmentRadiation, state) end,
test_lost = function (state) comms.diag__set_alarm(ALARM.ReactorLost, state) end,
test_crit = function (state) comms.diag__set_alarm(ALARM.CriticalDamage, state) end,
test_dmg = function (state) comms.diag__set_alarm(ALARM.ReactorDamage, state) end,
test_overtemp = function (state) comms.diag__set_alarm(ALARM.ReactorOverTemp, state) end,
test_hightemp = function (state) comms.diag__set_alarm(ALARM.ReactorHighTemp, state) end,
test_wasteleak = function (state) comms.diag__set_alarm(ALARM.ReactorWasteLeak, state) end,
test_highwaste = function (state) comms.diag__set_alarm(ALARM.ReactorHighWaste, state) end,
test_rps = function (state) comms.diag__set_alarm(ALARM.RPSTransient, state) end,
test_rcs = function (state) comms.diag__set_alarm(ALARM.RCSTransient, state) end,
test_turbinet = function (state) comms.diag__set_alarm(ALARM.TurbineTrip, state) end,
stop_alarms = function () comms.diag__set_alarm(0, false) end,
get_tone_states = function () comms.diag__get_alarm_tones() end,
ready_warn = nil, ---@type graphics_element
tone_buttons = {},
alarm_buttons = {},
tone_indicators = {} -- indicators to update from supervisor tone states
}
---@class pocket_nav
io.nav = {
page = NAV_PAGE.HOME, ---@type NAV_PAGE
sub_pages = { NAV_PAGE.HOME, NAV_PAGE.UNITS, NAV_PAGE.REACTORS, NAV_PAGE.BOILERS, NAV_PAGE.TURBINES, NAV_PAGE.DIAG },
tasks = {}
}
-- add a task to be performed periodically while on a given page
---@param page NAV_PAGE page to add task to
---@param task function function to execute
function io.nav.register_task(page, task)
if io.nav.tasks[page] == nil then io.nav.tasks[page] = {} end
table.insert(io.nav.tasks[page], task)
end
end
-- initialize facility-dependent components of pocket iocontrol
function iocontrol.init_fac() end
-- set network link state
---@param state POCKET_LINK_STATE
function iocontrol.report_link_state(state) io.ps.publish("link_state", state) end
-- get the IO controller database
function iocontrol.get_db() return io end
return iocontrol

View File

@@ -1,16 +1,15 @@
local comms = require("scada-common.comms")
local log = require("scada-common.log")
local util = require("scada-common.util")
local comms = require("scada-common.comms")
local log = require("scada-common.log")
local util = require("scada-common.util")
local coreio = require("pocket.coreio")
local iocontrol = require("pocket.iocontrol")
local PROTOCOL = comms.PROTOCOL
local DEVICE_TYPE = comms.DEVICE_TYPE
local ESTABLISH_ACK = comms.ESTABLISH_ACK
local SCADA_MGMT_TYPE = comms.SCADA_MGMT_TYPE
-- local CAPI_TYPE = comms.CAPI_TYPE
local LINK_STATE = coreio.LINK_STATE
local LINK_STATE = iocontrol.LINK_STATE
local pocket = {}
@@ -79,20 +78,6 @@ function pocket.comms(version, nic, pkt_channel, svr_channel, crd_channel, range
self.api.seq_num = self.api.seq_num + 1
end
-- send a packet to the coordinator API
-----@param msg_type CAPI_TYPE
-----@param msg table
-- local function _send_api(msg_type, msg)
-- local s_pkt = comms.scada_packet()
-- local pkt = comms.capi_packet()
-- pkt.make(msg_type, msg)
-- s_pkt.make(self.api.addr, self.api.seq_num, PROTOCOL.COORD_API, pkt.raw_sendable())
-- nic.transmit(crd_channel, pkt_channel, s_pkt)
-- self.api.seq_num = self.api.seq_num + 1
-- end
-- attempt supervisor connection establishment
local function _send_sv_establish()
_send_sv(SCADA_MGMT_TYPE.ESTABLISH, { comms.version, version, DEVICE_TYPE.PKT })
@@ -147,7 +132,7 @@ function pocket.comms(version, nic, pkt_channel, svr_channel, crd_channel, range
-- attempt to re-link if any of the dependent links aren't active
function public.link_update()
if not self.sv.linked then
coreio.report_link_state(util.trinary(self.api.linked, LINK_STATE.API_LINK_ONLY, LINK_STATE.UNLINKED))
iocontrol.report_link_state(util.trinary(self.api.linked, LINK_STATE.API_LINK_ONLY, LINK_STATE.UNLINKED))
if self.establish_delay_counter <= 0 then
_send_sv_establish()
@@ -156,7 +141,7 @@ function pocket.comms(version, nic, pkt_channel, svr_channel, crd_channel, range
self.establish_delay_counter = self.establish_delay_counter - 1
end
elseif not self.api.linked then
coreio.report_link_state(LINK_STATE.SV_LINK_ONLY)
iocontrol.report_link_state(LINK_STATE.SV_LINK_ONLY)
if self.establish_delay_counter <= 0 then
_send_api_establish()
@@ -166,10 +151,29 @@ function pocket.comms(version, nic, pkt_channel, svr_channel, crd_channel, range
end
else
-- linked, all good!
coreio.report_link_state(LINK_STATE.LINKED)
iocontrol.report_link_state(LINK_STATE.LINKED)
end
end
-- supervisor get active alarm tones
function public.diag__get_alarm_tones()
if self.sv.linked then _send_sv(SCADA_MGMT_TYPE.DIAG_TONE_GET, {}) end
end
-- supervisor test alarm tones by tone
---@param id TONE|0 tone ID, or 0 to stop all
---@param state boolean tone state
function public.diag__set_alarm_tone(id, state)
if self.sv.linked then _send_sv(SCADA_MGMT_TYPE.DIAG_TONE_SET, { id, state }) end
end
-- supervisor test alarm tones by alarm
---@param id ALARM|0 alarm ID, 0 to stop all
---@param state boolean alarm state
function public.diag__set_alarm(id, state)
if self.sv.linked then _send_sv(SCADA_MGMT_TYPE.DIAG_ALARM_SET, { id, state }) end
end
-- parse a packet
---@param side string
---@param sender integer
@@ -205,6 +209,8 @@ function pocket.comms(version, nic, pkt_channel, svr_channel, crd_channel, range
-- handle a packet
---@param packet mgmt_frame|capi_frame|nil
function public.handle_packet(packet)
local diag = iocontrol.get_db().diag
if packet ~= nil then
local l_chan = packet.scada_frame.local_channel()
local r_chan = packet.scada_frame.remote_channel()
@@ -231,47 +237,9 @@ function pocket.comms(version, nic, pkt_channel, svr_channel, crd_channel, range
-- feed watchdog on valid sequence number
api_watchdog.feed()
if protocol == PROTOCOL.COORD_API then
---@cast packet capi_frame
elseif protocol == PROTOCOL.SCADA_MGMT then
if protocol == PROTOCOL.SCADA_MGMT then
---@cast packet mgmt_frame
if packet.type == SCADA_MGMT_TYPE.ESTABLISH then
-- connection with coordinator established
if packet.length == 1 then
local est_ack = packet.data[1]
if est_ack == ESTABLISH_ACK.ALLOW then
log.info("coordinator connection established")
self.establish_delay_counter = 0
self.api.linked = true
self.api.addr = src_addr
if self.sv.linked then
coreio.report_link_state(LINK_STATE.LINKED)
else
coreio.report_link_state(LINK_STATE.API_LINK_ONLY)
end
elseif est_ack == ESTABLISH_ACK.DENY then
if self.api.last_est_ack ~= est_ack then
log.info("coordinator connection denied")
end
elseif est_ack == ESTABLISH_ACK.COLLISION then
if self.api.last_est_ack ~= est_ack then
log.info("coordinator connection denied due to collision")
end
elseif est_ack == ESTABLISH_ACK.BAD_VERSION then
if self.api.last_est_ack ~= est_ack then
log.info("coordinator comms version mismatch")
end
else
log.debug("coordinator SCADA_MGMT establish packet reply unsupported")
end
self.api.last_est_ack = est_ack
else
log.debug("coordinator SCADA_MGMT establish packet length mismatch")
end
elseif self.api.linked then
if self.api.linked then
if packet.type == SCADA_MGMT_TYPE.KEEP_ALIVE then
-- keep alive request received, echo back
if packet.length == 1 then
@@ -298,6 +266,42 @@ function pocket.comms(version, nic, pkt_channel, svr_channel, crd_channel, range
else
log.debug("received unknown SCADA_MGMT packet type " .. packet.type .. " from coordinator")
end
elseif packet.type == SCADA_MGMT_TYPE.ESTABLISH then
-- connection with coordinator established
if packet.length == 1 then
local est_ack = packet.data[1]
if est_ack == ESTABLISH_ACK.ALLOW then
log.info("coordinator connection established")
self.establish_delay_counter = 0
self.api.linked = true
self.api.addr = src_addr
if self.sv.linked then
iocontrol.report_link_state(LINK_STATE.LINKED)
else
iocontrol.report_link_state(LINK_STATE.API_LINK_ONLY)
end
elseif est_ack == ESTABLISH_ACK.DENY then
if self.api.last_est_ack ~= est_ack then
log.info("coordinator connection denied")
end
elseif est_ack == ESTABLISH_ACK.COLLISION then
if self.api.last_est_ack ~= est_ack then
log.info("coordinator connection denied due to collision")
end
elseif est_ack == ESTABLISH_ACK.BAD_VERSION then
if self.api.last_est_ack ~= est_ack then
log.info("coordinator comms version mismatch")
end
else
log.debug("coordinator SCADA_MGMT establish packet reply unsupported")
end
self.api.last_est_ack = est_ack
else
log.debug("coordinator SCADA_MGMT establish packet length mismatch")
end
else
log.debug("discarding coordinator non-link SCADA_MGMT packet before linked")
end
@@ -325,43 +329,7 @@ function pocket.comms(version, nic, pkt_channel, svr_channel, crd_channel, range
-- handle packet
if protocol == PROTOCOL.SCADA_MGMT then
---@cast packet mgmt_frame
if packet.type == SCADA_MGMT_TYPE.ESTABLISH then
-- connection with supervisor established
if packet.length == 1 then
local est_ack = packet.data[1]
if est_ack == ESTABLISH_ACK.ALLOW then
log.info("supervisor connection established")
self.establish_delay_counter = 0
self.sv.linked = true
self.sv.addr = src_addr
if self.api.linked then
coreio.report_link_state(LINK_STATE.LINKED)
else
coreio.report_link_state(LINK_STATE.SV_LINK_ONLY)
end
elseif est_ack == ESTABLISH_ACK.DENY then
if self.sv.last_est_ack ~= est_ack then
log.info("supervisor connection denied")
end
elseif est_ack == ESTABLISH_ACK.COLLISION then
if self.sv.last_est_ack ~= est_ack then
log.info("supervisor connection denied due to collision")
end
elseif est_ack == ESTABLISH_ACK.BAD_VERSION then
if self.sv.last_est_ack ~= est_ack then
log.info("supervisor comms version mismatch")
end
else
log.debug("supervisor SCADA_MGMT establish packet reply unsupported")
end
self.sv.last_est_ack = est_ack
else
log.debug("supervisor SCADA_MGMT establish packet length mismatch")
end
elseif self.sv.linked then
if self.sv.linked then
if packet.type == SCADA_MGMT_TYPE.KEEP_ALIVE then
-- keep alive request received, echo back
if packet.length == 1 then
@@ -385,9 +353,90 @@ function pocket.comms(version, nic, pkt_channel, svr_channel, crd_channel, range
self.sv.r_seq_num = nil
self.sv.addr = comms.BROADCAST
log.info("supervisor server connection closed by remote host")
elseif packet.type == SCADA_MGMT_TYPE.DIAG_TONE_GET then
if packet.length == 8 then
for i = 1, #packet.data do
diag.tone_test.tone_indicators[i].update(packet.data[i] == true)
end
else
log.debug("supervisor SCADA diag alarm states packet length mismatch")
end
elseif packet.type == SCADA_MGMT_TYPE.DIAG_TONE_SET then
if packet.length == 1 and packet.data[1] == false then
diag.tone_test.ready_warn.set_value("testing denied")
log.debug("supervisor SCADA diag tone set failed")
elseif packet.length == 2 and type(packet.data[2]) == "table" then
local ready = packet.data[1]
local states = packet.data[2]
diag.tone_test.ready_warn.set_value(util.trinary(ready, "", "system not ready"))
for i = 1, #states do
if diag.tone_test.tone_buttons[i] ~= nil then
diag.tone_test.tone_buttons[i].set_value(states[i] == true)
diag.tone_test.tone_indicators[i].update(states[i] == true)
end
end
else
log.debug("supervisor SCADA diag tone set packet length/type mismatch")
end
elseif packet.type == SCADA_MGMT_TYPE.DIAG_ALARM_SET then
if packet.length == 1 and packet.data[1] == false then
diag.tone_test.ready_warn.set_value("testing denied")
log.debug("supervisor SCADA diag alarm set failed")
elseif packet.length == 2 and type(packet.data[2]) == "table" then
local ready = packet.data[1]
local states = packet.data[2]
diag.tone_test.ready_warn.set_value(util.trinary(ready, "", "system not ready"))
for i = 1, #states do
if diag.tone_test.alarm_buttons[i] ~= nil then
diag.tone_test.alarm_buttons[i].set_value(states[i] == true)
end
end
else
log.debug("supervisor SCADA diag alarm set packet length/type mismatch")
end
else
log.debug("received unknown SCADA_MGMT packet type " .. packet.type .. " from supervisor")
end
elseif packet.type == SCADA_MGMT_TYPE.ESTABLISH then
-- connection with supervisor established
if packet.length == 1 then
local est_ack = packet.data[1]
if est_ack == ESTABLISH_ACK.ALLOW then
log.info("supervisor connection established")
self.establish_delay_counter = 0
self.sv.linked = true
self.sv.addr = src_addr
if self.api.linked then
iocontrol.report_link_state(LINK_STATE.LINKED)
else
iocontrol.report_link_state(LINK_STATE.SV_LINK_ONLY)
end
elseif est_ack == ESTABLISH_ACK.DENY then
if self.sv.last_est_ack ~= est_ack then
log.info("supervisor connection denied")
end
elseif est_ack == ESTABLISH_ACK.COLLISION then
if self.sv.last_est_ack ~= est_ack then
log.info("supervisor connection denied due to collision")
end
elseif est_ack == ESTABLISH_ACK.BAD_VERSION then
if self.sv.last_est_ack ~= est_ack then
log.info("supervisor comms version mismatch")
end
else
log.debug("supervisor SCADA_MGMT establish packet reply unsupported")
end
self.sv.last_est_ack = est_ack
else
log.debug("supervisor SCADA_MGMT establish packet length mismatch")
end
else
log.debug("discarding supervisor non-link SCADA_MGMT packet before linked")
end

View File

@@ -4,21 +4,21 @@
require("/initenv").init_env()
local crash = require("scada-common.crash")
local log = require("scada-common.log")
local network = require("scada-common.network")
local ppm = require("scada-common.ppm")
local tcd = require("scada-common.tcd")
local util = require("scada-common.util")
local crash = require("scada-common.crash")
local log = require("scada-common.log")
local network = require("scada-common.network")
local ppm = require("scada-common.ppm")
local tcd = require("scada-common.tcd")
local util = require("scada-common.util")
local core = require("graphics.core")
local core = require("graphics.core")
local config = require("pocket.config")
local coreio = require("pocket.coreio")
local pocket = require("pocket.pocket")
local renderer = require("pocket.renderer")
local config = require("pocket.config")
local iocontrol = require("pocket.iocontrol")
local pocket = require("pocket.pocket")
local renderer = require("pocket.renderer")
local POCKET_VERSION = "alpha-v0.5.1"
local POCKET_VERSION = "v0.6.0-alpha"
local println = util.println
local println_ts = util.println_ts
@@ -73,7 +73,7 @@ local function main()
network.init_mac(config.AUTH_KEY)
end
coreio.report_link_state(coreio.LINK_STATE.UNLINKED)
iocontrol.report_link_state(iocontrol.LINK_STATE.UNLINKED)
-- get the communications modem
local modem = ppm.get_wireless_modem()
@@ -104,6 +104,9 @@ local function main()
local MAIN_CLOCK = 0.5
local loop_clock = util.new_clock(MAIN_CLOCK)
-- init I/O control
iocontrol.init_core(pocket_comms)
----------------------------------------
-- start the UI
----------------------------------------
@@ -112,7 +115,7 @@ local function main()
if not ui_ok then
renderer.close_ui()
println(util.c("UI error: ", message))
log.error(util.c("startup> GUI crashed with error ", message))
log.error(util.c("startup> GUI render failed with error ", message))
else
-- start clock
loop_clock.start()
@@ -128,6 +131,9 @@ local function main()
conn_wd.api.feed()
log.debug("startup> conn watchdog started")
local io_db = iocontrol.get_db()
local nav = io_db.nav
-- main event loop
while true do
local event, param1, param2, param3, param4, param5 = util.pull_event()
@@ -140,6 +146,13 @@ local function main()
-- relink if necessary
pocket_comms.link_update()
-- update any tasks for the active page
if (type(nav.tasks[nav.page]) == "table") then
for i = 1, #nav.tasks[nav.page] do
nav.tasks[nav.page][i]()
end
end
loop_clock.start()
elseif conn_wd.sv.is_timer(param1) then
-- supervisor watchdog timeout

View File

@@ -2,17 +2,18 @@
-- Pocket GUI Root
--
local coreio = require("pocket.coreio")
local iocontrol = require("pocket.iocontrol")
local style = require("pocket.ui.style")
local conn_waiting = require("pocket.ui.components.conn_waiting")
local home_page = require("pocket.ui.pages.home_page")
local unit_page = require("pocket.ui.pages.unit_page")
local reactor_page = require("pocket.ui.pages.reactor_page")
local boiler_page = require("pocket.ui.pages.boiler_page")
local diag_page = require("pocket.ui.pages.diag_page")
local home_page = require("pocket.ui.pages.home_page")
local reactor_page = require("pocket.ui.pages.reactor_page")
local turbine_page = require("pocket.ui.pages.turbine_page")
local unit_page = require("pocket.ui.pages.unit_page")
local core = require("graphics.core")
@@ -22,6 +23,9 @@ local TextBox = require("graphics.elements.textbox")
local Sidebar = require("graphics.elements.controls.sidebar")
local LINK_STATE = iocontrol.LINK_STATE
local NAV_PAGE = iocontrol.NAV_PAGE
local TEXT_ALIGN = core.TEXT_ALIGN
local cpair = core.cpair
@@ -29,6 +33,9 @@ local cpair = core.cpair
-- create new main view
---@param main graphics_element main displaybox
local function init(main)
local nav = iocontrol.get_db().nav
local ps = iocontrol.get_db().ps
-- window header message
TextBox{parent=main,y=1,text="",alignment=TEXT_ALIGN.LEFT,height=1,fg_bg=style.header}
@@ -45,10 +52,10 @@ local function init(main)
local root_pane = MultiPane{parent=root_pane_div,x=1,y=1,panes=root_panes}
root_pane.register(coreio.core_ps(), "link_state", function (state)
if state == coreio.LINK_STATE.UNLINKED or state == coreio.LINK_STATE.API_LINK_ONLY then
root_pane.register(ps, "link_state", function (state)
if state == LINK_STATE.UNLINKED or state == LINK_STATE.API_LINK_ONLY then
root_pane.set_value(1)
elseif state == coreio.LINK_STATE.SV_LINK_ONLY then
elseif state == LINK_STATE.SV_LINK_ONLY then
root_pane.set_value(2)
else
root_pane.set_value(3)
@@ -81,19 +88,36 @@ local function init(main)
{
char = "T",
color = cpair(colors.black,colors.white)
},
{
char = "D",
color = cpair(colors.black,colors.orange)
}
}
local pane_1 = home_page(page_div)
local pane_2 = unit_page(page_div)
local pane_3 = reactor_page(page_div)
local pane_4 = boiler_page(page_div)
local pane_5 = turbine_page(page_div)
local panes = { pane_1, pane_2, pane_3, pane_4, pane_5 }
local panes = { home_page(page_div), unit_page(page_div), reactor_page(page_div), boiler_page(page_div), turbine_page(page_div), diag_page(page_div) }
local page_pane = MultiPane{parent=page_div,x=1,y=1,panes=panes}
Sidebar{parent=main_pane,x=1,y=1,tabs=sidebar_tabs,fg_bg=cpair(colors.white,colors.gray),callback=page_pane.set_value}
local function navigate_sidebar(page)
if page == 1 then
nav.page = nav.sub_pages[NAV_PAGE.HOME]
elseif page == 2 then
nav.page = nav.sub_pages[NAV_PAGE.UNITS]
elseif page == 3 then
nav.page = nav.sub_pages[NAV_PAGE.REACTORS]
elseif page == 4 then
nav.page = nav.sub_pages[NAV_PAGE.BOILERS]
elseif page == 5 then
nav.page = nav.sub_pages[NAV_PAGE.TURBINES]
elseif page == 6 then
nav.page = nav.sub_pages[NAV_PAGE.DIAG]
end
page_pane.set_value(page)
end
Sidebar{parent=main_pane,x=1,y=1,tabs=sidebar_tabs,fg_bg=cpair(colors.white,colors.gray),callback=navigate_sidebar}
end
return init

View File

@@ -0,0 +1,147 @@
local iocontrol = require("pocket.iocontrol")
local core = require("graphics.core")
local Div = require("graphics.elements.div")
local MultiPane = require("graphics.elements.multipane")
local TextBox = require("graphics.elements.textbox")
local IndicatorLight = require("graphics.elements.indicators.light")
local App = require("graphics.elements.controls.app")
local Checkbox = require("graphics.elements.controls.checkbox")
local PushButton = require("graphics.elements.controls.push_button")
local SwitchButton = require("graphics.elements.controls.switch_button")
local cpair = core.cpair
local NAV_PAGE = iocontrol.NAV_PAGE
local TEXT_ALIGN = core.TEXT_ALIGN
-- new diagnostics page view
---@param root graphics_element parent
local function new_view(root)
local db = iocontrol.get_db()
local main = Div{parent=root,x=1,y=1}
local diag_home = Div{parent=main,x=1,y=1}
TextBox{parent=diag_home,text="Diagnostic Apps",x=1,y=2,height=1,alignment=TEXT_ALIGN.CENTER}
local alarm_test = Div{parent=main,x=1,y=1}
local panes = { diag_home, alarm_test }
local page_pane = MultiPane{parent=main,x=1,y=1,panes=panes}
local function navigate_diag()
page_pane.set_value(1)
db.nav.page = NAV_PAGE.DIAG
db.nav.sub_pages[NAV_PAGE.DIAG] = NAV_PAGE.DIAG
end
local function navigate_alarm()
page_pane.set_value(2)
db.nav.page = NAV_PAGE.D_ALARMS
db.nav.sub_pages[NAV_PAGE.DIAG] = NAV_PAGE.D_ALARMS
end
------------------------
-- Alarm Testing Page --
------------------------
db.nav.register_task(NAV_PAGE.D_ALARMS, db.diag.tone_test.get_tone_states)
local ttest = db.diag.tone_test
local c_wht_gray = cpair(colors.white, colors.gray)
local c_red_gray = cpair(colors.red, colors.gray)
local c_yel_gray = cpair(colors.yellow, colors.gray)
local c_blue_gray = cpair(colors.blue, colors.gray)
local audio = Div{parent=alarm_test,x=1,y=1}
TextBox{parent=audio,y=1,text="Alarm Sounder Tests",height=1,alignment=TEXT_ALIGN.CENTER}
ttest.ready_warn = TextBox{parent=audio,y=2,text="",height=1,alignment=TEXT_ALIGN.CENTER,fg_bg=cpair(colors.yellow,colors.black)}
PushButton{parent=audio,x=13,y=18,text="\x11 BACK",min_width=8,fg_bg=cpair(colors.black,colors.lightGray),active_fg_bg=c_wht_gray,callback=navigate_diag}
local tones = Div{parent=audio,x=2,y=3,height=10,width=8,fg_bg=cpair(colors.black,colors.yellow)}
TextBox{parent=tones,text="Tones",height=1,alignment=TEXT_ALIGN.CENTER,fg_bg=audio.get_fg_bg()}
local test_btns = {}
test_btns[1] = SwitchButton{parent=tones,text="TEST 1",min_width=8,active_fg_bg=c_wht_gray,callback=ttest.test_1}
test_btns[2] = SwitchButton{parent=tones,text="TEST 2",min_width=8,active_fg_bg=c_wht_gray,callback=ttest.test_2}
test_btns[3] = SwitchButton{parent=tones,text="TEST 3",min_width=8,active_fg_bg=c_wht_gray,callback=ttest.test_3}
test_btns[4] = SwitchButton{parent=tones,text="TEST 4",min_width=8,active_fg_bg=c_wht_gray,callback=ttest.test_4}
test_btns[5] = SwitchButton{parent=tones,text="TEST 5",min_width=8,active_fg_bg=c_wht_gray,callback=ttest.test_5}
test_btns[6] = SwitchButton{parent=tones,text="TEST 6",min_width=8,active_fg_bg=c_wht_gray,callback=ttest.test_6}
test_btns[7] = SwitchButton{parent=tones,text="TEST 7",min_width=8,active_fg_bg=c_wht_gray,callback=ttest.test_7}
test_btns[8] = SwitchButton{parent=tones,text="TEST 8",min_width=8,active_fg_bg=c_wht_gray,callback=ttest.test_8}
ttest.tone_buttons = test_btns
local function stop_all_tones()
for i = 1, #test_btns do test_btns[i].set_value(false) end
ttest.stop_tones()
end
PushButton{parent=tones,text="STOP",min_width=8,active_fg_bg=c_wht_gray,fg_bg=cpair(colors.black,colors.red),callback=stop_all_tones}
local alarms = Div{parent=audio,x=11,y=3,height=15,fg_bg=cpair(colors.lightGray,colors.black)}
TextBox{parent=alarms,text="Alarms (\x13)",height=1,alignment=TEXT_ALIGN.CENTER,fg_bg=audio.get_fg_bg()}
local alarm_btns = {}
alarm_btns[1] = Checkbox{parent=alarms,label="BREACH",min_width=15,box_fg_bg=c_red_gray,callback=ttest.test_breach}
alarm_btns[2] = Checkbox{parent=alarms,label="RADIATION",min_width=15,box_fg_bg=c_red_gray,callback=ttest.test_rad}
alarm_btns[3] = Checkbox{parent=alarms,label="RCT LOST",min_width=15,box_fg_bg=c_red_gray,callback=ttest.test_lost}
alarm_btns[4] = Checkbox{parent=alarms,label="CRIT DAMAGE",min_width=15,box_fg_bg=c_red_gray,callback=ttest.test_crit}
alarm_btns[5] = Checkbox{parent=alarms,label="DAMAGE",min_width=15,box_fg_bg=c_red_gray,callback=ttest.test_dmg}
alarm_btns[6] = Checkbox{parent=alarms,label="OVER TEMP",min_width=15,box_fg_bg=c_red_gray,callback=ttest.test_overtemp}
alarm_btns[7] = Checkbox{parent=alarms,label="HIGH TEMP",min_width=15,box_fg_bg=c_yel_gray,callback=ttest.test_hightemp}
alarm_btns[8] = Checkbox{parent=alarms,label="WASTE LEAK",min_width=15,box_fg_bg=c_red_gray,callback=ttest.test_wasteleak}
alarm_btns[9] = Checkbox{parent=alarms,label="WASTE HIGH",min_width=15,box_fg_bg=c_yel_gray,callback=ttest.test_highwaste}
alarm_btns[10] = Checkbox{parent=alarms,label="RPS TRANS",min_width=15,box_fg_bg=c_yel_gray,callback=ttest.test_rps}
alarm_btns[11] = Checkbox{parent=alarms,label="RCS TRANS",min_width=15,box_fg_bg=c_yel_gray,callback=ttest.test_rcs}
alarm_btns[12] = Checkbox{parent=alarms,label="TURBINE TRP",min_width=15,box_fg_bg=c_red_gray,callback=ttest.test_turbinet}
ttest.alarm_buttons = alarm_btns
local function stop_all_alarms()
for i = 1, #alarm_btns do alarm_btns[i].set_value(false) end
ttest.stop_alarms()
end
PushButton{parent=alarms,x=3,y=15,text="STOP \x13",min_width=8,fg_bg=cpair(colors.black,colors.red),active_fg_bg=c_wht_gray,callback=stop_all_alarms}
local states = Div{parent=audio,x=2,y=14,height=5,width=8}
TextBox{parent=states,text="States",height=1,alignment=TEXT_ALIGN.CENTER}
local t_1 = IndicatorLight{parent=states,label="1",colors=c_blue_gray}
local t_2 = IndicatorLight{parent=states,label="2",colors=c_blue_gray}
local t_3 = IndicatorLight{parent=states,label="3",colors=c_blue_gray}
local t_4 = IndicatorLight{parent=states,label="4",colors=c_blue_gray}
local t_5 = IndicatorLight{parent=states,x=6,y=2,label="5",colors=c_blue_gray}
local t_6 = IndicatorLight{parent=states,x=6,label="6",colors=c_blue_gray}
local t_7 = IndicatorLight{parent=states,x=6,label="7",colors=c_blue_gray}
local t_8 = IndicatorLight{parent=states,x=6,label="8",colors=c_blue_gray}
ttest.tone_indicators = { t_1, t_2, t_3, t_4, t_5, t_6, t_7, t_8 }
--------------
-- App List --
--------------
App{parent=diag_home,x=3,y=4,text="\x0f",title="Alarm",callback=navigate_alarm,app_fg_bg=cpair(colors.black,colors.red),active_fg_bg=cpair(colors.white,colors.gray)}
App{parent=diag_home,x=10,y=4,text="\x1e",title="LoopT",callback=function()end,app_fg_bg=cpair(colors.black,colors.cyan)}
App{parent=diag_home,x=17,y=4,text="@",title="Comps",callback=function()end,app_fg_bg=cpair(colors.black,colors.orange)}
return main
end
return new_view

View File

@@ -1,20 +1,21 @@
-- local style = require("pocket.ui.style")
local core = require("graphics.core")
local core = require("graphics.core")
local Div = require("graphics.elements.div")
local Div = require("graphics.elements.div")
local TextBox = require("graphics.elements.textbox")
local App = require("graphics.elements.controls.app")
-- local cpair = core.cpair
local TEXT_ALIGN = core.TEXT_ALIGN
local cpair = core.cpair
-- new home page view
---@param root graphics_element parent
local function new_view(root)
local main = Div{parent=root,x=1,y=1}
TextBox{parent=main,text="HOME",x=1,y=1,height=1,alignment=TEXT_ALIGN.CENTER}
App{parent=main,x=3,y=2,text="\x17",title="PRC",callback=function()end,app_fg_bg=cpair(colors.black,colors.purple)}
App{parent=main,x=10,y=2,text="\x15",title="CTL",callback=function()end,app_fg_bg=cpair(colors.black,colors.green)}
App{parent=main,x=17,y=2,text="\x08",title="DEV",callback=function()end,app_fg_bg=cpair(colors.black,colors.lightGray)}
App{parent=main,x=3,y=7,text="\x7f",title="Waste",callback=function()end,app_fg_bg=cpair(colors.black,colors.brown)}
App{parent=main,x=10,y=7,text="\xb6",title="Guide",callback=function()end,app_fg_bg=cpair(colors.black,colors.cyan)}
return main
end

View File

@@ -1,5 +1,5 @@
--
-- Main SCADA Coordinator GUI
-- Reactor PLC Front Panel GUI
--
local types = require("scada-common.types")
@@ -28,7 +28,7 @@ local TEXT_ALIGN = core.TEXT_ALIGN
local cpair = core.cpair
local border = core.border
-- create new main view
-- create new front panel view
---@param panel graphics_element main displaybox
local function init(panel)
local header = TextBox{parent=panel,y=1,text="REACTOR PLC - UNIT ?",alignment=TEXT_ALIGN.CENTER,height=1,fg_bg=style.header}

View File

@@ -12,6 +12,7 @@ local cpair = core.cpair
-- remap global colors
colors.ivory = colors.pink
colors.yellow_hc = colors.purple
colors.red_off = colors.brown
colors.yellow_off = colors.magenta
colors.green_off = colors.lime
@@ -28,7 +29,7 @@ style.colors = {
{ c = colors.cyan, hex = 0x34bac8 },
{ c = colors.lightBlue, hex = 0x6cc0f2 },
{ c = colors.blue, hex = 0x0096ff },
{ c = colors.purple, hex = 0xb156ee },
{ c = colors.purple, hex = 0xb156ee }, -- YELLOW HIGH CONTRAST
{ c = colors.pink, hex = 0xdcd9ca }, -- IVORY
{ c = colors.magenta, hex = 0x85862c }, -- YELLOW OFF
-- { c = colors.white, hex = 0xdcd9ca },

View File

@@ -929,47 +929,7 @@ function plc.comms(id, version, nic, plc_channel, svr_channel, range, reactor, r
---@cast packet mgmt_frame
-- if linked, only accept packets from configured supervisor
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 due to re-establish")
else
if est_ack == ESTABLISH_ACK.DENY then
println_ts("received unsolicited link denial, unlinking")
log.warning("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.debug("unsolicited unknown establish request response")
end
-- unlink
self.sv_addr = comms.BROADCAST
self.linked = false
end
-- clear this since this is for something that was unsolicited
self.last_est_ack = ESTABLISH_ACK.ALLOW
-- report link state
databus.tx_link_state(est_ack + 1)
else
log.debug("SCADA_MGMT establish packet length mismatch")
end
elseif packet.type == SCADA_MGMT_TYPE.KEEP_ALIVE 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]

View File

@@ -19,7 +19,7 @@ local plc = require("reactor-plc.plc")
local renderer = require("reactor-plc.renderer")
local threads = require("reactor-plc.threads")
local R_PLC_VERSION = "v1.5.0"
local R_PLC_VERSION = "v1.5.6"
local println = util.println
local println_ts = util.println_ts
@@ -190,7 +190,7 @@ local function main()
renderer.close_ui()
println_ts(util.c("UI error: ", message))
println("init> running without front panel")
log.error(util.c("GUI crashed with error ", message))
log.error(util.c("front panel GUI render failed with error ", message))
log.info("init> running in headless mode without front panel")
end
end

View File

@@ -77,7 +77,7 @@ function threads.thread__main(smem, init)
loop_clock.start()
-- send updated data
if nic.connected() then
if nic.is_connected() then
if plc_comms.is_linked() then
smem.q.mq_comms_tx.push_command(MQ__COMM_CMD.SEND_STATUS)
else
@@ -116,7 +116,7 @@ function threads.thread__main(smem, init)
smem.q.mq_rps.push_command(MQ__RPS_CMD.SCRAM)
-- determine if we are still in a degraded state
if (not networked) or nic.connected() then
if (not networked) or nic.is_connected() then
plc_state.degraded = false
end
@@ -146,7 +146,7 @@ function threads.thread__main(smem, init)
-- update indicators
databus.tx_hw_status(plc_state)
elseif event == "modem_message" and networked and plc_state.init_ok and nic.connected() then
elseif event == "modem_message" and networked and plc_state.init_ok and nic.is_connected() then
-- got a packet
local packet = plc_comms.parse_packet(param1, param2, param3, param4, param5)
if packet ~= nil then
@@ -165,7 +165,7 @@ function threads.thread__main(smem, init)
local type, device = ppm.handle_unmount(param1)
if type ~= nil and device ~= nil then
if type == "fissionReactorLogicAdapter" then
if device == plc_dev.reactor then
println_ts("reactor disconnected!")
log.error("reactor logic adapter disconnected")
@@ -177,16 +177,21 @@ function threads.thread__main(smem, init)
nic.disconnect()
println_ts("comms modem disconnected!")
log.error("comms modem disconnected")
log.warning("comms modem disconnected")
plc_state.no_modem = true
local other_modem = ppm.get_wireless_modem()
if other_modem then
log.info("found another wireless modem, using it for comms")
nic.connect(other_modem)
else
plc_state.no_modem = true
plc_state.degraded = 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)
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
end
plc_state.degraded = true
else
log.warning("non-comms modem disconnected")
end
@@ -200,7 +205,7 @@ function threads.thread__main(smem, init)
local type, device = ppm.mount(param1)
if type ~= nil and device ~= nil then
if type == "fissionReactorLogicAdapter" then
if plc_state.no_reactor and (type == "fissionReactorLogicAdapter") then
-- reconnected reactor
plc_dev.reactor = device
plc_state.no_reactor = false
@@ -230,7 +235,7 @@ function threads.thread__main(smem, init)
rps.reset()
end
elseif networked and type == "modem" then
if device.isWireless() then
if device.isWireless() and not nic.is_connected() then
-- reconnected modem
plc_dev.modem = device
plc_state.no_modem = false
@@ -244,6 +249,8 @@ function threads.thread__main(smem, init)
if not plc_state.no_reactor then
plc_state.degraded = false
end
elseif device.isWireless() then
log.info("unused wireless modem reconnected")
else
log.info("wired modem reconnected")
end

View File

@@ -15,6 +15,10 @@ config.COMMS_TIMEOUT = 5
-- all devices on the same network must use the same key
-- config.AUTH_KEY = "SCADAfacility123"
-- 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
-- log path
config.LOG_PATH = "/log.txt"
-- log mode

View File

@@ -37,6 +37,12 @@ function databus.tx_hw_modem(has_modem)
databus.ps.publish("has_modem", has_modem)
end
-- transmit the number of speakers connected
---@param count integer
function databus.tx_hw_spkr_count(count)
databus.ps.publish("speaker_count", count)
end
-- transmit unit hardware type across the bus
---@param uid integer unit ID
---@param type RTU_UNIT_TYPE

View File

@@ -2,7 +2,7 @@ local rtu = require("rtu.rtu")
local boilerv_rtu = {}
-- create new boiler (mek 10.1+) device
-- create new boiler device
---@nodiscard
---@param boiler table
---@return rtu_device interface, boolean faulted

48
rtu/dev/dynamicv_rtu.lua Normal file
View File

@@ -0,0 +1,48 @@
local rtu = require("rtu.rtu")
local dynamicv_rtu = {}
-- create new dynamic tank device
---@nodiscard
---@param dynamic_tank table
---@return rtu_device interface, boolean faulted
function dynamicv_rtu.new(dynamic_tank)
local unit = rtu.init_unit()
-- disable auto fault clearing
dynamic_tank.__p_clear_fault()
dynamic_tank.__p_disable_afc()
-- discrete inputs --
unit.connect_di(dynamic_tank.isFormed)
-- coils --
unit.connect_coil(function () dynamic_tank.incrementContainerEditMode() end, function () end)
unit.connect_coil(function () dynamic_tank.decrementContainerEditMode() end, function () end)
-- input registers --
-- multiblock properties
unit.connect_input_reg(dynamic_tank.getLength)
unit.connect_input_reg(dynamic_tank.getWidth)
unit.connect_input_reg(dynamic_tank.getHeight)
unit.connect_input_reg(dynamic_tank.getMinPos)
unit.connect_input_reg(dynamic_tank.getMaxPos)
-- build properties
unit.connect_input_reg(dynamic_tank.getTankCapacity)
unit.connect_input_reg(dynamic_tank.getChemicalTankCapacity)
-- tanks/containers
unit.connect_input_reg(dynamic_tank.getStored)
unit.connect_input_reg(dynamic_tank.getFilledPercentage)
-- holding registers --
unit.connect_holding_reg(dynamic_tank.getContainerEditMode, dynamic_tank.setContainerEditMode)
-- check if any calls faulted
local faulted = dynamic_tank.__p_is_faulted()
dynamic_tank.__p_clear_fault()
dynamic_tank.__p_enable_afc()
return unit.interface(), faulted
end
return dynamicv_rtu

View File

@@ -2,7 +2,7 @@ local rtu = require("rtu.rtu")
local turbinev_rtu = {}
-- create new turbine (mek 10.1+) device
-- create new turbine device
---@nodiscard
---@param turbine table
---@return rtu_device interface, boolean faulted

View File

@@ -1,39 +1,31 @@
--
-- Main SCADA Coordinator GUI
-- RTU Front Panel GUI
--
local types = require("scada-common.types")
local util = require("scada-common.util")
local types = require("scada-common.types")
local util = require("scada-common.util")
local databus = require("rtu.databus")
local databus = require("rtu.databus")
local style = require("rtu.panel.style")
local style = require("rtu.panel.style")
local core = require("graphics.core")
local core = require("graphics.core")
local Div = require("graphics.elements.div")
local TextBox = require("graphics.elements.textbox")
local Div = require("graphics.elements.div")
local TextBox = require("graphics.elements.textbox")
local LED = require("graphics.elements.indicators.led")
local RGBLED = require("graphics.elements.indicators.ledrgb")
local DataIndicator = require("graphics.elements.indicators.data")
local LED = require("graphics.elements.indicators.led")
local RGBLED = require("graphics.elements.indicators.ledrgb")
local TEXT_ALIGN = core.TEXT_ALIGN
local cpair = core.cpair
local UNIT_TYPE_LABELS = {
"UNKNOWN",
"REDSTONE",
"BOILER",
"TURBINE",
"IND MATRIX",
"SPS",
"SNA",
"ENV DETECTOR"
}
local UNIT_TYPE_LABELS = { "UNKNOWN", "REDSTONE", "BOILER", "TURBINE", "DYNAMIC TANK", "IND MATRIX", "SPS", "SNA", "ENV DETECTOR" }
-- create new main view
-- create new front panel view
---@param panel graphics_element main displaybox
---@param units table unit list
local function init(panel, units)
@@ -71,6 +63,10 @@ local function init(panel, units)
local comp_id = util.sprintf("(%d)", os.getComputerID())
TextBox{parent=system,x=9,y=4,width=6,height=1,text=comp_id,fg_bg=cpair(colors.lightGray,colors.ivory)}
TextBox{parent=system,x=1,y=14,text="SPEAKERS",height=1,width=8,fg_bg=style.label}
local speaker_count = DataIndicator{parent=system,x=10,y=14,label="",format="%3d",value=0,width=3,fg_bg=cpair(colors.gray,colors.white)}
speaker_count.register(databus.ps, "speaker_count", speaker_count.update)
--
-- about label
--

View File

@@ -12,6 +12,7 @@ local cpair = core.cpair
-- remap global colors
colors.ivory = colors.pink
colors.yellow_hc = colors.purple
colors.red_off = colors.brown
colors.yellow_off = colors.magenta
colors.green_off = colors.lime
@@ -28,7 +29,7 @@ style.colors = {
{ c = colors.cyan, hex = 0x34bac8 },
{ c = colors.lightBlue, hex = 0x6cc0f2 },
{ c = colors.blue, hex = 0x0096ff },
{ c = colors.purple, hex = 0xb156ee },
{ c = colors.purple, hex = 0xb156ee }, -- YELLOW HIGH CONTRAST
{ c = colors.pink, hex = 0xdcd9ca }, -- IVORY
{ c = colors.magenta, hex = 0x85862c }, -- YELLOW OFF
-- { c = colors.white, hex = 0xdcd9ca },

View File

@@ -1,9 +1,11 @@
local audio = require("scada-common.audio")
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 config = require("rtu.config")
local databus = require("rtu.databus")
local modbus = require("rtu.modbus")
@@ -155,6 +157,48 @@ function rtu.init_unit()
return protected
end
-- create an alarm speaker sounder
---@param speaker table device peripheral
function rtu.init_sounder(speaker)
---@class rtu_speaker_sounder
local spkr_ctl = {
speaker = speaker,
name = ppm.get_iface(speaker),
playing = false,
stream = audio.new_stream(),
play = function () end,
stop = function () end,
continue = function () end
}
-- continue audio stream if playing
function spkr_ctl.continue()
if spkr_ctl.playing then
if spkr_ctl.speaker ~= nil and spkr_ctl.stream.has_next_block() then
local success = spkr_ctl.speaker.playAudio(spkr_ctl.stream.get_next_block(), config.SOUNDER_VOLUME)
if not success then log.error(util.c("rtu_sounder(", spkr_ctl.name, "): error playing audio")) end
end
end
end
-- start audio stream playback
function spkr_ctl.play()
if not spkr_ctl.playing then
spkr_ctl.playing = true
return spkr_ctl.continue()
end
end
-- stop audio stream playback
function spkr_ctl.stop()
spkr_ctl.playing = false
spkr_ctl.speaker.stop()
spkr_ctl.stream.stop()
end
return spkr_ctl
end
-- RTU Communications
---@nodiscard
---@param version string RTU version
@@ -312,7 +356,8 @@ function rtu.comms(version, nic, rtu_channel, svr_channel, range, conn_watchdog)
---@param packet modbus_frame|mgmt_frame
---@param units table RTU units
---@param rtu_state rtu_state
function public.handle_packet(packet, units, rtu_state)
---@param sounders table speaker alarm sounders
function public.handle_packet(packet, units, rtu_state, sounders)
-- print a log message to the terminal as long as the UI isn't running
local function println_ts(message) if not rtu_state.fp_ok then util.println_ts(message) end end
@@ -387,7 +432,49 @@ function rtu.comms(version, nic, rtu_channel, svr_channel, range, conn_watchdog)
elseif protocol == PROTOCOL.SCADA_MGMT then
---@cast packet mgmt_frame
-- SCADA management packet
if packet.type == SCADA_MGMT_TYPE.ESTABLISH then
if 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)
elseif packet.type == SCADA_MGMT_TYPE.RTU_TONE_ALARM then
-- alarm tone update from supervisor
if (packet.length == 1) and type(packet.data[1] == "table") and (#packet.data[1] == 8) then
local states = packet.data[1]
for i = 1, #sounders do
local s = sounders[i] ---@type rtu_speaker_sounder
-- set tone states
for id = 1, #states do s.stream.set_active(id, states[id] == true) end
end
end
else
-- not supported
log.debug("received unsupported SCADA_MGMT message type " .. packet.type)
end
elseif packet.type == SCADA_MGMT_TYPE.ESTABLISH then
if packet.length == 1 then
local est_ack = packet.data[1]
@@ -421,36 +508,6 @@ function rtu.comms(version, nic, rtu_channel, svr_channel, range, conn_watchdog)
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.debug("received unsupported SCADA_MGMT message type " .. packet.type)
end
else
log.debug("discarding non-link SCADA_MGMT packet before linked")
end

View File

@@ -4,6 +4,7 @@
require("/initenv").init_env()
local audio = require("scada-common.audio")
local comms = require("scada-common.comms")
local crash = require("scada-common.crash")
local log = require("scada-common.log")
@@ -22,6 +23,7 @@ local rtu = require("rtu.rtu")
local threads = require("rtu.threads")
local boilerv_rtu = require("rtu.dev.boilerv_rtu")
local dynamicv_rtu = require("rtu.dev.dynamicv_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")
@@ -29,7 +31,7 @@ local sna_rtu = require("rtu.dev.sna_rtu")
local sps_rtu = require("rtu.dev.sps_rtu")
local turbinev_rtu = require("rtu.dev.turbinev_rtu")
local RTU_VERSION = "v1.4.0"
local RTU_VERSION = "v1.6.0"
local RTU_UNIT_TYPE = types.RTU_UNIT_TYPE
local RTU_UNIT_HW_STATE = databus.RTU_UNIT_HW_STATE
@@ -95,6 +97,9 @@ local function main()
return
end
-- generate alarm tones
audio.generate_tones()
---@class rtu_shared_memory
local __shared_memory = {
-- RTU system state flags
@@ -105,6 +110,11 @@ local function main()
shutdown = false
},
-- RTU gateway devices (not RTU units)
rtu_dev = {
sounders = {}
},
-- system objects
rtu_sys = {
nic = network.nic(modem),
@@ -342,6 +352,18 @@ local function main()
log.fatal(util.c("configure> failed to check if '", name, "' is a formed turbine multiblock"))
return false
end
elseif type == "dynamicValve" then
-- dynamic tank multiblock
rtu_type = RTU_UNIT_TYPE.DYNAMIC_VALVE
rtu_iface, faulted = dynamicv_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 dynamic tank multiblock"))
return false
end
elseif type == "inductionPort" then
-- induction matrix multiblock
rtu_type = RTU_UNIT_TYPE.IMATRIX
@@ -464,10 +486,22 @@ local function main()
renderer.close_ui()
println_ts(util.c("UI error: ", message))
println("startup> running without front panel")
log.error(util.c("GUI crashed with error ", message))
log.error(util.c("front panel GUI render failed with error ", message))
log.info("startup> running in headless mode without front panel")
end
-- find and setup all speakers
local speakers = ppm.get_all_devices("speaker")
for _, s in pairs(speakers) do
local sounder = rtu.init_sounder(s)
table.insert(__shared_memory.rtu_dev.sounders, sounder)
log.debug(util.c("startup> added speaker, attached as ", sounder.name))
end
databus.tx_hw_spkr_count(#__shared_memory.rtu_dev.sounders)
-- start connection watchdog
smem_sys.conn_watchdog = util.new_watchdog(config.COMMS_TIMEOUT)
log.debug("startup> conn watchdog started")

View File

@@ -8,8 +8,10 @@ local util = require("scada-common.util")
local databus = require("rtu.databus")
local modbus = require("rtu.modbus")
local renderer = require("rtu.renderer")
local rtu = require("rtu.rtu")
local boilerv_rtu = require("rtu.dev.boilerv_rtu")
local dynamicv_rtu = require("rtu.dev.dynamicv_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")
@@ -23,7 +25,7 @@ local threads = {}
local RTU_UNIT_TYPE = types.RTU_UNIT_TYPE
local UNIT_HW_STATE = databus.RTU_UNIT_HW_STATE
local MAIN_CLOCK = 2 -- (2Hz, 40 ticks)
local MAIN_CLOCK = 0.5 -- (2Hz, 10 ticks)
local COMMS_SLEEP = 100 -- (100ms, 2 ticks)
-- main thread
@@ -46,6 +48,7 @@ function threads.thread__main(smem)
-- load in from shared memory
local rtu_state = smem.rtu_state
local sounders = smem.rtu_dev.sounders
local nic = smem.rtu_sys.nic
local rtu_comms = smem.rtu_sys.rtu_comms
local conn_watchdog = smem.rtu_sys.conn_watchdog
@@ -65,6 +68,15 @@ function threads.thread__main(smem)
-- blink heartbeat indicator
databus.heartbeat()
-- update speaker states
for _, sounder in pairs(sounders) do
-- re-compute output if needed, then play audio if available
if sounder.stream.is_recompute_needed() then
sounder.stream.compute_buffer()
if sounder.stream.has_next_block() then sounder.play() else sounder.stop() end
end
end
-- start next clock timer
loop_clock.start()
@@ -97,12 +109,30 @@ function threads.thread__main(smem)
nic.disconnect()
println_ts("wireless modem disconnected!")
log.warning("comms modem disconnected!")
log.warning("comms modem disconnected")
databus.tx_hw_modem(false)
local other_modem = ppm.get_wireless_modem()
if other_modem then
log.info("found another wireless modem, using it for comms")
nic.connect(other_modem)
else
databus.tx_hw_modem(false)
end
else
log.warning("non-comms modem disconnected")
end
elseif type == "speaker" then
for i = 1, #sounders do
if sounders[i].speaker == device then
table.remove(sounders, i)
log.warning(util.c("speaker ", param1, " disconnected"))
println_ts("speaker disconnected")
databus.tx_hw_spkr_count(#sounders)
break
end
end
else
for i = 1, #units do
-- find disconnected device
@@ -127,7 +157,7 @@ function threads.thread__main(smem)
if type ~= nil and device ~= nil then
if type == "modem" then
if device.isWireless() then
if device.isWireless() and not nic.is_connected() then
-- reconnected modem
nic.connect(device)
@@ -135,9 +165,18 @@ function threads.thread__main(smem)
log.info("comms modem reconnected")
databus.tx_hw_modem(true)
elseif device.isWireless() then
log.info("unused wireless modem reconnected")
else
log.info("wired modem reconnected")
end
elseif type == "speaker" then
table.insert(sounders, rtu.init_sounder(device))
println_ts("speaker connected")
log.info(util.c("connected speaker ", param1))
databus.tx_hw_spkr_count(#sounders)
else
-- relink lost peripheral to correct unit entry
for i = 1, #units do
@@ -181,21 +220,22 @@ function threads.thread__main(smem)
databus.tx_unit_hw_type(unit.uid, unit.type)
end
-- note for multiblock structures: if not formed, indexing the multiblock functions results in a PPM fault
if unit.type == RTU_UNIT_TYPE.BOILER_VALVE then
unit.rtu, faulted = boilerv_rtu.new(device)
-- if not formed, indexing the multiblock functions would have resulted in a PPM fault
unit.formed = util.trinary(faulted, false, nil)
elseif unit.type == RTU_UNIT_TYPE.TURBINE_VALVE then
unit.rtu, faulted = turbinev_rtu.new(device)
-- if not formed, indexing the multiblock functions would have resulted in a PPM fault
unit.formed = util.trinary(faulted, false, nil)
elseif unit.type == RTU_UNIT_TYPE.DYNAMIC_VALVE then
unit.rtu, faulted = dynamicv_rtu.new(device)
unit.formed = util.trinary(faulted, false, nil)
elseif unit.type == RTU_UNIT_TYPE.IMATRIX then
unit.rtu, faulted = imatrix_rtu.new(device)
-- if not formed, indexing the multiblock functions would have resulted in a PPM fault
unit.formed = util.trinary(faulted, false, nil)
elseif unit.type == RTU_UNIT_TYPE.SPS then
unit.rtu, faulted = sps_rtu.new(device)
-- if not formed, indexing the multiblock functions would have resulted in a PPM fault
unit.formed = util.trinary(faulted, false, nil)
elseif unit.type == RTU_UNIT_TYPE.SNA then
unit.rtu, faulted = sna_rtu.new(device)
@@ -242,6 +282,15 @@ function threads.thread__main(smem)
elseif event == "mouse_click" or event == "mouse_up" or event == "mouse_drag" or event == "mouse_scroll" then
-- handle a mouse event
renderer.handle_mouse(core.events.new_mouse_event(event, param1, param2, param3))
elseif event == "speaker_audio_empty" then
-- handle empty speaker audio buffer
for i = 1, #sounders do
local sounder = sounders[i] ---@type rtu_speaker_sounder
if sounder.name == param1 then
sounder.continue()
break
end
end
end
-- check for termination request
@@ -289,6 +338,7 @@ function threads.thread__comms(smem)
-- load in from shared memory
local rtu_state = smem.rtu_state
local sounders = smem.rtu_dev.sounders
local rtu_comms = smem.rtu_sys.rtu_comms
local units = smem.rtu_sys.units
@@ -298,7 +348,9 @@ function threads.thread__comms(smem)
-- thread loop
while true do
-- check for messages in the message queue
local handle_start = util.time()
-- check for messages in the message queue while not shut down
while comms_queue.ready() and not rtu_state.shutdown do
local msg = comms_queue.pop()
@@ -309,15 +361,21 @@ function threads.thread__comms(smem)
-- 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)
-- handle the packet (rtu_state passed to allow setting link flag, sounders passed to manage alarm audio)
rtu_comms.handle_packet(msg.message, units, rtu_state, sounders)
end
end
-- quick yield
util.nop()
-- max 100ms spent processing queue
if util.time() - handle_start > 100 then
log.warning("comms thread exceeded 100ms queue process limit")
break
end
end
-- quick yield
util.nop()
-- check for termination request
if rtu_state.shutdown then
rtu_comms.close(rtu_state)
@@ -404,6 +462,12 @@ function threads.thread__unit_comms(smem, unit)
util.nop()
end
-- check for termination request
if rtu_state.shutdown then
log.info("rtu unit thread exiting -> " .. short_name)
break
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()
@@ -441,6 +505,12 @@ function threads.thread__unit_comms(smem, unit)
unit.rtu, faulted = turbinev_rtu.new(device)
unit.formed = device.isFormed()
unit.modbus_io = modbus.new(unit.rtu, true)
elseif type == "dynamicValve" and unit.type == RTU_UNIT_TYPE.DYNAMIC_VALVE then
-- dynamic tank multiblock
unit.device = device
unit.rtu, faulted = dynamicv_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
@@ -491,12 +561,6 @@ function threads.thread__unit_comms(smem, unit)
-- update hw status
databus.tx_unit_hw_status(unit.uid, unit.hw_state)
-- 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

313
scada-common/audio.lua Normal file
View File

@@ -0,0 +1,313 @@
--
-- Audio & Tone Control for Alarms
--
-- sounds modeled after https://www.e2s.com/references-and-guidelines/listen-and-download-alarm-tones
-- note: max samples = 0x20000 (128 * 1024 samples)
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 _05s_SAMPLES = 24000 -- half a second worth of samples
---@class audio
local audio = {}
---@enum TONE
local TONE = {
T_340Hz_Int_2Hz = 1,
T_544Hz_440Hz_Alt = 2,
T_660Hz_Int_125ms = 3,
T_745Hz_Int_1Hz = 4,
T_800Hz_Int = 5,
T_800Hz_1000Hz_Alt = 6,
T_1000Hz_Int = 7,
T_1800Hz_Int_4Hz = 8
}
audio.TONE = TONE
local tone_data = {
{ {}, {}, {}, {} }, -- 340Hz @ 2Hz Intermittent
{ {}, {}, {}, {} }, -- 544Hz 100mS / 440Hz 400mS Alternating
{ {}, {}, {}, {} }, -- 660Hz @ 125ms On 125ms Off
{ {}, {}, {}, {} }, -- 745Hz @ 1Hz Intermittent
{ {}, {}, {}, {} }, -- 800Hz @ 0.25s On 1.75s Off
{ {}, {}, {}, {} }, -- 800/1000Hz @ 0.25s Alternating
{ {}, {}, {}, {} }, -- 1KHz 1s on, 1s off Intermittent
{ {}, {}, {}, {} } -- 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)
tone_data[1][1][i] = val
tone_data[1][3][i] = val
tone_data[1][2][i] = 0
tone_data[1][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
tone_data[2][1][i] = value
tone_data[2][2][i] = value
tone_data[2][3][i] = value
tone_data[2][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)
tone_data[3][set][i] = val
t = (t + dt) % _2_PI
else
t = 0
tone_data[3][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)
tone_data[4][1][i] = val
tone_data[4][3][i] = val
tone_data[4][2][i] = 0
tone_data[4][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
tone_data[5][1][i] = val
else
tone_data[5][1][i] = 0
end
tone_data[5][2][i] = 0
tone_data[5][3][i] = 0
tone_data[5][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
tone_data[6][1][i] = val
tone_data[6][2][i] = val
tone_data[6][3][i] = val
tone_data[6][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)
tone_data[7][1][i] = val
tone_data[7][2][i] = val
tone_data[7][3][i] = 0
tone_data[7][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
tone_data[8][1][i] = val
tone_data[8][2][i] = val
tone_data[8][3][i] = val
tone_data[8][4][i] = val
end
end
--#endregion
-- generate all 8 tone sequences
function audio.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
-- 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
-- clear output buffer
---@param buffer table quad buffer
local function clear(buffer)
for i = 1, 4 do
for s = 1, _05s_SAMPLES do buffer[i][s] = 0 end
end
end
-- create a new audio tone stream controller
function audio.new_stream()
local self = {
any_active = false,
need_recompute = false,
next_block = 1,
-- split audio up into 0.5s samples, so specific components can be ended quicker
quad_buffer = { {}, {}, {}, {} },
-- all tone enable states
tone_active = { false, false, false, false, false, false, false, false }
}
clear(self.quad_buffer)
---@class tone_stream
local public = {}
-- add a tone to the output buffer
---@param index TONE tone ID
---@param active boolean active state
function public.set_active(index, active)
if self.tone_active[index] ~= nil then
if self.tone_active[index] ~= active then self.need_recompute = true end
self.tone_active[index] = active
end
end
-- check if a tone is active
---@param index TONE tone index
function public.is_active(index)
if self.tone_active[index] then return self.tone_active[index] end
return false
end
-- set all tones inactive, reset next block, and clear output buffer
function public.stop()
for i = 1, #self.tone_active do self.tone_active[i] = false end
self.next_block = 1
clear(self.quad_buffer)
end
-- check if the output buffer needs to be recomputed due to changes
function public.is_recompute_needed() return self.need_recompute end
-- re-compute the output buffer
function public.compute_buffer()
clear(self.quad_buffer)
self.need_recompute = false
self.any_active = false
for id = 1, #tone_data do
if self.tone_active[id] then
self.any_active = true
for i = 1, 4 do
local buffer = self.quad_buffer[i]
local values = tone_data[id][i]
for s = 1, _05s_SAMPLES do self.quad_buffer[i][s] = limit(buffer[s] + values[s]) end
end
end
end
end
-- check if the next audio block has data
function public.has_next_block() return #self.quad_buffer[self.next_block] > 0 end
-- get the next audio block
function public.get_next_block()
local block = self.quad_buffer[self.next_block]
self.next_block = self.next_block + 1
if self.next_block > 4 then self.next_block = 1 end
return block
end
return public
end
return audio

View File

@@ -14,50 +14,54 @@ local max_distance = nil ---@type number|nil maximum acceptable t
---@class comms
local comms = {}
comms.version = "2.1.0"
comms.version = "2.2.1"
---@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
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
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
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
RTU_TONE_ALARM = 5, -- instruct RTUs to play specified alarm tones
DIAG_TONE_GET = 6, -- diagnostic: get alarm tones
DIAG_TONE_SET = 7, -- diagnostic: set alarm tones
DIAG_ALARM_SET = 8 -- diagnostic: set alarm to simulate audio for
}
---@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
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
@@ -66,48 +70,50 @@ 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
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
PKT = 4 -- pocket device type for establish
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
PKT = 4 -- pocket 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
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
SCRAM_ALL = 0, -- SCRAM all reactors
STOP = 1, -- stop automatic process control
START = 2, -- start automatic process control
ACK_ALL_ALARMS = 3, -- acknowledge all alarms on all units
SET_WASTE_MODE = 4, -- set automatic waste processing mode
SET_PU_FB = 5 -- set plutonium fallback mode
}
---@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
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
@@ -144,6 +150,7 @@ function comms.scada_packet()
local self = {
modem_msg_in = nil, ---@type modem_message|nil
valid = false,
authenticated = false,
raw = {},
src_addr = comms.BROADCAST,
dest_addr = comms.BROADCAST,
@@ -232,6 +239,9 @@ function comms.scada_packet()
return self.valid
end
-- report that this packet has been authenticated (was received with a valid HMAC)
function public.stamp_authenticated() self.authenticated = true end
-- public accessors --
---@nodiscard
@@ -246,6 +256,8 @@ function comms.scada_packet()
---@nodiscard
function public.is_valid() return self.valid end
---@nodiscard
function public.is_authenticated() return self.authenticated end
---@nodiscard
function public.src_addr() return self.src_addr end
@@ -311,7 +323,7 @@ function comms.authd_packet()
self.valid = false
self.raw = self.modem_msg_in.msg
if (type(max_distance) == "number") and (distance > max_distance) then
if (type(max_distance) == "number") and (type(distance) == "number") and (distance > max_distance) then
-- outside of maximum allowable transmission distance
-- log.debug("comms.authd_packet.receive(): discarding packet with distance " .. distance .. " outside of trusted range")
else
@@ -586,7 +598,11 @@ function comms.mgmt_packet()
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
self.type == SCADA_MGMT_TYPE.RTU_DEV_REMOUNT or
self.type == SCADA_MGMT_TYPE.RTU_TONE_ALARM or
self.type == SCADA_MGMT_TYPE.DIAG_TONE_GET or
self.type == SCADA_MGMT_TYPE.DIAG_TONE_SET or
self.type == SCADA_MGMT_TYPE.DIAG_ALARM_SET
end
-- make a SCADA management packet

View File

@@ -2,10 +2,12 @@
-- System and Safety Constants
--
---@class scada_constants
local constants = {}
--#region Reactor Protection System (on the PLC) Limits
---@class _rps_constants
local rps = {}
rps.MAX_DAMAGE_PERCENT = 90 -- damage >= 90%
@@ -21,6 +23,7 @@ constants.RPS_LIMITS = rps
--#region Annunciator Limits
---@class _annunciator_constants
local annunc = {}
annunc.RCSFlowLow_H2O = -3.2 -- flow < -3.2 mB/s
@@ -44,6 +47,7 @@ constants.ANNUNCIATOR_LIMITS = annunc
--#region Supervisor Alarm Limits
---@class _alarm_constants
local alarms = {}
-- unit alarms

View File

@@ -6,8 +6,10 @@ local comms = require("scada-common.comms")
local log = require("scada-common.log")
local util = require("scada-common.util")
local core = require("graphics.core")
local has_graphics, core = pcall(require, "graphics.core")
local has_lockbox, lockbox = pcall(require, "lockbox")
---@class crash_handler
local crash = {}
local app = "unknown"
@@ -34,7 +36,8 @@ function crash.handler(error)
log.info(util.c("APPLICATION: ", app))
log.info(util.c("FIRMWARE VERSION: ", ver))
log.info(util.c("COMMS VERSION: ", comms.version))
log.info(util.c("GRAPHICS VERSION: ", core.version))
if has_graphics then log.info(util.c("GRAPHICS VERSION: ", core.version)) end
if has_lockbox then log.info(util.c("LOCKBOX VERSION: ", lockbox.version)) end
log.info("----------------------------------")
log.info(debug.traceback("--- begin debug trace ---", 1))
log.info("--- end debug trace ---")

View File

@@ -4,14 +4,11 @@
local util = require("scada-common.util")
---@class log
---@class logger
local log = {}
---@alias MODE integer
local MODE = {
APPEND = 0,
NEW = 1
}
local MODE = { APPEND = 0, NEW = 1 }
log.MODE = MODE
@@ -20,7 +17,9 @@ local logger = {
mode = MODE.APPEND,
debug = false,
file = nil,
dmesg_out = nil
dmesg_out = nil,
dmesg_restore_coord = { 1, 1 },
dmesg_scroll_count = 0
}
---@type function
@@ -158,6 +157,7 @@ function log.dmesg(msg, tag, tag_color)
if cur_y == out_h then
out.scroll(1)
out.setCursorPos(1, cur_y)
logger.dmesg_scroll_count = logger.dmesg_scroll_count + 1
else
out.setCursorPos(1, cur_y + 1)
end
@@ -193,6 +193,7 @@ function log.dmesg(msg, tag, tag_color)
if cur_y == out_h then
out.scroll(1)
out.setCursorPos(1, cur_y)
logger.dmesg_scroll_count = logger.dmesg_scroll_count + 1
else
out.setCursorPos(1, cur_y + 1)
end
@@ -201,6 +202,8 @@ function log.dmesg(msg, tag, tag_color)
out.write(lines[i])
end
logger.dmesg_restore_coord = { out.getCursorPos() }
_log(util.c("[", t_stamp, "] [", tag, "] ", msg))
end
@@ -215,6 +218,7 @@ end
---@return function update, function done
function log.dmesg_working(msg, tag, tag_color)
local ts_coord = log.dmesg(msg, tag, tag_color)
local initial_scroll = logger.dmesg_scroll_count
local out = logger.dmesg_out
local width = (ts_coord.x2 - ts_coord.x1) + 1
@@ -225,11 +229,14 @@ function log.dmesg_working(msg, tag, tag_color)
local counter = 0
local function update(sec_remaining)
local new_y = ts_coord.y - (logger.dmesg_scroll_count - initial_scroll)
if new_y < 1 then return end
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.setCursorPos(ts_coord.x1, new_y)
out.write(" ")
if counter % 4 == 0 then
@@ -249,10 +256,15 @@ function log.dmesg_working(msg, tag, tag_color)
out.setTextColor(initial_color)
counter = counter + 1
out.setCursorPos(table.unpack(logger.dmesg_restore_coord))
end
local function done(ok)
out.setCursorPos(ts_coord.x1, ts_coord.y)
local new_y = ts_coord.y - (logger.dmesg_scroll_count - initial_scroll)
if new_y < 1 then return end
out.setCursorPos(ts_coord.x1, new_y)
if ok or ok == nil then
out.setTextColor(colors.green)
@@ -263,6 +275,8 @@ function log.dmesg_working(msg, tag, tag_color)
end
out.setTextColor(initial_color)
out.setCursorPos(table.unpack(logger.dmesg_restore_coord))
end
return update, done

View File

@@ -4,6 +4,14 @@
local mqueue = {}
---@class queue_item
---@field qtype MQ_TYPE
---@field message any
---@class queue_data
---@field key any
---@field val any
---@enum MQ_TYPE
local TYPE = {
COMMAND = 0,
@@ -13,22 +21,14 @@ local TYPE = {
mqueue.TYPE = TYPE
local insert = table.insert
local remove = table.remove
-- 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 = {}
@@ -48,28 +48,20 @@ function mqueue.new()
-- 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
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
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
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
function public.push_packet(packet) _push(TYPE.PACKET, packet) end
-- get an item off the queue
---@nodiscard
@@ -77,9 +69,7 @@ function mqueue.new()
function public.pop()
if #queue > 0 then
return remove(queue, 1)
else
return nil
end
else return nil end
end
return public

View File

@@ -2,19 +2,21 @@
-- Network Communications
--
local comms = require("scada-common.comms")
local log = require("scada-common.log")
local util = require("scada-common.util")
local md5 = require("lockbox.digest.md5")
local sha256 = require("lockbox.digest.sha2_256")
local pbkdf2 = require("lockbox.kdf.pbkdf2")
local hmac = require("lockbox.mac.hmac")
local stream = require("lockbox.util.stream")
local array = require("lockbox.util.array")
local comms = require("scada-common.comms")
local log = require("scada-common.log")
local util = require("scada-common.util")
---@class scada_net_interface
local network = {}
-- cryptography engine
local c_eng = {
key = nil,
hmac = nil
@@ -94,7 +96,7 @@ function network.nic(modem)
-- check if this NIC has a connected modem
---@nodiscard
function public.connected() return self.connected end
function public.is_connected() return self.connected end
-- connect to a modem peripheral
---@param reconnected_modem table
@@ -212,6 +214,7 @@ function network.nic(modem)
if packet_hmac == computed_hmac then
-- log.debug("crypto.modem.receive: HMAC verified in " .. (util.time_ms() - start) .. "ms")
s_packet.receive(side, sender, reply_to, textutils.unserialize(msg), distance)
s_packet.stamp_authenticated()
else
-- log.debug("crypto.modem.receive: HMAC failed verification in " .. (util.time_ms() - start) .. "ms")
end

View File

@@ -8,15 +8,13 @@ local util = require("scada-common.util")
---@class ppm
local ppm = {}
local ACCESS_FAULT = nil ---@type nil
local UNDEFINED_FIELD = "undefined field"
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
ppm.ACCESS_FAULT = ACCESS_FAULT
ppm.UNDEFINED_FIELD = UNDEFINED_FIELD
ppm.VIRTUAL_DEVICE_TYPE = VIRTUAL_DEVICE_TYPE
----------------------------
-- PRIVATE DATA/FUNCTIONS --
@@ -34,9 +32,9 @@ local ppm_sys = {
mute = false
}
-- wrap peripheral calls with lua protected call as we don't want a disconnect to crash a program<br>
-- also provides peripheral-specific fault checks (auto-clear fault defaults to true)<br>
-- assumes iface is a valid peripheral
-- Wrap peripheral calls with lua protected call as we don't want a disconnect to crash a program.
-- Additionally provides peripheral-specific fault checks (auto-clear fault defaults to true).<br>
-- Note: assumes iface is a valid peripheral.
---@param iface string CC peripheral interface
local function peri_init(iface)
local self = {
@@ -307,16 +305,12 @@ end
-- list all available peripherals
---@nodiscard
---@return table names
function ppm.list_avail()
return peripheral.getNames()
end
function ppm.list_avail() return peripheral.getNames() end
-- list mounted peripherals
---@nodiscard
---@return table mounts
function ppm.list_mounts()
return ppm_sys.mounts
end
function ppm.list_mounts() return ppm_sys.mounts end
-- get a mounted peripheral side/interface by device table
---@nodiscard
@@ -390,9 +384,7 @@ end
-- 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
function ppm.get_fission_reactor() return ppm.get_device("fissionReactorLogicAdapter") end
-- get the wireless modem (if multiple, returns the first)<br>
-- if this is in a CraftOS emulated environment, wired modems will be used instead
@@ -419,9 +411,7 @@ 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
if device.type == "monitor" then list[iface] = device end
end
return list

View File

@@ -9,14 +9,12 @@ local psil = {}
-- instantiate a new PSI layer
---@nodiscard
function psil.create()
local self = {
ic = {}
}
local ic = {}
-- allocate a new interconnect field
---@key string data key
local function alloc(key)
self.ic[key] = { subscribers = {}, value = nil }
ic[key] = { subscribers = {}, value = nil }
end
---@class psil
@@ -28,22 +26,22 @@ function psil.create()
---@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
if ic[key] == nil then
alloc(key)
elseif self.ic[key].value ~= nil then
func(self.ic[key].value)
elseif ic[key].value ~= nil then
func(ic[key].value)
end
-- subscribe to key
table.insert(self.ic[key].subscribers, { notify = func })
table.insert(ic[key].subscribers, { notify = func })
end
-- unsubscribe a function from a given key
---@param key string data key
---@param func function function to unsubscribe
function public.unsubscribe(key, func)
if self.ic[key] ~= nil then
util.filter_table(self.ic[key].subscribers, function (s) return s.notify ~= func end)
if ic[key] ~= nil then
util.filter_table(ic[key].subscribers, function (s) return s.notify ~= func end)
end
end
@@ -51,32 +49,32 @@ function psil.create()
---@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 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)
if ic[key].value ~= value then
for i = 1, #ic[key].subscribers do
ic[key].subscribers[i].notify(value)
end
end
self.ic[key].value = value
ic[key].value = value
end
-- publish a toggled boolean value to a given key, passing it to all subscribers if it has changed<br>
-- this is intended to be used to toggle boolean indicators such as heartbeats without extra state variables
---@param key string data key
function public.toggle(key)
if self.ic[key] == nil then alloc(key) end
if ic[key] == nil then alloc(key) end
self.ic[key].value = self.ic[key].value == false
ic[key].value = ic[key].value == false
for i = 1, #self.ic[key].subscribers do
self.ic[key].subscribers[i].notify(self.ic[key].value)
for i = 1, #ic[key].subscribers do
ic[key].subscribers[i].notify(ic[key].value)
end
end
-- clear the contents of the interconnect
function public.purge() self.ic = nil end
function public.purge() ic = {} end
return public
end

View File

@@ -123,7 +123,7 @@ function rsio.to_string(port)
if util.is_int(port) and port > 0 and port <= #names then
return names[port]
else
return ""
return "UNKNOWN"
end
end

View File

@@ -68,7 +68,7 @@ function tcd.handle(event)
end
end
-- identify any overdo callbacks<br>
-- identify any overdue callbacks<br>
-- prints to log debug output
function tcd.diagnostics()
for timer, entry in pairs(registry) do

View File

@@ -89,16 +89,18 @@ types.RTU_UNIT_TYPE = {
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
DYNAMIC_VALVE = 4, -- dynamic tank, mekanism 10.1+
IMATRIX = 5, -- induction matrix
SPS = 6, -- SPS
SNA = 7, -- SNA
ENV_DETECTOR = 8 -- environment detector
}
types.RTU_UNIT_NAMES = {
"redstone",
"boiler_valve",
"turbine_valve",
"dynamic_valve",
"induction_matrix",
"sps",
"sna",
@@ -115,6 +117,7 @@ function types.rtu_type_to_string(utype)
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.DYNAMIC_VALVE or
utype == types.RTU_UNIT_TYPE.IMATRIX or
utype == types.RTU_UNIT_TYPE.SPS or
utype == types.RTU_UNIT_TYPE.SNA or
@@ -158,13 +161,26 @@ types.PROCESS_NAMES = {
---@enum WASTE_MODE
types.WASTE_MODE = {
AUTO = 1,
PLUTONIUM = 2,
POLONIUM = 3,
ANTI_MATTER = 4
MANUAL_PLUTONIUM = 2,
MANUAL_POLONIUM = 3,
MANUAL_ANTI_MATTER = 4
}
types.WASTE_MODE_NAMES = {
"AUTO",
"MANUAL_PLUTONIUM",
"MANUAL_POLONIUM",
"MANUAL_ANTI_MATTER"
}
---@enum WASTE_PRODUCT
types.WASTE_PRODUCT = {
PLUTONIUM = 1,
POLONIUM = 2,
ANTI_MATTER = 3
}
types.WASTE_PRODUCT_NAMES = {
"PLUTONIUM",
"POLONIUM",
"ANTI_MATTER"
@@ -315,6 +331,17 @@ types.RPS_TRIP_CAUSE = {
FORCE_DISABLED = "force_disabled"
}
---@alias container_mode
---| "BOTH"
---| "FILL"
---| "EMPTY"
types.CONTAINER_MODE = {
BOTH = "BOTH",
FILL = "FILL",
EMPTY = "EMPTY"
}
---@alias dumping_mode
---| "IDLE"
---| "DUMPING"

View File

@@ -7,6 +7,9 @@ local cc_strings = require("cc.strings")
---@class util
local util = {}
-- scada-common version
util.version = "1.0.1"
-- ENVIRONMENT CONSTANTS --
util.TICK_TIME_S = 0.05
@@ -80,9 +83,9 @@ end
---@return string
function util.strrep(str, n)
local repeated = ""
for _ = 1, n do
repeated = repeated .. str
end
for _ = 1, n do repeated = repeated .. str end
return repeated
end
@@ -123,7 +126,9 @@ function util.strwrap(str, limit) return cc_strings.wrap(str, limit) end
---@diagnostic disable-next-line: unused-vararg
function util.concat(...)
local str = ""
for _, v in ipairs(arg) do str = str .. util.strval(v) end
return str
end
@@ -322,7 +327,8 @@ end
--- EVENT_CONSUMER: this function consumes events
function util.nop() util.psleep(0.05) end
-- attempt to maintain a minimum loop timing (duration of execution)
-- attempt to maintain a minimum loop timing (duration of execution)<br>
-- note: will not yield for time periods less than 50ms
---@nodiscard
---@param target_timing integer minimum amount of milliseconds to wait for
---@param last_update integer millisecond time of last update
@@ -399,7 +405,7 @@ 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
local function ZFE(fe) return fe / 1000000000000000000000.0 end -- how & why did you do this?
-- format a power value into XXX.XX UNIT format (FE, kFE, MFE, GFE, TFE, PFE, EFE, ZFE)
---@nodiscard

View File

@@ -10,27 +10,39 @@ config.RTU_CHANNEL = 16242
config.CRD_CHANNEL = 16243
-- pocket comms channel
config.PKT_CHANNEL = 16244
-- max trusted modem message distance (0 to disable check)
-- 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
-- time in seconds (>= 2) before assuming a remote
-- device is no longer active
config.PLC_TIMEOUT = 5
config.RTU_TIMEOUT = 5
config.CRD_TIMEOUT = 5
config.PKT_TIMEOUT = 5
-- facility authentication key (do NOT use one of your passwords)
-- facility authentication key
-- (do NOT use one of your passwords)
-- this enables verifying that messages are authentic
-- all devices on the same network must use the same key
-- all devices on this network must use this key
-- config.AUTH_KEY = "SCADAfacility123"
-- expected number of reactors
config.NUM_REACTORS = 4
-- expected number of boilers/turbines for each reactor
-- expected number of devices for each unit
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
-- reactor unit 1
{ BOILERS = 1, TURBINES = 1, TANK = false },
-- reactor unit 2
{ BOILERS = 1, TURBINES = 1, TANK = false },
-- reactor unit 3
{ BOILERS = 1, TURBINES = 1, TANK = false },
-- reactor unit 4
{ BOILERS = 1, TURBINES = 1, TANK = false }
}
-- advanced facility dynamic tank configuration
-- (see wiki for details)
-- by default, dynamic tanks are for each unit
config.FAC_TANK_MODE = 0
config.FAC_TANK_DEFS = { 0, 0, 0, 0 }
-- log path
config.LOG_PATH = "/log.txt"

View File

@@ -1,19 +1,32 @@
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 audio = require("scada-common.audio")
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 unit = require("supervisor.unit")
local rsctl = require("supervisor.session.rsctl")
local qtypes = require("supervisor.session.rtu.qtypes")
local PROCESS = types.PROCESS
local PROCESS_NAMES = types.PROCESS_NAMES
local PRIO = types.ALARM_PRIORITY
local rsctl = require("supervisor.session.rsctl")
local TONE = audio.TONE
local ALARM = types.ALARM
local PRIO = types.ALARM_PRIORITY
local ALARM_STATE = types.ALARM_STATE
local CONTAINER_MODE = types.CONTAINER_MODE
local PROCESS = types.PROCESS
local PROCESS_NAMES = types.PROCESS_NAMES
local RTU_UNIT_TYPE = types.RTU_UNIT_TYPE
local WASTE_MODE = types.WASTE_MODE
local WASTE = types.WASTE_PRODUCT
local IO = rsio.IO
local DTV_RTU_S_DATA = qtypes.DTV_RTU_S_DATA
-- 7.14 kJ per blade for 1 mB of fissile fuel<br>
-- 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)
@@ -51,16 +64,20 @@ 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
---@param cooling_conf sv_cooling_conf cooling configurations of reactor units
function facility.new(num_reactors, cooling_conf)
local self = {
units = {},
status_text = { "START UP", "initializing..." },
all_sys_ok = false,
allow_testing = false,
-- rtus
rtu_conn_count = 0,
rtu_list = {},
redstone = {},
induction = {},
sps = {},
tanks = {},
envd = {},
-- redstone I/O control
io_ctl = nil, ---@type rs_controller
@@ -99,6 +116,16 @@ function facility.new(num_reactors, cooling_conf)
last_update = 0,
last_error = 0.0,
last_time = 0.0,
-- waste processing
waste_product = WASTE.PLUTONIUM,
current_waste_product = WASTE.PLUTONIUM,
pu_fallback = false,
-- alarm tones
tone_states = {},
test_tone_set = false,
test_tone_reset = false,
test_tone_states = {},
test_alarm_states = {},
-- statistics
im_stat_init = false,
avg_charge = util.mov_avg(3, 0.0),
@@ -108,17 +135,21 @@ function facility.new(num_reactors, cooling_conf)
-- create units
for i = 1, num_reactors do
table.insert(self.units, unit.new(i, cooling_conf[i].BOILERS, cooling_conf[i].TURBINES))
table.insert(self.units, unit.new(i, cooling_conf.r_cool[i].BOILERS, cooling_conf.r_cool[i].TURBINES))
table.insert(self.group_map, 0)
end
-- list for RTU session management
self.rtu_list = { self.redstone, self.induction, self.sps, self.tanks, self.envd }
-- 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)
-- fill blank alarm/tone states
for _ = 1, 12 do table.insert(self.test_alarm_states, false) end
for _ = 1, 8 do
table.insert(self.tone_states, false)
table.insert(self.test_tone_states, false)
end
-- check if all auto-controlled units completed ramping
@@ -205,40 +236,69 @@ function facility.new(num_reactors, cooling_conf)
table.insert(self.redstone, rs_unit)
end
-- link an imatrix RTU session
-- link an induction matrix RTU session
---@param imatrix unit_session
---@return boolean linked induction matrix accepted (max 1)
function public.add_imatrix(imatrix)
table.insert(self.induction, imatrix)
if #self.induction == 0 then
table.insert(self.induction, imatrix)
return true
else return false end
end
-- link an SPS RTU session
---@param sps unit_session
---@return boolean linked SPS accepted (max 1)
function public.add_sps(sps)
if #self.sps == 0 then
table.insert(self.sps, sps)
return true
else return false end
end
-- link a dynamic tank RTU session
---@param dynamic_tank unit_session
---@return boolean linked dynamic tank accepted (max 1)
function public.add_tank(dynamic_tank)
if #self.tanks == 0 then
table.insert(self.tanks, dynamic_tank)
return true
else return false end
end
-- link an environment detector RTU session
---@param envd unit_session
---@return boolean linked environment detector accepted (max 1)
function public.add_envd(envd)
table.insert(self.envd, envd)
if #self.envd == 0 then
table.insert(self.envd, envd)
return true
else return false end
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)
for _, v in pairs(self.rtu_list) do util.filter_table(v, function (s) return s.get_session_id() ~= session end) 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
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)
for _, v in pairs(self.rtu_list) do util.filter_table(v, function (u) return u.is_connected() end) end
-- check if test routines are allowed right now
self.allow_testing = true
for i = 1, #self.units do
local u = self.units[i] ---@type reactor_unit
self.allow_testing = self.allow_testing and u.is_safe_idle()
end
-- current state for process control
local charge_update = 0
@@ -277,6 +337,8 @@ function facility.new(num_reactors, cooling_conf)
-- Run Process Control --
-------------------------
--#region Process Control
local avg_charge = self.avg_charge.compute()
local avg_inflow = self.avg_inflow.compute()
@@ -542,10 +604,14 @@ function facility.new(num_reactors, cooling_conf)
next_mode = PROCESS.INACTIVE
end
--#endregion
------------------------------
-- Evaluate Automatic SCRAM --
------------------------------
--#region Automatic SCRAM
local astatus = self.ascram_status
if self.induction[1] ~= nil then
@@ -659,6 +725,8 @@ function facility.new(num_reactors, cooling_conf)
end
end
--#endregion
-- update last mode and set next mode
self.last_mode = self.mode
self.mode = next_mode
@@ -692,12 +760,147 @@ function facility.new(num_reactors, cooling_conf)
self.io_ctl.digital_write(IO.F_ALARM, has_alarm)
end
----------------
-- Unit Tasks --
----------------
local insufficent_po_rate = false
local need_emcool = false
for i = 1, #self.units do
local u = self.units[i] ---@type reactor_unit
-- update auto waste processing
if u.get_control_inf().waste_mode == WASTE_MODE.AUTO then
if (u.get_sna_rate() * 10.0) < u.get_burn_rate() then
insufficent_po_rate = true
end
end
-- check if unit activated emergency coolant & uses facility tanks
if (cooling_conf.fac_tank_mode > 0) and u.is_emer_cool_tripped() and (cooling_conf.fac_tank_defs[i] == 2) then
need_emcool = true
end
end
-- update waste product
if self.waste_product == WASTE.PLUTONIUM or (self.pu_fallback and insufficent_po_rate) then
self.current_waste_product = WASTE.PLUTONIUM
else self.current_waste_product = self.waste_product end
-- make sure dynamic tanks are allowing outflow if required
-- set all, rather than trying to determine which is for which (simpler & safer)
-- there should be no need for any to be in fill only mode
if need_emcool then
for i = 1, #self.tanks do
local session = self.tanks[i] ---@type unit_session
local tank = session.get_db() ---@type dynamicv_session_db
if tank.state.container_mode == CONTAINER_MODE.FILL then
session.get_cmd_queue().push_data(DTV_RTU_S_DATA.SET_CONT_MODE, CONTAINER_MODE.BOTH)
end
end
end
------------------------
-- Update Alarm Tones --
------------------------
local allow_test = self.allow_testing and self.test_tone_set
local alarms = { false, false, false, false, false, false, false, false, false, false, false, false }
-- reset tone states before re-evaluting
for i = 1, #self.tone_states do self.tone_states[i] = false end
if allow_test then
alarms = self.test_alarm_states
else
-- check all alarms for all units
for i = 1, #self.units do
local u = self.units[i] ---@type reactor_unit
for id, alarm in pairs(u.get_alarms()) do
alarms[id] = alarms[id] or (alarm == ALARM_STATE.TRIPPED)
end
end
if not self.test_tone_reset then
-- clear testing alarms if we aren't using them
for i = 1, #self.test_alarm_states do self.test_alarm_states[i] = false end
end
end
-- Evaluate Alarms --
-- containment breach is worst case CRITICAL alarm, this takes priority
if alarms[ALARM.ContainmentBreach] then
self.tone_states[TONE.T_1800Hz_Int_4Hz] = true
else
-- critical damage is highest priority CRITICAL level alarm
if alarms[ALARM.CriticalDamage] then
self.tone_states[TONE.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
self.tone_states[TONE.T_544Hz_440Hz_Alt] = true
-- URGENT level turbine trip
elseif alarms[ALARM.TurbineTrip] then
self.tone_states[TONE.T_745Hz_Int_1Hz] = true
-- URGENT level reactor lost
elseif alarms[ALARM.ReactorLost] then
self.tone_states[TONE.T_340Hz_Int_2Hz] = true
-- TIMELY level alarms
elseif alarms[ALARM.ReactorHighTemp] or alarms[ALARM.ReactorHighWaste] or alarms[ALARM.RCSTransient] then
self.tone_states[TONE.T_800Hz_Int] = true
end
end
-- check RPS transient URGENT level alarm
if alarms[ALARM.RPSTransient] then
self.tone_states[TONE.T_1000Hz_Int] = true
-- disable really painful audio combination
self.tone_states[TONE.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
self.tone_states[TONE.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 self.tone_states[TONE.T_1000Hz_Int] and alarms[ALARM.ReactorLost] then self.tone_states[TONE.T_340Hz_Int_2Hz] = true end
-- it sounds *really* bad if this is in conjunction with these other tones, so disable them
self.tone_states[TONE.T_745Hz_Int_1Hz] = false
self.tone_states[TONE.T_800Hz_Int] = false
self.tone_states[TONE.T_1000Hz_Int] = false
end
-- add to tone states if testing is active
if allow_test then
for i = 1, #self.tone_states do
self.tone_states[i] = self.tone_states[i] or self.test_tone_states[i]
end
self.test_tone_reset = false
else
if not self.test_tone_reset then
-- clear testing tones if we aren't using them
for i = 1, #self.test_tone_states do self.test_tone_states[i] = false end
end
-- flag that tones were reset
self.test_tone_set = false
self.test_tone_reset = true
end
end
-- call the update function of all units in the facility
-- call the update function of all units in the facility<br>
-- additionally sets the requested auto waste mode if applicable
function public.update_units()
for i = 1, #self.units do
local u = self.units[i] ---@type reactor_unit
u.auto_set_waste(self.current_waste_product)
u.update()
end
end
@@ -721,15 +924,15 @@ function facility.new(num_reactors, cooling_conf)
end
-- stop auto control
function public.auto_stop()
self.mode = PROCESS.INACTIVE
end
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
local charge_scaler = 1000000 -- convert MFE to FE
local gen_scaler = 1000 -- convert kFE to FE
local ready = false
-- load up current limits
local limits = {}
@@ -749,11 +952,11 @@ function facility.new(num_reactors, cooling_conf)
end
if (type(config.charge_target) == "number") and config.charge_target >= 0 then
self.charge_setpoint = config.charge_target * 1000000 -- convert MFE to FE
self.charge_setpoint = config.charge_target * charge_scaler
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
self.gen_rate_setpoint = config.gen_target * gen_scaler
end
if (type(config.limits) == "table") and (#config.limits == num_reactors) then
@@ -769,11 +972,9 @@ function facility.new(num_reactors, cooling_conf)
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
if (self.mode_set == PROCESS.CHARGE) and (self.charge_setpoint <= 0) or
(self.mode_set == PROCESS.GEN_RATE) and (self.gen_rate_setpoint <= 0) or
(self.mode_set == PROCESS.BURN_RATE) and (self.burn_target < 0.1) then
ready = false
end
@@ -782,7 +983,14 @@ function facility.new(num_reactors, cooling_conf)
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 }
return {
ready,
self.mode_set,
self.burn_target,
self.charge_setpoint / charge_scaler,
self.gen_rate_setpoint / gen_scaler,
limits
}
end
-- SETTINGS --
@@ -807,15 +1015,79 @@ function facility.new(num_reactors, cooling_conf)
end
end
-- set waste production
---@param product WASTE_PRODUCT target product
---@return WASTE_PRODUCT product newly set value, if valid
function public.set_waste_product(product)
if product == WASTE.PLUTONIUM or product == WASTE.POLONIUM or product == WASTE.ANTI_MATTER then
self.waste_product = product
end
return self.waste_product
end
-- enable/disable plutonium fallback
---@param enabled boolean requested state
---@return boolean enabled newly set value
function public.set_pu_fallback(enabled)
self.pu_fallback = enabled == true
return self.pu_fallback
end
-- DIAGNOSTIC TESTING --
-- attempt to set a test tone state
---@param id TONE|0 tone ID or 0 to disable all
---@param state boolean state
---@return boolean allow_testing, table test_tone_states
function public.diag_set_test_tone(id, state)
if self.allow_testing then
self.test_tone_set = true
self.test_tone_reset = false
if id == 0 then
for i = 1, #self.test_tone_states do self.test_tone_states[i] = false end
else
self.test_tone_states[id] = state
end
end
return self.allow_testing, self.test_tone_states
end
-- attempt to set a test alarm state
---@param id ALARM|0 alarm ID or 0 to disable all
---@param state boolean state
---@return boolean allow_testing, table test_alarm_states
function public.diag_set_test_alarm(id, state)
if self.allow_testing then
self.test_tone_set = true
self.test_tone_reset = false
if id == 0 then
for i = 1, #self.test_alarm_states do self.test_alarm_states[i] = false end
else
self.test_alarm_states[id] = state
end
end
return self.allow_testing, self.test_alarm_states
end
-- READ STATES/PROPERTIES --
-- get build properties of all machines
-- get current alarm tone on/off states
---@nodiscard
---@param inc_imatrix boolean? true/nil to include induction matrix build, false to exclude
function public.get_build(inc_imatrix)
function public.get_alarm_tones() return self.tone_states end
-- get build properties of all facility devices
---@nodiscard
---@param type RTU_UNIT_TYPE? type or nil to include only a particular unit type, or to include all if nil
function public.get_build(type)
local all = type == nil
local build = {}
if inc_imatrix ~= false then
if all or type == RTU_UNIT_TYPE.IMATRIX then
build.induction = {}
for i = 1, #self.induction do
local matrix = self.induction[i] ---@type unit_session
@@ -823,6 +1095,22 @@ function facility.new(num_reactors, cooling_conf)
end
end
if all or type == RTU_UNIT_TYPE.SPS then
build.sps = {}
for i = 1, #self.sps do
local sps = self.sps[i] ---@type unit_session
build.sps[sps.get_device_idx()] = { sps.get_db().formed, sps.get_db().build }
end
end
if all or type == RTU_UNIT_TYPE.DYNAMIC_VALVE then
build.tanks = {}
for i = 1, #self.tanks do
local tank = self.tanks[i] ---@type unit_session
build.tanks[tank.get_device_idx()] = { tank.get_db().formed, tank.get_db().build }
end
end
return build
end
@@ -844,7 +1132,9 @@ function facility.new(num_reactors, cooling_conf)
astat.gen_fault or self.mode == PROCESS.GEN_RATE_FAULT_IDLE,
self.status_text[1],
self.status_text[2],
self.group_map
self.group_map,
self.current_waste_product,
(self.current_waste_product == WASTE.PLUTONIUM) and (self.waste_product ~= WASTE.PLUTONIUM)
}
end
@@ -866,23 +1156,32 @@ function facility.new(num_reactors, cooling_conf)
-- 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
}
local matrix = self.induction[i] ---@type unit_session
local db = matrix.get_db() ---@type imatrix_session_db
status.induction[matrix.get_device_idx()] = { matrix.is_faulted(), db.formed, db.state, db.tanks }
end
-- status of sps
status.sps = {}
for i = 1, #self.sps do
local sps = self.sps[i] ---@type unit_session
local db = sps.get_db() ---@type sps_session_db
status.sps[sps.get_device_idx()] = { sps.is_faulted(), db.formed, db.state, db.tanks }
end
-- status of dynamic tanks
status.tanks = {}
for i = 1, #self.tanks do
local tank = self.tanks[i] ---@type unit_session
local db = tank.get_db() ---@type dynamicv_session_db
status.tanks[tank.get_device_idx()] = { tank.is_faulted(), db.formed, db.state, 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
}
status.rad_mon[envd.get_device_idx()] = { envd.is_faulted(), envd.get_db().radiation }
end
return status

View File

@@ -1,5 +1,5 @@
--
-- Main SCADA Coordinator GUI
-- Supervisor Front Panel GUI
--
local util = require("scada-common.util")
@@ -29,7 +29,7 @@ local TEXT_ALIGN = core.TEXT_ALIGN
local cpair = core.cpair
-- create new main view
-- create new front panel view
---@param panel graphics_element main displaybox
local function init(panel)
TextBox{parent=panel,y=1,text="SCADA SUPERVISOR",alignment=TEXT_ALIGN.CENTER,height=1,fg_bg=style.header}

View File

@@ -60,6 +60,8 @@ function pgi.delete_rtu_entry(session_id)
if not success then
log.error(util.c("PGI: failed to delete RTU entry (", result, ")"), true)
end
else
log.debug(util.c("PGI: tried to delete unknown RTU entry ", session_id))
end
end
@@ -87,6 +89,8 @@ function pgi.delete_pdg_entry(session_id)
if not success then
log.error(util.c("PGI: failed to delete PDG entry (", result, ")"), true)
end
else
log.debug(util.c("PGI: tried to delete unknown PDG entry ", session_id))
end
end

View File

@@ -1,7 +1,6 @@
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 databus = require("supervisor.databus")
@@ -16,10 +15,11 @@ 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_DATA = svqtypes.SV_Q_DATA
-- grace period in seconds for coordinator to finish UI draw to prevent timeout
local WATCHDOG_GRACE = 20.0
-- retry time constants in ms
-- local INITIAL_WAIT = 1500
local RETRY_PERIOD = 1000
@@ -64,6 +64,7 @@ function coordinator.new_session(id, s_addr, in_queue, out_queue, timeout, facil
r_seq_num = nil,
connected = true,
conn_watchdog = util.new_watchdog(timeout),
establish_time = util.time_s(),
last_rtt = 0,
-- periodic messages
periodics = {
@@ -153,7 +154,8 @@ function coordinator.new_session(id, s_addr, in_queue, out_queue, timeout, facil
local function _send_fac_status()
local status = {
facility.get_control_status(),
facility.get_rtu_statuses()
facility.get_rtu_statuses(),
facility.get_alarm_tones()
}
_send(SCADA_CRDN_TYPE.FAC_STATUS, status)
@@ -171,7 +173,8 @@ function coordinator.new_session(id, s_addr, in_queue, out_queue, timeout, facil
unit.get_rtu_statuses(),
unit.get_annunciator(),
unit.get_alarms(),
unit.get_state()
unit.get_state(),
unit.get_valves()
}
end
@@ -258,6 +261,18 @@ function coordinator.new_session(id, s_addr, in_queue, out_queue, timeout, facil
elseif cmd == FAC_COMMAND.ACK_ALL_ALARMS then
facility.ack_all()
_send(SCADA_CRDN_TYPE.FAC_CMD, { cmd, true })
elseif cmd == FAC_COMMAND.SET_WASTE_MODE then
if pkt.length == 2 then
_send(SCADA_CRDN_TYPE.FAC_CMD, { cmd, facility.set_waste_product(pkt.data[2]) })
else
log.debug(log_header .. "CRDN set waste mode packet length mismatch")
end
elseif cmd == FAC_COMMAND.SET_PU_FB then
if pkt.length == 2 then
_send(SCADA_CRDN_TYPE.FAC_CMD, { cmd, facility.set_pu_fallback(pkt.data[2]) })
else
log.debug(log_header .. "CRDN set pu fallback packet length mismatch")
end
else
log.debug(log_header .. "CRDN facility command unknown")
end
@@ -294,9 +309,9 @@ function coordinator.new_session(id, s_addr, in_queue, out_queue, timeout, facil
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])
unit.set_waste_mode(pkt.data[3])
else
log.debug(log_header .. "CRDN unit command set waste missing option")
log.debug(log_header .. "CRDN unit command set waste missing/invalid option")
end
elseif cmd == UNIT_COMMAND.ACK_ALL_ALARMS then
unit.ack_all()
@@ -345,7 +360,15 @@ function coordinator.new_session(id, s_addr, in_queue, out_queue, timeout, facil
-- 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
local is_wd = self.conn_watchdog.is_timer(timer) and self.connected
-- if we are waiting for initial coordinator UI draw, don't close yet
if is_wd and (util.time_s() - self.establish_time) <= WATCHDOG_GRACE then
self.conn_watchdog.feed()
is_wd = false
end
return is_wd
end
-- close the connection
@@ -394,7 +417,7 @@ function coordinator.new_session(id, s_addr, in_queue, out_queue, timeout, facil
local builds = {}
local unit = self.units[unit_id] ---@type reactor_unit
builds[unit_id] = unit.get_build(true, false, false)
builds[unit_id] = unit.get_build(-1)
_send(SCADA_CRDN_TYPE.UNIT_BUILDS, { builds })
elseif cmd.key == CRD_S_DATA.RESEND_RTU_BUILD then
@@ -408,7 +431,7 @@ function coordinator.new_session(id, s_addr, in_queue, out_queue, timeout, facil
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)
builds[unit_id] = unit.get_build(cmd.val.type)
_send(SCADA_CRDN_TYPE.UNIT_BUILDS, { builds })
else
@@ -417,7 +440,7 @@ function coordinator.new_session(id, s_addr, in_queue, out_queue, timeout, facil
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) })
_send(SCADA_CRDN_TYPE.FAC_BUILDS, { facility.get_build(cmd.val.type) })
end
else
log.error(log_header .. "unsupported data command received in in_queue (this is a bug)", true)

Some files were not shown because too many files have changed in this diff Show More