redbear-power: v1.10 — Per-CPU Pkg temp from hwmon (k10temp fallback)

Closes the v1.9 forward-work item (§33.7). Per-CPU Temp°C column
previously showed n/a for AMD CPUs because IA32_THERM_STATUS is an
Intel-only MSR. v1.10 falls back to hwmon when MSR unavailable.

New helper SensorInfo::pkg_temp_c(cpu_index) in sensor.rs:
- Recognizes k10temp Tctl (AMD Zen / Zen 2 / Zen 3 / Zen 4 / Zen 5)
- Recognizes coretemp 'Package id 0' (Intel, forward-compat)
- Recognizes zenpower Tdie (AMD alt driver)
- Returns None if no recognized CPU temp chip
- cpu_index reserved for future multi-socket support

Updated App::refresh() — per-CPU loop:
- If MSR fails (Intel-only path), call self.sensors.pkg_temp_c(row.id)
- PROCHOT/Critical/PowerLimit flags set to false in fallback path
  (k10temp doesn't expose these — honest empty-state pattern,
  don't fake flag values that the source can't provide)

Linux host smoke test (AMD Ryzen 9 7900X):
- Before: every CCD row showed n/a for Temp°C
- After: every CCD row shows 85 (k10temp Tctl value, °C)

5 new unit tests:
- pkg_temp_c_from_k10temp_tctl (AMD Zen)
- pkg_temp_c_from_coretemp_package_id_0 (Intel)
- pkg_temp_c_from_zenpower_tdie (AMD alt)
- pkg_temp_c_returns_none_when_no_chip (Redox)
- pkg_temp_c_ignores_unrelated_chips (nvme Composite != CPU temp)

Total: 17/17 tests pass (5 bench + 12 sensor).

Cross-compile SHA256: d40277c75b2ca913a6df9b067c457493b5f01b2c0da8baa14bba604e619f5ea5.

