Merge pull request 'code clean-up' (#1) from clean-up into main
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
66
Cargo.lock
generated
66
Cargo.lock
generated
@@ -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"
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
18
src/about.rs
18
src/about.rs
@@ -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
73
src/config.rs
Normal 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
264
src/device.rs
Normal 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
550
src/hid_worker.rs
Normal 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.");
|
||||||
|
}
|
||||||
1104
src/main.rs
1104
src/main.rs
File diff suppressed because it is too large
Load Diff
7
src/state.rs
Normal file
7
src/state.rs
Normal 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
566
src/ui.rs
Normal 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
246
src/util.rs
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user