diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/sigma-caster.rs | 338 | ||||
-rw-r--r-- | src/sigma-configurator.rs | 499 | ||||
-rw-r--r-- | src/sigma-sender.rs | 317 |
3 files changed, 1154 insertions, 0 deletions
diff --git a/src/sigma-caster.rs b/src/sigma-caster.rs new file mode 100644 index 0000000..b7f10eb --- /dev/null +++ b/src/sigma-caster.rs @@ -0,0 +1,338 @@ +use std::env; +use std::fs::File; +use std::io::Read; +use std::net::UdpSocket; +use std::path::Path; +use std::process::Command; +use std::thread; +use std::time::{Duration, Instant}; + +use anyhow::{anyhow, Result}; +use clap::Parser; +use indicatif::{ProgressBar, ProgressStyle}; +use tempfile::NamedTempFile; + +// Default stream ID that's known to work with Bodet systems (as hex string like Python) +const DEFAULT_STREAM_ID: u16 = 0x110c; + +#[derive(Parser)] +#[command(name = "sigma-caster")] +#[command(about = "Stream MP3 files to Bodet PSA System")] +#[command(long_about = None)] +struct Args { + /// Path to MP3 file to stream + mp3_file: String, + + /// Multicast/unicast address + #[arg(short = 'i', long = "ip", default_value = "239.192.55.1", help = "IP address to send to")] + addr: String, + + /// UDP port + #[arg(short, long, default_value = "1681", help = "UDP port")] + port: u16, + + /// Zones (space-separated list like: 1 2 3) + #[arg(short = 'z', long = "zones", value_delimiter = ' ', default_values = ["1"], help = "Zone numbers (space-separated)")] + zones: Vec<u8>, + + /// Software volume control (1-8, where 8 is loudest) + #[arg(short = 'v', long = "volume", default_value = "8", help = "Software volume (1-8, where 8 is loudest)")] + volume: u8, + + /// Don't send duplicate packets + #[arg(long, help = "Don't send duplicate packets")] + no_duplicates: bool, + + /// MP3 chunk size in bytes + #[arg(short, long, default_value = "1000", help = "MP3 chunk size in bytes")] + chunk_size: usize, + + /// Audio quality preset + #[arg(short, long, value_enum, default_value = "high", help = "Audio quality preset")] + quality: Quality, + + /// Fixed stream ID (default: 0x110c) + #[arg(long, help = "Fixed stream ID (default: 0x110c)")] + stream_id: Option<u16>, + + /// Show debug information + #[arg(long, help = "Show debug information")] + debug: bool, +} + +#[derive(clap::ValueEnum, Clone, Debug)] +enum Quality { + High, + Low, +} + +struct QualityPreset { + bitrate: &'static str, + sample_rate: u32, + description: &'static str, +} + +impl Quality { + fn preset(&self) -> QualityPreset { + match self { + Quality::High => QualityPreset { + bitrate: "256k", + sample_rate: 48000, + description: "High Quality (256kbps, 48kHz, Mono)", + }, + Quality::Low => QualityPreset { + bitrate: "64k", + sample_rate: 32000, + description: "Low Quality (64kbps, 32kHz, Mono)", + }, + } + } +} + +fn compute_psa_checksum(data: &[u8]) -> [u8; 2] { + let mut var_e: u16 = 0x0000; + for (i, &b) in data.iter().enumerate() { + var_e ^= (b as u16).wrapping_add(i as u16) & 0xFFFF; + } + var_e.to_be_bytes() +} + +fn create_mp3_packet(sequence_num: u8, zone_info: &[u8], stream_id: u16, mp3_chunk: &[u8]) -> Result<Vec<u8>> { + let mut packet = Vec::new(); + + // MEL header + packet.extend_from_slice(b"MEL"); + + // Build payload components + let start_marker = [0x01, 0x00]; + let seq = [sequence_num, 0xff]; + let command = [0x07, 0x03]; + let stream_id_bytes = stream_id.to_le_bytes(); + let constant_data = [0x05, 0x08, 0x05, 0x03, 0xe8]; + + // Calculate payload length - matches Python: start_marker + payload + 7 + let payload_len = seq.len() + command.len() + zone_info.len() + + stream_id_bytes.len() + constant_data.len() + mp3_chunk.len(); + let total_len = start_marker.len() + payload_len + 7; + + // Length field (big endian) + packet.extend_from_slice(&(total_len as u16).to_be_bytes()); + + // Start marker + packet.extend_from_slice(&start_marker); + + // Payload + packet.extend_from_slice(&seq); + packet.extend_from_slice(&command); + packet.extend_from_slice(zone_info); + packet.extend_from_slice(&stream_id_bytes); + packet.extend_from_slice(&constant_data); + packet.extend_from_slice(mp3_chunk); + + // Checksum + let checksum = compute_psa_checksum(&packet); + packet.extend_from_slice(&checksum); + + Ok(packet) +} + +fn check_lame_available() -> Result<()> { + Command::new("lame") + .arg("--version") + .output() + .map_err(|_| anyhow!("LAME encoder not found. Please install LAME and ensure it's in PATH."))?; + Ok(()) +} + +fn apply_volume_to_mp3(input_file: &str, output_file: &str, volume: u8, preset: &QualityPreset) -> Result<()> { + // Convert volume (1-8) to LAME scale factor + let volume_scale = (volume as f32) / 8.0; + + let mut cmd = Command::new("lame"); + cmd.arg("--quiet") + .arg("-m").arg("m") // mono + .arg("--resample").arg(&(preset.sample_rate / 1000).to_string()) + .arg("-b").arg(preset.bitrate.trim_end_matches('k')) + .arg("--cbr") + .arg("--noreplaygain"); + + // Apply volume scaling if needed + if volume < 8 { + cmd.arg("--scale").arg(&format!("{:.3}", volume_scale)); + } + + cmd.arg("--tt").arg("") // empty title + .arg("--tc").arg("") // empty comment + .arg("--nohist") + .arg(input_file) + .arg(output_file); + + let output = cmd.output()?; + + if !output.status.success() { + return Err(anyhow!("LAME transcoding with volume failed: {}", + String::from_utf8_lossy(&output.stderr))); + } + + Ok(()) +} + +fn transcode_mp3(input_file: &str, preset: &QualityPreset, volume: u8) -> Result<NamedTempFile> { + println!("Transcoding audio to {} (Volume: {}/8)...", preset.description, volume); + + let temp_file = NamedTempFile::new()?; + + apply_volume_to_mp3(input_file, temp_file.path().to_str().unwrap(), volume, preset)?; + + Ok(temp_file) +} + +fn chunk_data(data: &[u8], chunk_size: usize) -> Vec<&[u8]> { + data.chunks(chunk_size).collect() +} + +fn calculate_delay_ms(chunk_size: usize, bitrate_kbps: u32) -> u64 { + let bytes_per_second = bitrate_kbps * 1000 / 8; + (chunk_size as u64 * 1000) / bytes_per_second as u64 +} + +fn build_zone_bytes(zones: &[u8]) -> [u8; 13] { + let mut zone_bytes = [0u8; 13]; // 13 bytes like Python (12 + extra 00) + for &zone in zones { + if zone == 0 || zone > 100 { + continue; + } + let idx = (zone - 1) / 8; + let bit = (zone - 1) % 8; + zone_bytes[idx as usize] |= 1 << bit; + } + // Last byte stays 0x00 as per Python DEFAULT_ZONE_INFO pattern + zone_bytes +} + +fn main() -> Result<()> { + // Show help if no arguments provided + if env::args().len() == 1 { + eprintln!("sigma-caster - Stream MP3 files to Bodet PSA System + +Usage: sigma-caster [OPTIONS] <MP3_FILE> + +Arguments: + <MP3_FILE> Path to MP3 file to stream + +Options: + -i, --ip <IP> IP address to send to [default: 239.192.55.1] + -p, --port <PORT> UDP port [default: 1681] + -z, --zones <ZONES>... Zone numbers (space-separated) [default: 1] + -v, --volume <VOLUME> Software volume (1-8, where 8 is loudest) [default: 8] + --no-duplicates Don't send duplicate packets + -c, --chunk-size <SIZE> MP3 chunk size in bytes [default: 1000] + -q, --quality <QUALITY> Audio quality preset [default: high] [possible values: high, low] + --stream-id <ID> Fixed stream ID (default: 0x110c) + --debug Show debug information + -h, --help Print help + +Examples: + sigma-caster audio.mp3 + # Stream audio.mp3 to zone 1 at full volume + sigma-caster -i 172.16.20.109 -z 1 2 3 -v 6 audio.mp3 + # Stream to zones 1,2,3 at volume 6/8 on specific IP + sigma-caster --debug -q low audio.mp3 + # Stream with low quality and show debug info"); + std::process::exit(1); + } + + let args = Args::parse(); + + // Validate volume range + if !(1..=8).contains(&args.volume) { + return Err(anyhow!("Volume must be between 1 and 8")); + } + + // Check dependencies + check_lame_available()?; + + // Validate input file + if !Path::new(&args.mp3_file).exists() { + return Err(anyhow!("MP3 file not found: {}", args.mp3_file)); + } + + // Build zone info from zone numbers + let zone_bytes = build_zone_bytes(&args.zones); + + // Get quality preset + let preset = args.quality.preset(); + + // Transcode MP3 with volume control + let temp_file = transcode_mp3(&args.mp3_file, &preset, args.volume)?; + + // Read transcoded MP3 data + let mut mp3_data = Vec::new(); + File::open(temp_file.path())?.read_to_end(&mut mp3_data)?; + + // Use default stream ID if not specified + let stream_id = args.stream_id.unwrap_or(DEFAULT_STREAM_ID); + + // Chunk the data + let chunks = chunk_data(&mp3_data, args.chunk_size); + let total_chunks = chunks.len(); + + // Calculate timing + let bitrate_kbps = preset.bitrate.trim_end_matches('k').parse::<u32>()?; + let delay_ms = calculate_delay_ms(args.chunk_size, bitrate_kbps); + + println!("Streaming {} ({:.2} KB)", + Path::new(&args.mp3_file).file_name().unwrap().to_string_lossy(), + mp3_data.len() as f64 / 1024.0); + println!("Split into {} packets of {} bytes each", total_chunks, args.chunk_size); + println!("Sending to {}:{}", args.addr, args.port); + println!("Stream ID: {:04x}, Quality: {}", stream_id, preset.description); + println!("Zones: {:?}, Volume: {}/8", args.zones, args.volume); + println!("Duplicate packets: {}", if args.no_duplicates { "No" } else { "Yes" }); + println!("Delay per packet: {}ms", delay_ms); + + // Setup UDP socket + let socket = UdpSocket::bind("0.0.0.0:0")?; + let target_addr = format!("{}:{}", args.addr, args.port); + + // Progress bar + let progress = ProgressBar::new(total_chunks as u64); + progress.set_style(ProgressStyle::default_bar() + .template("[{elapsed_precise}] {bar:40.cyan/blue} {pos:>7}/{len:7} {msg}") + .unwrap()); + + // Stream chunks + let start_time = Instant::now(); + let mut sequence_num = 0u8; + + for (i, chunk) in chunks.iter().enumerate() { + // Create packet + let packet = create_mp3_packet(sequence_num, &zone_bytes, stream_id, chunk)?; + + if args.debug && i == 0 { + let hex_str = packet.iter().map(|b| format!("{:02x}", b)).collect::<String>(); + println!("DEBUG: First packet hex: {}", hex_str); + } + + // Send packet + socket.send_to(&packet, &target_addr)?; + + // Send duplicate if requested + if !args.no_duplicates { + socket.send_to(&packet, &target_addr)?; + } + + sequence_num = sequence_num.wrapping_add(1); + progress.inc(1); + + // Wait for next packet (except for last one) + if i < total_chunks - 1 { + thread::sleep(Duration::from_millis(delay_ms)); + } + } + + progress.finish_with_message("Streaming completed!"); + println!("Total time: {:.2}s", start_time.elapsed().as_secs_f64()); + + Ok(()) +} diff --git a/src/sigma-configurator.rs b/src/sigma-configurator.rs new file mode 100644 index 0000000..c1f9b34 --- /dev/null +++ b/src/sigma-configurator.rs @@ -0,0 +1,499 @@ +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<OutputMode>, + + /// Output file + #[arg(short = 'o', long = "output", help = "Output file path")] + output: Option<String>, +} + +#[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<u8>, +} + +#[derive(Debug, Serialize, Deserialize)] +struct DeviceConfiguration { + device_type: String, + device_ip: String, + buttons: Vec<ButtonConfig>, +} + +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<TcpStream> { + 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<Vec<u8>> { + // 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<u8> { + 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<DeviceConfiguration> { + 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::<Vec<_>>().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::<Vec<_>>().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 <TYPE> --ip <IP> + +Options: + -t, --type <TYPE> Device type to configure [possible values: button4] + -i, --ip <IP> Device IP address + -p, --port <PORT> TCP port [default: 5666] + -m, --mode <MODE> Operation mode [default: show] [possible values: show, configure] + --output-mode <OUTPUT_MODE> Output format (table, csv, json) + -o, --output <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(()) +} diff --git a/src/sigma-sender.rs b/src/sigma-sender.rs new file mode 100644 index 0000000..c31e6d6 --- /dev/null +++ b/src/sigma-sender.rs @@ -0,0 +1,317 @@ +use std::env; +use std::net::UdpSocket; + +#[derive(Clone, Copy)] +enum CommandType { + Melody, + Alarm, + Stop, +} + +impl CommandType { + fn code(&self) -> u16 { + match self { + CommandType::Melody => 0x3001, + CommandType::Alarm => 0x5001, + CommandType::Stop => 0x5002, + } + } + + fn name(&self) -> &'static str { + match self { + CommandType::Melody => "melody", + CommandType::Alarm => "alarm", + CommandType::Stop => "stop", + } + } +} + +fn compute_psa_checksum(data: &[u8]) -> [u8; 2] { + let mut var_e: u16 = 0x0000; + for (i, &b) in data.iter().enumerate() { + var_e ^= (b as u16).wrapping_add(i as u16) & 0xFFFF; + } + var_e.to_be_bytes() +} + +fn build_zone_bytes(zones: &[u8]) -> [u8; 12] { + let mut zone_bytes = [0u8; 12]; + + // Special case: zone 0 means all zones (set all bits) + if zones.contains(&0) { + return [0xff; 12]; + } + + for &zone in zones { + if zone > 100 { + continue; + } + let idx = (zone - 1) / 8; + let bit = (zone - 1) % 8; + zone_bytes[idx as usize] |= 1 << bit; + } + zone_bytes +} + +fn main() { + let args: Vec<String> = env::args().collect(); + + // Show help if no arguments provided + if args.len() == 1 { + eprintln!("sigma-sender - Send command packets to Bodet PSA System + +Usage: sigma-sender [OPTIONS] --command <COMMAND> --zones <ZONES>... [--melody <MELODY>] [--volume <VOLUME>] [--repeats <REPEATS>] + +Options: + -i, --ip <IP> IP address to send to [default: 239.192.55.1] + -p, --port <PORT> UDP port [default: 1681] + -c, --command <COMMAND> Command type: melody, alarm, stop + -m, --melody <MELODY> Melody number (1-30, required for melody/alarm) + -v, --volume <VOLUME> Volume level (1-8, required for melody/alarm) + -r, --repeats <REPEATS> Repeat count (0=infinite, 1-8, required for melody/alarm) + -z, --zones <ZONES>... Zone numbers (0=all zones, 1-100, space-separated) + --stop-all Quick stop all zones (equivalent to: --command stop --zones 0) + --debug Show debug information + -h, --help Print help + +Examples: + sigma-sender --command melody -m 5 -v 4 -r 2 -z 8 + # Send melody 5, volume 4, repeat 2 times to zone 8 + sigma-sender --command alarm -m 9 -v 8 -r 0 -z 1 2 3 + # Send alarm 9, volume 8, infinite repeats to zones 1, 2, and 3 + sigma-sender --command stop -z 0 + # Stop all zones + sigma-sender --stop-all + # Quick stop all zones + sigma-sender --debug --command melody -m 1 -v 1 -r 1 -z 8 + # Send melody 1 to zone 8 and show packet hex + +Notes: + - Commands: melody (3001), alarm (5001), stop (5002) + - Melodies: 1-30 (hex in protocol, decimal here) + - Volume: 1-8 (max 8) + - Repeats: 0=infinite, 1-8 (finite) + - Zones: 0=all zones, 1-100 (multiple allowed) + - Stop command ignores melody/volume/repeats parameters + - Defaults: IP=239.192.55.1, Port=1681"); + std::process::exit(1); + } + + // Parse command line arguments + let mut ip = "239.192.55.1".to_string(); + let mut port = 1681u16; + let mut debug = false; + let mut command: Option<CommandType> = None; + let mut melody: Option<u8> = None; + let mut volume: Option<u8> = None; + let mut repeats: Option<u8> = None; + let mut zones: Vec<u8> = Vec::new(); + let mut stop_all = false; + + let mut idx = 1; + while idx < args.len() { + match args[idx].as_str() { + "-i" | "--ip" => { + if idx + 1 < args.len() { + ip = args[idx + 1].clone(); + idx += 2; + } else { + eprintln!("Error: --ip requires a value"); + std::process::exit(1); + } + } + "-p" | "--port" => { + if idx + 1 < args.len() { + port = args[idx + 1].parse().unwrap_or_else(|_| { + eprintln!("Error: Invalid port number"); + std::process::exit(1); + }); + idx += 2; + } else { + eprintln!("Error: --port requires a value"); + std::process::exit(1); + } + } + "-c" | "--command" => { + if idx + 1 < args.len() { + command = Some(match args[idx + 1].as_str() { + "melody" => CommandType::Melody, + "alarm" => CommandType::Alarm, + "stop" => CommandType::Stop, + _ => { + eprintln!("Error: Invalid command type. Use: melody, alarm, or stop"); + std::process::exit(1); + } + }); + idx += 2; + } else { + eprintln!("Error: --command requires a value"); + std::process::exit(1); + } + } + "-m" | "--melody" => { + if idx + 1 < args.len() { + melody = Some(args[idx + 1].parse().unwrap_or_else(|_| { + eprintln!("Error: Invalid melody number"); + std::process::exit(1); + })); + idx += 2; + } else { + eprintln!("Error: --melody requires a value"); + std::process::exit(1); + } + } + "-v" | "--volume" => { + if idx + 1 < args.len() { + volume = Some(args[idx + 1].parse().unwrap_or_else(|_| { + eprintln!("Error: Invalid volume level"); + std::process::exit(1); + })); + idx += 2; + } else { + eprintln!("Error: --volume requires a value"); + std::process::exit(1); + } + } + "-r" | "--repeats" => { + if idx + 1 < args.len() { + repeats = Some(args[idx + 1].parse().unwrap_or_else(|_| { + eprintln!("Error: Invalid repeat count"); + std::process::exit(1); + })); + idx += 2; + } else { + eprintln!("Error: --repeats requires a value"); + std::process::exit(1); + } + } + "-z" | "--zones" => { + idx += 1; + while idx < args.len() && !args[idx].starts_with('-') { + let zone = args[idx].parse().unwrap_or_else(|_| { + eprintln!("Error: Invalid zone number: {}", args[idx]); + std::process::exit(1); + }); + zones.push(zone); + idx += 1; + } + } + "--stop-all" => { + stop_all = true; + idx += 1; + } + "--debug" => { + debug = true; + idx += 1; + } + "-h" | "--help" => { + std::process::exit(0); + } + _ => { + eprintln!("Error: Unknown argument: {}", args[idx]); + std::process::exit(1); + } + } + } + + // Handle --stop-all shortcut + if stop_all { + command = Some(CommandType::Stop); + zones = vec![0]; // All zones + } + + // Validate required arguments + let command = command.unwrap_or_else(|| { + eprintln!("Error: --command is required (use --stop-all for quick stop)"); + std::process::exit(1); + }); + + if zones.is_empty() { + eprintln!("Error: --zones is required"); + std::process::exit(1); + } + + // Validate command-specific requirements + match command { + CommandType::Melody | CommandType::Alarm => { + let melody = melody.unwrap_or_else(|| { + eprintln!("Error: --melody is required for {} command", command.name()); + std::process::exit(1); + }); + let volume = volume.unwrap_or_else(|| { + eprintln!("Error: --volume is required for {} command", command.name()); + std::process::exit(1); + }); + let repeats = repeats.unwrap_or_else(|| { + eprintln!("Error: --repeats is required for {} command", command.name()); + std::process::exit(1); + }); + + // Validate ranges + if !(1..=30).contains(&melody) || !(1..=8).contains(&volume) || repeats > 8 { + eprintln!("Error: Melody (1-30), Volume (1-8), Repeats (0-8, 0=infinite)"); + std::process::exit(1); + } + + // Build melody/alarm packet + let mut packet: Vec<u8> = Vec::new(); + packet.extend_from_slice(b"MEL"); + packet.extend_from_slice(&0x0021u16.to_be_bytes()); + packet.extend_from_slice(&[0x01, 0x00]); + packet.extend_from_slice(&[0x28, 0xff]); + packet.extend_from_slice(&command.code().to_be_bytes()); + packet.extend_from_slice(&build_zone_bytes(&zones)); + packet.extend_from_slice(&[0x00, 0x01]); + packet.push(volume); + packet.push(repeats); + packet.push(0x01); + packet.push(melody); + packet.extend_from_slice(&[0x01, 0x00]); + + let checksum = compute_psa_checksum(&packet); + packet.extend_from_slice(&checksum); + + if debug { + let hexstr = packet.iter().map(|b| format!("{:02x}", b)).collect::<Vec<_>>().join(""); + println!("DEBUG: Packet hex: {}", hexstr); + } + + let addr = format!("{}:{}", ip, port); + let sock = UdpSocket::bind("0.0.0.0:0").expect("bind failed"); + sock.set_multicast_ttl_v4(2).ok(); + sock.send_to(&packet, &addr).expect("send failed"); + + let repeat_str = if repeats == 0 { "infinite".to_string() } else { repeats.to_string() }; + println!("Sent {} {} (vol {}, rep {}, zones {:?}) to {}", + command.name(), melody, volume, repeat_str, zones, addr); + } + CommandType::Stop => { + // Build stop packet (different format, shorter) + let mut packet: Vec<u8> = Vec::new(); + packet.extend_from_slice(b"MEL"); + packet.extend_from_slice(&0x001bu16.to_be_bytes()); // 27 bytes for stop (to accommodate 2-byte checksum) + packet.extend_from_slice(&[0x01, 0x00]); + packet.extend_from_slice(&[0x2d, 0xff]); // Different sequence for stop + packet.extend_from_slice(&command.code().to_be_bytes()); + packet.extend_from_slice(&build_zone_bytes(&zones)); + packet.extend_from_slice(&[0x0f, 0x01]); // Different ending for stop + + let checksum = compute_psa_checksum(&packet); + packet.extend_from_slice(&checksum); // This adds 2 bytes + + if debug { + let hexstr = packet.iter().map(|b| format!("{:02x}", b)).collect::<Vec<_>>().join(""); + println!("DEBUG: Packet hex: {}", hexstr); + println!("DEBUG: Packet length: {} bytes", packet.len()); + } + + let addr = format!("{}:{}", ip, port); + let sock = UdpSocket::bind("0.0.0.0:0").expect("bind failed"); + sock.set_multicast_ttl_v4(2).ok(); + sock.send_to(&packet, &addr).expect("send failed"); + + let zone_str = if zones.contains(&0) { "all zones".to_string() } else { format!("zones {:?}", zones) }; + println!("Sent stop command to {} at {}", zone_str, addr); + } + } +}
\ No newline at end of file |