Skip to content

Commit 92f86b0

Browse files
committed
Prefer kernel charge registers over a stuck UPower energy-full
UPower can latch energy-full after a MacBook SBS blip (484 Wh on a 54 Wh Air), so percentage and time-to-full become nonsense. omarchy-battery-status already prefers sysfs for live watts; use charge_now/charge_full the same way, poll that helper for the bar icon, and stop treating a multi-hour UPower time-to-full as a charge hold.
1 parent f99d33a commit 92f86b0

8 files changed

Lines changed: 241 additions & 33 deletions

File tree

‎bin/omarchy-battery-status‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,77 @@ power_rate=$(awk -v rate="${power_rate_raw:-0}" 'BEGIN {
6262
print rounded
6363
}')
6464
state=$(awk '/state/ { print $2; exit }' <<<"$battery_info")
65+
66+
# Prefer kernel charge/energy registers. UPower can stick energy-full after a
67+
# firmware blip (MacBook SBS is the usual case), which then tanks percentage
68+
# and inflates time-to-full to days. Same idea as the live sysfs rate above.
69+
if [[ -r $battery_path/charge_now && -r $battery_path/charge_full ]]; then
70+
charge_now=$(<"$battery_path/charge_now")
71+
charge_full=$(<"$battery_path/charge_full")
72+
if [[ -n $charge_now && -n $charge_full ]] && (( charge_full > 0 )); then
73+
percentage=$(awk -v n="$charge_now" -v f="$charge_full" 'BEGIN {
74+
p = n * 100 / f
75+
if (p > 100) p = 100
76+
if (p < 0) p = 0
77+
printf "%d", p + 0.5
78+
}')
79+
if [[ -r $battery_path/voltage_min_design ]]; then
80+
capacity=$(awk -v f="$charge_full" -v v="$(<"$battery_path/voltage_min_design")" 'BEGIN { printf "%d", f * v / 1e12 }')
81+
elif [[ -r $battery_path/voltage_now ]]; then
82+
capacity=$(awk -v f="$charge_full" -v v="$(<"$battery_path/voltage_now")" 'BEGIN { printf "%d", f * v / 1e12 }')
83+
fi
84+
if [[ -r $battery_path/current_now ]]; then
85+
current_now=$(<"$battery_path/current_now")
86+
if awk -v i="$current_now" 'BEGIN { if (i < 0) i = -i; exit !(i > 10000) }'; then
87+
if [[ $state == "charging" || $(<"$battery_path/status" 2>/dev/null) == "Charging" ]]; then
88+
time_remaining=$(awk -v n="$charge_now" -v f="$charge_full" -v i="$current_now" 'BEGIN {
89+
if (i <= 0) exit
90+
hours = (f - n) / i
91+
if (hours < 0) hours = 0
92+
h = int(hours)
93+
m = int((hours - h) * 60)
94+
if (h <= 0) printf "%dm", m
95+
else if (m > 0) printf "%dh %dm", h, m
96+
else printf "%dh", h
97+
}')
98+
else
99+
time_remaining=$(awk -v n="$charge_now" -v i="$current_now" 'BEGIN {
100+
if (i < 0) i = -i
101+
if (i <= 0) exit
102+
hours = n / i
103+
h = int(hours)
104+
m = int((hours - h) * 60)
105+
if (h <= 0) printf "%dm", m
106+
else if (m > 0) printf "%dh %dm", h, m
107+
else printf "%dh", h
108+
}')
109+
fi
110+
fi
111+
fi
112+
fi
113+
elif [[ -r $battery_path/energy_now && -r $battery_path/energy_full ]]; then
114+
energy_now=$(<"$battery_path/energy_now")
115+
energy_full=$(<"$battery_path/energy_full")
116+
if [[ -n $energy_now && -n $energy_full ]] && (( energy_full > 0 )); then
117+
percentage=$(awk -v n="$energy_now" -v f="$energy_full" 'BEGIN {
118+
p = n * 100 / f
119+
if (p > 100) p = 100
120+
if (p < 0) p = 0
121+
printf "%d", p + 0.5
122+
}')
123+
capacity=$(awk -v f="$energy_full" 'BEGIN { printf "%d", f / 1000000 }')
124+
fi
125+
fi
126+
127+
if [[ -r $battery_path/status ]]; then
128+
case "$(<"$battery_path/status")" in
129+
Full) state=fully-charged ;;
130+
Charging) state=charging ;;
131+
Discharging) state=discharging ;;
132+
"Not charging") state=pending-charge ;;
133+
esac
134+
fi
135+
65136
threshold_start=$(awk '/charge-start-threshold:/ { gsub(/%/, "", $2); print int($2); exit }' <<<"$battery_info")
66137
threshold_end=$(awk '/charge-end-threshold:/ { gsub(/%/, "", $2); print int($2); exit }' <<<"$battery_info")
67138

