Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
c9f8325da9
|
|||
|
2bb5ce0327
|
|||
|
9b43f600b8
|
|||
|
75230a5cc7
|
|||
|
c313131a88
|
|||
|
8a889aef95
|
|||
| 644cfa4128 | |||
|
d4f7b00323
|
|||
|
bd9f94c244
|
|||
|
4df9ce4d49
|
|||
|
e2053f0d67
|
|||
| 7a35b65f2c | |||
|
8924f088ba
|
4
.github/workflows/makefile.yml
vendored
4
.github/workflows/makefile.yml
vendored
@@ -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
2
.gitignore
vendored
@@ -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
135
CONTRIBUTING.md
Normal 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
158
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"
|
||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
14
Cargo.toml
14
Cargo.toml
@@ -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
150
INSTALL.md
Normal 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
|
||||||
2
Makefile
2
Makefile
@@ -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
110
README.md
@@ -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
116
TECHNICAL.md
Normal 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
|
||||||
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 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
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]
|
||||||
|
}
|
||||||
|
}
|
||||||
248
src/device.rs
Normal file
248
src/device.rs
Normal 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
534
src/hid_worker.rs
Normal 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
78
src/lib.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1117
src/main.rs
1117
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
|
||||||
|
}
|
||||||
540
src/ui.rs
Normal file
540
src/ui.rs
Normal 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
227
src/util.rs
Normal 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
197
tests/basic_tests.rs
Normal 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)"
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user