diff --git a/coordinator/iocontrol.lua b/coordinator/iocontrol.lua index ce6d667..44f8cb7 100644 --- a/coordinator/iocontrol.lua +++ b/coordinator/iocontrol.lua @@ -83,6 +83,8 @@ function iocontrol.init(conf, comms) scram_ack = __generic_ack, ack_alarms_ack = __generic_ack, + alarm_tones = { false, false, false, false, false, false, false, false }, + ps = psil.create(), induction_ps_tbl = {}, @@ -664,6 +666,16 @@ function iocontrol.update_facility_status(status) end fac.ps.publish("rtu_count", fac.rtu_count) + + -- alarm tone commands + + if (type(status[3]) == "table") and (#status[3] == 8) then + fac.alarm_tones = status[3] + sounder.set(fac.alarm_tones) + else + log.debug(log_header .. "alarm tones not a table or length mismatch") + valid = false + end end return valid @@ -1013,9 +1025,6 @@ function iocontrol.update_unit_statuses(statuses) io.facility.ps.publish("sna_count", sna_count_sum) io.facility.ps.publish("pu_rate", pu_rate) io.facility.ps.publish("po_rate", po_rate) - - -- update alarm sounder - sounder.eval(io.units) end return valid diff --git a/coordinator/sounder.lua b/coordinator/sounder.lua index 86fb9b4..23fc702 100644 --- a/coordinator/sounder.lua +++ b/coordinator/sounder.lua @@ -2,269 +2,25 @@ -- Alarm Sounder -- +local audio = require("scada-common.audio") local log = require("scada-common.log") -local types = require("scada-common.types") -local util = require("scada-common.util") - -local ALARM = types.ALARM -local ALARM_STATE = types.ALARM_STATE ---@class sounder local sounder = {} --- note: max samples = 0x20000 (128 * 1024 samples) - -local _2_PI = 2 * math.pi -- 2 whole pies, hope you're hungry -local _DRATE = 48000 -- 48kHz audio -local _MAX_VAL = 127 / 2 -- max signed integer in this 8-bit audio -local _05s_SAMPLES = 24000 -- half a second worth of samples - -local test_alarms = { false, false, false, false, false, false, false, false, false, false, false, false } - local alarm_ctl = { speaker = nil, volume = 0.5, - playing = false, - num_active = 0, - next_block = 1, - -- split audio up into 0.5s samples so specific components can be ended quicker - quad_buffer = { {}, {}, {}, {} } + stream = audio.new_stream() } --- sounds modeled after https://www.e2s.com/references-and-guidelines/listen-and-download-alarm-tones - -local T_340Hz_Int_2Hz = 1 -local T_544Hz_440Hz_Alt = 2 -local T_660Hz_Int_125ms = 3 -local T_745Hz_Int_1Hz = 4 -local T_800Hz_Int = 5 -local T_800Hz_1000Hz_Alt = 6 -local T_1000Hz_Int = 7 -local T_1800Hz_Int_4Hz = 8 - -local TONES = { - { active = false, component = { {}, {}, {}, {} } }, -- 340Hz @ 2Hz Intermittent - { active = false, component = { {}, {}, {}, {} } }, -- 544Hz 100mS / 440Hz 400mS Alternating - { active = false, component = { {}, {}, {}, {} } }, -- 660Hz @ 125ms On 125ms Off - { active = false, component = { {}, {}, {}, {} } }, -- 745Hz @ 1Hz Intermittent - { active = false, component = { {}, {}, {}, {} } }, -- 800Hz @ 0.25s On 1.75s Off - { active = false, component = { {}, {}, {}, {} } }, -- 800/1000Hz @ 0.25s Alternating - { active = false, component = { {}, {}, {}, {} } }, -- 1KHz 1s on, 1s off Intermittent - { active = false, component = { {}, {}, {}, {} } } -- 1.8KHz @ 4Hz Intermittent -} - --- calculate how many samples are in the given number of milliseconds ----@nodiscard ----@param ms integer milliseconds ----@return integer samples -local function ms_to_samples(ms) return math.floor(ms * 48) end - ---#region Tone Generation (the Maths) - --- 340Hz @ 2Hz Intermittent -local function gen_tone_1() - local t, dt = 0, _2_PI * 340 / _DRATE - - for i = 1, _05s_SAMPLES do - local val = math.floor(math.sin(t) * _MAX_VAL) - TONES[1].component[1][i] = val - TONES[1].component[3][i] = val - TONES[1].component[2][i] = 0 - TONES[1].component[4][i] = 0 - t = (t + dt) % _2_PI - end -end - --- 544Hz 100mS / 440Hz 400mS Alternating -local function gen_tone_2() - local t1, dt1 = 0, _2_PI * 544 / _DRATE - local t2, dt2 = 0, _2_PI * 440 / _DRATE - local alternate_at = ms_to_samples(100) - - for i = 1, _05s_SAMPLES do - local value - - if i <= alternate_at then - value = math.floor(math.sin(t1) * _MAX_VAL) - t1 = (t1 + dt1) % _2_PI - else - value = math.floor(math.sin(t2) * _MAX_VAL) - t2 = (t2 + dt2) % _2_PI - end - - TONES[2].component[1][i] = value - TONES[2].component[2][i] = value - TONES[2].component[3][i] = value - TONES[2].component[4][i] = value - end -end - --- 660Hz @ 125ms On 125ms Off -local function gen_tone_3() - local elapsed_samples = 0 - local alternate_after = ms_to_samples(125) - local alternate_at = alternate_after - local mode = true - - local t, dt = 0, _2_PI * 660 / _DRATE - - for set = 1, 4 do - for i = 1, _05s_SAMPLES do - if mode then - local val = math.floor(math.sin(t) * _MAX_VAL) - TONES[3].component[set][i] = val - t = (t + dt) % _2_PI - else - t = 0 - TONES[3].component[set][i] = 0 - end - - if elapsed_samples == alternate_at then - mode = not mode - alternate_at = elapsed_samples + alternate_after - end - - elapsed_samples = elapsed_samples + 1 - end - end -end - --- 745Hz @ 1Hz Intermittent -local function gen_tone_4() - local t, dt = 0, _2_PI * 745 / _DRATE - - for i = 1, _05s_SAMPLES do - local val = math.floor(math.sin(t) * _MAX_VAL) - TONES[4].component[1][i] = val - TONES[4].component[3][i] = val - TONES[4].component[2][i] = 0 - TONES[4].component[4][i] = 0 - t = (t + dt) % _2_PI - end -end - --- 800Hz @ 0.25s On 1.75s Off -local function gen_tone_5() - local t, dt = 0, _2_PI * 800 / _DRATE - local stop_at = ms_to_samples(250) - - for i = 1, _05s_SAMPLES do - local val = math.floor(math.sin(t) * _MAX_VAL) - - if i > stop_at then - TONES[5].component[1][i] = val - else - TONES[5].component[1][i] = 0 - end - - TONES[5].component[2][i] = 0 - TONES[5].component[3][i] = 0 - TONES[5].component[4][i] = 0 - - t = (t + dt) % _2_PI - end -end - --- 1000/800Hz @ 0.25s Alternating -local function gen_tone_6() - local t1, dt1 = 0, _2_PI * 1000 / _DRATE - local t2, dt2 = 0, _2_PI * 800 / _DRATE - - local alternate_at = ms_to_samples(250) - - for i = 1, _05s_SAMPLES do - local val - if i <= alternate_at then - val = math.floor(math.sin(t1) * _MAX_VAL) - t1 = (t1 + dt1) % _2_PI - else - val = math.floor(math.sin(t2) * _MAX_VAL) - t2 = (t2 + dt2) % _2_PI - end - - TONES[6].component[1][i] = val - TONES[6].component[2][i] = val - TONES[6].component[3][i] = val - TONES[6].component[4][i] = val - end -end - --- 1KHz 1s on, 1s off Intermittent -local function gen_tone_7() - local t, dt = 0, _2_PI * 1000 / _DRATE - - for i = 1, _05s_SAMPLES do - local val = math.floor(math.sin(t) * _MAX_VAL) - TONES[7].component[1][i] = val - TONES[7].component[2][i] = val - TONES[7].component[3][i] = 0 - TONES[7].component[4][i] = 0 - t = (t + dt) % _2_PI - end -end - --- 1800Hz @ 4Hz Intermittent -local function gen_tone_8() - local t, dt = 0, _2_PI * 1800 / _DRATE - - local off_at = ms_to_samples(250) - - for i = 1, _05s_SAMPLES do - local val = 0 - - if i <= off_at then - val = math.floor(math.sin(t) * _MAX_VAL) - t = (t + dt) % _2_PI - end - - TONES[8].component[1][i] = val - TONES[8].component[2][i] = val - TONES[8].component[3][i] = val - TONES[8].component[4][i] = val - end -end - ---#endregion - --- hard audio limiter ----@nodiscard ----@param output number output level ----@return number limited -128.0 to 127.0 -local function limit(output) - return math.max(-128, math.min(127, output)) -end - --- zero the alarm audio buffer -local function zero() - for i = 1, 4 do - for s = 1, _05s_SAMPLES do alarm_ctl.quad_buffer[i][s] = 0 end - end -end - --- add an alarm to the output buffer ----@param alarm_idx integer tone ID -local function add(alarm_idx) - alarm_ctl.num_active = alarm_ctl.num_active + 1 - TONES[alarm_idx].active = true - - for i = 1, 4 do - for s = 1, _05s_SAMPLES do - alarm_ctl.quad_buffer[i][s] = limit(alarm_ctl.quad_buffer[i][s] + TONES[alarm_idx].component[i][s]) - end - end -end - -- start audio or continue audio on buffer empty ---@return boolean success successfully added buffer to audio output local function play() if not alarm_ctl.playing then alarm_ctl.playing = true - alarm_ctl.next_block = 1 - return sounder.continue() - else - return true - end + else return true end end -- initialize the annunciator alarm system @@ -273,23 +29,10 @@ end function sounder.init(speaker, volume) alarm_ctl.speaker = speaker alarm_ctl.speaker.stop() - alarm_ctl.volume = volume - alarm_ctl.playing = false - alarm_ctl.num_active = 0 - alarm_ctl.next_block = 1 + alarm_ctl.stream.stop() - zero() - - -- generate tones - gen_tone_1() - gen_tone_2() - gen_tone_3() - gen_tone_4() - gen_tone_5() - gen_tone_6() - gen_tone_7() - gen_tone_8() + audio.generate_tones() end -- reconnect the speaker peripheral @@ -297,172 +40,65 @@ end function sounder.reconnect(speaker) alarm_ctl.speaker = speaker alarm_ctl.playing = false - alarm_ctl.next_block = 1 - alarm_ctl.num_active = 0 - for id = 1, #TONES do TONES[id].active = false end + alarm_ctl.stream.stop() end --- check alarm state to enable/disable alarms ----@param units table|nil unit list or nil to use test mode -function sounder.eval(units) - local changed = false - local any_active = false - local new_states = { false, false, false, false, false, false, false, false } - local alarms = { false, false, false, false, false, false, false, false, false, false, false, false } +-- set alarm tones +---@param states table alarm tone commands from supervisor +function sounder.set(states) + -- set tone states + for id = 1, #states do alarm_ctl.stream.set_active(id, states[id]) end - if units ~= nil then - -- check all alarms for all units - for i = 1, #units do - local unit = units[i] ---@type ioctl_unit - for id = 1, #unit.alarms do - alarms[id] = alarms[id] or (unit.alarms[id] == ALARM_STATE.TRIPPED) - end - end - else - alarms = test_alarms - end - - -- containment breach is worst case CRITICAL alarm, this takes priority - if alarms[ALARM.ContainmentBreach] then - new_states[T_1800Hz_Int_4Hz] = true - else - -- critical damage is highest priority CRITICAL level alarm - if alarms[ALARM.CriticalDamage] then - new_states[T_660Hz_Int_125ms] = true - else - -- EMERGENCY level alarms + URGENT over temp - if alarms[ALARM.ReactorDamage] or alarms[ALARM.ReactorOverTemp] or alarms[ALARM.ReactorWasteLeak] then - new_states[T_544Hz_440Hz_Alt] = true - -- URGENT level turbine trip - elseif alarms[ALARM.TurbineTrip] then - new_states[T_745Hz_Int_1Hz] = true - -- URGENT level reactor lost - elseif alarms[ALARM.ReactorLost] then - new_states[T_340Hz_Int_2Hz] = true - -- TIMELY level alarms - elseif alarms[ALARM.ReactorHighTemp] or alarms[ALARM.ReactorHighWaste] or alarms[ALARM.RCSTransient] then - new_states[T_800Hz_Int] = true - end - end - - -- check RPS transient URGENT level alarm - if alarms[ALARM.RPSTransient] then - new_states[T_1000Hz_Int] = true - -- disable really painful audio combination - new_states[T_340Hz_Int_2Hz] = false - end - end - - -- radiation is a big concern, always play this CRITICAL level alarm if active - if alarms[ALARM.ContainmentRadiation] then - new_states[T_800Hz_1000Hz_Alt] = true - -- we are going to disable the RPS trip alarm audio due to conflict, and if it was enabled - -- then we can re-enable the reactor lost alarm audio since it doesn't painfully combine with this one - if new_states[T_1000Hz_Int] and alarms[ALARM.ReactorLost] then new_states[T_340Hz_Int_2Hz] = true end - -- it sounds *really* bad if this is in conjunction with these other tones, so disable them - new_states[T_745Hz_Int_1Hz] = false - new_states[T_800Hz_Int] = false - new_states[T_1000Hz_Int] = false - end - - -- check if any changed, check if any active, update active flags - for id = 1, #TONES do - if new_states[id] ~= TONES[id].active then - TONES[id].active = new_states[id] - changed = true - end - - if TONES[id].active then any_active = true end - end - - -- zero and re-add tones if changed - if changed then - zero() - - for id = 1, #TONES do - if TONES[id].active then add(id) end - end - end - - if any_active then play() else sounder.stop() end + -- re-compute output if needed, then play audio if available + if alarm_ctl.stream.is_recompute_needed() then alarm_ctl.stream.compute_buffer() end + if alarm_ctl.stream.has_next_block() then play() else sounder.stop() end end -- stop all audio and clear output buffer function sounder.stop() alarm_ctl.playing = false alarm_ctl.speaker.stop() - alarm_ctl.next_block = 1 - alarm_ctl.num_active = 0 - for id = 1, #TONES do TONES[id].active = false end - zero() + alarm_ctl.stream.stop() end -- continue audio on buffer empty ---@return boolean success successfully added buffer to audio output function sounder.continue() + local success = false + if alarm_ctl.playing then - if alarm_ctl.speaker ~= nil and #alarm_ctl.quad_buffer[alarm_ctl.next_block] > 0 then - local success = alarm_ctl.speaker.playAudio(alarm_ctl.quad_buffer[alarm_ctl.next_block], alarm_ctl.volume) - - alarm_ctl.next_block = alarm_ctl.next_block + 1 - if alarm_ctl.next_block > 4 then alarm_ctl.next_block = 1 end - - if not success then - log.debug("SOUNDER: error playing audio") - end - - return success - else - return false + if alarm_ctl.speaker ~= nil and alarm_ctl.stream.has_next_block() then + success = alarm_ctl.speaker.playAudio(alarm_ctl.stream.get_next_block(), alarm_ctl.volume) + if not success then log.error("SOUNDER: error playing audio") end end - else - return false end + + return success 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_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 +-- 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 --#endregion diff --git a/coordinator/startup.lua b/coordinator/startup.lua index a3cf6fa..75b5eb9 100644 --- a/coordinator/startup.lua +++ b/coordinator/startup.lua @@ -22,7 +22,7 @@ local sounder = require("coordinator.sounder") local apisessions = require("coordinator.session.apisessions") -local COORDINATOR_VERSION = "v0.21.2" +local COORDINATOR_VERSION = "v0.22.0" local println = util.println local println_ts = util.println_ts diff --git a/scada-common/audio.lua b/scada-common/audio.lua new file mode 100644 index 0000000..5b73090 --- /dev/null +++ b/scada-common/audio.lua @@ -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_id +local TONES = { + 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.TONES = TONES + +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_id tone ID + ---@param active boolean active state + function public.set_active(index, active) + if self.tone_active[index] 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_id 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] + 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 diff --git a/supervisor/facility.lua b/supervisor/facility.lua index 77aa676..c72f5a2 100644 --- a/supervisor/facility.lua +++ b/supervisor/facility.lua @@ -1,3 +1,4 @@ +local audio = require("scada-common.audio") local const = require("scada-common.constants") local log = require("scada-common.log") local rsio = require("scada-common.rsio") @@ -8,12 +9,16 @@ local unit = require("supervisor.unit") local rsctl = require("supervisor.session.rsctl") -local PROCESS = types.PROCESS +local TONES = audio.TONES + +local PROCESS = types.PROCESS local PROCESS_NAMES = types.PROCESS_NAMES -local PRIO = types.ALARM_PRIORITY +local PRIO = types.ALARM_PRIORITY +local ALARM = types.ALARM +local ALARM_STATE = types.ALARM_STATE local RTU_UNIT_TYPE = types.RTU_UNIT_TYPE -local WASTE = types.WASTE_PRODUCT -local WASTE_MODE = types.WASTE_MODE +local WASTE = types.WASTE_PRODUCT +local WASTE_MODE = types.WASTE_MODE local IO = rsio.IO @@ -109,6 +114,8 @@ function facility.new(num_reactors, cooling_conf) waste_product = WASTE.PLUTONIUM, current_waste_product = WASTE.PLUTONIUM, pu_fallback = false, + -- alarm tones + tone_states = { false, false, false, false, false, false, false, false }, -- statistics im_stat_init = false, avg_charge = util.mov_avg(3, 0.0), @@ -750,6 +757,63 @@ function facility.new(num_reactors, cooling_conf) if self.waste_product == WASTE.PLUTONIUM or (self.pu_fallback and insufficent_po_rate) then self.current_waste_product = WASTE.PLUTONIUM else self.current_waste_product = self.waste_product end + + ------------------------ + -- Update Alarm Tones -- + ------------------------ + + local alarms = { false, false, false, false, false, false, false, false, false, false, false, false } + + -- 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 + + -- containment breach is worst case CRITICAL alarm, this takes priority + if alarms[ALARM.ContainmentBreach] then + self.tone_states[TONES.T_1800Hz_Int_4Hz] = true + else + -- critical damage is highest priority CRITICAL level alarm + if alarms[ALARM.CriticalDamage] then + self.tone_states[TONES.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[TONES.T_544Hz_440Hz_Alt] = true + -- URGENT level turbine trip + elseif alarms[ALARM.TurbineTrip] then + self.tone_states[TONES.T_745Hz_Int_1Hz] = true + -- URGENT level reactor lost + elseif alarms[ALARM.ReactorLost] then + self.tone_states[TONES.T_340Hz_Int_2Hz] = true + -- TIMELY level alarms + elseif alarms[ALARM.ReactorHighTemp] or alarms[ALARM.ReactorHighWaste] or alarms[ALARM.RCSTransient] then + self.tone_states[TONES.T_800Hz_Int] = true + end + end + + -- check RPS transient URGENT level alarm + if alarms[ALARM.RPSTransient] then + self.tone_states[TONES.T_1000Hz_Int] = true + -- disable really painful audio combination + self.tone_states[TONES.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[TONES.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[TONES.T_1000Hz_Int] and alarms[ALARM.ReactorLost] then self.tone_states[TONES.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[TONES.T_745Hz_Int_1Hz] = false + self.tone_states[TONES.T_800Hz_Int] = false + self.tone_states[TONES.T_1000Hz_Int] = false + end end -- call the update function of all units in the facility
@@ -893,6 +957,10 @@ function facility.new(num_reactors, cooling_conf) -- 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 ---@nodiscard ---@param type RTU_UNIT_TYPE? type or nil to include only a particular unit type, or to include all if nil diff --git a/supervisor/session/coordinator.lua b/supervisor/session/coordinator.lua index 98b7431..df69c5b 100644 --- a/supervisor/session/coordinator.lua +++ b/supervisor/session/coordinator.lua @@ -150,7 +150,8 @@ function coordinator.new_session(id, s_addr, in_queue, out_queue, timeout, facil local function _send_fac_status() local status = { facility.get_control_status(), - facility.get_rtu_statuses() + facility.get_rtu_statuses(), + facility.get_alarm_tones() } _send(SCADA_CRDN_TYPE.FAC_STATUS, status) diff --git a/supervisor/startup.lua b/supervisor/startup.lua index 59f5c4f..8f6a6fb 100644 --- a/supervisor/startup.lua +++ b/supervisor/startup.lua @@ -21,7 +21,7 @@ local supervisor = require("supervisor.supervisor") local svsessions = require("supervisor.session.svsessions") -local SUPERVISOR_VERSION = "v0.20.4" +local SUPERVISOR_VERSION = "v0.21.0" local println = util.println local println_ts = util.println_ts