xps 13 3980

This commit is contained in:
2026-02-26 13:37:04 +01:00
commit d6ac8e5931
21 changed files with 4562 additions and 0 deletions

View File

@@ -0,0 +1,51 @@
name: Build and Release
on:
push:
branches:
- main
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Build Release
run: cargo build --release
- name: Get Version
id: get_version
run: |
VERSION=$(grep '^version' Cargo.toml | head -1 | cut -d '"' -f 2)
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
- name: Prepare Binary
run: |
cp target/release/xps-thermal-bench xps-thermal-bench-linux-amd64
sha256sum xps-thermal-bench-linux-amd64 > xps-thermal-bench-linux-amd64.sha256
- name: Release
uses: softprops/action-gh-release@v2
if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/')
with:
files: |
xps-thermal-bench-linux-amd64
xps-thermal-bench-linux-amd64.sha256
tag_name: v${{ steps.get_version.outputs.VERSION }}
name: Release v${{ steps.get_version.outputs.VERSION }}
draft: false
prerelease: false
target_commitish: main
generate_release_notes: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
/target
i8kmon.*.conf
thermal_history.json
src_old
throttled.conf

2587
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

30
Cargo.toml Normal file
View File

@@ -0,0 +1,30 @@
[package]
name = "ember-tune"
version = "1.0.0"
edition = "2024"
authors = ["Nils Pukropp <nils@narl.io>"]
readme = "README.md"
[[bin]]
name = "ember-tune"
path = "src/main.rs"
[dependencies]
anyhow = "1.0.102"
thiserror = "2.0.18"
miette = { version = "7.5", features = ["fancy"] }
console = "0.15"
owo-colors = "4.2"
ratatui = "0.30.0"
crossterm = "0.29.0"
ctrlc = "3.5.2"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
clap = { version = "4.5", features = ["derive", "string", "wrap_help"] }
color-eyre = "0.6"
tracing = "0.1"
tracing-subscriber = "0.3"
tracing-appender = "0.2"
sysinfo = "0.38"
libc = "0.2"
num_cpus = "1.17"

87
README.md Normal file
View File

@@ -0,0 +1,87 @@
# xps-thermal-bench(8)
## NAME
xps-thermal-bench - Scientific, power-aware thermal optimizer for Dell XPS laptops
## SYNOPSIS
**xps-thermal-bench** [*OPTIONS*]
## DESCRIPTION
**xps-thermal-bench** is a professional diagnostic utility that automates the generation and deployment of optimized **i8kmon(1)** and **throttled** configurations. It uses scientific thermal resistance modeling and real-world throughput benchmarks to find the perfect balance between performance and acoustics for your specific hardware unit.
### Key Scientific Features:
* **Environmental Calibration**: Samples your natural ambient idle temperature at startup to anchor fan curves correctly for your specific session (TTY vs. Desktop).
* **Thermal Resistance ($R_{\theta}$) Modeling**: Calculates the exact cooling efficiency (C/W) of your heatsink to determine sustainable power limits.
* **Thermal Inertia Tracking**: Measures how fast your laptop sheds heat to dynamically tune fan hysteresis, preventing the high-pitched "RPM hunting" common in thin-and-light chassis.
* **Silicon Knee Detection**: Identifies your CPU's unique throttling point by monitoring frequency stability jitter during load.
* **Interactive Deployment**: One-key installation of generated profiles directly to the system with automatic service restarts.
## WORKFLOW
1. **System Audit**: The tool validates your kernel modules (`dell_smm`), power state (AC is required for accuracy), and interfering services.
2. **Calibration**: Captures your current environmental thermal floor.
3. **Benchmarking**: Runs a matrix of loads (25%100%) against every fan tier.
4. **Optimization**: Calculates Quiet, Balanced, and Performance profiles using the gathered telemetry.
5. **Installation**: Press **'i'** in the TUI to deploy your favorite profile and restart services immediately.
## OPTIONS
**-b, --baseline-temp** *CELSIUS*
Target temperature for the cooling phase between tests. Default: 50.
**-c, --critical-temp** *CELSIUS*
Safety ceiling at which a test is aborted. Default: 95.
**-C, --i8kmon-conf** *PATH*
Override the path to the i8kmon configuration. (Default: Auto-discovered via systemd).
**-T, --throttled-conf** *PATH*
Override the path to the throttled configuration. (Default: Auto-discovered via systemd).
**-e, --export-dir** *DIR*
Directory to write generated configurations and backups.
**-f, --history-file** *PATH*
Path to the JSON telemetry database. Default: thermal_history.json.
**-q, --quick**
Fast-track mode (skips intermediate 50% and 75% load tiers).
**-s, --stressor** *STRESSOR*
stress-ng workload type (e.g., `matrixprod`, `avx`, `cpu`, `fft`). Default: matrixprod.
**--no-tui**
Disables the interactive terminal UI for a structured stdout log.
**--skip-checks**
Bypasses the system audit. Use only if you know your hardware is compatible.
**--reprocess** [*HISTORY_JSON*]
Generates new profiles and power limits from existing data without hardware tests.
## KEYBINDS (TUI Mode)
**TAB**
Cycle between Quiet, Balanced, and Performance profile previews.
**i**
**Install** the currently selected profile to the system and restart services.
**q**
Quit and restore original system state (governors, frequencies, services).
## SAFETY & CLEANUP
The tool is designed to be "interruption-safe." Even if the program panics or is killed via `Ctrl+C`, it utilizes the Rust `Drop` trait to ensure:
* Original CPU scaling governors are restored.
* CPU frequency limits are unlocked.
* Background power management services (like `auto-cpufreq` or `tlp`) are restarted.
## SETUP
### Prerequisites
- **Kernel**: `dell_smm_hwmon` must be loaded.
- **Power**: AC adapter must be connected.
- **Dependencies**: `stress-ng`, `i8kmon`, and `throttled`.
### Installation
```bash
cargo build --release
sudo ./target/release/xps-thermal-bench
```
## SEE ALSO
**i8kmon(1)**, **throttled(8)**, **stress-ng(1)**

View File

@@ -0,0 +1,105 @@
[meta]
vendor = "Dell Inc."
family = "XPS"
model = "XPS 13 9380"
[[requirements]]
id = "msr-writes"
name = "MSR Write Access"
check_type = "Cmdline"
target = "msr.allow_writes=on"
action = "Manual"
severity = "Fatal"
message = "Throttled requires MSR write access to bypass TDP locks."
solution = "Add msr.allow_writes=on to kernel parameters."
[[requirements]]
id = "dell-smm-ignore-dmi"
name = "Dell SMM Ignore DMI"
check_type = "Cmdline"
target = "dell_smm_hwmon.ignore_dmi=1"
action = "Manual"
severity = "Fatal"
message = "Required to force load fan control driver on this model."
solution = "Add dell_smm_hwmon.ignore_dmi=1 to kernel parameters."
[[requirements]]
id = "bios-fan-ctrl"
name = "BIOS Fan Control"
check_type = "Service"
target = "dell-bios-fan-control"
invert = true
action = "AutoRestore"
severity = "Warning"
message = "BIOS overrides i8kmon, causing fan pulsing."
solution = "Tool will disable BIOS fan control (0) during run."
[[diagnostics]]
id = "gpe-storm-fix"
name = "Interrupt Storm Fix"
check_type = "Cmdline"
target = "acpi_mask_gpe=0x6E"
action = "Manual"
severity = "Warning"
message = "GPE 0x6E often storms on 9380, wasting 20% CPU."
solution = "Add acpi_mask_gpe=0x6E to kernel parameters."
[[diagnostics]]
id = "s3-sleep"
name = "S3 Deep Sleep"
check_type = "Cmdline"
target = "mem_sleep_default=deep"
action = "Manual"
severity = "Warning"
message = "Modern Standby (s2idle) fails to sleep properly on 9380."
solution = "Add mem_sleep_default=deep to force S3 sleep."
[[diagnostics]]
id = "psr-fix"
name = "Panel Self Refresh Fix"
check_type = "Cmdline"
target = "i915.enable_psr=0"
action = "Manual"
severity = "Warning"
message = "PSR causes screen freezes on 9380."
solution = "Add i915.enable_psr=0 to kernel parameters."
[[diagnostics]]
id = "color-range-fix"
name = "Full RGB Color Range"
check_type = "Cmdline"
target = "i915.color_range=2"
action = "Manual"
severity = "Warning"
message = "Ensures full 0-255 RGB range on external monitors."
solution = "Add i915.color_range=2 to kernel parameters."
[[diagnostics]]
id = "nvme-latency-fix"
name = "NVMe Latency Fix"
check_type = "Cmdline"
target = "nvme_core.default_ps_max_latency_us=5500"
action = "Manual"
severity = "Warning"
message = "Prevents SSD hangs on certain 9380 firmware."
solution = "Add nvme_core.default_ps_max_latency_us=5500 to kernel parameters."
[[diagnostics]]
id = "audio-pop-fix"
name = "Audio Pop Fix"
check_type = "Cmdline"
target = "snd_hda_intel.power_save=0"
action = "Manual"
severity = "Warning"
message = "Prevents 'popping' sound in headphones."
solution = "Add snd_hda_intel.power_save=0 to kernel parameters."
[[diagnostics]]
id = "intel-sgx"
name = "Intel SGX State"
check_type = "File"
target = "/dev/sgx"
action = "Manual"
severity = "Warning"
message = "SGX must be 'Software Controlled' for MSR access."
solution = "Set Intel SGX to 'Software Controlled' in BIOS."

