From c82c3eee839018ad6c3f16d8dde4961774d11dda Mon Sep 17 00:00:00 2001 From: root Date: Fri, 8 Aug 2025 10:30:07 +0100 Subject: [PATCH] feat: add Catchbox Hub DSP receiver driver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds comprehensive driver for Catchbox Hub DSP wireless microphone system with: - JSON-over-TCP communication (configurable port, default 3000) - Complete microphone status monitoring (mute, battery, signal, connection) - Device information retrieval (name, firmware, hardware, serial) - Network configuration management (MAC, IP mode, IP, subnet, gateway) - Individual and bulk microphone mute/unmute controls - Automatic polling with configurable intervals - Robust error handling and input validation - Comprehensive test coverage 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- drivers/catchbox/hub_dsp.cr | 258 +++++++++++++++++++++ drivers/catchbox/hub_dsp_models.cr | 153 +++++++++++++ drivers/catchbox/hub_dsp_spec.cr | 349 +++++++++++++++++++++++++++++ 3 files changed, 760 insertions(+) create mode 100644 drivers/catchbox/hub_dsp.cr create mode 100644 drivers/catchbox/hub_dsp_models.cr create mode 100644 drivers/catchbox/hub_dsp_spec.cr diff --git a/drivers/catchbox/hub_dsp.cr b/drivers/catchbox/hub_dsp.cr new file mode 100644 index 0000000000..c49e7d6a70 --- /dev/null +++ b/drivers/catchbox/hub_dsp.cr @@ -0,0 +1,258 @@ +require "placeos-driver" +require "./hub_dsp_models" + +# Documentation: https://docs.catchbox.com/ +# API Command List: https://docs.google.com/spreadsheets/d/10aOYyVSSGEU3oRSo80UGlRG2WUvq-uPR/edit + +class Catchbox::HubDSP < PlaceOS::Driver + descriptive_name "Catchbox Hub DSP Receiver" + generic_name :AudioProcessor + description "Controls Catchbox Hub DSP receiver for wireless microphone management. Configure IP address and TCP port in device settings." + + tcp_port 3000 + + default_settings({ + poll_interval: 30, + }) + + def on_load + transport.tokenizer = Tokenizer.new("\n") + on_update + end + + def on_update + @poll_interval = setting(Int32, :poll_interval) || 30 + # Clamp to a sensible minimum to avoid tight loops + @poll_interval = 1 if @poll_interval < 1 + end + + def connected + logger.info "Connected to Catchbox Hub DSP" + schedule.clear + + schedule.every(@poll_interval.seconds, immediate: true) do + query_device_info + query_network_info + query_mic_status + end + end + + def disconnected + logger.warn "Disconnected from Catchbox Hub DSP" + schedule.clear + end + + def query_device_info + request = ApiRequest.new( + rx: RxCommand.new( + device: DeviceCommand.new(name: nil) + ) + ) + send_request(request, name: "device_info") + end + + def query_network_info + request = ApiRequest.new( + rx: RxCommand.new( + network: NetworkCommand.new( + mac: nil, + ip_mode: nil, + ip: nil, + subnet: nil, + gateway: nil + ) + ) + ) + send_request(request, name: "network_info") + end + + def query_mic_status + request = ApiRequest.new( + rx: RxCommand.new( + audio: AudioCommand.new( + input: AudioInputCommand.new( + mic1: MicCommand.new(mute: nil), + mic2: MicCommand.new(mute: nil), + mic3: MicCommand.new(mute: nil) + ) + ) + ) + ) + send_request(request, name: "mic_status") + end + + def set_device_name(name : String) + request = ApiRequest.new( + rx: RxCommand.new( + device: DeviceCommand.new(name: name) + ) + ) + send_request(request, name: "set_device_name") + end + + def set_network_config(ip_mode : String, ip : String? = nil, subnet : String? = nil, gateway : String? = nil) + # Validate IP mode to reduce typos and protocol errors + valid_modes = {"Static", "DHCP"} + unless valid_modes.includes?(ip_mode) + raise ArgumentError.new("ip_mode must be one of: #{valid_modes.join(", ")}") + end + + request = ApiRequest.new( + rx: RxCommand.new( + network: NetworkCommand.new( + ip_mode: ip_mode, + ip: ip, + subnet: subnet, + gateway: gateway + ) + ) + ) + send_request(request, name: "set_network") + end + + def network_reboot + request = ApiRequest.new( + rx: RxCommand.new( + network: NetworkCommand.new(reboot: true) + ) + ) + send_request(request, name: "network_reboot") + end + + def mute_mic(mic_number : Int32, muted : Bool = true) + raise ArgumentError.new("Mic number must be 1, 2, or 3") unless (1..3).includes?(mic_number) + + mic_cmd = MicCommand.new(mute: muted) + input_cmd = case mic_number + when 1 + AudioInputCommand.new(mic1: mic_cmd) + when 2 + AudioInputCommand.new(mic2: mic_cmd) + when 3 + AudioInputCommand.new(mic3: mic_cmd) + else + raise ArgumentError.new("Invalid mic number") + end + + request = ApiRequest.new( + rx: RxCommand.new( + audio: AudioCommand.new(input: input_cmd) + ) + ) + + send_request(request, name: "mute_mic_#{mic_number}") + end + + def unmute_mic(mic_number : Int32) + mute_mic(mic_number, false) + end + + def mute_all_mics(muted : Bool = true) + (1..3).each { |mic| mute_mic(mic, muted) } + end + + def unmute_all_mics + mute_all_mics(false) + end + + private def send_request(request : ApiRequest, **options) + json_data = request.to_json + logger.debug { "Sending: #{json_data}" } + # Append newline to align with newline tokenizer and typical TCP JSON framing + send(json_data + "\n", **options) + end + + def received(data, task) + data_string = String.new(data).strip + logger.debug { "Received: #{data_string}" } + + return unless data_string.starts_with?("{") && data_string.ends_with?("}") + + begin + response = ApiResponse.from_json(data_string) + + if response.error != 0 + logger.warn "API Error #{response.error} received" + task.try(&.abort("API Error: #{response.error}")) + return + end + + process_response(response, task) + task.try(&.success(response)) + + rescue ex : JSON::ParseException + logger.error "JSON Parse Error: #{ex.message}" + logger.debug "Raw data: #{data_string}" + task.try(&.abort("JSON Parse Error: #{ex.message}")) + rescue ex + logger.error "Unexpected error: #{ex.message}" + task.try(&.abort("Unexpected error: #{ex.message}")) + end + end + + private def process_response(response : ApiResponse, task) + return unless rx_response = response.rx + + if network = rx_response.network + update_network_status(network) + end + + if device = rx_response.device + update_device_status(device) + end + + if audio = rx_response.audio + update_audio_status(audio) + end + end + + private def update_network_status(network : NetworkResponse) + self[:mac_address] = network.mac if network.mac + self[:ip_mode] = network.ip_mode if network.ip_mode + self[:ip_address] = network.ip if network.ip + self[:subnet_mask] = network.subnet if network.subnet + self[:gateway] = network.gateway if network.gateway + + logger.debug "Network status updated" + end + + private def update_device_status(device : DeviceResponse) + self[:device_name] = device.name if device.name + self[:firmware_version] = device.firmware if device.firmware + self[:hardware_version] = device.hardware if device.hardware + self[:serial_number] = device.serial if device.serial + + logger.debug "Device status updated" + end + + private def update_audio_status(audio : AudioResponse) + return unless input = audio.input + + if mic1 = input.mic1 + update_mic_status(1, mic1) + end + + if mic2 = input.mic2 + update_mic_status(2, mic2) + end + + if mic3 = input.mic3 + update_mic_status(3, mic3) + end + end + + private def update_mic_status(mic_number : Int32, mic : MicResponse) + prefix = "mic#{mic_number}" + + if !mic.mute.nil? + self[("#{prefix}_muted").to_sym] = mic.mute + self[("#{prefix}_audio_enabled").to_sym] = !mic.mute + end + + self[("#{prefix}_battery_level").to_sym] = mic.battery if mic.battery + self[("#{prefix}_signal_strength").to_sym] = mic.signal if mic.signal + self[("#{prefix}_connected").to_sym] = mic.connected if !mic.connected.nil? + + logger.debug "Mic #{mic_number} status updated" + end +end \ No newline at end of file diff --git a/drivers/catchbox/hub_dsp_models.cr b/drivers/catchbox/hub_dsp_models.cr new file mode 100644 index 0000000000..d08504779f --- /dev/null +++ b/drivers/catchbox/hub_dsp_models.cr @@ -0,0 +1,153 @@ +require "json" + +module Catchbox + struct ApiRequest + include JSON::Serializable + + property rx : RxCommand? + + def initialize(@rx : RxCommand? = nil) + end + end + + struct ApiResponse + include JSON::Serializable + + property rx : RxResponse? + property error : Int32 = 0 + + def initialize(@rx : RxResponse? = nil, @error : Int32 = 0) + end + end + + struct RxCommand + include JSON::Serializable + + property network : NetworkCommand? + property device : DeviceCommand? + property audio : AudioCommand? + + def initialize(@network : NetworkCommand? = nil, @device : DeviceCommand? = nil, @audio : AudioCommand? = nil) + end + end + + struct RxResponse + include JSON::Serializable + + property network : NetworkResponse? + property device : DeviceResponse? + property audio : AudioResponse? + + def initialize(@network : NetworkResponse? = nil, @device : DeviceResponse? = nil, @audio : AudioResponse? = nil) + end + end + + struct NetworkCommand + include JSON::Serializable + + property mac : String? + property ip_mode : String? + property ip : String? + property subnet : String? + property gateway : String? + property reboot : Bool? + + def initialize(@mac : String? = nil, @ip_mode : String? = nil, @ip : String? = nil, @subnet : String? = nil, @gateway : String? = nil, @reboot : Bool? = nil) + end + end + + struct NetworkResponse + include JSON::Serializable + + property mac : String? + property ip_mode : String? + property ip : String? + property subnet : String? + property gateway : String? + + def initialize(@mac : String? = nil, @ip_mode : String? = nil, @ip : String? = nil, @subnet : String? = nil, @gateway : String? = nil) + end + end + + struct DeviceCommand + include JSON::Serializable + + property name : String? + + def initialize(@name : String? = nil) + end + end + + struct DeviceResponse + include JSON::Serializable + + property name : String? + property firmware : String? + property hardware : String? + property serial : String? + + def initialize(@name : String? = nil, @firmware : String? = nil, @hardware : String? = nil, @serial : String? = nil) + end + end + + struct AudioCommand + include JSON::Serializable + + property input : AudioInputCommand? + + def initialize(@input : AudioInputCommand? = nil) + end + end + + struct AudioResponse + include JSON::Serializable + + property input : AudioInputResponse? + + def initialize(@input : AudioInputResponse? = nil) + end + end + + struct AudioInputCommand + include JSON::Serializable + + property mic1 : MicCommand? + property mic2 : MicCommand? + property mic3 : MicCommand? + + def initialize(@mic1 : MicCommand? = nil, @mic2 : MicCommand? = nil, @mic3 : MicCommand? = nil) + end + end + + struct AudioInputResponse + include JSON::Serializable + + property mic1 : MicResponse? + property mic2 : MicResponse? + property mic3 : MicResponse? + + def initialize(@mic1 : MicResponse? = nil, @mic2 : MicResponse? = nil, @mic3 : MicResponse? = nil) + end + end + + struct MicCommand + include JSON::Serializable + + property mute : Bool? + + def initialize(@mute : Bool? = nil) + end + end + + struct MicResponse + include JSON::Serializable + + property mute : Bool? + property battery : Int32? + property signal : Int32? + property connected : Bool? + + def initialize(@mute : Bool? = nil, @battery : Int32? = nil, @signal : Int32? = nil, @connected : Bool? = nil) + end + end +end \ No newline at end of file diff --git a/drivers/catchbox/hub_dsp_spec.cr b/drivers/catchbox/hub_dsp_spec.cr new file mode 100644 index 0000000000..0906e13a45 --- /dev/null +++ b/drivers/catchbox/hub_dsp_spec.cr @@ -0,0 +1,349 @@ +require "placeos-driver/spec" + +DriverSpecs.mock_driver "Catchbox::HubDSP" do + it "should query device information on connect" do + should_send({ + "rx" => { + "device" => { + "name" => nil, + }, + }, + }.to_json + "\n") + + responds({ + "rx" => { + "device" => { + "name" => "Conference Room Hub", + "firmware" => "1.2.3", + "hardware" => "v1.0", + "serial" => "CB123456", + }, + }, + "error" => 0, + }.to_json) + + status[:device_name].should eq("Conference Room Hub") + status[:firmware_version].should eq("1.2.3") + status[:hardware_version].should eq("v1.0") + status[:serial_number].should eq("CB123456") + end + + it "should query network information on connect" do + should_send({ + "rx" => { + "network" => { + "mac" => nil, + "ip_mode" => nil, + "ip" => nil, + "subnet" => nil, + "gateway" => nil, + }, + }, + }.to_json + "\n") + + responds({ + "rx" => { + "network" => { + "mac" => "00:B0:D0:63:C2:26", + "ip_mode" => "Static", + "ip" => "192.168.1.100", + "subnet" => "255.255.255.0", + "gateway" => "192.168.1.1", + }, + }, + "error" => 0, + }.to_json) + + status[:mac_address].should eq("00:B0:D0:63:C2:26") + status[:ip_mode].should eq("Static") + status[:ip_address].should eq("192.168.1.100") + status[:subnet_mask].should eq("255.255.255.0") + status[:gateway].should eq("192.168.1.1") + end + + it "should query microphone status on connect" do + should_send({ + "rx" => { + "audio" => { + "input" => { + "mic1" => {"mute" => nil}, + "mic2" => {"mute" => nil}, + "mic3" => {"mute" => nil}, + }, + }, + }, + }.to_json + "\n") + + responds({ + "rx" => { + "audio" => { + "input" => { + "mic1" => { + "mute" => false, + "battery" => 85, + "signal" => -45, + "connected" => true, + }, + "mic2" => { + "mute" => true, + "battery" => 72, + "signal" => -38, + "connected" => true, + }, + "mic3" => { + "mute" => false, + "battery" => 0, + "signal" => 0, + "connected" => false, + }, + }, + }, + }, + "error" => 0, + }.to_json) + + status[:mic1_muted].should eq(false) + status[:mic1_audio_enabled].should eq(true) + status[:mic1_battery_level].should eq(85) + status[:mic1_signal_strength].should eq(-45) + status[:mic1_connected].should eq(true) + + status[:mic2_muted].should eq(true) + status[:mic2_audio_enabled].should eq(false) + status[:mic2_battery_level].should eq(72) + status[:mic2_signal_strength].should eq(-38) + status[:mic2_connected].should eq(true) + + status[:mic3_muted].should eq(false) + status[:mic3_audio_enabled].should eq(true) + status[:mic3_battery_level].should eq(0) + status[:mic3_signal_strength].should eq(0) + status[:mic3_connected].should eq(false) + end + + it "should mute a specific microphone" do + exec(:mute_mic, 1, true) + + should_send({ + "rx" => { + "audio" => { + "input" => { + "mic1" => {"mute" => true}, + }, + }, + }, + }.to_json + "\n") + + responds({ + "rx" => { + "audio" => { + "input" => { + "mic1" => {"mute" => true}, + }, + }, + }, + "error" => 0, + }.to_json) + + status[:mic1_muted].should eq(true) + status[:mic1_audio_enabled].should eq(false) + end + + it "should unmute a specific microphone" do + exec(:unmute_mic, 2) + + should_send({ + "rx" => { + "audio" => { + "input" => { + "mic2" => {"mute" => false}, + }, + }, + }, + }.to_json + "\n") + + responds({ + "rx" => { + "audio" => { + "input" => { + "mic2" => {"mute" => false}, + }, + }, + }, + "error" => 0, + }.to_json) + + status[:mic2_muted].should eq(false) + status[:mic2_audio_enabled].should eq(true) + end + + it "should set device name" do + exec(:set_device_name, "New Room Name") + + should_send({ + "rx" => { + "device" => { + "name" => "New Room Name", + }, + }, + }.to_json + "\n") + + responds({ + "rx" => { + "device" => { + "name" => "New Room Name", + }, + }, + "error" => 0, + }.to_json) + + status[:device_name].should eq("New Room Name") + end + + it "should configure network settings" do + exec(:set_network_config, "Static", "192.168.2.100", "255.255.255.0", "192.168.2.1") + + should_send({ + "rx" => { + "network" => { + "ip_mode" => "Static", + "ip" => "192.168.2.100", + "subnet" => "255.255.255.0", + "gateway" => "192.168.2.1", + }, + }, + }.to_json + "\n") + + responds({ + "rx" => { + "network" => { + "ip_mode" => "Static", + "ip" => "192.168.2.100", + "subnet" => "255.255.255.0", + "gateway" => "192.168.2.1", + }, + }, + "error" => 0, + }.to_json) + + status[:ip_mode].should eq("Static") + status[:ip_address].should eq("192.168.2.100") + status[:subnet_mask].should eq("255.255.255.0") + status[:gateway].should eq("192.168.2.1") + end + + it "should handle network reboot command" do + exec(:network_reboot) + + should_send({ + "rx" => { + "network" => { + "reboot" => true, + }, + }, + }.to_json + "\n") + + responds({ + "rx" => {}, + "error" => 0, + }.to_json) + end + + it "should mute all microphones" do + exec(:mute_all_mics) + + should_send({ + "rx" => { + "audio" => { + "input" => { + "mic1" => {"mute" => true}, + }, + }, + }, + }.to_json + "\n") + + responds({ + "rx" => { + "audio" => { + "input" => { + "mic1" => {"mute" => true}, + }, + }, + }, + "error" => 0, + }.to_json) + + should_send({ + "rx" => { + "audio" => { + "input" => { + "mic2" => {"mute" => true}, + }, + }, + }, + }.to_json + "\n") + + responds({ + "rx" => { + "audio" => { + "input" => { + "mic2" => {"mute" => true}, + }, + }, + }, + "error" => 0, + }.to_json) + + should_send({ + "rx" => { + "audio" => { + "input" => { + "mic3" => {"mute" => true}, + }, + }, + }, + }.to_json + "\n") + + responds({ + "rx" => { + "audio" => { + "input" => { + "mic3" => {"mute" => true}, + }, + }, + }, + "error" => 0, + }.to_json) + + status[:mic1_muted].should eq(true) + status[:mic2_muted].should eq(true) + status[:mic3_muted].should eq(true) + end + + it "should handle API errors gracefully" do + exec(:query_device_info) + + should_send({ + "rx" => { + "device" => { + "name" => nil, + }, + }, + }.to_json + "\n") + + responds({ + "rx" => {}, + "error" => 1, + }.to_json) + end + + it "should validate microphone number range" do + expect_raises(ArgumentError, "Mic number must be 1, 2, or 3") do + exec(:mute_mic, 0, true).get + end + + expect_raises(ArgumentError, "Mic number must be 1, 2, or 3") do + exec(:mute_mic, 4, true).get + end + end +end \ No newline at end of file