diff --git a/drivers/amx/svsi/virtual_switcher.cr b/drivers/amx/svsi/virtual_switcher.cr index 4ed0a1169c3..dce19834278 100644 --- a/drivers/amx/svsi/virtual_switcher.cr +++ b/drivers/amx/svsi/virtual_switcher.cr @@ -27,7 +27,7 @@ class Amx::Svsi::VirtualSwitcher < PlaceOS::Driver end end - private def connect(inouts : Hash(Input, Array(Output)), &) + private def connect(inouts : FullSwitch, &) inouts.each do |input, outputs| if input == 0 stream = 0 # disconnected diff --git a/drivers/kramer/switches/protocol3000.cr b/drivers/kramer/switches/protocol3000.cr new file mode 100644 index 00000000000..8348f9d2cec --- /dev/null +++ b/drivers/kramer/switches/protocol3000.cr @@ -0,0 +1,241 @@ +require "placeos-driver" +require "placeos-driver/interface/muteable" +require "placeos-driver/interface/switchable" + +# Documentation: https://aca.im/driver_docs/Kramer/protocol_3000_2.10_user.pdf + +class Kramer::Switcher::Protocol3000 < PlaceOS::Driver + include Interface::Muteable + include Interface::Switchable(Int32, Int32) + include Interface::InputSelection(Int32) + + # Discovery Information + tcp_port 23 + descriptive_name "Kramer Protocol 3000 Switcher" + generic_name :Switcher + + @device_id : String? = nil + @destination : String? = nil + @login_level : Bool? = nil + @password : String? = nil + + DELIMITER = "\x0D\x0A" + + def on_load + transport.tokenizer = Tokenizer.new(DELIMITER) + on_update + end + + def on_update + @device_id = setting?(String, :kramer_id) + @destination = "#{@device_id}@" if @device_id + @login_level = setting?(Bool, :kramer_login) + @password = setting?(String, :kramer_password) if @login_level + + state + end + + def connected + state + + schedule.every(1.minute) do + logger.debug { "-- Kramer Maintaining Connection" } + do_send("MODEL?", priority: 0) # Low priority poll to maintain connection + end + end + + def disconnected + schedule.clear + end + + # Get current state of the switcher + private def state + protocol_handshake + login + get_machine_info + end + + def switch_video(input : Int32, output : Array(Int32)) + do_send(CMDS["switch_video"], build_switch_data({input => output})) + end + + def switch_audio(input : Int32, output : Array(Int32)) + do_send(CMDS["switch_audio"], build_switch_data({input => output})) + end + + def switch(map : Hash(Int32, Array(Int32)), layer : SwitchLayer? = nil) + case layer + in Nil, .all? + switch_video(map.first_key, map.first_value) + switch_audio(map.first_key, map.first_value) + in .video? + switch_video(map.first_key, map.first_value) + in .audio? + switch_audio(map.first_key, map.first_value) + in .data?, .data2? + logger.debug { "layer #{layer} not available on extron matrix" } + return + end + end + + def switch_to(input : Int32) + logger.debug { "switching input to #{input}" } + self[:input] = input.to_s + end + + enum RouteType + Video = 1 + Audio = 2 + USB = 3 + AudioVideo = 12 + VideoUSB = 13 + AudioVideoUSB = 123 + end + + def route(map : Hash(Int32, Array(Int32)), type : RouteType = RouteType::AudioVideo) + map.each do |input, outputs| + outputs.each do |output| + do_send(CMDS["route"], type.value, output, input) + end + end + end + + def mute( + state : Bool = true, + index : Int32 | String = 0, + layer : MuteLayer = MuteLayer::AudioVideo + ) + mute_video(index, state) if layer.video? || layer.audio_video? + mute_audio(index, state) if layer.audio? || layer.audio_video? + end + + def mute_video(index : Int32 | String = 0, state : Bool = true) + do_send(CMDS["video_mute"], index, state ? 1 : 0) + end + + def mute_audio(index : Int32 | String = 0, state : Bool = true) + do_send(CMDS["audio_mute"], index, state ? 1 : 0) + end + + def help + do_send(CMDS["help"]) + end + + def model + do_send(CMDS["model"]) + end + + def received(data, task) + # Remove initialiser `~` and delimiter "\x0D\x0A" + data = String.new(data[1..-3]) + logger.debug { "Kramer sent #{data}" } + + # Extract and check the machine number if we've defined it + components = data.split('@') + return if components.size > 1 && @device_id && components[0] != @device_id + + data = components[-1].strip + components = data.split(/\s+|,/) + + cmd = components[0] + args = components[1..-1] + args.pop if args[-1]? == "OK" + + logger.debug { "Kramer cmd: #{cmd}, args: #{args}" } + + if cmd == "OK" + return task.try &.success + elsif cmd[0..2] == "ERR" || args[0][0..2] == "ERR" + if cmd[0..2] == "ERR" + error = cmd[3..-1] + errfor = nil + else + error = args[0][3..-1] + errfor = " on #{cmd}" + end + self[:last_error] = error + return task.try &.abort("Kramer command error #{error}#{errfor}") + end + + case c = CMDS[cmd] + when "info" + self[:video_inputs] = args[1].to_i + self[:video_outputs] = args[3].to_i + when "route" + # response looks like ~01@ROUTE 12,1,4 OK + layer = args[0].to_i + dest = args[1].to_i + src = args[2].to_i + self["#{RouteType.from_value(layer).to_s.underscore}#{dest}"] = src + when "switch", "switch_audio", "switch_video" + # return string like "in>out,in>out,in>out OK" + case c + when "switch_audio" then type = "audio" + when "switch_video" then type = "video" + else type = "av" + end + + args.each do |map| + inout = map.split('>') + logger.debug { "inout is #{inout}" } + self["#{type}#{inout[1]}"] = inout[0].to_i + end + when "audio_mute" + # Response looks like: ~01@VMUTE 1,0 OK + output = args[0] + mute = args[1] + self["audio#{output}_muted"] = mute[0] == '1' + when "video_mute" + output = args[0] + mute = args[1] + self["video#{output}_muted"] = mute[0] == '1' + end + + task.try &.success + end + + CMDS = { + "info" => "INFO-IO?", + "login" => "LOGIN", + "route" => "ROUTE", + "switch" => "AV", + "switch_audio" => "AUD", + "switch_video" => "VID", + "audio_mute" => "MUTE", + "video_mute" => "VMUTE", + "help" => "HELP", + "model" => "MODEL?", + } + CMDS.merge!(CMDS.invert) + + private def build_switch_data(map : Hash(Int32, Array(Int32))) + data = String.build do |str| + map.each do |input, outputs| + str << outputs.join { |output| "#{input}>#{output}," } + end + end + data[0..-2] # Remove the comma at the end + end + + private def protocol_handshake + do_send("", priority: 99) + end + + private def login + if @login_level && (pass = @password) + do_send(CMDS["login"], pass, priority: 99) + end + end + + private def get_machine_info + do_send(CMDS["info"], priority: 99) + end + + private def do_send(command, *args, **options) + cmd = args.empty? ? "##{@destination}#{command}\r" : "##{@destination}#{command} #{args.join(',')}\r" + + logger.debug { "Kramer sending: #{cmd}" } + pp "Kramer sending: #{cmd}" + send(cmd, **options) + end +end diff --git a/drivers/kramer/switches/protocol3000_spec.cr b/drivers/kramer/switches/protocol3000_spec.cr new file mode 100644 index 00000000000..dffb34b87ae --- /dev/null +++ b/drivers/kramer/switches/protocol3000_spec.cr @@ -0,0 +1,60 @@ +DriverSpecs.mock_driver "Kramer::Switcher::Protocol3000" do + # on_load + # state + # protocol_handshake + should_send("#\r") + responds("~01@ OK\x0D\x0A") + # get_machine_info + should_send("#INFO-IO?\r") + responds("~01@ OK\x0D\x0A") + + settings({ + kramer_id: "01", + kramer_login: true, + kramer_password: "pass", + }) + # on_update + # state + # protocol_handshake + should_send("#01@\r") + responds("~01@ OK\x0D\x0A") + # login + should_send("#01@LOGIN pass\r") + responds("~01@ OK\x0D\x0A") + # get_machine_info + should_send("#01@INFO-IO?\r") + responds("~01@ OK\x0D\x0A") + + exec(:switch_video, 1, [1, 2, 3]) + should_send("#01@VID 1>1,1>2,1>3\r") + responds("~01@VID 1>1,1>2,1>3 OK\x0D\x0A") + status[:video1].should eq(1) + status[:video2].should eq(1) + status[:video3].should eq(1) + + exec(:switch_audio, 2, [1, 2, 3]) + should_send("#01@AUD 2>1,2>2,2>3\r") + responds("~01@AUD 2>1,2>2,2>3 OK\x0D\x0A") + status[:audio1].should eq(2) + status[:audio2].should eq(2) + status[:audio3].should eq(2) + + exec(:route, {3 => [1, 2, 3]}) + should_send("#01@ROUTE 12,1,3\r") + responds("~01@ROUTE 12,1,3 OK\x0D\x0A") + should_send("#01@ROUTE 12,2,3\r") + responds("~01@ROUTE 12,2,3 OK\x0D\x0A") + should_send("#01@ROUTE 12,3,3\r") + responds("~01@ROUTE 12,3,3 OK\x0D\x0A") + status[:audio_video1].should eq(3) + status[:audio_video2].should eq(3) + status[:audio_video3].should eq(3) + + exec(:mute, true, 6) + should_send("#01@VMUTE 6,1\r") + responds("#01@VMUTE 6,1 OK\x0D\x0A") + status[:video6_muted].should eq(true) + should_send("#01@MUTE 6,1\r") + responds("#01@MUTE 6,1 OK\x0D\x0A") + status[:audio6_muted].should eq(true) +end diff --git a/drivers/kramer/switches/vs_hdmi.cr b/drivers/kramer/switches/vs_hdmi.cr new file mode 100644 index 00000000000..b6fd835adf9 --- /dev/null +++ b/drivers/kramer/switches/vs_hdmi.cr @@ -0,0 +1,105 @@ +require "placeos-driver" +require "placeos-driver/interface/switchable" + +# Documentation: https://aca.im/driver_docs/Kramer/Kramer%20protocol%202000%20v0.51.pdf + +class Kramer::Switcher::VsHdmi < PlaceOS::Driver + include Interface::Switchable(Int32, Int32) + include Interface::InputSelection(Int32) + + # Discovery Information + tcp_port 23 + descriptive_name "Kramer Protocol 2000 Switcher" + generic_name :Switcher + + def on_load + queue.delay = 150.milliseconds + queue.wait = false + end + + def connected + get_machine_type + end + + private def get_machine_type + command = Bytes[62, 0x81, 0x81, 0xFF] + send(command, name: :inputs) # no. video inputs + command = Bytes[62, 0x82, 0x81, 0xFF] + send(command, name: :outputs) # no. of video outputs + end + + enum Command + ResetVideo = 0 + SwitchVideo = 1 + StatusVideo = 5 + DefineMachine = 62 + IdentifyMachine = 61 + end + + def switch_video(map : Hash(Int32, Array(Int32))) + command = Bytes[1, 0x80, 0x80, 0xFF] + + map.each do |input, outputs| + outputs.each do |output| + command[1] += input + command[2] += output + outname = "video#{output}" + send(command, name: outname) + self[outname] = input + end + end + end + + def switch(map : Hash(Int32, Array(Int32)), layer : SwitchLayer? = nil) + case layer + in Nil, .all? + switch_video(map) + in .video? + switch_video(map) + in .audio?, .data?, .data2? + logger.debug { "layer #{layer} not available on extron matrix" } + return + end + end + + def switch_to(input : Int32) + logger.debug { "switching input to #{input}" } + self[:input] = input.to_s + end + + def received(data, task) + logger.debug { "Kramer sent 0x#{data.hexstring}" } + + # Only process response if we are the destination + return unless data[0].bit(6) == 1 + input = data[1] & 0b111_111 + output = data[2] & 0b111_111 + + case Command.from_value(data[0] & 0b111_111) + when .define_machine? + if input == 1 + self[:video_inputs] = output + elsif input == 2 + self[:video_outputs] = output + end + when .status_video? + if output == 0 # Then input has been applied to all the outputs + logger.debug { "Kramer switched #{input} -> All" } + + (1..self[:video_outputs].as_i).each { |i| self["video#{i}"] = input } + else + self["video#{output}"] = input + + logger.debug { "Kramer switched #{input} -> #{output}" } + + # As we may not know the max number of inputs if get_machine_type didn't work + self[:video_inputs] = input if input > self[:video_inputs].as_i + self[:video_outputs] = output if output > self[:video_outputs].as_i + end + when .identify_machine? + logger.debug { "Kramer switcher protocol #{input}.#{output}" } + end + + task.try &.success + end +end diff --git a/drivers/kramer/switches/vs_hdmi_spec.cr b/drivers/kramer/switches/vs_hdmi_spec.cr new file mode 100644 index 00000000000..01eaecaa89d --- /dev/null +++ b/drivers/kramer/switches/vs_hdmi_spec.cr @@ -0,0 +1,31 @@ +DriverSpecs.mock_driver "Kramer::Switcher::VsHdmi" do + # connected + # get_machine_type + # no. of video inputs + should_send(Bytes[62, 0x81, 0x81, 0xFF]) + responds(Bytes[0x7E, 0x81, 0b1000_0011, 0x82]) + status[:video_inputs].should eq(3) + # no. of video outputs + should_send(Bytes[62, 0x82, 0x81, 0xFF]) + responds(Bytes[0x7E, 0x82, 0x90, 0x82]) + status[:video_outputs].should eq(16) + + exec(:switch_video, { + 5 => [8], + }) + should_send(Bytes[1, 0x85, 0x88, 0xFF]) + status[:video8].should eq(5) + + exec(:switch_video, { + 1 => [2, 3], + 4 => [5, 6], + }) + should_send(Bytes[1, 138, 144, 0xFF]) + status[:video2].should eq(1) + status[:video3].should eq(1) + status[:video5].should eq(4) + status[:video6].should eq(4) + + # Command::IdentifyMachine version response + transmit(Bytes[0x7D, 0x83, 0x85, 0x81]) +end