Compare commits

13 Commits
1.0.0 ... main

Author SHA1 Message Date
c9f8325da9 Add unit tests for core components
Some checks failed
Makefile CI / Release - Linux-x86_64 (push) Has been cancelled
Makefile CI / Release - Windows-x86_64 (push) Has been cancelled
2025-04-10 13:44:53 -04:00
2bb5ce0327 Rename udev rules file and update documentation
- Renamed udev rules file from 99-vpc.rules to 70-vpc.rules for better priority
- Updated all references to the file in Makefile and documentation
- Added instructions for manually creating udev rules when using precompiled binaries
- Improved installation instructions for both Windows and Linux platforms
2025-04-10 13:44:52 -04:00
9b43f600b8 cleaned up unused imports
Some checks failed
Makefile CI / Release - Linux-x86_64 (push) Failing after 23s
Makefile CI / Release - Windows-x86_64 (push) Has been cancelled
2025-03-30 13:25:37 -04:00
75230a5cc7 cleaned up unused functions
Some checks failed
Makefile CI / Release - Linux-x86_64 (push) Failing after 1m13s
Makefile CI / Release - Windows-x86_64 (push) Has been cancelled
2025-03-30 13:21:05 -04:00
c313131a88 set the width for both columns so the GUI doesn't auto resize when there are no devices selected
Some checks failed
Makefile CI / Release - Linux-x86_64 (push) Failing after 44s
Makefile CI / Release - Windows-x86_64 (push) Has been cancelled
2025-03-30 13:12:29 -04:00
8a889aef95 Removed duplicated code in worker thread
Some checks failed
Makefile CI / Release - Linux-x86_64 (push) Failing after 45s
Makefile CI / Release - Windows-x86_64 (push) Has been cancelled
2025-03-29 10:29:03 -04:00
644cfa4128 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
2025-03-28 22:50:39 -04:00
d4f7b00323 Updated old firmware report format
Some checks failed
Makefile CI / Release - Linux-x86_64 (pull_request) Failing after 1m31s
Makefile CI / Release - Windows-x86_64 (pull_request) Has been cancelled
2025-03-28 22:43:24 -04:00
bd9f94c244 Added the ability to support different firmware report versions 2025-03-28 22:26:52 -04:00
4df9ce4d49 Fixed issue where device would reboot when stopping (was sending an invalid clear buffer payload)
Added a manual device list refresh button
Updated device list validator function to not remove a device from the config just because it's not available
2025-03-28 19:34:12 -04:00
e2053f0d67 Code rewrite to make the code more modular.
Fixed issue where newer firmware "20241226" wouldn't actually work correctly.

Needs testing on older firmware to see if they still work
2025-03-28 18:58:06 -04:00
7a35b65f2c Update .github/workflows/makefile.yml
Some checks failed
Makefile CI / Release - Linux-x86_64 (push) Failing after 7m51s
Makefile CI / Release - Windows-x86_64 (push) Has been cancelled
2025-01-30 01:03:36 -05:00
8924f088ba - update: about page updates 2025-01-29 18:02:58 -05:00
20 changed files with 2730 additions and 1000 deletions

View File

@@ -2,9 +2,9 @@ name: Makefile CI
on: on:
push: push:
branches: [ "trunk" ] branches: [ "main" ]
pull_request: pull_request:
branches: [ "trunk" ] branches: [ "main" ]
env: env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always

2
.gitignore vendored
View File

@@ -4,7 +4,7 @@
### C++ template ### C++ template
# Prerequisites # Prerequisites
*.d #*.d
# Compiled Object files # Compiled Object files
*.slo *.slo

