From e2053f0d67c2c19e9847e6ddd85b8687f2787420 Mon Sep 17 00:00:00 2001 From: raven <7156279+RavenX8@users.noreply.github.com> Date: Fri, 28 Mar 2025 18:58:06 -0400 Subject: [PATCH 1/4] Code rewrite to make the code more modular. Fixed issue where newer firmware "20241226" wouldn't actually work correctly. Needs testing on older firmware to see if they still work --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/about.rs | 18 +- src/config.rs | 73 +++ src/device.rs | 267 +++++++++++ src/hid_worker.rs | 406 ++++++++++++++++ src/main.rs | 1126 +++++++-------------------------------------- src/state.rs | 7 + src/ui.rs | 561 ++++++++++++++++++++++ src/util.rs | 54 +++ 10 files changed, 1549 insertions(+), 967 deletions(-) create mode 100644 src/config.rs create mode 100644 src/device.rs create mode 100644 src/hid_worker.rs create mode 100644 src/state.rs create mode 100644 src/ui.rs create mode 100644 src/util.rs diff --git a/Cargo.lock b/Cargo.lock index d1ce19f..f55f798 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2710,7 +2710,7 @@ dependencies = [ [[package]] name = "shift_tool" -version = "0.3.0" +version = "0.4.0" dependencies = [ "clap", "dirs", diff --git a/Cargo.toml b/Cargo.toml index 2fc33d7..a81782e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "shift_tool" -version = "0.3.0" +version = "0.4.0" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/src/about.rs b/src/about.rs index 957fad6..c85d396 100644 --- a/src/about.rs +++ b/src/about.rs @@ -1,14 +1,14 @@ -pub fn about() -> [&'static str; 7] { - [ +pub fn about() -> Vec { + vec![ "This program was designed to replicate the VPC Shift Tool functions \ - bundled with the VirPil control software package.", - "\n", - "Shift Tool Copyright (C) 2024-2025 RavenX8", + bundled with the VirPil control software package.".to_string(), + "\n".to_string(), + "Shift Tool Copyright (C) 2024-2025 RavenX8".to_string(), "This program comes with ABSOLUTELY NO WARRANTY. This is free software, and you are welcome to redistribute it - under certain conditions.", - "License: GNU General Public License v3.0", - "Author: RavenX8", - "https://github.com/RavenX8/vpc-shift-tool", + under certain conditions.".to_string(), + "License: GNU General Public License v3.0".to_string(), + "Author: RavenX8".to_string(), + "https://github.com/RavenX8/vpc-shift-tool".to_string(), ] } \ No newline at end of file diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..2d41571 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,73 @@ +use serde::{Deserialize, Serialize}; +use std::ops::{Index, IndexMut}; + +// Configuration data saved to JSON +#[derive(Debug, Serialize, Deserialize)] +pub struct ConfigData { + #[serde(default)] // Ensure field exists even if missing in JSON + pub sources: Vec, + #[serde(default)] + pub receivers: Vec, + #[serde(default)] // Use default if missing + pub shift_modifiers: ModifiersArray, +} + +// Default values for a new configuration +impl Default for ConfigData { + fn default() -> Self { + Self { + sources: vec![], // Start with no sources configured + receivers: vec![], + shift_modifiers: ModifiersArray::default(), // Defaults to all OR + } + } +} + +// Enum for shift modifier logic +#[derive(Debug, Copy, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum ShiftModifiers { + OR = 0, + AND = 1, + XOR = 2, +} + +// How the modifier is displayed in the UI +impl std::fmt::Display for ShiftModifiers { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + ShiftModifiers::OR => write!(f, "OR"), + ShiftModifiers::AND => write!(f, "AND"), + ShiftModifiers::XOR => write!(f, "XOR"), + } + } +} + +// Wrapper for the array of modifiers to implement Default and Indexing +#[derive(Debug, Copy, Clone, Serialize, Deserialize)] +pub struct ModifiersArray { + data: [ShiftModifiers; 8], +} + +impl Default for ModifiersArray { + fn default() -> Self { + Self { + data: [ShiftModifiers::OR; 8], // Default to OR for all 8 bits + } + } +} + +// Allow indexing like `modifiers_array[i]` +impl Index for ModifiersArray { + type Output = ShiftModifiers; + + fn index(&self, index: usize) -> &ShiftModifiers { + &self.data[index] + } +} + +// Allow mutable indexing like `modifiers_array[i] = ...` +impl IndexMut for ModifiersArray { + fn index_mut(&mut self, index: usize) -> &mut ShiftModifiers { + &mut self.data[index] + } +} diff --git a/src/device.rs b/src/device.rs new file mode 100644 index 0000000..d9e94d3 --- /dev/null +++ b/src/device.rs @@ -0,0 +1,267 @@ +use hidapi::{DeviceInfo, HidApi, HidError}; +use log::{error, info, warn}; // Use log crate +use serde::{Deserialize, Serialize}; +use std::rc::Rc; // Keep Rc for potential sharing within UI if needed + +// Represents a discovered VPC device +#[derive(Debug, PartialEq, PartialOrd, Ord, Eq, Hash, Clone)] +pub struct VpcDevice { + pub full_name: String, // Combined identifier + pub name: Rc, // Product String + pub firmware: Rc, // Manufacturer String (often firmware version) + pub vendor_id: u16, + pub product_id: u16, + pub serial_number: String, + pub usage: u16, // HID usage page/id (less commonly needed for opening) + pub active: bool, // Is the worker thread currently connected? +} + +impl Default for VpcDevice { + fn default() -> Self { + Self { + full_name: String::from(""), + name: String::from("-NO CONNECTION (Select device from list)-").into(), + firmware: String::from("").into(), + vendor_id: 0, + product_id: 0, + serial_number: String::from(""), + usage: 0, + active: false, + } + } +} + +// How the device is displayed in dropdowns +impl std::fmt::Display for VpcDevice { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + if self.vendor_id == 0 && self.product_id == 0 { + // Default/placeholder entry + write!(f, "{}", self.name) + } else { + write!( + f, + "VID:{:04X} PID:{:04X} {} (SN:{} FW:{})", // More info + self.vendor_id, + self.product_id, + self.name, + if self.serial_number.is_empty() { "N/A" } else { &self.serial_number }, + if self.firmware.is_empty() { "N/A" } else { &self.firmware } + ) + } + } +} + +// Data structure for saving selected devices in config +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SavedDevice { + pub vendor_id: u16, + pub product_id: u16, + pub serial_number: String, + pub state_enabled: [bool; 8], // Which shift bits are active for this device +} + +impl Default for SavedDevice { + fn default() -> Self { + Self { + vendor_id: 0, + product_id: 0, + serial_number: String::from(""), + state_enabled: [true; 8], // Default to all enabled + } + } +} + +/// Finds the index in the `device_list` corresponding to the saved device data. +/// Returns 0 (default "No Connection") if not found or if saved_device is invalid. +// Make this function standalone or static, not requiring &self +pub(crate) fn find_device_index_for_saved( + device_list: &[VpcDevice], // Pass device list explicitly + saved_device: &SavedDevice, +) -> usize { + if saved_device.vendor_id == 0 && saved_device.product_id == 0 { + return 0; // Point to the default "No Connection" entry + } + device_list + .iter() + .position(|d| { + d.vendor_id == saved_device.vendor_id + && d.product_id == saved_device.product_id + && d.serial_number == saved_device.serial_number + }) + .unwrap_or(0) // Default to index 0 ("No Connection") if not found +} + + +// --- Device Management Functions --- + +// Now part of ShiftTool impl block +impl crate::ShiftTool { + /// Refreshes the internal list of available HID devices. + pub(crate) fn refresh_devices(&mut self) { + info!("Refreshing device list..."); + match HidApi::new() { + Ok(hidapi) => { + let mut current_devices: Vec = Vec::new(); + // Keep track of seen devices to avoid duplicates + // Use a HashSet for efficient checking + use std::collections::HashSet; + let mut seen_devices = HashSet::new(); + + for device_info in hidapi.device_list() { + // Filter for specific vendor if desired + if device_info.vendor_id() == crate::hid_worker::VENDOR_ID_FILTER { + if let Some(vpc_device) = + create_vpc_device_from_info(device_info) + { + // Create a unique key for the device + let device_key = ( + vpc_device.vendor_id, + vpc_device.product_id, + vpc_device.serial_number.clone(), + ); + + // Check if we've already added this unique device + if seen_devices.insert(device_key) { + // If insert returns true, it's a new device + if crate::util::is_supported( + vpc_device.firmware.to_string(), + ) { + info!("Found supported device: {}", vpc_device); + current_devices.push(vpc_device); + } else { + warn!( + "Found unsupported device (firmware?): {}", + vpc_device + ); + // Optionally add unsupported devices too, just filter later? + // current_devices.push(vpc_device); + } + } else { + // Device already seen (duplicate entry from hidapi) + log::trace!("Skipping duplicate device entry: {}", vpc_device); + } + } + } + } + + // Sort devices (e.g., by name) + current_devices.sort_by(|a, b| a.name.cmp(&b.name)); + + // Add the default "no connection" entry *after* sorting real devices + current_devices.insert(0, VpcDevice::default()); + + + // Update the app's device list + self.device_list = current_devices; + info!( + "Device list refresh complete. Found {} unique devices.", + self.device_list.len() - 1 // Exclude default entry + ); + + // Validate selected devices against the new, deduplicated list + self.validate_selected_devices(); + + } + Err(e) => { + error!("Failed to create HidApi for device refresh: {}", e); + } + } + } + + /// Finds the index in the `device_list` corresponding to the saved receiver config. + pub(crate) fn find_receiver_device_index(&self, receiver_config_index: usize) -> usize { + self.find_device_index_for_saved( + &self.config.data.receivers[receiver_config_index] + ) + } + + /// Finds the index in the `device_list` corresponding to the saved source config. + pub(crate) fn find_source_device_index(&self, source_config_index: usize) -> usize { + self.find_device_index_for_saved( + &self.config.data.sources[source_config_index] + ) + } + + /// Generic helper to find a device index based on SavedDevice data. + fn find_device_index_for_saved(&self, saved_device: &SavedDevice) -> usize { + if saved_device.vendor_id == 0 && saved_device.product_id == 0 { + return 0; // Point to the default "No Connection" entry + } + self.device_list + .iter() + .position(|d| { + d.vendor_id == saved_device.vendor_id + && d.product_id == saved_device.product_id + && d.serial_number == saved_device.serial_number + }) + .unwrap_or(0) // Default to index 0 ("No Connection") if not found + } + + /// Checks if saved source/receiver devices still exist in the refreshed list. + /// Resets the config entry to default if the device is gone. + fn validate_selected_devices(&mut self) { + let mut changed = false; + for i in 0..self.config.data.sources.len() { + let idx = self.find_source_device_index(i); + if idx == 0 && (self.config.data.sources[i].vendor_id != 0 || self.config.data.sources[i].product_id != 0) { + warn!("Previously selected source device {} not found after refresh. Resetting.", i + 1); + self.config.data.sources[i] = SavedDevice::default(); + changed = true; + } + } + for i in 0..self.config.data.receivers.len() { + let idx = self.find_receiver_device_index(i); + if idx == 0 && (self.config.data.receivers[i].vendor_id != 0 || self.config.data.receivers[i].product_id != 0) { + warn!("Previously selected receiver device {} not found after refresh. Resetting.", i + 1); + self.config.data.receivers[i] = SavedDevice::default(); + changed = true; + } + } + if changed { + // Optionally save the config immediately after validation changes + // if let Err(e) = self.config.save() { + // error!("Failed to save config after device validation: {}", e); + // } + } + } +} + + +/// Creates a VpcDevice from HidApi's DeviceInfo. +fn create_vpc_device_from_info(device_info: &DeviceInfo) -> Option { + // ... (same as before) + let vendor_id = device_info.vendor_id(); + let product_id = device_info.product_id(); + let name = device_info + .product_string() + .unwrap_or("Unknown Product") + .to_string(); + let firmware = device_info + .manufacturer_string() + .unwrap_or("Unknown Firmware") + .to_string(); + let serial_number = device_info.serial_number().unwrap_or("").to_string(); + let usage = device_info.usage(); + + if vendor_id == 0 || product_id == 0 || name == "Unknown Product" { + return None; + } + + let full_name = format!( + "{:04X}:{:04X}:{}", + vendor_id, + product_id, + if serial_number.is_empty() { "no_sn" } else { &serial_number } + ); + + Some(VpcDevice { + full_name, + name: name.into(), + firmware: firmware.into(), + vendor_id, + product_id, + serial_number, + usage, + active: false, + }) +} diff --git a/src/hid_worker.rs b/src/hid_worker.rs new file mode 100644 index 0000000..590b82b --- /dev/null +++ b/src/hid_worker.rs @@ -0,0 +1,406 @@ +use crate::config::{ModifiersArray}; +use crate::device::SavedDevice; +use crate::{SharedDeviceState, SharedStateFlag}; // Import shared types +use crate::util::{merge_u8_into_u16, read_bit, set_bit}; +use hidapi::{HidApi, HidDevice, HidError}; +use std::{ + sync::{Arc, Condvar, Mutex}, + thread, + time::Duration, +}; + +// Constants for HID communication +const FEATURE_REPORT_ID: u8 = 4; +const REPORT_BUFFER_SIZE: usize = 19; // 1 byte ID + 64 bytes data +pub const VENDOR_ID_FILTER: u16 = 0x3344; // Assuming Virpil VID +const WORKER_SLEEP_MS: u64 = 100; // Reduced sleep time for better responsiveness + +// Structure to hold data passed to the worker thread +// Clone Arcs for shared state, clone config data needed +struct WorkerData { + run_state: SharedStateFlag, + sources_config: Vec, + receivers_config: Vec, + shift_modifiers: ModifiersArray, + source_states_shared: Vec, + receiver_states_shared: Vec, + final_shift_state_shared: SharedDeviceState, +} + +// Main function to spawn the worker thread +// Now part of ShiftTool impl block +impl crate::ShiftTool { + pub(crate) fn spawn_worker(&mut self) -> bool { + log::info!("Attempting to spawn HID worker thread..."); + + // Clone data needed by the thread + let worker_data = WorkerData { + run_state: self.thread_state.clone(), + sources_config: self.config.data.sources.clone(), + receivers_config: self.config.data.receivers.clone(), + shift_modifiers: self.config.data.shift_modifiers, // Copy (it's Copy) + source_states_shared: self.source_states.clone(), + receiver_states_shared: self.receiver_states.clone(), + final_shift_state_shared: self.shift_state.clone(), + }; + + // Spawn the thread + thread::spawn(move || { + // Create HidApi instance *within* the thread + match HidApi::new() { // Use new() which enumerates internally + Ok(hidapi) => { + log::info!("HidApi created successfully in worker thread."); + // Filter devices *within* the thread if needed, though opening by VID/PID/SN is primary + // hidapi.add_devices(VENDOR_ID_FILTER, 0).ok(); // Optional filtering + + run_hid_worker_loop(hidapi, worker_data); + } + Err(e) => { + log::error!("Failed to create HidApi in worker thread: {}", e); + // How to signal failure back? Could use another shared state. + // For now, thread just exits. + } + } + }); + + log::info!("HID worker thread spawn initiated."); + true // Indicate spawn attempt was made + } + + // Cleanup actions when the worker is stopped from the UI + pub(crate) fn stop_worker_cleanup(&mut self) { + log::info!("Performing worker stop cleanup..."); + // Reset shared states displayed in the UI + let reset_state = |state_arc: &SharedDeviceState| { + if let Ok(mut state) = state_arc.lock() { + *state = 0; + } + // No need to notify condvar if only UI reads it + }; + + self.source_states.iter().for_each(reset_state); + self.receiver_states.iter().for_each(reset_state); + reset_state(&self.shift_state); + + // Mark all devices as inactive in the UI list + for device in self.device_list.iter_mut() { + device.active = false; + } + log::info!("Worker stop cleanup finished."); + } +} + + +// Helper to open devices, returns Result for better error handling +fn open_hid_devices( + hidapi: &HidApi, + device_configs: &[SavedDevice], +) -> Vec> { // Return Option to represent open failures + let mut devices = Vec::with_capacity(device_configs.len()); + for config in device_configs { + if config.vendor_id == 0 || config.product_id == 0 { + log::warn!("Skipping device with zero VID/PID in config."); + devices.push(None); // Placeholder for unconfigured/invalid device + continue; + } + match hidapi.open( + config.vendor_id, + config.product_id + ) { + Ok(device) => { + log::info!( + "Successfully opened device: VID={:04x}, PID={:04x}, SN={}", + config.vendor_id, config.product_id, config.serial_number + ); + // Set non-blocking mode + if let Err(e) = device.set_blocking_mode(false) { + log::error!("Failed to set non-blocking mode: {}", e); + // Decide if this is fatal for this device + devices.push(None); // Treat as failure if non-blocking fails + } else { + devices.push(Some(device)); + } + } + Err(e) => { + log::warn!( + "Failed to open device VID={:04x}, PID={:04x}, SN={}: {}", + config.vendor_id, config.product_id, config.serial_number, e + ); + devices.push(None); // Push None on failure + } + } + } + devices +} + + +// The core worker loop logic +fn run_hid_worker_loop(hidapi: HidApi, data: WorkerData) { + log::info!("HID worker loop starting."); + + // --- Device Opening --- + // Open sources and receivers, keeping track of which ones succeeded + let mut source_devices = open_hid_devices(&hidapi, &data.sources_config); + let mut receiver_devices = open_hid_devices(&hidapi, &data.receivers_config); + + // Buffers for HID reports + let mut read_buffer = [0u8; REPORT_BUFFER_SIZE]; + let mut write_buffer = [0u8; REPORT_BUFFER_SIZE]; // Buffer for calculated output + + let &(ref run_lock, ref run_cvar) = &*data.run_state; + + loop { + // --- Check Run State --- + let should_run = { // Scope for mutex guard + match run_lock.lock() { + Ok(guard) => *guard, + Err(poisoned) => { + log::error!("Run state mutex poisoned in worker loop!"); + false + } + } + }; + + if !should_run { + log::info!("Stop signal received, exiting worker loop."); + break; // Exit the loop + } + + // --- Read from Source Devices --- + let mut current_source_states: Vec> = vec![None; source_devices.len()]; + read_buffer[0] = FEATURE_REPORT_ID; // Set report ID for reading + + for (i, device_opt) in source_devices.iter_mut().enumerate() { + if let Some(device) = device_opt { + // Attempt to read feature report + match device.get_feature_report(&mut read_buffer) { + Ok(bytes_read) if bytes_read >= 3 => { // Need at least ID + 2 bytes data + let state_val = merge_u8_into_u16(read_buffer[1], read_buffer[2]); + current_source_states[i] = Some(state_val); + // Update shared state for UI + if let Some(shared_state) = data.source_states_shared.get(i) { + if let Ok(mut guard) = shared_state.lock() { + log::debug!("Worker: Updating source_states_shared[{}] from {} to {}", i, *guard, state_val); + *guard = state_val; + } + } + } + Ok(bytes_read) => { // Read ok, but not enough data? + log::warn!("Source {} read only {} bytes for report {}.", i, bytes_read, FEATURE_REPORT_ID); + current_source_states[i] = None; // Treat as no data + if let Some(shared_state) = data.source_states_shared.get(i) { + if let Ok(mut guard) = shared_state.lock() { *guard = 0; } // Reset UI state + } + } + Err(e) => { + log::warn!("Error reading from source {}: {}. Attempting reopen.", i, e); + current_source_states[i] = None; // Clear state on error + if let Some(shared_state) = data.source_states_shared.get(i) { + if let Ok(mut guard) = shared_state.lock() { *guard = 0; } // Reset UI state + } + // Attempt to reopen the device + *device_opt = hidapi.open( + data.sources_config[i].vendor_id, + data.sources_config[i].product_id + ).ok().and_then(|d| { d.set_blocking_mode(false).ok()?; Some(d) }); // Re-open and set non-blocking + + if device_opt.is_none() { + log::warn!("Reopen failed for source {}.", i); + } else { + log::info!("Reopen successful for source {}.", i); + } + } + } + } else { + // Device was not opened initially or failed reopen + current_source_states[i] = None; + if let Some(shared_state) = data.source_states_shared.get(i) { + if let Ok(mut guard) = shared_state.lock() { *guard = 0; } // Reset UI state + } + } + } + + // --- Calculate Final State based on Rules --- + let mut final_state: u16 = 0; + write_buffer.fill(0); // Clear write buffer + write_buffer[0] = FEATURE_REPORT_ID; + + for bit_pos in 0..8u8 { + let mut relevant_values: Vec = Vec::new(); + for (source_idx, state_opt) in current_source_states.iter().enumerate() { + // Check if this source is enabled for this bit + if data.sources_config[source_idx].state_enabled[bit_pos as usize] { + if let Some(state_val) = state_opt { + relevant_values.push(read_bit(*state_val, bit_pos)); + } else { + // How to handle missing data? Assume false? Or skip? + // Assuming false if device errored or didn't report + relevant_values.push(false); + } + } + } + + if !relevant_values.is_empty() { + let modifier = data.shift_modifiers[bit_pos as usize]; + let result_bit = match modifier { + crate::config::ShiftModifiers::OR => relevant_values.iter().any(|&v| v), + crate::config::ShiftModifiers::AND => relevant_values.iter().all(|&v| v), + crate::config::ShiftModifiers::XOR => relevant_values.iter().fold(false, |acc, &v| acc ^ v), + }; + + // Set the corresponding bit in the final state and write buffer + if result_bit { + final_state |= 1 << bit_pos; + // Assuming the state maps directly to bytes 1 and 2 + write_buffer[1] = final_state as u8; // Low byte + write_buffer[2] = (final_state >> 8) as u8; // High byte + } + } + } + + // Update shared final state for UI + if let Ok(mut guard) = data.final_shift_state_shared.lock() { + *guard = final_state; + } + + // --- Write to Receiver Devices --- + // We need to determine the correct length to send. Assuming report 4 is always ID + 2 bytes. + const bytes_to_send: usize = 19; // Report ID (1) + Data (2) + + let zero_buffer: [u8; bytes_to_send] = { + let mut buf = [0u8; bytes_to_send]; + buf[0] = FEATURE_REPORT_ID; // Set Report ID 4 + // All other bytes (1-18) remain 0 for the zero state + buf + }; + + for (i, device_opt) in receiver_devices.iter_mut().enumerate() { + if let Some(device) = device_opt { + match device.send_feature_report(&zero_buffer) { + Ok(_) => { + // Create a temporary buffer potentially filtered by receiver's enabled bits + let mut filtered_write_buffer = write_buffer; // Copy base calculated state + let mut filtered_final_state = final_state; + + // Apply receiver's enabled mask + for bit_pos in 0..8u8 { + if !data.receivers_config[i].state_enabled[bit_pos as usize] { + // If this bit is disabled for this receiver, force it to 0 + filtered_final_state &= !(1 << bit_pos); + } + } + // Update buffer bytes based on filtered state + filtered_write_buffer[1] = (filtered_final_state >> 8) as u8; + filtered_write_buffer[2] = filtered_final_state as u8; + filtered_write_buffer[3..19].fill(0); + + + // --- Optional: Read receiver's current state and merge --- + // This part makes it more complex. If you want the output to *combine* + // with the receiver's own state, you'd read it first. + // For simplicity, let's just *set* the state based on calculation. + // If merging is needed, uncomment and adapt: + read_buffer[0] = FEATURE_REPORT_ID; + if let Ok(bytes) = device.get_feature_report(&mut read_buffer) { + if bytes >= 3 { + let receiver_current_low = read_buffer[1]; + let receiver_current_high = read_buffer[2]; + // Merge logic here, e.g., ORing the states + filtered_write_buffer[1] |= receiver_current_low; + filtered_write_buffer[2] |= receiver_current_high; + } + } + // --- End Optional Merge --- + + log::debug!( + "Worker: Attempting send to receiver[{}], state: {}, buffer ({} bytes): {:02X?}", + i, + filtered_final_state, + 19, // Log the length being sent + &filtered_write_buffer[0..19] // Log the full slice + ); + + + // Send the potentially filtered feature report + match device.send_feature_report(&filtered_write_buffer[0..bytes_to_send]) { + Ok(_) => { + log::debug!("Worker: Send to receiver[{}] successful.", i); + // Successfully sent. Update UI state for this receiver. + if let Some(shared_state) = data.receiver_states_shared.get(i) { + if let Ok(mut guard) = shared_state.lock() { + // Update with the state *we sent* + let state_val = merge_u8_into_u16(filtered_write_buffer[1], filtered_write_buffer[2]); + log::debug!("Worker: Updating receiver_states_shared[{}] from {} to {}", i, *guard, state_val); + *guard = state_val; + } + } + } + Err(e) => { + log::warn!("Error writing to receiver {}: {}. Attempting reopen.", i, e); + if let Some(shared_state) = data.receiver_states_shared.get(i) { + if let Ok(mut guard) = shared_state.lock() { *guard = 0; } // Reset UI state + } + // Attempt to reopen + *device_opt = hidapi.open( + data.receivers_config[i].vendor_id, + data.receivers_config[i].product_id, + ).ok().and_then(|d| { + d.set_blocking_mode(false).ok()?; + Some(d) + }); + + if device_opt.is_none() { + log::warn!("Reopen failed for receiver {}.", i); + } else { + log::info!("Reopen successful for receiver {}.", i); + } + } + } + } + Err(e_zero) => { + // Handle error sending the zero state reset + log::warn!("Worker: Error sending zero state reset to receiver[{}]: {:?}", i, e_zero); + // Reset UI state, attempt reopen + if let Some(shared_state) = data.receiver_states_shared.get(i) { + if let Ok(mut guard) = shared_state.lock() { *guard = 0; } + } + log::debug!("Worker: Attempting to reopen receiver[{}] after zero-send failure...", i); + *device_opt = hidapi.open( data.receivers_config[i].vendor_id, + data.receivers_config[i].product_id + ).ok().and_then(|d| { + d.set_blocking_mode(false).ok()?; + Some(d) + }); + if device_opt.is_none() { + log::warn!("Reopen failed for receiver {}.", i); + } else { + log::info!("Reopen successful for receiver {}.", i); + } + } // End Err for zero send + } + } else { + // Device not open, reset UI state + if let Some(shared_state) = data.receiver_states_shared.get(i) { + if let Ok(mut guard) = shared_state.lock() { *guard = 0; } + } + } + } + + // --- Sleep --- + thread::sleep(Duration::from_millis(WORKER_SLEEP_MS)); + } // End loop + + // --- Cleanup before thread exit --- + log::info!("Worker loop finished. Performing cleanup..."); + // Optionally send a 'zero' report to all devices on exit + let cleanup_buffer: [u8; 3] = [FEATURE_REPORT_ID, 0, 0]; + for device_opt in source_devices.iter_mut().chain(receiver_devices.iter_mut()) { + if let Some(device) = device_opt { + if let Err(e) = device.send_feature_report(&cleanup_buffer) { + log::warn!("Error sending cleanup report: {}", e); + } + } + } + log::info!("Worker thread cleanup complete. Exiting."); + // HidApi and HidDevices are dropped automatically here, closing handles. +} diff --git a/src/main.rs b/src/main.rs index 1922053..059499b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,1019 +1,233 @@ -#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // hide console window on Windows in release -// hide console window on Windows in release -extern crate hidapi; +// Declare modules +mod about; +mod config; +mod device; +mod hid_worker; +mod state; +mod ui; +mod util; -use std::ops::{Index, IndexMut}; -use std::rc::Rc; +use std::process::exit; +// External Crate Imports (only those needed directly in main.rs) +use eframe::{egui, glow}; +use fast_config::Config; use std::sync::{Arc, Condvar, Mutex}; -use std::thread; use std::time::Duration; -use clap::Parser; -use eframe::{egui, glow}; -use eframe::egui::{Color32, Context, Ui}; -use fast_config::Config; -use hidapi::{DeviceInfo, HidApi}; -#[cfg(feature = "logging")] -use log::{debug, error, info, trace}; -use serde::{Deserialize, Serialize}; - -mod about; +// Internal Module Imports +use config::{ConfigData}; // Import specific items +use device::{VpcDevice, SavedDevice}; +use state::State; // Import the State enum +// Constants const PROGRAM_TITLE: &str = "OpenVPC - Shift Tool"; -const INITIAL_WIDTH: f32 = 720.0; +const INITIAL_WIDTH: f32 = 740.0; const INITIAL_HEIGHT: f32 = 260.0; -const DISABLED_COLOR: Color32 = Color32::from_rgb(255, 0, 0); +// Type aliases for shared state can make signatures cleaner +pub type SharedStateFlag = Arc<(Mutex, Condvar)>; +pub type SharedDeviceState = Arc>; // Assuming Condvar isn't strictly needed here -#[derive(Copy, Clone, PartialEq)] -enum State { - About, - Initialising, - Running, -} - -#[derive(Parser, Debug)] -#[command(version, about, long_about = None)] -struct Args { - /// Allows all firmware versions - #[arg(short, long, default_value_t = false)] - skip_firmware: bool, -} - -fn main() -> eframe::Result<()> { - env_logger::init(); // Log to stderr (if you run with `RUST_LOG=debug`). - let _ = Args::parse(); - - #[cfg(feature = "logging")] { - info!("Creating main window..."); - } - - let options = eframe::NativeOptions { - viewport: egui::ViewportBuilder::default() - .with_inner_size([INITIAL_WIDTH, INITIAL_HEIGHT]), - ..Default::default() - }; - - eframe::run_native( - PROGRAM_TITLE, - options, - Box::new(|_cc| Ok(Box::new(ShiftTool::default()))), - ) -} - -fn read_bit(value: u16, position: u8) -> bool { - (value & (1 << position)) != 0 -} - -fn set_bit(value: u8, bit_position: u8, bit_value: bool) -> u8 { - if bit_value { - value | (1 << bit_position) // Set the bit to 1 - } else { - value & !(1 << bit_position) // Set the bit to 0 - } -} - -fn merge_u8_into_u16(high: u8, low: u8) -> u16 { - // Shift `high` to the left by 8 bits and combine it with `low` - let merged = (high as u16) << 8 | (low as u16); - merged -} - -fn is_supported(_input: String) -> bool { - true - // let args = Args::parse(); - // if args.skip_firmware { - // return true; - // } - // - // let fixed_list = vec![ - // String::from("VIRPIL Controls 20220720"), - // String::from("VIRPIL Controls 20230328"), - // String::from("VIRPIL Controls 20240323"), - // ]; - // - // fixed_list.contains(&input) -} - -fn calculate_full_device_name(device_info: &DeviceInfo) -> String { - let full_name = format!("{:04x}:{:04x}:{}:{}", device_info.vendor_id(), device_info.product_id(), device_info.serial_number().unwrap_or_default(), device_info.usage()); - full_name -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct SavedDevice { - vendor_id: u16, - product_id: u16, - serial_number: String, - state_enabled: [bool; 8], -} - -impl Default for SavedDevice { - fn default() -> Self { - Self { - vendor_id: 0, - product_id: 0, - serial_number: String::from(""), - state_enabled: [true; 8], - } - } -} - -#[derive(Debug, Copy, Clone, Serialize, Deserialize)] -enum ShiftModifiers { - OR = 0, - AND = 1, - XOR = 2, -} - -impl std::fmt::Display for ShiftModifiers { - fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - let text = match self { - ShiftModifiers::OR => "OR", - ShiftModifiers::AND => "AND", - ShiftModifiers::XOR => "XOR", - }; - write!(f, "{}", text) - } -} - -#[derive(Debug, Copy, Clone, Serialize, Deserialize)] -struct ModifiersArray { - data: [ShiftModifiers; 8], -} - -impl Index for ModifiersArray { - type Output = ShiftModifiers; - - fn index(&self, index: usize) -> &ShiftModifiers { - &self.data[index] - } -} - -impl IndexMut for ModifiersArray { - fn index_mut(&mut self, index: usize) -> &mut ShiftModifiers { - &mut self.data[index] - } -} - -#[derive(Debug, PartialEq, PartialOrd, Ord, Eq, Hash, Clone)] -struct VpcDevice { - full_name: String, - name: Rc, - firmware: Rc, - vendor_id: u16, - product_id: u16, - serial_number: String, - usage: u16, - active: bool, -} - -impl Default for VpcDevice { - fn default() -> Self { - Self { - full_name: String::from("").into(), - name: String::from("-NO CONNECTION (Select device from the list)-").into(), - firmware: String::from("").into(), - vendor_id: 0, - product_id: 0, - serial_number: String::from(""), - usage: 0, - active: false, - } - } -} - -impl std::fmt::Display for VpcDevice { - fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - if self.vendor_id == 0 && self.product_id == 0 { - write!(f, "{}", self.name) - } else { - write!(f, "VID: {:04x} PID: {:04x} {} ({})", self.vendor_id, self.product_id, self.name, self.firmware) - } - } -} - -#[derive(Serialize, Deserialize)] -struct ConfigData { - sources: Vec, - receivers: Vec, - shift_modifiers: ModifiersArray, -} - -impl Default for ConfigData { - fn default() -> Self { - Self { - sources: vec![], - receivers: vec![], - shift_modifiers: ModifiersArray { data: [ShiftModifiers::OR; 8] }, - } - } -} - -struct ShiftTool { +// The main application struct +pub struct ShiftTool { + // State state: State, - device_list: Vec, - source_states: Vec, Condvar)>>, - receiver_states: Vec, Condvar)>>, - shift_state: Arc<(Mutex, Condvar)>, - thread_state: Arc<(Mutex, Condvar)>, + thread_state: SharedStateFlag, // Is the worker thread running? + + // Device Data + device_list: Vec, // List of discovered compatible devices + + // Shared state between UI and Worker Thread + source_states: Vec, // Current reported state per source + receiver_states: Vec, // Current reported state per receiver + shift_state: SharedDeviceState, // Combined/calculated shift state + + // Configuration config: Config, } impl Default for ShiftTool { fn default() -> Self { + // Determine config path safely + let config_dir = dirs::config_dir() + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_else(|| ".".to_string()); // Fallback to current dir + let config_path = format!("{}/shift_tool.json", config_dir); + + // Handle potential config creation error + let config = match Config::new(&config_path, ConfigData::default()) { + Ok(cfg) => cfg, + Err(e) => { + // Log the error appropriately + eprintln!("Error creating config file at {}: {}", config_path, e); + // Fallback to default in-memory config? Or panic? + // Using default() here might lead to data loss if file exists but is broken. + // For simplicity here, we proceed with default, but real app might need better handling. + exit(1) + } + }; + Self { state: State::Initialising, device_list: vec![], source_states: vec![], receiver_states: vec![], - shift_state: Arc::new((Mutex::new(0), Condvar::new())), + shift_state: Arc::new((Mutex::new(0))), // Keep Condvar if needed for shift_state? thread_state: Arc::new((Mutex::new(false), Condvar::new())), - config: Config::new(format!("{}/shift_tool.json", dirs::config_dir().unwrap_or_default().to_str().unwrap_or_default()), ConfigData::default()).unwrap(), + config, } } } +// Implementations specific to App lifecycle and top-level control impl ShiftTool { - fn about_screen(&mut self, ui: &mut Ui) { - ui.set_width(INITIAL_WIDTH); - ui.vertical(|ui| { - ui.horizontal(|ui| { - ui.centered_and_justified(|ui| { - let about = "About "; - ui.heading(format!("{}{}", about, PROGRAM_TITLE)); - }); - }); - for line in about::about() { - ui.horizontal(|ui| { - ui.centered_and_justified(|ui| { - ui.add(egui::Label::new(line)); - }); - }); - } - - ui.horizontal(|ui| { - let blank: String = " ".to_string(); - ui.centered_and_justified(|ui| { - ui.label(&blank); - if ui.button("OK") - .clicked() { - self.state = State::Running; - } - ui.label(&blank); - }); - }); - }); - } - + // Initialization logic called once at the start fn init(&mut self) { - // Do some init stuff here - let hidapi = HidApi::new_without_enumerate().expect("Was unable to open hid instance"); - self.refresh_devices(hidapi); + // Load config and populate initial sources/receivers based on config + // The config is already loaded in Default::default() + let num_sources = self.config.data.sources.len(); + let num_receivers = self.config.data.receivers.len(); - // Load config here and insert the prev sources/receivers - for _i in 0..self.config.data.sources.len() { - self.add_source(); + for _ in 0..num_sources { + self.add_source_state(); // Add state tracking + } + for _ in 0..num_receivers { + self.add_receiver_state(); // Add state tracking } - for _i in 0..self.config.data.receivers.len() { - self.add_receiver(); - } + // Initial device scan + self.refresh_devices(); // Now calls the method defined in device.rs self.state = State::Running; + log::info!("Initialization complete. State set to Running."); } - fn find_receiver_device_index(&mut self, i: usize) -> usize { - let device_idx = self.device_list.iter().position(|r| r.vendor_id == self.config.data.receivers[i].vendor_id - && r.product_id == self.config.data.receivers[i].product_id - && r.serial_number == self.config.data.receivers[i].serial_number - ).unwrap_or_default(); - device_idx + // Helper to add state tracking for a new source + fn add_source_state(&mut self) { + self.source_states + .push(Arc::new((Mutex::new(0)))); } - fn find_source_device_index(&mut self, i: usize) -> usize { - let device_idx = self.device_list.iter().position(|r| r.vendor_id == self.config.data.sources[i].vendor_id - && r.product_id == self.config.data.sources[i].product_id - && r.serial_number == self.config.data.sources[i].serial_number - ).unwrap_or_default(); - device_idx + // Helper to add state tracking for a new receiver + fn add_receiver_state(&mut self) { + self.receiver_states + .push(Arc::new((Mutex::new(0)))); } - fn refresh_devices(&mut self, mut hidapi: HidApi) { - match hidapi.reset_devices() { - Ok(_api) => {} - Err(e) => { - error!("Error: {}", e); - } - }; - match hidapi.add_devices(0x3344, 0) { - Ok(_api) => {} - Err(e) => { - error!("Error: {}", e); - } - } - - - let mut old_devices: Vec = vec![]; - let mut new_devices: Vec = vec![]; - for device in hidapi.device_list() { - let full_name = calculate_full_device_name(device); - #[cfg(feature = "logging")] { - info!("{}", full_name ); - } - let firmware_string = device.manufacturer_string().unwrap_or_else(|| ""); - let device_name = device.product_string().unwrap_or_else(|| ""); - let serial = device.serial_number().unwrap_or_default(); - let mut found = 0; - if !self.device_list.is_empty() { - found = self.device_list.iter().position(|r| r.vendor_id == device.vendor_id() - && r.product_id == device.product_id() - && r.serial_number == serial.to_string() - ).unwrap_or_default(); - } - if found != 0 { - old_devices.push(self.device_list[found].clone()); - continue; - } - - if is_supported(firmware_string.to_owned().into()) { - new_devices.push(VpcDevice { - full_name: full_name.to_string(), - name: device_name.to_owned().into(), - firmware: firmware_string.to_owned().into(), - vendor_id: device.vendor_id(), - product_id: device.product_id(), - serial_number: serial.to_string(), - usage: device.usage(), - active: false, - }); - } - } - for device_idx in 0..self.device_list.len() { - let device = &self.device_list[device_idx]; - let found = old_devices.iter().any(|r| r.vendor_id == device.vendor_id - && r.product_id == device.product_id - && r.serial_number == device.serial_number - ); - self.device_list[device_idx].active = found; - } - - if !new_devices.is_empty() { - if !self.device_list.is_empty() { - self.device_list.remove(0); - } - - let mut merged_list = self.device_list.clone(); - merged_list.extend(new_devices.into_iter()); - - // product_id is the only unique value from the device - merged_list.sort_by(|a, b| a.product_id.cmp(&b.product_id)); - merged_list.dedup_by(|a, b| a.product_id.eq(&b.product_id)); - - merged_list.insert(0, VpcDevice::default()); - self.device_list = merged_list.clone(); - } - - if self.device_list.is_empty() { - self.device_list.insert(0, VpcDevice::default()); - } - } - - fn spawn_worker(&mut self) -> bool { - let reference_to_self = self; - let shared_shift_state = reference_to_self.source_states.clone(); - let shared_receiver_shift_state = reference_to_self.receiver_states.clone(); - let shared_shift_modifiers = reference_to_self.config.data.shift_modifiers.clone(); - let shared_sources = reference_to_self.config.data.sources.clone(); - let shared_receivers = reference_to_self.config.data.receivers.clone(); - let shared_final_shift_state = reference_to_self.shift_state.clone(); - let shared_run_state = reference_to_self.thread_state.clone(); - let mut hidapi = HidApi::new_without_enumerate().expect("Was unable to open hid instance"); - - // Clear out our device list - match hidapi.reset_devices() { - Ok(_api) => {} - Err(e) => { - #[cfg(feature = "logging")] { - error!("Error: {}", e); - } - } - }; - // Grab all virpil devices - match hidapi.add_devices(0x3344, 0) { - Ok(_api) => { - #[cfg(feature = "logging")] { - debug!("Got devices"); - } - } - Err(e) => { - #[cfg(feature = "logging")] { - error!("Error: {}", e); - } - } - } - - // Attempt to open the source device - let mut source_devices = vec![]; - for i in 0..shared_sources.len() { - let source_device = reference_to_self.find_source_device_index(i); - // product_id is the only unique value from the device - let hid_device = match hidapi.open_serial(reference_to_self.device_list[source_device].vendor_id, reference_to_self.device_list[source_device].product_id, &*reference_to_self.device_list[source_device].serial_number) { - Ok(device) => device, - Err(_) => { - reference_to_self.device_list[source_device].active = false; - continue; - } - }; - reference_to_self.device_list[source_device].active = true; - hid_device.set_blocking_mode(false).expect("unable to set receiver blocking mode"); - source_devices.push(hid_device); - } - - // List of device handles for the thread to write to - let mut receiver_devices = vec![]; - // Loop for all receiver devices and open a handle to them - for i in 0..reference_to_self.config.data.receivers.len() { - let receiver_device = reference_to_self.find_receiver_device_index(i); - // product_id is the only unique value from the device - let hid_device = match hidapi.open_serial(reference_to_self.device_list[receiver_device].vendor_id, reference_to_self.device_list[receiver_device].product_id, &*reference_to_self.device_list[receiver_device].serial_number) { - Ok(device) => device, - Err(_) => { - reference_to_self.device_list[receiver_device].active = false; - continue; - } - }; - reference_to_self.device_list[receiver_device].active = true; - hid_device.set_blocking_mode(false).expect("unable to set receiver blocking mode"); - receiver_devices.push(hid_device); - } - - - #[cfg(feature = "logging")] { - trace!("Launching Thread..."); - } - thread::spawn(move || { - // Shift modes only return 2 bytes. - // byte[0] = report id - // byte[1..2]: u16 = shift mode. each bit rep each mode - // It might be possible to have multiple modes set at the same time, but I'm not sure - let mut buf: [u8; 3] = [0; 3]; - let mut finalbuf: [u8; 3] = [0; 3]; - let &(ref lock, ref _cvar) = &*shared_run_state; - - loop { - let mut shift_states: Vec = vec![]; - shift_states.resize(source_devices.len(), 0); - // Make sure we grab the lock in this small context here - { - // Attempt to acquire the lock - let started = match lock.try_lock() { - Ok(guard) => guard, - Err(_) => continue, // Retry if the lock couldn't be acquired immediately - }; - - // Check if the thread should exit - if !*started { - // Clear all device states here before closing - buf = [0x04, 0x00, 0x00]; - for device in &source_devices { - match device.send_feature_report(&mut buf) { - Ok(bytes_written) => bytes_written, - Err(_e) => continue, - }; - } - for device in &receiver_devices { - match device.send_feature_report(&mut buf) { - Ok(bytes_written) => bytes_written, - Err(_e) => continue, - }; - } - break; // Exit the loop if the variable is false - } - } - // Now that we unlocked the lock we can continue working - - // Do some work - buf[0] = 0x04; - finalbuf[0] = 0x04; - let mut index = 0; - for device in &mut source_devices { - buf[1] = 0; - buf[2] = 0; - // Reset the device state, this is so we can get a fresh REAL device state - match device.send_feature_report(&mut buf) { - Ok(bytes_written) => bytes_written, - Err(_e) => { - let hid_device = match hidapi.open_serial(shared_sources[index].vendor_id, shared_sources[index].product_id, &*shared_sources[index].serial_number) { - Ok(device) => device, - Err(_) => { - shift_states[index] = 0; - continue; - } - }; - // We were able to reopen the device so store it - let _ = std::mem::replace(device, hid_device); - continue; - } - }; - - // Get the REAL device state - match device.get_feature_report(&mut buf) { - Ok(bytes_written) => bytes_written, - Err(_e) => { - shift_states[index] = 0; - continue; - } - }; - - { - // notify the main gui of the current shift state. - // This is only to show the current state on the GUI. - let &(ref lock2, ref _cvar2) = &*shared_shift_state[index]; - // Attempt to acquire the lock - let mut shift = match lock2.try_lock() { - Ok(guard) => guard, - Err(_) => continue, // Retry if the lock couldn't be acquired immediately - }; - shift_states[index] = merge_u8_into_u16(buf[2], buf[1]); - *shift = shift_states[index]; - } - index = index + 1; - } - - for i in 0..8 { - let mut values: Vec = vec![]; - for j in 0..shift_states.len() { - if (&shared_sources[j].state_enabled)[>::into(i)] == true { - values.push(read_bit(shift_states[j], i)); - } - } - - let modifier = shared_shift_modifiers[i.into()]; - let final_value = match modifier { - ShiftModifiers::OR => values.iter().fold(false, |acc, &x| acc | x), - ShiftModifiers::AND => values.iter().fold(true, |acc, &x| acc & x), - ShiftModifiers::XOR => values.iter().fold(false, |acc, &x| acc ^ x), - }; - finalbuf[1] = set_bit(finalbuf[1], i, final_value); - } - - { - // notify the main gui of the current shift state. - // This is only to show the current state on the GUI. - let &(ref lock2, ref _cvar2) = &*shared_final_shift_state; - // Attempt to acquire the lock - let mut shift = match lock2.try_lock() { - Ok(guard) => guard, - Err(_) => continue, // Retry if the lock couldn't be acquired immediately - }; - *shift = merge_u8_into_u16(finalbuf[2], finalbuf[1]); - } - - // println!("Sending data..."); - // This sends the shift data read from the source device directly to the receivers - index = 0; // Reset the device index - for device in &mut receiver_devices { - let mut final_temp_buf: [u8; 3] = finalbuf.clone(); - - buf[1] = 0; - buf[2] = 0; - // Reset the device state, this is so we can get a fresh REAL device state - match device.send_feature_report(&mut buf) { - Ok(bytes_written) => bytes_written, - Err(_e) => { - let hid_device = match hidapi.open_serial(shared_receivers[index].vendor_id, shared_receivers[index].product_id, &*shared_receivers[index].serial_number) { - Ok(device) => device, - Err(_) => continue, - }; - - // We were able to reopen the device so store it - let _ = std::mem::replace(device, hid_device); - continue; - } - }; - - // Filter the final state for this device - for i in 0..8 { - if (&shared_receivers[index].state_enabled)[>::into(i)] == false { - final_temp_buf[1] = set_bit(final_temp_buf[1], i, false); - } - } - - // Get the devices own REAL shift state - match device.get_feature_report(&mut buf) { - Ok(bytes_written) => bytes_written, - Err(_e) => { - continue; - } - }; - - // Merge the real state and the final sent state together - final_temp_buf[1] |= buf[1]; - - // Send the new state to the device - match device.send_feature_report(&mut final_temp_buf) { - Ok(bytes_written) => bytes_written, - Err(_e) => continue, - }; - - buf[1] = 0; - buf[2] = 0; - // Get the devices new shift state - match device.get_feature_report(&mut buf) { - Ok(bytes_written) => bytes_written, - Err(_e) => { - continue; - } - }; - - { - // notify the main gui of the current shift state. - // This is only to show the current state on the GUI. - let &(ref lock2, ref _cvar2) = &*shared_receiver_shift_state[index]; - // Attempt to acquire the lock - let mut shift = match lock2.try_lock() { - Ok(guard) => guard, - Err(_) => continue, // Retry if the lock couldn't be acquired immediately - }; - *shift = merge_u8_into_u16(buf[2], buf[1]); - } - - // Go to the next device state index - index = index + 1; - } - - // Make sure we clear the buffer - finalbuf[1] = 0; - finalbuf[2] = 0; - - // Sleep for 200 milliseconds - thread::sleep(Duration::from_millis(200)); - } - #[cfg(feature = "logging")] { - trace!("Exiting Thread..."); - } - }); - return true; - } - - fn run(&mut self, ui: &mut Ui, ctx: &Context) { - let thread_running = self.get_thread_status(); - let hidapi = HidApi::new_without_enumerate().expect("Was unable to open hid instance"); - self.refresh_devices(hidapi); - - if self.config.data.sources.len() == 0 { - self.add_source(); - } - - ui.columns( - 2, - |columns| { - self.create_source_dropdown(thread_running, columns); - self.create_result_state_visual(thread_running, columns); - self.create_receiver_dropdown(thread_running, columns); - self.create_control_buttons(ctx, thread_running, columns); - }, - ); - } - - fn get_thread_status(&mut self) -> bool { - { - { - // Check to see if the worker thread is running - let &(ref lock, ref _cvar) = &*self.thread_state; - *(lock.lock().unwrap()) + // Helper to get thread status (could be in ui.rs or main.rs) + fn get_thread_status(&self) -> bool { + match self.thread_state.0.lock() { + Ok(guard) => *guard, + Err(poisoned) => { + log::error!("Thread state mutex poisoned!"); + **poisoned.get_ref() // Still try to get the value } } } - fn create_source_dropdown(&mut self, thread_running: bool, columns: &mut [Ui]) { - for i in 0..self.config.data.sources.len() { - let source_device = self.find_source_device_index(i); - columns[0].horizontal(|ui| { - let _ = ui.label(format!("Source {}", i + 1)); - egui::ComboBox::from_id_salt(format!("Source {}", i + 1)) - .width(500.0) - .selected_text(format!("{}", &self.device_list[source_device])) - .show_ui(ui, |ui| { - for j in 0..self.device_list.len() { - let value = ui.selectable_value(&mut &self.device_list[j].full_name, &self.device_list[source_device].full_name, format!("{}", self.device_list[j])); - if value.clicked() && !thread_running { - self.config.data.sources[i].vendor_id = self.device_list[j].vendor_id; - self.config.data.sources[i].product_id = self.device_list[j].product_id; - self.config.data.sources[i].serial_number = self.device_list[j].serial_number.clone(); - } - } - }); - }); - self.create_status_data(thread_running, columns, i, source_device, false); - columns[0].separator(); - } - } - - fn create_result_state_visual(&mut self, thread_running: bool, columns: &mut [Ui]) { - columns[0].horizontal(|ui| { - let _ = ui.label("Rules:"); - for j in 0..8 { - let rule = self.config.data.shift_modifiers[j]; - if ui.selectable_label(false, format!("{}", rule)).clicked() && !thread_running { - self.config.data.shift_modifiers[j] = match self.config.data.shift_modifiers[j] { - ShiftModifiers::OR => ShiftModifiers::AND, - ShiftModifiers::AND => ShiftModifiers::XOR, - ShiftModifiers::XOR => ShiftModifiers::OR, - } - } - } - }); - columns[0].horizontal(|ui| { - let state; - { - let &(ref lock2, ref _cvar) = &*self.shift_state; - state = *(lock2.lock().unwrap()); - } - - let _ = ui.label("Result:"); - for j in 0..5 { - let shift = read_bit(state, j); - let _ = ui.selectable_label(shift, format!("{}", j + 1)); - } - - // I'm not sure which of these 3 bits rep which mode - let dtnt = read_bit(state, 5); - let zoom = read_bit(state, 6); - let trim = read_bit(state, 7); - - let _ = ui.selectable_label(dtnt, "DTNT"); - let _ = ui.selectable_label(zoom, "ZOOM"); - let _ = ui.selectable_label(trim, "TRIM"); - }); - columns[0].separator(); - } - - fn create_receiver_dropdown(&mut self, thread_running: bool, columns: &mut [Ui]) { - for i in 0..self.config.data.receivers.len() { - let receiver_device = self.find_receiver_device_index(i); - columns[0].horizontal(|ui| { - egui::ComboBox::from_id_salt(i) - .width(500.0) - .selected_text(format!("{}", &self.device_list[receiver_device])) - .show_ui(ui, |ui| { - for j in 0..self.device_list.len() { - let value = ui.selectable_value(&mut &self.device_list[j].full_name, &self.device_list[receiver_device].full_name, format!("{}", self.device_list[j])); - if value.clicked() && !thread_running { - self.config.data.receivers[i].vendor_id = self.device_list[j].vendor_id; - self.config.data.receivers[i].product_id = self.device_list[j].product_id; - self.config.data.receivers[i].serial_number = self.device_list[j].serial_number.clone(); - } - } - }); - }); - self.create_status_data(thread_running, columns, i, receiver_device, true); - } - } - - fn create_status_data(&mut self, thread_running: bool, columns: &mut [Ui], i: usize, device_index: usize, is_receiver: bool) { - columns[0].horizontal(|ui| { - let state; - - let _ = ui.label("Shift: "); - let mut color; - - let device = if is_receiver { &mut self.config.data.receivers[i] } else { &mut self.config.data.sources[i] }; - - if is_receiver { - { - let &(ref lock2, ref _cvar) = &*self.receiver_states[i]; - state = *(lock2.lock().unwrap()); - } - } else { - { - let &(ref lock2, ref _cvar) = &*self.source_states[i]; - state = *(lock2.lock().unwrap()); - } - } - - for j in 0..5 { - let shift = read_bit(state, j); - - color = egui::Color32::default(); - if device.state_enabled[>::into(j)] == false { - color = DISABLED_COLOR; - } - if ui.selectable_label(shift, egui::RichText::new(format!("{}", j + 1)).background_color(color)).clicked() && !thread_running { - device.state_enabled[>::into(j)] = !device.state_enabled[>::into(j)]; - }; - } - - // I'm not sure which of these 3 bits rep which mode - let dtnt = read_bit(state, 5); - let zoom = read_bit(state, 6); - let trim = read_bit(state, 7); - - color = egui::Color32::default(); - if device.state_enabled[5] == false { - color = DISABLED_COLOR; - } - if ui.selectable_label(dtnt, egui::RichText::new("DTNT").background_color(color)).clicked() && !thread_running { - device.state_enabled[5] = !device.state_enabled[5]; - } - - color = egui::Color32::default(); - if device.state_enabled[6] == false { - color = DISABLED_COLOR; - } - if ui.selectable_label(zoom, egui::RichText::new("ZOOM").background_color(color)).clicked() && !thread_running { - device.state_enabled[6] = !device.state_enabled[6]; - } - - color = egui::Color32::default(); - if device.state_enabled[7] == false { - color = DISABLED_COLOR; - } - if ui.selectable_label(trim, egui::RichText::new("TRIM").background_color(color)).clicked() && !thread_running { - device.state_enabled[7] = !device.state_enabled[7]; - } - - let active = self.device_list[device_index].active && thread_running; - let mut active_text = "OFFLINE"; - if active == true { - active_text = "ONLINE"; - } - let _ = ui.selectable_label(active, active_text); - }); - } - - fn create_control_buttons(&mut self, ctx: &Context, thread_running: bool, columns: &mut [Ui]) { - columns[1].vertical(|ui| { - let mut color = egui::Color32::default(); - let mut start_stop_button_text = "Start"; - if thread_running { - start_stop_button_text = "Stop"; - color = DISABLED_COLOR; - } - if ui.button(egui::RichText::new(start_stop_button_text).background_color(color)) - .clicked() { - // Don't do anything if we didn't select a source and receiver - if self.config.data.sources.len() == 0 || self.config.data.receivers.len() == 0 { - return; - } - - #[cfg(feature = "logging")] { - trace!("Toggling run thread..."); - } - let is_started; - { - let &(ref lock, ref cvar) = &*self.thread_state; - let mut started = lock.lock().unwrap(); - is_started = *started; - *started = !*started; - cvar.notify_all(); - } - - if !is_started { - self.start_worker(); - } else { - self.stop_worker(); - } - - let _ = self.config.save(); - #[cfg(feature = "logging")] { - trace!("Done"); - } - } - - if ui.add_enabled(!thread_running, egui::Button::new("Add Source")).clicked() { - self.add_source(); - } - if self.config.data.sources.len() > 1 && ui.add_enabled(!thread_running, egui::Button::new("Remove Source")).clicked() { - self.remove_source(); - } - - if ui.add_enabled(!thread_running, egui::Button::new("Add Receiver")).clicked() { - self.add_receiver(); - } - - if self.config.data.receivers.len() > 0 && ui.add_enabled(!thread_running, egui::Button::new("Remove Receiver")).clicked() { - self.remove_receiver(); - } - // if columns[1].button("Save").clicked() {} - if ui.button("About").clicked() { - self.state = State::About; - } - if ui.button("Exit").clicked() { - ctx.send_viewport_cmd(egui::ViewportCommand::Close); - } - }); - } - - fn start_worker(&mut self) { - if !self.spawn_worker() { - { - let &(ref lock, ref cvar) = &*self.thread_state; - let mut started = lock.lock().unwrap(); - *started = false; - cvar.notify_all(); - } - } - } - - fn stop_worker(&mut self) { - for source_state in self.source_states.clone() { - { - // reset each source state - let &(ref lock, ref cvar) = &*source_state; - let mut state = lock.lock().unwrap(); - *state = 0; - cvar.notify_all(); - } - } - for receiver_state in self.receiver_states.clone() { - { - // reset each source state - let &(ref lock, ref cvar) = &*receiver_state; - let mut state = lock.lock().unwrap(); - *state = 0; - cvar.notify_all(); - } - } - { - // reset result state - let &(ref lock, ref cvar) = &*self.shift_state; - let mut state = lock.lock().unwrap(); - *state = 0; - cvar.notify_all(); - } - for i in 0..self.device_list.len() { - self.device_list[i].active = false; - } - } - - fn remove_receiver(&mut self) { - self.receiver_states.pop(); - self.config.data.receivers.pop(); - } - - fn add_receiver(&mut self) { - self.receiver_states.push(Arc::new((Mutex::new(0), Condvar::new()))); - if self.state != State::Initialising { - self.config.data.receivers.push(SavedDevice::default()); - } - } - - fn add_source(&mut self) { - self.source_states.push(Arc::new((Mutex::new(0), Condvar::new()))); - if self.state != State::Initialising { - self.config.data.sources.push(SavedDevice::default()); - } - } - - fn remove_source(&mut self) { - self.source_states.pop(); - self.config.data.sources.pop(); - } - - fn shutdown(&mut self) { - // Make sure we clean up our thread + // Graceful shutdown logic + fn shutdown_app(&mut self) { + log::info!("Shutdown requested."); + // Signal the worker thread to stop { let &(ref lock, ref cvar) = &*self.thread_state; - let mut started = lock.lock().unwrap(); - *started = false; - cvar.notify_all(); + match lock.lock() { + Ok(mut started) => { + *started = false; + log::info!("Signaling worker thread to stop."); + } + Err(_) => { + log::error!("Thread state mutex poisoned during shutdown!"); + } + } + cvar.notify_all(); // Wake up thread if it's waiting } - // Give the thread some time to get the shutdown event - let _ = self.config.save(); - thread::sleep(Duration::from_millis(200)); + + // Save configuration + if let Err(e) = self.config.save() { + log::error!("Failed to save configuration on exit: {}", e); + } else { + log::info!("Configuration saved."); + } + + // Give the thread a moment to process the stop signal (optional) + // Note: Joining the thread handle would be more robust if we kept it. + std::thread::sleep(Duration::from_millis(250)); + log::info!("Shutdown complete."); } } - +// Main eframe application loop impl eframe::App for ShiftTool { - fn update(&mut self, ctx: &Context, _frame: &mut eframe::Frame) { - ctx.request_repaint_after(Duration::ZERO); + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + log::debug!("Update Called."); + // Request repaint ensures GUI updates even if worker is slow + ctx.request_repaint_after(Duration::from_millis(50)); // e.g., 10 FPS target egui::CentralPanel::default().show(ctx, |ui| { + // Use show_inside_add to handle resize correctly egui::Resize::default() .default_width(INITIAL_WIDTH) .default_height(INITIAL_HEIGHT) .auto_sized() - .show(ui, |ui| { - match self.state { - State::Initialising => self.init(), - State::About => self.about_screen(ui), - State::Running => self.run(ui, ctx), + .show(ui, |ui| match self.state { + State::Initialising => { + // Show a simple "Loading..." message while init runs + ui.centered_and_justified(|ui| { + ui.label("Initialising..."); + }); + // Actual init logic runs once after this frame + self.init(); + } + State::About => { + // Call the UI drawing function from the ui module + ui::draw_about_screen(self, ui); + } + State::Running => { + // Call the UI drawing function from the ui module + ui::draw_running_state(self, ui, ctx); } }); }); } + // Called when the application is about to close fn on_exit(&mut self, _gl: Option<&glow::Context>) { - #[cfg(feature = "logging")] { - info!("Shutting down..."); - } - self.shutdown(); - #[cfg(feature = "logging")] { - info!("Done"); - } + self.shutdown_app(); } -} \ No newline at end of file +} + +// Application Entry Point +fn main() -> eframe::Result<()> { + // Initialize logging + env_logger::init(); + + // --- Command Line Argument Parsing --- + // If you need args, keep this, otherwise remove clap dependency. + use clap::Parser; + #[derive(Parser, Debug)] + #[command(version, about, long_about = None)] + struct Args { + #[arg(short, long, default_value_t = false)] + skip_firmware: bool, + } + let _args = Args::parse(); + // --- End Argument Parsing --- + + log::info!("Starting {}", PROGRAM_TITLE); + + let options = eframe::NativeOptions { + viewport: egui::ViewportBuilder::default() + .with_inner_size([INITIAL_WIDTH, INITIAL_HEIGHT]) + .with_title(PROGRAM_TITLE), // Set window title here + ..Default::default() + }; + + eframe::run_native( + PROGRAM_TITLE, // Used for window title if not set in viewport + options, + Box::new(|_cc| Ok(Box::new(ShiftTool::default()))), // Create the app instance + ) +} diff --git a/src/state.rs b/src/state.rs new file mode 100644 index 0000000..54eb2f8 --- /dev/null +++ b/src/state.rs @@ -0,0 +1,7 @@ +// Represents the current high-level state of the application UI +#[derive(Copy, Clone, PartialEq, Eq, Debug)] +pub enum State { + Initialising, // App is starting, loading config, doing initial scan + Running, // Main operational state, showing devices and controls + About, // Showing the about screen +} diff --git a/src/ui.rs b/src/ui.rs new file mode 100644 index 0000000..6dc4775 --- /dev/null +++ b/src/ui.rs @@ -0,0 +1,561 @@ +use crate::about; +use crate::config::{ShiftModifiers}; +use crate::device::VpcDevice; // Assuming VpcDevice has Display impl +use crate::{ShiftTool, INITIAL_HEIGHT, INITIAL_WIDTH, PROGRAM_TITLE}; // Import main struct +use crate::state::State; +use crate::util::read_bit; // Import utility +use eframe::egui::{self, Color32, Context, ScrollArea, Ui}; + +const DISABLED_COLOR: Color32 = Color32::from_rgb(255, 0, 0); // Red for disabled + +// Keep UI drawing functions associated with ShiftTool +impl ShiftTool { + // --- Button/Action Handlers (called from draw_running_state) --- + + fn handle_start_stop_toggle(&mut self) { + if self.config.data.sources.is_empty() + || self.config.data.receivers.is_empty() + { + log::warn!("Start/Stop ignored: No source or receiver selected."); + return; // Don't toggle if no devices configured + } + + let was_started; + { + let &(ref lock, ref cvar) = &*self.thread_state; + let mut started_guard = lock.lock().expect("Thread state mutex poisoned"); + was_started = *started_guard; + *started_guard = !was_started; // Toggle the state + log::info!("Toggled worker thread state to: {}", *started_guard); + cvar.notify_all(); // Notify thread if it was waiting + } // Mutex guard dropped here + + if !was_started { + // If we just started it + if !self.spawn_worker() { + // If spawning failed, revert the state + log::error!("Worker thread failed to spawn, reverting state."); + let &(ref lock, ref cvar) = &*self.thread_state; + let mut started_guard = lock.lock().expect("Thread state mutex poisoned"); + *started_guard = false; + cvar.notify_all(); + } else { + log::info!("Worker thread started."); + // Save config on start + if let Err(e) = self.config.save() { + log::error!("Failed to save config on start: {}", e); + } + } + } else { + // If we just stopped it + log::info!("Worker thread stopped."); + self.stop_worker_cleanup(); // Perform cleanup actions + // Save config on stop + if let Err(e) = self.config.save() { + log::error!("Failed to save config on stop: {}", e); + } + } + } + + fn handle_add_source(&mut self) { + self.add_source_state(); // Add state tracking + self.config.data.sources.push(Default::default()); // Add config entry + log::debug!("Added source device slot."); + } + + fn handle_remove_source(&mut self) { + if self.config.data.sources.len() > 1 { + self.source_states.pop(); + self.config.data.sources.pop(); + log::debug!("Removed last source device slot."); + } + } + + fn handle_add_receiver(&mut self) { + self.add_receiver_state(); // Add state tracking + self.config.data.receivers.push(Default::default()); // Add config entry + log::debug!("Added receiver device slot."); + } + + fn handle_remove_receiver(&mut self) { + if !self.config.data.receivers.is_empty() { + self.receiver_states.pop(); + self.config.data.receivers.pop(); + log::debug!("Removed last receiver device slot."); + } + } +} + +// --- UI Drawing Functions --- + +pub(crate) fn draw_about_screen(app: &mut ShiftTool, ui: &mut Ui) { + ui.set_width(INITIAL_WIDTH); + ui.vertical_centered(|ui| { + ui.heading(format!("About {}", PROGRAM_TITLE)); + ui.separator(); + for line in about::about() { + ui.label(line); + } + ui.separator(); + if ui.button("OK").clicked() { + app.state = State::Running; + } + }); +} + +pub(crate) fn draw_running_state( + app: &mut ShiftTool, + ui: &mut Ui, + ctx: &Context, +) { + let thread_running = app.get_thread_status(); + app.refresh_devices(); // Need to be careful about frequent HID API calls + + if app.config.data.sources.is_empty() { + // Ensure at least one source slot exists initially + app.handle_add_source(); + } + + ui.columns(2, |columns| { + ScrollArea::vertical() + .auto_shrink([false, false]) + .show(&mut columns[0], |ui| { + ui.vertical(|ui| { + draw_sources_section(app, ui, thread_running); + ui.separator(); + draw_rules_section(app, ui, thread_running); + ui.separator(); + draw_receivers_section(app, ui, thread_running); + ui.add_space(10.0); + }); + }); + + columns[1].vertical(|ui| { + draw_control_buttons(app, ui, ctx, thread_running); + }); + }); +} + +fn draw_sources_section( + app: &mut ShiftTool, + ui: &mut Ui, + thread_running: bool, +) { + ui.heading("Sources"); + for i in 0..app.config.data.sources.len() { + // --- Immutable Operations First --- + let saved_config_for_find = app.config.data.sources[i].clone(); + let selected_device_idx = crate::device::find_device_index_for_saved( + &app.device_list, // Pass immutable borrow of device_list + &saved_config_for_find, + ); + + // --- Now get mutable borrow for UI elements that might change config --- + let source_config = &mut app.config.data.sources[i]; + let device_list = &app.device_list; // Re-borrow immutably (allowed alongside mutable borrow of a *different* field) + let source_states = &app.source_states; + + let vid = source_config.vendor_id; + let pid = source_config.product_id; + + ui.horizontal(|ui| { + ui.label(format!("Source {}:", i + 1)); + // Device Selector Combo Box + device_selector_combo( + ui, + format!("source_combo_{}", i), + device_list, // Pass immutable borrow + selected_device_idx, + |selected_idx| { + if selected_idx < device_list.len() { // Bounds check + source_config.vendor_id = device_list[selected_idx].vendor_id; + source_config.product_id = device_list[selected_idx].product_id; + source_config.serial_number = + device_list[selected_idx].serial_number.clone(); + } + }, + thread_running, + ); + }); // Mutable borrow of source_config might end here or after status bits + + // Draw status bits for this source + if let Some(state_arc) = source_states.get(i) { + let state_val = match state_arc.lock() { // Use match + Ok(guard) => { + log::debug!("UI: Reading source_states[{}] = {}", i, *guard); + *guard // Dereference the guard to get the value + } + Err(poisoned) => { + log::error!("UI: Mutex poisoned for source_states[{}]!", i); + **poisoned.get_ref() // Try to get value anyway + } + }; + + // Pass mutable borrow of state_enabled part of source_config + draw_status_bits( + ui, + "Shift:", + state_val, + &mut source_config.state_enabled, + vid, + pid, + thread_running, + thread_running, + true + ); + } else { + ui.colored_label(Color32::RED, "Error: State mismatch"); + } + + ui.add_space(5.0); + } // Mutable borrow of source_config definitely ends here + ui.add_space(10.0); +} + +fn draw_rules_section( + app: &mut ShiftTool, + ui: &mut Ui, + thread_running: bool, +) { + ui.heading("Rules & Result"); + ui.horizontal(|ui| { + ui.label("Rules:"); + ui.add_enabled_ui(!thread_running, |ui| { + for j in 0..8 { + let current_modifier = app.config.data.shift_modifiers[j]; + if ui + .selectable_label(false, format!("{}", current_modifier)) + .clicked() + { + // Cycle through modifiers on click + app.config.data.shift_modifiers[j] = match current_modifier { + ShiftModifiers::OR => ShiftModifiers::AND, + ShiftModifiers::AND => ShiftModifiers::XOR, + ShiftModifiers::XOR => ShiftModifiers::OR, + }; + } + } + }); + }); + + // Display combined result state + let final_state_val = *app.shift_state.lock().unwrap(); + draw_status_bits( + ui, + "Result:", + final_state_val, + &mut [true; 8], // Pass dummy array + 0, + 0, + false, + true, + false, + ); + ui.add_space(10.0); // Space after the section +} + +fn draw_receivers_section( + app: &mut ShiftTool, + ui: &mut Ui, + thread_running: bool, +) { + ui.heading("Receivers"); + if app.config.data.receivers.is_empty() { + ui.label("(Add a receiver using the controls on the right)"); + } + // Iterate by index + for i in 0..app.config.data.receivers.len() { + // --- Immutable Operations First --- + let saved_config_for_find = app.config.data.receivers[i].clone(); + let selected_device_idx = crate::device::find_device_index_for_saved( + &app.device_list, + &saved_config_for_find, + ); + + // --- Mutable Borrow Scope --- + let receiver_config = &mut app.config.data.receivers[i]; + let device_list = &app.device_list; + let receiver_states = &app.receiver_states; + + let vid = receiver_config.vendor_id; + let pid = receiver_config.product_id; + + ui.horizontal(|ui| { + ui.label(format!("Receiver {}:", i + 1)); + device_selector_combo( + ui, + format!("receiver_combo_{}", i), + device_list, + selected_device_idx, + |selected_idx| { + if selected_idx < device_list.len() { // Bounds check + receiver_config.vendor_id = device_list[selected_idx].vendor_id; + receiver_config.product_id = device_list[selected_idx].product_id; + receiver_config.serial_number = + device_list[selected_idx].serial_number.clone(); + } + }, + thread_running, + ); + }); // Mut borrow might end here + + if let Some(state_arc) = receiver_states.get(i) { + let state_val = match state_arc.lock() { // Use match + Ok(guard) => { + log::debug!("UI: Reading receiver_states[{}] = {}", i, *guard); + *guard // Dereference the guard to get the value + } + Err(poisoned) => { + log::error!("UI: Mutex poisoned for receiver_states[{}]!", i); + **poisoned.get_ref() // Try to get value anyway + } + }; + draw_status_bits( + ui, + "Shift:", + state_val, + &mut receiver_config.state_enabled, // Pass mut borrow + vid, + pid, + thread_running, + thread_running, + true + ); + } else { + ui.colored_label(Color32::RED, "Error: State mismatch"); + } + + ui.add_space(5.0); + } // Mut borrow ends here +} + +// --- UI Helper Widgets --- + +/// Creates a ComboBox for selecting a device. +fn device_selector_combo( + ui: &mut Ui, + id_source: impl std::hash::Hash, + device_list: &[VpcDevice], + selected_device_idx: usize, + mut on_select: impl FnMut(usize), // Closure called when selection changes + disabled: bool, +) { + let selected_text = if selected_device_idx < device_list.len() { + format!("{}", device_list[selected_device_idx]) + } else { + // Handle case where index might be out of bounds after a refresh + "-SELECT DEVICE-".to_string() + }; + + ui.add_enabled_ui(!disabled, |ui| { + egui::ComboBox::from_id_source(id_source) + .width(300.0) // Adjust width as needed + .selected_text(selected_text) + .show_ui(ui, |ui| { + for (j, device) in device_list.iter().enumerate() { + // Use selectable_value to handle selection logic + if ui + .selectable_label( + j == selected_device_idx, + format!("{}", device), + ) + .clicked() + { + if j != selected_device_idx { + on_select(j); // Call the provided closure + } + } + } + }); + }); +} + +/// Draws the row of shift status bits (1-5, DTNT, ZOOM, TRIM). +fn draw_status_bits( + ui: &mut Ui, + label: &str, + state_value: u16, + enabled_mask: &mut [bool; 8], + vendor_id: u16, + product_id: u16, + thread_running: bool, + bits_disabled: bool, // If the whole row should be unclickable + show_online_status: bool, +) { + ui.horizontal(|ui| { + ui.label(label); + log::debug!("draw_status_bits received state_value: {}", state_value); + + ui.add_enabled_ui(!bits_disabled, |ui| { + // Bits 0-4 (Shift 1-5) + for j in 0..5u8 { + let bit_is_set = read_bit(state_value, j); + let is_enabled = enabled_mask[j as usize]; + let color = if !is_enabled { + DISABLED_COLOR + } else { + Color32::TRANSPARENT // Default background + }; + + log::debug!( + " Bit {}: state={}, enabled={}, calculated_selected={}", + j, state_value, is_enabled, bit_is_set + ); + + // Use selectable_value for clickable behavior + if ui + .selectable_label( + bit_is_set, + egui::RichText::new(format!("{}", j + 1)) + .background_color(color), + ) + .clicked() + { + // Toggle the enabled state if clicked + enabled_mask[j as usize] = !is_enabled; + } + } + + // Special Bits (DTNT, ZOOM, TRIM) - Assuming order 5, 6, 7 + let special_bits = [("DTNT", 5u8), ("ZOOM", 6u8), ("TRIM", 7u8)]; + for (name, bit_pos) in special_bits { + let bit_is_set = read_bit(state_value, bit_pos); + let is_enabled = enabled_mask[bit_pos as usize]; + let color = if !is_enabled { + DISABLED_COLOR + } else { + Color32::TRANSPARENT + }; + log::debug!( + " Bit {}: name={}, state={}, enabled={}, calculated_selected={}", + bit_pos, name, state_value, is_enabled, bit_is_set + ); + + if ui + .selectable_label( + bit_is_set, + egui::RichText::new(name).background_color(color), + ) + .clicked() + { + enabled_mask[bit_pos as usize] = !is_enabled; + } + } + }); + + // --- Draw the Online/Offline Status --- + // Add some spacing before the status + if show_online_status { + ui.add_space(15.0); // Adjust as needed + + let is_configured = vendor_id != 0 && product_id != 0; + let (text, color) = if thread_running && is_configured { + ("ONLINE", Color32::GREEN) + } else if !is_configured { + ("UNCONFIGURED", Color32::YELLOW) + } else { + ("OFFLINE", Color32::GRAY) + }; + // Add the status label directly here + ui.label(egui::RichText::new(text).color(color)); + } + }); +} + +/// Draws the ONLINE/OFFLINE status indicator. +fn draw_online_status( + ui: &mut Ui, + saved_device_config: &crate::device::SavedDevice, // Pass the config for this slot + thread_running: bool, +) { + // Infer status: Online if thread is running AND device is configured (VID/PID != 0) + let is_configured = saved_device_config.vendor_id != 0 && saved_device_config.product_id != 0; + + // Determine status text and color + let (text, color) = if thread_running && is_configured { + // We assume the worker *tries* to talk to configured devices. + // A more advanced check could involve reading another shared state + // updated by the worker indicating recent success/failure for this device. + ("ONLINE", Color32::GREEN) + } else if !is_configured { + ("UNCONFIGURED", Color32::YELLOW) // Show if slot is empty + } else { // Thread not running or device not configured + ("OFFLINE", Color32::GRAY) + }; + + // Use selectable_label for consistent look, but make it non-interactive + // Set 'selected' argument to false as it's just a status display + ui.selectable_label(false, egui::RichText::new(text).color(color)); +} + + + +/// Draws the control buttons in the right column. +fn draw_control_buttons( + app: &mut ShiftTool, + ui: &mut Ui, + ctx: &Context, + thread_running: bool, +) { + // Start/Stop Button + let (start_stop_text, start_stop_color) = if thread_running { + ("Stop", DISABLED_COLOR) + } else { + ("Start", Color32::GREEN) // Use Green for Start + }; + if ui + .button( + egui::RichText::new(start_stop_text) + .color(Color32::BLACK) // Text color + .background_color(start_stop_color), + ) + .clicked() + { + app.handle_start_stop_toggle(); + } + + // ui.separator(); + + // Add/Remove Source Buttons + if ui.add_enabled(!thread_running, egui::Button::new("Add Source")).clicked() { + app.handle_add_source(); + } + if app.config.data.sources.len() > 1 { // Only show remove if more than 1 + if ui.add_enabled(!thread_running, egui::Button::new("Remove Source")).clicked() { + app.handle_remove_source(); + } + } + + // ui.separator(); + + // Add/Remove Receiver Buttons + if ui.add_enabled(!thread_running, egui::Button::new("Add Receiver")).clicked() { + app.handle_add_receiver(); + } + if !app.config.data.receivers.is_empty() { // Only show remove if > 0 + if ui.add_enabled(!thread_running, egui::Button::new("Remove Receiver")).clicked() { + app.handle_remove_receiver(); + } + } + + // ui.separator(); + + // Other Buttons + // if ui.add_enabled(!thread_running, egui::Button::new("Save Config")).clicked() { + // if let Err(e) = app.config.save() { + // log::error!("Failed to manually save config: {}", e); + // // Optionally show feedback to user in UI + // } else { + // log::info!("Configuration saved manually."); + // } + // } + + if ui.button("About").clicked() { + app.state = State::About; + } + + if ui.button("Exit").clicked() { + // Ask eframe to close the window. `on_exit` will be called. + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } +} diff --git a/src/util.rs b/src/util.rs new file mode 100644 index 0000000..cd540e7 --- /dev/null +++ b/src/util.rs @@ -0,0 +1,54 @@ +use log::warn; // Use log crate + +/// Reads a specific bit from a u16 value. +/// `position` is 0-indexed (0-15). +pub(crate) fn read_bit(value: u16, position: u8) -> bool { + if position > 15 { + warn!("read_bit called with invalid position: {}", position); + return false; + } + (value & (1 << position)) != 0 +} + +/// Sets or clears a specific bit in a u8 value. +/// `bit_position` is 0-indexed (0-7). +/// Returns the modified u8 value. +pub(crate) fn set_bit(value: u8, bit_position: u8, bit_value: bool) -> u8 { + if bit_position > 7 { + warn!("set_bit called with invalid position: {}", bit_position); + return value; // Return original value on error + } + if bit_value { + value | (1 << bit_position) // Set the bit to 1 + } else { + value & !(1 << bit_position) // Set the bit to 0 + } +} + +/// Combines high and low bytes into a u16 value. +pub(crate) fn merge_u8_into_u16(high_byte: u8, low_byte: u8) -> u16 { + (high_byte as u16) << 8 | (low_byte as u16) +} + +/// Checks if a device firmware string is supported. +/// TODO: Implement actual firmware checking logic if needed. +pub(crate) fn is_supported(firmware_string: String) -> bool { + // Currently allows all devices. + // If you re-enable firmware checking, use the `args` or a config setting. + // let args = crate::main::Args::parse(); // Need to handle args properly + // if args.skip_firmware { return true; } + + // Example fixed list check: + // let supported_firmware = [ + // "VIRPIL Controls 20220720", + // "VIRPIL Controls 20230328", + // "VIRPIL Controls 20240323", + // ]; + // supported_firmware.contains(&firmware_string.as_str()) + + if firmware_string.is_empty() || firmware_string == "Unknown Firmware" { + warn!("Device has missing or unknown firmware string."); + // Decide if these should be allowed or not. Allowing for now. + } + true +} From 4df9ce4d498a8f7b72e785d59a4f7331ad8d5450 Mon Sep 17 00:00:00 2001 From: raven <7156279+RavenX8@users.noreply.github.com> Date: Fri, 28 Mar 2025 19:34:12 -0400 Subject: [PATCH 2/4] Fixed issue where device would reboot when stopping (was sending an invalid clear buffer payload) Added a manual device list refresh button Updated device list validator function to not remove a device from the config just because it's not available --- src/device.rs | 35 ++++++++++++++++------------------- src/hid_worker.rs | 18 ++++++++++-------- src/ui.rs | 9 +++++++-- 3 files changed, 33 insertions(+), 29 deletions(-) diff --git a/src/device.rs b/src/device.rs index d9e94d3..8dad19c 100644 --- a/src/device.rs +++ b/src/device.rs @@ -1,5 +1,5 @@ use hidapi::{DeviceInfo, HidApi, HidError}; -use log::{error, info, warn}; // Use log crate +use log::{error, info, warn, debug, trace}; // Use log crate use serde::{Deserialize, Serialize}; use std::rc::Rc; // Keep Rc for potential sharing within UI if needed @@ -98,7 +98,7 @@ pub(crate) fn find_device_index_for_saved( impl crate::ShiftTool { /// Refreshes the internal list of available HID devices. pub(crate) fn refresh_devices(&mut self) { - info!("Refreshing device list..."); + trace!("Refreshing device list..."); match HidApi::new() { Ok(hidapi) => { let mut current_devices: Vec = Vec::new(); @@ -126,7 +126,7 @@ impl crate::ShiftTool { if crate::util::is_supported( vpc_device.firmware.to_string(), ) { - info!("Found supported device: {}", vpc_device); + debug!("Found supported device: {}", vpc_device); current_devices.push(vpc_device); } else { warn!( @@ -153,7 +153,7 @@ impl crate::ShiftTool { // Update the app's device list self.device_list = current_devices; - info!( + debug!( "Device list refresh complete. Found {} unique devices.", self.device_list.len() - 1 // Exclude default entry ); @@ -200,29 +200,26 @@ impl crate::ShiftTool { /// Checks if saved source/receiver devices still exist in the refreshed list. /// Resets the config entry to default if the device is gone. fn validate_selected_devices(&mut self) { - let mut changed = false; for i in 0..self.config.data.sources.len() { - let idx = self.find_source_device_index(i); + let idx = self.find_device_index_for_saved(&self.config.data.sources[i]); + // Check if device *was* configured but is *not* found (idx 0 is default/not found) if idx == 0 && (self.config.data.sources[i].vendor_id != 0 || self.config.data.sources[i].product_id != 0) { - warn!("Previously selected source device {} not found after refresh. Resetting.", i + 1); - self.config.data.sources[i] = SavedDevice::default(); - changed = true; + // Log that the configured device is currently missing, but DO NOT reset config + warn!( + "validate_selected_devices: Configured source device {} (VID={:04X}, PID={:04X}) not found in refreshed list. Keeping configuration.", + i + 1, self.config.data.sources[i].vendor_id, self.config.data.sources[i].product_id + ); } } for i in 0..self.config.data.receivers.len() { - let idx = self.find_receiver_device_index(i); + let idx = self.find_device_index_for_saved(&self.config.data.receivers[i]); if idx == 0 && (self.config.data.receivers[i].vendor_id != 0 || self.config.data.receivers[i].product_id != 0) { - warn!("Previously selected receiver device {} not found after refresh. Resetting.", i + 1); - self.config.data.receivers[i] = SavedDevice::default(); - changed = true; + warn!( + "validate_selected_devices: Configured receiver device {} (VID={:04X}, PID={:04X}) not found in refreshed list. Keeping configuration.", + i + 1, self.config.data.receivers[i].vendor_id, self.config.data.receivers[i].product_id + ); } } - if changed { - // Optionally save the config immediately after validation changes - // if let Err(e) = self.config.save() { - // error!("Failed to save config after device validation: {}", e); - // } - } } } diff --git a/src/hid_worker.rs b/src/hid_worker.rs index 590b82b..417f8f6 100644 --- a/src/hid_worker.rs +++ b/src/hid_worker.rs @@ -264,11 +264,8 @@ fn run_hid_worker_loop(hidapi: HidApi, data: WorkerData) { } // --- Write to Receiver Devices --- - // We need to determine the correct length to send. Assuming report 4 is always ID + 2 bytes. - const bytes_to_send: usize = 19; // Report ID (1) + Data (2) - - let zero_buffer: [u8; bytes_to_send] = { - let mut buf = [0u8; bytes_to_send]; + let zero_buffer: [u8; REPORT_BUFFER_SIZE] = { + let mut buf = [0u8; REPORT_BUFFER_SIZE]; buf[0] = FEATURE_REPORT_ID; // Set Report ID 4 // All other bytes (1-18) remain 0 for the zero state buf @@ -322,7 +319,7 @@ fn run_hid_worker_loop(hidapi: HidApi, data: WorkerData) { // Send the potentially filtered feature report - match device.send_feature_report(&filtered_write_buffer[0..bytes_to_send]) { + match device.send_feature_report(&filtered_write_buffer[0..REPORT_BUFFER_SIZE]) { Ok(_) => { log::debug!("Worker: Send to receiver[{}] successful.", i); // Successfully sent. Update UI state for this receiver. @@ -392,8 +389,13 @@ fn run_hid_worker_loop(hidapi: HidApi, data: WorkerData) { // --- Cleanup before thread exit --- log::info!("Worker loop finished. Performing cleanup..."); - // Optionally send a 'zero' report to all devices on exit - let cleanup_buffer: [u8; 3] = [FEATURE_REPORT_ID, 0, 0]; + // Send a 'zero' report to all devices on exit + let cleanup_buffer: [u8; REPORT_BUFFER_SIZE] = { + let mut buf = [0u8; REPORT_BUFFER_SIZE]; + buf[0] = FEATURE_REPORT_ID; // Set Report ID 4 + // All other bytes (1-18) remain 0 for the zero state + buf + }; for device_opt in source_devices.iter_mut().chain(receiver_devices.iter_mut()) { if let Some(device) = device_opt { if let Err(e) = device.send_feature_report(&cleanup_buffer) { diff --git a/src/ui.rs b/src/ui.rs index 6dc4775..6356630 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -194,7 +194,7 @@ fn draw_sources_section( // Pass mutable borrow of state_enabled part of source_config draw_status_bits( ui, - "Shift:", + " Shift:", state_val, &mut source_config.state_enabled, vid, @@ -312,7 +312,7 @@ fn draw_receivers_section( }; draw_status_bits( ui, - "Shift:", + " Shift:", state_val, &mut receiver_config.state_enabled, // Pass mut borrow vid, @@ -550,6 +550,11 @@ fn draw_control_buttons( // } // } + if ui.add_enabled(!thread_running, egui::Button::new("Refresh Devices")).clicked() { + log::info!("Refreshing device list manually."); + app.refresh_devices(); + } + if ui.button("About").clicked() { app.state = State::About; } From bd9f94c244672cd87851b20dd3ef8675acf7d9ad Mon Sep 17 00:00:00 2001 From: raven <7156279+RavenX8@users.noreply.github.com> Date: Fri, 28 Mar 2025 22:26:52 -0400 Subject: [PATCH 3/4] Added the ability to support different firmware report versions --- Cargo.lock | 64 ++++++- Cargo.toml | 1 + src/device.rs | 2 +- src/hid_worker.rs | 454 ++++++++++++++++++++++++++++++---------------- src/main.rs | 18 +- src/util.rs | 235 +++++++++++++++++++++++- 6 files changed, 598 insertions(+), 176 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f55f798..875b7ea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -93,7 +93,7 @@ dependencies = [ "paste", "static_assertions", "windows", - "windows-core", + "windows-core 0.58.0", ] [[package]] @@ -165,6 +165,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -650,6 +656,20 @@ dependencies = [ "libc", ] +[[package]] +name = "chrono" +version = "0.4.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a7964611d71df112cb1730f2ee67324fcf4d0fc6606acbbe9bfe06df124637c" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "clap" version = "4.5.27" @@ -1523,6 +1543,30 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" +[[package]] +name = "iana-time-zone" +version = "0.1.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2fd658b06e56721792c5df4475705b6cda790e9298d19d2f8af083457bcd127" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.52.0", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "1.5.0" @@ -2712,6 +2756,7 @@ dependencies = [ name = "shift_tool" version = "0.4.0" dependencies = [ + "chrono", "clap", "dirs", "eframe", @@ -3481,7 +3526,16 @@ version = "0.58.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" dependencies = [ - "windows-core", + "windows-core 0.58.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ "windows-targets 0.52.6", ] @@ -3520,6 +3574,12 @@ dependencies = [ "syn", ] +[[package]] +name = "windows-link" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38" + [[package]] name = "windows-result" version = "0.2.0" diff --git a/Cargo.toml b/Cargo.toml index a81782e..8956bd3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ hidapi = "2.6.1" log = "0.4.21" serde = { version = "1.0.197", features = ["derive"] } dirs = { version = "6.0.0", features = [] } +chrono = "0.4.40" [features] diff --git a/src/device.rs b/src/device.rs index 8dad19c..a47017a 100644 --- a/src/device.rs +++ b/src/device.rs @@ -192,7 +192,7 @@ impl crate::ShiftTool { .position(|d| { d.vendor_id == saved_device.vendor_id && d.product_id == saved_device.product_id - && d.serial_number == saved_device.serial_number + // && d.serial_number == saved_device.serial_number }) .unwrap_or(0) // Default to index 0 ("No Connection") if not found } diff --git a/src/hid_worker.rs b/src/hid_worker.rs index 417f8f6..319f143 100644 --- a/src/hid_worker.rs +++ b/src/hid_worker.rs @@ -1,7 +1,8 @@ use crate::config::{ModifiersArray}; use crate::device::SavedDevice; use crate::{SharedDeviceState, SharedStateFlag}; // Import shared types -use crate::util::{merge_u8_into_u16, read_bit, set_bit}; +use crate::util::{self, merge_u8_into_u16, read_bit, set_bit, ReportFormat, MAX_REPORT_SIZE}; +use log::{debug, error, info, trace, warn}; use hidapi::{HidApi, HidDevice, HidError}; use std::{ sync::{Arc, Condvar, Mutex}, @@ -10,17 +11,22 @@ use std::{ }; // Constants for HID communication -const FEATURE_REPORT_ID: u8 = 4; -const REPORT_BUFFER_SIZE: usize = 19; // 1 byte ID + 64 bytes data pub const VENDOR_ID_FILTER: u16 = 0x3344; // Assuming Virpil VID const WORKER_SLEEP_MS: u64 = 100; // Reduced sleep time for better responsiveness + +#[derive(Clone)] +struct DeviceWorkerInfo { + config: SavedDevice, + format: ReportFormat, +} + // Structure to hold data passed to the worker thread // Clone Arcs for shared state, clone config data needed struct WorkerData { run_state: SharedStateFlag, - sources_config: Vec, - receivers_config: Vec, + sources_info: Vec, + receivers_info: Vec, shift_modifiers: ModifiersArray, source_states_shared: Vec, receiver_states_shared: Vec, @@ -31,13 +37,97 @@ struct WorkerData { // Now part of ShiftTool impl block impl crate::ShiftTool { pub(crate) fn spawn_worker(&mut self) -> bool { - log::info!("Attempting to spawn HID worker thread..."); + info!("Attempting to spawn HID worker thread..."); + + let mut sources_info: Vec = Vec::new(); + for (i, source_config) in self.config.data.sources.iter().enumerate() { + + // 1. Find the corresponding VpcDevice in the current device_list + // This is needed to get the firmware string. + let device_idx = crate::device::find_device_index_for_saved( + &self.device_list, // The list of currently detected devices + source_config, // The config for the i-th source slot + ); + + // 2. Get the firmware string from the found VpcDevice + let firmware_str = if device_idx != 0 && device_idx < self.device_list.len() { + // Successfully found the device in the current list + self.device_list[device_idx].firmware.to_string() // Access the firmware field + } else { + // Device not found (index 0 is default/placeholder) or list issue + warn!("Source device {} not found in current list for format determination.", i); + "".to_string() // Use empty string if not found + }; + + let name_str = if device_idx != 0 && device_idx < self.device_list.len() { + // Successfully found the device in the current list + self.device_list[device_idx].name.to_string() // Access the firmware field + } else { + // Device not found (index 0 is default/placeholder) or list issue + warn!("Source device {} not found in current list for format determination.", i); + "".to_string() // Use empty string if not found + }; + + // 3. Call determine_report_format with the firmware string + // This function (from src/util.rs) contains the logic + // to check dates or patterns and return the correct format. + let determined_format: ReportFormat = util::determine_report_format(&name_str, &firmware_str); + + // 4. Log the result for debugging + info!( + "Determined report format {:?} for source {} (Firmware: '{}')", + determined_format, // Log the whole struct (uses Debug derive) + i, + firmware_str + ); + + // 5. Store the result along with the config in DeviceWorkerInfo + sources_info.push(DeviceWorkerInfo { + config: source_config.clone(), // Clone the config part + format: determined_format, // Store the determined format + }); + } + + let mut receivers_info: Vec = Vec::new(); + for (i, receiver_config) in self.config.data.receivers.iter().enumerate() { + let device_idx = crate::device::find_device_index_for_saved( + &self.device_list, + receiver_config, + ); + let firmware_str = if device_idx != 0 && device_idx < self.device_list.len() { + self.device_list[device_idx].firmware.to_string() + } else { + warn!("Receiver device {} not found in current list for format determination.", i); + "".to_string() + }; + let name_str = if device_idx != 0 && device_idx < self.device_list.len() { + self.device_list[device_idx].name.to_string() + } else { + warn!("Receiver device {} not found in current list for format determination.", i); + "".to_string() + }; + + let determined_format: ReportFormat = util::determine_report_format(&name_str, &firmware_str); + + info!( + "Determined report format {:?} for receiver {} (Firmware: '{}')", + determined_format, + i, + firmware_str + ); + + receivers_info.push(DeviceWorkerInfo { + config: receiver_config.clone(), + format: determined_format, + }); + } + // Clone data needed by the thread let worker_data = WorkerData { run_state: self.thread_state.clone(), - sources_config: self.config.data.sources.clone(), - receivers_config: self.config.data.receivers.clone(), + sources_info, + receivers_info, shift_modifiers: self.config.data.shift_modifiers, // Copy (it's Copy) source_states_shared: self.source_states.clone(), receiver_states_shared: self.receiver_states.clone(), @@ -49,27 +139,27 @@ impl crate::ShiftTool { // Create HidApi instance *within* the thread match HidApi::new() { // Use new() which enumerates internally Ok(hidapi) => { - log::info!("HidApi created successfully in worker thread."); + info!("HidApi created successfully in worker thread."); // Filter devices *within* the thread if needed, though opening by VID/PID/SN is primary // hidapi.add_devices(VENDOR_ID_FILTER, 0).ok(); // Optional filtering run_hid_worker_loop(hidapi, worker_data); } Err(e) => { - log::error!("Failed to create HidApi in worker thread: {}", e); + error!("Failed to create HidApi in worker thread: {}", e); // How to signal failure back? Could use another shared state. // For now, thread just exits. } } }); - log::info!("HID worker thread spawn initiated."); + info!("HID worker thread spawn initiated."); true // Indicate spawn attempt was made } // Cleanup actions when the worker is stopped from the UI pub(crate) fn stop_worker_cleanup(&mut self) { - log::info!("Performing worker stop cleanup..."); + info!("Performing worker stop cleanup..."); // Reset shared states displayed in the UI let reset_state = |state_arc: &SharedDeviceState| { if let Ok(mut state) = state_arc.lock() { @@ -86,45 +176,67 @@ impl crate::ShiftTool { for device in self.device_list.iter_mut() { device.active = false; } - log::info!("Worker stop cleanup finished."); + info!("Worker stop cleanup finished."); } } -// Helper to open devices, returns Result for better error handling +/// Opens HID devices based on the provided configuration and format info. +/// +/// Iterates through the `device_infos`, attempts to open each device using +/// VID, PID, and Serial Number from the `config` field. Sets non-blocking mode. +/// +/// Returns a Vec where each element corresponds to an input `DeviceWorkerInfo`. +/// Contains `Some(HidDevice)` on success, or `None` if the device couldn't be +/// opened, wasn't configured (VID/PID=0), or failed to set non-blocking mode. fn open_hid_devices( hidapi: &HidApi, - device_configs: &[SavedDevice], -) -> Vec> { // Return Option to represent open failures - let mut devices = Vec::with_capacity(device_configs.len()); - for config in device_configs { + device_infos: &[DeviceWorkerInfo], // Accepts a slice of the new struct +) -> Vec> { + let mut devices = Vec::with_capacity(device_infos.len()); + + // Iterate through the DeviceWorkerInfo structs + for (i, info) in device_infos.iter().enumerate() { + // Use info.config to get the device identifiers + let config = &info.config; + + // Skip if device is not configured (VID/PID are zero) if config.vendor_id == 0 || config.product_id == 0 { - log::warn!("Skipping device with zero VID/PID in config."); - devices.push(None); // Placeholder for unconfigured/invalid device + log::trace!("Skipping opening device slot {} (unconfigured).", i); + devices.push(None); // Placeholder for unconfigured slot continue; } + + // Attempt to open the device match hidapi.open( config.vendor_id, - config.product_id + config.product_id, ) { Ok(device) => { + // Log success with format info for context log::info!( - "Successfully opened device: VID={:04x}, PID={:04x}, SN={}", - config.vendor_id, config.product_id, config.serial_number + "Successfully opened device slot {}: VID={:04X}, PID={:04X}, SN='{}', Format='{}'", + i, config.vendor_id, config.product_id, config.serial_number, info.format.name // Log format name ); - // Set non-blocking mode + + // Attempt to set non-blocking mode if let Err(e) = device.set_blocking_mode(false) { - log::error!("Failed to set non-blocking mode: {}", e); - // Decide if this is fatal for this device - devices.push(None); // Treat as failure if non-blocking fails + log::error!( + "Failed to set non-blocking mode for device slot {}: {:?}. Treating as open failure.", + i, e + ); + // Decide if this is fatal: Yes, treat as failure if non-blocking fails + devices.push(None); } else { + // Successfully opened and set non-blocking devices.push(Some(device)); } } Err(e) => { + // Log failure to open log::warn!( - "Failed to open device VID={:04x}, PID={:04x}, SN={}: {}", - config.vendor_id, config.product_id, config.serial_number, e + "Failed to open device slot {}: VID={:04X}, PID={:04X}, SN='{}': {:?}", + i, config.vendor_id, config.product_id, config.serial_number, e ); devices.push(None); // Push None on failure } @@ -140,12 +252,12 @@ fn run_hid_worker_loop(hidapi: HidApi, data: WorkerData) { // --- Device Opening --- // Open sources and receivers, keeping track of which ones succeeded - let mut source_devices = open_hid_devices(&hidapi, &data.sources_config); - let mut receiver_devices = open_hid_devices(&hidapi, &data.receivers_config); + let mut source_devices = open_hid_devices(&hidapi, &data.sources_info); + let mut receiver_devices = open_hid_devices(&hidapi, &data.receivers_info); // Buffers for HID reports - let mut read_buffer = [0u8; REPORT_BUFFER_SIZE]; - let mut write_buffer = [0u8; REPORT_BUFFER_SIZE]; // Buffer for calculated output + let mut read_buffer = [0u8; MAX_REPORT_SIZE]; + let mut write_buffer = [0u8; MAX_REPORT_SIZE]; // Buffer for calculated output let &(ref run_lock, ref run_cvar) = &*data.run_state; @@ -155,60 +267,61 @@ fn run_hid_worker_loop(hidapi: HidApi, data: WorkerData) { match run_lock.lock() { Ok(guard) => *guard, Err(poisoned) => { - log::error!("Run state mutex poisoned in worker loop!"); + error!("Run state mutex poisoned in worker loop!"); false } } }; if !should_run { - log::info!("Stop signal received, exiting worker loop."); + info!("Stop signal received, exiting worker loop."); break; // Exit the loop } // --- Read from Source Devices --- let mut current_source_states: Vec> = vec![None; source_devices.len()]; - read_buffer[0] = FEATURE_REPORT_ID; // Set report ID for reading for (i, device_opt) in source_devices.iter_mut().enumerate() { if let Some(device) = device_opt { + let source_info = &data.sources_info[i]; + let source_format = source_info.format; + read_buffer[0] = source_format.report_id; + // Attempt to read feature report match device.get_feature_report(&mut read_buffer) { - Ok(bytes_read) if bytes_read >= 3 => { // Need at least ID + 2 bytes data - let state_val = merge_u8_into_u16(read_buffer[1], read_buffer[2]); - current_source_states[i] = Some(state_val); - // Update shared state for UI - if let Some(shared_state) = data.source_states_shared.get(i) { - if let Ok(mut guard) = shared_state.lock() { - log::debug!("Worker: Updating source_states_shared[{}] from {} to {}", i, *guard, state_val); - *guard = state_val; + Ok(bytes_read) => { + if let Some(state_val) = source_format.unpack_state(&read_buffer[0..bytes_read]) { + trace!("Worker: Unpacked state {} from source {}", state_val, i); + current_source_states[i] = Some(state_val); + // Update shared state for UI + if let Some(shared_state) = data.source_states_shared.get(i) { + if let Ok(mut guard) = shared_state.lock() { *guard = state_val; } + else { log::error!("Worker: Mutex poisoned for source_states_shared[{}]!", i); } + } + } else { + // unpack_state returned None (e.g., wrong ID, too short) + log::warn!("Worker: Failed to unpack state from source {} (bytes read: {}) using format '{}'", i, bytes_read, source_format.name); + current_source_states[i] = None; + if let Some(shared_state) = data.source_states_shared.get(i) { + if let Ok(mut guard) = shared_state.lock() { *guard = 0; } // Reset UI } } } - Ok(bytes_read) => { // Read ok, but not enough data? - log::warn!("Source {} read only {} bytes for report {}.", i, bytes_read, FEATURE_REPORT_ID); - current_source_states[i] = None; // Treat as no data - if let Some(shared_state) = data.source_states_shared.get(i) { - if let Ok(mut guard) = shared_state.lock() { *guard = 0; } // Reset UI state - } - } Err(e) => { - log::warn!("Error reading from source {}: {}. Attempting reopen.", i, e); - current_source_states[i] = None; // Clear state on error + log::warn!("Worker: Error reading from source {}: {:?}. Attempting reopen.", i, e); + current_source_states[i] = None; if let Some(shared_state) = data.source_states_shared.get(i) { - if let Ok(mut guard) = shared_state.lock() { *guard = 0; } // Reset UI state - } - // Attempt to reopen the device - *device_opt = hidapi.open( - data.sources_config[i].vendor_id, - data.sources_config[i].product_id - ).ok().and_then(|d| { d.set_blocking_mode(false).ok()?; Some(d) }); // Re-open and set non-blocking - - if device_opt.is_none() { - log::warn!("Reopen failed for source {}.", i); - } else { - log::info!("Reopen successful for source {}.", i); + if let Ok(mut guard) = shared_state.lock() { *guard = 0; } } + // Reopen logic using source_info.config + log::debug!("Worker: Attempting to reopen source[{}]...", i); + *device_opt = hidapi.open_serial( + source_info.config.vendor_id, + source_info.config.product_id, + &source_info.config.serial_number, + ).ok().and_then(|d| d.set_blocking_mode(false).ok().map(|_| d)); // Simplified reopen + if device_opt.is_some() { log::info!("Worker: Reopen successful for source[{}].", i); } + else { log::warn!("Worker: Reopen failed for source[{}].", i); } } } } else { @@ -220,26 +333,15 @@ fn run_hid_worker_loop(hidapi: HidApi, data: WorkerData) { } } - // --- Calculate Final State based on Rules --- + // --- 3. Calculate Final State based on Rules --- let mut final_state: u16 = 0; - write_buffer.fill(0); // Clear write buffer - write_buffer[0] = FEATURE_REPORT_ID; - for bit_pos in 0..8u8 { let mut relevant_values: Vec = Vec::new(); for (source_idx, state_opt) in current_source_states.iter().enumerate() { - // Check if this source is enabled for this bit - if data.sources_config[source_idx].state_enabled[bit_pos as usize] { - if let Some(state_val) = state_opt { - relevant_values.push(read_bit(*state_val, bit_pos)); - } else { - // How to handle missing data? Assume false? Or skip? - // Assuming false if device errored or didn't report - relevant_values.push(false); - } + if data.sources_info[source_idx].config.state_enabled[bit_pos as usize] { + relevant_values.push(state_opt.map_or(false, |s| util::read_bit(s, bit_pos))); } } - if !relevant_values.is_empty() { let modifier = data.shift_modifiers[bit_pos as usize]; let result_bit = match modifier { @@ -247,100 +349,130 @@ fn run_hid_worker_loop(hidapi: HidApi, data: WorkerData) { crate::config::ShiftModifiers::AND => relevant_values.iter().all(|&v| v), crate::config::ShiftModifiers::XOR => relevant_values.iter().fold(false, |acc, &v| acc ^ v), }; - - // Set the corresponding bit in the final state and write buffer - if result_bit { - final_state |= 1 << bit_pos; - // Assuming the state maps directly to bytes 1 and 2 - write_buffer[1] = final_state as u8; // Low byte - write_buffer[2] = (final_state >> 8) as u8; // High byte - } + if result_bit { final_state |= 1 << bit_pos; } } } - // Update shared final state for UI if let Ok(mut guard) = data.final_shift_state_shared.lock() { *guard = final_state; } + // --- End Calculate Final State --- - // --- Write to Receiver Devices --- - let zero_buffer: [u8; REPORT_BUFFER_SIZE] = { - let mut buf = [0u8; REPORT_BUFFER_SIZE]; - buf[0] = FEATURE_REPORT_ID; // Set Report ID 4 - // All other bytes (1-18) remain 0 for the zero state - buf - }; - + // --- 4. Write to Receiver Devices --- for (i, device_opt) in receiver_devices.iter_mut().enumerate() { if let Some(device) = device_opt { - match device.send_feature_report(&zero_buffer) { + let receiver_info = &data.receivers_info[i]; + let receiver_format = receiver_info.format; + + // --- 4a. Send Zero State Report First --- + let zero_buffer_slice = receiver_format.pack_state(&mut write_buffer, 0); + if zero_buffer_slice.is_empty() { /* handle error */ continue; } + + log::trace!("Worker: Sending zero state reset ({} bytes) to receiver[{}] using format '{}'", receiver_format.total_size, i, receiver_format.name); + match device.send_feature_report(zero_buffer_slice) { Ok(_) => { - // Create a temporary buffer potentially filtered by receiver's enabled bits - let mut filtered_write_buffer = write_buffer; // Copy base calculated state - let mut filtered_final_state = final_state; + log::trace!("Worker: Zero state sent successfully to receiver[{}].", i); + + // --- 4b. If Zero Send OK, Prepare and Send Actual State --- + let mut state_to_send = final_state; // Start with the globally calculated state // Apply receiver's enabled mask for bit_pos in 0..8u8 { - if !data.receivers_config[i].state_enabled[bit_pos as usize] { - // If this bit is disabled for this receiver, force it to 0 - filtered_final_state &= !(1 << bit_pos); + if !receiver_info.config.state_enabled[bit_pos as usize] { + state_to_send &= !(1 << bit_pos); } } - // Update buffer bytes based on filtered state - filtered_write_buffer[1] = (filtered_final_state >> 8) as u8; - filtered_write_buffer[2] = filtered_final_state as u8; - filtered_write_buffer[3..19].fill(0); + // --- Start: Read receiver's current state and merge --- + let mut receiver_current_state: u16 = 0; // Default to 0 if read fails + read_buffer[0] = receiver_format.report_id; // Set ID for reading receiver - // --- Optional: Read receiver's current state and merge --- - // This part makes it more complex. If you want the output to *combine* - // with the receiver's own state, you'd read it first. - // For simplicity, let's just *set* the state based on calculation. - // If merging is needed, uncomment and adapt: - read_buffer[0] = FEATURE_REPORT_ID; - if let Ok(bytes) = device.get_feature_report(&mut read_buffer) { - if bytes >= 3 { - let receiver_current_low = read_buffer[1]; - let receiver_current_high = read_buffer[2]; - // Merge logic here, e.g., ORing the states - filtered_write_buffer[1] |= receiver_current_low; - filtered_write_buffer[2] |= receiver_current_high; + log::trace!("Worker: Reading current state from receiver[{}] before merge.", i); + match device.get_feature_report(&mut read_buffer) { + Ok(bytes_read) => { + if let Some(current_state) = receiver_format.unpack_state(&read_buffer[0..bytes_read]) { + log::trace!("Worker: Receiver[{}] current unpacked state: {}", i, current_state); + receiver_current_state = current_state; + } else { + log::warn!("Worker: Failed to unpack current state from receiver {} (bytes read: {}) using format '{}'. Merge will use 0.", i, bytes_read, receiver_format.name); + } + } + Err(e_read) => { + // Log error reading current state, but proceed with merge using 0 + log::warn!("Worker: Error reading current state from receiver[{}]: {:?}. Merge will use 0.", i, e_read); + // Note: Don't attempt reopen here, as we are about to send anyway. + // If send fails later, reopen will be attempted then. } } - // --- End Optional Merge --- - log::debug!( - "Worker: Attempting send to receiver[{}], state: {}, buffer ({} bytes): {:02X?}", - i, - filtered_final_state, - 19, // Log the length being sent - &filtered_write_buffer[0..19] // Log the full slice + // --- Read current state (Optional Merge) --- + read_buffer[0] = receiver_format.report_id; // Use receiver format ID + let mut receiver_current_state: u16 = 0; + log::trace!("Worker: Reading current state from receiver[{}] before merge using format '{}'.", i, receiver_format.name); + match device.get_feature_report(&mut read_buffer) { + Ok(bytes_read) => { + // --- Use correct unpack_state function name --- + if let Some(current_state) = receiver_format.unpack_state(&read_buffer[0..bytes_read]) { + // --- End change --- + receiver_current_state = current_state; + } else { /* warn unpack failed */ } + } + Err(e_read) => { /* warn read failed */ } + } + state_to_send |= receiver_current_state; // Merge + // --- End Read current state --- + + // Use pack_state to prepare the buffer slice with the potentially merged state + let actual_buffer_slice = receiver_format.pack_state( + &mut write_buffer, + state_to_send, // Use the final (potentially merged) state ); + if actual_buffer_slice.is_empty() { /* handle pack error */ continue; } - // Send the potentially filtered feature report - match device.send_feature_report(&filtered_write_buffer[0..REPORT_BUFFER_SIZE]) { + log::debug!( + "Worker: Attempting send final state to receiver[{}], state: {}, buffer ({} bytes): {:02X?}", + i, state_to_send, receiver_format.total_size, actual_buffer_slice + ); + + // Send the actual calculated/merged state + match device.send_feature_report(actual_buffer_slice) { Ok(_) => { - log::debug!("Worker: Send to receiver[{}] successful.", i); - // Successfully sent. Update UI state for this receiver. + log::debug!("Worker: Final state send to receiver[{}] successful.", i); + // Update shared state for UI with the state we just sent if let Some(shared_state) = data.receiver_states_shared.get(i) { if let Ok(mut guard) = shared_state.lock() { - // Update with the state *we sent* - let state_val = merge_u8_into_u16(filtered_write_buffer[1], filtered_write_buffer[2]); - log::debug!("Worker: Updating receiver_states_shared[{}] from {} to {}", i, *guard, state_val); - *guard = state_val; + *guard = state_to_send; // Update with the sent state + } else { + if let Some(shared_state) = data.receiver_states_shared.get(i) { + match shared_state.lock() { + Ok(mut guard) => *guard = 0, + Err(poisoned) => { + log::error!("Mutex for receiver_states_shared[{}] poisoned! Recovering and resetting.", i); + *poisoned.into_inner() = 0; + } + } + } } } } - Err(e) => { - log::warn!("Error writing to receiver {}: {}. Attempting reopen.", i, e); + Err(e_actual) => { + // ... (error handling, reopen logic for send failure) ... + log::warn!("Worker: Error sending final state to receiver[{}]: {:?}", i, e_actual); if let Some(shared_state) = data.receiver_states_shared.get(i) { - if let Ok(mut guard) = shared_state.lock() { *guard = 0; } // Reset UI state + match shared_state.lock() { + Ok(mut guard) => *guard = 0, + Err(poisoned) => { + log::error!("Mutex for receiver_states_shared[{}] poisoned! Recovering and resetting.", i); + *poisoned.into_inner() = 0; + } + } } - // Attempt to reopen + + log::debug!("Worker: Attempting to reopen receiver[{}] after final-send failure...", i); *device_opt = hidapi.open( - data.receivers_config[i].vendor_id, - data.receivers_config[i].product_id, + data.receivers_info[i].config.vendor_id, + data.receivers_info[i].config.product_id, ).ok().and_then(|d| { d.set_blocking_mode(false).ok()?; Some(d) @@ -352,8 +484,8 @@ fn run_hid_worker_loop(hidapi: HidApi, data: WorkerData) { log::info!("Reopen successful for receiver {}.", i); } } - } - } + } // End match send actual state + } // End Ok for zero send Err(e_zero) => { // Handle error sending the zero state reset log::warn!("Worker: Error sending zero state reset to receiver[{}]: {:?}", i, e_zero); @@ -362,8 +494,8 @@ fn run_hid_worker_loop(hidapi: HidApi, data: WorkerData) { if let Ok(mut guard) = shared_state.lock() { *guard = 0; } } log::debug!("Worker: Attempting to reopen receiver[{}] after zero-send failure...", i); - *device_opt = hidapi.open( data.receivers_config[i].vendor_id, - data.receivers_config[i].product_id + *device_opt = hidapi.open( data.receivers_info[i].config.vendor_id, + data.receivers_info[i].config.product_id ).ok().and_then(|d| { d.set_blocking_mode(false).ok()?; Some(d) @@ -389,20 +521,30 @@ fn run_hid_worker_loop(hidapi: HidApi, data: WorkerData) { // --- Cleanup before thread exit --- log::info!("Worker loop finished. Performing cleanup..."); - // Send a 'zero' report to all devices on exit - let cleanup_buffer: [u8; REPORT_BUFFER_SIZE] = { - let mut buf = [0u8; REPORT_BUFFER_SIZE]; - buf[0] = FEATURE_REPORT_ID; // Set Report ID 4 - // All other bytes (1-18) remain 0 for the zero state - buf - }; - for device_opt in source_devices.iter_mut().chain(receiver_devices.iter_mut()) { + for (i, device_opt) in receiver_devices.iter_mut().enumerate() { if let Some(device) = device_opt { - if let Err(e) = device.send_feature_report(&cleanup_buffer) { - log::warn!("Error sending cleanup report: {}", e); + let receiver_info = &data.receivers_info[i]; + let receiver_format = receiver_info.format; + + // --- 4a. Send Zero State Report First --- + let zero_buffer_slice = receiver_format.pack_state(&mut write_buffer, 0); + if zero_buffer_slice.is_empty() { /* handle error */ continue; } + + log::trace!("Worker: Sending zero state reset ({} bytes) to receiver[{}] using format '{}'", receiver_format.total_size, i, receiver_format.name); + match device.send_feature_report(zero_buffer_slice) { + Ok(_) => { + log::trace!("Worker: Zero state sent successfully to receiver[{}].", i); + if let Some(shared_state) = data.receiver_states_shared.get(i) { + if let Ok(mut guard) = shared_state.lock() { *guard = 0; } + } + } + Err(e_actual) => { + if let Some(shared_state) = data.receiver_states_shared.get(i) { + if let Ok(mut guard) = shared_state.lock() { *guard = 0; } + } + } } } } log::info!("Worker thread cleanup complete. Exiting."); - // HidApi and HidDevices are dropped automatically here, closing handles. } diff --git a/src/main.rs b/src/main.rs index 059499b..db23c5f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,6 +15,7 @@ use eframe::{egui, glow}; use fast_config::Config; use std::sync::{Arc, Condvar, Mutex}; use std::time::Duration; +use clap::Parser; // Internal Module Imports use config::{ConfigData}; // Import specific items @@ -30,6 +31,13 @@ const INITIAL_HEIGHT: f32 = 260.0; pub type SharedStateFlag = Arc<(Mutex, Condvar)>; pub type SharedDeviceState = Arc>; // Assuming Condvar isn't strictly needed here +#[derive(Parser, Debug)] +#[command(version, about, long_about = None)] +struct Args { + #[arg(short, long, default_value_t = false)] + skip_firmware: bool, +} + // The main application struct pub struct ShiftTool { // State @@ -205,15 +213,7 @@ fn main() -> eframe::Result<()> { env_logger::init(); // --- Command Line Argument Parsing --- - // If you need args, keep this, otherwise remove clap dependency. - use clap::Parser; - #[derive(Parser, Debug)] - #[command(version, about, long_about = None)] - struct Args { - #[arg(short, long, default_value_t = false)] - skip_firmware: bool, - } - let _args = Args::parse(); + // let _args = Args::parse(); // --- End Argument Parsing --- log::info!("Starting {}", PROGRAM_TITLE); diff --git a/src/util.rs b/src/util.rs index cd540e7..01141fb 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,4 +1,224 @@ -use log::warn; // Use log crate +use clap::Parser; +use chrono::NaiveDate; +use log::{error, info, trace, warn}; + +pub(crate) const FEATURE_REPORT_ID_SHIFT: u8 = 4; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct ReportFormat { + pub name: &'static str, + pub report_id: u8, + pub total_size: usize, + high_byte_idx: usize, + low_byte_idx: usize, +} + +impl ReportFormat { + /// Packs the u16 state into the provided buffer according to this format's rules. + /// + /// It sets the report ID, places the high and low bytes of the state at the + /// correct indices, and zeros out any remaining padding bytes up to `total_size`. + /// Assumes the provided `buffer` is large enough to hold `total_size` bytes. + /// + /// # Arguments + /// * `buffer`: A mutable byte slice, assumed to be large enough (e.g., MAX_REPORT_SIZE). + /// The relevant part (`0..total_size`) will be modified. + /// * `state`: The `u16` state value to pack. + /// + /// # Returns + /// A slice `&'buf [u8]` representing the packed report (`&buffer[0..self.total_size]`). + /// Returns an empty slice if the buffer is too small. + pub fn pack_state<'buf>( + &self, + buffer: &'buf mut [u8], + state: u16, + ) -> &'buf [u8] { + // 1. Safety Check: Ensure buffer is large enough + if buffer.len() < self.total_size { + error!( + "Buffer too small (len={}) for packing report format '{}' (size={})", + buffer.len(), + self.name, + self.total_size + ); + // Return empty slice to indicate error, calling code should handle this + return &[]; + } + + // 2. Clear the portion of the buffer we will use (safer than assuming zeros) + // This handles the zero-padding requirement automatically. + buffer[0..self.total_size].fill(0); + + // 3. Set the Report ID (Byte 0) + buffer[0] = self.report_id; + + // 4. Pack state bytes into their defined indices + // Check indices against buffer length again just in case format is invalid + if self.high_byte_idx != usize::MAX { + if self.high_byte_idx < self.total_size { // Check index within format size + buffer[self.high_byte_idx] = (state >> 8) as u8; + } else { error!("High byte index {} out of bounds for format '{}' (size={})", self.high_byte_idx, self.name, self.total_size); } + } else if (state >> 8) != 0 { + warn!("pack_state ({}): State {} has high byte, but format doesn't support it.", self.name, state); + } + + if self.low_byte_idx < self.total_size { + buffer[self.low_byte_idx] = state as u8; // Low byte + } else { + error!("Low byte index {} out of bounds for format '{}' (size={})", self.low_byte_idx, self.name, self.total_size); + } + + // 5. Return the slice representing the fully packed report + &buffer[0..self.total_size] + } + + /// Unpacks the u16 state from a received buffer slice based on this format's rules. + /// + /// Checks the report ID and minimum length required by the format. + /// Extracts the high and low bytes from the specified indices and merges them. + /// + /// # Arguments + /// * `received_data`: A byte slice containing the data read from the HID device + /// (should include the report ID at index 0). + /// + /// # Returns + /// `Some(u16)` containing the unpacked state if successful, `None` otherwise + /// (e.g., wrong report ID, buffer too short). + pub fn unpack_state(&self, received_data: &[u8]) -> Option { + // 1. Basic Checks: Empty buffer or incorrect Report ID + if received_data.is_empty() || received_data[0] != self.report_id { + trace!( + "unpack_state ({}): Invalid ID (expected {}, got {}) or empty buffer.", + self.name, self.report_id, if received_data.is_empty() { "N/A".to_string() } else { received_data[0].to_string() } + ); + return None; + } + + // 2. Determine minimum length required based on defined indices + // We absolutely need the bytes up to the highest index used. + let low_byte = if received_data.len() > self.low_byte_idx { + received_data[self.low_byte_idx] + } else { + warn!("unpack_state ({}): Received data length {} too short for low byte index {}.", self.name, received_data.len(), self.low_byte_idx); + return None; + }; + + let high_byte = if self.high_byte_idx != usize::MAX { // Does format expect a high byte? + if received_data.len() > self.high_byte_idx { // Did we receive enough data for it? + received_data[self.high_byte_idx] + } else { // Expected high byte, but didn't receive it + trace!("unpack_state ({}): Received data length {} too short for high byte index {}. Assuming 0.", self.name, received_data.len(), self.high_byte_idx); + 0 + } + } else { // Format doesn't define a high byte + 0 + }; + // --- End Graceful Handling --- + + + // 4. Merge bytes + let state = (high_byte as u16) << 8 | (low_byte as u16); + + trace!("unpack_state ({}): Extracted state {}", self.name, state); + Some(state) + } +} + +const FORMAT_ORIGINAL: ReportFormat = ReportFormat { + name: "Original (Size 3)", // Add name + report_id: FEATURE_REPORT_ID_SHIFT, + total_size: 3, + high_byte_idx: 1, + low_byte_idx: 2, +}; + +const FORMAT_THROTTLE: ReportFormat = ReportFormat { + name: "Original Throttle (Size 2)", // Add name + report_id: FEATURE_REPORT_ID_SHIFT, + total_size: 2, + high_byte_idx: usize::MAX, + low_byte_idx: 1, +}; + +const FORMAT_NEW: ReportFormat = ReportFormat { + name: "NEW (Size 19)", // Add name + report_id: FEATURE_REPORT_ID_SHIFT, + total_size: 19, + high_byte_idx: 1, + low_byte_idx: 2, +}; + +struct FormatRule { + // Criteria: Function that takes firmware string and returns true if it matches + matches: fn(&str, &str) -> bool, + // Result: The format to use if criteria matches + format: ReportFormat, +} + +const FORMAT_RULES: &[FormatRule] = &[ + // Rule 1: Check for Original format based on date for Throttles + FormatRule { + matches: |name, fw| { + if name.contains("Throttle") == false { + return false + } + const THRESHOLD: &str = "2024-12-26"; + let date_str = fw.split_whitespace().last().unwrap_or(""); + if date_str.len() == 8 { + if let Ok(fw_date) = NaiveDate::parse_from_str(date_str, "%Y%m%d") { + if let Ok(t_date) = NaiveDate::parse_from_str(THRESHOLD, "%Y-%m-%d") { + return fw_date < t_date; // Return true if older + } + } + } + false // Don't match if parsing fails or format wrong + }, + format: FORMAT_THROTTLE, + }, + // Rule 2: Check for Original format based on date + FormatRule { + matches: |name, fw| { + const THRESHOLD: &str = "2024-12-26"; + let date_str = fw.split_whitespace().last().unwrap_or(""); + if date_str.len() == 8 { + if let Ok(fw_date) = NaiveDate::parse_from_str(date_str, "%Y%m%d") { + if let Ok(t_date) = NaiveDate::parse_from_str(THRESHOLD, "%Y-%m-%d") { + return fw_date < t_date; // Return true if older + } + } + } + false // Don't match if parsing fails or format wrong + }, + format: FORMAT_ORIGINAL, + }, + // Rule 2: Add more rules here if needed (e.g., for FORMAT_MIDDLE) + // FormatRule { matches: |fw| fw.contains("SPECIAL"), format: FORMAT_MIDDLE }, + + // Rule N: Default rule (matches anything if previous rules didn't) + // This isn't strictly needed if we have a default below, but can be explicit. + // FormatRule { matches: |_| true, format: FORMAT_NEW }, +]; + +// --- The main function to determine the format --- +pub(crate) fn determine_report_format(name: &str, firmware: &str) -> ReportFormat { + // Iterate through the rules + for rule in FORMAT_RULES { + if (rule.matches)(name, firmware) { + trace!("Device '{}' Firmware '{}' matched rule for format '{}'", name, firmware, rule.format.name); + return rule.format; + } + } + + // If no rules matched, return a default (e.g., the newest format) + let default_format = FORMAT_NEW; // Define the default + warn!( + "Firmware '{}' did not match any specific rules. Defaulting to format '{}'", + firmware, default_format.name + ); + default_format +} + +pub(crate) const MAX_REPORT_SIZE: usize = FORMAT_NEW.total_size; /// Reads a specific bit from a u16 value. /// `position` is 0-indexed (0-15). @@ -34,17 +254,16 @@ pub(crate) fn merge_u8_into_u16(high_byte: u8, low_byte: u8) -> u16 { /// TODO: Implement actual firmware checking logic if needed. pub(crate) fn is_supported(firmware_string: String) -> bool { // Currently allows all devices. - // If you re-enable firmware checking, use the `args` or a config setting. - // let args = crate::main::Args::parse(); // Need to handle args properly - // if args.skip_firmware { return true; } + let args = crate::Args::parse(); // Need to handle args properly + if args.skip_firmware { return true; } // Example fixed list check: // let supported_firmware = [ - // "VIRPIL Controls 20220720", - // "VIRPIL Controls 20230328", - // "VIRPIL Controls 20240323", + // // "VIRPIL Controls 20220720", + // // "VIRPIL Controls 20230328", + // // "VIRPIL Controls 20240323", + // "VIRPIL Controls 20241226", // ]; - // supported_firmware.contains(&firmware_string.as_str()) if firmware_string.is_empty() || firmware_string == "Unknown Firmware" { warn!("Device has missing or unknown firmware string."); From d4f7b00323bae48e0b64d36d170c1e4febe2d4bd Mon Sep 17 00:00:00 2001 From: raven <7156279+RavenX8@users.noreply.github.com> Date: Fri, 28 Mar 2025 22:43:24 -0400 Subject: [PATCH 4/4] Updated old firmware report format --- src/util.rs | 31 ++----------------------------- 1 file changed, 2 insertions(+), 29 deletions(-) diff --git a/src/util.rs b/src/util.rs index 01141fb..b09b143 100644 --- a/src/util.rs +++ b/src/util.rs @@ -125,15 +125,7 @@ impl ReportFormat { } const FORMAT_ORIGINAL: ReportFormat = ReportFormat { - name: "Original (Size 3)", // Add name - report_id: FEATURE_REPORT_ID_SHIFT, - total_size: 3, - high_byte_idx: 1, - low_byte_idx: 2, -}; - -const FORMAT_THROTTLE: ReportFormat = ReportFormat { - name: "Original Throttle (Size 2)", // Add name + name: "Original (Size 2)", // Add name report_id: FEATURE_REPORT_ID_SHIFT, total_size: 2, high_byte_idx: usize::MAX, @@ -156,26 +148,7 @@ struct FormatRule { } const FORMAT_RULES: &[FormatRule] = &[ - // Rule 1: Check for Original format based on date for Throttles - FormatRule { - matches: |name, fw| { - if name.contains("Throttle") == false { - return false - } - const THRESHOLD: &str = "2024-12-26"; - let date_str = fw.split_whitespace().last().unwrap_or(""); - if date_str.len() == 8 { - if let Ok(fw_date) = NaiveDate::parse_from_str(date_str, "%Y%m%d") { - if let Ok(t_date) = NaiveDate::parse_from_str(THRESHOLD, "%Y-%m-%d") { - return fw_date < t_date; // Return true if older - } - } - } - false // Don't match if parsing fails or format wrong - }, - format: FORMAT_THROTTLE, - }, - // Rule 2: Check for Original format based on date + // Rule 1: Check for Original format based on date FormatRule { matches: |name, fw| { const THRESHOLD: &str = "2024-12-26";