endura 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. endura-0.1.0/LICENSE +21 -0
  2. endura-0.1.0/PKG-INFO +229 -0
  3. endura-0.1.0/README.md +205 -0
  4. endura-0.1.0/endura/__init__.py +1 -0
  5. endura-0.1.0/endura/audio_gate.py +213 -0
  6. endura-0.1.0/endura/daemon.py +285 -0
  7. endura-0.1.0/endura/data/kwin/endura-focus/contents/code/main.js +7 -0
  8. endura-0.1.0/endura/data/kwin/endura-focus/metadata.json +13 -0
  9. endura-0.1.0/endura/data/packaging/endura-audio-gate.service +13 -0
  10. endura-0.1.0/endura/data/packaging/endura-nap.service +15 -0
  11. endura-0.1.0/endura/data/packaging/endurad.service +13 -0
  12. endura-0.1.0/endura/data/packaging/io.github.endura.desktop +9 -0
  13. endura-0.1.0/endura/data/tools/burst.c +23 -0
  14. endura-0.1.0/endura/data/tools/endura-bench +26 -0
  15. endura-0.1.0/endura/data/tscsync/LICENSE +21 -0
  16. endura-0.1.0/endura/data/tscsync/README.md +4 -0
  17. endura-0.1.0/endura/data/tscsync/set-mode.sh +38 -0
  18. endura-0.1.0/endura/defaults.py +47 -0
  19. endura-0.1.0/endura/gui.py +388 -0
  20. endura-0.1.0/endura/hw.py +119 -0
  21. endura-0.1.0/endura/i18n.py +20 -0
  22. endura-0.1.0/endura/inputwatch.py +64 -0
  23. endura-0.1.0/endura/installer.py +150 -0
  24. endura-0.1.0/endura/nap.py +253 -0
  25. endura-0.1.0/endura/panel.py +86 -0
  26. endura-0.1.0/endura/plasma/hide.js +24 -0
  27. endura-0.1.0/endura/plasma/show.js +29 -0
  28. endura-0.1.0/endura/telemetry.py +229 -0
  29. endura-0.1.0/endura/tweaks.py +1038 -0
  30. endura-0.1.0/endura/util.py +131 -0
  31. endura-0.1.0/endura.egg-info/PKG-INFO +229 -0
  32. endura-0.1.0/endura.egg-info/SOURCES.txt +36 -0
  33. endura-0.1.0/endura.egg-info/dependency_links.txt +1 -0
  34. endura-0.1.0/endura.egg-info/entry_points.txt +4 -0
  35. endura-0.1.0/endura.egg-info/requires.txt +3 -0
  36. endura-0.1.0/endura.egg-info/top_level.txt +1 -0
  37. endura-0.1.0/pyproject.toml +50 -0
  38. endura-0.1.0/setup.cfg +4 -0
