code clean-up #1

Merged
Raven merged 4 commits from clean-up into main 2025-03-28 22:50:50 -04:00
10 changed files with 1549 additions and 967 deletions
Showing only changes of commit e2053f0d67 - Show all commits

2
Cargo.lock generated
View File

@@ -2710,7 +2710,7 @@ dependencies = [
[[package]]
name = "shift_tool"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"clap",
"dirs",

View File

@@ -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

View File

@@ -1,14 +1,14 @@
pub fn about() -> [&'static str; 7] {
[
pub fn about() -> Vec<String> {
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(),
]
}

73
src/config.rs Normal file
View File

@@ -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<crate::device::SavedDevice>,
#[serde(default)]
pub receivers: Vec<crate::device::SavedDevice>,
#[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<usize> for ModifiersArray {
type Output = ShiftModifiers;
fn index(&self, index: usize) -> &ShiftModifiers {
&self.data[index]
}
}
// Allow mutable indexing like `modifiers_array[i] = ...`
impl IndexMut<usize> for ModifiersArray {
fn index_mut(&mut self, index: usize) -> &mut ShiftModifiers {
&mut self.data[index]
}
}

267
src/device.rs Normal file
View File

@@ -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<String>, // Product String
pub firmware: Rc<String>, // 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<VpcDevice> = 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<VpcDevice> {
// ... (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,
})
}

406
src/hid_worker.rs Normal file
View File

@@ -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<SavedDevice>,
receivers_config: Vec<SavedDevice>,
shift_modifiers: ModifiersArray,
source_states_shared: Vec<SharedDeviceState>,
receiver_states_shared: Vec<SharedDeviceState>,
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<Option<HidDevice>> { // Return Option<HidDevice> 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<Option<u16>> = 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<bool> = 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.
}

File diff suppressed because it is too large Load Diff

7
src/state.rs Normal file
View File

@@ -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
}

561
src/ui.rs Normal file
View File

@@ -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);
}
}

54
src/util.rs Normal file
View File

@@ -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
}