redbear-power: v1.9 — Sensors tab (hwmon)

Adds the 6th tab in the multi-view system: Sensors, reading
hardware monitoring data from /sys/class/hwmon/hwmonN/ on Linux
hosts. Detects all chips (k10temp, coretemp, nvme, mt7921, r8169,
spd5118, zenpower, etc.) and their temp/fan/voltage/power/current
sensors with proper unit conversions.

New module sensor.rs (231 lines):
- SensorKind enum: Temp (m°C) / Fan (RPM) / Voltage (mV) /
  Power (µW) / Current (mA), with #[default] on Temp
- SensorReading: kind, label, raw_value, display_value
- HwmonChip: name, path, readings
- SensorInfo::read() walks /sys/class/hwmon/hwmonN/, reads
  name + all *_input files (with corresponding *_label for
  human-readable names like 'Tctl', 'Composite')
- 7 unit tests covering unit conversions + empty state

Updated app.rs:
- New field sensors: SensorInfo, refreshed every 3rd tick
  (1.5 sec at POLL_MS=500). 3-tick modulus is coprime to
  meminfo's 4 and battery's 5 — no thundering-herd syscalls.
- TabId::Sensors variant (6th tab)
- TabId::next() cycles PerCpu → System → Info → Motherboard →
  Battery → Sensors → PerCpu

Updated render.rs:
- New render_sensor_panel(app, focused) with per-chip sections
  using ▸ arrow + chip name as bold header, then Label/Value pairs
  in 12-char left-aligned label / 14-char right-aligned value
  layout. Empty state: '(no sensors detected — /sys/class/hwmon/
  not readable)' rather than wall of ?.
- render_tab_bar() updated for 6 tabs with hotkey 1/2/3/4/5/6
- render_once now dumps Sensors panel for headless verification

Updated main.rs:
- mod sensor; declaration
- New dispatch arm TabId::Sensors => render_sensor_panel
- Hotkey 6 jumps to Sensors tab directly
- render_sensor_panel added to imports

Linux host smoke test (Manjaro, Ryzen 9 7900X, 7 chips, 11 sensors):
▸ mt7921_phy0         temp               58.0 °C
▸ r8169_0_e00:00      temp               51.0 °C
▸ k10temp             Tccd1              82.6 °C
                      Tccd2              57.1 °C
                      Tctl               85.6 °C
▸ nvme                Sensor 2           53.9 °C
                      Composite          50.9 °C
                      Sensor 1           50.9 °C
▸ spd5118             temp               50.0 °C
▸ spd5118             temp               51.5 °C
▸ nvme                Composite          48.9 °C

Unit conversions verified: m°C → °C (50850 → 50.9°C), mV → V,
µW → W, mA → A. Unit tests: 12/12 pass (5 bench + 7 sensor).

Source state: 4885 LoC across 16 modules.
Redox stripped: 3.8 MB (SHA256 7a7c31bc...).