endura-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Endura contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
endura-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,229 @@
1
+ Metadata-Version: 2.4
2
+ Name: endura
3
+ Version: 0.1.0
4
+ Summary: Measured, reversible battery-life tuning for Linux laptops, with a GUI
5
+ Author: Endura contributors
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/qvictl/endura
8
+ Project-URL: Issues, https://github.com/qvictl/endura/issues
9
+ Keywords: linux,laptop,battery,power-management,amd,kde,app-nap
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: X11 Applications :: Qt
12
+ Classifier: Intended Audience :: End Users/Desktop
13
+ Classifier: Operating System :: POSIX :: Linux
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: System :: Hardware
16
+ Classifier: Topic :: System :: Power (UPS)
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ License-File: endura/data/tscsync/LICENSE
21
+ Provides-Extra: gui
22
+ Requires-Dist: PyQt6>=6.5; extra == "gui"
23
+ Dynamic: license-file
24
+
25
+ # Endura
26
+
27
+ **Measured, reversible battery-life tuning for Linux laptops — with a GUI.**
28
+
29
+ [中文说明](README_zh.md)
30
+
31
+ Endura is a small root daemon, two user agents and a PyQt6 app. It applies a set of
32
+ power tweaks that were each **A/B-measured on real hardware**, can switch every one of
33
+ them on and off individually, and records per-app CPU time and wakeups so you can see
34
+ what is actually draining your battery.
35
+
36
+ It started as an investigation on one laptop that had been "fixed" by clamping the CPU
37
+ to 1.2 GHz and still only lasted ~6 hours. After the work below, the same machine idles
38
+ at **4.4 W with the screen on at 30 %** (≈ 22 h on its 99 Wh battery) while staying
39
+ *more* responsive than before. See the [case study](#case-study).
40
+
41
+ ![overview](docs/screenshot-overview.png)
42
+
43
+ ## What it does
44
+
45
+ | Tweak | What it changes | Measured effect (reference laptop) |
46
+ |---|---|---|
47
+ | **Responsive CPU policy** (battery) | EPP `balance_performance`, boost off, no max-freq cap, instead of the power-saver profile's EPP `power` | **−2.07 W** average (8 A/B rounds) *and* a 10 ms UI burst 34.5 → 13.3 ms |
48
+ | **Stop frequency cappers** | stops daemons that clamp `scaling_max_freq` | removing a 1.2 GHz clamp: **−1.5 W**, UI 3× faster |
49
+ | **TSC resync** (AMD, via [tscsync](https://github.com/Zanasin/tscsync)) | firmware left CPU0's TSC ~0.8 s behind, kernel fell back to HPET; tscsync aligns TSCs before Linux boots | clocksource back to `tsc`; every `clock_gettime()` is vDSO again instead of a syscall + HPET read |
50
+ | **Panel Self Refresh / memory stutter** (amdgpu) | undoes an old `amdgpu.dcdebugmask=0x12` workaround via an override file | idle FCLK 1875 → 400 MHz; PSR residency 0 → ~90 % on a static screen |
51
+ | **Silent audio stream gate** | a stream playing pure digital silence is parked on a null sink so codec + speaker amp suspend; back within ~0.2 s on real sound | **−1.2 W** (a browser tab played silence for hours) |
52
+ | **App Nap** (battery) | unfocused chat apps run in a 2 s frozen / 0.4 s running cycle; after 5 min without input, chat apps and browsers sleep ~9 s per 0.4 s; any focus change or keyboard/mouse input wakes them in ~40 ms; apps with audio are never napped | Feishu in background **−0.40 W**; static screen **5.1 → 4.4 W** |
53
+ | **Hide Plasma panel system monitors** (battery) | the panel's CPU/net/disk graphs repainted the screen ~55×/s so PSR never engaged | static screen: 55 → 0 display flips/s |
54
+ | **Adaptive timer slack** | daemons polling on 10 ms timers get 500 ms slack (remote-desktop tools) or 50 ms (others); events still wake them instantly; restored within ~10 s when a remote session starts | Sunlogin 586 → 16 wakeups/s, ToDesk 608 → 104 |
55
+ | **Coalesce disk writes** (battery) | btrfs/ext4 `commit=120,noatime`, 60 s dirty expiry (runtime remount, fstab untouched) | NVMe writes 3443 → 1541 per minute |
56
+ | **Busy-loop watchdog** | notifies when an app wakes the CPU >1500×/s for 3 min | caught a chat-app backend at ~3000 wakeups/s costing **~4 W** |
57
+ | **Crash-loop guard** | stops services restarting >20× in 10 min | caught an updater with a broken npm dependency after 1286 restarts |
58
+ | **Vendor unit fix** | units declared `Type=forking` that never fork (start timeout → restart forever) | a remote-desktop daemon had restarted 113× |
59
+ | PCI runtime PM, ASPM `powersupersave`, NMI watchdog off, trim idle services, browser energy saver policy | the usual suspects | small, kept because they are free |
60
+
61
+ Tested and **not** adopted (no measurable gain on the reference laptop): AMD ABM levels 1/3,
62
+ 120 Hz + VRR vs 60 Hz, SMT off, Bluetooth USB autosuspend, cgroup CPU quotas for chatty
63
+ apps (quotas do not stop wakeups — only freezing does).
64
+
65
+ ## Smoothness
66
+
67
+ Saving power by making the machine sluggish was explicitly not allowed. Endura's
68
+ criterion, checked by `endura-bench`:
69
+
70
+ > From idle, a UI-like task that takes 10 ms at full performance must finish with
71
+ > **p90 ≤ 16.7 ms** (one 60 Hz frame); a 2 ms task **p90 ≤ 8.3 ms** (one 120 Hz frame).
72
+
73
+ | Policy | 2 ms task p50 | 10 ms task p50 | verdict |
74
+ |---|---|---|---|
75
+ | EPP performance + boost (reference) | 2.3 ms | 10.7 ms | — |
76
+ | EPP `balance_performance`, boost off (**Endura**) | 3.0 ms | 13.3 ms | pass |
77
+ | EPP `balance_power` | 4.8 ms | 21.3 ms | fail |
78
+ | EPP `power` (typical power-saver) | 6.9 ms | 34.5 ms | fail |
79
+ | EPP `power` + 1.2 GHz clamp | 8.0 ms | 39.8 ms | fail |
80
+
81
+ ## Telemetry and tools
82
+
83
+ * **Overview**: live battery and SoC power, 1 h graph, every tweak with status
84
+ (active / battery only / needs reboot / not available on this hardware).
85
+ * **Apps**: per-app CPU time *and wakeups per second* over 1 h … 7 days (battery time only),
86
+ plus a **freeze probe** that measures an app's real power by freezing and thawing its
87
+ cgroup a few times. Wakeups often matter more than CPU %: a backend at 3 % CPU but
88
+ 3000 wakeups/s cost 4 W.
89
+ * **Smoothness**: runs the benchmark above.
90
+ * [`lab/`](lab/) has the A/B scripts used for every number on this page.
91
+
92
+ ## Requirements
93
+
94
+ * systemd, cgroup v2, Python 3.10+, PyQt6, pulsectl, dbus-python, PyGObject, PipeWire (pulse)
95
+ * Laptop with a battery exposed in `/sys/class/power_supply`
96
+ * Best support: AMD Ryzen APUs (SoC power telemetry, amdgpu PSR, TSC fix) and KDE Plasma 6
97
+ on Wayland (focus tracking for App Nap, panel monitor hiding). Generic tweaks work on Intel
98
+ and other desktops; tweaks whose hardware is missing show as *not available*.
99
+
100
+ ## Install
101
+
102
+ From PyPI:
103
+
104
+ ```sh
105
+ pipx install endura # or: pip install --user endura
106
+ sudo "$(which endura-install)" # installs deps (dnf / apt / pacman), daemon, GUI
107
+ endura-gui # or "Endura" in the application menu
108
+ ```
109
+
110
+ From source:
111
+
112
+ ```sh
113
+ git clone https://github.com/qvictl/endura.git
114
+ cd endura
115
+ sudo ./install.sh
116
+ ```
117
+
118
+ The system services run from a self-contained copy in `/usr/lib/endura` with the
119
+ distribution's `/usr/bin/python3`, so they do not depend on where pip put the package.
120
+
121
+ Most tweaks are on by default and apply only on battery. Reboot-scoped ones (TSC resync,
122
+ PSR, stutter) are off until you enable them in the GUI. For the TSC fix, install
123
+ [tscsync](https://github.com/Zanasin/tscsync) first — Endura only toggles it.
124
+
125
+ ```sh
126
+ sudo "$(which endura-uninstall)" # or ./uninstall.sh: reverts every tweak first, then removes Endura
127
+ ```
128
+
129
+ Every file a tweak modifies is backed up in `/var/lib/endura/backup`; module options go to a
130
+ separate override (`/etc/modprobe.d/zz-endura-amdgpu.conf`) instead of editing yours.
131
+
132
+ ## Configuration
133
+
134
+ `/etc/endura/config.json`:
135
+
136
+ ```json
137
+ {
138
+ "enabled": {"app_nap": true, "amdgpu_psr": true},
139
+ "options": {
140
+ "battery_epp": "balance_performance",
141
+ "slack_deep": ["sunloginclient", "ToDesk_Service"],
142
+ "nap_apps": {
143
+ "com.slack.Slack": {"globs": ["app-flatpak-com.slack.Slack-*.scope"], "cls": ["slack"], "tier": 1}
144
+ }
145
+ }
146
+ }
147
+ ```
148
+
149
+ Defaults and all keys: [`endura/defaults.py`](endura/defaults.py). `nap_apps` matches the
150
+ app's systemd cgroup (flatpak scopes, KDE/GNOME `app-*.service` units); tier 1 naps when
151
+ unfocused, tier 2 only after 5 minutes without input.
152
+
153
+ ## Architecture
154
+
155
+ | Part | Runs as | Role |
156
+ |---|---|---|
157
+ | `endura-install` | root, once | copies the package to `/usr/lib/endura`, installs units, KWin script, desktop file |
158
+ | `endurad` | root (systemd) | applies tweaks, follows AC/battery, telemetry in `/var/lib/endura/telemetry.db`, JSON API on `/run/endura/endura.sock` (group `wheel`), input-activity mark for App Nap |
159
+ | `endura-nap` + KWin script `endura-focus` | user (systemd --user) | App Nap: freezes cgroups (`cgroup.freeze`), wakes on focus (KWin → D-Bus) or input (inotify on `/run/endura/input`) |
160
+ | `endura-audio-gate` | user | silence detection with one low-rate `parec` monitor; park sink forced to a large quantum |
161
+ | `endura-gui` | user | PyQt6 front end |
162
+
163
+ The daemon itself was tuned too: a 5 s loop, few ACPI EC reads (each `power_supply` read is
164
+ an EC transaction on many laptops), batched SQLite commits, no PAM sessions per user command.
165
+ It costs ≈ 0.5 % of one core.
166
+
167
+ ## Case study
168
+
169
+ Reference machine: **Lenovo ThinkBook 14 G8+ AHP** — AMD Ryzen 7 H 255 (Zen 4, Radeon 780M),
170
+ 32 GB LPDDR5X, 3072×1920 120 Hz IPS panel, Intel AX210, 99.9 Wh battery, Fedora 43, KDE Plasma 6
171
+ Wayland, kernel 7.0. Daily apps that had to stay open: Feishu, Chrome, Sunlogin, Discord,
172
+ v2rayN, WeChat. Constraints: Wi-Fi and Bluetooth on, 30 % brightness, no loss of smoothness.
173
+
174
+ **Starting point.** A previous "fix" ran a feedback loop that lowered `scaling_max_freq` until
175
+ battery draw fell under 12 W, plus cgroup CPU quotas on the chat apps. It sat at the 1.2 GHz
176
+ floor all day; the machine drew 14–16 W and lasted ≈ 6 h. A 10 ms UI task took 40 ms.
177
+
178
+ **Investigation, in the order things were found** (whole-system battery power, A/B, screen
179
+ on, apps open unless noted):
180
+
181
+ 1. **The clamp was counter-productive.** Removing it lowered draw by ~1.5 W: slow cores stay
182
+ in C0 longer and keep the fabric awake. Power-saver's EPP `power` was the next culprit:
183
+ `balance_performance` with boost off was −2.07 W *and* 2.6× faster.
184
+ 2. **A browser tab played digital silence** for hours, keeping the codec and speaker amp on:
185
+ −1.2 W when the stream was parked on a null sink. This became the audio gate.
186
+ 3. **The kernel was on HPET.** dmesg: `Measured 3168609404 cycles TSC warp between CPUs,
187
+ turning off TSC clock`. The BIOS boots CPU0 ~0.8 s behind the other 15 threads; AMD has no
188
+ TSC_ADJUST. Every `clock_gettime()` became a syscall plus a slow MMIO read (one chat
189
+ backend did 3000 per second). Fixed with tscsync; offsets after boot ±16 cycles.
190
+ 4. **`amdgpu.dcdebugmask=0x12`** (an old flicker workaround) disabled both PSR and memory
191
+ stutter: FCLK was pinned at 1875 MHz. Re-enabled; FCLK idles at 400 MHz, no flicker so far.
192
+ 5. **One chat app's backend was stuck in a busy-wake loop**: its Rust task scheduler woke 15
193
+ workers every 5 ms (~3000 wakeups/s at 3 % CPU). Freezing just that process dropped the
194
+ whole system from 14.8 W to 10.1 W — **~4 W from one process**. An update + restart fixed it;
195
+ cgroup CPU quotas (what the old setup used) did nothing because they do not stop wakeups.
196
+ 6. **A remote-desktop daemon was in a start-timeout loop** (`Type=forking`, never forks;
197
+ 113 restarts) and, once fixed, still polled every 10 ms on 6 threads. Timer slack took it
198
+ from 586 to 16 wakeups/s without affecting remote sessions.
199
+ 7. **Freeze probes per app** with a static screen: chat app #1 0.51 W, chat app #2 0.30 W,
200
+ browser 0.08 W, Discord ≈ 0, all four together 1.35 W (more than the sum — the package only
201
+ reaches deep idle when *everything* is quiet). This became App Nap: −0.40 W with the chat
202
+ app in the background.
203
+ 8. **The screen was never static.** Tracing `drm_vblank_event` showed ~55 flips/s on an idle
204
+ desktop. Freezing cgroups one by one pinned it on the Plasma panel's seven system-monitor
205
+ widgets. Hidden on battery → 0 flips/s, PSR residency ~90 %.
206
+ 9. **The SSD never slept**: 57 writes/s from writeback and 30 s btrfs commits. `commit=120,noatime`
207
+ on battery halved the write count.
208
+ 10. **Housekeeping**: an updater crash-looping every 10 s (1286 restarts) → crash-loop guard;
209
+ the daemon's own cost cut from 2.5 % to 0.5 % of a core.
210
+
211
+ **Static screen, 30 % brightness, all apps open:**
212
+
213
+ | Stage | System power | Runtime on 99 Wh |
214
+ |---|---|---|
215
+ | Start (frequency clamp) | 14–16 W | ~6 h |
216
+ | CPU policy, audio gate, clamp removed | ~8 W (in use) | ~12 h |
217
+ | + TSC, PSR, stutter (reboot) | 6.0–6.3 W | ~16 h |
218
+ | + App Nap, timer slack | 5.4 W | ~18 h |
219
+ | + panel monitors hidden, write coalescing, deep-idle nap | **4.4 W** | **~22 h** |
220
+
221
+ Remaining floor (all apps frozen, PSR active, CPU < 2 %): SoC ≈ 2.9 W (display engine, memory
222
+ controller, fabric), panel ≈ 1.2 W of which the 30 % backlight is ≈ 0.6 W.
223
+
224
+ Evaluated and rejected: rebuilding the kernel for `CONFIG_RCU_LAZY` / the TEO idle governor
225
+ (Fedora ships neither; RCU woke only ~15×/s here — not worth a custom kernel on every update).
226
+
227
+ ## License
228
+
229
+ MIT. `endura/data/tscsync/set-mode.sh` is from [tscsync](https://github.com/Zanasin/tscsync) (MIT).
endura-0.1.0/README.md ADDED
@@ -0,0 +1,205 @@
1
+ # Endura
2
+
3
+ **Measured, reversible battery-life tuning for Linux laptops — with a GUI.**
4
+
5
+ [中文说明](README_zh.md)
6
+
7
+ Endura is a small root daemon, two user agents and a PyQt6 app. It applies a set of
8
+ power tweaks that were each **A/B-measured on real hardware**, can switch every one of
9
+ them on and off individually, and records per-app CPU time and wakeups so you can see
10
+ what is actually draining your battery.
11
+
12
+ It started as an investigation on one laptop that had been "fixed" by clamping the CPU
13
+ to 1.2 GHz and still only lasted ~6 hours. After the work below, the same machine idles
14
+ at **4.4 W with the screen on at 30 %** (≈ 22 h on its 99 Wh battery) while staying
15
+ *more* responsive than before. See the [case study](#case-study).
16
+
17
+ ![overview](docs/screenshot-overview.png)
18
+
19
+ ## What it does
20
+
21
+ | Tweak | What it changes | Measured effect (reference laptop) |
22
+ |---|---|---|
23
+ | **Responsive CPU policy** (battery) | EPP `balance_performance`, boost off, no max-freq cap, instead of the power-saver profile's EPP `power` | **−2.07 W** average (8 A/B rounds) *and* a 10 ms UI burst 34.5 → 13.3 ms |
24
+ | **Stop frequency cappers** | stops daemons that clamp `scaling_max_freq` | removing a 1.2 GHz clamp: **−1.5 W**, UI 3× faster |
25
+ | **TSC resync** (AMD, via [tscsync](https://github.com/Zanasin/tscsync)) | firmware left CPU0's TSC ~0.8 s behind, kernel fell back to HPET; tscsync aligns TSCs before Linux boots | clocksource back to `tsc`; every `clock_gettime()` is vDSO again instead of a syscall + HPET read |
26
+ | **Panel Self Refresh / memory stutter** (amdgpu) | undoes an old `amdgpu.dcdebugmask=0x12` workaround via an override file | idle FCLK 1875 → 400 MHz; PSR residency 0 → ~90 % on a static screen |
27
+ | **Silent audio stream gate** | a stream playing pure digital silence is parked on a null sink so codec + speaker amp suspend; back within ~0.2 s on real sound | **−1.2 W** (a browser tab played silence for hours) |
28
+ | **App Nap** (battery) | unfocused chat apps run in a 2 s frozen / 0.4 s running cycle; after 5 min without input, chat apps and browsers sleep ~9 s per 0.4 s; any focus change or keyboard/mouse input wakes them in ~40 ms; apps with audio are never napped | Feishu in background **−0.40 W**; static screen **5.1 → 4.4 W** |
29
+ | **Hide Plasma panel system monitors** (battery) | the panel's CPU/net/disk graphs repainted the screen ~55×/s so PSR never engaged | static screen: 55 → 0 display flips/s |
30
+ | **Adaptive timer slack** | daemons polling on 10 ms timers get 500 ms slack (remote-desktop tools) or 50 ms (others); events still wake them instantly; restored within ~10 s when a remote session starts | Sunlogin 586 → 16 wakeups/s, ToDesk 608 → 104 |
31
+ | **Coalesce disk writes** (battery) | btrfs/ext4 `commit=120,noatime`, 60 s dirty expiry (runtime remount, fstab untouched) | NVMe writes 3443 → 1541 per minute |
32
+ | **Busy-loop watchdog** | notifies when an app wakes the CPU >1500×/s for 3 min | caught a chat-app backend at ~3000 wakeups/s costing **~4 W** |
33
+ | **Crash-loop guard** | stops services restarting >20× in 10 min | caught an updater with a broken npm dependency after 1286 restarts |
34
+ | **Vendor unit fix** | units declared `Type=forking` that never fork (start timeout → restart forever) | a remote-desktop daemon had restarted 113× |
35
+ | PCI runtime PM, ASPM `powersupersave`, NMI watchdog off, trim idle services, browser energy saver policy | the usual suspects | small, kept because they are free |
36
+
37
+ Tested and **not** adopted (no measurable gain on the reference laptop): AMD ABM levels 1/3,
38
+ 120 Hz + VRR vs 60 Hz, SMT off, Bluetooth USB autosuspend, cgroup CPU quotas for chatty
39
+ apps (quotas do not stop wakeups — only freezing does).
40
+
41
+ ## Smoothness
42
+
43
+ Saving power by making the machine sluggish was explicitly not allowed. Endura's
44
+ criterion, checked by `endura-bench`:
45
+
46
+ > From idle, a UI-like task that takes 10 ms at full performance must finish with
47
+ > **p90 ≤ 16.7 ms** (one 60 Hz frame); a 2 ms task **p90 ≤ 8.3 ms** (one 120 Hz frame).
48
+
49
+ | Policy | 2 ms task p50 | 10 ms task p50 | verdict |
50
+ |---|---|---|---|
51
+ | EPP performance + boost (reference) | 2.3 ms | 10.7 ms | — |
52
+ | EPP `balance_performance`, boost off (**Endura**) | 3.0 ms | 13.3 ms | pass |
53
+ | EPP `balance_power` | 4.8 ms | 21.3 ms | fail |
54
+ | EPP `power` (typical power-saver) | 6.9 ms | 34.5 ms | fail |
55
+ | EPP `power` + 1.2 GHz clamp | 8.0 ms | 39.8 ms | fail |
56
+
57
+ ## Telemetry and tools
58
+
59
+ * **Overview**: live battery and SoC power, 1 h graph, every tweak with status
60
+ (active / battery only / needs reboot / not available on this hardware).
61
+ * **Apps**: per-app CPU time *and wakeups per second* over 1 h … 7 days (battery time only),
62
+ plus a **freeze probe** that measures an app's real power by freezing and thawing its
63
+ cgroup a few times. Wakeups often matter more than CPU %: a backend at 3 % CPU but
64
+ 3000 wakeups/s cost 4 W.
65
+ * **Smoothness**: runs the benchmark above.
66
+ * [`lab/`](lab/) has the A/B scripts used for every number on this page.
67
+
68
+ ## Requirements
69
+
70
+ * systemd, cgroup v2, Python 3.10+, PyQt6, pulsectl, dbus-python, PyGObject, PipeWire (pulse)
71
+ * Laptop with a battery exposed in `/sys/class/power_supply`
72
+ * Best support: AMD Ryzen APUs (SoC power telemetry, amdgpu PSR, TSC fix) and KDE Plasma 6
73
+ on Wayland (focus tracking for App Nap, panel monitor hiding). Generic tweaks work on Intel
74
+ and other desktops; tweaks whose hardware is missing show as *not available*.
75
+
76
+ ## Install
77
+
78
+ From PyPI:
79
+
80
+ ```sh
81
+ pipx install endura # or: pip install --user endura
82
+ sudo "$(which endura-install)" # installs deps (dnf / apt / pacman), daemon, GUI
83
+ endura-gui # or "Endura" in the application menu
84
+ ```
85
+
86
+ From source:
87
+
88
+ ```sh
89
+ git clone https://github.com/qvictl/endura.git
90
+ cd endura
91
+ sudo ./install.sh
92
+ ```
93
+
94
+ The system services run from a self-contained copy in `/usr/lib/endura` with the
95
+ distribution's `/usr/bin/python3`, so they do not depend on where pip put the package.
96
+
97
+ Most tweaks are on by default and apply only on battery. Reboot-scoped ones (TSC resync,
98
+ PSR, stutter) are off until you enable them in the GUI. For the TSC fix, install
99
+ [tscsync](https://github.com/Zanasin/tscsync) first — Endura only toggles it.
100
+
101
+ ```sh
102
+ sudo "$(which endura-uninstall)" # or ./uninstall.sh: reverts every tweak first, then removes Endura
103
+ ```
104
+
105
+ Every file a tweak modifies is backed up in `/var/lib/endura/backup`; module options go to a
106
+ separate override (`/etc/modprobe.d/zz-endura-amdgpu.conf`) instead of editing yours.
107
+
108
+ ## Configuration
109
+
110
+ `/etc/endura/config.json`:
111
+
112
+ ```json
113
+ {
114
+ "enabled": {"app_nap": true, "amdgpu_psr": true},
115
+ "options": {
116
+ "battery_epp": "balance_performance",
117
+ "slack_deep": ["sunloginclient", "ToDesk_Service"],
118
+ "nap_apps": {
119
+ "com.slack.Slack": {"globs": ["app-flatpak-com.slack.Slack-*.scope"], "cls": ["slack"], "tier": 1}
120
+ }
121
+ }
122
+ }
123
+ ```
124
+
125
+ Defaults and all keys: [`endura/defaults.py`](endura/defaults.py). `nap_apps` matches the
126
+ app's systemd cgroup (flatpak scopes, KDE/GNOME `app-*.service` units); tier 1 naps when
127
+ unfocused, tier 2 only after 5 minutes without input.
128
+
129
+ ## Architecture
130
+
131
+ | Part | Runs as | Role |
132
+ |---|---|---|
133
+ | `endura-install` | root, once | copies the package to `/usr/lib/endura`, installs units, KWin script, desktop file |
134
+ | `endurad` | root (systemd) | applies tweaks, follows AC/battery, telemetry in `/var/lib/endura/telemetry.db`, JSON API on `/run/endura/endura.sock` (group `wheel`), input-activity mark for App Nap |
135
+ | `endura-nap` + KWin script `endura-focus` | user (systemd --user) | App Nap: freezes cgroups (`cgroup.freeze`), wakes on focus (KWin → D-Bus) or input (inotify on `/run/endura/input`) |
136
+ | `endura-audio-gate` | user | silence detection with one low-rate `parec` monitor; park sink forced to a large quantum |
137
+ | `endura-gui` | user | PyQt6 front end |
138
+
139
+ The daemon itself was tuned too: a 5 s loop, few ACPI EC reads (each `power_supply` read is
140
+ an EC transaction on many laptops), batched SQLite commits, no PAM sessions per user command.
141
+ It costs ≈ 0.5 % of one core.
142
+
143
+ ## Case study
144
+
145
+ Reference machine: **Lenovo ThinkBook 14 G8+ AHP** — AMD Ryzen 7 H 255 (Zen 4, Radeon 780M),
146
+ 32 GB LPDDR5X, 3072×1920 120 Hz IPS panel, Intel AX210, 99.9 Wh battery, Fedora 43, KDE Plasma 6
147
+ Wayland, kernel 7.0. Daily apps that had to stay open: Feishu, Chrome, Sunlogin, Discord,
148
+ v2rayN, WeChat. Constraints: Wi-Fi and Bluetooth on, 30 % brightness, no loss of smoothness.
149
+
150
+ **Starting point.** A previous "fix" ran a feedback loop that lowered `scaling_max_freq` until
151
+ battery draw fell under 12 W, plus cgroup CPU quotas on the chat apps. It sat at the 1.2 GHz
152
+ floor all day; the machine drew 14–16 W and lasted ≈ 6 h. A 10 ms UI task took 40 ms.
153
+
154
+ **Investigation, in the order things were found** (whole-system battery power, A/B, screen
155
+ on, apps open unless noted):
156
+
157
+ 1. **The clamp was counter-productive.** Removing it lowered draw by ~1.5 W: slow cores stay
158
+ in C0 longer and keep the fabric awake. Power-saver's EPP `power` was the next culprit:
159
+ `balance_performance` with boost off was −2.07 W *and* 2.6× faster.
160
+ 2. **A browser tab played digital silence** for hours, keeping the codec and speaker amp on:
161
+ −1.2 W when the stream was parked on a null sink. This became the audio gate.
162
+ 3. **The kernel was on HPET.** dmesg: `Measured 3168609404 cycles TSC warp between CPUs,
163
+ turning off TSC clock`. The BIOS boots CPU0 ~0.8 s behind the other 15 threads; AMD has no
164
+ TSC_ADJUST. Every `clock_gettime()` became a syscall plus a slow MMIO read (one chat
165
+ backend did 3000 per second). Fixed with tscsync; offsets after boot ±16 cycles.
166
+ 4. **`amdgpu.dcdebugmask=0x12`** (an old flicker workaround) disabled both PSR and memory
167
+ stutter: FCLK was pinned at 1875 MHz. Re-enabled; FCLK idles at 400 MHz, no flicker so far.
168
+ 5. **One chat app's backend was stuck in a busy-wake loop**: its Rust task scheduler woke 15
169
+ workers every 5 ms (~3000 wakeups/s at 3 % CPU). Freezing just that process dropped the
170
+ whole system from 14.8 W to 10.1 W — **~4 W from one process**. An update + restart fixed it;
171
+ cgroup CPU quotas (what the old setup used) did nothing because they do not stop wakeups.
172
+ 6. **A remote-desktop daemon was in a start-timeout loop** (`Type=forking`, never forks;
173
+ 113 restarts) and, once fixed, still polled every 10 ms on 6 threads. Timer slack took it
174
+ from 586 to 16 wakeups/s without affecting remote sessions.
175
+ 7. **Freeze probes per app** with a static screen: chat app #1 0.51 W, chat app #2 0.30 W,
176
+ browser 0.08 W, Discord ≈ 0, all four together 1.35 W (more than the sum — the package only
177
+ reaches deep idle when *everything* is quiet). This became App Nap: −0.40 W with the chat
178
+ app in the background.
179
+ 8. **The screen was never static.** Tracing `drm_vblank_event` showed ~55 flips/s on an idle
180
+ desktop. Freezing cgroups one by one pinned it on the Plasma panel's seven system-monitor
181
+ widgets. Hidden on battery → 0 flips/s, PSR residency ~90 %.
182
+ 9. **The SSD never slept**: 57 writes/s from writeback and 30 s btrfs commits. `commit=120,noatime`
183
+ on battery halved the write count.
184
+ 10. **Housekeeping**: an updater crash-looping every 10 s (1286 restarts) → crash-loop guard;
185
+ the daemon's own cost cut from 2.5 % to 0.5 % of a core.
186
+
187
+ **Static screen, 30 % brightness, all apps open:**
188
+
189
+ | Stage | System power | Runtime on 99 Wh |
190
+ |---|---|---|
191
+ | Start (frequency clamp) | 14–16 W | ~6 h |
192
+ | CPU policy, audio gate, clamp removed | ~8 W (in use) | ~12 h |
193
+ | + TSC, PSR, stutter (reboot) | 6.0–6.3 W | ~16 h |
194
+ | + App Nap, timer slack | 5.4 W | ~18 h |
195
+ | + panel monitors hidden, write coalescing, deep-idle nap | **4.4 W** | **~22 h** |
196
+
197
+ Remaining floor (all apps frozen, PSR active, CPU < 2 %): SoC ≈ 2.9 W (display engine, memory
198
+ controller, fabric), panel ≈ 1.2 W of which the 30 % backlight is ≈ 0.6 W.
199
+
200
+ Evaluated and rejected: rebuilding the kernel for `CONFIG_RCU_LAZY` / the TEO idle governor
201
+ (Fedora ships neither; RCU woke only ~15×/s here — not worth a custom kernel on every update).
202
+
203
+ ## License
204
+
205
+ MIT. `endura/data/tscsync/set-mode.sh` is from [tscsync](https://github.com/Zanasin/tscsync) (MIT).
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,213 @@
1
+ """endura-audio-gate: park digitally-silent playback on a null sink.
2
+
3
+ Some apps keep an output stream open that plays pure silence for hours
4
+ (measured: a Chrome tab). That keeps the codec, the speaker amplifier and the
5
+ audio coprocessor powered: ~1.2 W on the reference laptop. When everything a hardware
6
+ sink plays has been silent for SILENT_SECS, its streams are moved to a null
7
+ sink (the hardware sink then suspends) and moved back as soon as any of them
8
+ makes a sound.
9
+
10
+ Level detection uses one long-lived `parec` on a sink monitor at a very low
11
+ sample rate, so the gate itself causes almost no wakeups. Brand-new streams
12
+ that land on the null sink (stream-restore remembering it) are moved to the
13
+ default sink at once.
14
+ """
15
+ import array
16
+ import os
17
+ import select
18
+ import signal
19
+ import subprocess
20
+ import sys
21
+ import time
22
+
23
+ import pulsectl
24
+
25
+ NULL = "endura_silence"
26
+ SILENT_SECS = 15
27
+ LEVEL = 4 # |s16| above this = sound (~ -78 dBFS)
28
+ RATE = 1000 # monitor sample rate; enough to see any signal energy
29
+
30
+
31
+ class Monitor:
32
+ """parec on <sink>.monitor; .loud() tells whether sound arrived since last call."""
33
+
34
+ def __init__(self, sink_name):
35
+ self.sink = sink_name
36
+ self.p = subprocess.Popen(
37
+ ["parec", f"--device={sink_name}.monitor", "--format=s16le", f"--rate={RATE}",
38
+ "--channels=1", "--latency-msec=200", "--raw", "--client-name=endura-audio-gate",
39
+ "--stream-name=endura-level"],
40
+ stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
41
+ os.set_blocking(self.p.stdout.fileno(), False)
42
+ self.started = time.monotonic()
43
+
44
+ def loud(self, timeout):
45
+ r, _, _ = select.select([self.p.stdout], [], [], timeout)
46
+ if not r:
47
+ return False
48
+ data = self.p.stdout.read(65536) or b""
49
+ a = array.array("h", data[: len(data) // 2 * 2])
50
+ return any(abs(x) > LEVEL for x in a)
51
+
52
+ def alive(self):
53
+ return self.p.poll() is None
54
+
55
+ def close(self):
56
+ self.p.terminate()
57
+ try:
58
+ self.p.wait(2)
59
+ except subprocess.TimeoutExpired:
60
+ self.p.kill()
61
+
62
+
63
+ class Gate:
64
+ def __init__(self):
65
+ self.pa = pulsectl.Pulse("endura-audio-gate")
66
+ self.mod = None
67
+ self.mon = None # Monitor
68
+ self.parked = {} # sink-input index -> original sink name
69
+ self.last_loud = time.monotonic()
70
+ self.running = True
71
+
72
+ def null_sink(self):
73
+ for s in self.pa.sink_list():
74
+ if s.name == NULL:
75
+ return s
76
+ self.mod = self.pa.module_load(
77
+ "module-null-sink",
78
+ f"sink_name={NULL} sink_properties='device.description=Endura-silence-park "
79
+ "priority.session=0 node.force-quantum=8192'")
80
+ return next(s for s in self.pa.sink_list() if s.name == NULL)
81
+
82
+ def default_hw_sink(self):
83
+ name = self.pa.server_info().default_sink_name
84
+ for s in self.pa.sink_list():
85
+ if s.name == name and name != NULL:
86
+ return s
87
+ hw = [s for s in self.pa.sink_list() if s.name != NULL]
88
+ return hw[0] if hw else None
89
+
90
+ def mic_active(self):
91
+ return any(so.proplist.get("application.name") != "endura-audio-gate"
92
+ for so in self.pa.source_output_list()
93
+ if not so.proplist.get("stream.monitor") and ".monitor" not in str(so.proplist.get("target.object", "")))
94
+
95
+ def streams(self):
96
+ return [si for si in self.pa.sink_input_list()
97
+ if si.proplist.get("application.name") != "endura-audio-gate"]
98
+
99
+ def watch(self, sink_name):
100
+ if self.mon and self.mon.sink == sink_name and self.mon.alive():
101
+ return
102
+ if self.mon:
103
+ self.mon.close()
104
+ self.mon = Monitor(sink_name) if sink_name else None
105
+
106
+ def log(self, msg):
107
+ print(msg, flush=True)
108
+
109
+ # ------------------------------------------------------------ logic --
110
+ def park_all(self, sink):
111
+ null = self.null_sink()
112
+ self.watch(None) # a monitor capture would keep the hardware sink awake
113
+ for si in self.streams():
114
+ if si.sink == sink.index:
115
+ self.parked[si.index] = sink.name
116
+ self.pa.sink_input_move(si.index, null.index)
117
+ self.log(f"park #{si.index} {si.proplist.get('application.name')} from {sink.name}")
118
+ self.watch(NULL)
119
+
120
+ def unpark_all(self, why):
121
+ self.watch(None)
122
+ null = self.null_sink()
123
+ for si in self.streams():
124
+ if si.sink == null.index:
125
+ target = self.parked.get(si.index)
126
+ sink = None
127
+ if target:
128
+ sink = next((s for s in self.pa.sink_list() if s.name == target), None)
129
+ sink = sink or self.default_hw_sink()
130
+ if sink:
131
+ self.pa.sink_input_move(si.index, sink.index)
132
+ self.log(f"unpark #{si.index} {si.proplist.get('application.name')} -> {sink.name} ({why})")
133
+ self.parked.clear()
134
+ self.last_loud = time.monotonic()
135
+
136
+ def step(self):
137
+ null = self.null_sink()
138
+ streams = self.streams()
139
+ alive = {si.index for si in streams}
140
+ self.parked = {k: v for k, v in self.parked.items() if k in alive}
141
+ # streams that arrived on the park sink by themselves go home immediately
142
+ for si in streams:
143
+ if si.sink == null.index and si.index not in self.parked:
144
+ hw = self.default_hw_sink()
145
+ if hw:
146
+ self.pa.sink_input_move(si.index, hw.index)
147
+ self.log(f"rescue #{si.index} {si.proplist.get('application.name')} -> {hw.name}")
148
+ streams = self.streams()
149
+ on_hw = [si for si in streams if si.sink != null.index]
150
+ on_null = [si for si in streams if si.sink == null.index]
151
+
152
+ if on_null:
153
+ if self.mon is None or self.mon.sink != NULL:
154
+ self.watch(NULL)
155
+ if self.mon.loud(0.25):
156
+ self.unpark_all("sound")
157
+ return
158
+ if on_hw:
159
+ # watch the hardware sink the (first) active stream plays to
160
+ sink = next((s for s in self.pa.sink_list() if s.index == on_hw[0].sink), None)
161
+ if not sink:
162
+ return
163
+ if not on_null:
164
+ self.watch(sink.name)
165
+ if self.mon.loud(0.5):
166
+ self.last_loud = time.monotonic()
167
+ if self.mic_active():
168
+ self.last_loud = time.monotonic()
169
+ if all(si.corked for si in on_hw):
170
+ return
171
+ if time.monotonic() - self.last_loud >= SILENT_SECS and \
172
+ time.monotonic() - (self.mon.started if self.mon else 0) >= 2:
173
+ self.park_all(sink)
174
+ elif not on_null:
175
+ self.watch(None)
176
+ self.last_loud = time.monotonic()
177
+ time.sleep(1.0)
178
+
179
+ def restore(self):
180
+ try:
181
+ self.unpark_all("shutdown")
182
+ for m in self.pa.module_list():
183
+ if m.name == "module-null-sink" and NULL in (m.argument or ""):
184
+ self.pa.module_unload(m.index)
185
+ except pulsectl.PulseError:
186
+ pass
187
+ self.watch(None)
188
+
189
+ def run(self):
190
+ signal.signal(signal.SIGTERM, lambda *a: setattr(self, "running", False))
191
+ signal.signal(signal.SIGINT, lambda *a: setattr(self, "running", False))
192
+ try:
193
+ while self.running:
194
+ self.step()
195
+ finally:
196
+ self.restore()
197
+
198
+
199
+ def main():
200
+ while True:
201
+ try:
202
+ Gate().run()
203
+ return
204
+ except (pulsectl.PulseDisconnected, pulsectl.PulseError) as e:
205
+ print("pulse:", e, file=sys.stderr, flush=True)
206
+ time.sleep(3)
207
+ except Exception as e:
208
+ print("gate error:", repr(e), file=sys.stderr, flush=True)
209
+ time.sleep(5)
210
+
211
+
212
+ if __name__ == "__main__":
213
+ main()