Merge branch 'devel' into 232-waste-valve-and-flow-monitoring-display

This commit is contained in:
Mikayla Fischler 2023-08-20 23:43:07 -04:00
commit 8b3f558f68
41 changed files with 1567 additions and 844 deletions

View File

@ -20,7 +20,7 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
local function println(message) print(tostring(message)) end local function println(message) print(tostring(message)) end
local function print(message) term.write(tostring(message)) end local function print(message) term.write(tostring(message)) end
local CCMSI_VERSION = "v1.7d" local CCMSI_VERSION = "v1.8"
local install_dir = "/.install-cache" local install_dir = "/.install-cache"
local manifest_path = "https://mikaylafischler.github.io/cc-mek-scada/manifests/" local manifest_path = "https://mikaylafischler.github.io/cc-mek-scada/manifests/"
@ -63,16 +63,15 @@ end
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 -- indicate actions to be taken based on package differences for installs/updates
local function show_pkg_change(name, v_local, v_remote) local function show_pkg_change(name, v)
if v_local ~= nil then if v.v_local ~= nil then
if v_local ~= v_remote then if v.v_local ~= v.v_remote then
print("[" .. name .. "] updating ");blue();print(v_local);white();print(" \xbb ");blue();println(v_remote);white() print("[" .. name .. "] updating ");blue();print(v.v_local);white();print(" \xbb ");blue();println(v.v_remote);white()
elseif mode == "install" then 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 end
else pkg_message("[" .. name .. "] new install of", v.v_remote) end
return v.v_local ~= v.v_remote
end end
-- read the local manifest file -- read the local manifest file
@ -284,6 +283,7 @@ elseif mode == "install" or mode == "update" then
app = { v_local = nil, v_remote = nil, changed = false }, app = { v_local = nil, v_remote = nil, changed = false },
boot = { 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 }, 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 }, graphics = { v_local = nil, v_remote = nil, changed = false },
lockbox = { v_local = nil, v_remote = nil, changed = false } lockbox = { v_local = nil, v_remote = nil, changed = false }
} }
@ -299,6 +299,7 @@ elseif mode == "install" or mode == "update" then
ver.boot.v_local = local_manifest.versions.bootloader ver.boot.v_local = local_manifest.versions.bootloader
ver.app.v_local = local_manifest.versions[app] ver.app.v_local = local_manifest.versions[app]
ver.comms.v_local = local_manifest.versions.comms ver.comms.v_local = local_manifest.versions.comms
ver.common.v_local = local_manifest.versions.common
ver.graphics.v_local = local_manifest.versions.graphics ver.graphics.v_local = local_manifest.versions.graphics
ver.lockbox.v_local = local_manifest.versions.lockbox ver.lockbox.v_local = local_manifest.versions.lockbox
@ -316,6 +317,7 @@ elseif mode == "install" or mode == "update" then
ver.boot.v_remote = manifest.versions.bootloader ver.boot.v_remote = manifest.versions.bootloader
ver.app.v_remote = manifest.versions[app] ver.app.v_remote = manifest.versions[app]
ver.comms.v_remote = manifest.versions.comms ver.comms.v_remote = manifest.versions.comms
ver.common.v_remote = manifest.versions.common
ver.graphics.v_remote = manifest.versions.graphics ver.graphics.v_remote = manifest.versions.graphics
ver.lockbox.v_remote = manifest.versions.lockbox ver.lockbox.v_remote = manifest.versions.lockbox
@ -327,28 +329,15 @@ elseif mode == "install" or mode == "update" then
end end
white() white()
-- display bootloader version change information ver.boot.changed = show_pkg_change("bootldr", ver.boot)
show_pkg_change("bootldr", ver.boot.v_local, ver.boot.v_remote) ver.common.changed = show_pkg_change("common", ver.common)
ver.boot.changed = ver.boot.v_local ~= ver.boot.v_remote ver.comms.changed = show_pkg_change("comms", ver.comms)
-- 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
if ver.comms.changed and ver.comms.v_local ~= nil then 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 end
ver.app.changed = show_pkg_change(app, ver.app)
-- display graphics version change information ver.graphics.changed = show_pkg_change("graphics", ver.graphics)
show_pkg_change("graphics", ver.graphics.v_local, ver.graphics.v_remote) ver.lockbox.changed = show_pkg_change("lockbox", ver.lockbox)
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
-- ask for confirmation -- ask for confirmation
if not ask_y_n("Continue", false) then return end if not ask_y_n("Continue", false) then return end
@ -392,16 +381,13 @@ elseif mode == "install" or mode == "update" then
if dependency == "system" then return not ver.boot.changed if dependency == "system" then return not ver.boot.changed
elseif dependency == "graphics" then return not ver.graphics.changed elseif dependency == "graphics" then return not ver.graphics.changed
elseif dependency == "lockbox" then return not ver.lockbox.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 elseif dependency == app then return not ver.app.changed
else return true end else return true end
end end
if not single_file_mode then if not single_file_mode then
if fs.exists(install_dir) then if fs.exists(install_dir) then fs.delete(install_dir);fs.makeDir(install_dir) end
fs.delete(install_dir)
fs.makeDir(install_dir)
end
-- download all dependencies -- download all dependencies
for _, dependency in pairs(dependencies) do for _, dependency in pairs(dependencies) do

View File

