diff --git a/Cargo.lock b/Cargo.lock index 2d9d73cc..a74eb73c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4703,6 +4703,7 @@ dependencies = [ "rog_profiles", "rog_slash", "serde", + "serde_json", "slint", "slint-build", "thiserror 2.0.18", diff --git a/rog-control-center/Cargo.toml b/rog-control-center/Cargo.toml index 97c56b44..02b846b4 100644 --- a/rog-control-center/Cargo.toml +++ b/rog-control-center/Cargo.toml @@ -44,6 +44,7 @@ notify-rust.workspace = true concat-idents.workspace = true futures-util.workspace = true thiserror.workspace = true +serde_json = "1.0" [dependencies.slint] git = "https://github.com/slint-ui/slint.git" diff --git a/rog-control-center/src/config.rs b/rog-control-center/src/config.rs index ef8207f2..82045cec 100644 --- a/rog-control-center/src/config.rs +++ b/rog-control-center/src/config.rs @@ -13,6 +13,8 @@ pub struct Config { pub run_in_background: bool, pub startup_in_background: bool, pub enable_tray_icon: bool, + #[serde(default)] + pub auto_refresh_rate: bool, pub ac_command: String, pub bat_command: String, pub dark_mode: bool, @@ -30,6 +32,7 @@ impl Default for Config { run_in_background: true, startup_in_background: false, enable_tray_icon: true, + auto_refresh_rate: false, dark_mode: true, start_fullscreen: false, fullscreen_width: 1920, @@ -86,6 +89,7 @@ impl From for Config { run_in_background: c.run_in_background, startup_in_background: c.startup_in_background, enable_tray_icon: true, + auto_refresh_rate: false, ac_command: c.ac_command, bat_command: c.bat_command, dark_mode: true, diff --git a/rog-control-center/src/ui/mod.rs b/rog-control-center/src/ui/mod.rs index b0d0b10c..97cb0b15 100644 --- a/rog-control-center/src/ui/mod.rs +++ b/rog-control-center/src/ui/mod.rs @@ -146,9 +146,10 @@ pub fn setup_window( available.contains(&"xyz.ljones.Anime".to_string()), available.contains(&"xyz.ljones.Slash".to_string()), available.contains(&"xyz.ljones.FanCurves".to_string()), - true, // GPU Configuration - true, // App Settings - true, // About + true, // GPU Configuration + available.contains(&"xyz.ljones.Platform".to_string()), // Battery Info + true, // App Settings + true, // About ] .into(), ); @@ -211,11 +212,19 @@ pub fn setup_app_settings_page(ui: &MainWindow, config: Arc>) { lock.write(); } }); + let config_copy = config.clone(); + global.on_set_auto_refresh_rate(move |enable| { + if let Ok(mut lock) = config_copy.try_lock() { + lock.auto_refresh_rate = enable; + lock.write(); + } + }); if let Ok(lock) = config.try_lock() { global.set_run_in_background(lock.run_in_background); global.set_startup_in_background(lock.startup_in_background); global.set_enable_tray_icon(lock.enable_tray_icon); global.set_enable_dgpu_notifications(lock.notifications.enabled); + global.set_auto_refresh_rate(lock.auto_refresh_rate); } } diff --git a/rog-control-center/src/ui/setup_system.rs b/rog-control-center/src/ui/setup_system.rs index 3de07613..d7926010 100644 --- a/rog-control-center/src/ui/setup_system.rs +++ b/rog-control-center/src/ui/setup_system.rs @@ -20,7 +20,7 @@ const MINMAX: AttrMinMax = AttrMinMax { current: -1.0, }; -pub fn setup_system_page(ui: &MainWindow, _config: Arc>) { +pub fn setup_system_page(ui: &MainWindow, config: Arc>) { let conn = zbus::blocking::Connection::system() .map_err(|e| error!("DBus system connection failed: {e:?}")) .unwrap(); @@ -37,6 +37,13 @@ pub fn setup_system_page(ui: &MainWindow, _config: Arc>) { .set_charge_control_end_threshold(-1.0); ui.global::() .set_charge_control_enabled(false); + ui.global::().set_battery_health(-1); + ui.global::() + .set_battery_power_consumption(-1.0); + ui.global::() + .set_battery_status("Unknown".into()); + ui.global::() + .set_battery_time_estimate("".into()); ui.global::().set_platform_profile(-1); ui.global::().set_panel_overdrive(-1); ui.global::().set_boot_sound(-1); @@ -69,6 +76,195 @@ pub fn setup_system_page(ui: &MainWindow, _config: Arc>) { .set_charge_control_enabled(true); } } + + let backend = detect_display_backend(); + let handle_copy = ui.as_weak(); + ui.global::() + .on_cb_refresh_rate_changed(move |idx| { + if let Some((name, _, modes)) = get_display_info(backend) { + if let Some(mode) = modes.get(idx as usize) { + set_display_mode(backend, &name, &mode.0); + if let Some(handle) = handle_copy.upgrade() { + handle + .global::() + .set_refresh_rate_active_idx(idx); + } + } + } + }); + + let handle = ui.as_weak(); + let config_clone = config.clone(); + tokio::spawn(async move { + let mut prev_ticks = read_cpu_ticks(); + let mut prev_online = None; + loop { + let power = rog_platform::power::AsusPower::new().ok(); + let (has_bat, health, consumption, status, estimate_str) = if let Some(ref p) = power { + if p.has_battery() { + let health = p.get_battery_health().unwrap_or(0) as i32; + let consumption = p.get_battery_power_consumption().unwrap_or(-1.0); + let status = p + .get_battery_status() + .unwrap_or_else(|_| "Unknown".to_string()); + let estimate = p.get_battery_time_estimate().ok().flatten(); + let est_str = if let Some((_, h, m)) = estimate { + if h > 0 { + format!("{}h {}m", h, m) + } else { + format!("{}m", m) + } + } else { + "".to_string() + }; + (true, health, consumption, status, est_str) + } else { + (false, -1, -1.0, "Unknown".to_string(), "".to_string()) + } + } else { + (false, -1, -1.0, "Unknown".to_string(), "".to_string()) + }; + + let cpu_temp = get_cpu_temp(); + let gpu_temp = get_gpu_temp(); + let (cpu_fan, gpu_fan, mid_fan) = get_fan_rpms(); + let cpu_freq = get_cpu_frequency_mhz(); + let ram_usage = get_ram_usage_pct(); + let gpu_usage = get_gpu_usage_pct(); + + let curr_ticks = read_cpu_ticks(); + let cpu_usage = if let (Some(p), Some(c)) = (&prev_ticks, &curr_ticks) { + let idle_diff = c.idle.saturating_sub(p.idle) as f32; + let total_diff = c.total.saturating_sub(p.total) as f32; + if total_diff > 0.0 { + ((1.0 - (idle_diff / total_diff)) * 100.0).clamp(0.0, 100.0) + } else { + 0.0 + } + } else { + 0.0 + }; + prev_ticks = curr_ticks; + + // Refresh rate auto-switching logic + let display_info = get_display_info(backend); + let mut refresh_rate_choices_strings = Vec::new(); + let mut active_idx = -1; + let mut output_name = String::new(); + let mut current_mode_id = String::new(); + let mut available_modes_store = Vec::new(); + if let Some((name, curr_id, modes)) = display_info { + output_name = name; + current_mode_id = curr_id; + available_modes_store = modes.clone(); + for (i, m) in modes.iter().enumerate() { + refresh_rate_choices_strings.push(format!("{:.2} Hz", m.2)); + if m.0 == current_mode_id { + active_idx = i as i32; + } + } + } + + let curr_online = power + .as_ref() + .map(|p| p.get_online().unwrap_or(1) == 1) + .unwrap_or(true); + if let Some(prev) = prev_online { + if prev != curr_online { + let auto_switch_enabled = if let Ok(lock) = config_clone.try_lock() { + lock.auto_refresh_rate + } else { + false + }; + if auto_switch_enabled && !available_modes_store.is_empty() { + let target_mode = if curr_online { + available_modes_store.last() + } else { + available_modes_store.first() + }; + if let Some(mode) = target_mode { + if mode.0 != current_mode_id { + set_display_mode(backend, &output_name, &mode.0); + current_mode_id = mode.0.clone(); + if let Some(pos) = + available_modes_store.iter().position(|x| x.0 == mode.0) + { + active_idx = pos as i32; + } + let msg = if curr_online { + format!("AC connected. Refresh rate set to {:.2} Hz.", mode.2) + } else { + format!("AC disconnected. Refresh rate set to {:.2} Hz for power saving.", mode.2) + }; + tokio::spawn(async move { + let _ = notify_rust::Notification::new() + .summary("Display Refresh Rate") + .body(&msg) + .icon("rog-control-center") + .show(); + }); + } + } + } + } + } + prev_online = Some(curr_online); + + let choices_strings = refresh_rate_choices_strings.clone(); + let success = handle.upgrade_in_event_loop(move |ui| { + let data = ui.global::(); + if has_bat { + data.set_battery_health(health); + data.set_battery_power_consumption(consumption); + data.set_battery_status(status.into()); + data.set_battery_time_estimate(estimate_str.into()); + } else { + data.set_battery_health(-1); + } + data.set_cpu_temp_val(cpu_temp); + data.set_gpu_temp_val(gpu_temp); + data.set_cpu_usage_val(cpu_usage); + data.set_gpu_usage_val(gpu_usage); + data.set_ram_usage_val(ram_usage); + data.set_cpu_freq_mhz(cpu_freq); + data.set_cpu_fan_rpm(cpu_fan); + data.set_gpu_fan_rpm(gpu_fan); + data.set_mid_fan_rpm(mid_fan); + + // Refresh rate properties + if active_idx != -1 { + if data.get_refresh_rate_active_idx() != active_idx { + data.set_refresh_rate_active_idx(active_idx); + } + let ui_choices = data.get_refresh_rate_choices(); + let mut choices_changed = ui_choices.row_count() != choices_strings.len(); + if !choices_changed { + for i in 0..ui_choices.row_count() { + if ui_choices.row_data(i) != Some(choices_strings[i].clone().into()) { + choices_changed = true; + break; + } + } + } + if choices_changed { + let model = slint::VecModel::default(); + for choice in &choices_strings { + model.push(choice.clone().into()); + } + data.set_refresh_rate_choices(slint::ModelRc::new(model)); + } + } else { + data.set_refresh_rate_active_idx(-1); + } + }); + + if success.is_err() { + break; + } + + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + } + }); } macro_rules! convert_value { @@ -746,3 +942,352 @@ pub fn setup_system_page_callbacks(ui: &MainWindow, _states: Arc>) .ok(); }); } + +fn get_cpu_temp() -> f32 { + if let Ok(entries) = std::fs::read_dir("/sys/class/hwmon") { + for entry in entries.flatten() { + let path = entry.path(); + if let Ok(name) = std::fs::read_to_string(path.join("name")) { + let name = name.trim(); + if name == "k10temp" || name == "coretemp" || name == "zenpower" { + if let Ok(temp_str) = std::fs::read_to_string(path.join("temp1_input")) { + if let Ok(temp_val) = temp_str.trim().parse::() { + return temp_val / 1000.0; + } + } + } + } + } + } + if let Ok(temp_str) = std::fs::read_to_string("/sys/class/thermal/thermal_zone0/temp") { + if let Ok(temp_val) = temp_str.trim().parse::() { + return temp_val / 1000.0; + } + } + 0.0 +} + +fn get_gpu_temp() -> f32 { + if let Ok(entries) = std::fs::read_dir("/sys/class/hwmon") { + for entry in entries.flatten() { + let path = entry.path(); + if let Ok(name) = std::fs::read_to_string(path.join("name")) { + let name = name.trim(); + if name == "amdgpu" || name == "nouveau" || name == "nvidia" { + if let Ok(temp_str) = std::fs::read_to_string(path.join("temp1_input")) { + if let Ok(temp_val) = temp_str.trim().parse::() { + return temp_val / 1000.0; + } + } + } + } + } + } + 0.0 +} + +fn get_fan_rpms() -> (i32, i32, i32) { + let mut cpu = 0; + let mut gpu = 0; + let mut mid = 0; + if let Ok(entries) = std::fs::read_dir("/sys/class/hwmon") { + for entry in entries.flatten() { + let path = entry.path(); + if let Ok(name) = std::fs::read_to_string(path.join("name")) { + if name.trim() == "asus" { + if let Ok(v) = std::fs::read_to_string(path.join("fan1_input")) { + cpu = v.trim().parse().unwrap_or(0); + } + if let Ok(v) = std::fs::read_to_string(path.join("fan2_input")) { + gpu = v.trim().parse().unwrap_or(0); + } + if let Ok(v) = std::fs::read_to_string(path.join("fan3_input")) { + mid = v.trim().parse().unwrap_or(0); + } + break; + } + } + } + } + (cpu, gpu, mid) +} + +fn get_cpu_frequency_mhz() -> f32 { + let mut total_freq = 0.0; + let mut count = 0; + if let Ok(entries) = std::fs::read_dir("/sys/devices/system/cpu") { + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().into_owned(); + if name.starts_with("cpu") && name[3..].chars().all(|c| c.is_ascii_digit()) { + let freq_path = entry.path().join("cpufreq/scaling_cur_freq"); + if let Ok(freq_str) = std::fs::read_to_string(freq_path) { + if let Ok(freq_khz) = freq_str.trim().parse::() { + total_freq += freq_khz / 1000.0; + count += 1; + } + } + } + } + } + if count == 0 { + if let Ok(cpuinfo) = std::fs::read_to_string("/proc/cpuinfo") { + for line in cpuinfo.lines() { + if line.starts_with("cpu MHz") { + if let Some(pos) = line.find(':') { + if let Ok(val) = line[pos + 1..].trim().parse::() { + total_freq += val; + count += 1; + } + } + } + } + } + } + if count > 0 { + total_freq / count as f32 + } else { + 0.0 + } +} + +fn get_ram_usage_pct() -> f32 { + if let Ok(meminfo) = std::fs::read_to_string("/proc/meminfo") { + let mut total = 0.0; + let mut available = 0.0; + for line in meminfo.lines() { + if line.starts_with("MemTotal:") { + let parts: Vec<&str> = line.split_whitespace().collect(); + if let Some(val) = parts.get(1) { + total = val.parse::().unwrap_or(0.0); + } + } else if line.starts_with("MemAvailable:") { + let parts: Vec<&str> = line.split_whitespace().collect(); + if let Some(val) = parts.get(1) { + available = val.parse::().unwrap_or(0.0); + } + } + } + if total > 0.0 { + return ((total - available) / total) * 100.0; + } + } + 0.0 +} + +struct CpuTicks { + idle: u64, + total: u64, +} + +fn read_cpu_ticks() -> Option { + let stat = std::fs::read_to_string("/proc/stat").ok()?; + let first_line = stat.lines().next()?; + if first_line.starts_with("cpu ") { + let parts: Vec<&str> = first_line.split_whitespace().collect(); + let mut total = 0u64; + let mut idle = 0u64; + for (i, part) in parts.iter().skip(1).enumerate() { + if let Ok(ticks) = part.parse::() { + total += ticks; + if i == 3 || i == 4 { + idle += ticks; + } + } + } + return Some(CpuTicks { idle, total }); + } + None +} + +fn get_gpu_usage_pct() -> f32 { + if let Ok(entries) = std::fs::read_dir("/sys/class/drm") { + for entry in entries.flatten() { + let path = entry.path().join("device/gpu_busy_percent"); + if path.exists() { + if let Ok(val_str) = std::fs::read_to_string(path) { + if let Ok(val) = val_str.trim().parse::() { + return val; + } + } + } + } + } + 0.0 +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum WaylandDisplayBackend { + KScreenDoctor, + WlrRandr, + None, +} + +fn detect_display_backend() -> WaylandDisplayBackend { + if std::process::Command::new("kscreen-doctor") + .arg("--help") + .output() + .is_ok() + { + WaylandDisplayBackend::KScreenDoctor + } else if std::process::Command::new("wlr-randr") + .arg("--help") + .output() + .is_ok() + { + WaylandDisplayBackend::WlrRandr + } else { + WaylandDisplayBackend::None + } +} + +fn get_display_info( + backend: WaylandDisplayBackend, +) -> Option<(String, String, Vec<(String, String, f64)>)> { + match backend { + WaylandDisplayBackend::KScreenDoctor => get_kscreen_info(), + WaylandDisplayBackend::WlrRandr => get_wlr_info(), + WaylandDisplayBackend::None => None, + } +} + +fn set_display_mode(backend: WaylandDisplayBackend, output_name: &str, mode_id: &str) { + match backend { + WaylandDisplayBackend::KScreenDoctor => set_kscreen_mode(output_name, mode_id), + WaylandDisplayBackend::WlrRandr => set_wlr_mode(output_name, mode_id), + WaylandDisplayBackend::None => {} + } +} + +#[derive(serde::Deserialize, Debug)] +struct KScreenOutput { + name: String, + #[serde(rename = "currentModeId")] + current_mode_id: String, + modes: Vec, + #[serde(rename = "type")] + output_type: i32, +} + +#[derive(serde::Deserialize, Debug)] +struct KScreenMode { + id: String, + name: String, + #[serde(rename = "refreshRate")] + refresh_rate: f64, +} + +#[derive(serde::Deserialize, Debug)] +struct KScreenConfig { + outputs: Vec, +} + +fn get_kscreen_info() -> Option<(String, String, Vec<(String, String, f64)>)> { + let output = std::process::Command::new("kscreen-doctor") + .arg("-j") + .output() + .ok()?; + if !output.status.success() { + return None; + } + let config: KScreenConfig = serde_json::from_slice(&output.stdout).ok()?; + let panel = config + .outputs + .iter() + .find(|o| o.output_type == 7 || o.name.starts_with("eDP"))?; + let current_mode = panel.modes.iter().find(|m| m.id == panel.current_mode_id)?; + let current_res = current_mode.name.split('@').next()?.trim(); + + let mut available_rates = Vec::new(); + for m in &panel.modes { + if let Some(res) = m.name.split('@').next() { + if res.trim() == current_res { + available_rates.push((m.id.clone(), m.name.clone(), m.refresh_rate)); + } + } + } + available_rates.sort_by(|a, b| a.2.partial_cmp(&b.2).unwrap_or(std::cmp::Ordering::Equal)); + + Some((panel.name.clone(), current_mode.id.clone(), available_rates)) +} + +fn set_kscreen_mode(output_name: &str, mode_id: &str) { + let arg = format!("output.{}.mode.{}", output_name, mode_id); + let _ = std::process::Command::new("kscreen-doctor") + .arg(arg) + .output(); +} + +fn get_wlr_info() -> Option<(String, String, Vec<(String, String, f64)>)> { + let output = std::process::Command::new("wlr-randr").output().ok()?; + if !output.status.success() { + return None; + } + let stdout = String::from_utf8_lossy(&output.stdout); + + let mut current_output = String::new(); + let mut is_edp = false; + let mut current_mode_str = String::new(); + let mut modes = Vec::new(); + + for line in stdout.lines() { + let trimmed = line.trim(); + if line.starts_with(' ') || line.starts_with('\t') { + if is_edp { + if trimmed.starts_with("Modes:") { + continue; + } + if trimmed.contains("px,") && trimmed.contains("Hz") { + let parts: Vec<&str> = trimmed.split(',').collect(); + if parts.len() >= 2 { + let res = parts[0].replace("px", "").trim().to_string(); + let hz_part = parts[1].trim(); + + let is_current = hz_part.contains("(current)"); + let clean_hz_part = hz_part + .replace("(current)", "") + .replace("(preferred)", "") + .replace("Hz", ""); + let hz_str = clean_hz_part.trim(); + if let Ok(hz) = hz_str.parse::() { + let mode_id = format!("{}@{}", res, hz_str); + let mode_name = format!("{} px, {} Hz", res, hz_str); + if is_current { + current_mode_str = mode_id.clone(); + } + modes.push((mode_id, mode_name, hz)); + } + } + } + } + } else { + let parts: Vec<&str> = trimmed.split_whitespace().collect(); + if let Some(name) = parts.first() { + if name.starts_with("eDP") { + current_output = name.to_string(); + is_edp = true; + } else { + is_edp = false; + } + } + } + } + + if !current_output.is_empty() && !modes.is_empty() { + modes.sort_by(|a, b| a.2.partial_cmp(&b.2).unwrap_or(std::cmp::Ordering::Equal)); + if current_mode_str.is_empty() { + current_mode_str = modes.first()?.0.clone(); + } + Some((current_output, current_mode_str, modes)) + } else { + None + } +} + +fn set_wlr_mode(output_name: &str, mode_id: &str) { + let _ = std::process::Command::new("wlr-randr") + .arg("--output") + .arg(output_name) + .arg("--mode") + .arg(mode_id) + .output(); +} diff --git a/rog-control-center/ui/main_window.slint b/rog-control-center/ui/main_window.slint index d4e42aa8..d7d42499 100644 --- a/rog-control-center/ui/main_window.slint +++ b/rog-control-center/ui/main_window.slint @@ -9,6 +9,7 @@ import { PageSlash, SlashPageData } from "pages/slash.slint"; import { RogItem } from "widgets/common.slint"; import { PageAura } from "pages/aura.slint"; import { PageGPU, GPUPageData } from "pages/gpu.slint"; +import { PageBattery } from "pages/battery.slint"; import { Node } from "widgets/graph.slint"; export { Node } import { FanPageData, FanType, Profile } from "types/fan_types.slint"; @@ -33,6 +34,7 @@ export component MainWindow inherits Window { true, true, true, // GPU Configuration + true, // Battery Info true, // App Settings true, // About ]; @@ -81,6 +83,7 @@ export component MainWindow inherits Window { @tr("Menu4" => "Slash Lighting"), @tr("Menu5" => "Fan Curves"), @tr("Menu6" => "GPU Configuration"), + @tr("Menu9" => "Battery Info"), @tr("Menu7" => "App Settings"), @tr("Menu8" => "About"), ]; @@ -135,18 +138,24 @@ export component MainWindow inherits Window { visible: side-bar.current-item == 5; } - if(side-bar.current-item == 6): PageAppSettings { + if(side-bar.current-item == 6): PageBattery { width: root.width - side-bar.width; height: root.height + 12px; visible: side-bar.current-item == 6; } - if(side-bar.current-item == 7): PageAbout { + if(side-bar.current-item == 7): PageAppSettings { width: root.width - side-bar.width; height: root.height + 12px; visible: side-bar.current-item == 7; } + if(side-bar.current-item == 8): PageAbout { + width: root.width - side-bar.width; + height: root.height + 12px; + visible: side-bar.current-item == 8; + } + if toast: Rectangle { x: 0px; y: root.height - self.height; diff --git a/rog-control-center/ui/pages/app_settings.slint b/rog-control-center/ui/pages/app_settings.slint index 95919868..937b6b69 100644 --- a/rog-control-center/ui/pages/app_settings.slint +++ b/rog-control-center/ui/pages/app_settings.slint @@ -10,6 +10,8 @@ export global AppSettingsPageData { callback set_enable_tray_icon(bool); in-out property enable_dgpu_notifications; callback set_enable_dgpu_notifications(bool); + in-out property auto_refresh_rate; + callback set_auto_refresh_rate(bool); } export component PageAppSettings inherits VerticalLayout { @@ -51,6 +53,13 @@ export component PageAppSettings inherits VerticalLayout { } } + SystemToggle { + text: @tr("Auto-switch refresh rate on battery"); + checked <=> AppSettingsPageData.auto_refresh_rate; + toggled => { + AppSettingsPageData.set_auto_refresh_rate(AppSettingsPageData.auto_refresh_rate) + } + } Text { color: Palette.accent-background; text: " WIP: some features like notifications are not complete"; diff --git a/rog-control-center/ui/pages/battery.slint b/rog-control-center/ui/pages/battery.slint new file mode 100644 index 00000000..0a8d4a76 --- /dev/null +++ b/rog-control-center/ui/pages/battery.slint @@ -0,0 +1,106 @@ +import { SystemSlider, RogItem } from "../widgets/common.slint"; +import { Palette, VerticalBox, ScrollView } from "std-widgets.slint"; +import { SystemPageData } from "system.slint"; + +export component PageBattery inherits Rectangle { + clip: true; + + ScrollView { + VerticalLayout { + padding: 12px; + spacing: 8px; + alignment: LayoutAlignment.start; + + Rectangle { + background: Palette.alternate-background; + border-color: Palette.border; + border-width: 1px; + border-radius: 2px; + height: 36px; + Text { + font-size: 16px; + color: Palette.control-foreground; + horizontal-alignment: TextHorizontalAlignment.center; + text: @tr("Battery Information"); + } + } + + if SystemPageData.charge_control_end_threshold != -1: SystemSlider { + text: @tr("Charge limit"); + minimum: 20; + maximum: 100; + has_reset: false; + enabled <=> SystemPageData.charge_control_enabled; + value: SystemPageData.charge_control_end_threshold; + released => { + SystemPageData.charge_control_end_threshold = self.value; + SystemPageData.cb_charge_control_end_threshold(Math.round(SystemPageData.charge_control_end_threshold)) + } + } + + if SystemPageData.battery_health != -1: Rectangle { + background: Palette.alternate-background; + border-color: Palette.border; + border-width: 1px; + border-radius: 4px; + + VerticalLayout { + padding: 12px; + spacing: 8px; + + HorizontalLayout { + alignment: LayoutAlignment.space-between; + Text { + text: @tr("Battery Health (Capacity):"); + color: Palette.control-foreground; + } + Text { + text: SystemPageData.battery_health + "%"; + font-weight: 700; + color: SystemPageData.battery_health > 80 ? #22c55e : (SystemPageData.battery_health > 50 ? #eab308 : #ef4444); + } + } + + HorizontalLayout { + alignment: LayoutAlignment.space-between; + Text { + text: @tr("Status:"); + color: Palette.control-foreground; + } + Text { + text: SystemPageData.battery_status; + font-weight: 700; + color: Palette.control-foreground; + } + } + + if SystemPageData.battery_power_consumption != -1.0: HorizontalLayout { + alignment: LayoutAlignment.space-between; + Text { + text: @tr("Power Draw:"); + color: Palette.control-foreground; + } + Text { + text: Math.round(SystemPageData.battery_power_consumption * 10) / 10 + " W"; + font-weight: 700; + color: SystemPageData.battery_status == "Discharging" ? #ef4444 : #22c55e; + } + } + + if SystemPageData.battery_time_estimate != "": HorizontalLayout { + alignment: LayoutAlignment.space-between; + Text { + text: SystemPageData.battery_status == "Charging" ? @tr("Time to Full:") : @tr("Time Remaining:"); + color: Palette.control-foreground; + } + Text { + text: SystemPageData.battery_time_estimate; + font-weight: 700; + color: Palette.control-foreground; + } + } + } + } + } + } +} diff --git a/rog-control-center/ui/pages/system.slint b/rog-control-center/ui/pages/system.slint index 48f1edf0..d62ae983 100644 --- a/rog-control-center/ui/pages/system.slint +++ b/rog-control-center/ui/pages/system.slint @@ -152,6 +152,24 @@ export global SystemPageData { in-out property ppt_enabled_available; in-out property ppt_enabled; callback cb_ppt_enabled(bool); + in-out property battery_health: -1; + + in-out property battery_power_consumption: -1.0; + in-out property battery_status: "Unknown"; + in-out property battery_time_estimate: ""; + in-out property cpu_temp_val: -1.0; + in-out property gpu_temp_val: -1.0; + in-out property cpu_usage_val: -1.0; + in-out property gpu_usage_val: -1.0; + in-out property ram_usage_val: -1.0; + in-out property cpu_freq_mhz: -1.0; + in-out property cpu_fan_rpm: -1; + in-out property gpu_fan_rpm: -1; + in-out property mid_fan_rpm: -1; + in-out property refresh_rate_current: ""; + in-out property <[string]> refresh_rate_choices: []; + in-out property refresh_rate_active_idx: -1; + callback cb_refresh_rate_changed(int); } export component PageSystem inherits Rectangle { @@ -193,16 +211,235 @@ export component PageSystem inherits Rectangle { } } - if SystemPageData.charge_control_end_threshold != -1: SystemSlider { - text: @tr("Charge limit"); - minimum: 20; - maximum: 100; - has_reset: false; - enabled <=> SystemPageData.charge_control_enabled; - value: SystemPageData.charge_control_end_threshold; - released => { - SystemPageData.charge_control_end_threshold = self.value; - SystemPageData.cb_charge_control_end_threshold(Math.round(SystemPageData.charge_control_end_threshold)) + if SystemPageData.refresh_rate_active_idx != -1: Rectangle { + background: Palette.alternate-background; + border-color: Palette.border; + border-width: 1px; + border-radius: 4px; + + VerticalLayout { + padding: 12px; + spacing: 8px; + + HorizontalLayout { + alignment: LayoutAlignment.start; + spacing: 8px; + Text { + font-size: 14px; + font-weight: 700; + color: Palette.accent-background; + text: @tr("Display Settings"); + } + } + + HorizontalLayout { + alignment: LayoutAlignment.space-between; + spacing: 12px; + + Text { + text: @tr("Refresh Rate:"); + color: Palette.control-foreground; + vertical-alignment: TextVerticalAlignment.center; + } + + SystemDropdown { + combo_min_width: 150px; + current_index <=> SystemPageData.refresh_rate_active_idx; + current_value: SystemPageData.refresh_rate_choices[SystemPageData.refresh_rate_active_idx]; + model <=> SystemPageData.refresh_rate_choices; + selected => { + SystemPageData.cb_refresh_rate_changed(SystemPageData.refresh_rate_active_idx) + } + } + } + } + } + + if SystemPageData.cpu_usage_val != -1.0: Rectangle { + background: Palette.alternate-background; + border-color: Palette.border; + border-width: 1px; + border-radius: 4px; + + VerticalLayout { + padding: 12px; + spacing: 12px; + + HorizontalLayout { + alignment: LayoutAlignment.start; + spacing: 8px; + Text { + font-size: 14px; + font-weight: 700; + color: Palette.accent-background; + text: @tr("Hardware Monitor"); + } + } + + HorizontalLayout { + spacing: 16px; + + // Left Column: CPU & Memory + VerticalLayout { + width: 50%; + spacing: 8px; + + Text { + text: @tr("CPU Status"); + font-weight: 700; + color: Palette.control-foreground; + font-size: 12px; + } + + HorizontalLayout { + alignment: LayoutAlignment.space-between; + Text { + text: @tr("Temperature:"); + color: Palette.control-foreground; + } + Text { + text: Math.round(SystemPageData.cpu_temp_val) + " °C"; + font-weight: 700; + color: SystemPageData.cpu_temp_val > 80 ? #ef4444 : (SystemPageData.cpu_temp_val > 65 ? #eab308 : #22c55e); + } + } + + HorizontalLayout { + alignment: LayoutAlignment.space-between; + Text { + text: @tr("Usage:"); + color: Palette.control-foreground; + } + Text { + text: Math.round(SystemPageData.cpu_usage_val) + "%"; + font-weight: 700; + color: Palette.control-foreground; + } + } + + HorizontalLayout { + alignment: LayoutAlignment.space-between; + Text { + text: @tr("Frequency:"); + color: Palette.control-foreground; + } + Text { + text: Math.round(SystemPageData.cpu_freq_mhz) + " MHz"; + font-weight: 700; + color: Palette.control-foreground; + } + } + + Rectangle { height: 4px; } // divider spacer + + Text { + text: @tr("Memory Status"); + font-weight: 700; + color: Palette.control-foreground; + font-size: 12px; + } + + HorizontalLayout { + alignment: LayoutAlignment.space-between; + Text { + text: @tr("RAM Usage:"); + color: Palette.control-foreground; + } + Text { + text: Math.round(SystemPageData.ram_usage_val) + "%"; + font-weight: 700; + color: Palette.control-foreground; + } + } + } + + // Right Column: GPU & Fans + VerticalLayout { + width: 50%; + spacing: 8px; + + Text { + text: @tr("GPU Status"); + font-weight: 700; + color: Palette.control-foreground; + font-size: 12px; + } + + HorizontalLayout { + alignment: LayoutAlignment.space-between; + Text { + text: @tr("Temperature:"); + color: Palette.control-foreground; + } + Text { + text: SystemPageData.gpu_temp_val > 0.0 ? (Math.round(SystemPageData.gpu_temp_val) + " °C") : @tr("N/A"); + font-weight: 700; + color: SystemPageData.gpu_temp_val > 80 ? #ef4444 : (SystemPageData.gpu_temp_val > 65 ? #eab308 : (SystemPageData.gpu_temp_val > 0.0 ? #22c55e : Palette.control-foreground)); + } + } + + HorizontalLayout { + alignment: LayoutAlignment.space-between; + Text { + text: @tr("Usage:"); + color: Palette.control-foreground; + } + Text { + text: SystemPageData.gpu_usage_val >= 0.0 ? (Math.round(SystemPageData.gpu_usage_val) + "%") : @tr("N/A"); + font-weight: 700; + color: Palette.control-foreground; + } + } + + Rectangle { height: 4px; } // divider spacer + + Text { + text: @tr("Fan Speeds"); + font-weight: 700; + color: Palette.control-foreground; + font-size: 12px; + } + + HorizontalLayout { + alignment: LayoutAlignment.space-between; + Text { + text: @tr("CPU Fan:"); + color: Palette.control-foreground; + } + Text { + text: SystemPageData.cpu_fan_rpm + " RPM"; + font-weight: 700; + color: Palette.control-foreground; + } + } + + HorizontalLayout { + alignment: LayoutAlignment.space-between; + Text { + text: @tr("GPU Fan:"); + color: Palette.control-foreground; + } + Text { + text: SystemPageData.gpu_fan_rpm + " RPM"; + font-weight: 700; + color: Palette.control-foreground; + } + } + + if SystemPageData.mid_fan_rpm > 0: HorizontalLayout { + alignment: LayoutAlignment.space-between; + Text { + text: @tr("Mid Fan:"); + color: Palette.control-foreground; + } + Text { + text: SystemPageData.mid_fan_rpm + " RPM"; + font-weight: 700; + color: Palette.control-foreground; + } + } + } + } } } diff --git a/rog-platform/src/power.rs b/rog-platform/src/power.rs index 5d2d1c79..a114921b 100644 --- a/rog-platform/src/power.rs +++ b/rog-platform/src/power.rs @@ -112,4 +112,293 @@ impl AsusPower { usb, }) } + + pub fn has_battery(&self) -> bool { + !self.battery.as_os_str().is_empty() + } + + pub fn get_battery_cycle_count(&self) -> Result { + let path = self.battery.join("cycle_count"); + if !path.exists() { + return Err(PlatformError::Read( + path.to_string_lossy().into(), + std::io::Error::new(std::io::ErrorKind::NotFound, "file not found"), + )); + } + let content = std::fs::read_to_string(&path) + .map_err(|e| PlatformError::Read(path.to_string_lossy().into(), e))?; + content.trim().parse::().map_err(|e| { + PlatformError::Read( + path.to_string_lossy().into(), + std::io::Error::new(std::io::ErrorKind::InvalidData, e), + ) + }) + } + + pub fn get_battery_health(&self) -> Result { + let full = if self.battery.join("energy_full").exists() { + let content = std::fs::read_to_string(self.battery.join("energy_full")) + .map_err(|e| PlatformError::Read("energy_full".into(), e))?; + content.trim().parse::().ok() + } else if self.battery.join("charge_full").exists() { + let content = std::fs::read_to_string(self.battery.join("charge_full")) + .map_err(|e| PlatformError::Read("charge_full".into(), e))?; + content.trim().parse::().ok() + } else { + None + }; + + let design = if self.battery.join("energy_full_design").exists() { + let content = std::fs::read_to_string(self.battery.join("energy_full_design")) + .map_err(|e| PlatformError::Read("energy_full_design".into(), e))?; + content.trim().parse::().ok() + } else if self.battery.join("charge_full_design").exists() { + let content = std::fs::read_to_string(self.battery.join("charge_full_design")) + .map_err(|e| PlatformError::Read("charge_full_design".into(), e))?; + content.trim().parse::().ok() + } else { + None + }; + + match (full, design) { + (Some(f), Some(d)) if d > 0.0 => { + let health = (f / d * 100.0).round().clamp(0.0, 100.0) as u8; + Ok(health) + } + _ => Err(PlatformError::Read( + self.battery.to_string_lossy().into(), + std::io::Error::new( + std::io::ErrorKind::NotFound, + "energy/charge attributes not found", + ), + )), + } + } + + pub fn get_battery_power_consumption(&self) -> Result { + if self.battery.join("power_now").exists() { + let content = std::fs::read_to_string(self.battery.join("power_now")) + .map_err(|e| PlatformError::Read("power_now".into(), e))?; + let power = content.trim().parse::().map_err(|e| { + PlatformError::Read( + "power_now".into(), + std::io::Error::new(std::io::ErrorKind::InvalidData, e), + ) + })?; + Ok(power / 1_000_000.0) + } else if self.battery.join("current_now").exists() + && self.battery.join("voltage_now").exists() + { + let current_str = std::fs::read_to_string(self.battery.join("current_now")) + .map_err(|e| PlatformError::Read("current_now".into(), e))?; + let voltage_str = std::fs::read_to_string(self.battery.join("voltage_now")) + .map_err(|e| PlatformError::Read("voltage_now".into(), e))?; + let current = current_str.trim().parse::().map_err(|e| { + PlatformError::Read( + "current_now".into(), + std::io::Error::new(std::io::ErrorKind::InvalidData, e), + ) + })?; + let voltage = voltage_str.trim().parse::().map_err(|e| { + PlatformError::Read( + "voltage_now".into(), + std::io::Error::new(std::io::ErrorKind::InvalidData, e), + ) + })?; + Ok((current * voltage) / 1_000_000_000.0) + } else { + Err(PlatformError::Read( + self.battery.to_string_lossy().into(), + std::io::Error::new( + std::io::ErrorKind::NotFound, + "power/current/voltage attributes not found", + ), + )) + } + } + + pub fn get_battery_status(&self) -> Result { + let path = self.battery.join("status"); + if !path.exists() { + return Err(PlatformError::Read( + path.to_string_lossy().into(), + std::io::Error::new(std::io::ErrorKind::NotFound, "file not found"), + )); + } + let content = std::fs::read_to_string(&path) + .map_err(|e| PlatformError::Read(path.to_string_lossy().into(), e))?; + Ok(content.trim().to_string()) + } + + pub fn get_battery_remaining_energy_wh(&self) -> Result { + if self.battery.join("energy_now").exists() { + let content = std::fs::read_to_string(self.battery.join("energy_now")) + .map_err(|e| PlatformError::Read("energy_now".into(), e))?; + let val = content.trim().parse::().map_err(|e| { + PlatformError::Read( + "energy_now".into(), + std::io::Error::new(std::io::ErrorKind::InvalidData, e), + ) + })?; + Ok(val / 1_000_000.0) + } else if self.battery.join("charge_now").exists() + && self.battery.join("voltage_now").exists() + { + let charge_str = std::fs::read_to_string(self.battery.join("charge_now")) + .map_err(|e| PlatformError::Read("charge_now".into(), e))?; + let voltage_str = std::fs::read_to_string(self.battery.join("voltage_now")) + .map_err(|e| PlatformError::Read("voltage_now".into(), e))?; + let charge = charge_str.trim().parse::().map_err(|e| { + PlatformError::Read( + "charge_now".into(), + std::io::Error::new(std::io::ErrorKind::InvalidData, e), + ) + })?; + let voltage = voltage_str.trim().parse::().map_err(|e| { + PlatformError::Read( + "voltage_now".into(), + std::io::Error::new(std::io::ErrorKind::InvalidData, e), + ) + })?; + Ok((charge * voltage) / 1_000_000_000.0) + } else { + Err(PlatformError::Read( + self.battery.to_string_lossy().into(), + std::io::Error::new( + std::io::ErrorKind::NotFound, + "energy_now/charge_now attributes not found", + ), + )) + } + } + + pub fn get_battery_full_energy_wh(&self) -> Result { + if self.battery.join("energy_full").exists() { + let content = std::fs::read_to_string(self.battery.join("energy_full")) + .map_err(|e| PlatformError::Read("energy_full".into(), e))?; + let val = content.trim().parse::().map_err(|e| { + PlatformError::Read( + "energy_full".into(), + std::io::Error::new(std::io::ErrorKind::InvalidData, e), + ) + })?; + Ok(val / 1_000_000.0) + } else if self.battery.join("charge_full").exists() + && self.battery.join("voltage_now").exists() + { + let charge_str = std::fs::read_to_string(self.battery.join("charge_full")) + .map_err(|e| PlatformError::Read("charge_full".into(), e))?; + let voltage_str = std::fs::read_to_string(self.battery.join("voltage_now")) + .map_err(|e| PlatformError::Read("voltage_now".into(), e))?; + let charge = charge_str.trim().parse::().map_err(|e| { + PlatformError::Read( + "charge_full".into(), + std::io::Error::new(std::io::ErrorKind::InvalidData, e), + ) + })?; + let voltage = voltage_str.trim().parse::().map_err(|e| { + PlatformError::Read( + "voltage_now".into(), + std::io::Error::new(std::io::ErrorKind::InvalidData, e), + ) + })?; + Ok((charge * voltage) / 1_000_000_000.0) + } else { + Err(PlatformError::Read( + self.battery.to_string_lossy().into(), + std::io::Error::new( + std::io::ErrorKind::NotFound, + "energy_full/charge_full attributes not found", + ), + )) + } + } + + pub fn get_battery_time_estimate(&self) -> Result> { + let status = self.get_battery_status()?; + let is_charging = match status.as_str() { + "Charging" => true, + "Discharging" => false, + _ => return Ok(None), + }; + + let power_draw = self.get_battery_power_consumption().unwrap_or(0.0).abs(); + if power_draw < 0.1 { + return Ok(None); + } + + let energy_now = self.get_battery_remaining_energy_wh()?; + let energy_full = self.get_battery_full_energy_wh()?; + + let remaining_wh = if is_charging { + let limit = self.get_charge_control_end_threshold().unwrap_or(100) as f64; + let target_wh = energy_full * (limit / 100.0); + if energy_now >= target_wh { + return Ok(None); + } + target_wh - energy_now + } else { + energy_now + }; + + let hours_float = remaining_wh / power_draw as f64; + if hours_float < 0.0 || hours_float.is_nan() || hours_float.is_infinite() { + return Ok(None); + } + + let total_minutes = (hours_float * 60.0).round() as i32; + let hours = total_minutes / 60; + let minutes = total_minutes % 60; + + Ok(Some((is_charging, hours, minutes))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::path::PathBuf; + + #[test] + fn test_battery_methods() { + let temp_dir = std::env::temp_dir().join("fake_battery"); + fs::create_dir_all(&temp_dir).unwrap(); + + // Write fake files + fs::write(temp_dir.join("cycle_count"), "42\n").unwrap(); + fs::write(temp_dir.join("energy_full"), "80000000\n").unwrap(); + fs::write(temp_dir.join("energy_full_design"), "100000000\n").unwrap(); + fs::write(temp_dir.join("energy_now"), "45000000\n").unwrap(); + fs::write(temp_dir.join("power_now"), "15000000\n").unwrap(); + fs::write(temp_dir.join("status"), "Discharging\n").unwrap(); + + let power = AsusPower { + mains: PathBuf::new(), + battery: temp_dir.clone(), + usb: None, + }; + + assert!(power.has_battery()); + assert_eq!(power.get_battery_cycle_count().unwrap(), 42); + assert_eq!(power.get_battery_health().unwrap(), 80); + assert_eq!(power.get_battery_power_consumption().unwrap(), 15.0); + assert_eq!(power.get_battery_status().unwrap(), "Discharging"); + assert_eq!(power.get_battery_remaining_energy_wh().unwrap(), 45.0); + assert_eq!(power.get_battery_full_energy_wh().unwrap(), 80.0); + assert_eq!( + power.get_battery_time_estimate().unwrap(), + Some((false, 3, 0)) + ); + + // Test charging estimation + fs::write(temp_dir.join("status"), "Charging\n").unwrap(); + assert_eq!( + power.get_battery_time_estimate().unwrap(), + Some((true, 2, 20)) + ); + + // Clean up + fs::remove_dir_all(&temp_dir).ok(); + } }