60
src/cli.rs Normal file
View File

@@ -0,0 +1,60 @@
use clap::{Parser, builder::styling};
use std::path::PathBuf;
const STYLES: styling::Styles = styling::Styles::styled()
.header(styling::AnsiColor::Green.on_default().bold())
.usage(styling::AnsiColor::Green.on_default().bold())
.literal(styling::AnsiColor::Cyan.on_default().bold())
.placeholder(styling::AnsiColor::Cyan.on_default());
#[derive(Parser, Debug)]
#[command(
name = "ember-tune",
author = "Nils Pukropp <nils@narl.io>",
version = "1.0.0",
about = "ember-tune: Scientifically-driven hardware power and thermal optimizer.",
long_about = "ember-tune transforms manual laptop tuning into a rigorous, automated engineering workflow. \nIt executes a state machine to find the 'Physical Sweet Spot' of your specific hardware by measuring \nthe Silicon Knee, Thermal Resistance (Rθ), and Thermal Inertia, then outputs optimal \nconfigurations for tools like 'throttled' or 'ryzenadj'.",
styles = STYLES,
after_help = "EXAMPLES:\n sudo ember-tune run # Run standard optimization\n sudo ember-tune run --dry-run # Audit and simulate without changes\n sudo ember-tune run --mock # Safe demo with fake hardware"
)]
pub struct Cli {
/// Path to output the optimized configuration file
#[arg(
short,
long,
default_value = "throttled.conf",
help = "Destination for the generated configuration file (e.g. /etc/throttled.conf)"
)]
pub config_out: PathBuf,
/// Maximum safe temperature (Celsius) for the benchmark
#[arg(
short,
long,
default_value_t = 95.0,
help = "The emergency thermal cutoff. If any sensor hits this, the benchmark aborts instantly."
)]
pub max_temp: f32,
/// Enable verbose debug logging
#[arg(
short,
long,
help = "Writes high-resolution diagnostic logs to /tmp/ember-tune.log"
)]
pub verbose: bool,
/// Use a mock hardware layer for safe testing
#[arg(
long,
help = "Emulates hardware responses. Ideal for testing UI/Logic on unsupported systems."
)]
pub mock: bool,
/// Run pre-flight audit only
#[arg(
long,
help = "Validate system requirements and conflict management without starting the benchmark."
)]
pub audit_only: bool,
}

View File

@@ -0,0 +1 @@
pub mod throttled;

View File

@@ -0,0 +1,38 @@
pub struct ThrottledConfig {
pub pl1_limit: f32,
pub pl2_limit: f32,
pub trip_temp: f32,
}
pub struct ThrottledTranslator;
impl ThrottledTranslator {
pub fn generate_conf(config: &ThrottledConfig) -> String {
format!(
r#"[GENERAL]
# Generated by FerroTherm Optimizer
# Physical Sweet Spot found at {pl1:.1}W
[BATTERY]
Update_Interval_ms: 3000
PL1_Tdp_W: {pl1:.0}
PL1_Duration_s: 28
PL2_Tdp_W: {pl2:.0}
PL2_Duration_s: 0.002
Trip_Temp_C: {trip:.0}
[AC]
Update_Interval_ms: 1000
PL1_Tdp_W: {pl1:.0}
PL1_Duration_s: 28
PL2_Tdp_W: {pl2:.0}
PL2_Duration_s: 0.002
Trip_Temp_C: {trip:.0}
"#,
pl1 = config.pl1_limit,
pl2 = config.pl2_limit,
trip = config.trip_temp
)
}
}

115
src/engine/mod.rs Normal file
View File