135
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,135 @@
# Contributing to OpenVPC Shift Tool
Thank you for your interest in contributing to the OpenVPC Shift Tool! This document provides guidelines and instructions for contributing to the project.
## Code of Conduct
Please be respectful and considerate of others when contributing to this project. We aim to foster an inclusive and welcoming community.
## Getting Started
1. Fork the repository on GitHub
2. Clone your fork locally:
```
git clone https://github.com/YOUR-USERNAME/vpc-shift-tool.git
cd vpc-shift-tool
```
3. Add the original repository as an upstream remote:
```
git remote add upstream https://github.com/RavenX8/vpc-shift-tool.git
```
4. Create a new branch for your changes:
```
git checkout -b feature/your-feature-name
```
## Development Environment Setup
1. Install the Rust toolchain from [rustup.rs](https://rustup.rs/)
2. Install dependencies:
- Windows: No additional dependencies required
- Linux: `sudo apt install libudev-dev pkg-config` (Ubuntu/Debian)
3. Build the project:
```
cargo build
```
4. Run the application:
```
cargo run
```
## Making Changes
1. Make your changes to the codebase
2. Write or update tests as necessary
3. Ensure all tests pass:
```
cargo test
```
4. Format your code:
```
cargo fmt
```
5. Run the linter:
```
cargo clippy
```
## Commit Guidelines
- Use clear and descriptive commit messages
- Reference issue numbers in your commit messages when applicable
- Keep commits focused on a single change
- Use the present tense ("Add feature" not "Added feature")
## Pull Request Process
1. Update your fork with the latest changes from upstream:
```
git fetch upstream
git rebase upstream/main
```
2. Push your changes to your fork:
```
git push origin feature/your-feature-name
```
3. Create a pull request through the GitHub interface
4. Ensure your PR description clearly describes the changes and their purpose
5. Link any related issues in the PR description
## Code Style
- Follow the Rust style guidelines
- Use meaningful variable and function names
- Add comments for complex logic
- Document public functions and types
## Project Structure
- `src/main.rs`: Application entry point and main structure
- `src/about.rs`: About screen information
- `src/config.rs`: Configuration handling
- `src/device.rs`: Device representation and management
- `src/hid_worker.rs`: HID communication worker thread
- `src/state.rs`: Application state management
- `src/ui.rs`: User interface components
- `src/util.rs`: Utility functions and constants
## Adding Support for New Devices
If you're adding support for new device types:
1. Update the device detection logic in `device.rs`
2. Add any necessary report format definitions in `util.rs`
3. Test with the actual hardware if possible
4. Document the new device support in your PR
## Testing
- Write unit tests for new functionality
- Test on both Windows and Linux if possible
- Test with actual VirPil hardware if available
## Documentation
- Update the README.md with any new features or changes
- Document new functions and types with rustdoc comments
- Update TECHNICAL.md for significant architectural changes
## Reporting Issues
If you find a bug or have a suggestion for improvement:
1. Check if the issue already exists in the [GitHub Issues](https://github.com/RavenX8/vpc-shift-tool/issues)
2. If not, create a new issue with:
- A clear title and description
- Steps to reproduce the issue
- Expected and actual behavior
- System information (OS, Rust version, etc.)
- Screenshots if applicable
## License
By contributing to this project, you agree that your contributions will be licensed under the project's [GNU General Public License v3.0](LICENSE).

158
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"
@@ -371,7 +377,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.96",
] ]
[[package]] [[package]]
@@ -406,7 +412,7 @@ checksum = "3f934833b4b7233644e5848f235df3f57ed8c80f1528a26c3dfa13d2147fa056"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.96",
] ]
[[package]] [[package]]
@@ -559,7 +565,7 @@ checksum = "3fa76293b4f7bb636ab88fd78228235b5248b4d05cc589aed610f954af5d7c7a"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.96",
] ]
[[package]] [[package]]
@@ -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"
@@ -681,7 +701,7 @@ dependencies = [
"heck", "heck",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.96",
] ]
[[package]] [[package]]
@@ -869,7 +889,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.96",
] ]
[[package]] [[package]]
@@ -1053,7 +1073,7 @@ checksum = "fc4caf64a58d7a6d65ab00639b046ff54399a39f5f2554728895ace4b297cd79"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.96",
] ]
[[package]] [[package]]
@@ -1208,7 +1228,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.96",
] ]
[[package]] [[package]]
@@ -1259,7 +1279,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.96",
] ]
[[package]] [[package]]
@@ -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"
@@ -1638,7 +1682,7 @@ checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.96",
] ]
[[package]] [[package]]
@@ -1893,6 +1937,26 @@ dependencies = [
"simd-adler32", "simd-adler32",
] ]
[[package]]
name = "mock-it"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c79f6245a4564f117ae4e640a829a7b425e641ca3e6ea279d74a9caf05d2daf"
dependencies = [
"mock-it_codegen",
]
[[package]]
name = "mock-it_codegen"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8a887ee7c909093b773c59ee57412f0fd29d2f262905eeea721cfc31a38e18f"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]] [[package]]
name = "naga" name = "naga"
version = "23.1.0" version = "23.1.0"
@@ -1999,7 +2063,7 @@ dependencies = [
"proc-macro-crate", "proc-macro-crate",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.96",
] ]
[[package]] [[package]]
@@ -2326,7 +2390,7 @@ dependencies = [
"pest_meta", "pest_meta",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.96",
] ]
[[package]] [[package]]
@@ -2357,7 +2421,7 @@ checksum = "d56a66c0c55993aa927429d0f8a0abfd74f084e4d9c192cffed01e418d83eefb"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.96",
] ]
[[package]] [[package]]
@@ -2660,7 +2724,7 @@ checksum = "5a9bf7cf98d04a2b28aead066b7496853d4779c9cc183c440dbac457641e19a0"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.96",
] ]
[[package]] [[package]]
@@ -2683,7 +2747,7 @@ checksum = "6c64451ba24fc7a6a2d60fc75dd9c83c90903b19028d4eff35e88fc1e86564e9"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.96",
] ]
[[package]] [[package]]
@@ -2710,8 +2774,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",
@@ -2719,6 +2784,7 @@ dependencies = [
"fast_config", "fast_config",
"hidapi", "hidapi",
"log", "log",
"mock-it",
"serde", "serde",
] ]
@@ -2845,6 +2911,17 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "syn"
version = "1.0.109"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.96" version = "2.0.96"
@@ -2864,7 +2941,7 @@ checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.96",
] ]
[[package]] [[package]]
@@ -2916,7 +2993,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.96",
] ]
[[package]] [[package]]
@@ -2927,7 +3004,7 @@ checksum = "26afc1baea8a989337eeb52b6e72a039780ce45c3edfcc9c5b9d112feeb173c2"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.96",
] ]
[[package]] [[package]]
@@ -3001,7 +3078,7 @@ checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.96",
] ]
[[package]] [[package]]
@@ -3148,7 +3225,7 @@ dependencies = [
"log", "log",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.96",
"wasm-bindgen-shared", "wasm-bindgen-shared",
] ]
@@ -3183,7 +3260,7 @@ checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.96",
"wasm-bindgen-backend", "wasm-bindgen-backend",
"wasm-bindgen-shared", "wasm-bindgen-shared",
] ]
@@ -3481,7 +3558,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",
] ]
@@ -3506,7 +3592,7 @@ checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.96",
] ]
[[package]] [[package]]
@@ -3517,9 +3603,15 @@ checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.96",
] ]
[[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"
@@ -3919,7 +4011,7 @@ checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.96",
"synstructure", "synstructure",
] ]
@@ -3979,7 +4071,7 @@ checksum = "709ab20fc57cb22af85be7b360239563209258430bccf38d8b979c5a2ae3ecce"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.96",
"zbus-lockstep", "zbus-lockstep",
"zbus_xml", "zbus_xml",
"zvariant", "zvariant",
@@ -3994,7 +4086,7 @@ dependencies = [
"proc-macro-crate", "proc-macro-crate",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.96",
"zvariant_utils", "zvariant_utils",
] ]
@@ -4040,7 +4132,7 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.96",
] ]
[[package]] [[package]]
@@ -4060,7 +4152,7 @@ checksum = "595eed982f7d355beb85837f651fa22e90b3c044842dc7f2c2842c086f295808"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.96",
"synstructure", "synstructure",
] ]
@@ -4083,7 +4175,7 @@ checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.96",
] ]
[[package]] [[package]]
@@ -4108,7 +4200,7 @@ dependencies = [
"proc-macro-crate", "proc-macro-crate",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.96",
"zvariant_utils", "zvariant_utils",
] ]
@@ -4120,5 +4212,5 @@ checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.96",
] ]

