Merge pull request 'code clean-up' (#1) from clean-up into main
Some checks failed
Makefile CI / Release - Linux-x86_64 (push) Failing after 23s
Makefile CI / Release - Windows-x86_64 (push) Has been cancelled

Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2025-03-28 22:50:39 -04:00
10 changed files with 1946 additions and 965 deletions

66
Cargo.lock generated
View File

@@ -93,7 +93,7 @@ dependencies = [
"paste", "paste",
"static_assertions", "static_assertions",
"windows", "windows",
"windows-core", "windows-core 0.58.0",
] ]
[[package]] [[package]]
@@ -165,6 +165,12 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04"
[[package]]
name = "android-tzdata"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0"
[[package]] [[package]]
name = "android_system_properties" name = "android_system_properties"
version = "0.1.5" version = "0.1.5"
@@ -650,6 +656,20 @@ dependencies = [
"libc", "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]] [[package]]
name = "clap" name = "clap"
version = "4.5.27" version = "4.5.27"
@@ -1523,6 +1543,30 @@ version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" 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]] [[package]]
name = "icu_collections" name = "icu_collections"
version = "1.5.0" version = "1.5.0"
@@ -2710,8 +2754,9 @@ dependencies = [
[[package]] [[package]]
name = "shift_tool" name = "shift_tool"
version = "0.3.0" version = "0.4.0"
dependencies = [ dependencies = [
"chrono",
"clap", "clap",
"dirs", "dirs",
"eframe", "eframe",
@@ -3481,7 +3526,16 @@ version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6"
dependencies = [ 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", "windows-targets 0.52.6",
] ]
@@ -3520,6 +3574,12 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "windows-link"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38"
[[package]] [[package]]
name = "windows-result" name = "windows-result"
version = "0.2.0" version = "0.2.0"

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "shift_tool" name = "shift_tool"
version = "0.3.0" version = "0.4.0"
edition = "2021" edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@@ -14,6 +14,7 @@ hidapi = "2.6.1"
log = "0.4.21" log = "0.4.21"
serde = { version = "1.0.197", features = ["derive"] } serde = { version = "1.0.197", features = ["derive"] }
dirs = { version = "6.0.0", features = [] } dirs = { version = "6.0.0", features = [] }
chrono = "0.4.40"
[features] [features]

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 \ "This program was designed to replicate the VPC Shift Tool functions \
bundled with the VirPil control software package.", bundled with the VirPil control software package.".to_string(),
"\n", "\n".to_string(),
"Shift Tool Copyright (C) 2024-2025 RavenX8", "Shift Tool Copyright (C) 2024-2025 RavenX8".to_string(),
"This program comes with ABSOLUTELY NO WARRANTY. "This program comes with ABSOLUTELY NO WARRANTY.
This is free software, and you are welcome to redistribute it This is free software, and you are welcome to redistribute it
under certain conditions.", under certain conditions.".to_string(),
"License: GNU General Public License v3.0", "License: GNU General Public License v3.0".to_string(),
"Author: RavenX8", "Author: RavenX8".to_string(),
"https://github.com/RavenX8/vpc-shift-tool", "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]
}
}

264
src/device.rs Normal file
View File

@@ -0,0 +1,264 @@
use hidapi::{DeviceInfo, HidApi, HidError};
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
// 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) {
trace!("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(),
) {
debug!("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;
debug!(
"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) {
for i in 0..self.config.data.sources.len() {
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) {
// 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_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!(
"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
);
}
}
}
}
/// 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,
})
}

550
src/hid_worker.rs Normal file
View File

@@ -0,0 +1,550 @@
use crate::config::{ModifiersArray};
use crate::device::SavedDevice;
use crate::{SharedDeviceState, SharedStateFlag}; // Import shared types
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},
thread,
time::Duration,
};
// Constants for HID communication
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_info: Vec<DeviceWorkerInfo>,
receivers_info: Vec<DeviceWorkerInfo>,
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 {
info!("Attempting to spawn HID worker thread...");
let mut sources_info: Vec<DeviceWorkerInfo> = 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<DeviceWorkerInfo> = 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_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(),
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) => {
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) => {
error!("Failed to create HidApi in worker thread: {}", e);
// How to signal failure back? Could use another shared state.
// For now, thread just exits.
}
}
});
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) {
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;
}
info!("Worker stop cleanup finished.");
}
}
/// 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_infos: &[DeviceWorkerInfo], // Accepts a slice of the new struct
) -> Vec<Option<HidDevice>> {
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::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,
) {
Ok(device) => {
// Log success with format info for context
log::info!(
"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
);
// Attempt to set non-blocking mode
if let Err(e) = device.set_blocking_mode(false) {
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 slot {}: VID={:04X}, PID={:04X}, SN='{}': {:?}",
i, 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_info);
let mut receiver_devices = open_hid_devices(&hidapi, &data.receivers_info);
// Buffers for HID reports
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;
loop {
// --- Check Run State ---
let should_run = { // Scope for mutex guard
match run_lock.lock() {
Ok(guard) => *guard,
Err(poisoned) => {
error!("Run state mutex poisoned in worker loop!");
false
}
}
};
if !should_run {
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()];
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 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
}
}
}
Err(e) => {
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; }
}
// 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 {
// 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
}
}
}
// --- 3. Calculate Final State based on Rules ---
let mut final_state: u16 = 0;
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() {
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 {
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),
};
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 ---
// --- 4. Write to Receiver Devices ---
for (i, device_opt) in receiver_devices.iter_mut().enumerate() {
if let Some(device) = device_opt {
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);
// --- 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 !receiver_info.config.state_enabled[bit_pos as usize] {
state_to_send &= !(1 << bit_pos);
}
}
// --- 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
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.
}
}
// --- 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; }
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: 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() {
*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_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) {
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;
}
}
}
log::debug!("Worker: Attempting to reopen receiver[{}] after final-send failure...", i);
*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)
});
if device_opt.is_none() {
log::warn!("Reopen failed for receiver {}.", i);
} else {
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);
// 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_info[i].config.vendor_id,
data.receivers_info[i].config.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...");
for (i, device_opt) in receiver_devices.iter_mut().enumerate() {
if let Some(device) = device_opt {
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.");
}

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
}

566
src/ui.rs Normal file
View File

@@ -0,0 +1,566 @@
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.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;
}
if ui.button("Exit").clicked() {
// Ask eframe to close the window. `on_exit` will be called.
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
}

246
src/util.rs Normal file
View File

@@ -0,0 +1,246 @@
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<u16> {
// 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 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
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).
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.
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 20241226",
// ];
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
}