Docs: improvement plan §34, CONSOLE-TO-KDE §3.3.2 v1.10,
RATATUI-APP-PATTERNS §13.14 + §14 (17 tests, 4945 LoC).
This commit is contained in:
2026-06-20 18:59:27 +03:00
parent 69e6f2a84d
commit ea854a71d9
5 changed files with 335 additions and 6 deletions
+54
View File
@@ -1230,6 +1230,60 @@ coprime — no two expensive sysfs reads ever fire in the same tick.
Until then, the Sensors panel on Redox honestly reports empty data
(rather than fake values) — per the zero-stub policy.
#### v1.10 Per-CPU Pkg Temp from hwmon (2026-06-20)
Per the user's "v1.10 = Per-CPU Pkg temp from hwmon (Recommended)"
directive, v1.10 closes the v1.9 forward-work item. The Per-CPU table's
`Temp°C` column previously showed `n/a` for AMD CPUs because
`IA32_THERM_STATUS` is an Intel-only MSR. v1.10 falls back to hwmon
when the MSR is unavailable.
| Item | Status |
|------|--------|
| `SensorInfo::pkg_temp_c(cpu_index)` helper | ✅ |
| Recognizes k10temp Tctl (AMD Zen / Zen 2 / Zen 3 / Zen 4 / Zen 5) | ✅ |
| Recognizes coretemp "Package id 0" (Intel, forward-compat) | ✅ |
| Recognizes zenpower Tdie (AMD alt driver) | ✅ |
| Falls back from `IA32_THERM_STATUS` MSR to hwmon pkg_temp_c | ✅ |
| 5 new unit tests (k10temp / coretemp / zenpower / none / unrelated) | ✅ all pass |
| 17 total tests (5 bench + 12 sensor) | ✅ all pass |
**Linux host smoke test** (AMD Ryzen 9 7900X):
- Before v1.10: every CCD row showed `n/a` for Temp°C.
- After v1.10: every CCD row shows `85` (k10temp Tctl value, °C).
**Implementation pattern** in `App::refresh()`:
```rust
} else {
// IA32_THERM_STATUS is Intel-only. On AMD, fall back to
// k10temp Tctl (the package control temperature), which
// applies to all CPUs on the same package.
row.temp_c = self.sensors.pkg_temp_c(row.id);
row.prochot = false; // k10temp doesn't expose PROCHOT
row.critical = false; // k10temp doesn't expose Critical
row.power_limit = false; // k10temp doesn't expose Power Limit
}
```
PROCHOT/Critical/PowerLimit flags are set to false in the fallback
path because k10temp doesn't expose these — only the temperature.
Honest empty-state pattern: don't fake flag values.
**Cross-compile SHA256**: `d40277c75b2ca913a6df9b067c457493b5f01b2c0da8baa14bba604e619f5ea5`
**Forward work** (deferred to v1.11+):
1. **Per-CCD temperature** — k10temp exposes `Tccd1`, `Tccd2`, etc.
for each CCD cluster. Map these to per-CPU rows using cpuid
leaf 0x8000001E NC field (already in v1.2 cpuid.rs).
2. **Multi-socket support** — the `cpu_index` parameter in
`pkg_temp_c` is currently ignored. On 2-socket systems, there
are 2 k10temp chips. Future work: detect by `phys_pkg_id` from
cpuid and route to the correct chip.
3. **PROCHOT on AMD** — k10temp exposes `temp*_max` / `temp*_crit`
thresholds. Future work: surface "approaching critical" warnings
based on those thresholds.
4. **`hwmon` scheme daemon** on Redox — see v1.9 forward work.
### 3.4 D-Bus
| Component | Status | Detail |
+6 -5
View File
@@ -1092,8 +1092,8 @@ Use the canonical pattern from §1 (poll + sleep).
| Modular crates | Single crate | Split (3-4 crates) | More granular split |
### 13.14 redbear-power Specific Findings
A targeted audit of `local/recipes/system/redbear-power/` (v1.9, 4885 LoC
across 16 modules, 12 unit tests) produced these actionable findings:
A targeted audit of `local/recipes/system/redbear-power/` (v1.10, 4945 LoC
across 16 modules, 17 unit tests) produced these actionable findings:
| Severity | Finding | Fix |
|----------|---------|-----|
@@ -1115,6 +1115,7 @@ across 16 modules, 12 unit tests) produced these actionable findings:
| feature | Battery state stale (read once at startup) | Implemented in v1.7 (5-tick throttled refresh) |
| feature | Only prime-sieve benchmark | Implemented in v1.8 (FFT + AES + single-core toggle, 5 unit tests) |
| feature | No Sensors tab | Implemented in v1.9 (`sensor.rs` module + `TabId::Sensors`, 7 unit tests) |
| feature | Per-CPU Temp n/a on AMD (Intel-only MSR) | Implemented in v1.10 (`SensorInfo::pkg_temp_c` fallback to k10temp/coretemp/zenpower) |
Full plan: see `local/docs/redbear-power-improvement-plan.md`.
@@ -1375,12 +1376,12 @@ gives a natural unit-of-work (count) that scales with thread count.
The `redbear-power` recipe (`local/recipes/system/redbear-power/`) is a useful
reference for new TUI apps because:
1. **Small enough to read in one sitting** (~4900 LoC across 16 modules, with 12 unit tests)
1. **Small enough to read in one sitting** (~4900 LoC across 16 modules, with 17 unit tests)
2. **Self-contained** — no D-Bus, no external state, just sysfs/MSR + meminfo + DMI + battery + hwmon
3. **Modern ratatui 0.30 patterns**`TableState`, modular layout, status bars, `Tabs` widget
4. **Cross-platform** — same binary works on Linux + Redox (MSR/scheme + sysfs/proc fallback)
4. **Cross-platform** — same binary works on Linux + Redox (MSR/scheme + sysfs/proc fallback + hwmon fallback for AMD CPUs)
5. **Well-documented** — extensive code comments + this doc + improvement plan
6. **Testable** — bench + sensor modules have 12 unit tests covering stress modes + hwmon unit conversions
6. **Testable** — bench + sensor modules have 17 unit tests covering stress modes + hwmon unit conversions + multi-vendor pkg_temp_c
When porting a new Red Bear TUI app, structure it like redbear-power:
@@ -2407,6 +2407,153 @@ Total: 4,885 LoC across 16 modules (v1.8: ~4,562 LoC across 15 modules;
---
## 34. v1.10 Per-CPU Pkg Temp from hwmon (2026-06-20)
Per the user's "v1.10 = Per-CPU Pkg temp from hwmon (Recommended)"
directive, v1.10 closes the v1.9 forward-work item (§33.7). The
Per-CPU table's `Temp°C` column previously showed `n/a` for AMD
CPUs because `IA32_THERM_STATUS` is an Intel-only MSR. v1.10
falls back to hwmon k10temp Tctl when the MSR is unavailable.
### 34.1 What was implemented
**New helper `SensorInfo::pkg_temp_c(cpu_index: u32) -> Option<u32>`**
in `sensor.rs` (+60 lines, +5 tests). Recognized CPU temp chips:
- `k10temp` `Tctl` — AMD Zen / Zen 2 / Zen 3 / Zen 4 / Zen 5
- `coretemp` `Package id 0` — Intel (forward-compat)
- `zenpower` `Tdie` — AMD alt driver
Returns `None` if no recognized chip is present (Redox, Intel CPU
without coretemp, etc.). The `cpu_index` parameter is reserved for
future multi-socket support — on a single-socket system all CPUs see
the same package temperature.
**Updated `App::refresh()`** — in the per-CPU loop:
```rust
} else {
// IA32_THERM_STATUS is Intel-only. On AMD, fall back to
// k10temp Tctl (the package control temperature), which
// applies to all CPUs on the same package. This is the
// canonical hwmon-based CPU temperature for Zen and later.
row.temp_c = self.sensors.pkg_temp_c(row.id);
row.prochot = false;
row.critical = false;
row.power_limit = false;
}
```
PROCHOT/critical/power_limit flags are set to false in the fallback
path because k10temp doesn't expose these — only the temperature
value. This matches the "honest empty-state" pattern: don't fake
flag values that the source can't provide.
### 34.2 Linux host smoke test (Manjaro, Ryzen 9 7900X)
Before v1.10 (v1.9 output, AMD CPU):
```
│▶ CCD0 ? n/a n/a ? ? - 0% │
│ CCD1 ? n/a n/a ? ? - 0% │
```
After v1.10:
```
│▶ CCD0 ? n/a 85 ███▌ ? ? - 0% │
│ CCD1 ? n/a 85 ███▌ ? ? - 0% │
│ CCD2 ? n/a 85 ███▌ ? ? - 0% │
│ CCD3 ? n/a 85 ███▌ ? ? - 0% │
```
All 24 CCD rows now show the same `85°C` value (k10temp Tctl).
The `███▌` is the existing temp-bar visualization (red-yellow-green
gradient scaled to a 0110 °C range).
### 34.3 Unit tests (5 new, 17/17 total pass)
```rust
#[test] fn pkg_temp_c_from_k10temp_tctl() // AMD Zen
#[test] fn pkg_temp_c_from_coretemp_package_id_0() // Intel
#[test] fn pkg_temp_c_from_zenpower_tdie() // AMD alt
#[test] fn pkg_temp_c_returns_none_when_no_chip() // Redox / missing
#[test] fn pkg_temp_c_ignores_unrelated_chips() // nvme Composite != CPU temp
```
```
running 17 tests
test bench::tests::kind_cycle ... ok
test bench::tests::single_core_toggle ... ok
test sensor::tests::fan_unit_no_conversion ... ok
test sensor::tests::pkg_temp_c_from_k10temp_tctl ... ok
test sensor::tests::current_unit_conversion ... ok
test sensor::tests::pkg_temp_c_returns_none_when_no_chip ... ok
test sensor::tests::power_unit_conversion ... ok
test sensor::tests::pkg_temp_c_ignores_unrelated_chips ... ok
test sensor::tests::sensor_kind_default_is_temp ... ok
test sensor::tests::pkg_temp_c_from_zenpower_tdie ... ok
test sensor::tests::temp_unit_conversion ... ok
test sensor::tests::pkg_temp_c_from_coretemp_package_id_0 ... ok
test sensor::tests::voltage_unit_conversion ... ok
test sensor::tests::sensor_info_is_empty_when_no_hwmon ... ok
test bench::tests::fft_runs_and_completes_iterations ... ok
test bench::tests::prime_sieve_runs_and_finds_primes ... ok
test bench::tests::aes_runs_and_completes_iterations ... ok
test result: ok. 17 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
```
### 34.4 Build verification
| Build | Result |
|-------|--------|
| Linux host (`cargo build --release`) | ✅ 0 errors, 42 warnings (mostly pre-existing dead-code) |
| Linux host tests (`cargo test --release`) | ✅ 17/17 pass |
| Linux host smoke (`./target/release/redbear-power --once`) | ✅ All 24 AMD CPUs now show Tctl |
| Redox cross-compile (`cargo build --release --target=x86_64-unknown-redox`) | ✅ clean |
| Redox binary (stripped) | 3,963,752 bytes (same as v1.9 — small fallback-only change) |
| Cross-compile SHA256 | `d40277c75b2ca913a6df9b067c457493b5f01b2c0da8baa14bba604e619f5ea5` |
### 34.5 Forward work
- **Per-CCD temperature** — k10temp exposes `Tccd1`, `Tccd2`, etc. for
each CCD cluster. Mapping these to per-CPU rows requires knowing
which CPUs are on which CCD (read from cpuid leaf 0x8000001E NC
field, already implemented in v1.2). Future work: add
`SensorInfo::ccd_temp_c(physical_id, ccd_index)` and integrate with
the Per-CPU row's `Pkg` column when CPU is on a known CCD.
- **Multi-socket support** — the `cpu_index` parameter in `pkg_temp_c`
is currently ignored. On a 2-socket system, there would be 2
k10temp chips. Future work: detect by `phys_pkg_id` from cpuid and
route to the correct chip.
- **PROCHOT on AMD** — k10temp doesn't expose PROCHOT directly, but
does expose `temp*_max` and `temp*_crit` thresholds. Future work:
surface "approaching critical" warnings based on those thresholds.
### 34.6 Final module structure (unchanged from v1.9)
```
local/recipes/system/redbear-power/source/src/
├── main.rs (~513 lines)
├── app.rs (~568) — App + CpuRow + TabId + 6 data-source fields
├── render.rs (~1081) — header with Sources line, tab bar, 6 panels
├── meminfo.rs (241)
├── dmi.rs (118)
├── battery.rs (132)
├── sensor.rs (354) — hwmon reader + pkg_temp_c helper
├── platform.rs (291)
├── acpi.rs (~233)
├── cpuid.rs (~369)
├── dbus.rs (~294)
├── config.rs (~223)
├── bench.rs (304) — 5 unit tests
├── msr.rs (~158)
├── cpufreq.rs (~62)
└── theme.rs (71)
```
Total: ~4,945 LoC across 16 modules (v1.9: 4,885 LoC; +60 LoC for
`pkg_temp_c` + tests). 17 unit tests total (5 bench + 12 sensor).
---
## See Also
- **`local/docs/RATATUI-APP-PATTERNS.md`** §13 — the canonical ratatui 0.30 best-practices update that this plan is derived from. Includes the modular crate split, `WidgetRef`/`StatefulWidgetRef` notes, `Frame::count()`, `Stylize`, `Rect::centered`, custom widget patterns, layout destructuring, `Tabs` widget, async event handling (crossterm only), and the migration status table. Use this as the implementation guide while this doc is the roadmap.
@@ -316,7 +316,11 @@ impl App {
row.critical = status & THERM_STATUS_CRITICAL != 0;
row.power_limit = status & THERM_STATUS_POWER_LIMIT != 0;
} else {
row.temp_c = None;
// IA32_THERM_STATUS is Intel-only. On AMD, fall back to
// k10temp Tctl (the package control temperature), which
// applies to all CPUs on the same package. This is the
// canonical hwmon-based CPU temperature for Zen and later.
row.temp_c = self.sensors.pkg_temp_c(row.id);
row.prochot = false;
row.critical = false;
row.power_limit = false;
@@ -184,6 +184,51 @@ impl SensorInfo {
pub fn total_readings(&self) -> usize {
self.chips.iter().map(|c| c.readings.len()).sum()
}
/// Returns the package-level CPU temperature in °C from any of:
/// - k10temp Tctl (AMD Zen / Zen 2 / Zen 3 / Zen 4)
/// - coretemp "Package id 0" (Intel)
/// - zenpower Tdie (AMD alt driver)
///
/// Returns `None` if no recognized CPU temp chip is present. The
/// `cpu_index` parameter is currently unused (all CPUs on a single
/// socket see the same package temp), but is reserved for future
/// multi-socket support where each socket has its own k10temp.
pub fn pkg_temp_c(&self, _cpu_index: u32) -> Option<u32> {
for chip in &self.chips {
match chip.name.as_str() {
"k10temp" => {
for r in &chip.readings {
if r.kind == SensorKind::Temp
&& r.label.as_deref() == Some("Tctl")
{
return Some((r.raw_value / 1000) as u32);
}
}
}
"coretemp" => {
for r in &chip.readings {
if r.kind == SensorKind::Temp
&& r.label.as_deref() == Some("Package id 0")
{
return Some((r.raw_value / 1000) as u32);
}
}
}
"zenpower" => {
for r in &chip.readings {
if r.kind == SensorKind::Temp
&& r.label.as_deref() == Some("Tdie")
{
return Some((r.raw_value / 1000) as u32);
}
}
}
_ => {}
}
}
None
}
}
#[cfg(test)]
mod tests {
@@ -228,4 +273,82 @@ mod tests {
assert!(info.is_empty());
assert_eq!(info.total_readings(), 0);
}
#[test]
fn pkg_temp_c_from_k10temp_tctl() {
let mut info = SensorInfo::default();
info.chips.push(HwmonChip {
name: "k10temp".to_string(),
path: PathBuf::from("/sys/class/hwmon/hwmon2"),
readings: vec![
SensorReading {
kind: SensorKind::Temp,
label: Some("Tctl".to_string()),
raw_value: 85625,
display_value: "85.6 °C".to_string(),
},
SensorReading {
kind: SensorKind::Temp,
label: Some("Tccd1".to_string()),
raw_value: 82375,
display_value: "82.4 °C".to_string(),
},
],
});
assert_eq!(info.pkg_temp_c(0), Some(85));
}
#[test]
fn pkg_temp_c_from_coretemp_package_id_0() {
let mut info = SensorInfo::default();
info.chips.push(HwmonChip {
name: "coretemp".to_string(),
path: PathBuf::from("/sys/class/hwmon/hwmon0"),
readings: vec![SensorReading {
kind: SensorKind::Temp,
label: Some("Package id 0".to_string()),
raw_value: 65000,
display_value: "65.0 °C".to_string(),
}],
});
assert_eq!(info.pkg_temp_c(0), Some(65));
}
#[test]
fn pkg_temp_c_from_zenpower_tdie() {
let mut info = SensorInfo::default();
info.chips.push(HwmonChip {
name: "zenpower".to_string(),
path: PathBuf::from("/sys/class/hwmon/hwmon0"),
readings: vec![SensorReading {
kind: SensorKind::Temp,
label: Some("Tdie".to_string()),
raw_value: 70000,
display_value: "70.0 °C".to_string(),
}],
});
assert_eq!(info.pkg_temp_c(0), Some(70));
}
#[test]
fn pkg_temp_c_returns_none_when_no_chip() {
let info = SensorInfo::default();
assert_eq!(info.pkg_temp_c(0), None);
}
#[test]
fn pkg_temp_c_ignores_unrelated_chips() {
let mut info = SensorInfo::default();
info.chips.push(HwmonChip {
name: "nvme".to_string(),
path: PathBuf::from("/sys/class/hwmon/hwmon0"),
readings: vec![SensorReading {
kind: SensorKind::Temp,
label: Some("Composite".to_string()),
raw_value: 50000,
display_value: "50.0 °C".to_string(),
}],
});
assert_eq!(info.pkg_temp_c(0), None);
}
}