4 Commits

Author SHA1 Message Date
ab4d5828d5 implemented tui for multiple fans 2026-02-26 14:12:20 +01:00
cab39a6478 implemented multiple fans 2026-02-26 14:06:49 +01:00
7e2bef58d2 fixed panic and added hardware_db.toml 2026-02-26 13:57:22 +01:00
989e6d4325 release 1.0.0
All checks were successful
Build and Release / release (push) Successful in 54s
2026-02-26 13:39:35 +01:00
13 changed files with 245 additions and 57 deletions

View File

@@ -31,16 +31,16 @@ jobs:
- name: Prepare Binary - name: Prepare Binary
run: | run: |
cp target/release/xps-thermal-bench xps-thermal-bench-linux-amd64 cp target/release/ember-tune ember-tune-linux-amd64
sha256sum xps-thermal-bench-linux-amd64 > xps-thermal-bench-linux-amd64.sha256 sha256sum ember-tune-linux-amd64 > ember-tune-linux-amd64.sha256
- name: Release - name: Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/') if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/')
with: with:
files: | files: |
xps-thermal-bench-linux-amd64 ember-tune-linux-amd64
xps-thermal-bench-linux-amd64.sha256 ember-tune-linux-amd64.sha256
tag_name: v${{ steps.get_version.outputs.VERSION }} tag_name: v${{ steps.get_version.outputs.VERSION }}
name: Release v${{ steps.get_version.outputs.VERSION }} name: Release v${{ steps.get_version.outputs.VERSION }}
draft: false draft: false

4
Cargo.lock generated
View File

@@ -512,8 +512,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]] [[package]]
name = "ember-tune" name = "ember-tune-rs"
version = "1.0.0" version = "1.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "ember-tune" name = "ember-tune-rs"
version = "1.0.0" version = "1.1.0"
edition = "2024" edition = "2024"
authors = ["Nils Pukropp <nils@narl.io>"] authors = ["Nils Pukropp <nils@narl.io>"]
readme = "README.md" readme = "README.md"

117
assets/hardware_db.toml Normal file
View File

@@ -0,0 +1,117 @@
[metadata]
version = "1.0.0"
updated = "2026-02-26"
description = "Hardware and Conflict Database for ember-tune Thermal Engine"
# service collision
[[conflicts]]
id = "tlp_vs_ppd"
services = ["tlp.service", "power-profiles-daemon.service"]
contention = "ACPI Platform Profile / EPP"
severity = "Critical"
fix_action = "MaskBoth"
help_text = "TLP and Power-Profiles-Daemon fight over power envelopes. Mask both to allow ember-tune deterministic control."
[[conflicts]]
id = "thermal_logic_collision"
services = ["thermald.service", "throttled.service"]
contention = "RAPL / MSR / BD-PROCHOT"
severity = "High"
fix_action = "SuspendService"
help_text = "Thermald and Throttled create a 'register ping-pong' loop. Disable throttled; ember-tune will manage RAPL limits."
[[conflicts]]
id = "freq_scaling_collision"
services = ["auto-cpufreq.service"]
contention = "CPU Scaling Governor"
severity = "Medium"
fix_action = "SuspendService"
help_text = "Auto-cpufreq interferes with deterministic Silicon Knee identification."
# manufacturer wide logic
[ecosystems.dell]
vendor_regex = "(Dell.*|Precision.*|Latitude.*|XPS.*)"
polling_cap_ms = 1000
drivers = ["dell_smm_hwmon"]
fan_manual_mode_cmd = "dell-bios-fan-control 0"
fan_auto_mode_cmd = "dell-bios-fan-control 1"
safety_register = "0x1FC" # BD PROCHOT MSR
[ecosystems.lenovo]
vendor_regex = "LENOVO"
lap_mode_path = "/sys/devices/platform/thinkpad_acpi/dytc_lapmode"
profiles_path = "/sys/firmware/acpi/platform_profile"
ec_write_required = false # Varies by model
[ecosystems.asus]
vendor_regex = "ASUSTeK.*"
thermal_policy_path = "/sys/devices/platform/asus-nb-wmi/throttle_thermal_policy"
policy_map = { Balanced = 0, Turbo = 1, Silent = 2 }
[ecosystems.hp]
vendor_regex = "HP"
msr_lock_register = "0x610"
msr_lock_bit = 63
fan_boost_path = "/sys/devices/platform/hp-wmi/hwmon/hwmon*/pwm1_enable"
[ecosystems.framework]
vendor_regex = "Framework"
ec_tool = "ectool"
optimization = "Direct-FFI-SMC"
# quirks: model quirks and fixes
[[quirks]]
model_regex = "XPS 13 93.*"
id = "dell_bd_prochot_fix"
issue = "False Positive 400MHz Lock"
monitor_msr = "0x1FC"
reset_bit = 0
action = "ClearBitOnSafeTemp"
[[quirks]]
model_regex = "ThinkPad T14.*"
id = "lenovo_lap_throttling"
issue = "11W TDP Lock in Lap Mode"
trigger_path = "/sys/devices/platform/thinkpad_acpi/dytc_lapmode"
trigger_value = "1"
action = "AbortOnLapMode"
[[quirks]]
model_regex = "ROG Zephyrus G14"
id = "asus_fan_hex_support"
issue = "Custom Hex Curve Interface"
target_path = "/sys/devices/platform/asus-nb-wmi/fan_curve"
format = "HexPair16"
[[quirks]]
model_regex = "Spectre x360"
id = "hp_rapl_lockout"
issue = "Hardware MSR Lockout"
action = "WarnUserMSRLocked"
# heuristic discovery
[discovery.sensors]
temp_labels = ["Package id 0", "Tdie", "Tctl", "CPU Temperature"]
fan_labels = ["CPU Fan", "GPU Fan", "System Fan"]
hwmon_priority = ["coretemp", "zenpower", "k10temp", "dell_smm"]
[discovery.actuators]
rapl_paths = ["intel-rapl:0", "package-0"]
amd_energy_paths = ["zenpower/energy1_input", "k10temp/energy1_input"]
governor_files = ["energy_performance_preference", "energy_performance_hint", "scaling_governor"]
# env health verification
[[preflight_checks]]
name = "MSR Write Access"
check_cmd = "grep -q 'msr.allow_writes=on' /proc/cmdline"
fail_help = "Add 'msr.allow_writes=on' to kernel parameters to allow power limit manipulation."
[[preflight_checks]]
name = "Kernel Lockdown Status"
check_cmd = "cat /sys/kernel/security/lockdown | grep -q '\\[none\\]'"
fail_help = "Kernel Lockdown is enabled. MMIO/MSR actuators are restricted by the Linux Security Module."

