Krolyxon
Commit a9ecc13

Branches

Refs →

auroraa9ecc13

aurora: battery care (charge limit, low battery warnings)

Author
krolyxon
Date
2026-10-25 15:15 +0530
Parent
b2c867c
Commit
a9ecc13a34368095e6a2f8749073644344793e0a

+238 −3

6 files changed

  1. A .config/quickshell/aurora/Battery.qml +165 −0
  2. M .config/quickshell/aurora/BatteryInfo.qml +3 −0
  3. M .config/quickshell/aurora/SetPower.qml +58 −1
  4. M .config/quickshell/aurora/Settings.qml +10 −1
  5. M .config/quickshell/aurora/SysPage.qml +1 −1
  6. M .config/quickshell/aurora/shell.qml +1 −0
A .config/quickshell/aurora/Battery.qml View
@@ -0,0 +1,165 @@
1+pragma Singleton
2+import QtQuick
3+import Quickshell
4+import Quickshell.Io
5+import Quickshell.Services.UPower
6+
7+// Battery care: charge limit and low-battery warnings.
8+// The Legion firmware only has a fixed cap ("conservation mode", sysfs charge_types Long_Life). UPower
9+// switches it without sudo (EnableChargeThreshold, polkit allow_active) but Quickshell's UPower doesn't
10+// expose that, hence busctl. Settings.chargeMode:
11+// off normal charging
12+// conservation firmware cap on all the time (kept by the EC, works without aurora)
13+// limit80 aurora turns the cap on at 80% while plugged in (charging stops and the charge is held),
14+// and off again below 75% or when unplugged. Only while aurora runs.
15+Singleton {
16+ id: root
17+
18+ property string path: "" // the battery's UPower object, found with `upower -e`
19+ property bool supported: false // ChargeThresholdSupported
20+ property bool capOn: false // ChargeThresholdEnabled (conservation mode right now)
21+ readonly property bool ready: path !== ""
22+ // UPower reports 0% for a moment at startup: no decisions before this
23+ property bool started: false
24+ Timer { interval: 4000; running: true; onTriggered: { root.started = true; root.evaluate(); root.checkLow() } }
25+
26+ readonly property var bat: UPower.displayDevice
27+ readonly property bool hasBat: bat !== null && bat.isPresent
28+ readonly property int pct: hasBat ? Math.round(bat.percentage > 1 ? bat.percentage : bat.percentage * 100) : 100
29+ readonly property bool onAc: !UPower.onBattery
30+ // plugged in but not charging because of the cap
31+ readonly property bool held: hasBat && onAc && capOn && bat.state !== UPowerDeviceState.Charging
32+
33+ // ---- charge limit
34+ function busctl(args) {
35+ return ["busctl", "--system"].concat(args.slice(0, 1), ["org.freedesktop.UPower", root.path,
36+ "org.freedesktop.UPower.Device"], args.slice(1))
37+ }
38+ function setCap(on) {
39+ if (!ready || !supported || setProc.running) return
40+ setProc.want = on
41+ setProc.command = busctl(["call", "EnableChargeThreshold", "b", on ? "true" : "false"])
42+ setProc.running = true
43+ }
44+ // what the cap should be for the current mode / charge / power source
45+ function evaluate() {
46+ if (!started || !ready || !supported || !hasBat || pct <= 0) return
47+ var mode = Settings.chargeMode, want = capOn
48+ if (mode === "conservation") want = true
49+ else if (mode === "limit80") {
50+ if (!onAc || pct < 75) want = false // unplugged: replugging under 80% charges again
51+ else if (pct >= 80) want = true
52+ } else want = false
53+ if (want !== capOn) setCap(want)
54+ }
55+
56+ onPctChanged: { evaluate(); checkLow() }
57+ onOnAcChanged: { evaluate(); checkLow() }
58+ Connections {
59+ target: Settings
60+ function onChargeModeChanged() { root.evaluate() }
61+ function onBatLowChanged() { root.checkLow() }
62+ function onBatCriticalChanged() { root.checkLow() }
63+ function onBatCriticalActionChanged() { root.warnedCritical = false; root.checkLow() }
64+ }
65+
66+ Process {
67+ command: ["upower", "-e"]
68+ running: true
69+ stdout: StdioCollector {
70+ onStreamFinished: {
71+ var l = text.split("\n")
72+ for (var i = 0; i < l.length; i++)
73+ if (l[i].indexOf("/battery_") >= 0) { root.path = l[i].trim(); break }
74+ if (root.ready) getProc.running = true
75+ }
76+ }
77+ }
78+ // "b true\nb false"
79+ Process {
80+ id: getProc
81+ command: root.busctl(["get-property", "ChargeThresholdSupported", "ChargeThresholdEnabled"])
82+ stdout: StdioCollector {
83+ onStreamFinished: {
84+ var l = text.trim().split("\n")
85+ if (l.length < 2) return
86+ root.supported = l[0].trim() === "b true"
87+ root.capOn = l[1].trim() === "b true"
88+ root.evaluate()
89+ }
90+ }
91+ }
92+ Process {
93+ id: setProc
94+ property bool want: false
95+ stderr: StdioCollector { id: setErr }
96+ onExited: code => {
97+ if (code !== 0)
98+ Quickshell.execDetached(["notify-send", "-a", "aurora", "-i", "battery", "charge limit",
99+ "couldn't switch conservation mode: " + setErr.text.trim()])
100+ getProc.running = true // read back the real state
101+ }
102+ }
103+
104+ // ---- power-profiles-daemon present? (Quickshell's PowerProfiles has no "available" flag)
105+ property bool hasProfiles: false
106+ Process {
107+ command: ["busctl", "--system", "status", "org.freedesktop.UPower.PowerProfiles"]
108+ running: true
109+ stdout: StdioCollector {}
110+ onExited: code => root.hasProfiles = code === 0
111+ }
112+
113+ // ---- low battery: one warning per level per discharge, then the critical action after 60s
114+ property bool warnedLow: false
115+ property bool warnedCritical: false
116+ function checkLow() {
117+ if (!started || (hasBat && pct <= 0)) return
118+ if (!hasBat || onAc) {
119+ warnedLow = warnedCritical = false
120+ if (critical.running) { critical.stop(); critNote.running = false }
121+ return
122+ }
123+ // re-arm once above a level again (a threshold changed in settings)
124+ if (pct > Settings.batLow) warnedLow = false
125+ if (pct > Settings.batCritical) {
126+ warnedCritical = false
127+ if (critical.running) { critical.stop(); critNote.running = false }
128+ }
129+ if (pct <= Settings.batCritical && !warnedCritical) {
130+ warnedCritical = warnedLow = true
131+ var act = Settings.batCriticalAction
132+ if (act === "none") {
133+ Quickshell.execDetached(["notify-send", "-a", "aurora", "-u", "critical", "-i", "battery-caution",
134+ "battery critical", pct + "% left, plug in now"])
135+ } else {
136+ critNote.command = ["notify-send", "-a", "aurora", "-u", "critical", "-i", "battery-caution",
137+ "-A", "cancel=cancel", "battery critical",
138+ pct + "% left: " + (act === "suspend" ? "suspending" : "powering off")
139+ + " in 60s unless plugged in"]
140+ critNote.running = true
141+ critical.restart()
142+ }
143+ } else if (pct <= Settings.batLow && !warnedLow) {
144+ warnedLow = true
145+ Quickshell.execDetached(["notify-send", "-a", "aurora", "-u", "critical", "-i", "battery-caution",
146+ "battery low", pct + "% left"])
147+ }
148+ }
149+
150+ Timer {
151+ id: critical
152+ interval: 60000
153+ onTriggered: {
154+ critNote.running = false
155+ Session.run(Settings.batCriticalAction === "suspend" ? "sleep" : "shutdown")
156+ }
157+ }
158+ // the critical warning's "cancel" button
159+ Process {
160+ id: critNote
161+ stdout: StdioCollector {
162+ onStreamFinished: if (text.trim() === "cancel") critical.stop()
163+ }
164+ }
165+}
M .config/quickshell/aurora/BatteryInfo.qml View
@@ -42,6 +42,9 @@ ColumnLayout {
4242 lines.push((root.charging ? "time to full " : "time left ") + Theme.fmtTime(t))
4343 if (root.bat.changeRate !== undefined)
4444 lines.push("power draw " + Math.abs(root.bat.changeRate).toFixed(1) + " W")
45+ if (Settings.chargeMode !== "off" && Battery.supported)
46+ lines.push("charge limit " + (Settings.chargeMode === "limit80" ? "80%" : "conservation")
47+ + (Battery.held ? ", holding" : ""))
4548 if (root.bat.healthSupported)
4649 lines.push("health " + Math.round(root.bat.healthPercentage) + "%")
4750 return lines.join("\n")
M .config/quickshell/aurora/SetPower.qml View
@@ -1,7 +1,8 @@
11 import QtQuick
22 import QtQuick.Layouts
3+import Quickshell.Services.UPower
34
4−// Settings app > Power: battery, graphics mode, idle timeouts (hypridle via Idle) and caffeine.
5+// Settings app > Power: battery + battery care (Battery), power profile, graphics mode, idle timeouts (hypridle via Idle) and caffeine.
56 Flickable {
67 id: flick
78 clip: true
@@ -56,6 +57,62 @@ Flickable {
5657 spacing: 8
5758
5859 BatteryInfo {}
60+
61+ SectionLabel { text: "battery care"; Layout.topMargin: 6; visible: Battery.hasBat }
62+ Choice {
63+ visible: Battery.supported
64+ label: "charge limit"
65+ options: [{ value: "off", label: "off" }, { value: "limit80", label: "80%" },
66+ { value: "conservation", label: "conservation" }]
67+ value: Settings.chargeMode
68+ onPicked: v => Settings.chargeMode = v
69+ }
70+ Text {
71+ visible: Battery.supported
72+ Layout.fillWidth: true
73+ wrapMode: Text.WordWrap
74+ text: Settings.chargeMode === "limit80"
75+ ? "aurora stops charging at 80% (conservation mode on) and resumes below 75%. Only while aurora runs."
76+ : Settings.chargeMode === "conservation"
77+ ? "the firmware's fixed cap, kept even when aurora isn't running. Conservation mode is "
78+ + (Battery.capOn ? "on" : "off") + "."
79+ : "charges to 100%"
80+ color: Theme.dim
81+ font.family: Theme.font
82+ font.pixelSize: Theme.fontSize - 2
83+ }
84+ Knob {
85+ visible: Battery.hasBat
86+ label: "warn at"
87+ from: 5; to: 40; unit: "%"
88+ value: Settings.batLow
89+ onMoved: v => Settings.batLow = v
90+ }
91+ Knob {
92+ visible: Battery.hasBat
93+ label: "critical at"
94+ from: 2; to: 15; unit: "%"
95+ value: Settings.batCritical
96+ onMoved: v => Settings.batCritical = v
97+ }
98+ Choice {
99+ visible: Battery.hasBat
100+ label: "when critical"
101+ options: [{ value: "none", label: "warn" }, { value: "suspend", label: "suspend" },
102+ { value: "poweroff", label: "power off" }]
103+ value: Settings.batCriticalAction
104+ onPicked: v => Settings.batCriticalAction = v
105+ }
106+
107+ Choice {
108+ Layout.topMargin: 6
109+ visible: Battery.hasProfiles
110+ label: "power profile"
111+ options: [{ value: PowerProfile.PowerSaver, label: "saver" }, { value: PowerProfile.Balanced, label: "balanced" }]
112+ .concat(PowerProfiles.hasPerformanceProfile ? [{ value: PowerProfile.Performance, label: "performance" }] : [])
113+ value: PowerProfiles.profile
114+ onPicked: v => PowerProfiles.profile = v
115+ }
59116 GpuSwitch { Layout.topMargin: 6 }
60117
61118 SectionLabel { text: "when idle"; Layout.topMargin: 6 }
M .config/quickshell/aurora/Settings.qml View
@@ -15,7 +15,8 @@ Singleton {
1515 osdTimeout: 1500, desktopClock: true, desktopViz: true, vizBars: 64,
1616 dock: true, dockPinned: ["foot", "librewolf", "pcmanfm", "obsidian"], wallFolder: "dusky-dark",
1717 notifSound: true, notifVolume: 0.7,
18− idleDim: 150, idleOff: 330, idleLock: 600, idleSuspend: 0, charTone: 0, portalAutoOpen: true
18+ idleDim: 150, idleOff: 330, idleLock: 600, idleSuspend: 0, charTone: 0, portalAutoOpen: true,
19+ chargeMode: "off", batLow: 15, batCritical: 5, batCriticalAction: "none"
1920 })
2021
2122 property alias fontSize: a.fontSize
@@ -39,6 +40,10 @@ Singleton {
3940 property alias idleSuspend: a.idleSuspend
4041 property alias charTone: a.charTone
4142 property alias portalAutoOpen: a.portalAutoOpen
43+ property alias chargeMode: a.chargeMode
44+ property alias batLow: a.batLow
45+ property alias batCritical: a.batCritical
46+ property alias batCriticalAction: a.batCriticalAction
4247
4348 function reset() {
4449 for (var k in defaults) a[k] = defaults[k]
@@ -84,6 +89,10 @@ Singleton {
8489 property int idleSuspend: 0
8590 property int charTone: 0 // glyph picker emoji skin tone, 0 = none, 1-5 light..dark
8691 property bool portalAutoOpen: true // open captive portal login pages in $BROWSER (Net.qml)
92+ property string chargeMode: "off" // off | conservation | limit80 (Battery.qml)
93+ property int batLow: 15 // low battery warning, %
94+ property int batCritical: 5
95+ property string batCriticalAction: "none" // none | suspend | poweroff
8796 }
8897 }
8998 // first run: create the file once the directory exists
M .config/quickshell/aurora/SysPage.qml View
@@ -55,7 +55,7 @@ Flickable {
5555 BatteryInfo { Layout.topMargin: 6 }
5656
5757 ColumnLayout {
58− visible: PowerProfiles.hasPerformanceProfile !== undefined
58+ visible: Battery.hasProfiles
5959 Layout.fillWidth: true
6060 Layout.topMargin: 6
6161 spacing: 8
M .config/quickshell/aurora/shell.qml View
@@ -21,6 +21,7 @@ ShellRoot {
2121 property var overlayScreen: focusedScreen()
2222 // touch singletons that must start with the shell (Idle creates ~/.config/aurora/hypridle.conf)
2323 readonly property string idleFile: Idle.file
24+ readonly property bool batCare: Battery.ready // charge limit + low battery warnings
2425 readonly property var tabNames: ["home", "audio", "net", "bt", "sys", "notif"]
2526 // settings app (a normal window): open state + shown section
2627 property bool settingsOpen: false