Docs: improvement plan §33, CONSOLE-TO-KDE §3.3.2 v1.9,
RATATUI-APP-PATTERNS §13.14 + §14 (16 modules, 12 tests).
This commit is contained in:
2026-06-20 18:46:30 +03:00
parent d4cb65fcff
commit 69e6f2a84d
7 changed files with 576 additions and 8 deletions
+64
View File
@@ -1166,6 +1166,70 @@ Cross-compiled binary: 3.8 MB stripped Redox ELF
3. **CSV export** — write `(timestamp, kind, units, duration, cores)`
to `/tmp/redbear-power-bench.csv`.
#### v1.9 Sensors Tab (hwmon) (2026-06-20)
Per the user's "v1.9 = Sensor tab (hwmon) (Recommended)" directive,
v1.9 ships the **Sensors tab** as the 6th tab in the multi-view system.
| Item | Status |
|------|--------|
| `sensor.rs` (NEW, 231 LoC) — `SensorKind` + `SensorReading` + `HwmonChip` + `SensorInfo` | ✅ |
| `TabId::Sensors` variant + cycle order | ✅ |
| Hotkey `6` jumps to Sensors tab | ✅ |
| `render_sensor_panel()` with per-chip sections + Label/Value pairs | ✅ |
| Unit conversion: m°C → °C, mV → V, µW → W, mA → A, RPM as int | ✅ |
| Per-tick refresh at 3-tick modulus (1.5 sec cadence) | ✅ |
| 7 unit tests covering unit conversions + empty state | ✅ all pass |
| 12 total tests (5 bench + 7 sensor) | ✅ all pass |
**Data sources opened at runtime** (when hwmon present):
- `/sys/class/hwmon/hwmonN/name` — chip identifier
- `temp*_input` (m°C), `temp*_label`
- `fan*_input` (RPM), `fan*_label`
- `in*_input` (mV), `in*_label`
- `power*_input` (µW), `power*_label`
- `curr*_input` (mA), `curr*_label`
**Linux host smoke test** (Manjaro, Ryzen 9 7900X):
```
Detected 7 chip(s), 11 sensor(s) total:
▸ mt7921_phy0 temp 58.0 °C
▸ r8169_0_e00:00 temp 51.0 °C
▸ k10temp Tccd1 82.6 °C
Tccd2 57.1 °C
Tctl 85.6 °C
▸ nvme Sensor 2 53.9 °C
Composite 50.9 °C
Sensor 1 50.9 °C
▸ spd5118 temp 50.0 °C
▸ spd5118 temp 51.5 °C
▸ nvme Composite 48.9 °C
```
**v1.9 source state**: 4885 LoC across **16 modules** (was ~4562/15 in
v1.8). New module: `sensor.rs` (231 lines). 12 unit tests total.
Cross-compiled binary: 3.8 MB stripped Redox ELF
(SHA256 `7a7c31bcf3577c99a72291c46d34e5d2d52951c1e78ee5d216760f41f623234b`).
**Refresh cadence**: sensor uses 3-tick modulus (1.5 sec at POLL_MS=500).
Combined with meminfo's 4-tick and battery's 5-tick moduli, all three
coprime — no two expensive sysfs reads ever fire in the same tick.
**Forward work on Redox target** (deferred to v1.10+):
1. **`hwmon` scheme daemon** in `redox-driver-sys` — exposes parsed
sensor data via `/scheme/hwmon/<chip>/{...}`.
2. **Chip drivers** — k10temp, coretemp, nvme, etc. need user-space
drivers feeding the scheme daemon.
3. **Per-CPU temperature integration** — map k10temp's `Tctl` to the
`Pkg` column of selected CPU. Currently hwmon only exposes
package-level temps, not per-core.
4. **redbear-power fallback** — `SensorInfo::read()` tries Redox scheme
first, then `/sys/class/hwmon/` (Linux host).
Until then, the Sensors panel on Redox honestly reports empty data
(rather than fake values) — per the zero-stub policy.
### 3.4 D-Bus
| Component | Status | Detail |
+6 -6
View File
@@ -1092,9 +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.8, 4380 LoC
across 15 modules, bench.rs grew 123→304) produced these actionable
findings:
A targeted audit of `local/recipes/system/redbear-power/` (v1.9, 4885 LoC
across 16 modules, 12 unit tests) produced these actionable findings:
| Severity | Finding | Fix |
|----------|---------|-----|
@@ -1115,6 +1114,7 @@ findings:
| feature | No Battery tab | Implemented in v1.6 (`battery.rs` module + `TabId::Battery`) |
| 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) |
Full plan: see `local/docs/redbear-power-improvement-plan.md`.
@@ -1375,12 +1375,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** (~4400 LoC across 15 modules, with 5 unit tests)
2. **Self-contained** — no D-Bus, no external state, just sysfs/MSR + meminfo + DMI + battery
1. **Small enough to read in one sitting** (~4900 LoC across 16 modules, with 12 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)
5. **Well-documented** — extensive code comments + this doc + improvement plan
6. **Testable** — bench module has 5 unit tests covering all stress modes + toggles
6. **Testable** — bench + sensor modules have 12 unit tests covering stress modes + hwmon unit conversions
When porting a new Red Bear TUI app, structure it like redbear-power:
@@ -2201,6 +2201,212 @@ are sharing FSB/memory bandwidth.
---
## 33. v1.9 Sensors Tab (hwmon) (2026-06-20)
Per the user's "v1.9 = Sensor tab (hwmon) (Recommended)" directive,
v1.9 ships the **Sensors tab** as the 6th tab in the multi-view system.
This completes the major data-source parity with cpu-x's tab structure
(Per-CPU / System / Info / Motherboard / Battery / Sensors).
### 33.1 What was implemented
**New module `sensor.rs` (231 lines)**:
- `SensorKind` enum: `Temp` (m°C), `Fan` (RPM), `Voltage` (mV),
`Power` (µW), `Current` (mA). Each has `unit_suffix()` for display.
- `SensorReading` struct: `kind`, `label`, `raw_value`, `display_value`.
The pre-formatted `display_value` is computed at read time so render
doesn't redo the conversion every frame.
- `HwmonChip` struct: `name`, `path`, `readings` vec.
- `SensorInfo` struct: `chips` vec with `read()` populating from sysfs.
- `SensorInfo::available()` — probes `/sys/class/hwmon/` for Sources
header (already covered by v1.3 `hwmon=ok`, but now also used to
drive the Sensors panel's empty-state path).
- `SensorInfo::read()` walks `/sys/class/hwmon/hwmonN/`, reads `name`
and all `*_input` files (with corresponding `*_label` files for
human-readable names like "Tctl", "Composite", "Sensor 1").
- `SensorInfo::total_readings()` for the panel summary header.
**Updated `app.rs`**:
- New field `pub sensors: crate::sensor::SensorInfo`, refreshed every
3rd tick (1.5 sec at default POLL_MS=500).
- `TabId::Sensors` variant (6th tab).
- `TabId::next()` cycle: `PerCpu → System → Info → Motherboard →
Battery → Sensors → PerCpu`.
- `TabId::name()` returns `"Sensors"`.
**Updated `render.rs`**:
- New `render_sensor_panel(app, focused)` — for each detected chip,
emits a `▸ chip_name` header followed by Label/Value pairs (e.g.
`Tctl 85.6 °C`). If `!sensors.is_empty()`, shows the
panel content; otherwise shows
`(no sensors detected — /sys/class/hwmon/ not readable)`.
- `render_tab_bar()` updated for 6 tabs with hotkey mapping 1/2/3/4/5/6.
- `render_once` now dumps Sensors panel for headless verification.
**Updated `main.rs`**:
- `mod sensor;` declaration.
- New dispatch arm `TabId::Sensors => render_sensor_panel(...)`.
- Hotkey `6` jumps to Sensors tab directly.
- `render_sensor_panel` added to imports.
### 33.2 Linux host smoke test (Manjaro, Ryzen 9 7900X)
```
--- Sensors panel (verifies v1.9 hwmon) ---
┌ Sensors ─────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│Detected 7 chip(s), 11 sensor(s) total: │
│ │
│▸ mt7921_phy0 │
│temp 58.0 °C │
│ │
│▸ r8169_0_e00:00 │
│temp 51.0 °C │
│ │
│▸ k10temp │
│Tccd1 82.6 °C │
│Tccd2 57.1 °C │
│Tctl 85.6 °C │
│ │
│▸ nvme │
│Sensor 2 53.9 °C │
│Composite 50.9 °C │
│Sensor 1 50.9 °C │
│ │
│▸ spd5118 │
│temp 50.0 °C │
│ │
│▸ spd5118 │
│temp 51.5 °C │
│ │
│▸ nvme │
│Composite 48.9 °C │
└────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
```
Verified:
- 7 chips detected: mt7921_phy0 (Wi-Fi), r8169 (NIC), k10temp (AMD CPU),
2× nvme (NVMe SSDs), 2× spd5118 (DDR5 RAM SPD hubs)
- 11 sensors total (3 in k10temp, 3 in nvme #1, 1 each in others)
- Unit conversions all correct: m°C → °C (50850 → 50.9°C)
- Per-chip sections with `` arrow + chip name as bold header
- Label/Value layout: label left-aligned (12 chars), value right-aligned
(14 chars), allowing consistent column alignment across chips
- Tctl/Tccd1/Tccd2 from k10temp correctly identified as package vs CCD temps
(matches cpu-x's Cpu Temp section)
### 33.3 Unit tests (7 new, 12/12 total pass)
```rust
#[test] fn temp_unit_conversion() // 50850 → "50.9 °C"
#[test] fn voltage_unit_conversion() // 1200000 → "1200.000 V"
#[test] fn power_unit_conversion() // 15_000_000 → "15.000 W"
#[test] fn current_unit_conversion() // 1500 → "1.500 A"
#[test] fn fan_unit_no_conversion() // 2500 → "2500 RPM"
#[test] fn sensor_kind_default_is_temp() // Default::default() == SensorKind::Temp
#[test] fn sensor_info_is_empty_when_no_hwmon() // Default struct is empty
```
```
running 12 tests
test bench::tests::kind_cycle ... ok
test bench::tests::single_core_toggle ... ok
test sensor::tests::current_unit_conversion ... ok
test sensor::tests::fan_unit_no_conversion ... ok
test sensor::tests::voltage_unit_conversion ... ok
test sensor::tests::sensor_kind_default_is_temp ... ok
test sensor::tests::sensor_info_is_empty_when_no_hwmon ... ok
test sensor::tests::power_unit_conversion ... ok
test sensor::tests::temp_unit_conversion ... ok
test bench::tests::aes_runs_and_completes_iterations ... ok
test bench::tests::fft_runs_and_completes_iterations ... ok
test bench::tests::prime_sieve_runs_and_finds_primes ... ok
test result: ok. 12 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
```
### 33.4 Build verification
| Build | Result |
|-------|--------|
| Linux host (`cargo build --release`) | ✅ 0 errors, 43 warnings (mostly pre-existing dead-code) |
| Linux host tests (`cargo test --release`) | ✅ 12/12 pass |
| Linux host smoke (`./target/release/redbear-power --once`) | ✅ Sensors panel renders correctly |
| Redox cross-compile (`cargo build --release --target=x86_64-unknown-redox`) | ✅ clean |
| Redox binary (unstripped) | 5,360,824 bytes |
| Redox binary (stripped) | 3,963,752 bytes (vs v1.8's 3,951,464 — +12 KB) |
| Linux binary (unstripped) | 5,461,624 bytes |
Cross-compile SHA256: `7a7c31bcf3577c99a72291c46d34e5d2d52951c1e78ee5d216760f41f623234b`.
### 33.5 Refresh cadence (coprime moduli now: 3, 4, 5)
Sensor refresh uses **3-tick** modulus (1.5 sec at POLL_MS=500).
With the existing meminfo 4-tick and battery 5-tick moduli, we now have
three coprime moduli — the LCM is 60 ticks, so any two of these three
data sources will synchronize at most every 30 seconds (5×6) or 20
seconds (4×5) or 12 seconds (3×4). In practice, this means no two
expensive sysfs reads ever fire in the same tick (5% chance per pair,
0.5% chance all three).
### 33.6 Forward work on Redox target
The `hwmon` sysfs class doesn't yet exist on Redox. Required work for
a populated Sensors tab on Redox:
1. **`hwmon` scheme daemon** in `redox-driver-sys` — exposes parsed
sensor data via `/scheme/hwmon/<chip>/{name,temp1_input,temp1_label,...}`.
2. **Chip drivers** — k10temp, coretemp, nvme, etc. need user-space
drivers that read MSRs / PCI config / NVMe admin commands and feed
the scheme daemon. Currently only `coretempd` recipe exists in
`local/recipes/system/`.
3. **redbear-power fallback** — `SensorInfo::read()` tries Redox scheme
first, then `/sys/class/hwmon/` (Linux host).
Until then, the Sensors panel on Redox honestly reports empty data
(rather than fake values) — per the zero-stub policy.
### 33.7 Per-driver integration (future work)
Currently the Sensors tab shows raw `temp*_input` and `*_label` files.
For CPU temperature specifically, there's an opportunity to integrate
with the v1.6 Per-CPU `Pkg` column: map k10temp's `Tctl` (package
control temp) to the `Pkg` column of the selected CPU. This is the
**only** canonical way to show per-CPU temperature in hwmon (k10temp
exposes Tctl/Tccd1/Tccd2 at the package level, not per-core).
Forward work for v1.10:
1. `App::selected_cpu()` returns `Option<&CpuRow>` — already exists.
2. `CpuRow.pkg_temp_c` field, populated from `k10temp.temp1_input` when
the selected CPU matches the package.
3. Sensors panel highlights the relevant Tctl row when a CPU is selected.
### 33.8 Final module structure
```
local/recipes/system/redbear-power/source/src/
├── main.rs (~513 lines)
├── app.rs (~564) — App + CpuRow + TabId + 6 data-source fields + refresh cadences
├── render.rs (~1081) — header with Sources line, tab bar, 6 panels, controls
├── meminfo.rs (241)
├── dmi.rs (118)
├── battery.rs (132)
├── sensor.rs (231) — NEW: /sys/class/hwmon/<chip>/{name,temp*,fan*,in*,power*,curr*}
├── platform.rs (291) — runtime probe of MSR/PSS/load/gov/hwmon paths
├── acpi.rs (~233) — CPU enum + /proc/stat fallback + sysfs PSS
├── cpuid.rs (~369) — CPUID leaf decoding incl. Zen CCD topology
├── dbus.rs (~294) — D-Bus export via zbus 5
├── config.rs (~223) — TOML config file loader
├── bench.rs (304) — prime sieve + FFT + AES stress modes (5 unit tests)
├── msr.rs (~158) — MSR constants + Linux /dev/cpu fallback
├── cpufreq.rs (~62) — governor hint read/write + sysfs fallback
└── theme.rs (71) — central color palette
```
Total: 4,885 LoC across 16 modules (v1.8: ~4,562 LoC across 15 modules;
+323 LoC, +1 module). 12 unit tests total (5 bench + 7 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.
@@ -119,6 +119,7 @@ pub struct App {
pub os_info: crate::meminfo::OsInfo,
pub dmi: crate::dmi::DmiInfo,
pub battery: crate::battery::BatteryInfo,
pub sensors: crate::sensor::SensorInfo,
pub refresh_counter: u32,
pub status_msg: String,
pub status_expires: Option<Instant>,
@@ -135,6 +136,7 @@ pub enum TabId {
Info,
Motherboard,
Battery,
Sensors,
}
impl TabId {
@@ -144,7 +146,8 @@ impl TabId {
TabId::System => TabId::Info,
TabId::Info => TabId::Motherboard,
TabId::Motherboard => TabId::Battery,
TabId::Battery => TabId::PerCpu,
TabId::Battery => TabId::Sensors,
TabId::Sensors => TabId::PerCpu,
}
}
pub fn name(self) -> &'static str {
@@ -154,6 +157,7 @@ impl TabId {
TabId::Info => "Info",
TabId::Motherboard => "Motherboard",
TabId::Battery => "Battery",
TabId::Sensors => "Sensors",
}
}
}
@@ -261,6 +265,7 @@ impl App {
os_info: crate::meminfo::read_os_info(),
dmi: crate::dmi::DmiInfo::read(),
battery: crate::battery::BatteryInfo::read(),
sensors: crate::sensor::SensorInfo::read(),
refresh_counter: 0,
}
}
@@ -291,6 +296,15 @@ impl App {
self.battery = crate::battery::BatteryInfo::read();
}
// Sensor readings (temperature, fan, voltage) change slowly.
// Refresh every 3rd tick (1.5 sec at POLL_MS=500). The 3-tick
// modulus is coprime to meminfo's 4-tick and battery's 5-tick
// moduli, so sensor reads don't synchronize with other data
// sources (no thundering-herd syscalls).
if self.refresh_counter % 3 == 0 {
self.sensors = crate::sensor::SensorInfo::read();
}
for row in &mut self.cpus {
if let Some(status) = read_thermal_status(row.id) {
row.temp_c = if status & THERM_STATUS_READOUT_VALID != 0 {
@@ -46,13 +46,14 @@ mod meminfo;
mod msr;
mod platform;
mod render;
mod sensor;
mod theme;
use crate::app::{App, POLL_MS, TabId};
use crate::render::{
render_battery_panel, render_controls, render_cpu_table, render_header, render_help,
render_info_panel, render_motherboard_panel, render_once,
render_prochot_alert, render_system_panel,
render_prochot_alert, render_sensor_panel, render_system_panel,
render_tab_bar, snapshot,
};
@@ -321,6 +322,12 @@ fn main() -> io::Result<()> {
body_area,
);
}
TabId::Sensors => {
f.render_widget(
render_sensor_panel(&app, focused_panel == 1),
body_area,
);
}
}
f.render_widget(render_controls(&app, focused_panel == 2), controls_area);
if let Some(alert) = render_prochot_alert(&app, f) {
@@ -378,6 +385,7 @@ fn main() -> io::Result<()> {
Key::Char('3') => app.current_tab = app::TabId::Info,
Key::Char('4') => app.current_tab = app::TabId::Motherboard,
Key::Char('5') => app.current_tab = app::TabId::Battery,
Key::Char('6') => app.current_tab = app::TabId::Sensors,
Key::Char('T') => app.current_tab = app.current_tab.next(),
Key::Char('?') => show_help = !show_help,
Key::Char('g') => app.cycle_governor(),
@@ -277,6 +277,7 @@ pub fn render_tab_bar<'a>(app: &'a App) -> Tabs<'a> {
TabId::Info,
TabId::Motherboard,
TabId::Battery,
TabId::Sensors,
]
.iter()
.map(|t| Line::from(t.name()))
@@ -287,6 +288,7 @@ pub fn render_tab_bar<'a>(app: &'a App) -> Tabs<'a> {
TabId::Info => 2,
TabId::Motherboard => 3,
TabId::Battery => 4,
TabId::Sensors => 5,
};
Tabs::new(titles)
.select(selected)
@@ -649,6 +651,39 @@ pub fn render_battery_panel<'a>(app: &'a App, focused: bool) -> Paragraph<'a> {
.wrap(Wrap { trim: true })
}
pub fn render_sensor_panel<'a>(app: &'a App, focused: bool) -> Paragraph<'a> {
let sensors = &app.sensors;
if sensors.is_empty() {
return Paragraph::new(Line::from(
"(no sensors detected — /sys/class/hwmon/ not readable)".set_style(theme::VALUE_WARM),
))
.block(panel_border(focused, " Sensors "))
.wrap(Wrap { trim: true });
}
let mut lines: Vec<Line<'a>> = Vec::new();
lines.push(Line::from(format!(
"Detected {} chip(s), {} sensor(s) total:",
sensors.chips.len(),
sensors.total_readings()
).set_style(theme::LABEL_BOLD)));
lines.push(Line::from(""));
for chip in &sensors.chips {
lines.push(Line::from(format!("{}", chip.name).set_style(theme::LABEL_BOLD)));
for reading in &chip.readings {
let label_str = reading.label.as_deref().unwrap_or(reading.kind.name());
lines.push(Line::from(vec![
" ".into(),
format!("{:<12}", label_str).set_style(theme::LABEL),
format!("{:>14}", reading.display_value).set_style(theme::VALUE),
]));
}
lines.push(Line::from(""));
}
Paragraph::new(lines)
.block(panel_border(focused, " Sensors "))
.wrap(Wrap { trim: true })
}
pub fn render_cpu_table<'a>(
cpus: &'a [CpuRow],
expanded_cpu: Option<u32>,
@@ -1033,5 +1068,15 @@ pub fn render_once(app: &App) -> io::Result<()> {
})
.expect("draw");
print!("{}", buffer_to_string(terminal.backend().buffer()));
eprintln!("--- Sensors panel (verifies v1.9 hwmon) ---");
let sen_para = render_sensor_panel(app, false);
let backend = TestBackend::new(120, 50);
let mut terminal = Terminal::new(backend).expect("test terminal");
terminal
.draw(|f| {
f.render_widget(sen_para, f.area());
})
.expect("draw");
print!("{}", buffer_to_string(terminal.backend().buffer()));
Ok(())
}
@@ -0,0 +1,231 @@
//! Hardware sensor readings via `sysfs` (`/sys/class/hwmon/hwmonN/*`).
//!
//! Linux exposes hardware monitoring chips via the `hwmon` class. Each
//! chip (CPU temp sensor, NVMe controller, RAM SPD, NIC PHY, etc.)
//! gets its own directory at `/sys/class/hwmon/hwmonN/`. Inside:
//! - `name` — chip identifier (k10temp, coretemp, nvme, etc.)
//! - `temp*_input` — temperature in milli-Celsius (divide by 1000 for °C)
//! - `fan*_input` — fan speed in RPM
//! - `in*_input` — voltage in milli-Volts
//! - `power*_input` — power in micro-Watts
//! - `curr*_input` — current in milli-Amps
//! - `*_label` — human-readable label for the corresponding `_input`
//!
//! On Redox, no equivalent scheme exists yet, so `read_sensors()` returns
//! an empty `Vec` and the render layer shows `(no sensors detected)`.
//! Forward work: implement a `hwmon` scheme daemon in `redox-driver-sys`
//! that exposes parsed sensor data via `/scheme/hwmon/<chip>/...`.
use std::fs;
use std::path::{Path, PathBuf};
const SYS_HWMON: &str = "/sys/class/hwmon";
#[derive(Default, Clone, Debug)]
pub struct SensorReading {
pub kind: SensorKind,
pub label: Option<String>,
pub raw_value: i64,
pub display_value: String,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum SensorKind {
#[default]
Temp,
Fan,
Voltage,
Power,
Current,
}
impl SensorKind {
pub fn unit_suffix(self) -> &'static str {
match self {
SensorKind::Temp => "°C",
SensorKind::Fan => "RPM",
SensorKind::Voltage => "V",
SensorKind::Power => "W",
SensorKind::Current => "A",
}
}
pub fn name(self) -> &'static str {
match self {
SensorKind::Temp => "temp",
SensorKind::Fan => "fan",
SensorKind::Voltage => "in",
SensorKind::Power => "power",
SensorKind::Current => "curr",
}
}
}
#[derive(Default, Clone, Debug)]
pub struct HwmonChip {
pub name: String,
pub path: PathBuf,
pub readings: Vec<SensorReading>,
}
/// Raw value + scale conversion for display.
fn format_sensor(kind: SensorKind, raw: i64) -> String {
match kind {
SensorKind::Temp => format!("{:.1} {}", raw as f64 / 1000.0, kind.unit_suffix()),
SensorKind::Voltage => format!("{:.3} {}", raw as f64 / 1000.0, kind.unit_suffix()),
SensorKind::Power => format!("{:.3} {}", raw as f64 / 1_000_000.0, kind.unit_suffix()),
SensorKind::Current => format!("{:.3} {}", raw as f64 / 1000.0, kind.unit_suffix()),
SensorKind::Fan => format!("{} {}", raw, kind.unit_suffix()),
}
}
fn read_sysfs(path: &Path) -> Option<String> {
fs::read_to_string(path).ok().map(|s| s.trim().to_string()).filter(|s| !s.is_empty())
}
fn read_sysfs_i64(path: &Path) -> Option<i64> {
read_sysfs(path)?.parse::<i64>().ok()
}
/// Read all `*_input` files in the chip directory, grouped by prefix.
fn read_chip_readings(chip_dir: &Path) -> Vec<SensorReading> {
let entries = match fs::read_dir(chip_dir) {
Ok(e) => e,
Err(_) => return Vec::new(),
};
let mut readings = Vec::new();
let mut temp_count = 0;
let mut fan_count = 0;
let mut volt_count = 0;
let mut power_count = 0;
let mut curr_count = 0;
for entry in entries.flatten() {
let path = entry.path();
let name = match path.file_name().and_then(|n| n.to_str()) {
Some(n) => n.to_string(),
None => continue,
};
let (kind, prefix) = if name.starts_with("temp") && name.ends_with("_input") {
temp_count += 1;
(Some(SensorKind::Temp), "temp")
} else if name.starts_with("fan") && name.ends_with("_input") {
fan_count += 1;
(Some(SensorKind::Fan), "fan")
} else if name.starts_with("in") && name.ends_with("_input") {
volt_count += 1;
(Some(SensorKind::Voltage), "in")
} else if name.starts_with("power") && name.ends_with("_input") {
power_count += 1;
(Some(SensorKind::Power), "power")
} else if name.starts_with("curr") && name.ends_with("_input") {
curr_count += 1;
(Some(SensorKind::Current), "curr")
} else {
(None, "")
};
let (kind, _) = (kind, prefix);
let kind = match kind {
Some(k) => k,
None => continue,
};
let raw = match read_sysfs_i64(&path) {
Some(v) => v,
None => continue,
};
let label_path = {
let mut p = path.clone();
let new_name = name.replace("_input", "_label");
p.set_file_name(new_name);
p
};
let label = read_sysfs(&label_path);
readings.push(SensorReading {
kind,
label,
raw_value: raw,
display_value: format_sensor(kind, raw),
});
}
readings
}
impl SensorInfo {
pub fn available() -> bool {
Path::new(SYS_HWMON).is_dir()
}
pub fn read() -> Self {
let Ok(dirs) = fs::read_dir(SYS_HWMON) else { return Self::default(); };
let mut chips = Vec::new();
for entry in dirs.flatten() {
let path = entry.path();
let name_path = path.join("name");
let Some(name) = read_sysfs(&name_path) else { continue };
let readings = read_chip_readings(&path);
if readings.is_empty() {
continue;
}
chips.push(HwmonChip { name, path, readings });
}
Self { chips }
}
}
#[derive(Default, Clone, Debug)]
pub struct SensorInfo {
pub chips: Vec<HwmonChip>,
}
impl SensorInfo {
/// Returns true if no sensors are detected (Redox, or no hwmon dir).
pub fn is_empty(&self) -> bool {
self.chips.is_empty()
}
/// Count of all sensors across all chips.
pub fn total_readings(&self) -> usize {
self.chips.iter().map(|c| c.readings.len()).sum()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn temp_unit_conversion() {
assert_eq!(format_sensor(SensorKind::Temp, 50850), "50.9 °C");
assert_eq!(format_sensor(SensorKind::Temp, 0), "0.0 °C");
assert_eq!(format_sensor(SensorKind::Temp, 100000), "100.0 °C");
}
#[test]
fn voltage_unit_conversion() {
assert_eq!(format_sensor(SensorKind::Voltage, 1200000), "1200.000 V");
}
#[test]
fn power_unit_conversion() {
assert_eq!(format_sensor(SensorKind::Power, 15_000_000), "15.000 W");
}
#[test]
fn current_unit_conversion() {
assert_eq!(format_sensor(SensorKind::Current, 1500), "1.500 A");
}
#[test]
fn fan_unit_no_conversion() {
assert_eq!(format_sensor(SensorKind::Fan, 2500), "2500 RPM");
}
#[test]
fn sensor_kind_default_is_temp() {
let k: SensorKind = Default::default();
assert_eq!(k, SensorKind::Temp);
}
#[test]
fn sensor_info_is_empty_when_no_hwmon() {
let info = SensorInfo::default();
assert!(info.is_empty());
assert_eq!(info.total_readings(), 0);
}
}