View File

@@ -40,7 +40,7 @@ pub struct Cli {
#[arg( #[arg(
short, short,
long, long,
help = "Writes high-resolution diagnostic logs to /tmp/ember-tune.log" help = "Writes high-resolution diagnostic logs to /var/log/ember-tune.log"
)] )]
pub verbose: bool, pub verbose: bool,

View File

@@ -69,8 +69,8 @@ impl OptimizerEngine {
.unwrap_or(0.0) .unwrap_or(0.0)
} }
/// Finds the "Silicon Knee" - the point where performance per watt plateaus /// Finds the "Silicon Knee" - the point where performance per watt (efficiency)
/// and thermal density spikes. /// starts to diminish significantly and thermal density spikes.
pub fn find_silicon_knee(&self, profile: &ThermalProfile) -> f32 { pub fn find_silicon_knee(&self, profile: &ThermalProfile) -> f32 {
if profile.points.len() < 3 { if profile.points.len() < 3 {
return profile.points.last().map(|p| p.power_w).unwrap_or(15.0); return profile.points.last().map(|p| p.power_w).unwrap_or(15.0);
@@ -82,27 +82,42 @@ impl OptimizerEngine {
let mut best_pl = points[0].power_w; let mut best_pl = points[0].power_w;
let mut max_score = f32::MIN; let mut max_score = f32::MIN;
// Use a sliding window (3 points) to calculate gradients more robustly
for i in 1..points.len() - 1 { for i in 1..points.len() - 1 {
let prev = &points[i - 1]; let prev = &points[i - 1];
let curr = &points[i]; let curr = &points[i];
let next = &points[i + 1]; let next = &points[i + 1];
// 1. Performance Gradient (dMHz/dW) // 1. Efficiency Metric (Throughput per Watt)
let dmhz_dw_prev = (curr.freq_mhz - prev.freq_mhz) / (curr.power_w - prev.power_w).max(0.1); // If throughput is 0 (unsupported), fallback to Frequency per Watt
let dmhz_dw_next = (next.freq_mhz - curr.freq_mhz) / (next.power_w - curr.power_w).max(0.1); let efficiency_curr = if curr.throughput > 0.0 {
let freq_diminish = dmhz_dw_prev - dmhz_dw_next; curr.throughput as f32 / curr.power_w.max(0.1)
} else {
curr.freq_mhz / curr.power_w.max(0.1)
};
// 2. Thermal Gradient (d2T/dW2) let efficiency_next = if next.throughput > 0.0 {
next.throughput as f32 / next.power_w.max(0.1)
} else {
next.freq_mhz / next.power_w.max(0.1)
};
// Diminishing returns: how much efficiency drops per additional watt
let efficiency_drop = (efficiency_curr - efficiency_next) / (next.power_w - curr.power_w).max(0.1);
// 2. Thermal Acceleration (d2T/dW2)
let dt_dw_prev = (curr.temp_c - prev.temp_c) / (curr.power_w - prev.power_w).max(0.1); 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 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); let temp_accel = (dt_dw_next - dt_dw_prev) / (next.power_w - prev.power_w).max(0.1);
// 3. Wall Detection // 3. Wall Detection (Any drop in absolute frequency/throughput is a hard wall)
let is_throttling = next.freq_mhz < curr.freq_mhz; let is_throttling = next.freq_mhz < curr.freq_mhz || (next.throughput > 0.0 && next.throughput < curr.throughput);
let penalty = if is_throttling { 2000.0 } else { 0.0 }; let penalty = if is_throttling { 5000.0 } else { 0.0 };
// Heuristic scoring: Weight thermal acceleration and diminishing frequency gains // Heuristic scoring:
let score = (freq_diminish * 2.0) + (temp_accel * 10.0) - penalty; // - Higher score is "Better" (The Knee is the peak of this curve)
// - We want high efficiency (low drop) and low thermal acceleration.
let score = (efficiency_curr * 10.0) - (efficiency_drop * 50.0) - (temp_accel * 20.0) - penalty;
if score > max_score { if score > max_score {
max_score = score; max_score = score;

View File

@@ -75,7 +75,7 @@ fn print_summary_report(result: &OptimizationResult) {
} }
fn setup_logging(verbose: bool) -> tracing_appender::non_blocking::WorkerGuard { fn setup_logging(verbose: bool) -> tracing_appender::non_blocking::WorkerGuard {
let file_appender = tracing_appender::rolling::never("/tmp", "ember-tune.log"); let file_appender = tracing_appender::rolling::never("/var/log", "ember-tune.log");
let (non_blocking, guard) = tracing_appender::non_blocking(file_appender); let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
let level = if verbose { tracing::Level::DEBUG } else { tracing::Level::INFO }; let level = if verbose { tracing::Level::DEBUG } else { tracing::Level::INFO };
@@ -217,7 +217,7 @@ fn main() -> Result<()> {
cpu_temp: 0.0, cpu_temp: 0.0,
power_w: 0.0, power_w: 0.0,
current_freq: 0.0, current_freq: 0.0,
fan_rpm: 0, fans: Vec::new(),
governor: "detecting".to_string(), governor: "detecting".to_string(),
pl1_limit: 0.0, pl1_limit: 0.0,
pl2_limit: 0.0, pl2_limit: 0.0,

View File

@@ -33,7 +33,7 @@ pub struct TelemetryState {
pub cpu_temp: f32, pub cpu_temp: f32,
pub power_w: f32, pub power_w: f32,
pub current_freq: f32, pub current_freq: f32,
pub fan_rpm: u32, pub fans: Vec<u32>,
// --- High-res History (Last 60s @ 500ms = 120 points) --- // --- High-res History (Last 60s @ 500ms = 120 points) ---
pub history_watts: Vec<f32>, pub history_watts: Vec<f32>,

View File

@@ -151,15 +151,16 @@ impl BenchmarkOrchestrator {
// Record data point // Record data point
let avg_p = self.sensors.get_power_w().unwrap_or(0.0); 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_t = self.sensors.get_temp().unwrap_or(0.0);
let avg_f = 2500.0; // Mock frequency until SensorBus expanded let avg_f = self.sensors.get_freq_mhz().unwrap_or(0.0);
let fan = self.sensors.get_fan_rpm().unwrap_or(0); let fans = self.sensors.get_fan_rpms().unwrap_or_default();
let primary_fan = fans.first().cloned().unwrap_or(0);
let tp = self.workload.get_throughput().unwrap_or(0.0); let tp = self.workload.get_throughput().unwrap_or(0.0);
self.profile.points.push(ThermalPoint { self.profile.points.push(ThermalPoint {
power_w: avg_p, power_w: avg_p,
temp_c: avg_t, temp_c: avg_t,
freq_mhz: avg_f, freq_mhz: avg_f,
fan_rpm: fan, fan_rpm: primary_fan,
throughput: tp, throughput: tp,
}); });
@@ -233,8 +234,8 @@ impl BenchmarkOrchestrator {
tick: 0, tick: 0,
cpu_temp: self.sensors.get_temp().unwrap_or(0.0), cpu_temp: self.sensors.get_temp().unwrap_or(0.0),
power_w: self.sensors.get_power_w().unwrap_or(0.0), power_w: self.sensors.get_power_w().unwrap_or(0.0),
current_freq: 0.0, current_freq: self.sensors.get_freq_mhz().unwrap_or(0.0),
fan_rpm: self.sensors.get_fan_rpm().unwrap_or(0), fans: self.sensors.get_fan_rpms().unwrap_or_default(),
governor: "unknown".to_string(), governor: "unknown".to_string(),
pl1_limit: 0.0, pl1_limit: 0.0,
pl2_limit: 0.0, pl2_limit: 0.0,
@@ -252,7 +253,7 @@ impl BenchmarkOrchestrator {
fn send_telemetry(&mut self, tick: u64) -> Result<()> { fn send_telemetry(&mut self, tick: u64) -> Result<()> {
let temp = self.sensors.get_temp().unwrap_or(0.0); let temp = self.sensors.get_temp().unwrap_or(0.0);
let pwr = self.sensors.get_power_w().unwrap_or(0.0); let pwr = self.sensors.get_power_w().unwrap_or(0.0);
let freq = 0.0; let freq = self.sensors.get_freq_mhz().unwrap_or(0.0);
self.history_temp.push_back(temp); self.history_temp.push_back(temp);
self.history_watts.push_back(pwr); self.history_watts.push_back(pwr);
@@ -271,7 +272,7 @@ impl BenchmarkOrchestrator {
cpu_temp: temp, cpu_temp: temp,
power_w: pwr, power_w: pwr,
current_freq: freq, current_freq: freq,
fan_rpm: self.sensors.get_fan_rpm().unwrap_or(0), fans: self.sensors.get_fan_rpms().unwrap_or_default(),
governor: "performance".to_string(), governor: "performance".to_string(),
pl1_limit: 15.0, pl1_limit: 15.0,
pl2_limit: 25.0, pl2_limit: 25.0,

View File

@@ -10,19 +10,20 @@ use tracing::debug;
pub struct DellXps9380Sal { pub struct DellXps9380Sal {
temp_path: PathBuf, temp_path: PathBuf,
pwr_path: PathBuf, pwr_path: PathBuf,
fan_path: PathBuf, fan_paths: Vec<PathBuf>,
freq_path: PathBuf,
pl1_path: PathBuf, pl1_path: PathBuf,
pl2_path: PathBuf, pl2_path: PathBuf,
last_poll: Mutex<Instant>, last_poll: Mutex<Instant>,
last_temp: Mutex<f32>, last_temp: Mutex<f32>,
last_fan: Mutex<u32>, last_fans: Mutex<Vec<u32>>,
} }
impl DellXps9380Sal { impl DellXps9380Sal {
pub fn init() -> Result<Self> { pub fn init() -> Result<Self> {
let mut temp_path = None; let mut temp_path = None;
let mut pwr_path = None; let mut pwr_path = None;
let mut fan_path = None; let mut fan_paths = Vec::new();
let mut rapl_base_path = None; let mut rapl_base_path = None;
// Dynamic hwmon discovery // Dynamic hwmon discovery
@@ -33,7 +34,17 @@ impl DellXps9380Sal {
if name == "dell_smm" { if name == "dell_smm" {
temp_path = Some(p.join("temp1_input")); temp_path = Some(p.join("temp1_input"));
fan_path = Some(p.join("fan1_input")); // Discover all fans
if let Ok(fan_entries) = fs::read_dir(&p) {
for fan_entry in fan_entries.flatten() {
let fan_p = fan_entry.path();
if fan_p.file_name().unwrap_or_default().to_string_lossy().starts_with("fan") &&
fan_p.file_name().unwrap_or_default().to_string_lossy().ends_with("_input") {
fan_paths.push(fan_p);
}
}
}
fan_paths.sort();
} }
if name == "intel_rapl" || name == "rapl" { if name == "intel_rapl" || name == "rapl" {
@@ -59,16 +70,18 @@ impl DellXps9380Sal {
} }
let rapl_base = rapl_base_path.context("Could not find RAPL package-0 path in powercap")?; let rapl_base = rapl_base_path.context("Could not find RAPL package-0 path in powercap")?;
let freq_path = PathBuf::from("/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq");
Ok(Self { Ok(Self {
temp_path: temp_path.context("Could not find dell_smm temperature path")?, temp_path: temp_path.context("Could not find dell_smm temperature path")?,
pwr_path: pwr_path.context("Could not find RAPL power path")?, pwr_path: pwr_path.context("Could not find RAPL power path")?,
fan_path: fan_path.context("Could not find dell_smm fan path")?, fan_paths,
freq_path,
pl1_path: rapl_base.join("constraint_0_power_limit_uw"), pl1_path: rapl_base.join("constraint_0_power_limit_uw"),
pl2_path: rapl_base.join("constraint_1_power_limit_uw"), pl2_path: rapl_base.join("constraint_1_power_limit_uw"),
last_poll: Mutex::new(Instant::now() - Duration::from_secs(2)), last_poll: Mutex::new(Instant::now() - Duration::from_secs(2)),
last_temp: Mutex::new(0.0), last_temp: Mutex::new(0.0),
last_fan: Mutex::new(0), last_fans: Mutex::new(Vec::new()),
}) })
} }
} }
@@ -123,9 +136,10 @@ impl DellXps9380Guard {
impl EnvironmentGuard for DellXps9380Guard { impl EnvironmentGuard for DellXps9380Guard {
fn suppress(&mut self) -> Result<()> { fn suppress(&mut self) -> Result<()> {
let services = ["tlp", "thermald"]; let services = ["tlp", "thermald", "i8kmon"];
for s in services { for s in services {
if Command::new("systemctl").args(["is-active", "--quiet", s]).status()?.success() { if Command::new("systemctl").args(["is-active", "--quiet", s]).status()?.success() {
debug!("Suppressing service: {}", s);
Command::new("systemctl").args(["stop", s]).status()?; Command::new("systemctl").args(["stop", s]).status()?;
self.stopped_services.push(s.to_string()); self.stopped_services.push(s.to_string());
} }
@@ -179,20 +193,32 @@ impl SensorBus for DellXps9380Sal {
} }
} }
fn get_fan_rpm(&self) -> Result<u32> { fn get_fan_rpms(&self) -> Result<Vec<u32>> {
let mut last_poll = self.last_poll.lock().unwrap(); let mut last_poll = self.last_poll.lock().unwrap();
let now = Instant::now(); let now = Instant::now();
if now.duration_since(*last_poll) < Duration::from_millis(1000) { if now.duration_since(*last_poll) < Duration::from_millis(1000) {
return Ok(*self.last_fan.lock().unwrap()); return Ok(self.last_fans.lock().unwrap().clone());
} }
let s = fs::read_to_string(&self.fan_path)?; let mut fans = Vec::new();
let val = s.trim().parse::<u32>()?; for path in &self.fan_paths {
if let Ok(s) = fs::read_to_string(path) {
if let Ok(rpm) = s.trim().parse::<u32>() {
fans.push(rpm);
}
}
}
*self.last_fan.lock().unwrap() = val; *self.last_fans.lock().unwrap() = fans.clone();
*last_poll = now; *last_poll = now;
Ok(fans)
}
fn get_freq_mhz(&self) -> Result<f32> {
let s = fs::read_to_string(&self.freq_path)?;
let val = s.trim().parse::<f32>()? / 1000.0;
Ok(val) Ok(val)
} }
} }

View File

@@ -50,8 +50,11 @@ impl SensorBus for MockSensorBus {
fn get_power_w(&self) -> Result<f32> { fn get_power_w(&self) -> Result<f32> {
Ok(15.0) Ok(15.0)
} }
fn get_fan_rpm(&self) -> Result<u32> { fn get_fan_rpms(&self) -> Result<Vec<u32>> {
Ok(2500) Ok(vec![2500])
}
fn get_freq_mhz(&self) -> Result<f32> {
Ok(3200.0)
} }
} }

View File

@@ -54,10 +54,11 @@ pub trait EnvironmentGuard {
} }
/// Read-only interface for standardized metrics. /// Read-only interface for standardized metrics.
pub trait SensorBus { pub trait SensorBus: Send + Sync {
fn get_temp(&self) -> Result<f32>; fn get_temp(&self) -> Result<f32>;
fn get_power_w(&self) -> Result<f32>; fn get_power_w(&self) -> Result<f32>;
fn get_fan_rpm(&self) -> Result<u32>; fn get_fan_rpms(&self) -> Result<Vec<u32>>;
fn get_freq_mhz(&self) -> Result<f32>;
} }
impl<T: SensorBus + ?Sized> SensorBus for Arc<T> { impl<T: SensorBus + ?Sized> SensorBus for Arc<T> {
@@ -67,8 +68,11 @@ impl<T: SensorBus + ?Sized> SensorBus for Arc<T> {
fn get_power_w(&self) -> Result<f32> { fn get_power_w(&self) -> Result<f32> {
(**self).get_power_w() (**self).get_power_w()
} }
fn get_fan_rpm(&self) -> Result<u32> { fn get_fan_rpms(&self) -> Result<Vec<u32>> {
(**self).get_fan_rpm() (**self).get_fan_rpms()
}
fn get_freq_mhz(&self) -> Result<f32> {
(**self).get_freq_mhz()
} }
} }

View File

@@ -18,7 +18,7 @@ pub struct DashboardState {
impl DashboardState { impl DashboardState {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
logs: vec!["FerroTherm Initialized.".to_string()], logs: vec!["ember-tune Initialized.".to_string()],
} }
} }
@@ -58,7 +58,7 @@ pub fn draw_dashboard(
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints([ .constraints([
Constraint::Length(10), // Gauges Constraint::Length(10), // Gauges
Constraint::Length(3), // Cooling Constraint::Min(4), // Cooling (Increased for multiple fans)
Constraint::Length(3), // CPU State Constraint::Length(3), // CPU State
Constraint::Min(4), // Metadata Constraint::Min(4), // Metadata
]) ])
@@ -92,7 +92,7 @@ fn draw_header(f: &mut Frame, area: Rect, state: &TelemetryState) {
let hostname = std::env::var("HOSTNAME").unwrap_or_else(|_| "localhost".into()); 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 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 center = Span::styled(" EMBER-TUNE THERMAL BENCH ", Style::default().fg(C_LAVENDER).add_modifier(Modifier::BOLD));
let right = Span::styled(format!(" UPTIME: {} ", uptime), Style::default().fg(C_SUBTEXT)); let right = Span::styled(format!(" UPTIME: {} ", uptime), Style::default().fg(C_SUBTEXT));
let total_width = area.width; let total_width = area.width;
@@ -182,13 +182,35 @@ fn draw_cooling(f: &mut Frame, area: Rect, state: &TelemetryState) {
let inner = block.inner(area); let inner = block.inner(area);
f.render_widget(block, area); f.render_widget(block, area);
let info = Line::from(vec![ let mut lines = Vec::new();
// Line 1: Tier
lines.push(Line::from(vec![
Span::styled(" Tier: ", Style::default().fg(C_LAVENDER)), Span::styled(" Tier: ", Style::default().fg(C_LAVENDER)),
Span::styled(&state.fan_tier, Style::default().fg(C_TEAL)), 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)),
]); // Line 2+: Fans
f.render_widget(Paragraph::new(info), inner); if state.fans.is_empty() {
lines.push(Line::from(vec![
Span::styled(" Fans: ", Style::default().fg(C_LAVENDER)),
Span::styled("N/A", Style::default().fg(C_SUBTEXT)),
]));
} else if state.fans.len() == 1 {
lines.push(Line::from(vec![
Span::styled(" Fan: ", Style::default().fg(C_LAVENDER)),
Span::styled(format!("{} RPM", state.fans[0]), Style::default().fg(C_TEXT)),
]));
} else {
for (i, rpm) in state.fans.iter().enumerate() {
lines.push(Line::from(vec![
Span::styled(format!(" Fan {}: ", i + 1), Style::default().fg(C_LAVENDER)),
Span::styled(format!("{} RPM", rpm), Style::default().fg(C_TEXT)),
]));
}
}
f.render_widget(Paragraph::new(lines), inner);
} }
fn draw_cpu_state(f: &mut Frame, area: Rect, state: &TelemetryState) { fn draw_cpu_state(f: &mut Frame, area: Rect, state: &TelemetryState) {