View File

@@ -1,10 +1,18 @@
[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
[[bin]]
name = "shift_tool"
path = "src/main.rs"
[lib]
name = "vpc_shift_tool"
path = "src/lib.rs"
[dependencies] [dependencies]
clap = { version = "4.5.4", features = ["derive"] } clap = { version = "4.5.4", features = ["derive"] }
eframe = "0.30.0" eframe = "0.30.0"
@@ -14,9 +22,13 @@ 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]
logging = [] logging = []
default = ["logging"] default = ["logging"]
[dev-dependencies]
mock-it = "0.9.0"

150
INSTALL.md Normal file
View File

@@ -0,0 +1,150 @@
# Installation Guide for OpenVPC Shift Tool
This guide provides detailed instructions for installing and setting up the OpenVPC Shift Tool on different operating systems.
## Windows Installation
### Using Pre-built Binary
1. Download the Windows build artifact (`Windows-x86_64_build`) from the [GitHub Actions](https://github.com/RavenX8/vpc-shift-tool/actions) page by selecting the most recent successful workflow run
2. The downloaded artifact is the executable binary itself (shift_tool.exe)
3. Place it in a location of your choice and run it
### Building from Source on Windows
1. Install the Rust toolchain from [rustup.rs](https://rustup.rs/)
2. Open Command Prompt or PowerShell
3. Clone the repository:
```
git clone https://github.com/RavenX8/vpc-shift-tool.git
cd vpc-shift-tool
```
4. Build the release version:
```
cargo build --release
```
5. The executable will be available at `target\release\shift_tool.exe`
## Linux Installation
### Using Pre-built Binary
1. Download the Linux build artifact (`Linux-x86_64_build`) from the [GitHub Actions](https://github.com/RavenX8/vpc-shift-tool/actions) page by selecting the most recent successful workflow run
2. The downloaded artifact is the executable binary itself, no extraction needed. Just make it executable:
```
chmod +x Linux-x86_64_build
# Optionally rename it to something more convenient
mv Linux-x86_64_build shift_tool
```
3. Create and install the udev rules for device access:
```
sudo mkdir -p /etc/udev/rules.d/
# Create the udev rule file
echo -e '# Virpil Control devices\nSUBSYSTEM=="usb", ATTRS{idVendor}=="3344", TAG+="uaccess", GROUP:="input"' | sudo tee /etc/udev/rules.d/70-vpc.rules
sudo udevadm control --reload-rules
sudo udevadm trigger
```
4. Make the binary executable and run it:
```
chmod +x shift_tool
./shift_tool
```
### Building from Source on Linux
1. Install dependencies:
```
# Ubuntu/Debian
sudo apt install build-essential libudev-dev pkg-config
# Fedora
sudo dnf install gcc libudev-devel pkgconfig
# Arch Linux
sudo pacman -S base-devel
```
2. Install the Rust toolchain from [rustup.rs](https://rustup.rs/)
3. Clone the repository:
```
git clone https://github.com/RavenX8/vpc-shift-tool.git
cd vpc-shift-tool
```
4. Build and install using the Makefile:
```
make
sudo make install
```
Or manually:
```
cargo build --release
sudo cp target/release/shift_tool /usr/local/bin/
sudo mkdir -p /etc/udev/rules.d/
sudo cp udev/rules.d/70-vpc.rules /etc/udev/rules.d/
sudo udevadm control --reload-rules
sudo udevadm trigger
```
## Verifying Installation
After installation, you can verify that the application can detect your VirPil devices:
1. Connect your VirPil device(s) to your computer
2. Launch the OpenVPC Shift Tool
3. Click the "Refresh Devices" button
4. Your devices should appear in the dropdown menus
If devices are not detected:
- On Windows, ensure you have the correct drivers installed
- On Linux, verify the udev rules are installed correctly and you've reloaded the rules
- Check that your devices are properly connected and powered on
## Configuration Location
The application stores its configuration in:
- Windows: `%APPDATA%\shift_tool.json`
- Linux: `~/.config/shift_tool.json`
## Uninstallation
### Windows
Simply delete the application files.
### Linux
If installed using the Makefile:
```
sudo rm /usr/local/bin/shift_tool
sudo rm /etc/udev/rules.d/70-vpc.rules
```
## Troubleshooting
### Linux Permission Issues
If you encounter permission issues accessing the devices on Linux:
1. Ensure the udev rules are installed correctly
2. Add your user to the input group:
```
sudo usermod -a -G input $USER
```
3. Log out and log back in, or reboot your system
4. Verify your user is in the input group:
```
groups $USER
```
### Windows Device Access Issues
If the application cannot access devices on Windows:
1. Ensure no other application (like the official VPC software) is currently using the devices
2. Try running the application as Administrator (right-click, "Run as Administrator")
3. Check Device Manager to ensure the devices are properly recognized by Windows

View File

@@ -25,7 +25,7 @@ install:
install -d $(DESTDIR)$(PREFIX)/bin install -d $(DESTDIR)$(PREFIX)/bin
install -d $(DESTDIR)/etc/udev/rules.d install -d $(DESTDIR)/etc/udev/rules.d
install -m 0755 target/$(target)/$(prog)$(extension) $(DESTDIR)$(PREFIX)/bin install -m 0755 target/$(target)/$(prog)$(extension) $(DESTDIR)$(PREFIX)/bin
install -m 0644 udev/rules.d/99-vpc.rules $(DESTDIR)/etc/udev/rules.d install -m 0644 udev/rules.d/70-vpc.rules $(DESTDIR)/etc/udev/rules.d
clean: clean:
cargo clean cargo clean

110
README.md
View File

@@ -1,2 +1,110 @@
# vpc-shift-tool # OpenVPC - Shift Tool
A free and open-source alternative to the VPC Shift Tool bundled with the VirPil control software package.
## Overview
OpenVPC Shift Tool is a utility designed for VirPil flight simulation controllers. It allows you to combine button inputs from multiple VirPil devices using logical operations (OR, AND, XOR), creating a "shift state" that can be sent to receiver devices. This enables more complex control schemes and button combinations for flight simulators.
## Features
- Connect to multiple VirPil devices simultaneously
- Configure source devices that provide button inputs
- Set up receiver devices that receive the combined shift state
- Choose between different logical operations (OR, AND, XOR) for each bit
- Automatic device detection for VirPil hardware
- Configuration saving and loading
- Cross-platform support (Windows and Linux)
## Installation
### Pre-built Binaries
Pre-built binaries for Windows and Linux are available in the GitHub Actions artifacts for each commit. You can find them by:
1. Going to the [Actions tab](https://github.com/RavenX8/vpc-shift-tool/actions)
2. Selecting the most recent successful workflow run
3. Downloading the appropriate artifact for your platform (Linux-x86_64_build or Windows-x86_64_build)
### Building from Source
#### Prerequisites
- Rust toolchain (install from [rustup.rs](https://rustup.rs/))
- For Linux: libudev-dev package
#### Build Steps
```bash
# Clone the repository
git clone https://github.com/RavenX8/vpc-shift-tool.git
cd vpc-shift-tool
# Build the release version
cargo build --release
```
The compiled binary will be available in `target/release/`.
### Linux Installation
On Linux, you need to install udev rules to access VirPil devices without root privileges:
```bash
# Using the Makefile
sudo make install
# Or manually
sudo cp udev/rules.d/70-vpc.rules /etc/udev/rules.d/
sudo udevadm control --reload-rules
sudo udevadm trigger
```
## Usage
1. Launch the application
2. The main interface shows three sections:
- **Sources**: Devices that provide button inputs
- **Rules**: Logical operations to apply to each bit
- **Receivers**: Devices that receive the combined shift state
3. Add source devices by selecting them from the dropdown menu
4. Configure which bits are active for each source
5. Set the logical operation (OR, AND, XOR) for each bit in the Rules section
6. Add receiver devices that will receive the combined shift state
7. Click "Start" to begin the shift operation
## Configuration
The application automatically saves your configuration to:
- Windows: `%APPDATA%\shift_tool.json`
- Linux: `~/.config/shift_tool.json`
## Troubleshooting
### Device Not Detected
- Ensure your VirPil devices are properly connected
- On Linux, verify udev rules are installed correctly
- Try refreshing the device list with the "Refresh Devices" button
### Permission Issues on Linux
If you encounter permission issues accessing the devices on Linux:
1. Ensure the udev rules are installed correctly
2. Log out and log back in, or reboot your system
3. Verify your user is in the "input" group: `groups $USER`
## License
GNU General Public License v3.0
## Author
RavenX8
## Links
- [GitHub Repository](https://github.com/RavenX8/vpc-shift-tool)
- [Gitea Repository](https://gitea.azgstudio.com/Raven/vpc-shift-tool)

116
TECHNICAL.md Normal file
View File

@@ -0,0 +1,116 @@
# OpenVPC Shift Tool - Technical Documentation
This document provides technical details about the OpenVPC Shift Tool application, its architecture, and how it works internally.
## Architecture Overview
The application is built using Rust with the following main components:
1. **GUI Layer**: Uses the `eframe` crate (egui) for cross-platform GUI
2. **HID Communication**: Uses the `hidapi` crate to communicate with VirPil devices
3. **Configuration Management**: Uses the `fast_config` crate for saving/loading settings
4. **Worker Thread**: Background thread that handles device communication
## Core Components
### Main Application Structure
The main application is represented by the `ShiftTool` struct in `src/main.rs`, which contains:
- State management
- Device list
- Shared state between UI and worker thread
- Configuration data
### Modules
- **about.rs**: Contains application information and about screen text
- **config.rs**: Configuration data structures and serialization
- **device.rs**: Device representation and management
- **hid_worker.rs**: Background worker thread for HID communication
- **state.rs**: Application state enum
- **ui.rs**: User interface drawing and event handling
- **util.rs**: Utility functions and constants
## Data Flow
1. The application scans for VirPil devices (vendor ID 0x3344)
2. User selects source and receiver devices in the UI
3. When "Start" is clicked, a worker thread is spawned
4. The worker thread:
- Opens connections to all configured devices
- Reads input from source devices
- Applies logical operations based on configuration
- Writes the resulting shift state to receiver devices
5. Shared state (protected by mutexes) is used to communicate between the UI and worker thread
## Device Communication
### Device Detection
Devices are detected using the HID API, filtering for VirPil's vendor ID (0x3344). The application creates `VpcDevice` objects for each detected device, which include:
- Vendor ID and Product ID
- Device name and firmware version
- Serial number
- Usage page/ID
### HID Protocol
The application supports different report formats based on device firmware versions. The worker thread:
1. Reads HID reports from source devices
2. Extracts button states from the reports
3. Applies logical operations (OR, AND, XOR) to combine states
4. Formats the combined state into HID reports
5. Sends the reports to receiver devices
## Configuration
Configuration is stored in JSON format using the `fast_config` crate. The configuration includes:
- Source devices (vendor ID, product ID, serial number, enabled bits)
- Receiver devices (vendor ID, product ID, serial number, enabled bits)
- Shift modifiers (logical operations for each bit)
## Threading Model
The application uses a main UI thread and a separate worker thread:
1. **Main Thread**: Handles UI rendering and user input
2. **Worker Thread**: Performs HID communication in the background
Thread synchronization is achieved using:
- `Arc<Mutex<T>>` for shared state
- `Arc<(Mutex<bool>, Condvar)>` for signaling thread termination
## Linux-Specific Features
On Linux, the application requires udev rules to access HID devices without root privileges. The rule is installed to `/etc/udev/rules.d/70-vpc.rules` and contains:
```
# Virpil Control devices
SUBSYSTEM=="usb", ATTRS{idVendor}=="3344", TAG+="uaccess", GROUP:="input"
```
## Building and Deployment
The application can be built using Cargo:
```bash
cargo build --release
```
A Makefile is provided for easier installation on Linux, which:
1. Builds the application
2. Installs the binary to `/usr/local/bin`
3. Installs udev rules to `/etc/udev/rules.d/`
## Future Development
Potential areas for enhancement:
- Support for additional device types
- More complex logical operations
- Custom button mapping
- Profile management
- Integration with game APIs

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 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/open-vpc", "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]
}
}

248
src/device.rs Normal file
View File

@@ -0,0 +1,248 @@
use hidapi::{DeviceInfo, HidApi};
use log::{error, warn, debug, trace}; // Use log crate
use serde::{Deserialize, Serialize};
use std::rc::Rc;
// 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.
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 ---
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);
}
}
}
/// 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,
})
}

