Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ members = [
resolver = "2"

[workspace.package]
version = "1.2.0"
version = "1.7.0"
edition = "2021"
authors = ["Eduardo Zenardi"]
license = "MIT"
Expand Down
128 changes: 128 additions & 0 deletions crates/synaptix-daemon/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ fn config_path() -> Option<PathBuf> {
directories::BaseDirs::new().map(|b| b.config_dir().join("synaptix").join("devices.json"))
}

/// Returns `~/.config/synaptix/headset_state.json`.
fn headset_state_path() -> Option<PathBuf> {
directories::BaseDirs::new().map(|b| b.config_dir().join("synaptix").join("headset_state.json"))
}

/// Reads `devices.json` from disk. Returns an empty map on any error (file missing is fine).
pub fn load_settings() -> HashMap<String, DeviceSettings> {
let path = match config_path() {
Expand Down Expand Up @@ -61,6 +66,78 @@ pub fn save_settings(settings: &HashMap<String, DeviceSettings>) {
}
}

/// Loads the persisted Kraken V4 Pro haptic level from disk.
///
/// Returns the saved level (0–100) or **33 (Low)** as a safe default when the
/// file is absent. Never returns 0 as default — 0 would reset the hub's
/// physical haptic setting to OFF on the first battery poll after daemon start.
pub fn load_haptic_level() -> u8 {
let path = match headset_state_path() {
Some(p) => p,
None => return 33,
};

let json = match fs::read_to_string(&path) {
Ok(j) => j,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return 33,
Err(e) => {
log::warn!(
"[Config] Failed to read headset state {}: {e}",
path.display()
);
return 33;
}
};

match serde_json::from_str::<serde_json::Value>(&json) {
Ok(v) => {
let level = v["kraken_haptic_level"]
.as_u64()
.map(|n| n as u8)
.unwrap_or(33);
log::info!("[Config] Loaded persisted haptic level: {level}");
level
}
Err(e) => {
log::warn!("[Config] Failed to parse headset state: {e}");
33
}
}
}

/// Saves the Kraken V4 Pro haptic level to disk so it survives daemon restarts.
///
/// Called from `DeviceManager::set_haptic_intensity` after a successful USB write.
pub fn save_haptic_level(level: u8) {
let path = match headset_state_path() {
Some(p) => p,
None => {
log::warn!("[Config] Could not resolve config directory — haptic level not saved");
return;
}
};

if let Some(dir) = path.parent() {
if let Err(e) = fs::create_dir_all(dir) {
log::error!(
"[Config] Failed to create config dir {}: {e}",
dir.display()
);
return;
}
}

let json = format!("{{\"kraken_haptic_level\":{level}}}");
if let Err(e) = fs::write(&path, &json) {
log::error!("[Config] Failed to write haptic level: {e}");
} else {
log::info!(
"[Config] Haptic level {level} persisted to {}",
path.display()
);
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -99,4 +176,55 @@ mod tests {

std::fs::remove_file(tmp).ok();
}

/// load_haptic_level returns 33 when no file exists (safe non-zero default).
#[test]
fn test_load_haptic_level_default_when_absent() {
let level = load_haptic_level_from_path(
&std::env::temp_dir().join("synaptix_haptic_NONEXISTENT_test.json"),
);
assert_eq!(
level, 33,
"default must be 33, not 0 (0 would reset hub haptics)"
);
}

/// save_haptic_level + load round-trip.
#[test]
fn test_save_and_load_haptic_level() {
let tmp = std::env::temp_dir().join("synaptix_test_haptic_level.json");
save_haptic_level_to_path(100, &tmp);
let loaded = load_haptic_level_from_path(&tmp);
assert_eq!(loaded, 100);

save_haptic_level_to_path(33, &tmp);
assert_eq!(load_haptic_level_from_path(&tmp), 33);

save_haptic_level_to_path(0, &tmp);
assert_eq!(
load_haptic_level_from_path(&tmp),
0,
"level 0 (off) must round-trip correctly"
);

std::fs::remove_file(tmp).ok();
}

/// save_haptic_level_to_path / load_haptic_level_from_path for testable I/O.
fn save_haptic_level_to_path(level: u8, path: &std::path::Path) {
let json = format!("{{\"kraken_haptic_level\":{level}}}");
std::fs::write(path, &json).unwrap();
}

fn load_haptic_level_from_path(path: &std::path::Path) -> u8 {
match std::fs::read_to_string(path) {
Ok(json) => serde_json::from_str::<serde_json::Value>(&json)
.ok()
.and_then(|v| v["kraken_haptic_level"].as_u64())
.map(|n| n as u8)
.unwrap_or(33),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => 33,
Err(_) => 33,
}
}
}
25 changes: 24 additions & 1 deletion crates/synaptix-daemon/src/device_manager.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
use std::collections::HashMap;
use std::sync::{
atomic::{AtomicU8, Ordering},
Arc,
};
use synaptix_protocol::{
registry::{get_device_profile, DeviceCapability},
BatteryState, DeviceSettings, LightingEffect, RazerDevice, RazerProductId,
Expand Down Expand Up @@ -40,6 +44,10 @@ pub struct DeviceManager {
pub(crate) devices: HashMap<String, RazerDevice>,
lighting: HashMap<String, LightingEffect>,
settings: HashMap<String, DeviceSettings>,
/// Current haptic level for the Kraken V4 Pro (PID 0x0568).
/// Shared with the battery polling loop so the trigger command uses the
/// real current level instead of always resetting to 0.
pub(crate) kraken_v4_haptic_level: Arc<AtomicU8>,
}

impl DeviceManager {
Expand All @@ -48,6 +56,7 @@ impl DeviceManager {
devices: HashMap::new(),
lighting: HashMap::new(),
settings: crate::config::load_settings(),
kraken_v4_haptic_level: Arc::new(AtomicU8::new(crate::config::load_haptic_level())),
}
}

Expand Down Expand Up @@ -390,7 +399,13 @@ impl DeviceManager {
let result = tokio::task::spawn_blocking(move || {
if pid == 0x0568 {
// Kraken V4 Pro OLED Hub: 64-byte proprietary HID report on
// Interface 4, wValue=0x0202. Wireshark-verified protocol path.
// Interface 4, wValue=0x0202. Sets haptic sensitivity/amplification.
//
// NOTE: The motor vibrates in response to audio playing through
// the headset (routed by the kernel ALSA driver on Interface 2).
// We only control the sensitivity level here — the ALSA driver
// provides the audio source. Attempting to claim Interface 2 via
// libusb detaches the ALSA driver and mutes the headset.
let payload = crate::razer_protocol::build_haptic_report(clamped);
crate::usb_backend::send_haptic_report(pid, &payload)
} else {
Expand All @@ -405,6 +420,14 @@ impl DeviceManager {
match result {
Ok(Ok(())) => {
log::info!("[SetHapticIntensity] USB transfer succeeded for {device_id}");
// Keep the shared level in sync so the battery polling loop sends
// build_haptic_report(clamped) as the trigger instead of level=0.
if pid == 0x0568 {
self.kraken_v4_haptic_level
.store(clamped, Ordering::Relaxed);
// Persist level so daemon restarts don't reset haptics to OFF.
crate::config::save_haptic_level(clamped);
}
true
}
Ok(Err(e)) => {
Expand Down
32 changes: 26 additions & 6 deletions crates/synaptix-daemon/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,19 @@ async fn run_daemon(tx: std::sync::mpsc::Sender<TrayUpdate>) {
if !detected_headsets.is_empty() {
let headset_conn = conn.clone();
let headset_tx = tx.clone();

// Clone the haptic-level Arc so the polling loop uses the current level
// as the battery trigger instead of always sending level=0 (which would
// reset haptics to OFF every 5 seconds).
let kraken_haptic_arc = {
let iface = conn
.object_server()
.interface::<_, DeviceManager>("/org/synaptix/Daemon")
.await
.expect("DeviceManager not registered");
let guard = iface.get().await;
guard.kraken_v4_haptic_level.clone()
};
let headset_pids: Vec<(u16, String, String)> = detected_headsets
.iter()
.map(|(pid, prod_id)| {
Expand All @@ -533,14 +546,21 @@ async fn run_daemon(tx: std::sync::mpsc::Sender<TrayUpdate>) {
let pid = *pid;
let device_id = device_id.clone();
let display_name = display_name.clone();
let haptic_level = kraken_haptic_arc.load(std::sync::atomic::Ordering::Relaxed);

let pct_opt =
tokio::task::spawn_blocking(move || usb_backend::poll_headset_battery(pid))
.await
.ok()
.flatten();
let pct_opt = tokio::task::spawn_blocking(move || {
usb_backend::poll_headset_battery(pid, haptic_level)
})
.await
.ok()
.flatten();

let Some(pct) = pct_opt else { continue };
// The Kraken V4 Pro is a WIRELESS headset. PID 0x0568 is the OLED
// Control Hub (a USB dongle). The headset connects wirelessly to the
// hub — USB ≠ charging the headset battery. Always report Discharging
// unless we detect a charging byte in the protocol (none found in 78
// Wireshark captures: all bytes beyond [2] are zero or constant).
let new_state = BatteryState::Discharging(pct);

let Ok(iface_ref) = headset_conn
Expand Down Expand Up @@ -573,7 +593,7 @@ async fn run_daemon(tx: std::sync::mpsc::Sender<TrayUpdate>) {
device_id: device_id.clone(),
device_name: display_name,
percentage: pct,
is_charging: false,
is_charging: false, // hub USB ≠ charging; headset is wireless
})
.ok();

Expand Down
Loading