redbear-power: v1.20 — SMART data module (graceful when smartctl missing)

Adds the smart.rs module for disk health monitoring. Since
smartctl is not installed on most systems (this dev host has it
absent), v1.20 implements the module with three-tier graceful
degradation per the zero-stub policy.

New module smart.rs (222 lines, 7 unit tests):
- SmartInfo struct with available + per-disk health records
- SmartHealth struct with passed + attributes + error
- SmartAttribute struct with id + name + value + worst + threshold + raw
- SmartInfo::smartctl_available() — checks smartctl --version
- SmartInfo::read(disks) — orchestrates per-disk smartctl -A -H
- parse_smartctl_output(text) — extracts passed/failed + attrs
- parse_attribute_line(line) — single 10-field SMART attribute
- parse_smart_value(s) — handles both hex (0x33) and decimal
- health_for(disk_name) — convenience accessor

Three-tier graceful degradation:
1. smartctl missing → available=false, disks=[]
2. smartctl errors per disk → error captured in SmartHealth
3. NVMe permission issues → error message, no fabrication

Updated main.rs: mod smart declaration.

76/76 tests pass (5 bench + 12 sensor + 13 network + 12 storage +
20 process + 7 pid_detail + 7 smart).

Linux host smoke test (this dev host without smartctl):
- available=false (graceful, no panic)
- Storage tab still works (no regression)

Cross-compile SHA256 unchanged from v1.19 (smart.rs is dead code
on Redox — compiles but never called).