534
src/hid_worker.rs Normal file
View File

@@ -0,0 +1,534 @@
use crate::config::{ModifiersArray};
use crate::device::SavedDevice;
use crate::{SharedDeviceState, SharedStateFlag}; // Import shared types
use crate::util::{self, ReportFormat, MAX_REPORT_SIZE};
use log::{error, info, trace, warn};
use hidapi::{HidApi, HidDevice};
use std::{
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.
}
}
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.");
}

78
src/lib.rs Normal file
View File

@@ -0,0 +1,78 @@
// Export modules for testing
pub mod about;
pub mod config;
pub mod device;
pub mod hid_worker;
pub mod state;
pub mod ui;
pub mod util;
// Re-export main struct and types for testing
pub use crate::config::ConfigData;
pub use crate::device::VpcDevice;
pub use crate::state::State;
// Constants
pub const PROGRAM_TITLE: &str = "OpenVPC - Shift Tool";
pub const INITIAL_WIDTH: f32 = 740.0;
pub const INITIAL_HEIGHT: f32 = 260.0;
// Type aliases for shared state
pub use std::sync::{Arc, Condvar, Mutex};
pub type SharedStateFlag = Arc<(Mutex<bool>, Condvar)>;
pub type SharedDeviceState = Arc<Mutex<u16>>;
// Args struct for command line parsing
use clap::Parser;
#[derive(Parser, Debug)]
#[command(version, about, long_about = None)]
pub struct Args {
#[arg(short, long, default_value_t = false)]
pub skip_firmware: bool,
}
// Wrapper for ConfigData to match the actual structure
pub use fast_config::Config;
// The main application struct
pub struct ShiftTool {
// State
pub state: State,
pub thread_state: SharedStateFlag, // Is the worker thread running?
// Device Data
pub device_list: Vec<VpcDevice>, // List of discovered compatible devices
// Shared state between UI and Worker Thread
pub shift_state: SharedDeviceState, // Current shift state
pub source_states: Vec<SharedDeviceState>, // Current state of each source device
pub receiver_states: Vec<SharedDeviceState>, // Current state of each receiver device
// Configuration
pub config: Config<ConfigData>,
pub selected_source: usize,
pub selected_receiver: usize,
}
// Implementations for ShiftTool
impl ShiftTool {
// Add a new source state tracking object
pub fn add_source_state(&mut self) {
self.source_states.push(Arc::new(Mutex::new(0)));
}
// Add a new receiver state tracking object
pub fn add_receiver_state(&mut self) {
self.receiver_states.push(Arc::new(Mutex::new(0)));
}
// Get the current thread status
pub fn get_thread_status(&self) -> bool {
let &(ref lock, _) = &*self.thread_state;
match lock.lock() {
Ok(guard) => *guard,
Err(_) => false, // Return false if the mutex is poisoned
}
}
}

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
}

