mirror of
https://github.com/MikaylaFischler/cc-mek-scada.git
synced 2024-08-30 18:22:34 +00:00
#184 WIP supervisor front panel
This commit is contained in:
parent
ff9a18a019
commit
2c7b98ba42
44
supervisor/databus.lua
Normal file
44
supervisor/databus.lua
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
--
|
||||||
|
-- Data Bus - Central Communication Linking for Supervisor Front Panel
|
||||||
|
--
|
||||||
|
|
||||||
|
local psil = require("scada-common.psil")
|
||||||
|
|
||||||
|
local databus = {}
|
||||||
|
|
||||||
|
local dbus_iface = {
|
||||||
|
ps = psil.create(),
|
||||||
|
session_entries = { rtu = {}, plc = {}, coord = {}, diag = {} }
|
||||||
|
}
|
||||||
|
|
||||||
|
-- call to toggle heartbeat signal
|
||||||
|
function databus.heartbeat() dbus_iface.ps.toggle("heartbeat") end
|
||||||
|
|
||||||
|
-- transmit firmware versions across the bus
|
||||||
|
---@param plc_v string supervisor version
|
||||||
|
---@param comms_v string comms version
|
||||||
|
function databus.tx_versions(plc_v, comms_v)
|
||||||
|
dbus_iface.ps.publish("version", plc_v)
|
||||||
|
dbus_iface.ps.publish("comms_version", comms_v)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- transmit hardware status for modem connection state
|
||||||
|
---@param has_modem boolean
|
||||||
|
function databus.tx_hw_modem(has_modem)
|
||||||
|
dbus_iface.ps.publish("has_modem", has_modem)
|
||||||
|
end
|
||||||
|
|
||||||
|
function databus.tx_svs_connection(type, data)
|
||||||
|
end
|
||||||
|
|
||||||
|
function databus.tx_svs_disconnection(type, data)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- link a function to receive data from the bus
|
||||||
|
---@param field string field name
|
||||||
|
---@param func function function to link
|
||||||
|
function databus.rx_field(field, func)
|
||||||
|
dbus_iface.ps.subscribe(field, func)
|
||||||
|
end
|
||||||
|
|
||||||
|
return databus
|
0
supervisor/panel/components/plc_entry.lua
Normal file
0
supervisor/panel/components/plc_entry.lua
Normal file
91
supervisor/panel/front_panel.lua
Normal file
91
supervisor/panel/front_panel.lua
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
--
|
||||||
|
-- Main SCADA Coordinator GUI
|
||||||
|
--
|
||||||
|
|
||||||
|
local util = require("scada-common.util")
|
||||||
|
|
||||||
|
local databus = require("supervisor.databus")
|
||||||
|
|
||||||
|
local style = require("supervisor.panel.style")
|
||||||
|
|
||||||
|
local core = require("graphics.core")
|
||||||
|
|
||||||
|
local Div = require("graphics.elements.div")
|
||||||
|
local MultiPane = require("graphics.elements.multipane")
|
||||||
|
local Rectangle = require("graphics.elements.rectangle")
|
||||||
|
local TextBox = require("graphics.elements.textbox")
|
||||||
|
|
||||||
|
local PushButton = require("graphics.elements.controls.push_button")
|
||||||
|
local TabBar = require("graphics.elements.controls.tabbar")
|
||||||
|
|
||||||
|
local LED = require("graphics.elements.indicators.led")
|
||||||
|
local LEDPair = require("graphics.elements.indicators.ledpair")
|
||||||
|
local RGBLED = require("graphics.elements.indicators.ledrgb")
|
||||||
|
|
||||||
|
local TEXT_ALIGN = core.graphics.TEXT_ALIGN
|
||||||
|
|
||||||
|
local cpair = core.graphics.cpair
|
||||||
|
local border = core.graphics.border
|
||||||
|
|
||||||
|
-- create new main 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}
|
||||||
|
|
||||||
|
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 on = LED{parent=system,label="POWER",colors=cpair(colors.green,colors.red)}
|
||||||
|
local heartbeat = LED{parent=system,label="HEARTBEAT",colors=cpair(colors.green,colors.green_off)}
|
||||||
|
on.update(true)
|
||||||
|
system.line_break()
|
||||||
|
|
||||||
|
databus.rx_field("heartbeat", heartbeat.update)
|
||||||
|
|
||||||
|
local modem = LED{parent=system,label="MODEM",colors=cpair(colors.green,colors.green_off)}
|
||||||
|
system.line_break()
|
||||||
|
|
||||||
|
databus.rx_field("has_modem", modem.update)
|
||||||
|
|
||||||
|
--
|
||||||
|
-- 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}
|
||||||
|
|
||||||
|
databus.rx_field("version", function (version) fw_v.set_value(util.c("FW: ", version)) end)
|
||||||
|
databus.rx_field("comms_version", function (version) comms_v.set_value(util.c("NT: v", version)) end)
|
||||||
|
|
||||||
|
--
|
||||||
|
-- page handling
|
||||||
|
--
|
||||||
|
|
||||||
|
local plc_list = Div{parent=page_div,x=1,y=1}
|
||||||
|
|
||||||
|
TextBox{parent=plc_list,x=2,y=2,text="v1.1.17 - PLC - UNIT 4 - :15004",alignment=TEXT_ALIGN.LEFT,height=1}
|
||||||
|
|
||||||
|
local panes = { main_page, plc_list, main_page, main_page, main_page }
|
||||||
|
|
||||||
|
local page_pane = MultiPane{parent=page_div,x=1,y=1,panes=panes}
|
||||||
|
|
||||||
|
local tabs = {
|
||||||
|
{ name = "Main", color = cpair(colors.black, colors.ivory) },
|
||||||
|
{ name = "PLCs", color = cpair(colors.black, colors.ivory) },
|
||||||
|
{ name = "RTUs", color = cpair(colors.black, colors.ivory) },
|
||||||
|
{ name = "CRDs", color = cpair(colors.black, colors.ivory) },
|
||||||
|
{ name = "PKTs", color = cpair(colors.black, colors.ivory) },
|
||||||
|
}
|
||||||
|
|
||||||
|
TabBar{parent=panel,y=2,tabs=tabs,min_width=10,callback=page_pane.set_value,fg_bg=cpair(colors.black,colors.white)}
|
||||||
|
end
|
||||||
|
|
||||||
|
return init
|
41
supervisor/panel/style.lua
Normal file
41
supervisor/panel/style.lua
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
--
|
||||||
|
-- Graphics Style Options
|
||||||
|
--
|
||||||
|
|
||||||
|
local core = require("graphics.core")
|
||||||
|
|
||||||
|
local style = {}
|
||||||
|
|
||||||
|
local cpair = core.graphics.cpair
|
||||||
|
|
||||||
|
-- GLOBAL --
|
||||||
|
|
||||||
|
-- remap global colors
|
||||||
|
colors.ivory = colors.pink
|
||||||
|
colors.red_off = colors.brown
|
||||||
|
colors.yellow_off = colors.magenta
|
||||||
|
colors.green_off = colors.lime
|
||||||
|
|
||||||
|
style.root = cpair(colors.black, colors.ivory)
|
||||||
|
style.header = cpair(colors.black, colors.lightGray)
|
||||||
|
|
||||||
|
style.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 },
|
||||||
|
{ 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
|
||||||
|
}
|
||||||
|
|
||||||
|
return style
|
80
supervisor/renderer.lua
Normal file
80
supervisor/renderer.lua
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
--
|
||||||
|
-- Graphics Rendering Control
|
||||||
|
--
|
||||||
|
|
||||||
|
local panel_view = require("supervisor.panel.front_panel")
|
||||||
|
local style = require("supervisor.panel.style")
|
||||||
|
|
||||||
|
local flasher = require("graphics.flasher")
|
||||||
|
|
||||||
|
local DisplayBox = require("graphics.elements.displaybox")
|
||||||
|
|
||||||
|
local renderer = {}
|
||||||
|
|
||||||
|
local ui = {
|
||||||
|
display = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
-- start the UI
|
||||||
|
function renderer.start_ui()
|
||||||
|
if ui.display == nil then
|
||||||
|
-- reset terminal
|
||||||
|
term.setTextColor(colors.white)
|
||||||
|
term.setBackgroundColor(colors.black)
|
||||||
|
term.clear()
|
||||||
|
term.setCursorPos(1, 1)
|
||||||
|
|
||||||
|
-- set overridden colors
|
||||||
|
for i = 1, #style.colors do
|
||||||
|
term.setPaletteColor(style.colors[i].c, style.colors[i].hex)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- init front panel view
|
||||||
|
ui.display = DisplayBox{window=term.current(),fg_bg=style.root}
|
||||||
|
panel_view(ui.display)
|
||||||
|
|
||||||
|
-- start flasher callback task
|
||||||
|
flasher.run()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- close out the UI
|
||||||
|
function renderer.close_ui()
|
||||||
|
if ui.display ~= nil then
|
||||||
|
-- stop blinking indicators
|
||||||
|
flasher.clear()
|
||||||
|
|
||||||
|
-- hide to stop animation callbacks
|
||||||
|
ui.display.hide()
|
||||||
|
|
||||||
|
-- clear root UI elements
|
||||||
|
ui.display = nil
|
||||||
|
|
||||||
|
-- restore colors
|
||||||
|
for i = 1, #style.colors do
|
||||||
|
local r, g, b = term.nativePaletteColor(style.colors[i].c)
|
||||||
|
term.setPaletteColor(style.colors[i].c, r, g, b)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- reset terminal
|
||||||
|
term.setTextColor(colors.white)
|
||||||
|
term.setBackgroundColor(colors.black)
|
||||||
|
term.clear()
|
||||||
|
term.setCursorPos(1, 1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- is the UI ready?
|
||||||
|
---@nodiscard
|
||||||
|
---@return boolean ready
|
||||||
|
function renderer.ui_ready() return ui.display ~= nil end
|
||||||
|
|
||||||
|
-- handle a mouse event
|
||||||
|
---@param event mouse_interaction
|
||||||
|
function renderer.handle_mouse(event)
|
||||||
|
if ui.display ~= nil then
|
||||||
|
ui.display.handle_mouse(event)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return renderer
|
@ -18,7 +18,8 @@ local RTU_UNIT_TYPE = types.RTU_UNIT_TYPE
|
|||||||
|
|
||||||
local SV_Q_DATA = svqtypes.SV_Q_DATA
|
local SV_Q_DATA = svqtypes.SV_Q_DATA
|
||||||
|
|
||||||
local println = util.println
|
-- local println = util.println
|
||||||
|
local println = function (str) end
|
||||||
|
|
||||||
-- retry time constants in ms
|
-- retry time constants in ms
|
||||||
-- local INITIAL_WAIT = 1500
|
-- local INITIAL_WAIT = 1500
|
||||||
|
@ -14,7 +14,8 @@ local SCADA_MGMT_TYPE = comms.SCADA_MGMT_TYPE
|
|||||||
local PLC_AUTO_ACK = comms.PLC_AUTO_ACK
|
local PLC_AUTO_ACK = comms.PLC_AUTO_ACK
|
||||||
local UNIT_COMMAND = comms.UNIT_COMMAND
|
local UNIT_COMMAND = comms.UNIT_COMMAND
|
||||||
|
|
||||||
local println = util.println
|
-- local println = util.println
|
||||||
|
local println = function (str) end
|
||||||
|
|
||||||
-- retry time constants in ms
|
-- retry time constants in ms
|
||||||
local INITIAL_WAIT = 1500
|
local INITIAL_WAIT = 1500
|
||||||
|
@ -8,7 +8,8 @@ local pocket = {}
|
|||||||
local PROTOCOL = comms.PROTOCOL
|
local PROTOCOL = comms.PROTOCOL
|
||||||
local SCADA_MGMT_TYPE = comms.SCADA_MGMT_TYPE
|
local SCADA_MGMT_TYPE = comms.SCADA_MGMT_TYPE
|
||||||
|
|
||||||
local println = util.println
|
-- local println = util.println
|
||||||
|
local println = function (str) end
|
||||||
|
|
||||||
-- retry time constants in ms
|
-- retry time constants in ms
|
||||||
-- local INITIAL_WAIT = 1500
|
-- local INITIAL_WAIT = 1500
|
||||||
|
@ -22,7 +22,8 @@ local PROTOCOL = comms.PROTOCOL
|
|||||||
local SCADA_MGMT_TYPE = comms.SCADA_MGMT_TYPE
|
local SCADA_MGMT_TYPE = comms.SCADA_MGMT_TYPE
|
||||||
local RTU_UNIT_TYPE = types.RTU_UNIT_TYPE
|
local RTU_UNIT_TYPE = types.RTU_UNIT_TYPE
|
||||||
|
|
||||||
local println = util.println
|
-- local println = util.println
|
||||||
|
local println = function (str) end
|
||||||
|
|
||||||
local PERIODICS = {
|
local PERIODICS = {
|
||||||
KEEP_ALIVE = 2000
|
KEEP_ALIVE = 2000
|
||||||
|
@ -22,6 +22,7 @@ local CRD_S_DATA = coordinator.CRD_S_DATA
|
|||||||
|
|
||||||
local svsessions = {}
|
local svsessions = {}
|
||||||
|
|
||||||
|
---@enum SESSION_TYPE
|
||||||
local SESSION_TYPE = {
|
local SESSION_TYPE = {
|
||||||
RTU_SESSION = 0, -- RTU gateway
|
RTU_SESSION = 0, -- RTU gateway
|
||||||
PLC_SESSION = 1, -- reactor PLC
|
PLC_SESSION = 1, -- reactor PLC
|
||||||
|
@ -5,16 +5,21 @@
|
|||||||
require("/initenv").init_env()
|
require("/initenv").init_env()
|
||||||
|
|
||||||
local crash = require("scada-common.crash")
|
local crash = require("scada-common.crash")
|
||||||
|
local comms = require("scada-common.comms")
|
||||||
local log = require("scada-common.log")
|
local log = require("scada-common.log")
|
||||||
local ppm = require("scada-common.ppm")
|
local ppm = require("scada-common.ppm")
|
||||||
local util = require("scada-common.util")
|
local util = require("scada-common.util")
|
||||||
|
|
||||||
|
local core = require("graphics.core")
|
||||||
|
|
||||||
local config = require("supervisor.config")
|
local config = require("supervisor.config")
|
||||||
|
local databus = require("supervisor.databus")
|
||||||
|
local renderer = require("supervisor.renderer")
|
||||||
local supervisor = require("supervisor.supervisor")
|
local supervisor = require("supervisor.supervisor")
|
||||||
|
|
||||||
local svsessions = require("supervisor.session.svsessions")
|
local svsessions = require("supervisor.session.svsessions")
|
||||||
|
|
||||||
local SUPERVISOR_VERSION = "v0.15.5"
|
local SUPERVISOR_VERSION = "v0.16.0"
|
||||||
|
|
||||||
local println = util.println
|
local println = util.println
|
||||||
local println_ts = util.println_ts
|
local println_ts = util.println_ts
|
||||||
@ -79,6 +84,9 @@ local function main()
|
|||||||
-- startup
|
-- startup
|
||||||
----------------------------------------
|
----------------------------------------
|
||||||
|
|
||||||
|
-- record firmware versions and ID
|
||||||
|
databus.tx_versions(SUPERVISOR_VERSION, comms.version)
|
||||||
|
|
||||||
-- mount connected devices
|
-- mount connected devices
|
||||||
ppm.mount_all()
|
ppm.mount_all()
|
||||||
|
|
||||||
@ -89,6 +97,16 @@ local function main()
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
databus.tx_hw_modem(true)
|
||||||
|
|
||||||
|
-- start UI
|
||||||
|
local fp_ok, message = pcall(renderer.start_ui)
|
||||||
|
|
||||||
|
if not fp_ok then
|
||||||
|
renderer.close_ui()
|
||||||
|
println_ts(util.c("UI error: ", message))
|
||||||
|
log.error(util.c("GUI crashed with error ", message))
|
||||||
|
else
|
||||||
-- start comms, open all channels
|
-- start comms, open all channels
|
||||||
local superv_comms = supervisor.comms(SUPERVISOR_VERSION, config.NUM_REACTORS, config.REACTOR_COOLING, modem,
|
local superv_comms = supervisor.comms(SUPERVISOR_VERSION, config.NUM_REACTORS, config.REACTOR_COOLING, modem,
|
||||||
config.SCADA_DEV_LISTEN, config.SCADA_SV_CTL_LISTEN, config.TRUSTED_RANGE)
|
config.SCADA_DEV_LISTEN, config.SCADA_SV_CTL_LISTEN, config.TRUSTED_RANGE)
|
||||||
@ -112,8 +130,8 @@ local function main()
|
|||||||
if type == "modem" then
|
if type == "modem" then
|
||||||
-- we only care if this is our wireless modem
|
-- we only care if this is our wireless modem
|
||||||
if device == modem then
|
if device == modem then
|
||||||
println_ts("wireless modem disconnected!")
|
|
||||||
log.warning("comms modem disconnected")
|
log.warning("comms modem disconnected")
|
||||||
|
databus.tx_hw_modem(false)
|
||||||
else
|
else
|
||||||
log.warning("non-comms modem disconnected")
|
log.warning("non-comms modem disconnected")
|
||||||
end
|
end
|
||||||
@ -129,8 +147,9 @@ local function main()
|
|||||||
modem = device
|
modem = device
|
||||||
superv_comms.reconnect_modem(modem)
|
superv_comms.reconnect_modem(modem)
|
||||||
|
|
||||||
println_ts("wireless modem reconnected.")
|
|
||||||
log.info("comms modem reconnected")
|
log.info("comms modem reconnected")
|
||||||
|
|
||||||
|
databus.tx_hw_modem(true)
|
||||||
else
|
else
|
||||||
log.info("wired modem reconnected")
|
log.info("wired modem reconnected")
|
||||||
end
|
end
|
||||||
@ -138,6 +157,7 @@ local function main()
|
|||||||
end
|
end
|
||||||
elseif event == "timer" and loop_clock.is_clock(param1) then
|
elseif event == "timer" and loop_clock.is_clock(param1) then
|
||||||
-- main loop tick
|
-- main loop tick
|
||||||
|
databus.heartbeat()
|
||||||
|
|
||||||
-- iterate sessions
|
-- iterate sessions
|
||||||
svsessions.iterate_all()
|
svsessions.iterate_all()
|
||||||
@ -153,11 +173,20 @@ local function main()
|
|||||||
-- got a packet
|
-- got a packet
|
||||||
local packet = superv_comms.parse_packet(param1, param2, param3, param4, param5)
|
local packet = superv_comms.parse_packet(param1, param2, param3, param4, param5)
|
||||||
superv_comms.handle_packet(packet)
|
superv_comms.handle_packet(packet)
|
||||||
|
elseif event == "mouse_click" then
|
||||||
|
-- handle a monitor touch event
|
||||||
|
renderer.handle_mouse(core.events.touch(param1, param2, param3))
|
||||||
|
log.debug(util.sprintf("mouse_click: %s [%d, %d]", param1, param2, param3))
|
||||||
|
elseif event == "mouse_drag" then
|
||||||
|
log.debug(util.sprintf("mouse_drag: %s [%d, %d]", param1, param2, param3))
|
||||||
|
elseif event == "mouse_scroll" then
|
||||||
|
log.debug(util.sprintf("mouse_scroll: %s [%d, %d]", param1, param2, param3))
|
||||||
|
elseif event == "mouse_up" then
|
||||||
|
log.debug(util.sprintf("mouse_up: %s [%d, %d]", param1, param2, param3))
|
||||||
end
|
end
|
||||||
|
|
||||||
-- check for termination request
|
-- check for termination request
|
||||||
if event == "terminate" or ppm.should_terminate() then
|
if event == "terminate" or ppm.should_terminate() then
|
||||||
println_ts("closing sessions...")
|
|
||||||
log.info("terminate requested, closing sessions...")
|
log.info("terminate requested, closing sessions...")
|
||||||
svsessions.close_all()
|
svsessions.close_all()
|
||||||
log.info("sessions closed")
|
log.info("sessions closed")
|
||||||
@ -165,8 +194,16 @@ local function main()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
renderer.close_ui()
|
||||||
|
end
|
||||||
|
|
||||||
println_ts("exited")
|
println_ts("exited")
|
||||||
log.info("exited")
|
log.info("exited")
|
||||||
end
|
end
|
||||||
|
|
||||||
if not xpcall(main, crash.handler) then crash.exit() else log.close() end
|
if not xpcall(main, crash.handler) then
|
||||||
|
pcall(renderer.close_ui)
|
||||||
|
crash.exit()
|
||||||
|
else
|
||||||
|
log.close()
|
||||||
|
end
|
||||||
|
@ -11,7 +11,8 @@ local DEVICE_TYPE = comms.DEVICE_TYPE
|
|||||||
local ESTABLISH_ACK = comms.ESTABLISH_ACK
|
local ESTABLISH_ACK = comms.ESTABLISH_ACK
|
||||||
local SCADA_MGMT_TYPE = comms.SCADA_MGMT_TYPE
|
local SCADA_MGMT_TYPE = comms.SCADA_MGMT_TYPE
|
||||||
|
|
||||||
local println = util.println
|
-- local println = util.println
|
||||||
|
local println = function (str) end
|
||||||
|
|
||||||
-- supervisory controller communications
|
-- supervisory controller communications
|
||||||
---@nodiscard
|
---@nodiscard
|
||||||
|
Loading…
Reference in New Issue
Block a user