@@ -0,0 +1,115 @@
use serde::{Serialize, Deserialize};
pub mod formatters;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ThermalPoint {
pub power_w: f32,
pub temp_c: f32,
pub freq_mhz: f32,
pub fan_rpm: u32,
pub throughput: f64,
}
#[derive(Debug, Default, Serialize, Deserialize, Clone)]
pub struct ThermalProfile {
pub points: Vec<ThermalPoint>,
pub ambient_temp: f32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OptimizationResult {
pub profile: ThermalProfile,
pub silicon_knee_watts: f32,
pub thermal_resistance_kw: f32,
pub recommended_pl1: f32,
pub recommended_pl2: f32,
pub max_temp_c: f32,
pub is_partial: bool,
}
pub struct OptimizerEngine {
window_size: usize,
}
impl OptimizerEngine {
pub fn new(window_size: usize) -> Self {
Self { window_size }
}
/// Applies a simple moving average (SMA) filter to a stream of values.
pub fn smooth(&self, data: &[f32]) -> Vec<f32> {
if data.is_empty() { return vec![]; }
let mut smoothed = Vec::with_capacity(data.len());
for i in 0..data.len() {
let start = if i < self.window_size { 0 } else { i - self.window_size + 1 };
let end = i + 1;
let sum: f32 = data[start..end].iter().sum();
smoothed.push(sum / (end - start) as f32);
}
smoothed
}
/// Calculates Thermal Resistance: R_theta = (T_core - T_ambient) / P_package
pub fn calculate_thermal_resistance(&self, profile: &ThermalProfile) -> f32 {
profile.points.iter()
.max_by(|a, b| a.power_w.partial_cmp(&b.power_w).unwrap_or(std::cmp::Ordering::Equal))
.map(|p| {
if p.power_w < 1.0 { 0.0 }
else { (p.temp_c - profile.ambient_temp) / p.power_w }
})
.unwrap_or(0.0)
}
pub fn get_max_temp(&self, profile: &ThermalProfile) -> f32 {
profile.points.iter()
.map(|p| p.temp_c)
.max_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
.unwrap_or(0.0)
}
/// Finds the "Silicon Knee" - the point where performance per watt plateaus
/// and thermal density spikes.
pub fn find_silicon_knee(&self, profile: &ThermalProfile) -> f32 {
if profile.points.len() < 3 {
return profile.points.last().map(|p| p.power_w).unwrap_or(15.0);
}
let mut points = profile.points.clone();
points.sort_by(|a, b| a.power_w.partial_cmp(&b.power_w).unwrap_or(std::cmp::Ordering::Equal));
let mut best_pl = points[0].power_w;
let mut max_score = f32::MIN;
for i in 1..points.len() - 1 {
let prev = &points[i - 1];
let curr = &points[i];
let next = &points[i + 1];
// 1. Performance Gradient (dMHz/dW)
let dmhz_dw_prev = (curr.freq_mhz - prev.freq_mhz) / (curr.power_w - prev.power_w).max(0.1);
let dmhz_dw_next = (next.freq_mhz - curr.freq_mhz) / (next.power_w - curr.power_w).max(0.1);
let freq_diminish = dmhz_dw_prev - dmhz_dw_next;
// 2. Thermal Gradient (d2T/dW2)
let dt_dw_prev = (curr.temp_c - prev.temp_c) / (curr.power_w - prev.power_w).max(0.1);
let dt_dw_next = (next.temp_c - curr.temp_c) / (next.power_w - curr.power_w).max(0.1);
let temp_accel = (dt_dw_next - dt_dw_prev) / (next.power_w - prev.power_w).max(0.1);
// 3. Wall Detection
let is_throttling = next.freq_mhz < curr.freq_mhz;
let penalty = if is_throttling { 2000.0 } else { 0.0 };
// Heuristic scoring: Weight thermal acceleration and diminishing frequency gains
let score = (freq_diminish * 2.0) + (temp_accel * 10.0) - penalty;
if score > max_score {
max_score = score;
best_pl = curr.power_w;
}
}
best_pl
}
}

57
src/load/mod.rs Normal file
View File

@@ -0,0 +1,57 @@
use anyhow::Result;
pub trait Workload {
/// Starts the workload with specified threads and load percentage.
fn start(&mut self, threads: usize, load_percent: usize) -> Result<()>;
/// Stops the workload.
fn stop(&mut self) -> Result<()>;
/// Returns the current throughput (e.g., ops/sec).
fn get_throughput(&self) -> Result<f64>;
}
pub struct StressNg {
child: Option<std::process::Child>,
}
impl StressNg {
pub fn new() -> Self {
Self { child: None }
}
}
impl Workload for StressNg {
fn start(&mut self, threads: usize, load_percent: usize) -> Result<()> {
self.stop()?; // Ensure any previous instance is stopped
let child = std::process::Command::new("stress-ng")
.args([
"--cpu", &threads.to_string(),
"--cpu-load", &load_percent.to_string(),
"--quiet"
])
.spawn()?;
self.child = Some(child);
Ok(())
}
fn stop(&mut self) -> Result<()> {
if let Some(mut child) = self.child.take() {
let _ = child.kill();
let _ = child.wait();
}
Ok(())
}
fn get_throughput(&self) -> Result<f64> {
// In a real implementation, we would parse stress-ng's temporary results
// or use a different workload that provides live throughput.
Ok(0.0)
}
}
impl Drop for StressNg {
fn drop(&mut self) {
let _ = self.stop();
}
}

302
src/main.rs Normal file
View File

@@ -0,0 +1,302 @@
mod mediator;
mod sal;
mod load;
mod orchestrator;
mod ui;
mod engine;
mod cli;
use miette::{Result, IntoDiagnostic, Diagnostic, Report};
use thiserror::Error;
use std::sync::mpsc;
use std::thread;
use std::time::{Duration, Instant};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::io;
use clap::Parser;
use tracing::{info, debug, error};
use crossterm::{
event::{self, Event, KeyCode},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{backend::CrosstermBackend, Terminal};
use cli::Cli;
use mediator::{TelemetryState, UiCommand, BenchmarkPhase};
use sal::traits::{PreflightAuditor, EnvironmentGuard, SensorBus, ActuatorBus, HardwareWatchdog, AuditError};
use sal::mock::{MockAuditor, MockGuard, MockSensorBus, MockActuatorBus, MockWatchdog};
use sal::dell_xps_9380::DellXps9380Sal;
use load::StressNg;
use orchestrator::BenchmarkOrchestrator;
use ui::dashboard::{draw_dashboard, DashboardState};
use engine::OptimizationResult;
use owo_colors::OwoColorize;
#[derive(Error, Diagnostic, Debug)]
#[error("Multiple pre-flight audit failures occurred.")]
struct MultiAuditError {
#[related]
errors: Vec<AuditError>,
}
fn print_summary_report(result: &OptimizationResult) {
println!();
let header = format!("{}", " 🔥 ember-tune optimization complete! ".bold().on_red().white());
println!("╭──────────────────────────────────────────────────╮");
println!("{:<48}", header);
println!("├──────────────────────────────────────────────────┤");
if result.is_partial {
println!("{}", " ⚠ WARNING: PARTIAL RESULTS (INTERRUPTED) ".yellow().bold());
println!("├──────────────────────────────────────────────────┤");
}
let knee_label = "Silicon Knee:".cyan();
println!("{:<28} {:>5.1} W / {:>3.0}°C │", knee_label, result.silicon_knee_watts, result.max_temp_c);
let res_label = "Thermal Resistance (Rθ):".cyan();
println!("{:<28} {:>8.3} K/W │", res_label, result.thermal_resistance_kw);
println!("│ │");
println!("{}", "Recommended Limits:".bold().green());
println!("│ Sustained (PL1): {:>5.1} W │", result.recommended_pl1);
println!("│ Burst (PL2): {:>5.1} W │", result.recommended_pl2);
println!("│ │");
println!("{}", "Apply to /etc/throttled.conf:".bold().magenta());
println!("│ PL1_Tdp_W: {:<5.1}", result.recommended_pl1);
println!("│ PL2_Tdp_W: {:<5.1}", result.recommended_pl2);
println!("╰──────────────────────────────────────────────────╯");
println!();
}
fn setup_logging(verbose: bool) -> tracing_appender::non_blocking::WorkerGuard {
let file_appender = tracing_appender::rolling::never("/tmp", "ember-tune.log");
let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
let level = if verbose { tracing::Level::DEBUG } else { tracing::Level::INFO };
tracing_subscriber::fmt()
.with_max_level(level)
.with_writer(non_blocking)
.with_ansi(false)
.init();
guard
}
fn main() -> Result<()> {
// 1. Diagnostics & CLI Initialization
let args = Cli::parse();
let _log_guard = setup_logging(args.verbose);
// Set panic hook to restore terminal state
std::panic::set_hook(Box::new(|panic_info| {
let _ = disable_raw_mode();
let mut stdout = io::stdout();
let _ = execute!(stdout, LeaveAlternateScreen, crossterm::cursor::Show);
eprintln!("\n\x1b[1;31mFATAL ERROR: ember-tune Panicked\x1b[0m");
eprintln!("----------------------------------------");
eprintln!("{}", panic_info);
eprintln!("----------------------------------------\n");
}));
info!("ember-tune starting with args: {:?}", args);
// 2. Pre-flight Audit (Before TUI)
let auditor: Arc<dyn PreflightAuditor> = if args.mock {
Arc::new(MockAuditor)
} else {
match DellXps9380Sal::init() {
Ok(sal) => Arc::new(sal),
Err(e) => return Err(miette::miette!("Failed to initialize Dell SAL: {}", e)),
}
};
println!("{}", console::style("─── Pre-flight System Audit ───").bold().cyan());
let mut audit_failures = Vec::new();
for step in auditor.audit() {
print!(" Checking {:<40} ", step.description);
io::Write::flush(&mut io::stdout()).into_diagnostic()?;
match step.outcome {
Ok(_) => {
println!("{}", console::style("[✓]").green());
}
Err(e) => {
println!("{}", console::style("[✗]").red());
audit_failures.push(e);
}
}
}
if !audit_failures.is_empty() {
println!();
return Err(Report::new(MultiAuditError { errors: audit_failures }));
}
println!("{}", console::style("✓ All pre-flight audits passed.").green().bold());
thread::sleep(Duration::from_secs(1));
if args.audit_only {
return Ok(());
}
// 3. Terminal Setup
enable_raw_mode().into_diagnostic()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen).into_diagnostic()?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend).into_diagnostic()?;
// 4. State & Communication Setup
let running = Arc::new(AtomicBool::new(true));
let r = running.clone();
let (telemetry_tx, telemetry_rx) = mpsc::channel::<TelemetryState>();
let (command_tx, command_rx) = mpsc::channel::<UiCommand>();
ctrlc::set_handler(move || {
r.store(false, Ordering::SeqCst);
}).expect("Error setting Ctrl-C handler");
// 5. Spawn Backend Orchestrator
let is_mock = args.mock;
let b_auditor = auditor.clone();
let backend_handle = thread::spawn(move || {
let (guard, sensors, actuators, watchdog): (
Box<dyn EnvironmentGuard>,
Box<dyn SensorBus>,
Box<dyn ActuatorBus>,
Box<dyn HardwareWatchdog>,
) = if is_mock {
(
Box::new(MockGuard::new()),
Box::new(MockSensorBus),
Box::new(MockActuatorBus),
Box::new(MockWatchdog),
)
} else {
// Re-init or share the SAL
let sal = Arc::new(DellXps9380Sal::init().expect("Failed to init Dell SAL in backend"));
(
Box::new(sal::dell_xps_9380::DellXps9380Guard::new()),
Box::new(sal.clone() as Arc<dyn SensorBus>),
Box::new(sal.clone() as Arc<dyn ActuatorBus>),
Box::new(sal as Arc<dyn HardwareWatchdog>),
)
};
let workload = Box::new(StressNg::new());
let mut orchestrator = BenchmarkOrchestrator::new(
Box::new(b_auditor),
guard,
sensors,
actuators,
watchdog,
workload,
telemetry_tx,
command_rx,
);
orchestrator.run()
});
// 6. Frontend Event Loop
let mut ui_state = DashboardState::new();
let mut last_telemetry = TelemetryState {
cpu_model: "Loading...".to_string(),
total_ram_gb: 0,
tick: 0,
cpu_temp: 0.0,
power_w: 0.0,
current_freq: 0.0,
fan_rpm: 0,
governor: "detecting".to_string(),
pl1_limit: 0.0,
pl2_limit: 0.0,
fan_tier: "auto".to_string(),
phase: BenchmarkPhase::Auditing,
history_watts: Vec::new(),
history_temp: Vec::new(),
history_mhz: Vec::new(),
log_event: None,
metadata: std::collections::HashMap::new(),
};
let tick_rate = Duration::from_millis(100);
let mut last_tick = Instant::now();
while running.load(Ordering::SeqCst) {
terminal.draw(|f| {
draw_dashboard(f, f.area(), &last_telemetry, &ui_state);
}).into_diagnostic()?;
let timeout = tick_rate
.checked_sub(last_tick.elapsed())
.unwrap_or(Duration::from_secs(0));
if event::poll(timeout).into_diagnostic()? {
if let Event::Key(key) = event::read().into_diagnostic()? {
match key.code {
KeyCode::Char('q') | KeyCode::Esc => {
let _ = command_tx.send(UiCommand::Abort);
running.store(false, Ordering::SeqCst);
}
_ => {}
}
}
}
while let Ok(new_state) = telemetry_rx.try_recv() {
if let Some(log) = &new_state.log_event {
ui_state.logs.push(log.clone());
debug!("Backend Log: {}", log);
} else {
ui_state.update(&new_state);
last_telemetry = new_state;
}
}
if last_tick.elapsed() >= tick_rate {
last_tick = Instant::now();
}
if backend_handle.is_finished() {
thread::sleep(Duration::from_secs(1));
break;
}
}
// 7. Terminal Restoration
disable_raw_mode().into_diagnostic()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen).into_diagnostic()?;
terminal.show_cursor().into_diagnostic()?;
// 8. Final Report (Post-TUI)
match backend_handle.join() {
Ok(Ok(result)) => {
print_summary_report(&result);
}
Ok(Err(e)) => {
if e.to_string() == "ABORTED" {
println!("{}", "Benchmark aborted by user. No summary available.".yellow());
} else {
error!("Orchestrator encountered error: {}", e);
eprintln!("{} {}", "Error:".red().bold(), e);
}
}
Err(_) => {
error!("Backend thread panicked!");
}
}
info!("ember-tune exited gracefully.");
Ok(())
}