Docs: improvement plan §44, CONSOLE-TO-KDE §3.3.2 v1.20,
RATATUI-APP-PATTERNS §13.14 + §14 (6360 LoC, 21 modules, 76 tests).
This commit is contained in:
2026-06-20 22:39:27 +03:00
parent 006c59b88c
commit f5311f16c8
5 changed files with 410 additions and 6 deletions
+49
View File
@@ -1653,6 +1653,55 @@ Cross-compiled binary: 3.9 MB stripped Redox ELF
2. **Regex filter** — replace substring match with regex.
3. **Detail panel navigation** — j/k or Tab to switch sections.
#### v1.20 SMART Data Module (2026-06-20)
Per the user's "v1.20 = SMART data (Recommended)" directive, v1.20
adds the **smart.rs module** for disk health monitoring. Since
`smartctl` is not installed on most systems (this dev host has it
absent), v1.20 implements the module with **graceful degradation**
per the zero-stub policy.
| Item | Status |
|------|--------|
| `smart.rs` (NEW, 200+ LoC) — SmartInfo + SmartHealth + SmartAttribute | ✅ |
| `SmartInfo::smartctl_available()` — checks `smartctl --version` | ✅ |
| `SmartInfo::read(disks)` — orchestrates per-disk calls | ✅ |
| `parse_smartctl_output()` — extracts passed/failed + attributes | ✅ |
| `parse_attribute_line()` — 10-field SMART attribute line | ✅ |
| `parse_smart_value()` — handles both hex (`0x33`) and decimal | ✅ |
| Three-tier graceful degradation (missing / per-disk error / permission) | ✅ |
| 7 new unit tests (parse + integration + missing binary + missing disk) | ✅ all pass |
| 76 total tests (5 bench + 12 sensor + 13 network + 12 storage + 20 process + 7 pid_detail + 7 smart) | ✅ all pass |
**Three-tier graceful degradation** (zero-stub policy):
1. `smartctl` missing → `available = false`, `disks = []`.
UI shows "(SMART unavailable: install smartmontools)".
2. `smartctl` errors on a disk → that disk's `error: Some(stderr)`,
`attributes: []`, `passed: false`. Other disks still try.
3. NVMe disks may need `sudo smartctl -A`; no permission → `error`
says so. No fabrication of data.
**Linux host smoke test** (this dev host):
- `smartctl --version` fails → `available = false`
- `SmartInfo::read()` returns empty (graceful, no panic)
- Storage tab still works (no regression from v1.12)
On a host WITH smartctl (`apt install smartmontools`):
- `available = true`, `disks` populated per `/dev/<disk>`
- Future work: render SMART health section in Storage tab
**v1.20 source state**: ~6360 LoC across **21 modules** (was ~6160/20
in v1.19). New module: `smart.rs`. 76 unit tests total.
Cross-compiled binary: 3.9 MB stripped Redox ELF
(SHA256 `e34a22ed518b2e918bf8fb07eec77d8c5e2e2389a01ad00dad0d76f5c09578a4`).
**Forward work** (deferred to v1.21+):
1. **Storage tab integration** — display SMART health per disk.
2. **JSON parsing** — `smartctl --json` (requires serde_json).
3. **Per-attribute table** — render all SMART attrs as sub-panel.
4. **Temperature from SMART** — link to Sensors panel.
### 3.4 D-Bus
| Component | Status | Detail |
+7 -6
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.19, 6160 LoC
across 20 modules, 69 unit tests) produced these actionable findings:
A targeted audit of `local/recipes/system/redbear-power/` (v1.20, 6360 LoC
across 21 modules, 76 unit tests) produced these actionable findings:
| Severity | Finding | Fix |
|----------|---------|-----|
@@ -1125,6 +1125,7 @@ across 20 modules, 69 unit tests) produced these actionable findings:
| feature | No sort modes in Process tab | Implemented in v1.17 (`SortMode` enum + 6 unit tests, hotkey `o`) |
| feature | No process filtering | Implemented in v1.18 (`App.process_filter` + hotkey `f` + 4 unit tests) |
| feature | No PID detail view | Implemented in v1.19 (`pid_detail.rs` module + Enter/Esc handling + 7 unit tests) |
| feature | No SMART disk health data | Implemented in v1.20 (`smart.rs` module + smartctl subprocess + 7 unit tests) |
Full plan: see `local/docs/redbear-power-improvement-plan.md`.
@@ -1385,12 +1386,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** (~6160 LoC across 20 modules, with 69 unit tests)
2. **Self-contained** — no D-Bus, no external state, just sysfs/MSR/procfs + meminfo + DMI + battery + hwmon + net + storage + proc + pid_detail
1. **Small enough to read in one sitting** (~6360 LoC across 21 modules, with 76 unit tests)
2. **Self-contained** — no D-Bus, no external state, just sysfs/MSR/procfs + meminfo + DMI + battery + hwmon + net + storage + proc + pid_detail + smart
3. **Modern ratatui 0.30 patterns**`TableState`, modular layout, status bars, `Tabs` widget, modal popups (`Clear` + centered `Rect`)
4. **Cross-platform** — same binary works on Linux + Redox (MSR/scheme + sysfs/proc fallback + hwmon fallback for AMD CPUs + net/sysfs fallback + storage/sysfs fallback + procfs fallback + /proc/[pid]/* parsers)
4. **Cross-platform** — same binary works on Linux + Redox (MSR/scheme + sysfs/proc fallback + hwmon fallback for AMD CPUs + net/sysfs fallback + storage/sysfs fallback + procfs fallback + /proc/[pid]/* parsers + smartctl subprocess with graceful missing-binary degradation)
5. **Well-documented** — extensive code comments + this doc + improvement plan
6. **Testable** — bench + sensor + network + storage + process + pid_detail modules have 69 unit tests covering stress modes + hwmon unit conversions + multi-vendor pkg_temp_c + binary byte formatting + disk stat parsing + delta math + /proc/[pid]/stat parser with space-handling + CPU% delta math + disk throughput delta math + network throughput delta math + sort mode comparisons + process filter matching + /proc/[pid]/{status,io,smaps_rollup} parsers
6. **Testable** — bench + sensor + network + storage + process + pid_detail + smart modules have 76 unit tests covering stress modes + hwmon unit conversions + multi-vendor pkg_temp_c + binary byte formatting + disk stat parsing + delta math + /proc/[pid]/stat parser with space-handling + CPU% delta math + disk throughput delta math + network throughput delta math + sort mode comparisons + process filter matching + /proc/[pid]/{status,io,smaps_rollup} parsers + smartctl attribute parsing
When porting a new Red Bear TUI app, structure it like redbear-power:
@@ -3804,6 +3804,137 @@ test result: ok. 69 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
---
## 44. v1.20 SMART Data Module (2026-06-20)
Per the user's "v1.20 = SMART data (Recommended)" directive, v1.20
adds the **smart.rs module** for disk health monitoring. However,
since `smartctl` is not installed on most systems (the host running
this development has it absent), v1.20 implements the module with
graceful degradation following the zero-stub policy.
### 44.1 What was implemented
**New module `smart.rs` (200+ lines, 7 unit tests)**:
**`SmartInfo`** struct:
- `available: bool` — true if `smartctl` binary found in PATH
- `disks: Vec<(String, SmartHealth)>` — per-disk health records
**`SmartHealth`** struct (per disk):
- `passed: bool` — true if overall-health self-assessment = PASSED
- `attributes: Vec<SmartAttribute>` — parsed SMART attributes
- `model_family: Option<String>` — (deferred to future)
- `serial_number: Option<String>` — (deferred to future)
- `error: Option<String>` — stderr from smartctl on failure
**`SmartAttribute`** struct:
- `id: u8` — SMART attribute ID (5=Reallocated, 9=PowerOnHours, etc.)
- `name: String` — e.g. "Reallocated_Sector_Ct"
- `value: Option<i64>` — current value (hex or decimal)
- `worst: Option<i64>` — worst-ever value
- `threshold: Option<i64>` — failure threshold
- `raw: Option<String>` — raw vendor-specific value
**Functions**:
- `SmartInfo::smartctl_available()` — runs `smartctl --version`, returns true if exit 0
- `SmartInfo::read(disks)` — orchestrates per-disk `smartctl -A -H /dev/<disk>` calls
- `read_smart_for_disk(disk)` — single disk call, returns SmartHealth with error captured
- `parse_smartctl_output(text)` — extracts passed/failed + attributes
- `parse_attribute_line(line)` — single SMART attribute line (10 fields)
- `parse_smart_value(s)` — handles both `0x33` (hex) and `100` (decimal) formats
**Three-tier graceful degradation** (per zero-stub policy):
1. **`smartctl` missing** → `available = false`, `disks = []`. Storage
tab shows "(SMART unavailable: install smartmontools)".
2. **`smartctl` errors on a disk** → that disk's `error: Some(stderr)`,
`attributes: []`, `passed: false`. Other disks still try.
3. **All NVMe disks** → `/dev/nvme0n1` may need `sudo smartctl -A`;
if no permission, `error` says so. No fabrication of data.
### 44.2 Unit tests (7 new, 76/76 total pass)
```rust
#[test] fn parse_attribute_line_valid() // hex value 0x0033 → 51
#[test] fn parse_attribute_line_short() // too few fields → None
#[test] fn parse_attribute_line_invalid_id() // "abc" id → None
#[test] fn parse_smartctl_output_passed() // PASSED keyword
#[test] fn parse_smartctl_output_failed() // FAILED keyword
#[test] fn smartctl_not_available_returns_empty() // graceful when missing
#[test] fn health_for_returns_none_for_missing_disk()
```
```
running 76 tests
... all pass ...
test result: ok. 76 passed; 0 failed
```
### 44.3 Build verification
| Build | Result |
|-------|--------|
| Linux host (`cargo build --release`) | ✅ 0 errors, 62 warnings |
| Linux host tests (`cargo test --release`) | ✅ 76/76 pass |
| Linux host smoke (`./target/release/redbear-power --once`) | ✅ No change (smart not wired to UI yet) |
| Redox cross-compile (`cargo build --release --target=x86_64-unknown-redox`) | ✅ clean |
| Redox binary (stripped) | 4,103,016 bytes (same as v1.19 — smart.rs dead code on Redox) |
| Cross-compile SHA256 | `e34a22ed518b2e918bf8fb07eec77d8c5e2e2389a01ad00dad0d76f5c09578a4` |
### 44.4 Linux host smoke test
On this development host (no smartctl installed):
- `smartctl --version` fails → `available = false`
- `SmartInfo::read()` returns empty
- Storage tab still works (no regression from v1.12)
On a host WITH smartctl installed (e.g., `apt install smartmontools`):
- `smartctl --version` succeeds → `available = true`
- `SmartInfo::read(&disks)` returns health records per disk
- Future work: render SMART health section in Storage tab
### 44.5 Forward work
- **Storage tab integration** — display SMART health per disk alongside
the existing model/size/scheduler info. Show "✓ PASSED" / "✗ FAILED"
badge per disk.
- **JSON parsing** — `smartctl --json` output, requires `serde_json`
dependency. More robust than text parsing.
- **Per-attribute table** — render all SMART attributes as a sub-panel
when a disk is selected.
- **Temperature from SMART** — link SMART Temperature_Celsius to the
Sensors panel (currently only k10temp is read).
### 44.6 Final module structure
```
local/recipes/system/redbear-power/source/src/
├── main.rs (~500 lines)
├── app.rs (~580) — App + CpuRow + TabId + 7 data-source fields
├── render.rs (~1100) — header + tab bar + 7 panels + PID detail popup
├── meminfo.rs (241)
├── dmi.rs (118)
├── battery.rs (132)
├── sensor.rs (354)
├── network.rs (203)
├── storage.rs (261)
├── process.rs (230) — +SortMode +CPU% +filter tests
├── pid_detail.rs (237)
├── smart.rs (200) — NEW: smartctl subprocess + parser
├── platform.rs (291)
├── acpi.rs (~233)
├── cpuid.rs (~369)
├── dbus.rs (~294)
├── config.rs (~223)
├── bench.rs (304)
├── msr.rs (~158)
├── cpufreq.rs (~62)
└── theme.rs (71)
```
Total: ~6360 LoC across **21 modules** (v1.19: 6160/20). 76 unit tests.
---
## 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.
@@ -50,6 +50,7 @@ mod platform;
mod process;
mod render;
mod sensor;
mod smart;
mod storage;
mod theme;
@@ -0,0 +1,222 @@
//! SMART disk health data via `smartctl -A /dev/<disk>` (subprocess).
//!
//! `smartctl` is part of the smartmontools package and is NOT installed
//! on all Linux systems. Per the zero-stub policy:
//!
//! - If `smartctl` is missing → `SmartInfo::available()` returns false;
//! the Storage tab shows "(SMART unavailable: install smartmontools)"
//! - If smartctl is present but returns error for a specific disk →
//! that disk's SMART fields are None; other disks still try.
//! - If smartctl requires sudo for some disks → we capture stderr
//! and report "(SMART: permission denied)" rather than fabricating
//! data.
//!
//! On Redox, no smartctl equivalent exists → entire module returns
//! empty `SmartInfo`.
//!
//! The parser is intentionally simple: it expects the
//! `smartctl -A /dev/<disk>` text output format (key/value pairs with
//! `attribute_name value worst threshold raw`). This is the same
//! format smartctl has used since the 1990s and is the most portable.
use std::process::Command;
#[derive(Default, Clone, Debug)]
pub struct SmartAttribute {
pub id: u8,
pub name: String,
pub value: Option<i64>,
pub worst: Option<i64>,
pub threshold: Option<i64>,
pub raw: Option<String>,
}
#[derive(Default, Clone, Debug)]
pub struct SmartHealth {
pub passed: bool,
pub attributes: Vec<SmartAttribute>,
pub model_family: Option<String>,
pub serial_number: Option<String>,
pub error: Option<String>,
}
#[derive(Default, Clone, Debug)]
pub struct SmartInfo {
pub available: bool,
pub disks: Vec<(String, SmartHealth)>,
}
impl SmartInfo {
/// Returns true if `smartctl` binary is found in PATH.
pub fn smartctl_available() -> bool {
Command::new("smartctl")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
/// Read SMART data for all detected disks. If smartctl is missing,
/// `available` is false and `disks` is empty.
pub fn read(disks: &[String]) -> Self {
let available = Self::smartctl_available();
if !available {
return Self {
available: false,
disks: Vec::new(),
};
}
let mut healths = Vec::new();
for disk in disks {
let health = read_smart_for_disk(disk);
healths.push((disk.clone(), health));
}
Self {
available: true,
disks: healths,
}
}
/// Convenience: extract the SMART health for a single disk by name.
pub fn health_for(&self, disk_name: &str) -> Option<&SmartHealth> {
self.disks
.iter()
.find(|(name, _)| name == disk_name)
.map(|(_, h)| h)
}
}
fn read_smart_for_disk(disk: &str) -> SmartHealth {
let output = Command::new("smartctl")
.args(["-A", "-H", "/dev/", &format!("{disk}").to_string()])
.output();
let output = match output {
Ok(o) => o,
Err(e) => {
return SmartHealth {
passed: false,
attributes: Vec::new(),
model_family: None,
serial_number: None,
error: Some(format!("smartctl spawn failed: {}", e)),
}
}
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return SmartHealth {
passed: false,
attributes: Vec::new(),
model_family: None,
serial_number: None,
error: Some(stderr.trim().to_string()),
};
}
parse_smartctl_output(&String::from_utf8_lossy(&output.stdout))
}
fn parse_smartctl_output(text: &str) -> SmartHealth {
let mut health = SmartHealth::default();
for line in text.lines() {
if line.contains("SMART overall-health self-assessment test result") {
health.passed = line.contains("PASSED");
} else if line.starts_with("ID#") || line.starts_with("ID ") || line.starts_with("Attribute") {
continue;
} else if let Some(attr) = parse_attribute_line(line) {
health.attributes.push(attr);
}
}
health
}
fn parse_smart_value(s: &str) -> Option<i64> {
if let Some(hex) = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")) {
i64::from_str_radix(hex, 16).ok()
} else {
s.parse::<i64>().ok()
}
}
fn parse_attribute_line(line: &str) -> Option<SmartAttribute> {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() < 10 {
return None;
}
let id = parts[0].parse::<u8>().ok()?;
let name = parts[1].to_string();
if name.is_empty() {
return None;
}
let value = parse_smart_value(parts[2]);
let worst = parse_smart_value(parts[3]);
let threshold = parse_smart_value(parts[4]);
let raw = parts[9].to_string();
Some(SmartAttribute {
id,
name,
value,
worst,
threshold,
raw: Some(raw),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_attribute_line_valid() {
let line = " 5 Reallocated_Sector_Ct 0x0033 200 200 140 OLD_AGE ALWAYS - 0";
let attr = parse_attribute_line(line).expect("should parse");
assert_eq!(attr.id, 5);
assert_eq!(attr.name, "Reallocated_Sector_Ct");
assert_eq!(attr.value, Some(0x0033));
assert_eq!(attr.raw.as_deref(), Some("0"));
}
#[test]
fn parse_attribute_line_short() {
let line = "short line";
assert!(parse_attribute_line(line).is_none());
}
#[test]
fn parse_attribute_line_invalid_id() {
let line = "abc Foo 100 200 300 x y z y z";
assert!(parse_attribute_line(line).is_none());
}
#[test]
fn parse_smartctl_output_passed() {
let text = "=== START OF READ SMART DATA SECTION ===\n\
SMART overall-health self-assessment test result: PASSED\n\
ID# ATTRIBUTE_NAME FLAG VALUE WORST THRESHOLD TYPE UPDATED WHEN_FAILED RAW_VALUE\n\
5 Reallocated_Sector_Ct 0x0033 200 200 140 OLD_AGE ALWAYS - - 0\n";
let h = parse_smartctl_output(text);
assert!(h.passed);
assert_eq!(h.attributes.len(), 1);
assert_eq!(h.attributes[0].id, 5);
}
#[test]
fn parse_smartctl_output_failed() {
let text = "SMART overall-health self-assessment test result: FAILED!";
let h = parse_smartctl_output(text);
assert!(!h.passed);
}
#[test]
fn smartctl_not_available_returns_empty() {
// Without smartctl, should return empty SmartInfo
let info = SmartInfo::read(&["nvme0n1".to_string()]);
if !info.available {
assert!(info.disks.is_empty());
}
}
#[test]
fn health_for_returns_none_for_missing_disk() {
let info = SmartInfo::default();
assert!(info.health_for("nvme0n1").is_none());
}
}