@ -84,6 +84,8 @@ function iocontrol.init(conf, comms)
scram_ack = __generic_ack, scram_ack = __generic_ack,
ack_alarms_ack = __generic_ack, ack_alarms_ack = __generic_ack,
alarm_tones = { false, false, false, false, false, false, false, false },
ps = psil.create(), ps = psil.create(),
induction_ps_tbl = {}, induction_ps_tbl = {},
@ -780,6 +782,16 @@ function iocontrol.update_facility_status(status)
end end
fac.ps.publish("rtu_count", fac.rtu_count) 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 end
return valid return valid
@ -1199,9 +1211,6 @@ function iocontrol.update_unit_statuses(statuses)
io.facility.ps.publish("po_pl_rate", po_pl_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("po_am_rate", po_am_rate)
io.facility.ps.publish("spent_waste_rate", spent_rate) io.facility.ps.publish("spent_waste_rate", spent_rate)
-- update alarm sounder
sounder.eval(io.units)
end end
return valid return valid

View File

@ -2,269 +2,25 @@
-- Alarm Sounder -- Alarm Sounder
-- --
local audio = require("scada-common.audio")
local log = require("scada-common.log") 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 ---@class sounder
local 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 = { local alarm_ctl = {
speaker = nil, speaker = nil,
volume = 0.5, volume = 0.5,
playing = false, stream = audio.new_stream()
num_active = 0,
next_block = 1,
-- split audio up into 0.5s samples so specific components can be ended quicker
quad_buffer = { {}, {}, {}, {} }
} }
-- sounds modeled after https://www.e2s.com/references-and-guidelines/listen-and-download-alarm-tones
local T_340Hz_Int_2Hz = 1
local T_544Hz_440Hz_Alt = 2
local T_660Hz_Int_125ms = 3
local T_745Hz_Int_1Hz = 4
local T_800Hz_Int = 5
local T_800Hz_1000Hz_Alt = 6
local T_1000Hz_Int = 7
local T_1800Hz_Int_4Hz = 8
local TONES = {
{ active = false, component = { {}, {}, {}, {} } }, -- 340Hz @ 2Hz Intermittent
{ active = false, component = { {}, {}, {}, {} } }, -- 544Hz 100mS / 440Hz 400mS Alternating
{ active = false, component = { {}, {}, {}, {} } }, -- 660Hz @ 125ms On 125ms Off
{ active = false, component = { {}, {}, {}, {} } }, -- 745Hz @ 1Hz Intermittent
{ active = false, component = { {}, {}, {}, {} } }, -- 800Hz @ 0.25s On 1.75s Off
{ active = false, component = { {}, {}, {}, {} } }, -- 800/1000Hz @ 0.25s Alternating
{ active = false, component = { {}, {}, {}, {} } }, -- 1KHz 1s on, 1s off Intermittent
{ active = false, component = { {}, {}, {}, {} } } -- 1.8KHz @ 4Hz Intermittent
}
-- calculate how many samples are in the given number of milliseconds
---@nodiscard
---@param ms integer milliseconds
---@return integer samples
local function ms_to_samples(ms) return math.floor(ms * 48) end
--#region Tone Generation (the Maths)
-- 340Hz @ 2Hz Intermittent
local function gen_tone_1()
local t, dt = 0, _2_PI * 340 / _DRATE
for i = 1, _05s_SAMPLES do
local val = math.floor(math.sin(t) * _MAX_VAL)
TONES[1].component[1][i] = val
TONES[1].component[3][i] = val
TONES[1].component[2][i] = 0
TONES[1].component[4][i] = 0
t = (t + dt) % _2_PI
end
end
-- 544Hz 100mS / 440Hz 400mS Alternating
local function gen_tone_2()
local t1, dt1 = 0, _2_PI * 544 / _DRATE
local t2, dt2 = 0, _2_PI * 440 / _DRATE
local alternate_at = ms_to_samples(100)
for i = 1, _05s_SAMPLES do
local value
if i <= alternate_at then
value = math.floor(math.sin(t1) * _MAX_VAL)
t1 = (t1 + dt1) % _2_PI
else
value = math.floor(math.sin(t2) * _MAX_VAL)
t2 = (t2 + dt2) % _2_PI
end
TONES[2].component[1][i] = value
TONES[2].component[2][i] = value
TONES[2].component[3][i] = value
TONES[2].component[4][i] = value
end
end
-- 660Hz @ 125ms On 125ms Off
local function gen_tone_3()
local elapsed_samples = 0
local alternate_after = ms_to_samples(125)
local alternate_at = alternate_after
local mode = true
local t, dt = 0, _2_PI * 660 / _DRATE
for set = 1, 4 do
for i = 1, _05s_SAMPLES do
if mode then
local val = math.floor(math.sin(t) * _MAX_VAL)
TONES[3].component[set][i] = val
t = (t + dt) % _2_PI
else
t = 0
TONES[3].component[set][i] = 0
end
if elapsed_samples == alternate_at then
mode = not mode
alternate_at = elapsed_samples + alternate_after
end
elapsed_samples = elapsed_samples + 1
end
end
end
-- 745Hz @ 1Hz Intermittent
local function gen_tone_4()
local t, dt = 0, _2_PI * 745 / _DRATE
for i = 1, _05s_SAMPLES do
local val = math.floor(math.sin(t) * _MAX_VAL)
TONES[4].component[1][i] = val
TONES[4].component[3][i] = val
TONES[4].component[2][i] = 0
TONES[4].component[4][i] = 0
t = (t + dt) % _2_PI
end
end
-- 800Hz @ 0.25s On 1.75s Off
local function gen_tone_5()
local t, dt = 0, _2_PI * 800 / _DRATE
local stop_at = ms_to_samples(250)
for i = 1, _05s_SAMPLES do
local val = math.floor(math.sin(t) * _MAX_VAL)
if i > stop_at then
TONES[5].component[1][i] = val
else
TONES[5].component[1][i] = 0
end
TONES[5].component[2][i] = 0
TONES[5].component[3][i] = 0
TONES[5].component[4][i] = 0
t = (t + dt) % _2_PI
end
end
-- 1000/800Hz @ 0.25s Alternating
local function gen_tone_6()
local t1, dt1 = 0, _2_PI * 1000 / _DRATE
local t2, dt2 = 0, _2_PI * 800 / _DRATE
local alternate_at = ms_to_samples(250)
for i = 1, _05s_SAMPLES do
local val
if i <= alternate_at then
val = math.floor(math.sin(t1) * _MAX_VAL)
t1 = (t1 + dt1) % _2_PI
else
val = math.floor(math.sin(t2) * _MAX_VAL)
t2 = (t2 + dt2) % _2_PI
end
TONES[6].component[1][i] = val
TONES[6].component[2][i] = val
TONES[6].component[3][i] = val
TONES[6].component[4][i] = val
end
end
-- 1KHz 1s on, 1s off Intermittent
local function gen_tone_7()
local t, dt = 0, _2_PI * 1000 / _DRATE
for i = 1, _05s_SAMPLES do
local val = math.floor(math.sin(t) * _MAX_VAL)
TONES[7].component[1][i] = val
TONES[7].component[2][i] = val
TONES[7].component[3][i] = 0
TONES[7].component[4][i] = 0
t = (t + dt) % _2_PI
end
end
-- 1800Hz @ 4Hz Intermittent
local function gen_tone_8()
local t, dt = 0, _2_PI * 1800 / _DRATE
local off_at = ms_to_samples(250)
for i = 1, _05s_SAMPLES do
local val = 0
if i <= off_at then
val = math.floor(math.sin(t) * _MAX_VAL)
t = (t + dt) % _2_PI
end
TONES[8].component[1][i] = val
TONES[8].component[2][i] = val
TONES[8].component[3][i] = val
TONES[8].component[4][i] = val
end
end
--#endregion
-- hard audio limiter
---@nodiscard
---@param output number output level
---@return number limited -128.0 to 127.0
local function limit(output)
return math.max(-128, math.min(127, output))
end
-- zero the alarm audio buffer
local function zero()
for i = 1, 4 do
for s = 1, _05s_SAMPLES do alarm_ctl.quad_buffer[i][s] = 0 end
end
end
-- add an alarm to the output buffer
---@param alarm_idx integer tone ID
local function add(alarm_idx)
alarm_ctl.num_active = alarm_ctl.num_active + 1
TONES[alarm_idx].active = true
for i = 1, 4 do
for s = 1, _05s_SAMPLES do
alarm_ctl.quad_buffer[i][s] = limit(alarm_ctl.quad_buffer[i][s] + TONES[alarm_idx].component[i][s])
end
end
end
-- start audio or continue audio on buffer empty -- start audio or continue audio on buffer empty
---@return boolean success successfully added buffer to audio output ---@return boolean success successfully added buffer to audio output
local function play() local function play()
if not alarm_ctl.playing then if not alarm_ctl.playing then
alarm_ctl.playing = true alarm_ctl.playing = true
alarm_ctl.next_block = 1
return sounder.continue() return sounder.continue()
else else return true end
return true
end
end end
-- initialize the annunciator alarm system -- initialize the annunciator alarm system
@ -273,23 +29,10 @@ end
function sounder.init(speaker, volume) function sounder.init(speaker, volume)
alarm_ctl.speaker = speaker alarm_ctl.speaker = speaker
alarm_ctl.speaker.stop() alarm_ctl.speaker.stop()
alarm_ctl.volume = volume alarm_ctl.volume = volume
alarm_ctl.playing = false alarm_ctl.stream.stop()
alarm_ctl.num_active = 0
alarm_ctl.next_block = 1
zero() audio.generate_tones()
-- 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 end
-- reconnect the speaker peripheral -- reconnect the speaker peripheral
@ -297,173 +40,40 @@ end
function sounder.reconnect(speaker) function sounder.reconnect(speaker)
alarm_ctl.speaker = speaker alarm_ctl.speaker = speaker
alarm_ctl.playing = false alarm_ctl.playing = false
alarm_ctl.next_block = 1 alarm_ctl.stream.stop()
alarm_ctl.num_active = 0
for id = 1, #TONES do TONES[id].active = false end
end end
-- check alarm state to enable/disable alarms -- set alarm tones
---@param units table|nil unit list or nil to use test mode ---@param states table alarm tone commands from supervisor
function sounder.eval(units) function sounder.set(states)
local changed = false -- set tone states
local any_active = false for id = 1, #states do alarm_ctl.stream.set_active(id, states[id]) end
local new_states = { false, false, false, false, false, false, false, false }
local alarms = { false, false, false, false, false, false, false, false, false, false, false, false }
if units ~= nil then -- re-compute output if needed, then play audio if available
-- check all alarms for all units if alarm_ctl.stream.is_recompute_needed() then alarm_ctl.stream.compute_buffer() end
for i = 1, #units do if alarm_ctl.stream.has_next_block() then play() else sounder.stop() end
local unit = units[i] ---@type ioctl_unit
for id = 1, #unit.alarms do
alarms[id] = alarms[id] or (unit.alarms[id] == ALARM_STATE.TRIPPED)
end
end
else
alarms = test_alarms
end
-- containment breach is worst case CRITICAL alarm, this takes priority
if alarms[ALARM.ContainmentBreach] then
new_states[T_1800Hz_Int_4Hz] = true
else
-- critical damage is highest priority CRITICAL level alarm
if alarms[ALARM.CriticalDamage] then
new_states[T_660Hz_Int_125ms] = true
else
-- EMERGENCY level alarms + URGENT over temp
if alarms[ALARM.ReactorDamage] or alarms[ALARM.ReactorOverTemp] or alarms[ALARM.ReactorWasteLeak] then
new_states[T_544Hz_440Hz_Alt] = true
-- URGENT level turbine trip
elseif alarms[ALARM.TurbineTrip] then
new_states[T_745Hz_Int_1Hz] = true
-- URGENT level reactor lost
elseif alarms[ALARM.ReactorLost] then
new_states[T_340Hz_Int_2Hz] = true
-- TIMELY level alarms
elseif alarms[ALARM.ReactorHighTemp] or alarms[ALARM.ReactorHighWaste] or alarms[ALARM.RCSTransient] then
new_states[T_800Hz_Int] = true
end
end
-- check RPS transient URGENT level alarm
if alarms[ALARM.RPSTransient] then
new_states[T_1000Hz_Int] = true
-- disable really painful audio combination
new_states[T_340Hz_Int_2Hz] = false
end
end
-- radiation is a big concern, always play this CRITICAL level alarm if active
if alarms[ALARM.ContainmentRadiation] then
new_states[T_800Hz_1000Hz_Alt] = true
-- we are going to disable the RPS trip alarm audio due to conflict, and if it was enabled
-- then we can re-enable the reactor lost alarm audio since it doesn't painfully combine with this one
if new_states[T_1000Hz_Int] and alarms[ALARM.ReactorLost] then new_states[T_340Hz_Int_2Hz] = true end
-- it sounds *really* bad if this is in conjunction with these other tones, so disable them
new_states[T_745Hz_Int_1Hz] = false
new_states[T_800Hz_Int] = false
new_states[T_1000Hz_Int] = false
end
-- check if any changed, check if any active, update active flags
for id = 1, #TONES do
if new_states[id] ~= TONES[id].active then
TONES[id].active = new_states[id]
changed = true
end
if TONES[id].active then any_active = true end
end
-- zero and re-add tones if changed
if changed then
zero()
for id = 1, #TONES do
if TONES[id].active then add(id) end
end
end
if any_active then play() else sounder.stop() end
end end
-- stop all audio and clear output buffer -- stop all audio and clear output buffer
function sounder.stop() function sounder.stop()
alarm_ctl.playing = false alarm_ctl.playing = false
alarm_ctl.speaker.stop() alarm_ctl.speaker.stop()
alarm_ctl.next_block = 1 alarm_ctl.stream.stop()
alarm_ctl.num_active = 0
for id = 1, #TONES do TONES[id].active = false end
zero()
end end
-- continue audio on buffer empty -- continue audio on buffer empty
---@return boolean success successfully added buffer to audio output ---@return boolean success successfully added buffer to audio output
function sounder.continue() function sounder.continue()
local success = false
if alarm_ctl.playing then if alarm_ctl.playing then
if alarm_ctl.speaker ~= nil and #alarm_ctl.quad_buffer[alarm_ctl.next_block] > 0 then if alarm_ctl.speaker ~= nil and alarm_ctl.stream.has_next_block() then
local success = alarm_ctl.speaker.playAudio(alarm_ctl.quad_buffer[alarm_ctl.next_block], alarm_ctl.volume) 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
alarm_ctl.next_block = alarm_ctl.next_block + 1 end
if alarm_ctl.next_block > 4 then alarm_ctl.next_block = 1 end
if not success then
log.debug("SOUNDER: error playing audio")
end end
return success return success
else
return false
end
else
return false
end
end end
--#region Test Functions
function sounder.test_1() add(1) play() end -- play tone T_340Hz_Int_2Hz
function sounder.test_2() add(2) play() end -- play tone T_544Hz_440Hz_Alt
function sounder.test_3() add(3) play() end -- play tone T_660Hz_Int_125ms
function sounder.test_4() add(4) play() end -- play tone T_745Hz_Int_1Hz
function sounder.test_5() add(5) play() end -- play tone T_800Hz_Int
function sounder.test_6() add(6) play() end -- play tone T_800Hz_1000Hz_Alt
function sounder.test_7() add(7) play() end -- play tone T_1000Hz_Int
function sounder.test_8() add(8) play() end -- play tone T_1800Hz_Int_4Hz
function sounder.test_breach(active) test_alarms[ALARM.ContainmentBreach] = active end ---@param active boolean
function sounder.test_rad(active) test_alarms[ALARM.ContainmentRadiation] = active end ---@param active boolean
function sounder.test_lost(active) test_alarms[ALARM.ReactorLost] = active end ---@param active boolean
function sounder.test_crit(active) test_alarms[ALARM.CriticalDamage] = active end ---@param active boolean
function sounder.test_dmg(active) test_alarms[ALARM.ReactorDamage] = active end ---@param active boolean
function sounder.test_overtemp(active) test_alarms[ALARM.ReactorOverTemp] = active end ---@param active boolean
function sounder.test_hightemp(active) test_alarms[ALARM.ReactorHighTemp] = active end ---@param active boolean
function sounder.test_wasteleak(active) test_alarms[ALARM.ReactorWasteLeak] = active end ---@param active boolean
function sounder.test_highwaste(active) test_alarms[ALARM.ReactorHighWaste] = active end ---@param active boolean
function sounder.test_rps(active) test_alarms[ALARM.RPSTransient] = active end ---@param active boolean
function sounder.test_rcs(active) test_alarms[ALARM.RCSTransient] = active end ---@param active boolean
function sounder.test_turbinet(active) test_alarms[ALARM.TurbineTrip] = active end ---@param active boolean
-- power rescaling limiter test
function sounder.test_power_scale()
local start = util.time_ms()
zero()
for id = 1, #TONES do
if TONES[id].active then
for i = 1, 4 do
for s = 1, _05s_SAMPLES do
alarm_ctl.quad_buffer[i][s] = limit(alarm_ctl.quad_buffer[i][s] +
(TONES[id].component[i][s] / math.sqrt(alarm_ctl.num_active)))
end
end
end
end
log.debug("SOUNDER: power rescale test took " .. (util.time_ms() - start) .. "ms")
end
--#endregion
return sounder return sounder

View File

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

View File

@ -20,6 +20,7 @@ local element = {}
---@alias graphics_args graphics_args_generic ---@alias graphics_args graphics_args_generic
---|waiting_args ---|waiting_args
---|app_button_args
---|checkbox_args ---|checkbox_args
---|hazard_button_args ---|hazard_button_args
---|multi_button_args ---|multi_button_args
@ -515,9 +516,10 @@ function element.new(args, child_offset_x, child_offset_y)
-- FUNCTION CALLBACKS -- -- 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 ---@param event mouse_interaction mouse interaction event
function public.handle_mouse(event) function public.handle_mouse(event)
if protected.window.isVisible() then
local x_ini, y_ini = event.initial.x, event.initial.y 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)
@ -530,6 +532,7 @@ function element.new(args, child_offset_x, child_offset_y)
for _, child in pairs(protected.children) do child.handle_mouse(event_T) end for _, child in pairs(protected.children) do child.handle_mouse(event_T) end
end end
end end
end
-- draw the element given new data -- draw the element given new data
---@vararg any new data ---@vararg any new data

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

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