50
src/mediator.rs Normal file
View File

@@ -0,0 +1,50 @@
use serde::{Serialize, Deserialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum BenchmarkPhase {
Auditing,
IdleCalibration,
StressTesting,
PhysicalModeling,
Finalizing,
}
impl Default for BenchmarkPhase {
fn default() -> Self {
Self::Auditing
}
}
#[derive(Debug, Clone)]
pub struct TelemetryState {
// --- Static Info ---
pub cpu_model: String,
pub total_ram_gb: u64,
// --- Dynamic States ---
pub tick: u64,
pub phase: BenchmarkPhase,
pub governor: String,
pub pl1_limit: f32,
pub pl2_limit: f32,
pub fan_tier: String,
// --- Instantaneous Metrics ---
pub cpu_temp: f32,
pub power_w: f32,
pub current_freq: f32,
pub fan_rpm: u32,
// --- High-res History (Last 60s @ 500ms = 120 points) ---
pub history_watts: Vec<f32>,
pub history_temp: Vec<f32>,
pub history_mhz: Vec<f32>,
pub log_event: Option<String>,
pub metadata: std::collections::HashMap<String, String>,
}
#[derive(Debug, Clone)]
pub enum UiCommand {
Abort,
}

288
src/orchestrator/mod.rs Normal file
View File

@@ -0,0 +1,288 @@
use anyhow::{Result, Context};
use std::sync::mpsc;
use std::time::{Duration, Instant};
use std::thread;
use std::collections::VecDeque;
use sysinfo::System;
use crate::sal::traits::{PreflightAuditor, EnvironmentGuard, SensorBus, ActuatorBus, HardwareWatchdog};
use crate::load::Workload;
use crate::mediator::{TelemetryState, UiCommand, BenchmarkPhase};
use crate::engine::{OptimizerEngine, ThermalProfile, ThermalPoint, OptimizationResult};
pub struct BenchmarkOrchestrator {
auditor: Box<dyn PreflightAuditor>,
guard: Box<dyn EnvironmentGuard>,
sensors: Box<dyn SensorBus>,
actuators: Box<dyn ActuatorBus>,
watchdog: Box<dyn HardwareWatchdog>,
workload: Box<dyn Workload>,
telemetry_tx: mpsc::Sender<TelemetryState>,
command_rx: mpsc::Receiver<UiCommand>,
phase: BenchmarkPhase,
profile: ThermalProfile,
engine: OptimizerEngine,
// --- History Buffers (120 points for 60s @ 500ms) ---
history_watts: VecDeque<f32>,
history_temp: VecDeque<f32>,
history_mhz: VecDeque<f32>,
// --- Static Info ---
cpu_model: String,
total_ram_gb: u64,
}
impl BenchmarkOrchestrator {
pub fn new(
auditor: Box<dyn PreflightAuditor>,
guard: Box<dyn EnvironmentGuard>,
sensors: Box<dyn SensorBus>,
actuators: Box<dyn ActuatorBus>,
watchdog: Box<dyn HardwareWatchdog>,
workload: Box<dyn Workload>,
telemetry_tx: mpsc::Sender<TelemetryState>,
command_rx: mpsc::Receiver<UiCommand>,
) -> Self {
let mut sys = System::new_all();
sys.refresh_all();
let cpu_model = sys.cpus().first()
.map(|c| c.brand().to_string())
.unwrap_or_else(|| "Unknown CPU".to_string());
let total_ram_gb = sys.total_memory() / 1024 / 1024 / 1024;
Self {
auditor,
guard,
sensors,
actuators,
watchdog,
workload,
telemetry_tx,
command_rx,
phase: BenchmarkPhase::Auditing,
profile: ThermalProfile::default(),
engine: OptimizerEngine::new(5),
history_watts: VecDeque::with_capacity(120),
history_temp: VecDeque::with_capacity(120),
history_mhz: VecDeque::with_capacity(120),
cpu_model,
total_ram_gb,
}
}
pub fn run(&mut self) -> Result<OptimizationResult> {
self.log("Starting ember-tune Benchmark Sequence.")?;
// Phase 1: Audit & Baseline
self.phase = BenchmarkPhase::Auditing;
for step in self.auditor.audit() {
if let Err(e) = step.outcome {
return Err(anyhow::anyhow!("Audit failed ({}): {:?}", step.description, e));
}
}
self.log("Suppressing background services (tlp, thermald)...")?;
self.guard.suppress().context("Failed to suppress background services")?;
// Baseline (Idle Calibration)
self.phase = BenchmarkPhase::IdleCalibration;
self.log("Phase 1: Recording Idle Baseline (10s)...")?;
self.actuators.set_fan_mode("auto")?; // Use auto for idle
let mut idle_temps = Vec::new();
let start = Instant::now();
let mut tick = 0;
while start.elapsed() < Duration::from_secs(10) {
self.check_abort()?;
self.send_telemetry(tick)?;
idle_temps.push(self.sensors.get_temp().unwrap_or(0.0));
tick += 1;
thread::sleep(Duration::from_millis(500));
}
self.profile.ambient_temp = self.engine.smooth(&idle_temps).last().cloned().unwrap_or(0.0);
self.log(&format!("✓ Idle Baseline: {:.1}°C", self.profile.ambient_temp))?;
// Phase 2: Stress Stepping
self.phase = BenchmarkPhase::StressTesting;
self.log("Phase 2: Starting Synthetic Stress Matrix.")?;
self.actuators.set_fan_mode("max")?; // Lock fans for consistent resistance
let power_steps = [15.0, 20.0, 25.0, 30.0, 35.0];
for &pl in &power_steps {
self.log(&format!("Testing PL1 = {:.0}W...", pl))?;
self.actuators.set_sustained_power_limit(pl)?;
self.actuators.set_burst_power_limit(pl + 5.0)?;
self.workload.start(num_cpus::get(), 100)?;
// Wait for equilibrium: Hybrid approach (15s min, 45s max)
let step_start = Instant::now();
let mut step_temps = VecDeque::with_capacity(30); // Last 15s @ 500ms
while step_start.elapsed() < Duration::from_secs(45) {
self.check_abort()?;
if self.watchdog.check_emergency()? {
self.log("⚠ EMERGENCY ABORT: Watchdog triggered!")?;
self.workload.stop()?;
return Err(anyhow::anyhow!("Hardware Watchdog Triggered"));
}
let t = self.sensors.get_temp().unwrap_or(0.0);
step_temps.push_back(t);
if step_temps.len() > 10 { step_temps.pop_front(); }
self.send_telemetry(tick)?;
tick += 1;
// Check for stability: Range < 0.5C over last 5s (10 ticks)
if step_start.elapsed() > Duration::from_secs(15) && step_temps.len() == 10 {
let min = step_temps.iter().fold(f32::MAX, |a, &b| a.min(b));
let max = step_temps.iter().fold(f32::MIN, |a, &b| a.max(b));
if (max - min) < 0.5 {
self.log(&format!(" Equilibrium reached at {:.1}°C", t))?;
break;
}
}
thread::sleep(Duration::from_millis(500));
}
// Record data point
let avg_p = self.sensors.get_power_w().unwrap_or(0.0);
let avg_t = self.sensors.get_temp().unwrap_or(0.0);
let avg_f = 2500.0; // Mock frequency until SensorBus expanded
let fan = self.sensors.get_fan_rpm().unwrap_or(0);
let tp = self.workload.get_throughput().unwrap_or(0.0);
self.profile.points.push(ThermalPoint {
power_w: avg_p,
temp_c: avg_t,
freq_mhz: avg_f,
fan_rpm: fan,
throughput: tp,
});
self.workload.stop()?;
self.log(" Step complete. Cooling down for 5s...")?;
thread::sleep(Duration::from_secs(5));
}
// Phase 4: Physical Modeling
self.phase = BenchmarkPhase::PhysicalModeling;
self.log("Phase 3: Calculating Silicon Physical Sweet Spot...")?;
let res = self.generate_result(false);
self.log(&format!("✓ Thermal Resistance (Rθ): {:.3} K/W", res.thermal_resistance_kw))?;
self.log(&format!("✓ Silicon Knee Found: {:.1} W", res.silicon_knee_watts))?;
thread::sleep(Duration::from_secs(3));
// Phase 5: Finalizing
self.phase = BenchmarkPhase::Finalizing;
self.log("Benchmark sequence complete. Generating configuration...")?;
let config = crate::engine::formatters::throttled::ThrottledConfig {
pl1_limit: res.silicon_knee_watts,
pl2_limit: res.recommended_pl2,
trip_temp: res.max_temp_c.max(95.0),
};
let conf_content = crate::engine::formatters::throttled::ThrottledTranslator::generate_conf(&config);
std::fs::write("throttled.conf", conf_content)?;
self.log("✓ Saved 'throttled.conf'.")?;
self.guard.restore()?;
self.log("✓ Environment restored.")?;
Ok(res)
}
pub fn generate_result(&self, is_partial: bool) -> OptimizationResult {
let r_theta = self.engine.calculate_thermal_resistance(&self.profile);
let knee = self.engine.find_silicon_knee(&self.profile);
let max_t = self.engine.get_max_temp(&self.profile);
OptimizationResult {
profile: self.profile.clone(),
silicon_knee_watts: knee,
thermal_resistance_kw: r_theta,
recommended_pl1: knee,
recommended_pl2: knee * 1.25,
max_temp_c: max_t,
is_partial,
}
}
fn check_abort(&self) -> Result<()> {
if let Ok(cmd) = self.command_rx.try_recv() {
match cmd {
UiCommand::Abort => {
return Err(anyhow::anyhow!("ABORTED"));
}
}
}
Ok(())
}
fn log(&self, msg: &str) -> Result<()> {
let state = TelemetryState {
cpu_model: self.cpu_model.clone(),
total_ram_gb: self.total_ram_gb,
tick: 0,
cpu_temp: self.sensors.get_temp().unwrap_or(0.0),
power_w: self.sensors.get_power_w().unwrap_or(0.0),
current_freq: 0.0,
fan_rpm: self.sensors.get_fan_rpm().unwrap_or(0),
governor: "unknown".to_string(),
pl1_limit: 0.0,
pl2_limit: 0.0,
fan_tier: "auto".to_string(),
phase: self.phase,
history_watts: Vec::new(),
history_temp: Vec::new(),
history_mhz: Vec::new(),
log_event: Some(msg.to_string()),
metadata: std::collections::HashMap::new(),
};
self.telemetry_tx.send(state).map_err(|_| anyhow::anyhow!("Telemetry channel closed"))
}
fn send_telemetry(&mut self, tick: u64) -> Result<()> {
let temp = self.sensors.get_temp().unwrap_or(0.0);
let pwr = self.sensors.get_power_w().unwrap_or(0.0);
let freq = 0.0;
self.history_temp.push_back(temp);
self.history_watts.push_back(pwr);
self.history_mhz.push_back(freq);
if self.history_temp.len() > 120 {
self.history_temp.pop_front();
self.history_watts.pop_front();
self.history_mhz.pop_front();
}
let state = TelemetryState {
cpu_model: self.cpu_model.clone(),
total_ram_gb: self.total_ram_gb,
tick,
cpu_temp: temp,
power_w: pwr,
current_freq: freq,
fan_rpm: self.sensors.get_fan_rpm().unwrap_or(0),
governor: "performance".to_string(),
pl1_limit: 15.0,
pl2_limit: 25.0,
fan_tier: "max".to_string(),
phase: self.phase,
history_watts: self.history_watts.iter().cloned().collect(),
history_temp: self.history_temp.iter().cloned().collect(),
history_mhz: self.history_mhz.iter().cloned().collect(),
log_event: None,
metadata: std::collections::HashMap::new(),
};
self.telemetry_tx.send(state).map_err(|_| anyhow::anyhow!("Telemetry channel closed"))
}
}

235
src/sal/dell_xps_9380.rs Normal file
View File

@@ -0,0 +1,235 @@
use super::traits::{PreflightAuditor, EnvironmentGuard, SensorBus, ActuatorBus, HardwareWatchdog, AuditError, AuditStep};
use anyhow::{Result, Context};
use std::fs;
use std::path::PathBuf;
use std::process::Command;
use std::time::{Duration, Instant};
use std::sync::Mutex;
use tracing::debug;
pub struct DellXps9380Sal {
temp_path: PathBuf,
pwr_path: PathBuf,
fan_path: PathBuf,
pl1_path: PathBuf,
pl2_path: PathBuf,
last_poll: Mutex<Instant>,
last_temp: Mutex<f32>,
last_fan: Mutex<u32>,
}
impl DellXps9380Sal {
pub fn init() -> Result<Self> {
let mut temp_path = None;
let mut pwr_path = None;
let mut fan_path = None;
let mut rapl_base_path = None;
// Dynamic hwmon discovery
if let Ok(entries) = fs::read_dir("/sys/class/hwmon") {
for entry in entries.flatten() {
let p = entry.path();
let name = fs::read_to_string(p.join("name")).unwrap_or_default().trim().to_string();
if name == "dell_smm" {
temp_path = Some(p.join("temp1_input"));
fan_path = Some(p.join("fan1_input"));
}
if name == "intel_rapl" || name == "rapl" {
pwr_path = Some(p.join("power1_average"));
}
}
}
// Discovery for RAPL via powercap
if let Ok(entries) = fs::read_dir("/sys/class/powercap") {
for entry in entries.flatten() {
let p = entry.path();
if let Ok(name) = fs::read_to_string(p.join("name")) {
if name.trim() == "package-0" {
rapl_base_path = Some(p.clone());
if pwr_path.is_none() {
pwr_path = Some(p.join("energy_uj"));
}
break;
}
}
}
}
let rapl_base = rapl_base_path.context("Could not find RAPL package-0 path in powercap")?;
Ok(Self {
temp_path: temp_path.context("Could not find dell_smm temperature path")?,
pwr_path: pwr_path.context("Could not find RAPL power path")?,
fan_path: fan_path.context("Could not find dell_smm fan path")?,
pl1_path: rapl_base.join("constraint_0_power_limit_uw"),
pl2_path: rapl_base.join("constraint_1_power_limit_uw"),
last_poll: Mutex::new(Instant::now() - Duration::from_secs(2)),
last_temp: Mutex::new(0.0),
last_fan: Mutex::new(0),
})
}
}
impl PreflightAuditor for DellXps9380Sal {
fn audit(&self) -> Box<dyn Iterator<Item = AuditStep> + '_> {
let mut steps = Vec::new();
// 1. Root check
steps.push(AuditStep {
description: "Root Privileges".to_string(),
outcome: if unsafe { libc::getuid() } == 0 { Ok(()) } else { Err(AuditError::RootRequired) }
});
// 2. Kernel parameters check
let cmdline = fs::read_to_string("/proc/cmdline").unwrap_or_default();
steps.push(AuditStep {
description: "Kernel Param: dell_smm_hwmon.ignore_dmi=1".to_string(),
outcome: if cmdline.contains("dell_smm_hwmon.ignore_dmi=1") { Ok(()) } else {
Err(AuditError::MissingKernelParam("dell_smm_hwmon.ignore_dmi=1".to_string()))
}
});
steps.push(AuditStep {
description: "Kernel Param: msr.allow_writes=on".to_string(),
outcome: if cmdline.contains("msr.allow_writes=on") { Ok(()) } else {
Err(AuditError::MissingKernelParam("msr.allow_writes=on".to_string()))
}
});
// 3. Check AC power
let ac_status = fs::read_to_string("/sys/class/power_supply/AC/online").unwrap_or_else(|_| "0".to_string());
steps.push(AuditStep {
description: "AC Power Connection".to_string(),
outcome: if ac_status.trim() == "1" { Ok(()) } else {
Err(AuditError::AcPowerMissing("System must be on AC power for benchmarking".to_string()))
}
});
Box::new(steps.into_iter())
}
}
pub struct DellXps9380Guard {
stopped_services: Vec<String>,
}
impl DellXps9380Guard {
pub fn new() -> Self {
Self { stopped_services: Vec::new() }
}
}
impl EnvironmentGuard for DellXps9380Guard {
fn suppress(&mut self) -> Result<()> {
let services = ["tlp", "thermald"];
for s in services {
if Command::new("systemctl").args(["is-active", "--quiet", s]).status()?.success() {
Command::new("systemctl").args(["stop", s]).status()?;
self.stopped_services.push(s.to_string());
}
}
Ok(())
}
fn restore(&mut self) -> Result<()> {
for s in &self.stopped_services {
let _ = Command::new("systemctl").args(["start", s]).status();
}
self.stopped_services.clear();
Ok(())
}
}
impl Drop for DellXps9380Guard {
fn drop(&mut self) {
let _ = self.restore();
}
}
impl SensorBus for DellXps9380Sal {
fn get_temp(&self) -> Result<f32> {
// Enforce 1000ms rate limit for Dell SMM as per GEMINI.md
let mut last_poll = self.last_poll.lock().unwrap();
let now = Instant::now();
if now.duration_since(*last_poll) < Duration::from_millis(1000) {
return Ok(*self.last_temp.lock().unwrap());
}
let s = fs::read_to_string(&self.temp_path)?;
let val = s.trim().parse::<f32>()? / 1000.0;
*self.last_temp.lock().unwrap() = val;
*last_poll = now;
Ok(val)
}
fn get_power_w(&self) -> Result<f32> {
if self.pwr_path.to_string_lossy().contains("energy_uj") {
let e1 = fs::read_to_string(&self.pwr_path)?.trim().parse::<u64>()?;
std::thread::sleep(Duration::from_millis(100));
let e2 = fs::read_to_string(&self.pwr_path)?.trim().parse::<u64>()?;
Ok((e2.saturating_sub(e1)) as f32 / 100000.0)
} else {
let s = fs::read_to_string(&self.pwr_path)?;
Ok(s.trim().parse::<f32>()? / 1000000.0)
}
}
fn get_fan_rpm(&self) -> Result<u32> {
let mut last_poll = self.last_poll.lock().unwrap();
let now = Instant::now();
if now.duration_since(*last_poll) < Duration::from_millis(1000) {
return Ok(*self.last_fan.lock().unwrap());
}
let s = fs::read_to_string(&self.fan_path)?;
let val = s.trim().parse::<u32>()?;
*self.last_fan.lock().unwrap() = val;
*last_poll = now;
Ok(val)
}
}
impl ActuatorBus for DellXps9380Sal {
fn set_fan_mode(&self, mode: &str) -> Result<()> {
match mode {
"max" | "Manual" => {
Command::new("dell-bios-fan-control").arg("0").status()?;
}
"auto" | "Auto" => {
Command::new("dell-bios-fan-control").arg("1").status()?;
}
_ => {
debug!("Unknown fan mode requested: {}", mode);
}
}
Ok(())
}
fn set_sustained_power_limit(&self, watts: f32) -> Result<()> {
let uw = (watts * 1_000_000.0) as u64;
fs::write(&self.pl1_path, uw.to_string())?;
Ok(())
}
fn set_burst_power_limit(&self, watts: f32) -> Result<()> {
let uw = (watts * 1_000_000.0) as u64;
fs::write(&self.pl2_path, uw.to_string())?;
Ok(())
}
}
impl HardwareWatchdog for DellXps9380Sal {
fn check_emergency(&self) -> Result<bool> {
// Check for thermal throttling or BD PROCHOT
// Simplified for now
Ok(false)
}
}