540
src/ui.rs Normal file
View File

@@ -0,0 +1,540 @@
use crate::about;
use crate::config::{ShiftModifiers};
use crate::device::VpcDevice; // Assuming VpcDevice has Display impl
use crate::{ShiftTool, 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| {
columns[0].set_width(612 as f32);
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].set_width(128 as f32);
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_salt(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 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);
}
}

227
src/util.rs Normal file
View File

@@ -0,0 +1,227 @@
use clap::Parser;
use chrono::NaiveDate;
use log::{error, 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
}
/// 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
}

197
tests/basic_tests.rs Normal file
View File

@@ -0,0 +1,197 @@
use vpc_shift_tool::config::{ConfigData, ShiftModifiers, ModifiersArray};
use vpc_shift_tool::device::{SavedDevice, VpcDevice};
use vpc_shift_tool::state::State;
use std::rc::Rc;
#[test]
fn test_config_data_default() {
// Test that the default ConfigData is created correctly
let config = ConfigData::default();
// Check that sources and receivers are empty
assert_eq!(config.sources.len(), 0);
assert_eq!(config.receivers.len(), 0);
// Check that shift_modifiers has the default value (all OR)
for i in 0..8 {
assert_eq!(config.shift_modifiers[i], ShiftModifiers::OR);
}
}
#[test]
fn test_shift_modifiers_display() {
// Test the Display implementation for ShiftModifiers
assert_eq!(format!("{}", ShiftModifiers::OR), "OR");
assert_eq!(format!("{}", ShiftModifiers::AND), "AND");
assert_eq!(format!("{}", ShiftModifiers::XOR), "XOR");
}
#[test]
fn test_saved_device_default() {
// Test that the default SavedDevice is created correctly
let device = SavedDevice::default();
assert_eq!(device.vendor_id, 0);
assert_eq!(device.product_id, 0);
assert_eq!(device.serial_number, "");
assert_eq!(device.state_enabled, [true; 8]); // All bits enabled by default
}
#[test]
fn test_state_enum() {
// Test that the State enum has the expected variants
let initializing = State::Initialising;
let about = State::About;
let running = State::Running;
// Test that the variants are different
assert_ne!(initializing, about);
assert_ne!(initializing, running);
assert_ne!(about, running);
// Test equality with same variant
assert_eq!(initializing, State::Initialising);
assert_eq!(about, State::About);
assert_eq!(running, State::Running);
}
#[test]
fn test_config_with_devices() {
// Test creating a ConfigData with sources and receivers
let mut config = ConfigData::default();
// Create some test devices
let device1 = SavedDevice {
vendor_id: 0x3344,
product_id: 0x0001,
serial_number: "123456".to_string(),
state_enabled: [true, false, true, false, true, false, true, false],
};
let device2 = SavedDevice {
vendor_id: 0x3344,
product_id: 0x0002,
serial_number: "654321".to_string(),
state_enabled: [false, true, false, true, false, true, false, true],
};
// Add devices to sources and receivers
config.sources.push(device1.clone());
config.receivers.push(device2.clone());
// Check that the devices were added correctly
assert_eq!(config.sources.len(), 1);
assert_eq!(config.receivers.len(), 1);
assert_eq!(config.sources[0].vendor_id, 0x3344);
assert_eq!(config.sources[0].product_id, 0x0001);
assert_eq!(config.sources[0].serial_number, "123456");
assert_eq!(config.sources[0].state_enabled, [true, false, true, false, true, false, true, false]);
assert_eq!(config.receivers[0].vendor_id, 0x3344);
assert_eq!(config.receivers[0].product_id, 0x0002);
assert_eq!(config.receivers[0].serial_number, "654321");
assert_eq!(config.receivers[0].state_enabled, [false, true, false, true, false, true, false, true]);
}
#[test]
fn test_modifiers_array() {
// Test the ModifiersArray implementation
let mut modifiers = ModifiersArray::default();
// Check default values
for i in 0..8 {
assert_eq!(modifiers[i], ShiftModifiers::OR);
}
// Test setting values
modifiers[0] = ShiftModifiers::AND;
modifiers[4] = ShiftModifiers::XOR;
// Check the modified values
assert_eq!(modifiers[0], ShiftModifiers::AND);
assert_eq!(modifiers[4], ShiftModifiers::XOR);
// Check that other values remain unchanged
for i in 1..4 {
assert_eq!(modifiers[i], ShiftModifiers::OR);
}
for i in 5..8 {
assert_eq!(modifiers[i], ShiftModifiers::OR);
}
}
#[test]
fn test_vpc_device_default() {
// Test the default VpcDevice implementation
let device = VpcDevice::default();
assert_eq!(device.full_name, "");
assert_eq!(*device.name, "-NO CONNECTION (Select device from list)-");
assert_eq!(*device.firmware, "");
assert_eq!(device.vendor_id, 0);
assert_eq!(device.product_id, 0);
assert_eq!(device.serial_number, "");
assert_eq!(device.usage, 0);
assert_eq!(device.active, false);
}
#[test]
fn test_vpc_device_display() {
// Test the Display implementation for VpcDevice
// Test default device
let device = VpcDevice::default();
assert_eq!(format!("{}", device), "-NO CONNECTION (Select device from list)-");
// Test a real device
let device = VpcDevice {
full_name: "3344:0001:123456".to_string(),
name: Rc::new("VPC MongoosT-50CM3".to_string()),
firmware: Rc::new("VIRPIL Controls 20240101".to_string()),
vendor_id: 0x3344,
product_id: 0x0001,
serial_number: "123456".to_string(),
usage: 0,
active: false,
};
assert_eq!(
format!("{}", device),
"VID:3344 PID:0001 VPC MongoosT-50CM3 (SN:123456 FW:VIRPIL Controls 20240101)"
);
// Test a device with empty serial number
let device = VpcDevice {
full_name: "3344:0001:no_sn".to_string(),
name: Rc::new("VPC MongoosT-50CM3".to_string()),
firmware: Rc::new("VIRPIL Controls 20240101".to_string()),
vendor_id: 0x3344,
product_id: 0x0001,
serial_number: "".to_string(),
usage: 0,
active: false,
};
assert_eq!(
format!("{}", device),
"VID:3344 PID:0001 VPC MongoosT-50CM3 (SN:N/A FW:VIRPIL Controls 20240101)"
);
// Test a device with empty firmware
let device = VpcDevice {
full_name: "3344:0001:123456".to_string(),
name: Rc::new("VPC MongoosT-50CM3".to_string()),
firmware: Rc::new("".to_string()),
vendor_id: 0x3344,
product_id: 0x0001,
serial_number: "123456".to_string(),
usage: 0,
active: false,
};
assert_eq!(
format!("{}", device),
"VID:3344 PID:0001 VPC MongoosT-50CM3 (SN:123456 FW:N/A)"
);
}