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.
- endura-0.1.0/LICENSE +21 -0
- endura-0.1.0/PKG-INFO +229 -0
- endura-0.1.0/README.md +205 -0
- endura-0.1.0/endura/__init__.py +1 -0
- endura-0.1.0/endura/audio_gate.py +213 -0
- endura-0.1.0/endura/daemon.py +285 -0
- endura-0.1.0/endura/data/kwin/endura-focus/contents/code/main.js +7 -0
- endura-0.1.0/endura/data/kwin/endura-focus/metadata.json +13 -0
- endura-0.1.0/endura/data/packaging/endura-audio-gate.service +13 -0
- endura-0.1.0/endura/data/packaging/endura-nap.service +15 -0
- endura-0.1.0/endura/data/packaging/endurad.service +13 -0
- endura-0.1.0/endura/data/packaging/io.github.endura.desktop +9 -0
- endura-0.1.0/endura/data/tools/burst.c +23 -0
- endura-0.1.0/endura/data/tools/endura-bench +26 -0
- endura-0.1.0/endura/data/tscsync/LICENSE +21 -0
- endura-0.1.0/endura/data/tscsync/README.md +4 -0
- endura-0.1.0/endura/data/tscsync/set-mode.sh +38 -0
- endura-0.1.0/endura/defaults.py +47 -0
- endura-0.1.0/endura/gui.py +388 -0
- endura-0.1.0/endura/hw.py +119 -0
- endura-0.1.0/endura/i18n.py +20 -0
- endura-0.1.0/endura/inputwatch.py +64 -0
- endura-0.1.0/endura/installer.py +150 -0
- endura-0.1.0/endura/nap.py +253 -0
- endura-0.1.0/endura/panel.py +86 -0
- endura-0.1.0/endura/plasma/hide.js +24 -0
- endura-0.1.0/endura/plasma/show.js +29 -0
- endura-0.1.0/endura/telemetry.py +229 -0
- endura-0.1.0/endura/tweaks.py +1038 -0
- endura-0.1.0/endura/util.py +131 -0
- endura-0.1.0/endura.egg-info/PKG-INFO +229 -0
- endura-0.1.0/endura.egg-info/SOURCES.txt +36 -0
- endura-0.1.0/endura.egg-info/dependency_links.txt +1 -0
- endura-0.1.0/endura.egg-info/entry_points.txt +4 -0
- endura-0.1.0/endura.egg-info/requires.txt +3 -0
- endura-0.1.0/endura.egg-info/top_level.txt +1 -0
- endura-0.1.0/pyproject.toml +50 -0
- 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
|
+

|
|
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
|
+

|
|
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()
|