@ -2,15 +2,14 @@ local comms = require("scada-common.comms")
local log = require("scada-common.log") local log = require("scada-common.log")
local util = require("scada-common.util") local util = require("scada-common.util")
local coreio = require("pocket.coreio") local iocontrol = require("pocket.iocontrol")
local PROTOCOL = comms.PROTOCOL local PROTOCOL = comms.PROTOCOL
local DEVICE_TYPE = comms.DEVICE_TYPE 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 CAPI_TYPE = comms.CAPI_TYPE
local LINK_STATE = coreio.LINK_STATE local LINK_STATE = iocontrol.LINK_STATE
local pocket = {} 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 self.api.seq_num = self.api.seq_num + 1
end 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 -- attempt supervisor connection establishment
local function _send_sv_establish() local function _send_sv_establish()
_send_sv(SCADA_MGMT_TYPE.ESTABLISH, { comms.version, version, DEVICE_TYPE.PKT }) _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 -- attempt to re-link if any of the dependent links aren't active
function public.link_update() function public.link_update()
if not self.sv.linked then 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 if self.establish_delay_counter <= 0 then
_send_sv_establish() _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 self.establish_delay_counter = self.establish_delay_counter - 1
end end
elseif not self.api.linked then 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 if self.establish_delay_counter <= 0 then
_send_api_establish() _send_api_establish()
@ -166,10 +151,29 @@ function pocket.comms(version, nic, pkt_channel, svr_channel, crd_channel, range
end end
else else
-- linked, all good! -- linked, all good!
coreio.report_link_state(LINK_STATE.LINKED) iocontrol.report_link_state(LINK_STATE.LINKED)
end end
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 -- parse a packet
---@param side string ---@param side string
---@param sender integer ---@param sender integer
@ -205,6 +209,8 @@ function pocket.comms(version, nic, pkt_channel, svr_channel, crd_channel, range
-- handle a packet -- handle a packet
---@param packet mgmt_frame|capi_frame|nil ---@param packet mgmt_frame|capi_frame|nil
function public.handle_packet(packet) function public.handle_packet(packet)
local diag = iocontrol.get_db().diag
if packet ~= nil then if packet ~= nil then
local l_chan = packet.scada_frame.local_channel() local l_chan = packet.scada_frame.local_channel()
local r_chan = packet.scada_frame.remote_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 -- feed watchdog on valid sequence number
api_watchdog.feed() api_watchdog.feed()
if protocol == PROTOCOL.COORD_API then if protocol == PROTOCOL.SCADA_MGMT then
---@cast packet capi_frame
elseif protocol == PROTOCOL.SCADA_MGMT then
---@cast packet mgmt_frame ---@cast packet mgmt_frame
if packet.type == SCADA_MGMT_TYPE.ESTABLISH then if self.api.linked 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 packet.type == SCADA_MGMT_TYPE.KEEP_ALIVE then if packet.type == SCADA_MGMT_TYPE.KEEP_ALIVE then
-- keep alive request received, echo back -- keep alive request received, echo back
if packet.length == 1 then if packet.length == 1 then
@ -298,6 +266,42 @@ function pocket.comms(version, nic, pkt_channel, svr_channel, crd_channel, range
else else
log.debug("received unknown SCADA_MGMT packet type " .. packet.type .. " from coordinator") log.debug("received unknown SCADA_MGMT packet type " .. packet.type .. " from coordinator")
end 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 else
log.debug("discarding coordinator non-link SCADA_MGMT packet before linked") log.debug("discarding coordinator non-link SCADA_MGMT packet before linked")
end end
@ -325,43 +329,7 @@ function pocket.comms(version, nic, pkt_channel, svr_channel, crd_channel, range
-- handle packet -- handle packet
if protocol == PROTOCOL.SCADA_MGMT then if protocol == PROTOCOL.SCADA_MGMT then
---@cast packet mgmt_frame ---@cast packet mgmt_frame
if packet.type == SCADA_MGMT_TYPE.ESTABLISH then if self.sv.linked 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 packet.type == SCADA_MGMT_TYPE.KEEP_ALIVE then if packet.type == SCADA_MGMT_TYPE.KEEP_ALIVE then
-- keep alive request received, echo back -- keep alive request received, echo back
if packet.length == 1 then 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.r_seq_num = nil
self.sv.addr = comms.BROADCAST self.sv.addr = comms.BROADCAST
log.info("supervisor server connection closed by remote host") 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 else
log.debug("received unknown SCADA_MGMT packet type " .. packet.type .. " from supervisor") log.debug("received unknown SCADA_MGMT packet type " .. packet.type .. " from supervisor")
end 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 else
log.debug("discarding supervisor non-link SCADA_MGMT packet before linked") log.debug("discarding supervisor non-link SCADA_MGMT packet before linked")
end end

View File

@ -14,11 +14,11 @@ local util = require("scada-common.util")
local core = require("graphics.core") local core = require("graphics.core")
local config = require("pocket.config") local config = require("pocket.config")
local coreio = require("pocket.coreio") local iocontrol = require("pocket.iocontrol")
local pocket = require("pocket.pocket") local pocket = require("pocket.pocket")
local renderer = require("pocket.renderer") local renderer = require("pocket.renderer")
local POCKET_VERSION = "alpha-v0.5.2" local POCKET_VERSION = "v0.6.0-alpha"
local println = util.println local println = util.println
local println_ts = util.println_ts local println_ts = util.println_ts
@ -73,7 +73,7 @@ local function main()
network.init_mac(config.AUTH_KEY) network.init_mac(config.AUTH_KEY)
end end
coreio.report_link_state(coreio.LINK_STATE.UNLINKED) iocontrol.report_link_state(iocontrol.LINK_STATE.UNLINKED)
-- get the communications modem -- get the communications modem
local modem = ppm.get_wireless_modem() local modem = ppm.get_wireless_modem()
@ -104,6 +104,9 @@ local function main()
local MAIN_CLOCK = 0.5 local MAIN_CLOCK = 0.5
local loop_clock = util.new_clock(MAIN_CLOCK) local loop_clock = util.new_clock(MAIN_CLOCK)
-- init I/O control
iocontrol.init_core(pocket_comms)
---------------------------------------- ----------------------------------------
-- start the UI -- start the UI
---------------------------------------- ----------------------------------------
@ -128,6 +131,9 @@ local function main()
conn_wd.api.feed() conn_wd.api.feed()
log.debug("startup> conn watchdog started") log.debug("startup> conn watchdog started")
local io_db = iocontrol.get_db()
local nav = io_db.nav
-- main event loop -- main event loop
while true do while true do
local event, param1, param2, param3, param4, param5 = util.pull_event() local event, param1, param2, param3, param4, param5 = util.pull_event()
@ -140,6 +146,13 @@ local function main()
-- relink if necessary -- relink if necessary
pocket_comms.link_update() 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() loop_clock.start()
elseif conn_wd.sv.is_timer(param1) then elseif conn_wd.sv.is_timer(param1) then
-- supervisor watchdog timeout -- supervisor watchdog timeout

View File

@ -2,17 +2,18 @@
-- Pocket GUI Root -- Pocket GUI Root
-- --
local coreio = require("pocket.coreio") local iocontrol = require("pocket.iocontrol")
local style = require("pocket.ui.style") local style = require("pocket.ui.style")
local conn_waiting = require("pocket.ui.components.conn_waiting") 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 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 turbine_page = require("pocket.ui.pages.turbine_page")
local unit_page = require("pocket.ui.pages.unit_page")
local core = require("graphics.core") local core = require("graphics.core")
@ -22,6 +23,9 @@ local TextBox = require("graphics.elements.textbox")
local Sidebar = require("graphics.elements.controls.sidebar") 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 TEXT_ALIGN = core.TEXT_ALIGN
local cpair = core.cpair local cpair = core.cpair
@ -29,6 +33,9 @@ local cpair = core.cpair
-- create new main view -- create new main view
---@param main graphics_element main displaybox ---@param main graphics_element main displaybox
local function init(main) local function init(main)
local nav = iocontrol.get_db().nav
local ps = iocontrol.get_db().ps
-- window header message -- window header message
TextBox{parent=main,y=1,text="",alignment=TEXT_ALIGN.LEFT,height=1,fg_bg=style.header} 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} 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) root_pane.register(ps, "link_state", function (state)
if state == coreio.LINK_STATE.UNLINKED or state == coreio.LINK_STATE.API_LINK_ONLY then if state == LINK_STATE.UNLINKED or state == LINK_STATE.API_LINK_ONLY then
root_pane.set_value(1) 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) root_pane.set_value(2)
else else
root_pane.set_value(3) root_pane.set_value(3)
@ -81,19 +88,36 @@ local function init(main)
{ {
char = "T", char = "T",
color = cpair(colors.black,colors.white) color = cpair(colors.black,colors.white)
},
{
char = "D",
color = cpair(colors.black,colors.orange)
} }
} }
local pane_1 = home_page(page_div) 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 pane_2 = unit_page(page_div)
local pane_3 = reactor_page(page_div)
local pane_4 = boiler_page(page_div)
local pane_5 = turbine_page(page_div)
local panes = { pane_1, pane_2, pane_3, pane_4, pane_5 }
local page_pane = MultiPane{parent=page_div,x=1,y=1,panes=panes} 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 end
return init 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 cpair = core.cpair local App = require("graphics.elements.controls.app")
local TEXT_ALIGN = core.TEXT_ALIGN local cpair = core.cpair
-- new home page view -- new home page view
---@param root graphics_element parent ---@param root graphics_element parent
local function new_view(root) local function new_view(root)
local main = Div{parent=root,x=1,y=1} 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 return main
end end