76
src/sal/mock.rs Normal file
View File

@@ -0,0 +1,76 @@
use super::traits::{PreflightAuditor, EnvironmentGuard, SensorBus, ActuatorBus, HardwareWatchdog, AuditStep};
use anyhow::Result;
pub struct MockAuditor;
impl PreflightAuditor for MockAuditor {
fn audit(&self) -> Box<dyn Iterator<Item = AuditStep> + '_> {
let steps = vec![
AuditStep {
description: "Mock Root Privileges".to_string(),
outcome: Ok(()),
},
AuditStep {
description: "Mock AC Power Status".to_string(),
outcome: Ok(()),
},
];
Box::new(steps.into_iter())
}
}
pub struct MockGuard {
pub suppressed: bool,
}
impl MockGuard {
pub fn new() -> Self {
Self { suppressed: false }
}
}
impl EnvironmentGuard for MockGuard {
fn suppress(&mut self) -> Result<()> {
self.suppressed = true;
Ok(())
}
fn restore(&mut self) -> Result<()> {
self.suppressed = false;
Ok(())
}
}
impl Drop for MockGuard {
fn drop(&mut self) {
let _ = self.restore();
}
}
pub struct MockSensorBus;
impl SensorBus for MockSensorBus {
fn get_temp(&self) -> Result<f32> {
Ok(42.0)
}
fn get_power_w(&self) -> Result<f32> {
Ok(15.0)
}
fn get_fan_rpm(&self) -> Result<u32> {
Ok(2500)
}
}
pub struct MockActuatorBus;
impl ActuatorBus for MockActuatorBus {
fn set_fan_mode(&self, _mode: &str) -> Result<()> {
Ok(())
}
fn set_sustained_power_limit(&self, _watts: f32) -> Result<()> {
Ok(())
}
fn set_burst_power_limit(&self, _watts: f32) -> Result<()> {
Ok(())
}
}
pub struct MockWatchdog;
impl HardwareWatchdog for MockWatchdog {
fn check_emergency(&self) -> Result<bool> {
Ok(false)
}
}

