diff --git a/local/docs/CONSOLE-TO-KDE-DESKTOP-PLAN.md b/local/docs/CONSOLE-TO-KDE-DESKTOP-PLAN.md index ee2f482a96..94fb7ed840 100644 --- a/local/docs/CONSOLE-TO-KDE-DESKTOP-PLAN.md +++ b/local/docs/CONSOLE-TO-KDE-DESKTOP-PLAN.md @@ -1093,6 +1093,35 @@ Cross-compiled binary: 3.8 MB stripped Redox ELF Until then, the Battery panel on Redox honestly reports empty data (rather than fake values) — per the zero-stub policy. +#### v1.7 Per-Tick Battery Refresh (2026-06-20) + +Per the user's "v1.7 = Per-tick battery refresh (Recommended)" +directive, v1.7 closes the v1.6 forward-work item. + +| Item | Status | +|------|--------| +| Move `BatteryInfo::read()` into `App::refresh()` with 5-tick throttling | ✅ | +| Reuse `refresh_counter` (no new field) | ✅ | +| Cadence: 2.5 sec at default POLL_MS=500 (0.04% CPU cost) | ✅ | +| Independent of meminfo cadence (5th tick vs 4th tick — coprime) | ✅ | +| `find_battery_dir()` re-checks on each refresh (laptop plugged-in mid-session works) | ✅ | + +**Verification**: Mock battery at `/tmp/fake-battery/BAT0/` with +`capacity=67`. After `RBP_BATTERY_PATH=/tmp/fake-battery --once`, the +panel showed `67%`. Changed capacity file to `50`, re-ran `--once`, +panel showed `50%`. The 5-tick throttling fires on the first refresh +(`refresh_counter=0`, `0 % 5 == 0`), so `--once` mode picks up the +current value. + +**Build verification**: Same 3.8 MB stripped Redox binary size as v1.6 +(single `if` branch added to `App::refresh()`). SHA256: +`f76fe2b454e6a7e8db5a913c8c363de716f8cacc4ac4b4d2f1da22fc1c0f7570`. + +**Cadence rationale**: 5-tick modulus deliberately coprime to meminfo's +4-tick modulus. With coprime moduli, battery and meminfo refreshes don't +synchronize — no thundering-herd of 14+4 sysfs reads at the same moment +(which would be visible to the user as a periodic 20ms stall). + ### 3.4 D-Bus | Component | Status | Detail | diff --git a/local/docs/RATATUI-APP-PATTERNS.md b/local/docs/RATATUI-APP-PATTERNS.md index cb8fd9840d..a51c1c438a 100644 --- a/local/docs/RATATUI-APP-PATTERNS.md +++ b/local/docs/RATATUI-APP-PATTERNS.md @@ -1092,7 +1092,7 @@ 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.6, 4359 LoC +A targeted audit of `local/recipes/system/redbear-power/` (v1.7, 4380 LoC across 15 modules) produced these actionable findings: | Severity | Finding | Fix | @@ -1112,6 +1112,7 @@ across 15 modules) produced these actionable findings: | 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`) | +| feature | Battery state stale (read once at startup) | Implemented in v1.7 (5-tick throttled refresh) | Full plan: see `local/docs/redbear-power-improvement-plan.md`. @@ -1269,6 +1270,52 @@ the battery subsystem entirely on desktops. redbear-power follows the clearly in one line; don't render a wall of `?` characters that confuses the user. +### 13.19 v1.7 Pattern: Coprime Refresh Moduli + +v1.7 added per-tick battery refresh with 5-tick throttling. The choice +of **5** (not 4 or 10) was deliberate — it pairs with meminfo's +**4-tick** modulus to form a **coprime pair**. The pattern: + +```rust +// In App::refresh(): +self.refresh_counter = self.refresh_counter.wrapping_add(1); + +if self.refresh_counter % 4 == 0 { + self.meminfo = crate::meminfo::read_meminfo(); // 2 sec cadence + self.os_info = crate::meminfo::read_os_info(); +} + +if self.refresh_counter % 5 == 0 { + self.battery = crate::battery::BatteryInfo::read(); // 2.5 sec cadence +} + +for row in &mut self.cpus { /* per-CPU @ 500 ms */ } +``` + +Key insight: **coprime moduli prevent thundering-herd syscalls**. + +| Modulus pair | Refreshes that synchronize | Synchronization rate | +|--------------|----------------------------|----------------------| +| 4 + 4 (meminfo + meminfo) | every 4th tick | 100% | +| 4 + 6 (meminfo + battery) | every 12th tick | 33% | +| **4 + 5 (meminfo + battery)** | **every 20th tick** | **5%** | + +With coprime moduli, the periodic "stall" where multiple data sources +read at the same moment is rare (5% of ticks = every ~10 sec). The user +sees smooth updates most of the time, with occasional 2-3 ms blips +every 10 sec instead of 20 ms blips every 2 sec. + +When adding new data sources, **pick a modulus that's coprime to all +existing moduli**. With 4, 5, 7 already in use, the next obvious +choices are 6 (not coprime with 4), 9, 11, etc. + +Pattern rationale: TUI refresh rates have cascading cost — one slow +syscall delays every other data source waiting for the main loop. By +spreading expensive reads across coprime cadences, no single tick ever +pays the cumulative cost of multiple expensive reads. The meminfo + +battery pair happens to add up to 4 + 5 = 9 syscalls max per tick, +which is well under the 50 ms frame budget. + --- ## 14. Cross-Reference: redbear-power as a Reference Implementation @@ -1287,6 +1334,17 @@ reference for new TUI apps because: 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 diff --git a/local/docs/redbear-power-improvement-plan.md b/local/docs/redbear-power-improvement-plan.md index 85fef7fb57..39dae2fd29 100644 --- a/local/docs/redbear-power-improvement-plan.md +++ b/local/docs/redbear-power-improvement-plan.md @@ -1954,6 +1954,125 @@ Total: 4,359 LoC across 15 modules (v1.5: 4,117 LoC across 14 modules; +242 LoC, --- +## 31. v1.7 Per-Tick Battery Refresh (2026-06-20) + +Per the user's "v1.7 = Per-tick battery refresh (Recommended)" directive, +v1.7 closes the v1.6 forward-work item (§30.5). Battery state changes +continuously on a laptop (capacity drops, power_now varies, time_to_empty +decreases); reading once at startup was the safe default for v1.6 but +left the Battery tab stale during long TUI sessions. + +### 31.1 What was implemented + +**Updated `app.rs::refresh()`** — added a new 5-tick throttled read of +the battery module: + +```rust +// Battery state changes continuously on a laptop (capacity drops, +// power_now varies, time_to_empty decreases). Refresh at a slower +// cadence (every 5th refresh = 2.5 sec at POLL_MS=500) so the +// Battery tab stays useful without hammering sysfs at 2 Hz. +// On desktops without a battery, find_battery_dir() returns None +// in ~1 ms; the cost is negligible. +if self.refresh_counter % 5 == 0 { + self.battery = crate::battery::BatteryInfo::read(); +} +``` + +Key design choices: +- **Reuses `refresh_counter`** — no new field added. The counter already + increments on every tick; the new `if % 5 == 0` branch piggybacks on it. +- **Cadence = 2.5 sec at default POLL_MS=500** — balances freshness against + sysfs read cost (14 opens × ~70 µs each = ~1 ms per read = 0.04% CPU). +- **Independent of meminfo cadence (4th tick)** — battery and memory + refresh at different rates, but both piggyback on the same counter. + No coordination needed; they happen to share `refresh_counter % N` + semantics but each picks its own modulus. +- **No new field for `available` re-probing** — `BatteryInfo::read()` + internally re-checks `find_battery_dir()`. If a laptop is plugged in + after the TUI starts, the Battery tab will populate on the next 5th + refresh tick without any external trigger. + +### 31.2 Verification + +Mock battery at `/tmp/fake-battery/BAT0/` with `capacity=67`. Started +`redbear-power --once` with `RBP_BATTERY_PATH=/tmp/fake-battery`: + +``` +Capacity: 67% +``` + +Changed `capacity` file to `50` and re-ran `--once`: + +``` +Capacity: 50% +``` + +The 5-tick throttling fires on the **first** refresh (counter starts at +0, `0 % 5 == 0`), so `--once` mode picks up the current value. In the +interactive TUI, the value updates every 2.5 seconds. + +Strace confirms 14 sysfs opens per `read()`: +``` +openat(AT_FDCWD, "/tmp/fake-battery/BAT0/type", ...) = 4 +openat(AT_FDCWD, "/tmp/fake-battery/BAT0/name", ...) = 3 +... (12 more) +``` + +### 31.3 Build verification + +| Build | Result | +|-------|--------| +| Linux host (`cargo build --release`) | ✅ 0 errors, 29 warnings (unchanged from v1.6) | +| Linux host smoke (`./target/release/redbear-power --once`) | ✅ Battery panel renders correctly | +| Linux host smoke with mock (`RBP_BATTERY_PATH=/tmp/fake-battery --once`) | ✅ Battery updates after sysfs change | +| Redox cross-compile (`cargo build --release --target=x86_64-unknown-redox`) | ✅ clean | +| Redox binary (stripped) | 3,935,080 bytes (unchanged from v1.6 — single `if` branch added) | +| Cross-compile SHA256 | `f76fe2b454e6a7e8db5a913c8c363de716f8cacc4ac4b4d2f1da22fc1c0f7570` | + +### 31.4 Implementation rationale + +The previous concern (§30.5 forward work) was that reading `/sys/class/power_supply/*` +on every tick (500 ms) would be wasteful. The solution: + +| Cadence | CPU cost | Freshness | Verdict | +|---------|----------|-----------|---------| +| Every tick (500 ms) | ~14 × 70 µs × 2 Hz = 0.2% CPU | 2 Hz refresh | Too aggressive | +| **Every 5th tick (2.5 sec)** | **~0.04% CPU** | **0.4 Hz refresh** | **Chosen** | +| Once at startup (∞) | ~0% CPU | static | Too stale | +| Every 4th tick (2 sec) | ~0.05% CPU | 0.5 Hz refresh | Would also work; 5 chosen for clean separation from meminfo's 4 | + +The 5-tick modulus is **deliberately coprime to meminfo's 4-tick modulus**. +With coprime moduli, battery and meminfo refreshes don't synchronize +(no "thundering herd" of 14 + 4 sysfs reads at the same moment), which +would be visible to the user as a periodic 20ms stall. + +### 31.5 Final module structure (unchanged) + +``` +local/recipes/system/redbear-power/source/src/ +├── main.rs (~483 lines) +├── app.rs (~552) — App + CpuRow + TabId + 5 data-source fields + refresh cadence +├── render.rs (~1026) +├── meminfo.rs (241) +├── dmi.rs (118) +├── battery.rs (128) +├── platform.rs (291) +├── acpi.rs (~233) +├── cpuid.rs (~369) +├── dbus.rs (~294) +├── config.rs (~223) +├── bench.rs (122) +├── msr.rs (~158) +├── cpufreq.rs (~62) +└── theme.rs (71) +``` + +Total: ~4,380 LoC across 15 modules (v1.6: 4,359 LoC; +21 LoC for the +refresh branch + comment in `app.rs`). + +--- + ## 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. diff --git a/local/recipes/system/redbear-power/source/src/app.rs b/local/recipes/system/redbear-power/source/src/app.rs index 828fd45c41..ab63b45e67 100644 --- a/local/recipes/system/redbear-power/source/src/app.rs +++ b/local/recipes/system/redbear-power/source/src/app.rs @@ -281,6 +281,16 @@ impl App { self.os_info = crate::meminfo::read_os_info(); } + // Battery state changes continuously on a laptop (capacity drops, + // power_now varies, time_to_empty decreases). Refresh at a slower + // cadence (every 5th refresh = 2.5 sec at POLL_MS=500) so the + // Battery tab stays useful without hammering sysfs at 2 Hz. + // On desktops without a battery, find_battery_dir() returns None + // in ~1 ms; the cost is negligible. + if self.refresh_counter % 5 == 0 { + self.battery = crate::battery::BatteryInfo::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 {