View File

@ -19,7 +19,7 @@ local plc = require("reactor-plc.plc")
local renderer = require("reactor-plc.renderer") local renderer = require("reactor-plc.renderer")
local threads = require("reactor-plc.threads") local threads = require("reactor-plc.threads")
local R_PLC_VERSION = "v1.5.5" local R_PLC_VERSION = "v1.5.6"
local println = util.println local println = util.println
local println_ts = util.println_ts local println_ts = util.println_ts

View File

@ -165,7 +165,7 @@ function threads.thread__main(smem, init)
local type, device = ppm.handle_unmount(param1) local type, device = ppm.handle_unmount(param1)
if type ~= nil and device ~= nil then if type ~= nil and device ~= nil then
if type == "fissionReactorLogicAdapter" then if device == plc_dev.reactor then
println_ts("reactor disconnected!") println_ts("reactor disconnected!")
log.error("reactor logic adapter disconnected") log.error("reactor logic adapter disconnected")
@ -205,7 +205,7 @@ function threads.thread__main(smem, init)
local type, device = ppm.mount(param1) local type, device = ppm.mount(param1)
if type ~= nil and device ~= nil then if type ~= nil and device ~= nil then
if type == "fissionReactorLogicAdapter" then if plc_state.no_reactor and (type == "fissionReactorLogicAdapter") then
-- reconnected reactor -- reconnected reactor
plc_dev.reactor = device plc_dev.reactor = device
plc_state.no_reactor = false plc_state.no_reactor = false