3
src/sal/mod.rs Normal file
View File

@@ -0,0 +1,3 @@
pub mod traits;
pub mod mock;
pub mod dell_xps_9380;

103
src/sal/traits.rs Normal file
View File

@@ -0,0 +1,103 @@
use anyhow::Result;
use thiserror::Error;
use miette::Diagnostic;
use std::sync::Arc;
#[derive(Error, Diagnostic, Debug, Clone)]
pub enum AuditError {
#[error("Missing root privileges.")]
#[diagnostic(code(ember_tune::root_required), severity(error))]
#[help("ember-tune requires direct hardware access (MSRs, sysfs). Please run with 'sudo'.")]
RootRequired,
#[error("Missing kernel parameter: {0}")]
#[diagnostic(code(ember_tune::missing_kernel_param), severity(error))]
#[help("Add '{0}' to your GRUB_CMDLINE_LINUX_DEFAULT in /etc/default/grub, then run 'sudo update-grub' and reboot.")]
MissingKernelParam(String),
#[error("System is running on battery: {0}")]
#[diagnostic(code(ember_tune::ac_power_missing), severity(error))]
#[help("Thermal benchmarking requires a stable AC power source to ensure consistent PL limits. Please plug in your charger.")]
AcPowerMissing(String),
#[error("Incompatible kernel version: {0}")]
#[diagnostic(code(ember_tune::kernel_incompatible), severity(error))]
#[help("Your kernel version '{0}' may not support the required RAPL or SMM interfaces. Please upgrade to a recent LTS kernel (6.1+).")]
KernelIncompatible(String),
#[error("Required tool missing: {0}")]
#[diagnostic(code(ember_tune::tool_missing), severity(error))]
#[help("The utility '{0}' is required for this SAL. Please install it using your package manager (e.g., 'sudo apt install {0}').")]
ToolMissing(String),
}
pub struct AuditStep {
pub description: String,
pub outcome: Result<(), AuditError>,
}
/// Evaluates immutable system states (e.g., kernel bootline parameters, AC power status).
pub trait PreflightAuditor: Send + Sync {
fn audit(&self) -> Box<dyn Iterator<Item = AuditStep> + '_>;
}
impl<T: PreflightAuditor + ?Sized> PreflightAuditor for Arc<T> {
fn audit(&self) -> Box<dyn Iterator<Item = AuditStep> + '_> {
(**self).audit()
}
}
/// Suppresses conflicting daemons (tlp, thermald).
pub trait EnvironmentGuard {
fn suppress(&mut self) -> Result<()>;
fn restore(&mut self) -> Result<()>;
}
/// Read-only interface for standardized metrics.
pub trait SensorBus {
fn get_temp(&self) -> Result<f32>;
fn get_power_w(&self) -> Result<f32>;
fn get_fan_rpm(&self) -> Result<u32>;
}
impl<T: SensorBus + ?Sized> SensorBus for Arc<T> {
fn get_temp(&self) -> Result<f32> {
(**self).get_temp()
}
fn get_power_w(&self) -> Result<f32> {
(**self).get_power_w()
}
fn get_fan_rpm(&self) -> Result<u32> {
(**self).get_fan_rpm()
}
}
/// Write-only interface for hardware commands.
pub trait ActuatorBus {
fn set_fan_mode(&self, mode: &str) -> Result<()>;
fn set_sustained_power_limit(&self, watts: f32) -> Result<()>;
fn set_burst_power_limit(&self, watts: f32) -> Result<()>;
}
impl<T: ActuatorBus + ?Sized> ActuatorBus for Arc<T> {
fn set_fan_mode(&self, mode: &str) -> Result<()> {
(**self).set_fan_mode(mode)
}
fn set_sustained_power_limit(&self, watts: f32) -> Result<()> {
(**self).set_sustained_power_limit(watts)
}
fn set_burst_power_limit(&self, watts: f32) -> Result<()> {
(**self).set_burst_power_limit(watts)
}
}
/// Concurrent monitor for catastrophic states.
pub trait HardwareWatchdog {
fn check_emergency(&self) -> Result<bool>;
}
impl<T: HardwareWatchdog + ?Sized> HardwareWatchdog for Arc<T> {
fn check_emergency(&self) -> Result<bool> {
(**self).check_emergency()
}
}

