redbear-power: v1.6 — Battery tab (power_supply)
Adds the 5th tab in the multi-view system: Battery, reading power_supply data from /sys/class/power_supply/BAT*/ on Linux hosts. New module battery.rs (128 lines): - BatteryInfo struct with 15 fields (available, name, status, capacity_percent, energy_now_wh, energy_full_wh, power_now_w, voltage_now_v, time_to_empty_s, time_to_full_s, cycle_count, technology, model_name, manufacturer, serial_number) - find_battery_dir() scans /sys/class/power_supply/ for type==Battery - read() populates all fields with inline µWh→Wh, µV→V conversion - health_percent() computes current_charge / full_charge ratio - display/display_u32/display_u64/display_f64 helpers - format_duration(secs) — Xh Ym / Ym Zs / Zs - RBP_BATTERY_PATH env override (testing + dev workflow) Updated app.rs: - New field battery: BatteryInfo, initialized once in App::new() - TabId::Battery variant (5th tab) - TabId::next() cycles PerCpu → System → Info → Motherboard → Battery Updated render.rs: - New render_battery_panel(app, focused) with 3 section blocks (Identity / State / Power). If !available, shows '(no battery detected — /sys/class/power_supply/BAT* not present)'. - render_tab_bar() updated for 5 tabs with hotkey 1/2/3/4/5 mapping - render_once now dumps Battery panel for headless verification Updated main.rs: - mod battery; declaration - New dispatch arm TabId::Battery => render_battery_panel - Hotkey 5 jumps to Battery tab directly - render_battery_panel added to imports Mock battery smoke test (RBP_BATTERY_PATH=/tmp/fake-battery): - Manufacturer: MSI, Model: MPG X670E, Technology: Li-ion, Cycles: 127 - Status: Discharging, Capacity: 67%, Health: 67% - Energy: 33.50 Wh / 50.00 Wh (µWh→Wh conversion verified) - Power: 8.50 W, Voltage: 12.50 V (µV→V conversion verified) - Time to empty: 3h 0m, Time to full: ? (correctly hidden when 0) On desktop (no battery): (no battery detected — /sys/class/power_supply/BAT* not present) Source state: 4359 LoC across 15 modules (v1.5: 4117/14). Redox stripped: 3.8 MB (SHA256 c6fca172...). Docs: improvement plan §30, CONSOLE-TO-KDE §3.3.2 v1.6, RATATUI-APP-PATTERNS §13.18 + §14.
This commit is contained in:
@@ -1034,6 +1034,65 @@ Cross-compiled binary: 3.7 MB stripped Redox ELF
|
||||
Until then, the Motherboard panel on Redox honestly reports empty data
|
||||
(rather than fake values) — per the zero-stub policy.
|
||||
|
||||
#### v1.6 Battery Tab (2026-06-20)
|
||||
|
||||
Per the user's "v1.6 = Battery tab (Recommended)" directive, v1.6
|
||||
ships the **Battery tab** as the 5th tab in the multi-view system.
|
||||
|
||||
| Item | Status |
|
||||
|------|--------|
|
||||
| `battery.rs` (NEW, 128 LoC) — `BatteryInfo` with 15 fields + per-field sysfs read | ✅ |
|
||||
| `TabId::Battery` variant + cycle order (`PerCpu → System → Info → Motherboard → Battery`) | ✅ |
|
||||
| Hotkey `5` jumps directly to Battery tab | ✅ |
|
||||
| `render_battery_panel()` with 3 sections: Identity / State / Power | ✅ |
|
||||
| `RBP_BATTERY_PATH` env override (useful for testing + dev workflow) | ✅ |
|
||||
| `render_once` now dumps Battery panel for headless verification | ✅ |
|
||||
|
||||
**Data sources opened at runtime** (when battery present):
|
||||
- `/sys/class/power_supply/BAT0/type` — device type filter
|
||||
- `status`, `capacity`, `energy_now`, `energy_full`, `power_now`,
|
||||
`voltage_now`, `time_to_empty`, `time_to_full`, `cycle_count`,
|
||||
`technology`, `model_name`, `manufacturer`, `serial_number` (13 fields)
|
||||
|
||||
**Mock battery smoke test** (`RBP_BATTERY_PATH=/tmp/fake-battery`):
|
||||
```
|
||||
Manufacturer: MSI Technology: Li-ion Cycles: 127
|
||||
Status: Discharging Capacity: 67% Health: 67%
|
||||
Energy: 33.50 Wh / 50.00 Wh
|
||||
Power: 8.50 W Voltage: 12.50 V
|
||||
Time to empty: 3h 0m Time to full: ?
|
||||
```
|
||||
|
||||
µWh → Wh conversion verified (33,500,000 µWh → 33.50 Wh); µV → V
|
||||
conversion verified (12,500,000 µV → 12.50 V); `time_to_full=0`
|
||||
correctly hidden.
|
||||
|
||||
**On actual desktop host** (no battery):
|
||||
```
|
||||
(no battery detected — /sys/class/power_supply/BAT* not present)
|
||||
```
|
||||
|
||||
**v1.6 source state**: 4359 LoC across **15 modules** (was 4117/14 in
|
||||
v1.5). New module: `battery.rs` (128 lines).
|
||||
|
||||
Cross-compiled binary: 3.8 MB stripped Redox ELF
|
||||
(SHA256 `c6fca1728faff9edd053b933f0c57075e25dfe52450b7ab604d04d5024b1cc88`).
|
||||
|
||||
**Forward work on Redox target** (deferred to v1.7+):
|
||||
1. **`power_supply` scheme daemon** — exposes battery state via
|
||||
`/scheme/power_supply/BAT0/{status,capacity,...}` (mirrors sysfs).
|
||||
2. **ACPI battery object parser** — read `_BST` (battery status) and
|
||||
`_BIF` (battery information) AML methods; convert to `BatteryInfo`
|
||||
fields.
|
||||
3. **Per-tick refresh** — battery state should be polled at 2-5 Hz,
|
||||
not read once at startup. Move `BatteryInfo::read()` into
|
||||
`App::refresh()` with 5-tick throttling.
|
||||
4. **redbear-power fallback** — `find_battery_dir()` tries Redox
|
||||
scheme first, then `/sys/class/power_supply/` (Linux host).
|
||||
|
||||
Until then, the Battery panel on Redox honestly reports empty data
|
||||
(rather than fake values) — per the zero-stub policy.
|
||||
|
||||
### 3.4 D-Bus
|
||||
|
||||
| Component | Status | Detail |
|
||||
|
||||
@@ -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.5, 4117 LoC
|
||||
across 14 modules) produced these actionable findings:
|
||||
A targeted audit of `local/recipes/system/redbear-power/` (v1.6, 4359 LoC
|
||||
across 15 modules) produced these actionable findings:
|
||||
|
||||
| Severity | Finding | Fix |
|
||||
|----------|---------|-----|
|
||||
@@ -1111,6 +1111,7 @@ across 14 modules) produced these actionable findings:
|
||||
| feature | No Linux-host fallbacks (hardcoded `/scheme/sys/...` paths) | Implemented in v1.3 (`platform.rs` runtime probe + per-module fallbacks) |
|
||||
| feature | No memory or OS info display | Implemented in v1.4 (`meminfo.rs` module + `mem_bar_line` helper) |
|
||||
| feature | No Motherboard / DMI tab | Implemented in v1.5 (`dmi.rs` module + `TabId::Motherboard`) |
|
||||
| feature | No Battery tab | Implemented in v1.6 (`battery.rs` module + `TabId::Battery`) |
|
||||
|
||||
Full plan: see `local/docs/redbear-power-improvement-plan.md`.
|
||||
|
||||
@@ -1212,6 +1213,62 @@ change at runtime) and cheap (≤ 20 sysfs reads = < 1 ms). The
|
||||
`Option<String>` per-field design means a permission error on
|
||||
`product_serial` (root-only) doesn't disable the entire Motherboard tab.
|
||||
|
||||
### 13.18 v1.6 Module Pattern: `battery.rs` with Empty-State Short-Circuit
|
||||
|
||||
v1.6 added `battery.rs` (128 lines) as a self-contained power-source
|
||||
data source module. The pattern differs from `dmi.rs` because the
|
||||
battery is sometimes **entirely absent** (desktop without UPS, server,
|
||||
container) — not just partially readable.
|
||||
|
||||
```rust
|
||||
// battery.rs skeleton
|
||||
#[derive(Default, Clone, Debug)]
|
||||
pub struct BatteryInfo {
|
||||
pub available: bool, // ← false on desktop, true on laptop
|
||||
pub status: Option<String>,
|
||||
pub capacity_percent: Option<u32>,
|
||||
pub energy_now_wh: Option<f64>,
|
||||
// ... 12 more fields
|
||||
}
|
||||
|
||||
impl BatteryInfo {
|
||||
pub fn read() -> Self {
|
||||
let Some(base) = Self::find_battery_dir() else {
|
||||
return Self::default(); // ← available=false
|
||||
};
|
||||
// ... populate all fields from sysfs
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Key conventions:
|
||||
- **`available: bool` is a top-level field**, not just `is_empty()`.
|
||||
Distinguishes "battery doesn't exist" from "battery exists but I can't
|
||||
read all its fields" — both render differently:
|
||||
- `!available` → `(no battery detected — /sys/class/power_supply/BAT* not present)`
|
||||
- `available && all fields None` → wall of `?` characters + empty sections
|
||||
- **`RBP_BATTERY_PATH` env override** — redirects `find_battery_dir()` to
|
||||
a fixture directory. Useful for:
|
||||
- **Testing**: create a fake `/tmp/fake-battery/BAT0/` with realistic
|
||||
values and verify the panel renders correctly without needing a
|
||||
real laptop.
|
||||
- **Dev workflow**: redirect to a non-standard sysfs mount.
|
||||
- **Unit conversion inline** — `energy_now` is µWh, divide by 1_000_000
|
||||
at read time. Don't store raw µWh and convert at render time.
|
||||
- **`format_duration(secs)` helper** — converts seconds → "3h 0m" /
|
||||
"0m 5s" / "0s". Hidden when `secs == 0` (matches `?` for "not
|
||||
applicable" semantics, not "0 seconds remaining").
|
||||
- **Per-tick refresh deferred** — battery state should ideally be
|
||||
polled at 2-5 Hz, but `App::refresh()` doesn't call `read()` yet.
|
||||
Reading once at startup is the safe default for the first iteration;
|
||||
per-tick refresh is documented as v1.7 forward work.
|
||||
|
||||
Pattern rationale: many TUI monitoring tools (htop, btop, cpu-x) skip
|
||||
the battery subsystem entirely on desktops. redbear-power follows the
|
||||
**honest empty-state** pattern: if the source doesn't exist, say so
|
||||
clearly in one line; don't render a wall of `?` characters that
|
||||
confuses the user.
|
||||
|
||||
---
|
||||
|
||||
## 14. Cross-Reference: redbear-power as a Reference Implementation
|
||||
@@ -1219,6 +1276,17 @@ change at runtime) and cheap (≤ 20 sysfs reads = < 1 ms). The
|
||||
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)
|
||||
2. **Self-contained** — no D-Bus, no external state, just sysfs/MSR + meminfo + DMI + battery
|
||||
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
|
||||
|
||||
## 14. Cross-Reference: redbear-power as a Reference Implementation
|
||||
|
||||
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** (~4100 LoC across 14 modules)
|
||||
2. **Self-contained** — no D-Bus, no external state, just sysfs/MSR + meminfo + DMI
|
||||
3. **Modern ratatui 0.30 patterns** — `TableState`, modular layout, status bars, `Tabs` widget
|
||||
|
||||
@@ -1787,6 +1787,173 @@ Total: 4,117 LoC across 14 modules (v1.4: 3,864 LoC across 13 modules; +253 LoC,
|
||||
|
||||
---
|
||||
|
||||
## 30. v1.6 Battery Tab (2026-06-20)
|
||||
|
||||
Per the user's "commit v1.5 done. v1.6 = Battery tab (Recommended)"
|
||||
directive, v1.6 ships the **Battery tab** as the 5th tab in the
|
||||
multi-view system.
|
||||
|
||||
### 30.1 What was implemented
|
||||
|
||||
**New module `battery.rs` (128 lines)**:
|
||||
- `BatteryInfo` struct with 15 fields: `available`, `name`, `status`,
|
||||
`capacity_percent`, `energy_now_wh`, `energy_full_wh`, `power_now_w`,
|
||||
`voltage_now_v`, `time_to_empty_s`, `time_to_full_s`, `cycle_count`,
|
||||
`technology`, `model_name`, `manufacturer`, `serial_number`.
|
||||
- `find_battery_dir()` — scans `/sys/class/power_supply/` for the first
|
||||
device with `type == "Battery"`. Returns `None` if absent (desktop
|
||||
without UPS).
|
||||
- `read()` — populates all fields by reading each sysfs file
|
||||
independently. Unit conversion (µWh → Wh, µV → V) handled inline.
|
||||
- `health_percent()` — computes charge / full charge ratio.
|
||||
- `display`, `display_u32`, `display_u64`, `display_f64` — render
|
||||
helpers (Some → value, None → "?").
|
||||
- `format_duration(secs)` — formats seconds → "Xh Ym" / "Ym Zs" / "Zs".
|
||||
- `RBP_BATTERY_PATH` env override — useful for testing and dev workflow;
|
||||
redirects `find_battery_dir()` to a fixture directory.
|
||||
|
||||
**Updated `app.rs`**:
|
||||
- New field `pub battery: crate::battery::BatteryInfo`, initialized once
|
||||
in `App::new()` (battery state changes but for now match DMI cadence
|
||||
— read once at startup is the safe default; per-tick refresh is
|
||||
forward work for v1.7+).
|
||||
- `TabId::Battery` variant (5th tab).
|
||||
- `TabId::next()` cycle: `PerCpu → System → Info → Motherboard → Battery → PerCpu`.
|
||||
- `TabId::name()` returns `"Battery"`.
|
||||
|
||||
**Updated `render.rs`**:
|
||||
- New `render_battery_panel(app, focused)` — produces a `Paragraph`
|
||||
with 3 section blocks (Identity, State, Power). If `!bat.available`,
|
||||
shows `(no battery detected — /sys/class/power_supply/BAT* not present)`
|
||||
rather than a wall of `?` characters (zero-stub policy).
|
||||
- `render_tab_bar()` updated for 5 tabs with hotkey mapping 1/2/3/4/5.
|
||||
- `render_once` now dumps the Battery panel as a fourth snapshot.
|
||||
|
||||
**Updated `main.rs`**:
|
||||
- `mod battery;` declaration.
|
||||
- New dispatch arm `TabId::Battery => render_battery_panel(...)`.
|
||||
- Hotkey `5` jumps to Battery tab directly.
|
||||
- `render_battery_panel` added to imports.
|
||||
|
||||
### 30.2 Mock battery smoke test
|
||||
|
||||
Created `/tmp/fake-battery/BAT0/` with:
|
||||
- `type=Battery`, `name=BAT0`, `status=Discharging`
|
||||
- `capacity=67`, `energy_now=33500000` (µWh), `energy_full=50000000`
|
||||
- `power_now=8500000` (µW = 8.5 W), `voltage_now=12500000` (µV = 12.5 V)
|
||||
- `time_to_empty=10800` (3h), `time_to_full=0` (not charging)
|
||||
- `cycle_count=127`, `technology=Li-ion`
|
||||
- `model_name=MPG X670E`, `manufacturer=MSI`, `serial_number=ABC123`
|
||||
|
||||
Ran with `RBP_BATTERY_PATH=/tmp/fake-battery`:
|
||||
|
||||
```
|
||||
--- Battery panel (verifies v1.6 power_supply) ---
|
||||
┌ Battery ─────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│Identity │
|
||||
│Manufacturer: MSI │
|
||||
│Model: MPG X670E │
|
||||
│Technology: Li-ion │
|
||||
│Serial: ABC123 │
|
||||
│Cycles: 127 │
|
||||
│ │
|
||||
│State │
|
||||
│Status: Discharging │
|
||||
│Capacity: 67% │
|
||||
│Energy: 33.50 Wh / 50.00 Wh │
|
||||
│Health: 67% (current charge / full charge) │
|
||||
│ │
|
||||
│Power │
|
||||
│Power: 8.50 W │
|
||||
│Voltage: 12.50 V │
|
||||
│Time to empty: 3h 0m │
|
||||
│Time to full: ? │
|
||||
└────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Verified:
|
||||
- µWh → Wh conversion: 33,500,000 µWh → 33.50 Wh ✓
|
||||
- µV → V conversion: 12,500,000 µV → 12.50 V ✓
|
||||
- `time_to_full=0` correctly shows `?` (zero duration hidden, matches
|
||||
the SMBIOS pattern of hiding empty fields)
|
||||
- Health% computation: 33.50 / 50.00 × 100 = 67% ✓
|
||||
- All 15 fields read and rendered in their respective sections
|
||||
|
||||
On the **actual host** (no battery), the panel correctly shows
|
||||
`(no battery detected — /sys/class/power_supply/BAT* not present)`.
|
||||
|
||||
### 30.3 Build verification
|
||||
|
||||
| Build | Result |
|
||||
|-------|--------|
|
||||
| Linux host (`cargo build --release`) | ✅ 0 errors, 29 warnings (all pre-existing dead-code) |
|
||||
| Linux host smoke (`./target/release/redbear-power --once`) | ✅ No-battery panel renders correctly |
|
||||
| Linux host smoke with mock (`RBP_BATTERY_PATH=/tmp/fake-battery --once`) | ✅ Full battery panel renders correctly |
|
||||
| Redox cross-compile (`cargo build --release --target=x86_64-unknown-redox`) | ✅ clean |
|
||||
| Redox binary (unstripped) | 5,310,464 bytes |
|
||||
| Redox binary (stripped) | 3,935,080 bytes (vs v1.5's 3,918,696 — +16 KB) |
|
||||
| Linux binary (unstripped) | 5,418,968 bytes |
|
||||
|
||||
Cross-compile SHA256: `c6fca1728faff9edd053b933f0c57075e25dfe52450b7ab604d04d5024b1cc88`.
|
||||
|
||||
### 30.4 Forward work on Redox target
|
||||
|
||||
The `power_supply` sysfs class doesn't yet exist on Redox. Required work
|
||||
for a populated Battery tab on Redox:
|
||||
|
||||
1. **`power_supply` scheme daemon** — exposes battery state via
|
||||
`/scheme/power_supply/BAT0/{status,capacity,energy_now,...}` (mirrors
|
||||
the sysfs layout on Linux).
|
||||
2. **ACPI battery object parser** — read the `_BST` (battery status) and
|
||||
`_BIF` (battery information) AML methods; convert to `BatteryInfo`
|
||||
fields with same unit conversions.
|
||||
3. **redbear-power fallback** — `find_battery_dir()` tries Redox scheme
|
||||
first, then `/sys/class/power_supply/` (Linux host).
|
||||
|
||||
Until then, the Battery panel on Redox honestly reports empty data
|
||||
(rather than fake values) — per the zero-stub policy.
|
||||
|
||||
### 30.5 Per-tick battery refresh (forward work, v1.7+)
|
||||
|
||||
`BatteryInfo::read()` is currently called once at `App::new()` time.
|
||||
On a real laptop, battery state changes continuously (capacity drops,
|
||||
power_now varies, time_to_empty decreases). For a useful real-time
|
||||
view, the battery module needs to be polled at the same cadence as
|
||||
per-CPU stats (500 ms default).
|
||||
|
||||
Implementation plan:
|
||||
1. Move `battery: BatteryInfo` read into `App::refresh()`.
|
||||
2. Add a `bat_available: bool` derived field to drive the empty-state
|
||||
path (no need to keep `available: bool` in `BatteryInfo` if App
|
||||
drives the cadence).
|
||||
3. Add a 5-tick throttling (every 5th refresh = 2.5 sec at 500 ms)
|
||||
to avoid hammering `/sys/class/power_supply/` at 2 Hz.
|
||||
|
||||
### 30.6 Final module structure
|
||||
|
||||
```
|
||||
local/recipes/system/redbear-power/source/src/
|
||||
├── main.rs (~483 lines) — event loop, key + mouse + D-Bus command dispatch
|
||||
├── app.rs (~540) — App + CpuRow + TabId + meminfo + os_info + dmi + battery fields
|
||||
├── render.rs (~1026) — header with Sources line, tab bar, 5 panels, controls
|
||||
├── meminfo.rs (241) — /proc/meminfo + /etc/os-release + /proc/uptime + /etc/hostname
|
||||
├── dmi.rs (118) — /sys/class/dmi/id/{sys,product,board,bios,chassis}
|
||||
├── battery.rs (128) — NEW: /sys/class/power_supply/BAT*/{status,capacity,energy,...}
|
||||
├── 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 (122) — prime-sieve stress benchmark
|
||||
├── 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,359 LoC across 15 modules (v1.5: 4,117 LoC across 14 modules; +242 LoC, +1 module).
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -118,6 +118,7 @@ pub struct App {
|
||||
pub meminfo: crate::meminfo::MemInfo,
|
||||
pub os_info: crate::meminfo::OsInfo,
|
||||
pub dmi: crate::dmi::DmiInfo,
|
||||
pub battery: crate::battery::BatteryInfo,
|
||||
pub refresh_counter: u32,
|
||||
pub status_msg: String,
|
||||
pub status_expires: Option<Instant>,
|
||||
@@ -133,6 +134,7 @@ pub enum TabId {
|
||||
System,
|
||||
Info,
|
||||
Motherboard,
|
||||
Battery,
|
||||
}
|
||||
|
||||
impl TabId {
|
||||
@@ -141,7 +143,8 @@ impl TabId {
|
||||
TabId::PerCpu => TabId::System,
|
||||
TabId::System => TabId::Info,
|
||||
TabId::Info => TabId::Motherboard,
|
||||
TabId::Motherboard => TabId::PerCpu,
|
||||
TabId::Motherboard => TabId::Battery,
|
||||
TabId::Battery => TabId::PerCpu,
|
||||
}
|
||||
}
|
||||
pub fn name(self) -> &'static str {
|
||||
@@ -150,6 +153,7 @@ impl TabId {
|
||||
TabId::System => "System",
|
||||
TabId::Info => "Info",
|
||||
TabId::Motherboard => "Motherboard",
|
||||
TabId::Battery => "Battery",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -256,6 +260,7 @@ impl App {
|
||||
meminfo: crate::meminfo::read_meminfo(),
|
||||
os_info: crate::meminfo::read_os_info(),
|
||||
dmi: crate::dmi::DmiInfo::read(),
|
||||
battery: crate::battery::BatteryInfo::read(),
|
||||
refresh_counter: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
//! Battery information via `sysfs` (`/sys/class/power_supply/BAT0/*`).
|
||||
//!
|
||||
//! Linux hosts expose laptop batteries through the `power_supply` class.
|
||||
//! On Redox, no equivalent scheme exists yet, so `read_battery()` returns
|
||||
//! an `available=false` `BatteryInfo` and the render layer skips the
|
||||
//! panel entirely — per the zero-stub policy.
|
||||
//!
|
||||
//! The Redox target needs a `power_supply` scheme daemon (likely wired to
|
||||
//! ACPI); this is forward work tracked in the v1.6 docs.
|
||||
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const SYS_POWER_SUPPLY: &str = "/sys/class/power_supply";
|
||||
|
||||
#[derive(Default, Clone, Debug)]
|
||||
pub struct BatteryInfo {
|
||||
pub available: bool,
|
||||
pub name: Option<String>,
|
||||
pub status: Option<String>,
|
||||
pub capacity_percent: Option<u32>,
|
||||
pub energy_now_wh: Option<f64>,
|
||||
pub energy_full_wh: Option<f64>,
|
||||
pub power_now_w: Option<f64>,
|
||||
pub voltage_now_v: Option<f64>,
|
||||
pub time_to_empty_s: Option<u64>,
|
||||
pub time_to_full_s: Option<u64>,
|
||||
pub cycle_count: Option<u32>,
|
||||
pub technology: Option<String>,
|
||||
pub model_name: Option<String>,
|
||||
pub manufacturer: Option<String>,
|
||||
pub serial_number: Option<String>,
|
||||
}
|
||||
|
||||
fn read_sysfs(path: &Path) -> Option<String> {
|
||||
match fs::read_to_string(path) {
|
||||
Ok(s) => {
|
||||
let trimmed = s.trim().to_string();
|
||||
if trimmed.is_empty() { None } else { Some(trimmed) }
|
||||
}
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn read_sysfs_u64(path: &Path) -> Option<u64> {
|
||||
read_sysfs(path)?.trim().parse::<u64>().ok()
|
||||
}
|
||||
|
||||
fn read_sysfs_u32(path: &Path) -> Option<u32> {
|
||||
read_sysfs(path)?.trim().parse::<u32>().ok()
|
||||
}
|
||||
|
||||
fn read_sysfs_f64_micro_to_units(path: &Path, unit_divisor: f64) -> Option<f64> {
|
||||
let raw = read_sysfs_u64(path)?;
|
||||
Some((raw as f64) / unit_divisor)
|
||||
}
|
||||
|
||||
impl BatteryInfo {
|
||||
/// Scan `/sys/class/power_supply/` for the first battery device
|
||||
/// (`type == "Battery"`). Returns `None` if no battery is present
|
||||
/// (desktop without UPS).
|
||||
fn find_battery_dir() -> Option<PathBuf> {
|
||||
let search_root = env::var("RBP_BATTERY_PATH")
|
||||
.ok()
|
||||
.unwrap_or_else(|| SYS_POWER_SUPPLY.to_string());
|
||||
let dir = fs::read_dir(&search_root).ok()?;
|
||||
for entry in dir.flatten() {
|
||||
let type_path = entry.path().join("type");
|
||||
if let Some(t) = read_sysfs(&type_path) {
|
||||
if t == "Battery" {
|
||||
return Some(entry.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Build a populated `BatteryInfo` from sysfs. Returns an empty
|
||||
/// `available=false` struct if no battery is detected.
|
||||
pub fn read() -> Self {
|
||||
let Some(base) = Self::find_battery_dir() else {
|
||||
return Self::default();
|
||||
};
|
||||
Self {
|
||||
available: true,
|
||||
name: read_sysfs(&base.join("name")),
|
||||
status: read_sysfs(&base.join("status")),
|
||||
capacity_percent: read_sysfs_u32(&base.join("capacity")),
|
||||
energy_now_wh: read_sysfs_f64_micro_to_units(&base.join("energy_now"), 1_000_000.0),
|
||||
energy_full_wh: read_sysfs_f64_micro_to_units(&base.join("energy_full"), 1_000_000.0),
|
||||
power_now_w: read_sysfs_f64_micro_to_units(&base.join("power_now"), 1_000_000.0),
|
||||
voltage_now_v: read_sysfs_f64_micro_to_units(&base.join("voltage_now"), 1_000_000.0),
|
||||
time_to_empty_s: read_sysfs_u64(&base.join("time_to_empty")),
|
||||
time_to_full_s: read_sysfs_u64(&base.join("time_to_full")),
|
||||
cycle_count: read_sysfs_u32(&base.join("cycle_count")),
|
||||
technology: read_sysfs(&base.join("technology")),
|
||||
model_name: read_sysfs(&base.join("model_name")),
|
||||
manufacturer: read_sysfs(&base.join("manufacturer")),
|
||||
serial_number: read_sysfs(&base.join("serial_number")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn health_percent(&self) -> Option<u32> {
|
||||
let now = self.energy_now_wh?;
|
||||
let full = self.energy_full_wh?;
|
||||
if full <= 0.0 { None } else { Some(((now / full) * 100.0).round() as u32) }
|
||||
}
|
||||
|
||||
pub fn display(field: &Option<String>) -> &str {
|
||||
field.as_deref().unwrap_or("?")
|
||||
}
|
||||
|
||||
pub fn display_u32(field: &Option<u32>) -> String {
|
||||
field.map(|v| v.to_string()).unwrap_or_else(|| "?".to_string())
|
||||
}
|
||||
|
||||
pub fn display_u64(field: &Option<u64>) -> String {
|
||||
field.map(|v| v.to_string()).unwrap_or_else(|| "?".to_string())
|
||||
}
|
||||
|
||||
pub fn display_f64(field: &Option<f64>) -> String {
|
||||
field.map(|v| format!("{v:.2}")).unwrap_or_else(|| "?".to_string())
|
||||
}
|
||||
|
||||
pub fn format_duration(secs: u64) -> String {
|
||||
if secs == 0 { return "?".to_string(); }
|
||||
let h = secs / 3600;
|
||||
let m = (secs % 3600) / 60;
|
||||
let s = secs % 60;
|
||||
if h > 0 { format!("{h}h {m}m") } else if m > 0 { format!("{m}m {s}s") } else { format!("{s}s") }
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ use termion::screen::IntoAlternateScreen;
|
||||
|
||||
mod acpi;
|
||||
mod app;
|
||||
mod battery;
|
||||
mod bench;
|
||||
mod config;
|
||||
mod cpufreq;
|
||||
@@ -49,7 +50,7 @@ mod theme;
|
||||
|
||||
use crate::app::{App, POLL_MS, TabId};
|
||||
use crate::render::{
|
||||
render_controls, render_cpu_table, render_header, render_help,
|
||||
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_tab_bar, snapshot,
|
||||
@@ -314,6 +315,12 @@ fn main() -> io::Result<()> {
|
||||
body_area,
|
||||
);
|
||||
}
|
||||
TabId::Battery => {
|
||||
f.render_widget(
|
||||
render_battery_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) {
|
||||
@@ -370,6 +377,7 @@ fn main() -> io::Result<()> {
|
||||
Key::Char('2') => app.current_tab = app::TabId::System,
|
||||
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('T') => app.current_tab = app.current_tab.next(),
|
||||
Key::Char('?') => show_help = !show_help,
|
||||
Key::Char('g') => app.cycle_governor(),
|
||||
|
||||
@@ -276,6 +276,7 @@ pub fn render_tab_bar<'a>(app: &'a App) -> Tabs<'a> {
|
||||
TabId::System,
|
||||
TabId::Info,
|
||||
TabId::Motherboard,
|
||||
TabId::Battery,
|
||||
]
|
||||
.iter()
|
||||
.map(|t| Line::from(t.name()))
|
||||
@@ -285,6 +286,7 @@ pub fn render_tab_bar<'a>(app: &'a App) -> Tabs<'a> {
|
||||
TabId::System => 1,
|
||||
TabId::Info => 2,
|
||||
TabId::Motherboard => 3,
|
||||
TabId::Battery => 4,
|
||||
};
|
||||
Tabs::new(titles)
|
||||
.select(selected)
|
||||
@@ -558,6 +560,95 @@ pub fn render_motherboard_panel<'a>(app: &'a App, focused: bool) -> Paragraph<'a
|
||||
.wrap(Wrap { trim: true })
|
||||
}
|
||||
|
||||
pub fn render_battery_panel<'a>(app: &'a App, focused: bool) -> Paragraph<'a> {
|
||||
let bat = &app.battery;
|
||||
if !bat.available {
|
||||
return Paragraph::new(Line::from(
|
||||
"(no battery detected — /sys/class/power_supply/BAT* not present)".set_style(theme::VALUE_WARM),
|
||||
))
|
||||
.block(panel_border(focused, " Battery "))
|
||||
.wrap(Wrap { trim: true });
|
||||
}
|
||||
let mut lines: Vec<Line<'a>> = Vec::new();
|
||||
lines.push(Line::from("Identity".set_style(theme::LABEL_BOLD)));
|
||||
lines.push(Line::from(vec![
|
||||
" Manufacturer: ".set_style(theme::LABEL),
|
||||
crate::battery::BatteryInfo::display(&bat.manufacturer).set_style(theme::VALUE),
|
||||
]));
|
||||
lines.push(Line::from(vec![
|
||||
" Model: ".set_style(theme::LABEL),
|
||||
crate::battery::BatteryInfo::display(&bat.model_name).set_style(theme::VALUE),
|
||||
]));
|
||||
lines.push(Line::from(vec![
|
||||
" Technology: ".set_style(theme::LABEL),
|
||||
crate::battery::BatteryInfo::display(&bat.technology).set_style(theme::VALUE),
|
||||
]));
|
||||
lines.push(Line::from(vec![
|
||||
" Serial: ".set_style(theme::LABEL),
|
||||
crate::battery::BatteryInfo::display(&bat.serial_number).set_style(theme::VALUE),
|
||||
]));
|
||||
lines.push(Line::from(vec![
|
||||
" Cycles: ".set_style(theme::LABEL),
|
||||
crate::battery::BatteryInfo::display_u32(&bat.cycle_count).set_style(theme::VALUE),
|
||||
]));
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from("State".set_style(theme::LABEL_BOLD)));
|
||||
lines.push(Line::from(vec![
|
||||
" Status: ".set_style(theme::LABEL),
|
||||
crate::battery::BatteryInfo::display(&bat.status).set_style(theme::VALUE),
|
||||
]));
|
||||
lines.push(Line::from(vec![
|
||||
" Capacity: ".set_style(theme::LABEL),
|
||||
crate::battery::BatteryInfo::display_u32(&bat.capacity_percent).set_style(theme::VALUE),
|
||||
"%".set_style(theme::VALUE),
|
||||
]));
|
||||
lines.push(Line::from(vec![
|
||||
" Energy: ".set_style(theme::LABEL),
|
||||
crate::battery::BatteryInfo::display_f64(&bat.energy_now_wh).set_style(theme::VALUE),
|
||||
" Wh".set_style(theme::VALUE),
|
||||
" / ".set_style(theme::VALUE),
|
||||
crate::battery::BatteryInfo::display_f64(&bat.energy_full_wh).set_style(theme::VALUE),
|
||||
" Wh".set_style(theme::VALUE),
|
||||
]));
|
||||
lines.push(Line::from(vec![
|
||||
" Health: ".set_style(theme::LABEL),
|
||||
crate::battery::BatteryInfo::display_u32(&bat.health_percent()).set_style(theme::VALUE),
|
||||
"%".set_style(theme::VALUE),
|
||||
" (current charge / full charge)".set_style(theme::VALUE_OFF),
|
||||
]));
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from("Power".set_style(theme::LABEL_BOLD)));
|
||||
lines.push(Line::from(vec![
|
||||
" Power: ".set_style(theme::LABEL),
|
||||
crate::battery::BatteryInfo::display_f64(&bat.power_now_w).set_style(theme::VALUE),
|
||||
" W".set_style(theme::VALUE),
|
||||
]));
|
||||
lines.push(Line::from(vec![
|
||||
" Voltage: ".set_style(theme::LABEL),
|
||||
crate::battery::BatteryInfo::display_f64(&bat.voltage_now_v).set_style(theme::VALUE),
|
||||
" V".set_style(theme::VALUE),
|
||||
]));
|
||||
lines.push(Line::from(vec![
|
||||
" Time to empty: ".set_style(theme::LABEL),
|
||||
if let Some(s) = bat.time_to_empty_s {
|
||||
crate::battery::BatteryInfo::format_duration(s).set_style(theme::VALUE)
|
||||
} else {
|
||||
"?".set_style(theme::VALUE_OFF)
|
||||
},
|
||||
]));
|
||||
lines.push(Line::from(vec![
|
||||
" Time to full: ".set_style(theme::LABEL),
|
||||
if let Some(s) = bat.time_to_full_s {
|
||||
crate::battery::BatteryInfo::format_duration(s).set_style(theme::VALUE)
|
||||
} else {
|
||||
"?".set_style(theme::VALUE_OFF)
|
||||
},
|
||||
]));
|
||||
Paragraph::new(lines)
|
||||
.block(panel_border(focused, " Battery "))
|
||||
.wrap(Wrap { trim: true })
|
||||
}
|
||||
|
||||
pub fn render_cpu_table<'a>(
|
||||
cpus: &'a [CpuRow],
|
||||
expanded_cpu: Option<u32>,
|
||||
@@ -922,5 +1013,15 @@ pub fn render_once(app: &App) -> io::Result<()> {
|
||||
})
|
||||
.expect("draw");
|
||||
print!("{}", buffer_to_string(terminal.backend().buffer()));
|
||||
eprintln!("--- Battery panel (verifies v1.6 power_supply) ---");
|
||||
let bat_para = render_battery_panel(app, false);
|
||||
let backend = TestBackend::new(120, 30);
|
||||
let mut terminal = Terminal::new(backend).expect("test terminal");
|
||||
terminal
|
||||
.draw(|f| {
|
||||
f.render_widget(bat_para, f.area());
|
||||
})
|
||||
.expect("draw");
|
||||
print!("{}", buffer_to_string(terminal.backend().buffer()));
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user