Report only real device data and remove unverifiable fields
- Drop simulated-controller fallback: simulation now only runs when explicitly enabled in Settings (or outside Tauri), so the app no longer claims a controller is connected when none is plugged in - Derive connection type from the kernel bus type (USB/Bluetooth) instead of an unreliable udev property - Read real battery percentage from /sys/class/power_supply; hide the field entirely when the device reports none - Remove Input mode and Firmware version fields (not obtainable) - Query virtual controller status from the backend instead of a hardcoded per-device flag - Exclude the app's own uinput virtual controller from device discovery - Rescan for controllers indefinitely while disconnected so a controller plugged in at any time is detected without restarting - Strip leftover debug instrumentation from the previous session Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
108
src-tauri/Cargo.lock
generated
108
src-tauri/Cargo.lock
generated
@@ -888,7 +888,6 @@ dependencies = [
|
|||||||
"tauri-plugin-os",
|
"tauri-plugin-os",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.19",
|
||||||
"udev",
|
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1522,12 +1521,6 @@ version = "0.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "hermit-abi"
|
|
||||||
version = "0.3.9"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hermit-abi"
|
name = "hermit-abi"
|
||||||
version = "0.5.2"
|
version = "0.5.2"
|
||||||
@@ -1807,17 +1800,6 @@ dependencies = [
|
|||||||
"cfb",
|
"cfb",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "io-lifetimes"
|
|
||||||
version = "1.0.11"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "eae7b9aee968036d54dce06cebaefd919e4472e753296daccd6d344e3e2df0c2"
|
|
||||||
dependencies = [
|
|
||||||
"hermit-abi 0.3.9",
|
|
||||||
"libc",
|
|
||||||
"windows-sys 0.48.0",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ipnet"
|
name = "ipnet"
|
||||||
version = "2.12.0"
|
version = "2.12.0"
|
||||||
@@ -2018,16 +2000,6 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "libudev-sys"
|
|
||||||
version = "0.1.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324"
|
|
||||||
dependencies = [
|
|
||||||
"libc",
|
|
||||||
"pkg-config",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "linux-raw-sys"
|
name = "linux-raw-sys"
|
||||||
version = "0.12.1"
|
version = "0.12.1"
|
||||||
@@ -2676,7 +2648,7 @@ checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"concurrent-queue",
|
"concurrent-queue",
|
||||||
"hermit-abi 0.5.2",
|
"hermit-abi",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"rustix",
|
"rustix",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
@@ -4242,18 +4214,6 @@ version = "1.20.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "udev"
|
|
||||||
version = "0.9.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "af4e37e9ea4401fc841ff54b9ddfc9be1079b1e89434c1a6a865dd68980f7e9f"
|
|
||||||
dependencies = [
|
|
||||||
"io-lifetimes",
|
|
||||||
"libc",
|
|
||||||
"libudev-sys",
|
|
||||||
"pkg-config",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uds_windows"
|
name = "uds_windows"
|
||||||
version = "1.2.1"
|
version = "1.2.1"
|
||||||
@@ -4791,15 +4751,6 @@ dependencies = [
|
|||||||
"windows-targets 0.42.2",
|
"windows-targets 0.42.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows-sys"
|
|
||||||
version = "0.48.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
|
|
||||||
dependencies = [
|
|
||||||
"windows-targets 0.48.5",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.59.0"
|
version = "0.59.0"
|
||||||
@@ -4842,21 +4793,6 @@ dependencies = [
|
|||||||
"windows_x86_64_msvc 0.42.2",
|
"windows_x86_64_msvc 0.42.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows-targets"
|
|
||||||
version = "0.48.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
|
|
||||||
dependencies = [
|
|
||||||
"windows_aarch64_gnullvm 0.48.5",
|
|
||||||
"windows_aarch64_msvc 0.48.5",
|
|
||||||
"windows_i686_gnu 0.48.5",
|
|
||||||
"windows_i686_msvc 0.48.5",
|
|
||||||
"windows_x86_64_gnu 0.48.5",
|
|
||||||
"windows_x86_64_gnullvm 0.48.5",
|
|
||||||
"windows_x86_64_msvc 0.48.5",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-targets"
|
name = "windows-targets"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@@ -4914,12 +4850,6 @@ version = "0.42.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
|
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows_aarch64_gnullvm"
|
|
||||||
version = "0.48.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_aarch64_gnullvm"
|
name = "windows_aarch64_gnullvm"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@@ -4938,12 +4868,6 @@ version = "0.42.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
|
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows_aarch64_msvc"
|
|
||||||
version = "0.48.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_aarch64_msvc"
|
name = "windows_aarch64_msvc"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@@ -4962,12 +4886,6 @@ version = "0.42.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
|
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows_i686_gnu"
|
|
||||||
version = "0.48.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_gnu"
|
name = "windows_i686_gnu"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@@ -4998,12 +4916,6 @@ version = "0.42.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
|
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows_i686_msvc"
|
|
||||||
version = "0.48.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_msvc"
|
name = "windows_i686_msvc"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@@ -5022,12 +4934,6 @@ version = "0.42.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
|
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows_x86_64_gnu"
|
|
||||||
version = "0.48.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_gnu"
|
name = "windows_x86_64_gnu"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@@ -5046,12 +4952,6 @@ version = "0.42.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
|
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows_x86_64_gnullvm"
|
|
||||||
version = "0.48.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_gnullvm"
|
name = "windows_x86_64_gnullvm"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@@ -5070,12 +4970,6 @@ version = "0.42.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
|
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows_x86_64_msvc"
|
|
||||||
version = "0.48.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_msvc"
|
name = "windows_x86_64_msvc"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
|||||||
uuid = { version = "1", features = ["v4"] }
|
uuid = { version = "1", features = ["v4"] }
|
||||||
dirs = "6"
|
dirs = "6"
|
||||||
evdev = "0.13"
|
evdev = "0.13"
|
||||||
udev = "0.9"
|
|
||||||
sysinfo = "0.37"
|
sysinfo = "0.37"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|||||||
@@ -1,62 +1,11 @@
|
|||||||
use std::fs::OpenOptions;
|
|
||||||
use std::io::Write;
|
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
|
||||||
|
|
||||||
use crate::error::{AppError, AppResult};
|
use crate::error::{AppError, AppResult};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
fn debug_log(hypothesis_id: &str, message: &str, data: serde_json::Value) {
|
|
||||||
let entry = serde_json::json!({
|
|
||||||
"sessionId": "2801e0",
|
|
||||||
"runId": "initial",
|
|
||||||
"hypothesisId": hypothesis_id,
|
|
||||||
"location": "src-tauri/src/commands/mapping.rs",
|
|
||||||
"message": message,
|
|
||||||
"data": data,
|
|
||||||
"timestamp": SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis(),
|
|
||||||
});
|
|
||||||
match OpenOptions::new()
|
|
||||||
.create(true)
|
|
||||||
.append(true)
|
|
||||||
.open("/home/notdave/Documents/Code Projects/Apps/Linux 8bitdo ultimate interface/.cursor/debug-2801e0.log")
|
|
||||||
{
|
|
||||||
Ok(mut file) => {
|
|
||||||
if let Err(error) = writeln!(file, "{entry}") {
|
|
||||||
// #region agent log
|
|
||||||
eprintln!("H13 debug log write failed: {error}");
|
|
||||||
// #endregion
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(error) => {
|
|
||||||
// #region agent log
|
|
||||||
eprintln!("H13 debug log open failed: {error}");
|
|
||||||
// #endregion
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn log_remapping_pipeline_startup() {
|
|
||||||
// #region agent log
|
|
||||||
debug_log(
|
|
||||||
"H12",
|
|
||||||
"Native remapping pipeline initialized",
|
|
||||||
serde_json::json!({}),
|
|
||||||
);
|
|
||||||
// #endregion
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Creates a managed virtual controller after a deliberate user action.
|
/// Creates a managed virtual controller after a deliberate user action.
|
||||||
/// Event forwarding remains disabled until the physical device is safely
|
/// Event forwarding remains disabled until the physical device is safely
|
||||||
/// grabbed by the pipeline; this avoids accidental duplicate game input.
|
/// grabbed by the pipeline; this avoids accidental duplicate game input.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn create_virtual_controller(state: tauri::State<'_, AppState>) -> AppResult<()> {
|
pub fn create_virtual_controller(state: tauri::State<'_, AppState>) -> AppResult<()> {
|
||||||
// #region agent log
|
|
||||||
debug_log(
|
|
||||||
"H9",
|
|
||||||
"Virtual controller creation requested",
|
|
||||||
serde_json::json!({}),
|
|
||||||
);
|
|
||||||
// #endregion
|
|
||||||
let mut virtual_controller = state
|
let mut virtual_controller = state
|
||||||
.virtual_controller
|
.virtual_controller
|
||||||
.lock()
|
.lock()
|
||||||
@@ -69,13 +18,6 @@ pub fn create_virtual_controller(state: tauri::State<'_, AppState>) -> AppResult
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn destroy_virtual_controller(state: tauri::State<'_, AppState>) -> AppResult<()> {
|
pub fn destroy_virtual_controller(state: tauri::State<'_, AppState>) -> AppResult<()> {
|
||||||
// #region agent log
|
|
||||||
debug_log(
|
|
||||||
"H9",
|
|
||||||
"Virtual controller destruction requested",
|
|
||||||
serde_json::json!({}),
|
|
||||||
);
|
|
||||||
// #endregion
|
|
||||||
let mut virtual_controller = state
|
let mut virtual_controller = state
|
||||||
.virtual_controller
|
.virtual_controller
|
||||||
.lock()
|
.lock()
|
||||||
@@ -101,17 +43,6 @@ pub fn apply_remapping_profile(
|
|||||||
state: tauri::State<'_, AppState>,
|
state: tauri::State<'_, AppState>,
|
||||||
profile: crate::profile_schema::Profile,
|
profile: crate::profile_schema::Profile,
|
||||||
) -> AppResult<()> {
|
) -> AppResult<()> {
|
||||||
// #region agent log
|
|
||||||
debug_log(
|
|
||||||
"H10",
|
|
||||||
"Remapping profile received by native pipeline",
|
|
||||||
serde_json::json!({
|
|
||||||
"profileId": profile.id,
|
|
||||||
"leftSensitivity": profile.left_trigger.sensitivity,
|
|
||||||
"rightSensitivity": profile.right_trigger.sensitivity,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
// #endregion
|
|
||||||
let mut active_profile = state
|
let mut active_profile = state
|
||||||
.active_remap_profile
|
.active_remap_profile
|
||||||
.lock()
|
.lock()
|
||||||
@@ -139,12 +70,5 @@ pub fn set_remapping_enabled(state: tauri::State<'_, AppState>, enabled: bool) -
|
|||||||
.lock()
|
.lock()
|
||||||
.map_err(|_| AppError::Other("remapping state lock poisoned".to_string()))?;
|
.map_err(|_| AppError::Other("remapping state lock poisoned".to_string()))?;
|
||||||
*remapping_enabled = enabled;
|
*remapping_enabled = enabled;
|
||||||
// #region agent log
|
|
||||||
debug_log(
|
|
||||||
"H10",
|
|
||||||
"Native remapping state changed",
|
|
||||||
serde_json::json!({"enabled": enabled}),
|
|
||||||
);
|
|
||||||
// #endregion
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,18 +2,21 @@
|
|||||||
//!
|
//!
|
||||||
//! This module only queries kernel-provided evdev metadata and capability
|
//! This module only queries kernel-provided evdev metadata and capability
|
||||||
//! bitsets. It never sends vendor commands or accesses firmware protocols.
|
//! bitsets. It never sends vendor commands or accesses firmware protocols.
|
||||||
|
//! Every reported field is backed by real kernel data; anything the kernel
|
||||||
|
//! does not expose (e.g. battery on wired controllers) is reported as absent
|
||||||
|
//! rather than guessed.
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::{
|
|
||||||
fs::OpenOptions,
|
|
||||||
io::Write,
|
|
||||||
time::{SystemTime, UNIX_EPOCH},
|
|
||||||
};
|
|
||||||
|
|
||||||
use evdev::{AbsoluteAxisCode, Device, KeyCode};
|
use evdev::{AbsoluteAxisCode, BusType, Device, KeyCode};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
const EIGHTBITDO_VENDOR_ID: u16 = 0x2dc8;
|
const EIGHTBITDO_VENDOR_ID: u16 = 0x2dc8;
|
||||||
|
/// Product id of the uinput device this app creates (see `uinput_writer`).
|
||||||
|
/// It must be excluded from discovery so the app never lists its own output
|
||||||
|
/// as a physical controller.
|
||||||
|
const VIRTUAL_CONTROLLER_PRODUCT_ID: u16 = 0x8bcc;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@@ -56,10 +59,7 @@ pub struct ControllerInfo {
|
|||||||
pub device_path: String,
|
pub device_path: String,
|
||||||
pub vendor_id: u16,
|
pub vendor_id: u16,
|
||||||
pub product_id: u16,
|
pub product_id: u16,
|
||||||
pub mode: String,
|
|
||||||
pub battery_percent: Option<u8>,
|
pub battery_percent: Option<u8>,
|
||||||
pub firmware_version: Option<String>,
|
|
||||||
pub virtual_controller_active: bool,
|
|
||||||
pub capabilities: DeviceCapabilities,
|
pub capabilities: DeviceCapabilities,
|
||||||
pub is_simulated: bool,
|
pub is_simulated: bool,
|
||||||
}
|
}
|
||||||
@@ -115,25 +115,39 @@ fn is_gamepad(device: &Device) -> bool {
|
|||||||
has_gamepad_button && has_stick_axis
|
has_gamepad_button && has_stick_axis
|
||||||
}
|
}
|
||||||
|
|
||||||
fn connection_for(path: &Path) -> String {
|
/// Maps the kernel-reported bus type to a connection kind. This is
|
||||||
let udev_path = udev::Device::from_subsystem_sysname(
|
/// authoritative: the kernel always knows which bus a device enumerated on.
|
||||||
"input".to_string(),
|
/// A 2.4GHz dongle presents as USB because the dongle itself is a USB device.
|
||||||
path.file_name()
|
fn connection_for(bustype: u16) -> String {
|
||||||
.and_then(|name| name.to_str())
|
match BusType(bustype) {
|
||||||
.unwrap_or_default()
|
BusType::BUS_USB => "usb".to_string(),
|
||||||
.to_string(),
|
BusType::BUS_BLUETOOTH => "bluetooth".to_string(),
|
||||||
);
|
_ => "unknown".to_string(),
|
||||||
if let Ok(device) = udev_path {
|
}
|
||||||
let mut properties = device.properties();
|
}
|
||||||
if let Some(property) = properties.find(|property| property.name() == "ID_BUS") {
|
|
||||||
return match property.value().to_string_lossy().as_ref() {
|
/// Reads the battery percentage the kernel reports for this input device, if
|
||||||
"bluetooth" => "bluetooth".to_string(),
|
/// any. Bluetooth HID controllers expose a `power_supply` entry whose device
|
||||||
"usb" => "usb".to_string(),
|
/// is an ancestor of the input node; wired controllers usually expose none,
|
||||||
_ => "unknown".to_string(),
|
/// in which case `None` is returned and the UI omits the field.
|
||||||
};
|
fn battery_percent_for(event_path: &Path) -> Option<u8> {
|
||||||
|
let event_name = event_path.file_name()?.to_str()?;
|
||||||
|
let input_sysfs = fs::canonicalize(format!("/sys/class/input/{event_name}")).ok()?;
|
||||||
|
for entry in fs::read_dir("/sys/class/power_supply").ok()?.flatten() {
|
||||||
|
let supply = entry.path();
|
||||||
|
let Ok(supply_device) = fs::canonicalize(supply.join("device")) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !input_sysfs.starts_with(&supply_device) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Ok(capacity) = fs::read_to_string(supply.join("capacity")) {
|
||||||
|
if let Ok(percent) = capacity.trim().parse::<u8>() {
|
||||||
|
return Some(percent.min(100));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"unknown".to_string()
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn probe_device(path: &Path, device: &Device) -> Option<ControllerInfo> {
|
pub fn probe_device(path: &Path, device: &Device) -> Option<ControllerInfo> {
|
||||||
@@ -145,6 +159,12 @@ pub fn probe_device(path: &Path, device: &Device) -> Option<ControllerInfo> {
|
|||||||
let kernel_id = raw_id.as_ref();
|
let kernel_id = raw_id.as_ref();
|
||||||
let vendor_id = kernel_id.vendor;
|
let vendor_id = kernel_id.vendor;
|
||||||
let product_id = kernel_id.product;
|
let product_id = kernel_id.product;
|
||||||
|
|
||||||
|
// Never report this app's own virtual output device as a controller.
|
||||||
|
if vendor_id == EIGHTBITDO_VENDOR_ID && product_id == VIRTUAL_CONTROLLER_PRODUCT_ID {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
let fallback_name = device.name().unwrap_or("Unknown Controller");
|
let fallback_name = device.name().unwrap_or("Unknown Controller");
|
||||||
let name = known_name(vendor_id, product_id, fallback_name);
|
let name = known_name(vendor_id, product_id, fallback_name);
|
||||||
|
|
||||||
@@ -184,24 +204,17 @@ pub fn probe_device(path: &Path, device: &Device) -> Option<ControllerInfo> {
|
|||||||
let has_rumble = device
|
let has_rumble = device
|
||||||
.supported_ff()
|
.supported_ff()
|
||||||
.is_some_and(|ff| ff.iter().next().is_some());
|
.is_some_and(|ff| ff.iter().next().is_some());
|
||||||
let is_eightbitdo = vendor_id == EIGHTBITDO_VENDOR_ID;
|
let battery_percent = battery_percent_for(path);
|
||||||
let path_text = path.to_string_lossy().into_owned();
|
let path_text = path.to_string_lossy().into_owned();
|
||||||
|
|
||||||
Some(ControllerInfo {
|
Some(ControllerInfo {
|
||||||
id: path_text.clone(),
|
id: path_text.clone(),
|
||||||
name,
|
name,
|
||||||
connection: connection_for(path),
|
connection: connection_for(kernel_id.bustype),
|
||||||
device_path: path_text,
|
device_path: path_text,
|
||||||
vendor_id,
|
vendor_id,
|
||||||
product_id,
|
product_id,
|
||||||
mode: if is_eightbitdo {
|
battery_percent,
|
||||||
"xinput".to_string()
|
|
||||||
} else {
|
|
||||||
"unknown".to_string()
|
|
||||||
},
|
|
||||||
battery_percent: None,
|
|
||||||
firmware_version: None,
|
|
||||||
virtual_controller_active: false,
|
|
||||||
capabilities: DeviceCapabilities {
|
capabilities: DeviceCapabilities {
|
||||||
vendor_id,
|
vendor_id,
|
||||||
product_id,
|
product_id,
|
||||||
@@ -209,7 +222,7 @@ pub fn probe_device(path: &Path, device: &Device) -> Option<ControllerInfo> {
|
|||||||
paddle_count: 0,
|
paddle_count: 0,
|
||||||
has_gyro: false,
|
has_gyro: false,
|
||||||
has_rumble,
|
has_rumble,
|
||||||
has_battery: false,
|
has_battery: battery_percent.is_some(),
|
||||||
buttons,
|
buttons,
|
||||||
axes,
|
axes,
|
||||||
},
|
},
|
||||||
@@ -222,19 +235,5 @@ pub fn enumerate_controllers() -> Vec<ControllerInfo> {
|
|||||||
.filter_map(|(path, device)| probe_device(&path, &device))
|
.filter_map(|(path, device)| probe_device(&path, &device))
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
controllers.sort_by(|a, b| a.name.cmp(&b.name));
|
controllers.sort_by(|a, b| a.name.cmp(&b.name));
|
||||||
// #region agent log
|
|
||||||
let entry = serde_json::json!({
|
|
||||||
"sessionId": "2801e0",
|
|
||||||
"runId": "initial",
|
|
||||||
"hypothesisId": "H4",
|
|
||||||
"location": "src-tauri/src/device_detection.rs:enumerate_controllers",
|
|
||||||
"message": "Controller discovery completed",
|
|
||||||
"data": {"count": controllers.len(), "controllers": controllers.iter().map(|controller| serde_json::json!({"name": controller.name, "connection": controller.connection, "path": controller.device_path, "vendorId": controller.vendor_id, "productId": controller.product_id})).collect::<Vec<_>>()},
|
|
||||||
"timestamp": SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis(),
|
|
||||||
});
|
|
||||||
if let Ok(mut file) = OpenOptions::new().create(true).append(true).open("/home/notdave/Documents/Code Projects/Apps/Linux 8bitdo ultimate interface/.cursor/debug-2801e0.log") {
|
|
||||||
let _ = writeln!(file, "{entry}");
|
|
||||||
}
|
|
||||||
// #endregion
|
|
||||||
controllers
|
controllers
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,7 @@
|
|||||||
//! behavior.
|
//! behavior.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::fs::OpenOptions;
|
|
||||||
use std::io::Write;
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
@@ -21,27 +18,6 @@ use crate::state::AppState;
|
|||||||
|
|
||||||
pub const INPUT_EVENT_NAME: &str = "controller-input";
|
pub const INPUT_EVENT_NAME: &str = "controller-input";
|
||||||
pub const READER_STATUS_EVENT_NAME: &str = "controller-reader-status";
|
pub const READER_STATUS_EVENT_NAME: &str = "controller-reader-status";
|
||||||
static LEFT_TRIGGER_OUTPUT_LOGGED: AtomicBool = AtomicBool::new(false);
|
|
||||||
static RIGHT_TRIGGER_OUTPUT_LOGGED: AtomicBool = AtomicBool::new(false);
|
|
||||||
|
|
||||||
fn debug_log(hypothesis_id: &str, message: &str, data: serde_json::Value) {
|
|
||||||
let entry = serde_json::json!({
|
|
||||||
"sessionId": "2801e0",
|
|
||||||
"runId": "initial",
|
|
||||||
"hypothesisId": hypothesis_id,
|
|
||||||
"location": "src-tauri/src/evdev_reader.rs",
|
|
||||||
"message": message,
|
|
||||||
"data": data,
|
|
||||||
"timestamp": timestamp_ms(),
|
|
||||||
});
|
|
||||||
if let Ok(mut file) = OpenOptions::new()
|
|
||||||
.create(true)
|
|
||||||
.append(true)
|
|
||||||
.open("/home/notdave/Documents/Code Projects/Apps/Linux 8bitdo ultimate interface/.cursor/debug-2801e0.log")
|
|
||||||
{
|
|
||||||
let _ = writeln!(file, "{entry}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@@ -146,15 +122,6 @@ fn forward_event(
|
|||||||
.unwrap_or((0, 255));
|
.unwrap_or((0, 255));
|
||||||
let raw = normalized(value, minimum, maximum) as f32;
|
let raw = normalized(value, minimum, maximum) as f32;
|
||||||
let transformed = crate::input_transform::transform_trigger(raw, &profile.left_trigger);
|
let transformed = crate::input_transform::transform_trigger(raw, &profile.left_trigger);
|
||||||
if !LEFT_TRIGGER_OUTPUT_LOGGED.swap(true, Ordering::Relaxed) {
|
|
||||||
// #region agent log
|
|
||||||
debug_log(
|
|
||||||
"H11",
|
|
||||||
"Forwarded transformed left trigger",
|
|
||||||
serde_json::json!({"raw": raw, "output": transformed, "outputValue": (transformed * 255.0).round() as i32}),
|
|
||||||
);
|
|
||||||
// #endregion
|
|
||||||
}
|
|
||||||
*AbsoluteAxisEvent::new(
|
*AbsoluteAxisEvent::new(
|
||||||
AbsoluteAxisCode::ABS_Z,
|
AbsoluteAxisCode::ABS_Z,
|
||||||
(transformed * 255.0).round() as i32,
|
(transformed * 255.0).round() as i32,
|
||||||
@@ -167,15 +134,6 @@ fn forward_event(
|
|||||||
.unwrap_or((0, 255));
|
.unwrap_or((0, 255));
|
||||||
let raw = normalized(value, minimum, maximum) as f32;
|
let raw = normalized(value, minimum, maximum) as f32;
|
||||||
let transformed = crate::input_transform::transform_trigger(raw, &profile.right_trigger);
|
let transformed = crate::input_transform::transform_trigger(raw, &profile.right_trigger);
|
||||||
if !RIGHT_TRIGGER_OUTPUT_LOGGED.swap(true, Ordering::Relaxed) {
|
|
||||||
// #region agent log
|
|
||||||
debug_log(
|
|
||||||
"H11",
|
|
||||||
"Forwarded transformed right trigger",
|
|
||||||
serde_json::json!({"raw": raw, "output": transformed, "outputValue": (transformed * 255.0).round() as i32}),
|
|
||||||
);
|
|
||||||
// #endregion
|
|
||||||
}
|
|
||||||
*AbsoluteAxisEvent::new(
|
*AbsoluteAxisEvent::new(
|
||||||
AbsoluteAxisCode::ABS_RZ,
|
AbsoluteAxisCode::ABS_RZ,
|
||||||
(transformed * 255.0).round() as i32,
|
(transformed * 255.0).round() as i32,
|
||||||
@@ -215,36 +173,15 @@ pub fn start(
|
|||||||
.lock()
|
.lock()
|
||||||
.map_err(|_| AppError::Other("device reader state lock poisoned".to_string()))?;
|
.map_err(|_| AppError::Other("device reader state lock poisoned".to_string()))?;
|
||||||
if !readers.insert(device_path.clone()) {
|
if !readers.insert(device_path.clone()) {
|
||||||
// #region agent log
|
|
||||||
debug_log(
|
|
||||||
"H2",
|
|
||||||
"Reader start skipped because path is already active",
|
|
||||||
serde_json::json!({"devicePath": device_path}),
|
|
||||||
);
|
|
||||||
// #endregion
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// #region agent log
|
|
||||||
debug_log(
|
|
||||||
"H2",
|
|
||||||
"Reader start requested",
|
|
||||||
serde_json::json!({"devicePath": device_path}),
|
|
||||||
);
|
|
||||||
// #endregion
|
|
||||||
|
|
||||||
thread::Builder::new()
|
thread::Builder::new()
|
||||||
.name(format!("evdev-reader:{device_path}"))
|
.name(format!("evdev-reader:{device_path}"))
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
let result = read_loop(&app, &device_path);
|
let result = read_loop(&app, &device_path);
|
||||||
if let Err(error) = result {
|
if let Err(error) = result {
|
||||||
// #region agent log
|
|
||||||
debug_log(
|
|
||||||
"H2",
|
|
||||||
"Reader loop ended with error",
|
|
||||||
serde_json::json!({"devicePath": device_path, "error": error.to_string()}),
|
|
||||||
);
|
|
||||||
// #endregion
|
|
||||||
log::warn!("evdev reader for {device_path} stopped: {error}");
|
log::warn!("evdev reader for {device_path} stopped: {error}");
|
||||||
if let Ok(mut readers) = app.state::<AppState>().active_readers.lock() {
|
if let Ok(mut readers) = app.state::<AppState>().active_readers.lock() {
|
||||||
readers.remove(&device_path);
|
readers.remove(&device_path);
|
||||||
@@ -275,13 +212,6 @@ fn read_loop(app: &AppHandle, device_path: &str) -> AppResult<()> {
|
|||||||
.map_err(AppError::Io)?
|
.map_err(AppError::Io)?
|
||||||
.map(|(axis, info)| (axis, (info.as_ref().minimum, info.as_ref().maximum)))
|
.map(|(axis, info)| (axis, (info.as_ref().minimum, info.as_ref().maximum)))
|
||||||
.collect::<HashMap<_, _>>();
|
.collect::<HashMap<_, _>>();
|
||||||
// #region agent log
|
|
||||||
debug_log(
|
|
||||||
"H2",
|
|
||||||
"Reader opened device",
|
|
||||||
serde_json::json!({"devicePath": device_path, "axisRangeCount": axis_ranges.len()}),
|
|
||||||
);
|
|
||||||
// #endregion
|
|
||||||
|
|
||||||
let _ = app.emit(
|
let _ = app.emit(
|
||||||
READER_STATUS_EVENT_NAME,
|
READER_STATUS_EVENT_NAME,
|
||||||
@@ -298,22 +228,8 @@ fn read_loop(app: &AppHandle, device_path: &str) -> AppResult<()> {
|
|||||||
if enabled != grabbed {
|
if enabled != grabbed {
|
||||||
if enabled {
|
if enabled {
|
||||||
device.grab().map_err(AppError::Io)?;
|
device.grab().map_err(AppError::Io)?;
|
||||||
// #region agent log
|
|
||||||
debug_log(
|
|
||||||
"H10",
|
|
||||||
"Physical controller exclusively grabbed for remapping",
|
|
||||||
serde_json::json!({"devicePath": device_path}),
|
|
||||||
);
|
|
||||||
// #endregion
|
|
||||||
} else {
|
} else {
|
||||||
device.ungrab().map_err(AppError::Io)?;
|
device.ungrab().map_err(AppError::Io)?;
|
||||||
// #region agent log
|
|
||||||
debug_log(
|
|
||||||
"H10",
|
|
||||||
"Physical controller released from remapping",
|
|
||||||
serde_json::json!({"devicePath": device_path}),
|
|
||||||
);
|
|
||||||
// #endregion
|
|
||||||
}
|
}
|
||||||
grabbed = enabled;
|
grabbed = enabled;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ pub fn run() {
|
|||||||
.build(),
|
.build(),
|
||||||
)
|
)
|
||||||
.setup(|_app| {
|
.setup(|_app| {
|
||||||
crate::commands::mapping::log_remapping_pipeline_startup();
|
|
||||||
if let Err(err) = profile_manager::ensure_initialized() {
|
if let Err(err) = profile_manager::ensure_initialized() {
|
||||||
log::error!("failed to initialize profile storage: {err}");
|
log::error!("failed to initialize profile storage: {err}");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Bluetooth, Cable, Radio, Usb } from "lucide-react";
|
import { Bluetooth, Cable, Usb } from "lucide-react";
|
||||||
import { useLocation } from "react-router-dom";
|
import { useLocation } from "react-router-dom";
|
||||||
|
|
||||||
import { Badge } from "@/components/common/Badge";
|
import { Badge } from "@/components/common/Badge";
|
||||||
@@ -11,14 +11,12 @@ import type { ConnectionKind } from "@/types/controller";
|
|||||||
const CONNECTION_ICON: Record<ConnectionKind, typeof Usb> = {
|
const CONNECTION_ICON: Record<ConnectionKind, typeof Usb> = {
|
||||||
usb: Usb,
|
usb: Usb,
|
||||||
bluetooth: Bluetooth,
|
bluetooth: Bluetooth,
|
||||||
receiver2_4ghz: Radio,
|
|
||||||
unknown: Cable,
|
unknown: Cable,
|
||||||
};
|
};
|
||||||
|
|
||||||
const CONNECTION_LABEL: Record<ConnectionKind, string> = {
|
const CONNECTION_LABEL: Record<ConnectionKind, string> = {
|
||||||
usb: "USB",
|
usb: "USB",
|
||||||
bluetooth: "Bluetooth",
|
bluetooth: "Bluetooth",
|
||||||
receiver2_4ghz: "2.4GHz Receiver",
|
|
||||||
unknown: "Unknown",
|
unknown: "Unknown",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,13 @@ import { useControllerStore } from "@/stores/controllerStore";
|
|||||||
import { useProfileStore } from "@/stores/profileStore";
|
import { useProfileStore } from "@/stores/profileStore";
|
||||||
import { useSettingsStore } from "@/stores/settingsStore";
|
import { useSettingsStore } from "@/stores/settingsStore";
|
||||||
import { processStick, processTrigger } from "@/utils/deadzoneMath";
|
import { processStick, processTrigger } from "@/utils/deadzoneMath";
|
||||||
|
import type { ConnectionKind } from "@/types/controller";
|
||||||
|
|
||||||
|
const CONNECTION_LABEL: Record<ConnectionKind, string> = {
|
||||||
|
usb: "USB",
|
||||||
|
bluetooth: "Bluetooth",
|
||||||
|
unknown: "Unknown connection",
|
||||||
|
};
|
||||||
|
|
||||||
export function Dashboard() {
|
export function Dashboard() {
|
||||||
const controllers = useControllerStore((s) => s.controllers);
|
const controllers = useControllerStore((s) => s.controllers);
|
||||||
@@ -20,6 +27,7 @@ export function Dashboard() {
|
|||||||
const pollRateHz = useControllerStore((s) => s.pollRateHz);
|
const pollRateHz = useControllerStore((s) => s.pollRateHz);
|
||||||
const connectionStatus = useControllerStore((s) => s.connectionStatus);
|
const connectionStatus = useControllerStore((s) => s.connectionStatus);
|
||||||
const connectionMessage = useControllerStore((s) => s.connectionMessage);
|
const connectionMessage = useControllerStore((s) => s.connectionMessage);
|
||||||
|
const virtualControllerActive = useControllerStore((s) => s.virtualControllerActive);
|
||||||
|
|
||||||
const draftProfile = useProfileStore((s) => s.draftProfile);
|
const draftProfile = useProfileStore((s) => s.draftProfile);
|
||||||
const summaries = useProfileStore((s) => s.summaries);
|
const summaries = useProfileStore((s) => s.summaries);
|
||||||
@@ -60,16 +68,16 @@ export function Dashboard() {
|
|||||||
Connected
|
Connected
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge tone="neutral">
|
<Badge tone="neutral">
|
||||||
<Usb size={12} /> {activeController.mode.toUpperCase()}
|
<Usb size={12} /> {CONNECTION_LABEL[activeController.connection]}
|
||||||
</Badge>
|
</Badge>
|
||||||
{activeController.batteryPercent !== null && (
|
{activeController.batteryPercent !== null && (
|
||||||
<Badge tone="neutral">
|
<Badge tone="neutral">
|
||||||
<BatteryMedium size={12} /> {activeController.batteryPercent}%
|
<BatteryMedium size={12} /> {activeController.batteryPercent}%
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
<Badge tone={activeController.virtualControllerActive ? "accent" : "warning"}>
|
<Badge tone={virtualControllerActive ? "accent" : "warning"}>
|
||||||
<Power size={12} />
|
<Power size={12} />
|
||||||
Virtual controller {activeController.virtualControllerActive ? "active" : "inactive"}
|
Virtual controller {virtualControllerActive ? "active" : "inactive"}
|
||||||
</Badge>
|
</Badge>
|
||||||
{activeController.isSimulated && <Badge tone="warning">Simulated device</Badge>}
|
{activeController.isSimulated && <Badge tone="warning">Simulated device</Badge>}
|
||||||
</div>
|
</div>
|
||||||
@@ -79,7 +87,7 @@ export function Dashboard() {
|
|||||||
<p className="text-sm text-fg-muted">
|
<p className="text-sm text-fg-muted">
|
||||||
{connectionStatus === "reconnecting"
|
{connectionStatus === "reconnecting"
|
||||||
? connectionMessage ?? "Reconnecting to controller…"
|
? connectionMessage ?? "Reconnecting to controller…"
|
||||||
: "No controller detected. Connect an 8BitDo Ultimate controller to get started."}
|
: connectionMessage ?? "No controller detected. Connect an 8BitDo Ultimate controller to get started."}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|||||||
@@ -2,14 +2,12 @@ import type { ReactNode } from "react";
|
|||||||
|
|
||||||
import { Badge } from "@/components/common/Badge";
|
import { Badge } from "@/components/common/Badge";
|
||||||
import { Panel } from "@/components/common/Panel";
|
import { Panel } from "@/components/common/Panel";
|
||||||
import { StatusDot } from "@/components/common/StatusDot";
|
|
||||||
import { PageShell } from "@/components/layout/PageShell";
|
import { PageShell } from "@/components/layout/PageShell";
|
||||||
import { useControllerStore } from "@/stores/controllerStore";
|
import { useControllerStore } from "@/stores/controllerStore";
|
||||||
|
|
||||||
const CONNECTION_LABEL: Record<string, string> = {
|
const CONNECTION_LABEL: Record<string, string> = {
|
||||||
usb: "USB",
|
usb: "USB",
|
||||||
bluetooth: "Bluetooth",
|
bluetooth: "Bluetooth",
|
||||||
receiver2_4ghz: "2.4GHz Receiver",
|
|
||||||
unknown: "Unknown",
|
unknown: "Unknown",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -66,21 +64,9 @@ export function DeviceInfo() {
|
|||||||
<Field label="Device path" value={controller.devicePath} mono />
|
<Field label="Device path" value={controller.devicePath} mono />
|
||||||
<Field label="Vendor ID" value={hex(controller.vendorId)} mono />
|
<Field label="Vendor ID" value={hex(controller.vendorId)} mono />
|
||||||
<Field label="Product ID" value={hex(controller.productId)} mono />
|
<Field label="Product ID" value={hex(controller.productId)} mono />
|
||||||
<Field label="Input mode" value={controller.mode.toUpperCase()} />
|
{controller.batteryPercent !== null && (
|
||||||
<Field
|
<Field label="Battery" value={`${controller.batteryPercent}%`} />
|
||||||
label="Battery"
|
)}
|
||||||
value={controller.batteryPercent !== null ? `${controller.batteryPercent}%` : "Not reported"}
|
|
||||||
/>
|
|
||||||
<Field label="Firmware version" value={controller.firmwareVersion ?? "Not available"} />
|
|
||||||
<Field
|
|
||||||
label="Virtual controller"
|
|
||||||
value={
|
|
||||||
<span className="flex items-center gap-1.5">
|
|
||||||
<StatusDot tone={controller.virtualControllerActive ? "success" : "neutral"} />
|
|
||||||
{controller.virtualControllerActive ? "Active" : "Inactive"}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-5 border-t border-border pt-4">
|
<div className="mt-5 border-t border-border pt-4">
|
||||||
|
|||||||
@@ -33,9 +33,6 @@ export function Settings() {
|
|||||||
const [diagnostics, setDiagnostics] = useState<DiagnosticsSnapshot | null>(null);
|
const [diagnostics, setDiagnostics] = useState<DiagnosticsSnapshot | null>(null);
|
||||||
|
|
||||||
async function handleRemappingChange(enabled: boolean) {
|
async function handleRemappingChange(enabled: boolean) {
|
||||||
// #region agent log
|
|
||||||
fetch('http://127.0.0.1:7773/ingest/f58e9523-b2dd-4860-abd0-167312081aae',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'2801e0'},body:JSON.stringify({sessionId:'2801e0',runId:'initial',hypothesisId:'H14',location:'src/pages/Settings.tsx:handleRemappingChange',message:'Settings remapping toggle changed',data:{enabled},timestamp:Date.now()})}).catch(()=>{});
|
|
||||||
// #endregion
|
|
||||||
setRemappingEnabled(enabled);
|
setRemappingEnabled(enabled);
|
||||||
try {
|
try {
|
||||||
await setControllerRemapping(enabled);
|
await setControllerRemapping(enabled);
|
||||||
|
|||||||
@@ -33,10 +33,7 @@ export class MockControllerBackend implements ControllerBackend {
|
|||||||
devicePath: "/dev/input/event33 (simulated)",
|
devicePath: "/dev/input/event33 (simulated)",
|
||||||
vendorId: DEMO_DEVICE_CAPABILITIES.vendorId,
|
vendorId: DEMO_DEVICE_CAPABILITIES.vendorId,
|
||||||
productId: DEMO_DEVICE_CAPABILITIES.productId,
|
productId: DEMO_DEVICE_CAPABILITIES.productId,
|
||||||
mode: "xinput",
|
batteryPercent: null,
|
||||||
batteryPercent: 82,
|
|
||||||
firmwareVersion: null,
|
|
||||||
virtualControllerActive: true,
|
|
||||||
capabilities: DEMO_DEVICE_CAPABILITIES,
|
capabilities: DEMO_DEVICE_CAPABILITIES,
|
||||||
isSimulated: true,
|
isSimulated: true,
|
||||||
};
|
};
|
||||||
@@ -74,9 +71,8 @@ export class MockControllerBackend implements ControllerBackend {
|
|||||||
return this.rawEventEmitter.on(callback);
|
return this.rawEventEmitter.on(callback);
|
||||||
}
|
}
|
||||||
|
|
||||||
async setRemappingEnabled(enabled: boolean): Promise<void> {
|
async setRemappingEnabled(_enabled: boolean): Promise<void> {
|
||||||
this.info = { ...this.info, virtualControllerActive: enabled };
|
// The simulated backend has no real uinput device to toggle.
|
||||||
this.controllersEmitter.emit(this.snapshotControllers());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setSimulatedConnected(connected: boolean): void {
|
setSimulatedConnected(connected: boolean): void {
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export const tauriControllerService = {
|
|||||||
startReader: (devicePath: string) => invoke<void>("start_device_reader", { devicePath }),
|
startReader: (devicePath: string) => invoke<void>("start_device_reader", { devicePath }),
|
||||||
applyRemappingProfile: (profile: Profile) => invoke<void>("apply_remapping_profile", { profile }),
|
applyRemappingProfile: (profile: Profile) => invoke<void>("apply_remapping_profile", { profile }),
|
||||||
setRemappingEnabled: (enabled: boolean) => invoke<void>("set_remapping_enabled", { enabled }),
|
setRemappingEnabled: (enabled: boolean) => invoke<void>("set_remapping_enabled", { enabled }),
|
||||||
|
virtualControllerActive: () => invoke<boolean>("virtual_controller_active"),
|
||||||
onInput: async (callback: (event: RawInputEvent) => void): Promise<() => void> =>
|
onInput: async (callback: (event: RawInputEvent) => void): Promise<() => void> =>
|
||||||
listen<NativeInputEvent>(INPUT_EVENT, (event) => callback(toRawInputEvent(event.payload))),
|
listen<NativeInputEvent>(INPUT_EVENT, (event) => callback(toRawInputEvent(event.payload))),
|
||||||
onReaderStatus: async (callback: (status: ReaderStatus) => void): Promise<() => void> =>
|
onReaderStatus: async (callback: (status: ReaderStatus) => void): Promise<() => void> =>
|
||||||
|
|||||||
@@ -7,21 +7,10 @@ import type { ControllerInfo, ControllerLiveState, RawInputEvent } from "@/types
|
|||||||
|
|
||||||
const MAX_EVENT_LOG = 200;
|
const MAX_EVENT_LOG = 200;
|
||||||
const MAX_POLL_SAMPLES = 30;
|
const MAX_POLL_SAMPLES = 30;
|
||||||
const RECONNECT_INTERVAL_MS = 1_500;
|
const RESCAN_INTERVAL_MS = 2_000;
|
||||||
const MAX_RECONNECT_ATTEMPTS = 20;
|
|
||||||
|
|
||||||
export type ControllerConnectionStatus = "connected" | "reconnecting" | "disconnected" | "simulation";
|
export type ControllerConnectionStatus = "connected" | "reconnecting" | "disconnected" | "simulation";
|
||||||
|
|
||||||
function samePhysicalController(a: ControllerInfo, b: ControllerInfo): boolean {
|
|
||||||
return (
|
|
||||||
a.vendorId === b.vendorId &&
|
|
||||||
a.productId === b.productId &&
|
|
||||||
a.name === b.name &&
|
|
||||||
a.connection === b.connection &&
|
|
||||||
a.mode === b.mode
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function estimatePollRateHz(timestamps: number[]): number | null {
|
function estimatePollRateHz(timestamps: number[]): number | null {
|
||||||
if (timestamps.length < 2) return null;
|
if (timestamps.length < 2) return null;
|
||||||
const span = timestamps[timestamps.length - 1] - timestamps[0];
|
const span = timestamps[timestamps.length - 1] - timestamps[0];
|
||||||
@@ -29,6 +18,19 @@ function estimatePollRateHz(timestamps: number[]): number | null {
|
|||||||
return Math.round(((timestamps.length - 1) / span) * 1000);
|
return Math.round(((timestamps.length - 1) / span) * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matches a controller across reconnects, where the kernel usually assigns a
|
||||||
|
* new /dev/input/event* node (and therefore a new id).
|
||||||
|
*/
|
||||||
|
function samePhysicalController(a: ControllerInfo, b: ControllerInfo): boolean {
|
||||||
|
return (
|
||||||
|
a.vendorId === b.vendorId &&
|
||||||
|
a.productId === b.productId &&
|
||||||
|
a.name === b.name &&
|
||||||
|
a.connection === b.connection
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface ControllerState {
|
interface ControllerState {
|
||||||
controllers: ControllerInfo[];
|
controllers: ControllerInfo[];
|
||||||
activeControllerId: string | null;
|
activeControllerId: string | null;
|
||||||
@@ -38,6 +40,7 @@ interface ControllerState {
|
|||||||
latencyEstimateMs: number | null;
|
latencyEstimateMs: number | null;
|
||||||
connectionStatus: ControllerConnectionStatus;
|
connectionStatus: ControllerConnectionStatus;
|
||||||
connectionMessage: string | null;
|
connectionMessage: string | null;
|
||||||
|
virtualControllerActive: boolean;
|
||||||
initialized: boolean;
|
initialized: boolean;
|
||||||
usingSimulation: boolean;
|
usingSimulation: boolean;
|
||||||
init: () => Promise<void>;
|
init: () => Promise<void>;
|
||||||
@@ -48,17 +51,14 @@ interface ControllerState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let pollTimestamps: number[] = [];
|
let pollTimestamps: number[] = [];
|
||||||
let hasLoggedFirstPhysicalEvent = false;
|
let rescanTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
let reconnectAttempts = 0;
|
|
||||||
let refreshInFlight = false;
|
let refreshInFlight = false;
|
||||||
|
|
||||||
function clearReconnectTimer(): void {
|
function clearRescanTimer(): void {
|
||||||
if (reconnectTimer !== null) {
|
if (rescanTimer !== null) {
|
||||||
clearTimeout(reconnectTimer);
|
clearTimeout(rescanTimer);
|
||||||
reconnectTimer = null;
|
rescanTimer = null;
|
||||||
}
|
}
|
||||||
reconnectAttempts = 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useControllerStore = create<ControllerState>((set, get) => ({
|
export const useControllerStore = create<ControllerState>((set, get) => ({
|
||||||
@@ -70,6 +70,7 @@ export const useControllerStore = create<ControllerState>((set, get) => ({
|
|||||||
latencyEstimateMs: null,
|
latencyEstimateMs: null,
|
||||||
connectionStatus: "disconnected",
|
connectionStatus: "disconnected",
|
||||||
connectionMessage: null,
|
connectionMessage: null,
|
||||||
|
virtualControllerActive: false,
|
||||||
initialized: false,
|
initialized: false,
|
||||||
usingSimulation: false,
|
usingSimulation: false,
|
||||||
|
|
||||||
@@ -77,31 +78,6 @@ export const useControllerStore = create<ControllerState>((set, get) => ({
|
|||||||
if (get().initialized) return;
|
if (get().initialized) return;
|
||||||
set({ initialized: true });
|
set({ initialized: true });
|
||||||
|
|
||||||
const simulationRequested = useSettingsStore.getState().simulationMode;
|
|
||||||
let realControllers: ControllerInfo[] = [];
|
|
||||||
try {
|
|
||||||
realControllers = simulationRequested ? [] : await tauriControllerService.list();
|
|
||||||
} catch {
|
|
||||||
// Browser-only development and missing permissions both retain the safe
|
|
||||||
// simulated fallback rather than leaving the UI unusable.
|
|
||||||
}
|
|
||||||
|
|
||||||
const useSimulation = simulationRequested || realControllers.length === 0;
|
|
||||||
set({
|
|
||||||
controllers: useSimulation ? [] : realControllers,
|
|
||||||
activeControllerId: useSimulation ? null : realControllers[0]?.id ?? null,
|
|
||||||
usingSimulation: useSimulation,
|
|
||||||
connectionStatus: useSimulation ? "simulation" : "connected",
|
|
||||||
connectionMessage: useSimulation
|
|
||||||
? simulationRequested
|
|
||||||
? "Simulation mode is enabled."
|
|
||||||
: "No physical controller detected; using simulated input."
|
|
||||||
: null,
|
|
||||||
});
|
|
||||||
// #region agent log
|
|
||||||
fetch('http://127.0.0.1:7773/ingest/f58e9523-b2dd-4860-abd0-167312081aae',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'2801e0'},body:JSON.stringify({sessionId:'2801e0',runId:'initial',hypothesisId:'H3',location:'src/stores/controllerStore.ts:init',message:'Controller backend selected',data:{simulationRequested,realControllerCount:realControllers.length,useSimulation,devices:realControllers.map((controller)=>({id:controller.id,name:controller.name,connection:controller.connection,devicePath:controller.devicePath}))},timestamp:Date.now()})}).catch(()=>{});
|
|
||||||
// #endregion
|
|
||||||
|
|
||||||
const receiveLiveState = (liveState: ControllerLiveState) => {
|
const receiveLiveState = (liveState: ControllerLiveState) => {
|
||||||
pollTimestamps.push(liveState.timestamp);
|
pollTimestamps.push(liveState.timestamp);
|
||||||
if (pollTimestamps.length > MAX_POLL_SAMPLES) pollTimestamps.shift();
|
if (pollTimestamps.length > MAX_POLL_SAMPLES) pollTimestamps.shift();
|
||||||
@@ -111,12 +87,6 @@ export const useControllerStore = create<ControllerState>((set, get) => ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const receiveRawEvent = (event: RawInputEvent) => {
|
const receiveRawEvent = (event: RawInputEvent) => {
|
||||||
if (!get().usingSimulation && !hasLoggedFirstPhysicalEvent) {
|
|
||||||
hasLoggedFirstPhysicalEvent = true;
|
|
||||||
// #region agent log
|
|
||||||
fetch('http://127.0.0.1:7773/ingest/f58e9523-b2dd-4860-abd0-167312081aae',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'2801e0'},body:JSON.stringify({sessionId:'2801e0',runId:'initial',hypothesisId:'H2',location:'src/stores/controllerStore.ts:receiveRawEvent',message:'First physical input event received',data:{type:event.type,code:event.code,rawValue:event.rawValue,processedValue:event.processedValue},timestamp:Date.now()})}).catch(()=>{});
|
|
||||||
// #endregion
|
|
||||||
}
|
|
||||||
set((state) => ({ eventLog: [event, ...state.eventLog].slice(0, MAX_EVENT_LOG) }));
|
set((state) => ({ eventLog: [event, ...state.eventLog].slice(0, MAX_EVENT_LOG) }));
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const liveState = state.liveState ?? {
|
const liveState = state.liveState ?? {
|
||||||
@@ -135,113 +105,153 @@ export const useControllerStore = create<ControllerState>((set, get) => ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!useSimulation) {
|
const startSimulation = async (message: string) => {
|
||||||
const active = realControllers[0];
|
set({
|
||||||
await tauriControllerService.onInput(receiveRawEvent);
|
controllers: [],
|
||||||
const refreshNativeDevices = async (): Promise<boolean> => {
|
activeControllerId: null,
|
||||||
if (refreshInFlight) return false;
|
usingSimulation: true,
|
||||||
refreshInFlight = true;
|
connectionStatus: "simulation",
|
||||||
try {
|
connectionMessage: message,
|
||||||
const previousActive = get().activeController();
|
|
||||||
const devices = await tauriControllerService.list();
|
|
||||||
const nextActive =
|
|
||||||
devices.find((controller) => controller.id === previousActive?.id) ??
|
|
||||||
(previousActive ? devices.find((controller) => samePhysicalController(controller, previousActive)) : undefined) ??
|
|
||||||
devices[0];
|
|
||||||
|
|
||||||
if (!nextActive) {
|
|
||||||
set({
|
|
||||||
controllers: [],
|
|
||||||
activeControllerId: null,
|
|
||||||
liveState: null,
|
|
||||||
pollRateHz: null,
|
|
||||||
latencyEstimateMs: null,
|
|
||||||
connectionStatus: "reconnecting",
|
|
||||||
connectionMessage: "Waiting for the controller to reconnect…",
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
set({
|
|
||||||
controllers: devices,
|
|
||||||
activeControllerId: nextActive.id,
|
|
||||||
liveState: null,
|
|
||||||
pollRateHz: null,
|
|
||||||
latencyEstimateMs: null,
|
|
||||||
connectionStatus: "connected",
|
|
||||||
connectionMessage: null,
|
|
||||||
});
|
|
||||||
await tauriControllerService.startReader(nextActive.devicePath);
|
|
||||||
clearReconnectTimer();
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
set({
|
|
||||||
connectionStatus: "reconnecting",
|
|
||||||
connectionMessage: "Unable to access the controller; retrying…",
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
} finally {
|
|
||||||
refreshInFlight = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const scheduleReconnect = (): void => {
|
|
||||||
if (reconnectTimer !== null) return;
|
|
||||||
const attemptReconnect = async (): Promise<void> => {
|
|
||||||
reconnectTimer = null;
|
|
||||||
if (await refreshNativeDevices()) return;
|
|
||||||
reconnectAttempts += 1;
|
|
||||||
if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
|
||||||
set({
|
|
||||||
connectionStatus: "disconnected",
|
|
||||||
connectionMessage: "Controller did not reconnect. Reconnect it or restart input detection.",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
reconnectTimer = setTimeout(() => void attemptReconnect(), RECONNECT_INTERVAL_MS);
|
|
||||||
};
|
|
||||||
void attemptReconnect();
|
|
||||||
};
|
|
||||||
|
|
||||||
await tauriControllerService.onReaderStatus((status) => {
|
|
||||||
// #region agent log
|
|
||||||
fetch('http://127.0.0.1:7773/ingest/f58e9523-b2dd-4860-abd0-167312081aae',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'2801e0'},body:JSON.stringify({sessionId:'2801e0',runId:'initial',hypothesisId:'H6',location:'src/stores/controllerStore.ts:onReaderStatus',message:'Physical reader status changed',data:status,timestamp:Date.now()})}).catch(()=>{});
|
|
||||||
// #endregion
|
|
||||||
const activeController = get().activeController();
|
|
||||||
if (status.connected && activeController?.devicePath === status.devicePath) {
|
|
||||||
clearReconnectTimer();
|
|
||||||
set({ connectionStatus: "connected", connectionMessage: null });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!status.connected && activeController?.devicePath === status.devicePath) {
|
|
||||||
set({
|
|
||||||
liveState: null,
|
|
||||||
pollRateHz: null,
|
|
||||||
latencyEstimateMs: null,
|
|
||||||
connectionStatus: "reconnecting",
|
|
||||||
connectionMessage: status.message ?? "Controller disconnected; searching for it again…",
|
|
||||||
});
|
|
||||||
scheduleReconnect();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
if (active) {
|
await mockControllerBackend.init();
|
||||||
await tauriControllerService.startReader(active.devicePath);
|
mockControllerBackend.onControllersChanged((controllers) => {
|
||||||
}
|
set((state) => ({
|
||||||
|
controllers,
|
||||||
|
activeControllerId: controllers.some((c) => c.id === state.activeControllerId)
|
||||||
|
? state.activeControllerId
|
||||||
|
: controllers[0]?.id ?? null,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
mockControllerBackend.onLiveState(receiveLiveState);
|
||||||
|
mockControllerBackend.onRawEvent(receiveRawEvent);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (useSettingsStore.getState().simulationMode) {
|
||||||
|
await startSimulation("Simulation mode is enabled in Settings.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await mockControllerBackend.init();
|
let realControllers: ControllerInfo[];
|
||||||
mockControllerBackend.onControllersChanged((controllers) => {
|
try {
|
||||||
set((state) => ({
|
realControllers = await tauriControllerService.list();
|
||||||
controllers,
|
} catch {
|
||||||
activeControllerId: controllers.some((c) => c.id === state.activeControllerId)
|
// The backend is unreachable only when running in a plain browser
|
||||||
? state.activeControllerId
|
// (no Tauri). Fall back to the clearly-labeled simulated controller so
|
||||||
: controllers[0]?.id ?? null,
|
// frontend development stays possible.
|
||||||
}));
|
await startSimulation("Native backend unavailable; using simulated input.");
|
||||||
});
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
mockControllerBackend.onLiveState(receiveLiveState);
|
const refreshVirtualControllerActive = async () => {
|
||||||
mockControllerBackend.onRawEvent(receiveRawEvent);
|
try {
|
||||||
|
set({ virtualControllerActive: await tauriControllerService.virtualControllerActive() });
|
||||||
|
} catch {
|
||||||
|
set({ virtualControllerActive: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshNativeDevices = async (): Promise<boolean> => {
|
||||||
|
if (refreshInFlight) return false;
|
||||||
|
refreshInFlight = true;
|
||||||
|
try {
|
||||||
|
const previousActive = get().activeController();
|
||||||
|
const devices = await tauriControllerService.list();
|
||||||
|
const nextActive =
|
||||||
|
devices.find((controller) => controller.id === previousActive?.id) ??
|
||||||
|
(previousActive
|
||||||
|
? devices.find((controller) => samePhysicalController(controller, previousActive))
|
||||||
|
: undefined) ??
|
||||||
|
devices[0];
|
||||||
|
|
||||||
|
if (!nextActive) {
|
||||||
|
set((state) => ({
|
||||||
|
controllers: [],
|
||||||
|
activeControllerId: null,
|
||||||
|
liveState: null,
|
||||||
|
pollRateHz: null,
|
||||||
|
latencyEstimateMs: null,
|
||||||
|
connectionStatus: state.connectionStatus === "reconnecting" ? "reconnecting" : "disconnected",
|
||||||
|
connectionMessage:
|
||||||
|
state.connectionStatus === "reconnecting"
|
||||||
|
? "Waiting for the controller to reconnect…"
|
||||||
|
: "No controller detected.",
|
||||||
|
}));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
set({
|
||||||
|
controllers: devices,
|
||||||
|
activeControllerId: nextActive.id,
|
||||||
|
connectionStatus: "connected",
|
||||||
|
connectionMessage: null,
|
||||||
|
});
|
||||||
|
await tauriControllerService.startReader(nextActive.devicePath);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
set({
|
||||||
|
connectionStatus: "reconnecting",
|
||||||
|
connectionMessage: "Unable to access the controller; retrying…",
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
refreshInFlight = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Polls until a controller is present. Runs indefinitely so a controller
|
||||||
|
* plugged in at any time is picked up without restarting the app.
|
||||||
|
*/
|
||||||
|
const scheduleRescan = (): void => {
|
||||||
|
if (rescanTimer !== null) return;
|
||||||
|
const attempt = async (): Promise<void> => {
|
||||||
|
rescanTimer = null;
|
||||||
|
if (await refreshNativeDevices()) return;
|
||||||
|
rescanTimer = setTimeout(() => void attempt(), RESCAN_INTERVAL_MS);
|
||||||
|
};
|
||||||
|
rescanTimer = setTimeout(() => void attempt(), RESCAN_INTERVAL_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
await tauriControllerService.onInput(receiveRawEvent);
|
||||||
|
await tauriControllerService.onReaderStatus((status) => {
|
||||||
|
const activeController = get().activeController();
|
||||||
|
if (activeController?.devicePath !== status.devicePath) return;
|
||||||
|
if (status.connected) {
|
||||||
|
clearRescanTimer();
|
||||||
|
set({ connectionStatus: "connected", connectionMessage: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
set({
|
||||||
|
liveState: null,
|
||||||
|
pollRateHz: null,
|
||||||
|
latencyEstimateMs: null,
|
||||||
|
connectionStatus: "reconnecting",
|
||||||
|
connectionMessage: status.message ?? "Controller disconnected; searching for it again…",
|
||||||
|
});
|
||||||
|
scheduleRescan();
|
||||||
|
});
|
||||||
|
await refreshVirtualControllerActive();
|
||||||
|
|
||||||
|
const active = realControllers[0];
|
||||||
|
if (active) {
|
||||||
|
set({
|
||||||
|
controllers: realControllers,
|
||||||
|
activeControllerId: active.id,
|
||||||
|
usingSimulation: false,
|
||||||
|
connectionStatus: "connected",
|
||||||
|
connectionMessage: null,
|
||||||
|
});
|
||||||
|
await tauriControllerService.startReader(active.devicePath);
|
||||||
|
} else {
|
||||||
|
set({
|
||||||
|
controllers: [],
|
||||||
|
activeControllerId: null,
|
||||||
|
usingSimulation: false,
|
||||||
|
connectionStatus: "disconnected",
|
||||||
|
connectionMessage: "No controller detected.",
|
||||||
|
});
|
||||||
|
scheduleRescan();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
activeController: () => {
|
activeController: () => {
|
||||||
@@ -263,14 +273,16 @@ export const useControllerStore = create<ControllerState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
setRemappingEnabled: async (enabled) => {
|
setRemappingEnabled: async (enabled) => {
|
||||||
// #region agent log
|
|
||||||
fetch('http://127.0.0.1:7773/ingest/f58e9523-b2dd-4860-abd0-167312081aae',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'2801e0'},body:JSON.stringify({sessionId:'2801e0',runId:'initial',hypothesisId:'H8',location:'src/stores/controllerStore.ts:setRemappingEnabled',message:'Remapping toggle requested',data:{enabled,usingSimulation:get().usingSimulation,activeControllerId:get().activeControllerId},timestamp:Date.now()})}).catch(()=>{});
|
|
||||||
// #endregion
|
|
||||||
if (get().usingSimulation) {
|
if (get().usingSimulation) {
|
||||||
await mockControllerBackend.setRemappingEnabled(enabled);
|
await mockControllerBackend.setRemappingEnabled(enabled);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await tauriControllerService.setRemappingEnabled(enabled);
|
await tauriControllerService.setRemappingEnabled(enabled);
|
||||||
|
try {
|
||||||
|
set({ virtualControllerActive: await tauriControllerService.virtualControllerActive() });
|
||||||
|
} catch {
|
||||||
|
set({ virtualControllerActive: false });
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
clearEventLog: () => set({ eventLog: [] }),
|
clearEventLog: () => set({ eventLog: [] }),
|
||||||
|
|||||||
@@ -75,9 +75,6 @@ export const useProfileStore = create<ProfileState>((set, get) => ({
|
|||||||
const nextDraft = updater(deepClone(draft));
|
const nextDraft = updater(deepClone(draft));
|
||||||
const isDirty = !isEqual(nextDraft, get().activeProfile);
|
const isDirty = !isEqual(nextDraft, get().activeProfile);
|
||||||
set({ draftProfile: nextDraft, isDirty });
|
set({ draftProfile: nextDraft, isDirty });
|
||||||
// #region agent log
|
|
||||||
fetch('http://127.0.0.1:7773/ingest/f58e9523-b2dd-4860-abd0-167312081aae',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'2801e0'},body:JSON.stringify({sessionId:'2801e0',runId:'initial',hypothesisId:'H1',location:'src/stores/profileStore.ts:updateDraft',message:'Profile draft updated',data:{profileId:nextDraft.id,isDirty,leftTrigger:nextDraft.leftTrigger,rightTrigger:nextDraft.rightTrigger},timestamp:Date.now()})}).catch(()=>{});
|
|
||||||
// #endregion
|
|
||||||
},
|
},
|
||||||
|
|
||||||
applyDraft: async () => {
|
applyDraft: async () => {
|
||||||
@@ -90,9 +87,6 @@ export const useProfileStore = create<ProfileState>((set, get) => ({
|
|||||||
useUIStore.getState().pushToast(`Could not apply remapping: ${err}`, "danger");
|
useUIStore.getState().pushToast(`Could not apply remapping: ${err}`, "danger");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// #region agent log
|
|
||||||
fetch('http://127.0.0.1:7773/ingest/f58e9523-b2dd-4860-abd0-167312081aae',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'2801e0'},body:JSON.stringify({sessionId:'2801e0',runId:'post-fix',hypothesisId:'H7',location:'src/stores/profileStore.ts:applyDraft',message:'Profile successfully applied to native remapping pipeline',data:{profileId:draft.id},timestamp:Date.now()})}).catch(()=>{});
|
|
||||||
// #endregion
|
|
||||||
},
|
},
|
||||||
|
|
||||||
revertDraft: () => {
|
revertDraft: () => {
|
||||||
@@ -111,9 +105,6 @@ export const useProfileStore = create<ProfileState>((set, get) => ({
|
|||||||
set({ activeProfile: saved, draftProfile: deepClone(saved), isDirty: false, loading: false });
|
set({ activeProfile: saved, draftProfile: deepClone(saved), isDirty: false, loading: false });
|
||||||
await get().refreshList();
|
await get().refreshList();
|
||||||
useUIStore.getState().pushToast(`Saved profile "${saved.name}".`, "success");
|
useUIStore.getState().pushToast(`Saved profile "${saved.name}".`, "success");
|
||||||
// #region agent log
|
|
||||||
fetch('http://127.0.0.1:7773/ingest/f58e9523-b2dd-4860-abd0-167312081aae',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'2801e0'},body:JSON.stringify({sessionId:'2801e0',runId:'initial',hypothesisId:'H7',location:'src/stores/profileStore.ts:saveDraft',message:'Profile draft saved',data:{profileId:saved.id},timestamp:Date.now()})}).catch(()=>{});
|
|
||||||
// #endregion
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
set({ loading: false, error: String(err) });
|
set({ loading: false, error: String(err) });
|
||||||
useUIStore.getState().pushToast(`Failed to save profile: ${err}`, "danger");
|
useUIStore.getState().pushToast(`Failed to save profile: ${err}`, "danger");
|
||||||
|
|||||||
@@ -22,12 +22,7 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
remappingEnabled: true,
|
remappingEnabled: true,
|
||||||
setRemappingEnabled: (remappingEnabled) => set({ remappingEnabled }),
|
setRemappingEnabled: (remappingEnabled) => set({ remappingEnabled }),
|
||||||
simulationMode: false,
|
simulationMode: false,
|
||||||
setSimulationMode: (simulationMode) => {
|
setSimulationMode: (simulationMode) => set({ simulationMode }),
|
||||||
// #region agent log
|
|
||||||
fetch('http://127.0.0.1:7773/ingest/f58e9523-b2dd-4860-abd0-167312081aae',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'2801e0'},body:JSON.stringify({sessionId:'2801e0',runId:'initial',hypothesisId:'H5',location:'src/stores/settingsStore.ts:setSimulationMode',message:'Simulation mode changed',data:{simulationMode},timestamp:Date.now()})}).catch(()=>{});
|
|
||||||
// #endregion
|
|
||||||
set({ simulationMode });
|
|
||||||
},
|
|
||||||
autoSwitchProfiles: false,
|
autoSwitchProfiles: false,
|
||||||
setAutoSwitchProfiles: (autoSwitchProfiles) => set({ autoSwitchProfiles }),
|
setAutoSwitchProfiles: (autoSwitchProfiles) => set({ autoSwitchProfiles }),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -7,9 +7,7 @@
|
|||||||
* `mockControllerBackend` provides a realistic example.
|
* `mockControllerBackend` provides a realistic example.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type ConnectionKind = "usb" | "bluetooth" | "receiver2_4ghz" | "unknown";
|
export type ConnectionKind = "usb" | "bluetooth" | "unknown";
|
||||||
|
|
||||||
export type ControllerMode = "xinput" | "directinput" | "switch" | "unknown";
|
|
||||||
|
|
||||||
export interface ButtonCapability {
|
export interface ButtonCapability {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -45,10 +43,8 @@ export interface ControllerInfo {
|
|||||||
devicePath: string;
|
devicePath: string;
|
||||||
vendorId: number;
|
vendorId: number;
|
||||||
productId: number;
|
productId: number;
|
||||||
mode: ControllerMode;
|
/** Kernel-reported battery percentage; null when the device reports none. */
|
||||||
batteryPercent: number | null;
|
batteryPercent: number | null;
|
||||||
firmwareVersion: string | null;
|
|
||||||
virtualControllerActive: boolean;
|
|
||||||
capabilities: DeviceCapabilities;
|
capabilities: DeviceCapabilities;
|
||||||
isSimulated: boolean;
|
isSimulated: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user