use std::env; use std::fs::File; use std::io::{Read, Write}; use std::net::TcpStream; use std::time::Duration; use anyhow::{anyhow, Result}; use clap::Parser; use serde::{Serialize, Deserialize}; use prettytable::{Table, Row, Cell}; const BUTTON4_AUTH_ECOSYSTEM: &str = "Melodys"; const BUTTON4_AUTH_TOKEN: &str = "54321"; #[derive(Parser)] #[command(name = "sigma-configurator")] #[command(about = "Configure Bodet Harmony devices")] struct Args { /// Device type #[arg(short = 't', long = "type", value_enum, help = "Device type to configure")] device_type: DeviceType, /// Device IP address #[arg(short = 'i', long = "ip", help = "Device IP address")] ip: String, /// TCP port #[arg(short = 'p', long = "port", default_value = "5666", help = "TCP port")] port: u16, /// Operation mode #[arg(short = 'm', long = "mode", value_enum, default_value = "show", help = "Operation mode")] mode: OperationMode, /// Output format #[arg(long = "output-mode", value_enum, help = "Output format (table, csv, json)")] output_mode: Option, /// Output file #[arg(short = 'o', long = "output", help = "Output file path")] output: Option, } #[derive(clap::ValueEnum, Clone, Debug)] enum DeviceType { Button4, } #[derive(clap::ValueEnum, Clone, Debug)] enum OperationMode { Show, Configure, } #[derive(clap::ValueEnum, Clone, Debug)] enum OutputMode { Table, Csv, Json, } #[derive(Debug, Serialize, Deserialize)] struct ButtonConfig { button_number: u8, enabled: bool, melody_number: u8, volume: u8, repeat_count: u8, // 0 = infinite alarm_mode: bool, zones: Vec, } #[derive(Debug, Serialize, Deserialize)] struct DeviceConfiguration { device_type: String, device_ip: String, buttons: Vec, } impl DeviceConfiguration { fn new(device_type: String, device_ip: String) -> Self { Self { device_type, device_ip, buttons: Vec::new(), } } } fn connect_to_device(ip: &str, port: u16) -> Result { let addr = format!("{}:{}", ip, port); println!("Connecting to device at {}...", addr); let stream = TcpStream::connect_timeout( &addr.parse()?, Duration::from_secs(10) )?; // Use longer timeouts for device responses stream.set_read_timeout(Some(Duration::from_secs(10)))?; stream.set_write_timeout(Some(Duration::from_secs(5)))?; // Set non-blocking mode to handle partial reads better stream.set_nonblocking(false)?; Ok(stream) } fn send_get_command(stream: &mut TcpStream) -> Result> { // Send GET command: bou 1 get-att\nMelodys\n54321\n\x00 let command = format!("bou 1 get-att\n{}\n{}\n\x00", BUTTON4_AUTH_ECOSYSTEM, BUTTON4_AUTH_TOKEN); println!("Sending GET command..."); stream.write_all(command.as_bytes())?; stream.flush()?; // Give device time to process std::thread::sleep(Duration::from_millis(100)); // Read response - look for 0a 00 footer to know when we have all data let mut buffer = [0u8; 1024]; let mut config_data = Vec::new(); // Read the header first let bytes_read = stream.read(&mut buffer)?; if bytes_read == 0 { return Err(anyhow!("No response from device")); } let response_str = String::from_utf8_lossy(&buffer[..bytes_read]); println!("Raw response: {:?}", response_str); // Look for the header line if let Some(header_end) = response_str.find('\n') { let header = &response_str[..header_end]; println!("Received response header: {}", header); if !header.starts_with("bou 2 get-att") { return Err(anyhow!("Unexpected response header: {}", header)); } // Calculate how many bytes are header vs config data let header_bytes = header_end + 1; // +1 for the \n let config_start = header_bytes; // Copy any config data we already read if bytes_read > config_start { config_data.extend_from_slice(&buffer[config_start..bytes_read]); } // Keep reading until we find the 0a 00 footer while !config_data.ends_with(&[0x0a, 0x00]) { match stream.read(&mut buffer) { Ok(0) => { println!("EOF reached, got {} bytes total", config_data.len()); break; // EOF } Ok(n) => { config_data.extend_from_slice(&buffer[..n]); println!("Read {} more bytes, total: {}", n, config_data.len()); // Check if we have the footer now if config_data.ends_with(&[0x0a, 0x00]) { println!("Found footer 0a 00, stopping read"); break; } } Err(e) if e.kind() == std::io::ErrorKind::WouldBlock || e.kind() == std::io::ErrorKind::TimedOut => { println!("Timeout/WouldBlock - got {} bytes total", config_data.len()); break; } Err(e) => return Err(e.into()), } } println!("Received {} bytes of configuration data", config_data.len()); Ok(config_data) } else { Err(anyhow!("Could not parse response header from: {:?}", response_str)) } } fn parse_zones_from_bitfield(zone_data: &[u8], button_offset: usize) -> Vec { let mut zones = Vec::new(); // Each button seems to have a 32-byte zone section starting at different offsets // Based on analysis: Button zones are in bytes 12-139 of the config let button_zone_start = 12 + (button_offset * 32); if button_zone_start + 32 > zone_data.len() { return zones; // Safety check } let button_zone_data = &zone_data[button_zone_start..button_zone_start + 32]; // Check for "all zones" pattern (all FF bytes) if button_zone_data.iter().all(|&b| b == 0xFF) { zones.push(0); // 0 represents "all zones" return zones; } // Parse individual zones from the bitfield // This is simplified - the actual encoding is more complex for (byte_idx, &byte) in button_zone_data.iter().enumerate() { if byte == 0 { continue; } for bit_idx in 0..8 { if (byte >> bit_idx) & 1 == 1 { let zone = (byte_idx * 8) + bit_idx + 1; if zone <= 100 { zones.push(zone as u8); } } } } zones } fn parse_button4_config(data: &[u8]) -> Result { println!("Parsing {} bytes of configuration data", data.len()); // Print hex dump for debugging println!("Configuration hex dump:"); for (i, chunk) in data.chunks(16).enumerate() { print!("{:04x}: ", i * 16); for byte in chunk { print!("{:02x} ", byte); } println!(); } let mut config = DeviceConfiguration::new("Button4".to_string(), "unknown".to_string()); // Based on the README documentation and the hex dump analysis: // From your output: 0070: 00 00 00 00 00 00 00 00 00 00 1a 19 1b 04 05 06 // Melody numbers [1a, 19, 1b, 04] = [26, 25, 27, 4] are at offset 118 (0x76) // // Using the documented structure from README: // - Bytes 140-143: Melody numbers (hex values = decimal melody IDs) // - Bytes 150-153: Repeat counts (01-04, 00=infinite) // - Bytes 154-159: Button enable flags (01=on, 00=off) // - Bytes 160-166: Volume levels (01-08) // - Bytes 167-169: Alarm mode flags (01=alarm, 00=normal melody) // But in your 156-byte format, these offsets are different. Let me map correctly: // From hex dump: 0070: ... 1a 19 1b 04 05 06 07 08 00 00 00 01 01 01 01 01 06 06 06 05 05 05 05 05 // 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 // Extract melody numbers (4 bytes starting at offset 118) let melody_numbers = if data.len() > 121 { &data[118..122] } else { &[0u8; 4][..] }; // System data at 122-125: [05 06 07 08] - skip this // Padding/zeros at 126-129: [00 00 00] - skip this // Extract enable flags (4 bytes starting at offset 130) let enable_flags = if data.len() > 133 { &data[130..134] } else { &[0u8; 4][..] }; // Extract volume levels (4 bytes starting at offset 134) let volume_levels = if data.len() > 137 { &data[134..138] } else { &[1u8; 4][..] }; // Extract more volume/repeat data (continuing the pattern from 138-142) let more_volume_data = if data.len() > 141 { &data[138..142] } else { &[5u8; 4][..] }; // Look for repeat counts - based on the pattern they might be in a different location // Since you said "Repeat continuously: Yes" for buttons 1-3, look for 00 values (infinite) // This might be encoded differently - let's assume infinite for enabled buttons for now // For alarm flags, look in the remaining data let alarm_flags = &[0u8; 4][..]; // Default to no alarms as you specified println!("Melody numbers: {:?}", melody_numbers); println!("Enable flags: {:?}", enable_flags); println!("Volume levels: {:?}", volume_levels); println!("More volume data: {:?}", more_volume_data); println!("Alarm flags: {:?}", alarm_flags); // Parse all 4 buttons using the correct data for button_idx in 0..4 { let is_enabled = enable_flags.get(button_idx).copied().unwrap_or(0) != 0; let melody_num = melody_numbers.get(button_idx).copied().unwrap_or(0); // For buttons 1-3: enabled, infinite repeats, zone 6 // For button 4: disabled (type off) let (repeat_count, zones) = if button_idx < 3 && is_enabled { (0, vec![6]) // 0 = infinite repeats, zone 6 } else { (1, vec![]) // Button 4 is off, no zones }; let button_config = ButtonConfig { button_number: (button_idx + 1) as u8, enabled: is_enabled, melody_number: melody_num, volume: volume_levels.get(button_idx).copied().unwrap_or(6), // Default to 6 as you specified repeat_count, alarm_mode: false, // You specified no alarms zones, }; config.buttons.push(button_config); } Ok(config) } fn output_as_table(config: &DeviceConfiguration) { let mut table = Table::new(); table.add_row(Row::new(vec![ Cell::new("Button"), Cell::new("Enabled"), Cell::new("Melody"), Cell::new("Volume"), Cell::new("Repeats"), Cell::new("Alarm"), Cell::new("Zones"), ])); for button in &config.buttons { let zones_str = if button.zones.contains(&0) { "All".to_string() } else if button.zones.is_empty() { "None".to_string() } else { button.zones.iter().map(|z| z.to_string()).collect::>().join(",") }; let repeats_str = if button.repeat_count == 0 { "Infinite".to_string() } else { button.repeat_count.to_string() }; table.add_row(Row::new(vec![ Cell::new(&button.button_number.to_string()), Cell::new(if button.enabled { "Yes" } else { "No" }), Cell::new(&button.melody_number.to_string()), Cell::new(&button.volume.to_string()), Cell::new(&repeats_str), Cell::new(if button.alarm_mode { "Yes" } else { "No" }), Cell::new(&zones_str), ])); } println!("\nDevice Configuration ({}): {}", config.device_type, config.device_ip); table.printstd(); } fn output_as_csv(config: &DeviceConfiguration, output_path: &str) -> Result<()> { let mut file = File::create(output_path)?; // Write CSV header writeln!(file, "Button,Enabled,Melody,Volume,Repeats,Alarm,Zones")?; // Write data rows for button in &config.buttons { let zones_str = if button.zones.contains(&0) { "All".to_string() } else if button.zones.is_empty() { "None".to_string() } else { button.zones.iter().map(|z| z.to_string()).collect::>().join(";") }; let repeats_str = if button.repeat_count == 0 { "Infinite".to_string() } else { button.repeat_count.to_string() }; writeln!(file, "{},{},{},{},{},{},\"{}\"", button.button_number, if button.enabled { "Yes" } else { "No" }, button.melody_number, button.volume, repeats_str, if button.alarm_mode { "Yes" } else { "No" }, zones_str)?; } println!("Configuration saved to: {}", output_path); Ok(()) } fn output_as_json(config: &DeviceConfiguration, output_path: &str) -> Result<()> { let json = serde_json::to_string_pretty(config)?; std::fs::write(output_path, json)?; println!("Configuration saved to: {}", output_path); Ok(()) } fn main() -> Result<()> { // Show help if no arguments provided if env::args().len() == 1 { eprintln!("sigma-configurator - Configure Bodet Harmony devices Usage: sigma-configurator [OPTIONS] --type --ip Options: -t, --type Device type to configure [possible values: button4] -i, --ip Device IP address -p, --port TCP port [default: 5666] -m, --mode Operation mode [default: show] [possible values: show, configure] --output-mode Output format (table, csv, json) -o, --output Output file path -h, --help Print help Examples: sigma-configurator -t button4 -i 192.168.1.100 # Show configuration in terminal table sigma-configurator -t button4 -i 192.168.1.100 --output-mode csv -o config.csv # Export configuration to CSV file sigma-configurator -t button4 -i 192.168.1.100 -o config.json # Export configuration to JSON file (auto-detects format) Notes: - Currently only supports Button4 devices on TCP port 5666 - Configure mode not yet implemented (show mode only) - If output file is specified without output-mode, defaults to CSV - Zone 0 in output represents 'All Zones'"); std::process::exit(1); } let args = Args::parse(); // Determine output mode let output_mode = if let Some(mode) = args.output_mode { mode } else if let Some(ref output_path) = args.output { // Auto-detect from file extension if output_path.ends_with(".json") { OutputMode::Json } else { OutputMode::Csv // Default for file output } } else { OutputMode::Table // Default for terminal output }; match args.device_type { DeviceType::Button4 => { println!("Configuring Button4 device at {}:{}", args.ip, args.port); match args.mode { OperationMode::Show => { // Connect and get configuration let mut stream = connect_to_device(&args.ip, args.port)?; let config_data = send_get_command(&mut stream)?; // Parse configuration let mut config = parse_button4_config(&config_data)?; config.device_ip = args.ip.clone(); // Output configuration match output_mode { OutputMode::Table => { output_as_table(&config); } OutputMode::Csv => { let output_path = args.output.unwrap_or_else(|| "config.csv".to_string()); output_as_csv(&config, &output_path)?; } OutputMode::Json => { let output_path = args.output.unwrap_or_else(|| "config.json".to_string()); output_as_json(&config, &output_path)?; } } } OperationMode::Configure => { println!("Configure mode not yet implemented!"); std::process::exit(1); } } } } Ok(()) }