diff --git a/drivers/sony/camera/ptz_cgi.cr b/drivers/sony/camera/ptz_cgi.cr new file mode 100644 index 00000000000..ef706bf9ff0 --- /dev/null +++ b/drivers/sony/camera/ptz_cgi.cr @@ -0,0 +1,467 @@ +require "placeos-driver" +require "placeos-driver/interface/camera" +require "placeos-driver/interface/powerable" + +# Documentation: https://aca.im/driver_docs/Sony/sony-camera-CGI-Commands-1.pdf +# Sony PTZ Camera CGI Protocol Driver - Compatible with VISCA driver function names + +class Sony::Camera::PtzCGI < PlaceOS::Driver + include Interface::Powerable + include Interface::Camera + + # Discovery Information + generic_name :Camera + descriptive_name "Sony PTZ Camera HTTP CGI Protocol" + uri_base "http://192.168.1.100" + + default_settings({ + basic_auth: { + username: "admin", + password: "Admin_1234", + }, + max_pan_tilt_speed: 0x0F, + zoom_speed: 0x07, + zoom_max: 0x4000, + camera_no: 0x01, + invert_controls: false, + presets: { + name: {pan: 1, tilt: 1, zoom: 1}, + }, + }) + + enum Movement + Idle + Moving + Unknown + end + + def on_load + # Configure the constants to match VISCA driver + @pantilt_speed = -100..100 + self[:pan_speed] = self[:tilt_speed] = {min: -100, max: 100, stop: 0} + self[:has_discrete_zoom] = true + + schedule.every(60.seconds) { query_status } + schedule.in(5.seconds) do + query_status + info? + # Only query power if device supports it + spawn { power? } rescue nil + end + on_update + end + + # Settings + @max_pan_tilt_speed : UInt8 = 0x0F_u8 + @zoom_speed : UInt8 = 0x03_u8 + @zoom_max : UInt16 = 0x4000_u16 + @camera_address : UInt8 = 0x81_u8 + @invert_controls = false + + # State tracking - use consistent UInt16 types like VISCA driver + alias Presets = Hash(String, Tuple(UInt16, UInt16, Float64)) + @presets : Presets = {} of String => Tuple(UInt16, UInt16, Float64) + getter zoom_raw : UInt16 = 0_u16 + @zoom_pos : Float64 = 0.0 + getter tilt_pos : UInt16 = 0_u16 + getter pan_pos : UInt16 = 0_u16 + + # CGI-specific state + @moving = false + @zooming = false + @pan_range = 0..1 + @tilt_range = 0..1 + @zoom_range = 0..1 + + def on_update + @presets = setting?(Presets, :camera_presets) || @presets + @max_pan_tilt_speed = setting?(UInt8, :max_pan_tilt_speed) || 0x0F_u8 + @zoom_speed = setting?(UInt8, :zoom_speed) || 0x03_u8 + @zoom_max = setting?(UInt16, :zoom_max) || 0x4000_u16 + @camera_address = 0x80_u8 | (setting?(UInt8, :camera_no) || 1_u8) + + self[:presets] = @presets.keys + self[:inverted] = @invert_controls = setting?(Bool, :invert_controls) || false + end + + # 24bit twos complement for CGI protocol + private def twos_complement(value : Int32) : Int32 + # Handle 20-bit signed values (0xFFFFF mask) + if value < 0 + # Convert negative to 20-bit two's complement + ((~(-value) + 1) & 0xFFFFF) + elsif (value & 0x80000) != 0 + # Convert 20-bit two's complement to negative + -((~value + 1) & 0xFFFFF) + else + # Positive value, just mask to 20 bits + value & 0xFFFFF + end + end + + private def query(path, **opts, &block : Hash(String, String) -> _) + queue(**opts) do |task| + response = get(path) + data = response.body.not_nil! + + raise "unexpected response #{response.status_code}\n#{response.body}" unless response.success? + + # convert data into more consumable state + state = {} of String => String + data.split("&").each do |key_value| + parts = key_value.strip.split("=") + state[parts[0]] = parts[1] if parts.size >= 2 + end + + result = block.call(state) + task.success result + end + end + + private def action(path, **opts, &block : HTTP::Client::Response -> _) + queue(**opts) do |task| + response = get(path) + raise "request error #{response.status_code}\n#{response.body}" unless response.success? + + result = block.call(response) + task.success result + end + end + + def query_status(priority : Int32 = 0) + # Response looks like: + # AbsolutePTZF=15400,fd578,0000,cbde&PanMovementRange=eac00,15400 + query("/command/inquiry.cgi?inq=ptzf", priority: priority) do |response| + # load the current state + response.each do |key, value| + case key + when "AbsolutePTZF" + # Pan, Tilt, Zoom,Focus + # AbsolutePTZF=15400,fd578,0000,ca52 + parts = value.split(",") + pan_raw = twos_complement(parts[0].to_i(16)) + tilt_raw = twos_complement(parts[1].to_i(16)) + @zoom_raw = parts[2].to_i(16).to_u16 + + # Update consistent position tracking + @pan_pos = pan_raw.abs.clamp(0, UInt16::MAX).to_u16 + @tilt_pos = tilt_raw.abs.clamp(0, UInt16::MAX).to_u16 + + self[:pan] = pan_raw + self[:tilt] = tilt_raw + + when "PanMovementRange" + # PanMovementRange=eac00,15400 + parts = value.split(",") + pan_min = twos_complement parts[0].to_i(16) + pan_max = twos_complement parts[1].to_i(16) + @pan_range = pan_min..pan_max + self[:pan_range] = {min: pan_min, max: pan_max} + + when "TiltMovementRange" + # TiltMovementRange=fc400,b400 + parts = value.split(",") + tilt_min = twos_complement parts[0].to_i(16) + tilt_max = twos_complement parts[1].to_i(16) + @tilt_range = tilt_min..tilt_max + self[:tilt_range] = {min: tilt_min, max: tilt_max} + + when "ZoomMovementRange" + # min, max, digital + # ZoomMovementRange=0000,4000,7ac0 + parts = value.split(",") + zoom_min = parts[0].to_i(16) + zoom_max = parts[1].to_i(16) + @zoom_range = zoom_min..zoom_max + self[:zoom_range] = {min: zoom_min, max: zoom_max} + + when "PtzfStatus" + # PtzfStatus=idle,idle,idle,idle + parts = value.split(",") + if parts.size >= 3 + movements = parts[0..2].map { |state| Movement.parse(state) } + self[:moving] = @moving = movements.includes?(Movement::Moving) + end + + when "PanTiltMaxVelocity" + # PanTiltMaxVelocity=24 + # @max_speed = value.to_i(16) + end + end + + # Calculate zoom percentage based on zoom_max like VISCA driver + self[:zoom] = @zoom_pos = @zoom_raw.to_f * (100.0 / @zoom_max.to_f) + + response + end + end + + def info? + query("/command/inquiry.cgi?inq=system", priority: 0) do |response| + response.each do |key, value| + if {"ModelName", "Serial", "SoftVersion", "ModelForm", "CGIVersion"}.includes?(key) + self[key.underscore] = value + end + end + response + end + end + + # ====== Powerable Interface ====== + # VISCA-compatible function names + + def power(state : Bool) + logger.debug { "Setting power to #{state}" } + # CGI implementation for power control + power_cmd = state ? "on" : "standby" + action("/command/camera.cgi?Power=#{power_cmd}", + name: "power" + ) do + self[:power] = state + state + end + end + + def power? + query("/command/inquiry.cgi?inq=power", name: "power_query") do |response| + power_state = response["Power"]? == "on" + self[:power] = power_state + power_state + end + end + + # ====== Camera Interface ====== + # VISCA-compatible function names + + def home + logger.debug { "Moving camera to home position" } + action("/command/presetposition.cgi?HomePos=ptz-recall", + name: "position" + ) { query_status } + end + + def joystick(pan_speed : Float64, tilt_speed : Float64, index : Int32 | String = 0) + logger.debug { "Joystick movement: pan=#{pan_speed}, tilt=#{tilt_speed}" } + # Convert index to camera index (0-based to 1-based) + cam_index = index.to_i + 1 + + # Apply invert controls - match VISCA driver pattern + tilt_speed = -tilt_speed if @invert_controls + + # Convert speeds to appropriate range + pan_speed = pan_speed.to_i.clamp(-100, 100) + tilt_speed = tilt_speed.to_i.clamp(-100, 100) + + action("/command/ptzf.cgi?ContinuousPanTiltZoom=#{pan_speed.to_s(16)},#{tilt_speed.to_s(16)},0,image#{cam_index}", + name: "moving" + ) do + self[:moving] = @moving = (pan_speed != 0 || tilt_speed != 0) + + # query the current position after we've stopped moving + # query the current position after we've stopped moving - match VISCA pattern + if pan_speed == 0 && tilt_speed == 0 + spawn do + sleep 1.second + pantilt? + end + end + + @moving + end + end + + # VISCA-compatible pantilt with UInt16 parameters + def pantilt(pan : UInt16, tilt : UInt16, speed : UInt8) + # Convert to CGI format + pan_hex = pan.to_s(16) + tilt_hex = tilt.to_s(16) + + action("/command/ptzf.cgi?AbsolutePanTilt=#{pan_hex},#{tilt_hex},#{speed.to_s(16)}", + name: "position" + ) do + # Keep consistent state tracking + @pan_pos = pan + @tilt_pos = tilt + self[:pan] = pan.to_i + self[:tilt] = tilt.to_i + end + end + + # Additional pantilt method for CGI-style parameters + def pantilt(pan : Int32, tilt : Int32, zoom : Int32? = nil) : Nil + pan = pan.clamp(@pan_range.begin, @pan_range.end) + tilt = tilt.clamp(@tilt_range.begin, @tilt_range.end) + + pan_comp = twos_complement(pan) + tilt_comp = twos_complement(tilt) + + if zoom + zoom = zoom.clamp(@zoom_range.begin, @zoom_range.end) + zoom_comp = twos_complement(zoom) + + action("/command/ptzf.cgi?AbsolutePTZF=#{pan_comp.to_s(16)},#{tilt_comp.to_s(16)},#{zoom_comp.to_s(16)}", + name: "position" + ) do + # Keep consistent state tracking + @pan_pos = pan.abs.clamp(0, UInt16::MAX).to_u16 + @tilt_pos = tilt.abs.clamp(0, UInt16::MAX).to_u16 + @zoom_raw = zoom.not_nil!.to_u16 + + self[:pan] = pan + self[:tilt] = tilt + self[:zoom] = zoom.not_nil! + end + else + action("/command/ptzf.cgi?AbsolutePanTilt=#{pan_comp.to_s(16)},#{tilt_comp.to_s(16)},#{@max_pan_tilt_speed.to_s(16)}", + name: "position" + ) do + # Keep consistent state tracking + @pan_pos = pan.abs.clamp(0, UInt16::MAX).to_u16 + @tilt_pos = tilt.abs.clamp(0, UInt16::MAX).to_u16 + + self[:pan] = pan + self[:tilt] = tilt + end + end + end + + def recall(position : String, index : Int32 | String = 0) + if pos = @presets[position]? + pan_pos, tilt_pos, zoom_pos = pos + pantilt(pan_pos, tilt_pos, @max_pan_tilt_speed) + zoom_to(zoom_pos) + else + raise "unknown preset #{position}" + end + end + + def save_position(name : String, index : Int32 | String = 0) + @presets[name] = {@pan_pos, @tilt_pos, @zoom_pos} + save_presets + end + + def remove_position(name : String, index : Int32 | String = 0) + @presets.delete(name) + save_presets + end + + protected def save_presets + define_setting(:camera_presets, @presets) + self[:presets] = @presets.keys + end + + # ====== Moveable Interface ====== + # VISCA-compatible function names + + def move(position : MoveablePosition, index : Int32 | String = 0) + case position + in .up? + joystick(pan_speed: 0.0, tilt_speed: 50.0) + in .down? + joystick(pan_speed: 0.0, tilt_speed: -50.0) + in .left? + joystick(pan_speed: -50.0, tilt_speed: 0.0) + in .right? + joystick(pan_speed: 50.0, tilt_speed: 0.0) + in .in? + zoom(:in) + in .out? + zoom(:out) + in .open?, .close? + # not supported + end + end + + # ====== Zoomable Interface ====== + # VISCA-compatible function names + + def zoom_to(position : Float64, auto_focus : Bool = true, index : Int32 | String = 0) + cam_index = index.to_i + 1 + + position = position.clamp(0.0, 100.0) + percentage = position / 100.0 + zoom_value = (percentage * @zoom_max.to_f).to_u16 + + action("/command/ptzf.cgi?AbsoluteZoom=#{zoom_value.to_s(16)}", + name: "zooming" + ) do + @zoom_raw = zoom_value + self[:zoom] = @zoom_pos = position + end + end + + def zoom(direction : ZoomDirection, index : Int32 | String = 0) + cam_index = index.to_i + 1 + + if direction.stop? + action("/command/ptzf.cgi?Move=stop,zoom,image#{cam_index}", + priority: 999, + name: "zooming" + ) do + self[:zooming] = @zooming = false + spawn { sleep 500.milliseconds; zoom? } + end + else + action("/command/ptzf.cgi?Move=#{direction.out? ? "wide" : "near"},0,image#{cam_index}", + name: "zooming" + ) { self[:zooming] = @zooming = true } + end + end + + def zoom? + query("/command/inquiry.cgi?inq=zoom", name: "zoom_query", priority: 0) do |response| + if zoom_hex = response["AbsoluteZoom"]? + @zoom_raw = zoom_hex.to_i(16).to_u16 + self[:zoom] = @zoom_pos = @zoom_raw.to_f * (100.0 / @zoom_max.to_f) + end + @zoom_pos + end + end + + def pantilt? + query_status(priority: 0) + end + + # ====== Stoppable Interface ====== + # VISCA-compatible function names + + def stop(index : Int32 | String = 0, emergency : Bool = false) + cam_index = index.to_i + 1 + + action("/command/ptzf.cgi?Move=stop,motor,image#{cam_index}", + priority: 999, + name: "moving", + clear_queue: emergency + ) do + self[:moving] = @moving = false + query_status + end + + # Stop zoom if moving - match VISCA behavior + zoom(ZoomDirection::Stop, index) if @zooming + end + + # ====== PTZ Auto Framing Function ====== + # Client-requested function from page 40 of documentation + def ptzautoframing(enable : Bool = true, index : Int32 | String = 0) + logger.debug { "Setting PTZ Auto Framing to #{enable}" } + cam_index = index.to_i + 1 + command = enable ? "on" : "off" + + action("/command/ptzf.cgi?PTZAutoFraming=#{command},image#{cam_index}", + name: "autoframing" + ) do + self[:ptz_auto_framing] = enable + enable + end + end + + # Query auto framing status + def ptzautoframing? + query("/command/inquiry.cgi?inq=ptzautoframing", name: "autoframing_query") do |response| + auto_framing = response["PTZAutoFraming"]? == "on" + self[:ptz_auto_framing] = auto_framing + auto_framing + end + end +end \ No newline at end of file diff --git a/drivers/sony/camera/ptz_cgi_spec.cr b/drivers/sony/camera/ptz_cgi_spec.cr new file mode 100644 index 00000000000..0bac5e34774 --- /dev/null +++ b/drivers/sony/camera/ptz_cgi_spec.cr @@ -0,0 +1,285 @@ +require "placeos-driver/spec" + +DriverSpecs.mock_driver "Sony::Camera::PtzCGI" do + # Initialize with basic auth + settings({ + basic_auth: { + username: "admin", + password: "Admin_1234", + }, + max_pan_tilt_speed: 0x0F, + zoom_speed: 0x07, + zoom_max: 0x4000, + camera_no: 0x01, + invert_controls: false, + }) + + # Set the URI base for HTTP connections + update_settings({"uri_base" => "http://192.168.1.100"}) + + puts "Testing status query" + retval = exec(:query_status) + + # Mock the status response + expect_http_request do |_request, response| + response.status_code = 200 + response.output.puts %(AbsolutePTZF=15400,fd578,0000,cb5a&PanMovementRange=eac00,15400&PanPanoramaRange=de00,2200&PanTiltMaxVelocity=24&PtzInstance=1&TiltMovementRange=fc400,b400&TiltPanoramaRange=fc00,1200&ZoomMaxVelocity=8&ZoomMovementRange=0000,4000,7ac0&PtzfStatus=idle,idle,idle,idle&AbsoluteZoom=609) + end + + # Verify status parsing - updated for consistent twos_complement handling + retval.get.not_nil!["AbsoluteZoom"].should eq("609") + status[:pan].should eq(87040) + status[:pan_range].should eq({"min" => -87040, "max" => 87040}) + status[:tilt].should eq(-10888) + status[:tilt_range].should eq({"min" => -15360, "max" => 46080}) + status[:zoom].should be_close(6.0, 0.1) + + puts "Testing info query" + exec(:info?) + expect_http_request do |_request, response| + response.status_code = 200 + response.output.puts %(ModelName=BRC-X1000&Serial=12345678&SoftVersion=1.0.0&ModelForm=BRC-X1000&CGIVersion=1.0) + end + + status[:model_name].should eq("BRC-X1000") + status[:serial].should eq("12345678") + status[:soft_version].should eq("1.0.0") + + puts "Testing power on" + exec(:power, true) + expect_http_request do |_request, response| + response.status_code = 200 + response.output.puts "OK" + end + + status[:power].should be_true + + puts "Testing power query" + exec(:power?) + expect_http_request do |_request, response| + response.status_code = 200 + response.output.puts %(Power=on) + end + + status[:power].should be_true + + puts "Testing power off" + exec(:power, false) + expect_http_request do |_request, response| + response.status_code = 200 + response.output.puts "OK" + end + + status[:power].should be_false + + puts "Testing home position" + exec(:home) + expect_http_request do |_request, response| + response.status_code = 200 + response.output.puts "OK" + end + + # Should trigger status query after home + expect_http_request do |_request, response| + response.status_code = 200 + response.output.puts %(AbsolutePTZF=0,0,1000,cb5a&PanMovementRange=eac00,15400&TiltMovementRange=fc400,b400&ZoomMovementRange=0000,4000,7ac0&PtzfStatus=idle,idle,idle,idle) + end + + puts "Testing joystick movement" + exec(:joystick, 50.0, 25.0) + expect_http_request do |_request, response| + response.status_code = 200 + response.output.puts "OK" + end + + status[:moving].should be_true + + puts "Testing joystick stop" + exec(:joystick, 0.0, 0.0) + expect_http_request do |_request, response| + response.status_code = 200 + response.output.puts "OK" + end + + status[:moving].should be_false + + puts "Testing absolute pan/tilt (VISCA compatible)" + exec(:pantilt, 0x1000_u16, 0x2000_u16, 0x0F_u8) + expect_http_request do |_request, response| + response.status_code = 200 + response.output.puts "OK" + end + + puts "Testing absolute pan/tilt/zoom (CGI style)" + exec(:pantilt, 4096, 8192, 1024) + expect_http_request do |_request, response| + response.status_code = 200 + response.output.puts "OK" + end + + status[:pan].should eq(4096) + status[:tilt].should eq(8192) + + puts "Testing zoom to absolute position" + exec(:zoom_to, 50.0) + expect_http_request do |_request, response| + response.status_code = 200 + response.output.puts "OK" + end + + status[:zoom].should eq(50.0) + + puts "Testing zoom in" + exec(:zoom, "in") + expect_http_request do |_request, response| + response.status_code = 200 + response.output.puts "OK" + end + + status[:zooming].should be_true + + puts "Testing zoom stop" + exec(:zoom, "stop") + expect_http_request do |_request, response| + response.status_code = 200 + response.output.puts "OK" + end + + status[:zooming].should be_false + + puts "Testing zoom query" + exec(:zoom?) + expect_http_request do |_request, response| + response.status_code = 200 + response.output.puts %(AbsoluteZoom=800) + end + + puts "Testing move commands" + exec(:move, "up") + expect_http_request do |_request, response| + response.status_code = 200 + response.output.puts "OK" + end + + exec(:move, "down") + expect_http_request do |_request, response| + response.status_code = 200 + response.output.puts "OK" + end + + exec(:move, "left") + expect_http_request do |_request, response| + response.status_code = 200 + response.output.puts "OK" + end + + exec(:move, "right") + expect_http_request do |_request, response| + response.status_code = 200 + response.output.puts "OK" + end + + exec(:move, "in") + expect_http_request do |_request, response| + response.status_code = 200 + response.output.puts "OK" + end + + exec(:move, "out") + expect_http_request do |_request, response| + response.status_code = 200 + response.output.puts "OK" + end + + puts "Testing stop command" + exec(:stop) + expect_http_request do |_request, response| + response.status_code = 200 + response.output.puts "OK" + end + + status[:moving].should be_false + + puts "Testing emergency stop" + exec(:stop, 0, true) + expect_http_request do |_request, response| + response.status_code = 200 + response.output.puts "OK" + end + + puts "Testing preset save" + exec(:save_position, "preset1") + + status[:presets].should contain("preset1") + + puts "Testing preset recall" + exec(:recall, "preset1") + expect_http_request do |_request, response| + response.status_code = 200 + response.output.puts "OK" + end + + expect_http_request do |_request, response| + response.status_code = 200 + response.output.puts "OK" + end + + puts "Testing preset removal" + exec(:remove_position, "preset1") + + status[:presets].should_not contain("preset1") + + puts "Testing PTZ auto framing enable" + exec(:ptzautoframing, true) + expect_http_request do |_request, response| + response.status_code = 200 + response.output.puts "OK" + end + + status[:ptz_auto_framing].should be_true + + puts "Testing PTZ auto framing disable" + exec(:ptzautoframing, false) + expect_http_request do |_request, response| + response.status_code = 200 + response.output.puts "OK" + end + + status[:ptz_auto_framing].should be_false + + puts "Testing PTZ auto framing query" + exec(:ptzautoframing?) + expect_http_request do |_request, response| + response.status_code = 200 + response.output.puts %(PTZAutoFraming=on) + end + + status[:ptz_auto_framing].should be_true + + puts "Testing pan/tilt position query" + exec(:pantilt?) + expect_http_request do |_request, response| + response.status_code = 200 + response.output.puts %(AbsolutePTZF=2000,3000,1500,cb5a&PanMovementRange=eac00,15400&TiltMovementRange=fc400,b400&ZoomMovementRange=0000,4000,7ac0&PtzfStatus=idle,idle,idle,idle) + end + + status[:pan].should eq(8192) + status[:tilt].should eq(12288) + + puts "Testing error handling for unknown preset" + expect_raises(Exception, "unknown preset unknown_preset") do + exec(:recall, "unknown_preset") + end + + puts "Testing invert controls setting" + update_settings({invert_controls: true}) + status[:invert_controls].should be_true + status[:inverted].should be_true + + puts "Testing joystick with inverted controls" + exec(:joystick, 0.0, 50.0) # Should send -50 for tilt + expect_http_request do |_request, response| + response.status_code = 200 + response.output.puts "OK" + end +end \ No newline at end of file diff --git a/sony/displays/bravia_rest.cr b/sony/displays/bravia_rest.cr new file mode 100644 index 00000000000..dde60c9a00f --- /dev/null +++ b/sony/displays/bravia_rest.cr @@ -0,0 +1,537 @@ +require "placeos-driver" +require "placeos-driver/interface/powerable" +require "placeos-driver/interface/muteable" +require "placeos-driver/interface/switchable" +require "mutex" + +class Sony::Displays::BraviaRest < PlaceOS::Driver + include Interface::Powerable + include Interface::Muteable + include Interface::Switchable(Input) + + descriptive_name "Sony Bravia REST API" + generic_name :Display + description "Driver for Sony Bravia displays via REST API. Requires Pre-Shared Key authentication to be configured on the device." + + default_settings({ + psk: "", # PSK is required - must be configured on device + }) + + enum Input + Hdmi1 + Hdmi2 + Hdmi3 + Hdmi4 + Component1 + Component2 + Composite1 + Composite2 + Scart1 + Scart2 + PC + Cable + Satellite + Antenna + Application + end + + enum PowerStatus + Active + Standby + Off + end + + @psk : String = "" + @power_status : PowerStatus = PowerStatus::Off + @mute : Bool = false + @volume : Int32 = 0 + @current_input : Input = Input::Hdmi1 + @request_id : Int32 = 1 + @api_mutex = Mutex.new + + def on_load + on_update + end + + def on_update + psk = setting(String, :psk) + if psk.nil? || psk.empty? + logger.warn { "PSK is not configured. Please set the PSK in driver settings." } + @psk = "" + else + @psk = psk + end + end + + def connected + schedule.every(30.seconds) { query_power_status } + schedule.every(45.seconds) { query_volume_info } + schedule.every(60.seconds) { query_current_input } + + query_power_status + query_volume_info + query_current_input + end + + def disconnected + schedule.clear + end + + # Power Control + def power(state : Bool) + if state + power_on + else + power_off + end + end + + def power? : Bool + @power_status.active? + end + + def power_on + response = send_command("system", "setPowerStatus", [{"status" => true}]) + if response[:success?] + @power_status = PowerStatus::Active + self[:power] = true + self[:power_status] = "on" + end + response + end + + def power_off + response = send_command("system", "setPowerStatus", [{"status" => false}]) + if response[:success?] + @power_status = PowerStatus::Standby + self[:power] = false + self[:power_status] = "standby" + end + response + end + + def query_power_status + response = send_command("system", "getPowerStatus", [] of String) + if response[:success?] + begin + result = response[:get].as_a + if result.size > 0 + status_obj = result[0].as_h + status = status_obj["status"]?.try(&.as_s) + if status + case status.downcase + when "active" + @power_status = PowerStatus::Active + self[:power] = true + self[:power_status] = "on" + when "standby" + @power_status = PowerStatus::Standby + self[:power] = false + self[:power_status] = "standby" + else + @power_status = PowerStatus::Off + self[:power] = false + self[:power_status] = "off" + end + end + end + rescue ex + logger.warn(exception: ex) { "Failed to parse power status response" } + end + end + response + end + + # Volume Control + def mute(state : Bool = true) + if state + mute_on + else + mute_off + end + end + + def mute_on + response = send_command("audio", "setAudioMute", [{"status" => true}]) + if response[:success?] + @mute = true + self[:audio_mute] = true + end + response + end + + def mute_off + response = send_command("audio", "setAudioMute", [{"status" => false}]) + if response[:success?] + @mute = false + self[:audio_mute] = false + end + response + end + + def muted? : Bool + @mute + end + + def volume(level : Int32) + level = level.clamp(0, 100) + response = send_command("audio", "setAudioVolume", [{"target" => "speaker", "volume" => level.to_s}]) + if response[:success?] + @volume = level + self[:volume] = level + end + response + end + + def volume_up + volume(@volume + 1) + end + + def volume_down + volume(@volume - 1) + end + + def query_volume_info + response = send_command("audio", "getVolumeInformation", [] of String) + if response[:success?] + begin + result = response[:get].as_a + result.each do |item| + item_hash = item.as_h + if item_hash["target"]? == "speaker" + volume_str = item_hash["volume"]?.try(&.as_s) + mute_val = item_hash["mute"]?.try(&.as_bool) + + if volume_str && mute_val.is_a?(Bool) + @volume = volume_str.to_i + @mute = mute_val + self[:volume] = @volume + self[:audio_mute] = @mute + break + end + end + end + rescue ex + logger.warn(exception: ex) { "Failed to parse volume information response" } + end + end + response + end + + # Input Control + def switch_to(input : Input) + uri = input_to_uri(input) + response = send_command("avContent", "setPlayContent", [{"uri" => uri}]) + if response[:success?] + @current_input = input + self[:input] = input.to_s.downcase + end + response + end + + def switch_to(input : String) + input_enum = parse_input_string(input) + return false unless input_enum + result = switch_to(input_enum) + result[:success?] + end + + def input : Input + @current_input + end + + def query_current_input + response = send_command("avContent", "getPlayingContentInfo", [] of String) + if response[:success?] + begin + result = response[:get].as_a + if result.size > 0 + result_obj = result[0].as_h + uri = result_obj["uri"]?.try(&.as_s) + if uri + input = uri_to_input(uri) + if input + @current_input = input + self[:input] = input.to_s.downcase + end + end + end + rescue ex + logger.warn(exception: ex) { "Failed to parse current input response" } + end + end + response + end + + # Input shortcuts + def hdmi1; switch_to(Input::Hdmi1); end + def hdmi2; switch_to(Input::Hdmi2); end + def hdmi3; switch_to(Input::Hdmi3); end + def hdmi4; switch_to(Input::Hdmi4); end + + # Additional functionality + def get_system_information + send_command("system", "getSystemInformation", [] of String) + end + + def get_interface_information + send_command("system", "getInterfaceInformation", [] of String) + end + + def get_remote_controller_info + send_command("system", "getRemoteControllerInfo", [] of String) + end + + def send_ir_code(code : String) + send_command("system", "actIRCC", [{"ircc" => code}]) + end + + def get_content_list(source : String = "tv") + send_command("avContent", "getContentList", [{"source" => source}]) + end + + def get_scheme_list + send_command("avContent", "getSchemeList", [] of String) + end + + def get_source_list + send_command("avContent", "getSourceList", [{"scheme" => "tv"}]) + end + + def get_current_time + send_command("system", "getCurrentTime", [] of String) + end + + def set_language(language : String) + send_command("system", "setLanguage", [{"language" => language}]) + end + + def get_text_form(enc_type : String = "") + params = enc_type.empty? ? [] of String : [{"encType" => enc_type}] + send_command("appControl", "getTextForm", params) + end + + def set_text_form(text : String) + send_command("appControl", "setTextForm", [{"text" => text}]) + end + + def get_application_list + send_command("appControl", "getApplicationList", [] of String) + end + + def set_active_app(uri : String) + send_command("appControl", "setActiveApp", [{"uri" => uri}]) + end + + def terminate_apps + send_command("appControl", "terminateApps", [] of String) + end + + def get_application_status_list + send_command("appControl", "getApplicationStatusList", [] of String) + end + + def get_web_app_status + send_command("appControl", "getWebAppStatus", [] of String) + end + + # Picture settings + def get_scene_select + send_command("videoScreen", "getSceneSelect", [] of String) + end + + def set_scene_select(scene : String) + send_command("videoScreen", "setSceneSelect", [{"scene" => scene}]) + end + + def get_banner_mode + send_command("videoScreen", "getBannerMode", [] of String) + end + + def set_banner_mode(mode : String) + send_command("videoScreen", "setBannerMode", [{"mode" => mode}]) + end + + def get_pip_sub_screen_position + send_command("videoScreen", "getPipSubScreenPosition", [] of String) + end + + def set_pip_sub_screen_position(position : String) + send_command("videoScreen", "setPipSubScreenPosition", [{"position" => position}]) + end + + # Private helper methods + private def send_command(service : String, method : String, params) : NamedTuple(success?: Bool, get: JSON::Any) + # Check if PSK is configured + if @psk.empty? + logger.error { "PSK not configured - cannot send command #{method} to #{service}" } + return {success?: false, get: JSON.parse("{}")} + end + + @api_mutex.synchronize do + request_body = { + "method" => method, + "params" => params, + "id" => next_request_id, + "version" => "1.0", + } + + headers = HTTP::Headers{ + "Content-Type" => "application/json", + "X-Auth-PSK" => @psk, + } + + begin + response = post("/sony/#{service}", + body: request_body.to_json, + headers: headers + ) + + case response.status_code + when 200 + body = response.body + if body.empty? + logger.warn { "Empty response body for #{method}" } + return {success?: false, get: JSON.parse("{}")} + end + + begin + json_response = JSON.parse(body) + + if json_response.as_h.has_key?("error") + error = json_response["error"].as_a + logger.warn { "Sony Bravia API error: #{error[1]} (#{error[0]})" } + {success?: false, get: json_response} + else + {success?: true, get: json_response["result"]} + end + rescue ex + logger.error(exception: ex) { "Failed to parse JSON response for #{method}" } + {success?: false, get: JSON.parse("{}")} + end + else + logger.warn { "Sony Bravia HTTP error: #{response.status_code} - #{response.body}" } + {success?: false, get: JSON.parse("{}")} + end + rescue ex + logger.error(exception: ex) { "Failed to send command #{method} to #{service}" } + {success?: false, get: JSON.parse("{}")} + end + end + end + + private def next_request_id : Int32 + @request_id += 1 + end + + private def parse_input_string(input : String) : Input? + case input.downcase + when "hdmi1", "hdmi_1", "hdmi 1" + Input::Hdmi1 + when "hdmi2", "hdmi_2", "hdmi 2" + Input::Hdmi2 + when "hdmi3", "hdmi_3", "hdmi 3" + Input::Hdmi3 + when "hdmi4", "hdmi_4", "hdmi 4" + Input::Hdmi4 + when "component1", "component_1", "component 1" + Input::Component1 + when "component2", "component_2", "component 2" + Input::Component2 + when "composite1", "composite_1", "composite 1" + Input::Composite1 + when "composite2", "composite_2", "composite 2" + Input::Composite2 + when "scart1", "scart_1", "scart 1" + Input::Scart1 + when "scart2", "scart_2", "scart 2" + Input::Scart2 + when "pc" + Input::PC + when "cable" + Input::Cable + when "satellite" + Input::Satellite + when "antenna" + Input::Antenna + when "application", "app" + Input::Application + else + nil + end + end + + private def input_to_uri(input : Input) : String + case input + when .hdmi1? + "extInput:hdmi?port=1" + when .hdmi2? + "extInput:hdmi?port=2" + when .hdmi3? + "extInput:hdmi?port=3" + when .hdmi4? + "extInput:hdmi?port=4" + when .component1? + "extInput:component?port=1" + when .component2? + "extInput:component?port=2" + when .composite1? + "extInput:composite?port=1" + when .composite2? + "extInput:composite?port=2" + when .scart1? + "extInput:scart?port=1" + when .scart2? + "extInput:scart?port=2" + when .pc? + "extInput:pc?port=1" + when .cable? + "extInput:cec?type=tuner&port=1" + when .satellite? + "extInput:cec?type=tuner&port=2" + when .antenna? + "tv:dvbt" + when .application? + "app:" + else + "extInput:hdmi?port=1" + end + end + + private def uri_to_input(uri : String) : Input? + case uri + when /extInput:hdmi\?port=1/ + Input::Hdmi1 + when /extInput:hdmi\?port=2/ + Input::Hdmi2 + when /extInput:hdmi\?port=3/ + Input::Hdmi3 + when /extInput:hdmi\?port=4/ + Input::Hdmi4 + when /extInput:component\?port=1/ + Input::Component1 + when /extInput:component\?port=2/ + Input::Component2 + when /extInput:composite\?port=1/ + Input::Composite1 + when /extInput:composite\?port=2/ + Input::Composite2 + when /extInput:scart\?port=1/ + Input::Scart1 + when /extInput:scart\?port=2/ + Input::Scart2 + when /extInput:pc/ + Input::PC + when /extInput:cec\?type=tuner&port=1/ + Input::Cable + when /extInput:cec\?type=tuner&port=2/ + Input::Satellite + when /tv:dvbt/ + Input::Antenna + when /app:/ + Input::Application + else + nil + end + end +end \ No newline at end of file diff --git a/sony/displays/bravia_rest_spec.cr b/sony/displays/bravia_rest_spec.cr new file mode 100644 index 00000000000..be59143e226 --- /dev/null +++ b/sony/displays/bravia_rest_spec.cr @@ -0,0 +1,419 @@ +require "placeos-driver/spec" + +DriverSpecs.mock_driver "Sony::Displays::BraviaRest" do + settings({ + psk: "test1234", + }) + + # Power Control Tests + it "should power on the display" do + exec(:power_on) + + expect_http_request do |request, response| + headers = request.headers + headers["X-Auth-PSK"].should eq("test1234") + headers["Content-Type"].should eq("application/json") + + request.path.should eq("/sony/system") + + body = JSON.parse(request.body.not_nil!) + body["method"].should eq("setPowerStatus") + body["params"].as_a.first["status"].should eq(true) + + response.status_code = 200 + response << %<{"result":[{"status":"active"}],"id":1}> + end + + status[:power].should eq(true) + status[:power_status].should eq("on") + end + + it "should power off the display" do + exec(:power_off) + + expect_http_request do |request, response| + headers = request.headers + headers["X-Auth-PSK"].should eq("test1234") + + body = JSON.parse(request.body.not_nil!) + body["method"].should eq("setPowerStatus") + body["params"].as_a.first["status"].should eq(false) + + response.status_code = 200 + response << %<{"result":[{"status":"standby"}],"id":2}> + end + + status[:power].should eq(false) + status[:power_status].should eq("standby") + end + + it "should query power status" do + exec(:query_power_status) + + expect_http_request do |request, response| + body = JSON.parse(request.body.not_nil!) + body["method"].should eq("getPowerStatus") + + response.status_code = 200 + response << %<{"result":[{"status":"active"}],"id":3}> + end + + status[:power].should eq(true) + status[:power_status].should eq("on") + end + + # Volume Control Tests + it "should set volume level" do + exec(:volume, 50) + + expect_http_request do |request, response| + body = JSON.parse(request.body.not_nil!) + body["method"].should eq("setAudioVolume") + params = body["params"].as_a.first + params["target"].should eq("speaker") + params["volume"].should eq("50") + + response.status_code = 200 + response << %<{"result":[],"id":4}> + end + + status[:volume].should eq(50) + end + + it "should mute audio" do + exec(:mute_on) + + expect_http_request do |request, response| + body = JSON.parse(request.body.not_nil!) + body["method"].should eq("setAudioMute") + body["params"].as_a.first["status"].should eq(true) + + response.status_code = 200 + response << %<{"result":[],"id":5}> + end + + status[:audio_mute].should eq(true) + end + + it "should unmute audio" do + exec(:mute_off) + + expect_http_request do |request, response| + body = JSON.parse(request.body.not_nil!) + body["method"].should eq("setAudioMute") + body["params"].as_a.first["status"].should eq(false) + + response.status_code = 200 + response << %<{"result":[],"id":6}> + end + + status[:audio_mute].should eq(false) + end + + it "should query volume information" do + exec(:query_volume_info) + + expect_http_request do |request, response| + body = JSON.parse(request.body.not_nil!) + body["method"].should eq("getVolumeInformation") + + response.status_code = 200 + response << %<{"result":[{"target":"speaker","volume":"25","mute":false}],"id":7}> + end + + status[:volume].should eq(25) + status[:audio_mute].should eq(false) + end + + # Input Control Tests + it "should switch to HDMI1 input using string" do + exec(:switch_to, "hdmi1") + + expect_http_request do |request, response| + body = JSON.parse(request.body.not_nil!) + body["method"].should eq("setPlayContent") + body["params"].as_a.first["uri"].should eq("extInput:hdmi?port=1") + + response.status_code = 200 + response << %<{"result":[],"id":8}> + end + + status[:input].should eq("hdmi1") + end + + it "should switch to HDMI1 input using enum" do + exec(:switch_to, Sony::Displays::BraviaRest::Input::Hdmi1) + + expect_http_request do |request, response| + body = JSON.parse(request.body.not_nil!) + body["method"].should eq("setPlayContent") + body["params"].as_a.first["uri"].should eq("extInput:hdmi?port=1") + + response.status_code = 200 + response << %<{"result":[],"id":8}> + end + + status[:input].should eq("hdmi1") + end + + it "should switch to HDMI2 input" do + exec(:hdmi2) + + expect_http_request do |request, response| + body = JSON.parse(request.body.not_nil!) + body["method"].should eq("setPlayContent") + body["params"].as_a.first["uri"].should eq("extInput:hdmi?port=2") + + response.status_code = 200 + response << %<{"result":[],"id":9}> + end + + status[:input].should eq("hdmi2") + end + + it "should query current input" do + exec(:query_current_input) + + expect_http_request do |request, response| + body = JSON.parse(request.body.not_nil!) + body["method"].should eq("getPlayingContentInfo") + + response.status_code = 200 + response << %<{"result":[{"uri":"extInput:hdmi?port=3"}],"id":10}> + end + + status[:input].should eq("hdmi3") + end + + # Additional Functionality Tests + it "should get system information" do + exec(:get_system_information) + + expect_http_request do |request, response| + body = JSON.parse(request.body.not_nil!) + body["method"].should eq("getSystemInformation") + + response.status_code = 200 + response << %<{"result":[{"product":"TV","region":"US","model":"KD-55X900H"}],"id":11}> + end + end + + it "should send IR code" do + exec(:send_ir_code, "AAAAAQAAAAEAAAAvAw==") + + expect_http_request do |request, response| + body = JSON.parse(request.body.not_nil!) + body["method"].should eq("actIRCC") + body["params"].as_a.first["ircc"].should eq("AAAAAQAAAAEAAAAvAw==") + + response.status_code = 200 + response << %<{"result":[],"id":12}> + end + end + + it "should get application list" do + exec(:get_application_list) + + expect_http_request do |request, response| + body = JSON.parse(request.body.not_nil!) + body["method"].should eq("getApplicationList") + + response.status_code = 200 + response << %<{"result":[[{"title":"Netflix","uri":"netflix://"}]],"id":13}> + end + end + + it "should set active app" do + exec(:set_active_app, "netflix://") + + expect_http_request do |request, response| + body = JSON.parse(request.body.not_nil!) + body["method"].should eq("setActiveApp") + body["params"].as_a.first["uri"].should eq("netflix://") + + response.status_code = 200 + response << %<{"result":[],"id":14}> + end + end + + it "should get content list" do + exec(:get_content_list, "tv") + + expect_http_request do |request, response| + body = JSON.parse(request.body.not_nil!) + body["method"].should eq("getContentList") + body["params"].as_a.first["source"].should eq("tv") + + response.status_code = 200 + response << %<{"result":[[{"title":"Channel 1","uri":"tv:dvbc"}]],"id":15}> + end + end + + it "should get scene select" do + exec(:get_scene_select) + + expect_http_request do |request, response| + body = JSON.parse(request.body.not_nil!) + body["method"].should eq("getSceneSelect") + + response.status_code = 200 + response << %<{"result":[{"scene":"auto"}],"id":16}> + end + end + + it "should set scene select" do + exec(:set_scene_select, "cinema") + + expect_http_request do |request, response| + body = JSON.parse(request.body.not_nil!) + body["method"].should eq("setSceneSelect") + body["params"].as_a.first["scene"].should eq("cinema") + + response.status_code = 200 + response << %<{"result":[],"id":17}> + end + end + + # Error handling tests + it "should handle API errors" do + exec(:power_on) + + expect_http_request do |request, response| + response.status_code = 200 + response << %<{"error":[12,"Display is Off"],"id":18}> + end + + # Should not update power state on error + status[:power]?.should be_nil + end + + it "should handle PSK not configured" do + # Create new driver instance without PSK + driver = Sony::Displays::BraviaRest.new(module_id: "mod-test", settings: {} of String => String) + driver.logger = logger + + # Should not make HTTP request when PSK is empty + response = driver.power_on + response[:success?].should eq(false) + end + + it "should handle HTTP errors" do + exec(:power_on) + + expect_http_request do |request, response| + response.status_code = 401 + response << "Unauthorized" + end + + # Should not update power state on HTTP error + status[:power]?.should be_nil + end + + it "should handle empty response body" do + exec(:power_on) + + expect_http_request do |request, response| + response.status_code = 200 + response << "" + end + + # Should not update power state on empty response + status[:power]?.should be_nil + end + + it "should handle malformed JSON response" do + exec(:query_volume_info) + + expect_http_request do |request, response| + response.status_code = 200 + response << %<{"result":[{"target":"speaker","volume":null,"mute":"invalid"}],"id":23}> + end + + # Should not crash on malformed data - volume should remain unchanged + status[:volume]?.should be_nil + end + + # Interface compliance tests + it "should implement Powerable interface" do + exec(:power, true) + + expect_http_request do |request, response| + body = JSON.parse(request.body.not_nil!) + body["method"].should eq("setPowerStatus") + body["params"].as_a.first["status"].should eq(true) + + response.status_code = 200 + response << %<{"result":[{"status":"active"}],"id":19}> + end + + status[:power].should eq(true) + end + + it "should implement Muteable interface" do + exec(:mute, true) + + expect_http_request do |request, response| + body = JSON.parse(request.body.not_nil!) + body["method"].should eq("setAudioMute") + body["params"].as_a.first["status"].should eq(true) + + response.status_code = 200 + response << %<{"result":[],"id":20}> + end + + status[:audio_mute].should eq(true) + end + + it "should clamp volume values" do + exec(:volume, 150) + + expect_http_request do |request, response| + body = JSON.parse(request.body.not_nil!) + params = body["params"].as_a.first + params["volume"].should eq("100") # Should be clamped to 100 + + response.status_code = 200 + response << %<{"result":[],"id":21}> + end + + status[:volume].should eq(100) + end + + it "should handle negative volume values" do + exec(:volume, -10) + + expect_http_request do |request, response| + body = JSON.parse(request.body.not_nil!) + params = body["params"].as_a.first + params["volume"].should eq("0") # Should be clamped to 0 + + response.status_code = 200 + response << %<{"result":[],"id":22}> + end + + status[:volume].should eq(0) + end + + it "should parse various input string formats" do + # Test different input string formats + result1 = exec(:switch_to, "hdmi 1") + expect_http_request do |request, response| + body = JSON.parse(request.body.not_nil!) + body["params"].as_a.first["uri"].should eq("extInput:hdmi?port=1") + response.status_code = 200 + response << %<{"result":[],"id":24}> + end + + result2 = exec(:switch_to, "hdmi_2") + expect_http_request do |request, response| + body = JSON.parse(request.body.not_nil!) + body["params"].as_a.first["uri"].should eq("extInput:hdmi?port=2") + response.status_code = 200 + response << %<{"result":[],"id":25}> + end + + # Test invalid input + result3 = exec(:switch_to, "invalid_input") + result3.should eq(false) # Should return false for invalid inputs + end +end \ No newline at end of file