353
src/ui/dashboard.rs Normal file
View File

@@ -0,0 +1,353 @@
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Style, Modifier, Color},
text::{Span, Line},
widgets::{Block, Borders, List, ListItem, Paragraph, Chart, Dataset, Axis, BorderType, GraphType},
symbols::Marker,
Frame,
};
use crate::mediator::TelemetryState;
use crate::ui::theme::*;
/// DashboardState maintains UI-specific state that isn't part of the core telemetry,
/// such as the accumulated diagnostic logs.
pub struct DashboardState {
pub logs: Vec<String>,
}
impl DashboardState {
pub fn new() -> Self {
Self {
logs: vec!["FerroTherm Initialized.".to_string()],
}
}
/// Updates the UI state based on new telemetry.
pub fn update(&mut self, _state: &TelemetryState) {}
}
/// Main entry point for drawing the dashboard.
pub fn draw_dashboard(
f: &mut Frame,
area: Rect,
state: &TelemetryState,
ui_state: &DashboardState,
) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), // Header
Constraint::Length(3), // Top Block (System Info)
Constraint::Min(15), // Middle (Gauges + Blocks + Graphs)
Constraint::Length(8), // Bottom (Logs)
])
.split(area);
draw_header(f, chunks[0], state);
draw_sys_info(f, chunks[1], state);
let middle_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(32), // Gauges + Small Blocks
Constraint::Min(20), // Graphs
])
.split(chunks[2]);
let left_side_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(10), // Gauges
Constraint::Length(3), // Cooling
Constraint::Length(3), // CPU State
Constraint::Min(4), // Metadata
])
.split(middle_chunks[0]);
draw_gauges(f, left_side_chunks[0], state);
draw_cooling(f, left_side_chunks[1], state);
draw_cpu_state(f, left_side_chunks[2], state);
draw_metadata(f, left_side_chunks[3], state);
let right_side_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Ratio(1, 3),
Constraint::Ratio(1, 3),
Constraint::Ratio(1, 3),
])
.split(middle_chunks[1]);
draw_temp_graph(f, right_side_chunks[0], state);
draw_pwr_graph(f, right_side_chunks[1], state);
draw_freq_graph(f, right_side_chunks[2], state);
draw_logs(f, chunks[3], ui_state);
}
fn draw_header(f: &mut Frame, area: Rect, state: &TelemetryState) {
let uptime_secs = state.tick / 2;
let uptime = format!("{:02}:{:02}:{:02}", uptime_secs / 3600, (uptime_secs % 3600) / 60, uptime_secs % 60);
let hostname = std::env::var("HOSTNAME").unwrap_or_else(|_| "localhost".into());
let left = Span::styled(format!(" 󰈐 {} ", hostname), Style::default().fg(C_MAUVE).add_modifier(Modifier::BOLD));
let center = Span::styled(" FERROTHERM THERMAL BENCH ", Style::default().fg(C_LAVENDER).add_modifier(Modifier::BOLD));
let right = Span::styled(format!(" UPTIME: {} ", uptime), Style::default().fg(C_SUBTEXT));
let total_width = area.width;
let left_width = left.width() as u16;
let center_width = center.width() as u16;
let right_width = right.width() as u16;
let space1 = (total_width.saturating_sub(left_width + center_width + right_width)) / 2;
let space2 = total_width.saturating_sub(left_width + center_width + right_width + space1);
let header_text = Line::from(vec![
left,
Span::raw(" ".repeat(space1 as usize)),
center,
Span::raw(" ".repeat(space2 as usize)),
right,
]);
f.render_widget(Paragraph::new(header_text).style(Style::default().bg(C_SURFACE0)), area);
}
fn draw_sys_info(f: &mut Frame, area: Rect, state: &TelemetryState) {
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(C_SURFACE0))
.title(" SYSTEM INFO ");
let inner = block.inner(area);
f.render_widget(block, area);
let info_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(40),
Constraint::Percentage(20),
Constraint::Percentage(20),
Constraint::Percentage(20),
])
.split(inner);
let cpu_info = Line::from(vec![
Span::styled(" CPU: ", Style::default().fg(C_LAVENDER)),
Span::styled(&state.cpu_model, Style::default().fg(C_TEXT)),
]);
f.render_widget(Paragraph::new(cpu_info), info_chunks[0]);
let ram_info = Line::from(vec![
Span::styled(" RAM: ", Style::default().fg(C_LAVENDER)),
Span::styled(format!("{} GB", state.total_ram_gb), Style::default().fg(C_TEXT)),
]);
f.render_widget(Paragraph::new(ram_info), info_chunks[1]);
let gov_info = Line::from(vec![
Span::styled(" GOV: ", Style::default().fg(C_LAVENDER)),
Span::styled(&state.governor, Style::default().fg(C_TEAL)),
]);
f.render_widget(Paragraph::new(gov_info), info_chunks[2]);
let pl_info = Line::from(vec![
Span::styled(" PL1/2: ", Style::default().fg(C_LAVENDER)),
Span::styled(format!("{:.1}/{:.1}W", state.pl1_limit, state.pl2_limit), Style::default().fg(C_PEACH)),
]);
f.render_widget(Paragraph::new(pl_info), info_chunks[3]);
}
fn draw_gauges(f: &mut Frame, area: Rect, state: &TelemetryState) {
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(50),
Constraint::Percentage(50),
])
.split(area);
let temp_color = if state.cpu_temp > 90.0 { C_RED } else if state.cpu_temp > 75.0 { C_YELLOW } else { C_TEAL };
draw_vertical_gauge(f, chunks[0], "TEMP", state.cpu_temp, 100.0, temp_color, "°C");
draw_vertical_gauge(f, chunks[1], "PWR", state.power_w, 50.0, C_PEACH, "W");
}
fn draw_cooling(f: &mut Frame, area: Rect, state: &TelemetryState) {
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(C_SURFACE0))
.title(" COOLING ");
let inner = block.inner(area);
f.render_widget(block, area);
let info = Line::from(vec![
Span::styled(" Tier: ", Style::default().fg(C_LAVENDER)),
Span::styled(&state.fan_tier, Style::default().fg(C_TEAL)),
Span::styled(" | RPM: ", Style::default().fg(C_LAVENDER)),
Span::styled(format!("{}", state.fan_rpm), Style::default().fg(C_TEXT)),
]);
f.render_widget(Paragraph::new(info), inner);
}
fn draw_cpu_state(f: &mut Frame, area: Rect, state: &TelemetryState) {
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(C_SURFACE0))
.title(" CPU STATE ");
let inner = block.inner(area);
f.render_widget(block, area);
let info = Line::from(vec![
Span::styled(" Frequency: ", Style::default().fg(C_LAVENDER)),
Span::styled(format!("{:.0} MHz", state.current_freq), Style::default().fg(C_SAPPHIRE)),
]);
f.render_widget(Paragraph::new(info), inner);
}
fn draw_metadata(f: &mut Frame, area: Rect, state: &TelemetryState) {
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(C_SURFACE0))
.title(" HARDWARE INFO ");
let inner = block.inner(area);
f.render_widget(block, area);
if state.metadata.is_empty() {
f.render_widget(Paragraph::new(" No extra telemetry data.").style(Style::default().fg(C_OVERLAY0)), inner);
return;
}
let items: Vec<ListItem> = state.metadata.iter().map(|(k, v)| {
ListItem::new(Line::from(vec![
Span::styled(format!(" {}: ", k), Style::default().fg(C_LAVENDER)),
Span::styled(v, Style::default().fg(C_TEXT)),
]))
}).collect();
f.render_widget(List::new(items), inner);
}
fn draw_vertical_gauge(f: &mut Frame, area: Rect, label: &str, value: f32, max: f32, color: Color, unit: &str) {
let block = Block::default()
.title(format!(" {} ", label))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(C_SURFACE0));
let inner = block.inner(area);
f.render_widget(block, area);
if inner.height == 0 || inner.width == 0 { return; }
let percent = (value / max).clamp(0.0, 1.0);
let filled_height = (percent * inner.height as f32).round() as u16;
for y in 0..inner.height {
let is_filled = y < filled_height;
let symbol = if is_filled { "" } else { "" };
let style = if is_filled { Style::default().fg(color) } else { Style::default().fg(C_SURFACE0) };
let draw_y = inner.y + inner.height.saturating_sub(1).saturating_sub(y);
let row_text = symbol.repeat(inner.width as usize);
f.render_widget(Paragraph::new(row_text).style(style), Rect::new(inner.x, draw_y, inner.width, 1));
}
let val_str = format!("{:.1}{}", value, unit);
let val_len = val_str.len() as u16;
let val_x = inner.x + inner.width.saturating_sub(val_len) / 2;
let val_y = inner.y + inner.height / 2;
f.render_widget(
Paragraph::new(val_str).style(Style::default().fg(C_TEXT).bg(color).add_modifier(Modifier::BOLD)),
Rect::new(val_x, val_y, inner.width.min(val_len), 1)
);
}
fn draw_temp_graph(f: &mut Frame, area: Rect, state: &TelemetryState) {
let temp_data: Vec<(f64, f64)> = state.history_temp.iter().enumerate()
.map(|(i, &v)| (i as f64, v as f64)).collect();
let temp_dataset = Dataset::default()
.name("Temp")
.marker(Marker::Braille)
.graph_type(GraphType::Line)
.style(Style::default().fg(C_RED))
.data(&temp_data);
let chart = Chart::new(vec![temp_dataset])
.block(Block::default().title(" CPU TEMPERATURE (°C) ").borders(Borders::ALL).border_type(BorderType::Rounded).border_style(Style::default().fg(C_SURFACE0)))
.x_axis(Axis::default().bounds([0.0, 120.0]).style(Style::default().fg(C_OVERLAY0)))
.y_axis(Axis::default().bounds([30.0, 100.0]).labels(vec![
Line::from("30"),
Line::from("65"),
Line::from("100"),
]).style(Style::default().fg(C_OVERLAY0)));
f.render_widget(chart, area);
}
fn draw_pwr_graph(f: &mut Frame, area: Rect, state: &TelemetryState) {
let pwr_data: Vec<(f64, f64)> = state.history_watts.iter().enumerate()
.map(|(i, &v)| (i as f64, v as f64)).collect();
let pwr_dataset = Dataset::default()
.name("Power")
.marker(Marker::Braille)
.graph_type(GraphType::Line)
.style(Style::default().fg(C_PEACH))
.data(&pwr_data);
let chart = Chart::new(vec![pwr_dataset])
.block(Block::default().title(" PACKAGE POWER (W) ").borders(Borders::ALL).border_type(BorderType::Rounded).border_style(Style::default().fg(C_SURFACE0)))
.x_axis(Axis::default().bounds([0.0, 120.0]).style(Style::default().fg(C_OVERLAY0)))
.y_axis(Axis::default().bounds([0.0, 50.0]).labels(vec![
Line::from("0"),
Line::from("25"),
Line::from("50"),
]).style(Style::default().fg(C_OVERLAY0)));
f.render_widget(chart, area);
}
fn draw_freq_graph(f: &mut Frame, area: Rect, state: &TelemetryState) {
let freq_data: Vec<(f64, f64)> = state.history_mhz.iter().enumerate()
.map(|(i, &v)| (i as f64, v as f64)).collect();
let freq_dataset = Dataset::default()
.name("Freq")
.marker(Marker::Braille)
.graph_type(GraphType::Line)
.style(Style::default().fg(C_SAPPHIRE))
.data(&freq_data);
let chart = Chart::new(vec![freq_dataset])
.block(Block::default().title(" CPU FREQUENCY (MHz) ").borders(Borders::ALL).border_type(BorderType::Rounded).border_style(Style::default().fg(C_SURFACE0)))
.x_axis(Axis::default().bounds([0.0, 120.0]).style(Style::default().fg(C_OVERLAY0)))
.y_axis(Axis::default().bounds([0.0, 5000.0]).labels(vec![
Line::from("0"),
Line::from("2500"),
Line::from("5000"),
]).style(Style::default().fg(C_OVERLAY0)));
f.render_widget(chart, area);
}
fn draw_logs(f: &mut Frame, area: Rect, ui_state: &DashboardState) {
let log_items: Vec<ListItem> = ui_state.logs.iter().rev().take(area.height as usize).map(|l| {
let style = if l.contains("") || l.contains("failed") || l.contains("Abort") {
Style::default().fg(C_RED)
} else if l.contains("") || l.contains("complete") {
Style::default().fg(C_GREEN)
} else {
Style::default().fg(C_TEAL)
};
ListItem::new(format!(" {} ", l)).style(style)
}).collect();
f.render_widget(
List::new(log_items)
.block(Block::default().title(" DIAGNOSTIC LOG ").borders(Borders::ALL).border_type(BorderType::Rounded).border_style(Style::default().fg(C_SURFACE0))),
area
);
}

2
src/ui/mod.rs Normal file
View File

@@ -0,0 +1,2 @@
pub mod theme;
pub mod dashboard;

14
src/ui/theme.rs Normal file
View File

@@ -0,0 +1,14 @@
use ratatui::style::Color;
pub const C_SURFACE0: Color = Color::Rgb(54, 58, 79);
pub const C_OVERLAY0: Color = Color::Rgb(108, 112, 134);
pub const C_TEXT: Color = Color::Rgb(205, 214, 244);
pub const C_LAVENDER: Color = Color::Rgb(180, 190, 254);
pub const C_SAPPHIRE: Color = Color::Rgb(116, 199, 236);
pub const C_TEAL: Color = Color::Rgb(148, 226, 213);
pub const C_GREEN: Color = Color::Rgb(166, 227, 161);
pub const C_YELLOW: Color = Color::Rgb(249, 226, 175);
pub const C_PEACH: Color = Color::Rgb(250, 179, 135);
pub const C_RED: Color = Color::Rgb(243, 139, 168);
pub const C_MAUVE: Color = Color::Rgb(203, 166, 247);
pub const C_SUBTEXT: Color = Color::Rgb(166, 173, 200);