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
This commit is contained in:
2025-03-28 18:58:06 -04:00
parent 7a35b65f2c
commit e2053f0d67
10 changed files with 1549 additions and 967 deletions

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
}