View File

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

View File

@ -37,6 +37,12 @@ function databus.tx_hw_modem(has_modem)
databus.ps.publish("has_modem", has_modem) databus.ps.publish("has_modem", has_modem)
end 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 -- transmit unit hardware type across the bus
---@param uid integer unit ID ---@param uid integer unit ID
---@param type RTU_UNIT_TYPE ---@param type RTU_UNIT_TYPE

View File

@ -14,6 +14,7 @@ local core = require("graphics.core")
local Div = require("graphics.elements.div") local Div = require("graphics.elements.div")
local TextBox = require("graphics.elements.textbox") local TextBox = require("graphics.elements.textbox")
local DataIndicator = require("graphics.elements.indicators.data")
local LED = require("graphics.elements.indicators.led") local LED = require("graphics.elements.indicators.led")
local RGBLED = require("graphics.elements.indicators.ledrgb") local RGBLED = require("graphics.elements.indicators.ledrgb")
@ -21,17 +22,7 @@ local TEXT_ALIGN = core.TEXT_ALIGN
local cpair = core.cpair local cpair = core.cpair
local UNIT_TYPE_LABELS = { local UNIT_TYPE_LABELS = { "UNKNOWN", "REDSTONE", "BOILER", "TURBINE", "DYNAMIC TANK", "IND MATRIX", "SPS", "SNA", "ENV DETECTOR" }
"UNKNOWN",
"REDSTONE",
"BOILER",
"TURBINE",
"DYNAMIC TANK",
"IND MATRIX",
"SPS",
"SNA",
"ENV DETECTOR"
}
-- create new front panel view -- create new front panel view
@ -72,6 +63,10 @@ local function init(panel, units)
local comp_id = util.sprintf("(%d)", os.getComputerID()) 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=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 -- about label
-- --

View File

@ -1,9 +1,11 @@
local audio = require("scada-common.audio")
local comms = require("scada-common.comms") local comms = require("scada-common.comms")
local ppm = require("scada-common.ppm") local ppm = require("scada-common.ppm")
local log = require("scada-common.log") local log = require("scada-common.log")
local types = require("scada-common.types") local types = require("scada-common.types")
local util = require("scada-common.util") local util = require("scada-common.util")
local config = require("rtu.config")
local databus = require("rtu.databus") local databus = require("rtu.databus")
local modbus = require("rtu.modbus") local modbus = require("rtu.modbus")
@ -155,6 +157,48 @@ function rtu.init_unit()
return protected return protected
end 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 -- RTU Communications
---@nodiscard ---@nodiscard
---@param version string RTU version ---@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 packet modbus_frame|mgmt_frame
---@param units table RTU units ---@param units table RTU units
---@param rtu_state rtu_state ---@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 -- 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 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 elseif protocol == PROTOCOL.SCADA_MGMT then
---@cast packet mgmt_frame ---@cast packet mgmt_frame
-- SCADA management packet -- 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 if packet.length == 1 then
local est_ack = packet.data[1] local est_ack = packet.data[1]
@ -421,36 +508,6 @@ function rtu.comms(version, nic, rtu_channel, svr_channel, range, conn_watchdog)
else else
log.debug("SCADA_MGMT establish packet length mismatch") log.debug("SCADA_MGMT establish packet length mismatch")
end 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 else
log.debug("discarding non-link SCADA_MGMT packet before linked") log.debug("discarding non-link SCADA_MGMT packet before linked")
end end