‎shell/plugins/panels/power/Model.js‎

Lines changed: 37 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -45,47 +45,69 @@ function profileIcon(name) {
4545
return "󰂄"
4646
}
4747

48-
function batteryFraction(device) {
48+
function statusPercent(info) {
49+
if (!info || info.percentage == null || info.percentage === "") return -1
50+
var n = parseInt(String(info.percentage), 10)
51+
return isNaN(n) ? -1 : Math.max(0, Math.min(100, n))
52+
}
53+
54+
function statusState(info) {
55+
return info && info.state ? String(info.state) : ""
56+
}
57+
58+
function batteryFraction(device, info) {
59+
var p = statusPercent(info)
60+
if (p >= 0) return p / 100
4961
return device && device.isPresent ? Math.max(0, Math.min(1, device.percentage)) : 0
5062
}
5163

52-
function chargeThresholdActive(device, onBattery, states) {
64+
function chargeThresholdActive(device, onBattery, states, info) {
65+
var st = statusState(info)
66+
if (st === "holding") return true
67+
if (st === "fully-charged" || st === "charging" || st === "discharging") return false
68+
5369
var d = device || {}
5470
var s = states || {}
5571
if (!(d && d.isPresent && !onBattery)) return false
5672

57-
var fraction = batteryFraction(d)
73+
var fraction = batteryFraction(d, info)
5874
if (d.state === s.Discharging) return false
5975
if (d.state === s.PendingCharge) return true
6076
if (d.state === s.FullyCharged && fraction < 0.99) return true
6177
if (d.state !== s.Charging || fraction >= 0.99) return false
6278

63-
return Number(d.changeRate || 0) <= 0.2 || Number(d.timeToFull || 0) >= 8 * 60 * 60
79+
// Stalled charging at a hold point is a trickle under 0.2W.
80+
// Do not treat a multi-hour UPower "time to full" as a threshold: that is
81+
// the stuck energy-full bug, not a charge limit.
82+
return Number(d.changeRate || 0) <= 0.2
6483
}
6584

66-
function batteryIcon(device, onBattery, states) {
85+
function batteryIcon(device, onBattery, states, info) {
6786
var d = device || {}
6887
if (!d.isPresent) return ""
6988

7089
var chargingIcons = ["󰢜", "󰂆", "󰂇", "󰂈", "󰢝", "󰂉", "󰢞", "󰂊", "󰂋", "󰂅"]
7190
var defaultIcons = ["󰁺", "󰁻", "󰁼", "󰁽", "󰁾", "󰁿", "󰂀", "󰂁", "󰂂", "󰁹"]
72-
var index = Math.max(0, Math.min(9, Math.floor(d.percentage * 10)))
73-
var threshold = chargeThresholdActive(d, onBattery, states)
91+
var fraction = batteryFraction(d, info)
92+
var index = Math.max(0, Math.min(9, Math.floor(fraction * 10)))
93+
var threshold = chargeThresholdActive(d, onBattery, states, info)
94+
var st = statusState(info)
7495

7596
if (threshold) return defaultIcons[index]
76-
if (d.state === states.FullyCharged) return "󰂅"
77-
if (!onBattery) return chargingIcons[index]
97+
if (st === "fully-charged" || d.state === states.FullyCharged) return "󰂅"
98+
if (!onBattery && st !== "fully-charged") return chargingIcons[index]
7899
return defaultIcons[index]
79100
}
80101

81-
function modeLabel(device, onBattery, states) {
102+
function modeLabel(device, onBattery, states, info) {
82103
var d = device || {}
83104
if (!d.isPresent) return ""
84105

85-
var percentage = d.isPresent ? d.percentage : 0
86-
if (chargeThresholdActive(d, onBattery, states)) return "Threshold"
106+
var st = statusState(info)
107+
var percentage = batteryFraction(d, info)
108+
if (chargeThresholdActive(d, onBattery, states, info)) return "Threshold"
87109
if (onBattery) return "On battery"
88-
if (!onBattery && percentage >= 1) return "Fully charged"
110+
if (st === "fully-charged" || (!onBattery && percentage >= 0.99)) return "Fully charged"
89111
return "Charging"
90112
}
91113

@@ -96,6 +118,8 @@ if (typeof module !== "undefined") {
96118
parseKeyValue: parseKeyValue,
97119
parseProfiles: parseProfiles,
98120
profileIcon: profileIcon,
121+
statusPercent: statusPercent,
122+
statusState: statusState,
99123
batteryFraction: batteryFraction,
100124
chargeThresholdActive: chargeThresholdActive,
101125
batteryIcon: batteryIcon,

‎shell/plugins/panels/power/Panel.qml‎

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -47,21 +47,23 @@ Panel {
4747
setProfile(profiles[profileIndex])
4848
}
4949

50+
readonly property string batteryIconGlyph: Model.batteryIcon(UPower.displayDevice, root.discharging, upowerStates(), root.batteryInfo)
51+
5052
function batteryIcon() {
51-
var device = UPower.displayDevice
52-
return Model.batteryIcon(device, root.discharging, upowerStates())
53+
return root.batteryIconGlyph
5354
}
5455

5556
function modeLabel() {
5657
var device = UPower.displayDevice
57-
return Model.modeLabel(device, root.discharging, upowerStates())
58+
return Model.modeLabel(device, root.discharging, upowerStates(), root.batteryInfo)
5859
}
5960

6061
function profileIcon(name) {
6162
return Model.profileIcon(name)
6263
}
6364

6465
readonly property bool fullyCharged: {
66+
if (Model.statusState(root.batteryInfo) === "fully-charged") return true
6567
var device = UPower.displayDevice
6668
return device && device.isPresent && device.state === UPowerDeviceState.FullyCharged && !root.chargeThresholdActive
6769
}
@@ -71,15 +73,14 @@ Panel {
7173
}
7274
readonly property bool chargeThresholdActive: {
7375
var device = UPower.displayDevice
74-
return Model.chargeThresholdActive(device, root.discharging, upowerStates())
76+
return Model.chargeThresholdActive(device, root.discharging, upowerStates(), root.batteryInfo)
7577
}
76-
readonly property bool batteryFull: fullyCharged || (!root.discharging && batteryFraction >= 1)
78+
readonly property bool batteryFull: fullyCharged || (!root.discharging && batteryFraction >= 0.99)
7779
readonly property bool batteryFlowIdle: batteryFull || chargeThresholdActive
7880

7981
// 0..1 charge level, used by the visual progress bar.
8082
readonly property real batteryFraction: {
81-
var d = UPower.displayDevice
82-
return Model.batteryFraction(d)
83+
return Model.batteryFraction(UPower.displayDevice, root.batteryInfo)
8384
}
8485

8586
readonly property bool charging: {
@@ -132,10 +133,16 @@ Panel {
132133
return modeLabel()
133134
}
134135

136+
function refreshBattery() {
137+
if (!batteryPresent) return
138+
if (!batteryProc.running) batteryProc.running = true
139+
}
140+
135141
function refresh() {
136142
if (!batteryPresent) return
137143

138-
if (!batteryProc.running) batteryProc.running = true
144+
refreshBattery()
145+
if (!root.opened) return
139146
if (!profilesProc.running) profilesProc.running = true
140147
if (!systemProc.running) systemProc.running = true
141148
}
@@ -211,6 +218,16 @@ Panel {
211218
stdout: StdioCollector { waitForEnd: true; onStreamFinished: root.updateKeyValue(text, "battery") }
212219
}
213220

221+
Timer {
222+
interval: 3000
223+
running: true
224+
repeat: true
225+
triggeredOnStart: true
226+
onTriggered: root.refreshBattery()
227+
}
228+
229+
Component.onCompleted: root.refreshBattery()
230+
214231
Process {
215232
id: profilesProc
216233
command: ["omarchy-powerprofiles-list", "--active-state"]
@@ -279,7 +296,7 @@ Panel {
279296
bar: root.bar
280297
text: root.showPercentage && !vertical
281298
? Math.round(root.batteryFraction * 100) + "% " + root.batteryIcon()
282-
: root.batteryIcon()
299+
: root.batteryIconGlyph
283300
slotSize: Style.bar.iconSlot * (root.showPercentage && !vertical ? 2 : 1)
284301
tooltipText: ""
285302
onPressed: function(b) {
@@ -326,7 +343,7 @@ Panel {
326343
Text {
327344
id: heroIcon
328345
textFormat: Text.PlainText
329-
text: root.batteryIcon()
346+
text: root.batteryIconGlyph
330347
color: root.bar.foreground
331348
font.family: root.bar.fontFamily
332349
font.pixelSize: Style.font.display

‎shell/plugins/services/battery/BatteryModel.js‎

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,38 @@
1-
function batteryPercentage(device) {
1+
function parseStatusPercent(info) {
2+
if (!info || info.percentage == null || info.percentage === "") return -1
3+
var n = parseInt(String(info.percentage), 10)
4+
return isNaN(n) ? -1 : Math.max(0, Math.min(100, n))
5+
}
6+
7+
function parseKeyValue(raw) {
8+
var next = {}
9+
var lines = String(raw || "").split("\n")
10+
for (var i = 0; i < lines.length; i++) {
11+
var idx = lines[i].indexOf("\t")
12+
if (idx <= 0) continue
13+
next[lines[i].substring(0, idx)] = lines[i].substring(idx + 1).trim()
14+
}
15+
return next
16+
}
17+
18+
function batteryPercentage(device, info) {
19+
var fromStatus = parseStatusPercent(info)
20+
if (fromStatus >= 0) return fromStatus
221
if (!device || !device.isPresent) return -1
322
return Math.round(Number(device.percentage || 0) * 100)
423
}
524

6-
function isDischarging(device, onBattery, dischargingState) {
7-
return !!(device && device.isPresent && onBattery && device.state === dischargingState)
25+
function isDischarging(device, onBattery, dischargingState, info) {
26+
if (!onBattery) return false
27+
if (info && info.state === "discharging") return true
28+
return !!(device && device.isPresent && device.state === dischargingState)
829
}
930

10-
function shouldWarnLowBattery(device, onBattery, dischargingState, threshold, alreadyNotified) {
11-
var level = batteryPercentage(device)
31+
function shouldWarnLowBattery(device, onBattery, dischargingState, threshold, alreadyNotified, info) {
32+
var level = batteryPercentage(device, info)
1233
if (level < 0) return { level: level, notify: false, notifiedLowBattery: false }
1334

14-
var low = isDischarging(device, onBattery, dischargingState) && level <= threshold
35+
var low = isDischarging(device, onBattery, dischargingState, info) && level <= threshold
1536
return {
1637
level: level,
1738
notify: low && !alreadyNotified,
@@ -21,6 +42,8 @@ function shouldWarnLowBattery(device, onBattery, dischargingState, threshold, al
2142

2243
if (typeof module !== "undefined") {
2344
module.exports = {
45+
parseStatusPercent: parseStatusPercent,
46+
parseKeyValue: parseKeyValue,
2447
batteryPercentage: batteryPercentage,
2548
isDischarging: isDischarging,
2649
shouldWarnLowBattery: shouldWarnLowBattery

‎shell/plugins/services/battery/Service.qml‎

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ Item {
1212

1313
readonly property int batteryThreshold: 10
1414
property string pendingPowerSource: ""
15+
property var batteryInfo: ({})
1516

1617
PersistentProperties {
1718
id: persisted
@@ -20,15 +21,15 @@ Item {
2021
}
2122

2223
function batteryPercentage() {
23-
return BatteryModel.batteryPercentage(UPower.displayDevice)
24+
return BatteryModel.batteryPercentage(UPower.displayDevice, root.batteryInfo)
2425
}
2526

2627
function isDischarging() {
27-
return BatteryModel.isDischarging(UPower.displayDevice, UPower.onBattery, UPowerDeviceState.Discharging)
28+
return BatteryModel.isDischarging(UPower.displayDevice, UPower.onBattery, UPowerDeviceState.Discharging, root.batteryInfo)
2829
}
2930

3031
function checkBattery() {
31-
var state = BatteryModel.shouldWarnLowBattery(UPower.displayDevice, UPower.onBattery, UPowerDeviceState.Discharging, batteryThreshold, persisted.notifiedLowBattery)
32+
var state = BatteryModel.shouldWarnLowBattery(UPower.displayDevice, UPower.onBattery, UPowerDeviceState.Discharging, batteryThreshold, persisted.notifiedLowBattery, root.batteryInfo)
3233
persisted.notifiedLowBattery = state.notifiedLowBattery
3334
if (state.notify) sendLowBatteryWarning(state.level)
3435
}
@@ -55,6 +56,19 @@ Item {
5556

5657
Process { id: warningProcess }
5758

59+
Process {
60+
id: statusProc
61+
command: ["omarchy-battery-status", "--shell"]
62+
stdout: StdioCollector {
63+
waitForEnd: true
64+
onStreamFinished: {
65+
var next = BatteryModel.parseKeyValue(text)
66+
if (Object.keys(next).length > 0) root.batteryInfo = next
67+
root.checkBattery()
68+
}
69+
}
70+
}
71+
5872
Process {
5973
id: powerProfileProcess
6074
onExited: if (root.pendingPowerSource !== "") root.runPendingPowerProfile()
@@ -65,7 +79,10 @@ Item {
6579
running: true
6680
repeat: true
6781
triggeredOnStart: true
68-
onTriggered: root.checkBattery()
82+
onTriggered: {
83+
if (!statusProc.running) statusProc.running = true
84+
else root.checkBattery()
85+
}
6986
}
7087

7188
Connections {

0 commit comments

Comments
 (0)