View File

@ -4,6 +4,7 @@
require("/initenv").init_env() require("/initenv").init_env()
local audio = require("scada-common.audio")
local comms = require("scada-common.comms") local comms = require("scada-common.comms")
local crash = require("scada-common.crash") local crash = require("scada-common.crash")
local log = require("scada-common.log") local log = require("scada-common.log")
@ -30,7 +31,7 @@ local sna_rtu = require("rtu.dev.sna_rtu")
local sps_rtu = require("rtu.dev.sps_rtu") local sps_rtu = require("rtu.dev.sps_rtu")
local turbinev_rtu = require("rtu.dev.turbinev_rtu") local turbinev_rtu = require("rtu.dev.turbinev_rtu")
local RTU_VERSION = "v1.5.5" local RTU_VERSION = "v1.6.0"
local RTU_UNIT_TYPE = types.RTU_UNIT_TYPE local RTU_UNIT_TYPE = types.RTU_UNIT_TYPE
local RTU_UNIT_HW_STATE = databus.RTU_UNIT_HW_STATE local RTU_UNIT_HW_STATE = databus.RTU_UNIT_HW_STATE
@ -96,6 +97,9 @@ local function main()
return return
end end
-- generate alarm tones
audio.generate_tones()
---@class rtu_shared_memory ---@class rtu_shared_memory
local __shared_memory = { local __shared_memory = {
-- RTU system state flags -- RTU system state flags
@ -106,6 +110,11 @@ local function main()
shutdown = false shutdown = false
}, },
-- RTU gateway devices (not RTU units)
rtu_dev = {
sounders = {}
},
-- system objects -- system objects
rtu_sys = { rtu_sys = {
nic = network.nic(modem), nic = network.nic(modem),
@ -481,6 +490,18 @@ local function main()
log.info("startup> running in headless mode without front panel") log.info("startup> running in headless mode without front panel")
end 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 -- start connection watchdog
smem_sys.conn_watchdog = util.new_watchdog(config.COMMS_TIMEOUT) smem_sys.conn_watchdog = util.new_watchdog(config.COMMS_TIMEOUT)
log.debug("startup> conn watchdog started") log.debug("startup> conn watchdog started")

View File

@ -8,6 +8,7 @@ local util = require("scada-common.util")
local databus = require("rtu.databus") local databus = require("rtu.databus")
local modbus = require("rtu.modbus") local modbus = require("rtu.modbus")
local renderer = require("rtu.renderer") local renderer = require("rtu.renderer")
local rtu = require("rtu.rtu")
local boilerv_rtu = require("rtu.dev.boilerv_rtu") local boilerv_rtu = require("rtu.dev.boilerv_rtu")
local dynamicv_rtu = require("rtu.dev.dynamicv_rtu") local dynamicv_rtu = require("rtu.dev.dynamicv_rtu")
@ -24,7 +25,7 @@ local threads = {}
local RTU_UNIT_TYPE = types.RTU_UNIT_TYPE local RTU_UNIT_TYPE = types.RTU_UNIT_TYPE
local UNIT_HW_STATE = databus.RTU_UNIT_HW_STATE 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) local COMMS_SLEEP = 100 -- (100ms, 2 ticks)
-- main thread -- main thread
@ -47,6 +48,7 @@ function threads.thread__main(smem)
-- load in from shared memory -- load in from shared memory
local rtu_state = smem.rtu_state local rtu_state = smem.rtu_state
local sounders = smem.rtu_dev.sounders
local nic = smem.rtu_sys.nic local nic = smem.rtu_sys.nic
local rtu_comms = smem.rtu_sys.rtu_comms local rtu_comms = smem.rtu_sys.rtu_comms
local conn_watchdog = smem.rtu_sys.conn_watchdog local conn_watchdog = smem.rtu_sys.conn_watchdog
@ -66,6 +68,15 @@ function threads.thread__main(smem)
-- blink heartbeat indicator -- blink heartbeat indicator
databus.heartbeat() 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 -- start next clock timer
loop_clock.start() loop_clock.start()
@ -110,6 +121,18 @@ function threads.thread__main(smem)
else else
log.warning("non-comms modem disconnected") log.warning("non-comms modem disconnected")
end 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 else
for i = 1, #units do for i = 1, #units do
-- find disconnected device -- find disconnected device
@ -147,6 +170,13 @@ function threads.thread__main(smem)
else else
log.info("wired modem reconnected") log.info("wired modem reconnected")
end 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 else
-- relink lost peripheral to correct unit entry -- relink lost peripheral to correct unit entry
for i = 1, #units do for i = 1, #units do
@ -252,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 elseif event == "mouse_click" or event == "mouse_up" or event == "mouse_drag" or event == "mouse_scroll" then
-- handle a mouse event -- handle a mouse event
renderer.handle_mouse(core.events.new_mouse_event(event, param1, param2, param3)) 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 end
-- check for termination request -- check for termination request
@ -299,6 +338,7 @@ function threads.thread__comms(smem)
-- load in from shared memory -- load in from shared memory
local rtu_state = smem.rtu_state local rtu_state = smem.rtu_state
local sounders = smem.rtu_dev.sounders
local rtu_comms = smem.rtu_sys.rtu_comms local rtu_comms = smem.rtu_sys.rtu_comms
local units = smem.rtu_sys.units local units = smem.rtu_sys.units
@ -321,8 +361,8 @@ function threads.thread__comms(smem)
-- received data -- received data
elseif msg.qtype == mqueue.TYPE.PACKET then elseif msg.qtype == mqueue.TYPE.PACKET then
-- received a packet -- received a packet
-- handle the packet (rtu_state passed to allow setting link flag) -- 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) rtu_comms.handle_packet(msg.message, units, rtu_state, sounders)
end end
end 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,7 +14,7 @@ local max_distance = nil ---@type number|nil maximum acceptable t
---@class comms ---@class comms
local comms = {} local comms = {}
comms.version = "2.1.2" comms.version = "2.2.1"
---@enum PROTOCOL ---@enum PROTOCOL
local PROTOCOL = { local PROTOCOL = {
@ -46,7 +46,11 @@ local SCADA_MGMT_TYPE = {
KEEP_ALIVE = 1, -- keep alive packet w/ RTT KEEP_ALIVE = 1, -- keep alive packet w/ RTT
CLOSE = 2, -- close a connection CLOSE = 2, -- close a connection
RTU_ADVERT = 3, -- RTU capability advertisement RTU_ADVERT = 3, -- RTU capability advertisement
RTU_DEV_REMOUNT = 4 -- RTU multiblock possbily changed (formed, unformed) due to PPM remount 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 ---@enum SCADA_CRDN_TYPE
@ -146,6 +150,7 @@ function comms.scada_packet()
local self = { local self = {
modem_msg_in = nil, ---@type modem_message|nil modem_msg_in = nil, ---@type modem_message|nil
valid = false, valid = false,
authenticated = false,
raw = {}, raw = {},
src_addr = comms.BROADCAST, src_addr = comms.BROADCAST,
dest_addr = comms.BROADCAST, dest_addr = comms.BROADCAST,
@ -234,6 +239,9 @@ function comms.scada_packet()
return self.valid return self.valid
end end
-- report that this packet has been authenticated (was received with a valid HMAC)
function public.stamp_authenticated() self.authenticated = true end
-- public accessors -- -- public accessors --
---@nodiscard ---@nodiscard
@ -248,6 +256,8 @@ function comms.scada_packet()
---@nodiscard ---@nodiscard
function public.is_valid() return self.valid end function public.is_valid() return self.valid end
---@nodiscard
function public.is_authenticated() return self.authenticated end
---@nodiscard ---@nodiscard
function public.src_addr() return self.src_addr end function public.src_addr() return self.src_addr end
@ -313,7 +323,7 @@ function comms.authd_packet()
self.valid = false self.valid = false
self.raw = self.modem_msg_in.msg 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 -- outside of maximum allowable transmission distance
-- log.debug("comms.authd_packet.receive(): discarding packet with distance " .. distance .. " outside of trusted range") -- log.debug("comms.authd_packet.receive(): discarding packet with distance " .. distance .. " outside of trusted range")
else else
@ -588,7 +598,11 @@ function comms.mgmt_packet()
self.type == SCADA_MGMT_TYPE.CLOSE or self.type == SCADA_MGMT_TYPE.CLOSE or
self.type == SCADA_MGMT_TYPE.REMOTE_LINKED or self.type == SCADA_MGMT_TYPE.REMOTE_LINKED or
self.type == SCADA_MGMT_TYPE.RTU_ADVERT 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 end
-- make a SCADA management packet -- make a SCADA management packet

View File

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

View File

@ -6,8 +6,10 @@ local comms = require("scada-common.comms")
local log = require("scada-common.log") local log = require("scada-common.log")
local util = require("scada-common.util") 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 crash = {}
local app = "unknown" local app = "unknown"
@ -34,7 +36,8 @@ function crash.handler(error)
log.info(util.c("APPLICATION: ", app)) log.info(util.c("APPLICATION: ", app))
log.info(util.c("FIRMWARE VERSION: ", ver)) log.info(util.c("FIRMWARE VERSION: ", ver))
log.info(util.c("COMMS VERSION: ", comms.version)) 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("----------------------------------")
log.info(debug.traceback("--- begin debug trace ---", 1)) log.info(debug.traceback("--- begin debug trace ---", 1))
log.info("--- end debug trace ---") log.info("--- end debug trace ---")

View File

@ -4,14 +4,11 @@
local util = require("scada-common.util") local util = require("scada-common.util")
---@class log ---@class logger
local log = {} local log = {}
---@alias MODE integer ---@alias MODE integer
local MODE = { local MODE = { APPEND = 0, NEW = 1 }
APPEND = 0,
NEW = 1
}
log.MODE = MODE log.MODE = MODE

View File

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

View File

@ -2,19 +2,21 @@
-- Network Communications -- 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 md5 = require("lockbox.digest.md5")
local sha256 = require("lockbox.digest.sha2_256") local sha256 = require("lockbox.digest.sha2_256")
local pbkdf2 = require("lockbox.kdf.pbkdf2") local pbkdf2 = require("lockbox.kdf.pbkdf2")
local hmac = require("lockbox.mac.hmac") local hmac = require("lockbox.mac.hmac")
local stream = require("lockbox.util.stream") local stream = require("lockbox.util.stream")
local array = require("lockbox.util.array") 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 = {} local network = {}
-- cryptography engine
local c_eng = { local c_eng = {
key = nil, key = nil,
hmac = nil hmac = nil
@ -212,6 +214,7 @@ function network.nic(modem)
if packet_hmac == computed_hmac then if packet_hmac == computed_hmac then
-- log.debug("crypto.modem.receive: HMAC verified in " .. (util.time_ms() - start) .. "ms") -- 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.receive(side, sender, reply_to, textutils.unserialize(msg), distance)
s_packet.stamp_authenticated()
else else
-- log.debug("crypto.modem.receive: HMAC failed verification in " .. (util.time_ms() - start) .. "ms") -- log.debug("crypto.modem.receive: HMAC failed verification in " .. (util.time_ms() - start) .. "ms")
end end

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,3 +1,4 @@
local audio = require("scada-common.audio")
local const = require("scada-common.constants") local const = require("scada-common.constants")
local log = require("scada-common.log") local log = require("scada-common.log")
local rsio = require("scada-common.rsio") local rsio = require("scada-common.rsio")
@ -10,13 +11,17 @@ local qtypes = require("supervisor.session.rtu.qtypes")
local rsctl = require("supervisor.session.rsctl") 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 = types.PROCESS
local PROCESS_NAMES = types.PROCESS_NAMES local PROCESS_NAMES = types.PROCESS_NAMES
local PRIO = types.ALARM_PRIORITY
local RTU_UNIT_TYPE = types.RTU_UNIT_TYPE local RTU_UNIT_TYPE = types.RTU_UNIT_TYPE
local CONTAINER_MODE = types.CONTAINER_MODE
local WASTE = types.WASTE_PRODUCT
local WASTE_MODE = types.WASTE_MODE local WASTE_MODE = types.WASTE_MODE
local WASTE = types.WASTE_PRODUCT
local IO = rsio.IO local IO = rsio.IO
@ -65,6 +70,7 @@ function facility.new(num_reactors, cooling_conf)
units = {}, units = {},
status_text = { "START UP", "initializing..." }, status_text = { "START UP", "initializing..." },
all_sys_ok = false, all_sys_ok = false,
allow_testing = false,
-- rtus -- rtus
rtu_conn_count = 0, rtu_conn_count = 0,
rtu_list = {}, rtu_list = {},
@ -114,6 +120,12 @@ function facility.new(num_reactors, cooling_conf)
waste_product = WASTE.PLUTONIUM, waste_product = WASTE.PLUTONIUM,
current_waste_product = WASTE.PLUTONIUM, current_waste_product = WASTE.PLUTONIUM,
pu_fallback = false, pu_fallback = false,
-- alarm tones
tone_states = {},
test_tone_set = false,
test_tone_reset = false,
test_tone_states = {},
test_alarm_states = {},
-- statistics -- statistics
im_stat_init = false, im_stat_init = false,
avg_charge = util.mov_avg(3, 0.0), avg_charge = util.mov_avg(3, 0.0),
@ -133,6 +145,13 @@ function facility.new(num_reactors, cooling_conf)
-- init redstone RTU I/O controller -- init redstone RTU I/O controller
self.io_ctl = rsctl.new(self.redstone) self.io_ctl = rsctl.new(self.redstone)
-- 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 -- check if all auto-controlled units completed ramping
---@nodiscard ---@nodiscard
local function _all_units_ramped() local function _all_units_ramped()
@ -267,15 +286,20 @@ function facility.new(num_reactors, cooling_conf)
-- supervisor sessions reporting the list of active RTU sessions -- supervisor sessions reporting the list of active RTU sessions
---@param rtu_sessions table session list of all connected RTUs ---@param rtu_sessions table session list of all connected RTUs
function public.report_rtus(rtu_sessions) function public.report_rtus(rtu_sessions) self.rtu_conn_count = #rtu_sessions end
self.rtu_conn_count = #rtu_sessions
end
-- update (iterate) the facility management -- update (iterate) the facility management
function public.update() function public.update()
-- unlink RTU unit sessions if they are closed -- unlink RTU unit sessions if they are closed
for _, v in pairs(self.rtu_list) do util.filter_table(v, function (u) return u.is_connected() end) end 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 -- current state for process control
local charge_update = 0 local charge_update = 0
local rate_update = 0 local rate_update = 0
@ -778,6 +802,97 @@ function facility.new(num_reactors, cooling_conf)
end end
end 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 end
-- call the update function of all units in the facility<br> -- call the update function of all units in the facility<br>
@ -919,8 +1034,52 @@ function facility.new(num_reactors, cooling_conf)
return self.pu_fallback return self.pu_fallback
end 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 -- -- READ STATES/PROPERTIES --
-- get current alarm tone on/off states
---@nodiscard
function public.get_alarm_tones() return self.tone_states end
-- get build properties of all facility devices -- get build properties of all facility devices
---@nodiscard ---@nodiscard
---@param type RTU_UNIT_TYPE? type or nil to include only a particular unit type, or to include all if nil ---@param type RTU_UNIT_TYPE? type or nil to include only a particular unit type, or to include all if nil

View File

@ -154,7 +154,8 @@ function coordinator.new_session(id, s_addr, in_queue, out_queue, timeout, facil
local function _send_fac_status() local function _send_fac_status()
local status = { local status = {
facility.get_control_status(), facility.get_control_status(),
facility.get_rtu_statuses() facility.get_rtu_statuses(),
facility.get_alarm_tones()
} }
_send(SCADA_CRDN_TYPE.FAC_STATUS, status) _send(SCADA_CRDN_TYPE.FAC_STATUS, status)

View File

@ -33,8 +33,9 @@ local PERIODICS = {
---@param in_queue mqueue in message queue ---@param in_queue mqueue in message queue
---@param out_queue mqueue out message queue ---@param out_queue mqueue out message queue
---@param timeout number communications timeout ---@param timeout number communications timeout
---@param facility facility facility data table
---@param fp_ok boolean if the front panel UI is running ---@param fp_ok boolean if the front panel UI is running
function pocket.new_session(id, s_addr, in_queue, out_queue, timeout, fp_ok) function pocket.new_session(id, s_addr, in_queue, out_queue, timeout, facility, fp_ok)
-- print a log message to the terminal as long as the UI isn't running -- print a log message to the terminal as long as the UI isn't running
local function println(message) if not fp_ok then util.println_ts(message) end end local function println(message) if not fp_ok then util.println_ts(message) end end
@ -129,6 +130,55 @@ function pocket.new_session(id, s_addr, in_queue, out_queue, timeout, fp_ok)
elseif pkt.type == SCADA_MGMT_TYPE.CLOSE then elseif pkt.type == SCADA_MGMT_TYPE.CLOSE then
-- close the session -- close the session
_close() _close()
elseif pkt.type == SCADA_MGMT_TYPE.DIAG_TONE_GET then
-- get the state of alarm tones
_send_mgmt(SCADA_MGMT_TYPE.DIAG_TONE_GET, facility.get_alarm_tones())
elseif pkt.type == SCADA_MGMT_TYPE.DIAG_TONE_SET then
local valid = false
-- attempt to set a tone state
if pkt.scada_frame.is_authenticated() then
if pkt.length == 2 then
if type(pkt.data[1]) == "number" and type(pkt.data[2]) == "boolean" then
valid = true
-- try to set tone states, then send back if testing is allowed
local allow_testing, test_tone_states = facility.diag_set_test_tone(pkt.data[1], pkt.data[2])
_send_mgmt(SCADA_MGMT_TYPE.DIAG_TONE_SET, { allow_testing, test_tone_states })
else
log.debug(log_header .. "SCADA diag tone set packet data type mismatch")
end
else
log.debug(log_header .. "SCADA diag tone set packet length mismatch")
end
else
log.debug(log_header .. "DIAG_TONE_SET is blocked without HMAC for security")
end
if not valid then _send_mgmt(SCADA_MGMT_TYPE.DIAG_TONE_SET, { false }) end
elseif pkt.type == SCADA_MGMT_TYPE.DIAG_ALARM_SET then
local valid = false
-- attempt to set an alarm state
if pkt.scada_frame.is_authenticated() then
if pkt.length == 2 then
if type(pkt.data[1]) == "number" and type(pkt.data[2]) == "boolean" then
valid = true
-- try to set alarm states, then send back if testing is allowed
local allow_testing, test_alarm_states = facility.diag_set_test_alarm(pkt.data[1], pkt.data[2])
_send_mgmt(SCADA_MGMT_TYPE.DIAG_ALARM_SET, { allow_testing, test_alarm_states })
else
log.debug(log_header .. "SCADA diag alarm set packet data type mismatch")
end
else
log.debug(log_header .. "SCADA diag alarm set packet length mismatch")
end
else
log.debug(log_header .. "DIAG_ALARM_SET is blocked without HMAC for security")
end
if not valid then _send_mgmt(SCADA_MGMT_TYPE.DIAG_ALARM_SET, { false }) end
else else
log.debug(log_header .. "handler received unsupported SCADA_MGMT packet type " .. pkt.type) log.debug(log_header .. "handler received unsupported SCADA_MGMT packet type " .. pkt.type)
end end

View File

@ -26,7 +26,8 @@ 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 PERIODICS = { local PERIODICS = {
KEEP_ALIVE = 2000 KEEP_ALIVE = 2000,
ALARM_TONES = 500
} }
-- create a new RTU session -- create a new RTU session
@ -58,7 +59,8 @@ function rtu.new_session(id, s_addr, in_queue, out_queue, timeout, advertisement
-- periodic messages -- periodic messages
periodics = { periodics = {
last_update = 0, last_update = 0,
keep_alive = 0 keep_alive = 0,
alarm_tones = 0
}, },
units = {} units = {}
} }
@ -389,6 +391,14 @@ function rtu.new_session(id, s_addr, in_queue, out_queue, timeout, advertisement
periodics.keep_alive = 0 periodics.keep_alive = 0
end end
-- alarm tones
periodics.alarm_tones = periodics.alarm_tones + elapsed
if periodics.alarm_tones >= PERIODICS.ALARM_TONES then
_send_mgmt(SCADA_MGMT_TYPE.RTU_TONE_ALARM, { facility.get_alarm_tones() })
periodics.alarm_tones = 0
end
self.periodics.last_update = util.time() self.periodics.last_update = util.time()
-------------------------------------------- --------------------------------------------

View File

@ -104,8 +104,8 @@ local function _sv_handle_outq(session)
-- max 100ms spent processing queue -- max 100ms spent processing queue
if util.time() - handle_start > 100 then if util.time() - handle_start > 100 then
log.warning("[SVS] supervisor out queue handler exceeded 100ms queue process limit") log.debug("[SVS] supervisor out queue handler exceeded 100ms queue process limit")
log.warning(util.c("[SVS] offending session: ", session)) log.debug(util.c("[SVS] offending session: ", session))
break break
end end
end end
@ -430,7 +430,8 @@ function svsessions.establish_pdg_session(source_addr, version)
local id = self.next_ids.pdg local id = self.next_ids.pdg
pdg_s.instance = pocket.new_session(id, source_addr, pdg_s.in_queue, pdg_s.out_queue, config.PKT_TIMEOUT, self.fp_ok) pdg_s.instance = pocket.new_session(id, source_addr, pdg_s.in_queue, pdg_s.out_queue, config.PKT_TIMEOUT, self.facility,
self.fp_ok)
table.insert(self.sessions.pdg, pdg_s) table.insert(self.sessions.pdg, pdg_s)
local mt = { local mt = {

View File

@ -733,6 +733,23 @@ function unit.new(reactor_id, num_boilers, num_turbines)
return false return false
end end
-- check if the reactor is connected, is stopped, the RPS is not tripped, and no alarms are active
---@nodiscard
function public.is_safe_idle()
-- can't be disconnected
if self.plc_i == nil then return false end
-- reactor must be stopped and RPS can't be tripped
if self.plc_i.get_status().status or self.plc_i.get_db().rps_tripped then return false end
-- alarms must be inactive and not tripping
for _, alarm in pairs(self.alarms) do
if not (alarm.state == AISTATE.INACTIVE or alarm.state == AISTATE.RING_BACK) then return false end
end
return true
end
-- check if emergency coolant activation has been tripped -- check if emergency coolant activation has been tripped
---@nodiscard ---@nodiscard
function public.is_emer_cool_tripped() return self.emcool_opened end function public.is_emer_cool_tripped() return self.emcool_opened end