The oneUpPower kernel module powered the system off at low SOC, but the icon now reads the battery over I2C and no longer relies on that module. Restore the safety net in the icon itself. While running on battery, warn via a tray notification at <=10% and run `systemctl poweroff` at <=5%. systemctl poweroff is authorised for the active desktop session without sudo (logind CanPowerOff returns "yes"). State resets when AC is reconnected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
217 lines
6.9 KiB
Python
Executable File
217 lines
6.9 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Battery tray icon for Argon ONE UP on Wayland (labwc).
|
|
|
|
Reads the battery state of charge and AC status directly from the battery
|
|
management IC over I2C (bus 1, address 0x64) -- the same registers the
|
|
oneUpPower kernel module uses. This means the icon works for an ordinary
|
|
user (member of the ``i2c`` group) without sudo and without the kernel
|
|
module being loaded. Updates every 5 seconds.
|
|
|
|
When running on battery it also warns on low charge and powers the system
|
|
off when critically low, mirroring the oneUpPower kernel module.
|
|
"""
|
|
|
|
import sys
|
|
import time
|
|
import subprocess
|
|
|
|
import smbus2
|
|
from PyQt5.QtWidgets import QApplication, QSystemTrayIcon, QMenu, QAction
|
|
from PyQt5.QtGui import QIcon, QPainter, QColor, QFont, QPixmap
|
|
from PyQt5.QtCore import QTimer, Qt
|
|
|
|
# I2C layout of the Argon ONE UP battery IC (see battery/oneUpPower.c).
|
|
I2C_BUS = 1
|
|
BATTERY_ADDR = 0x64
|
|
SOC_REG = 0x04 # state of charge, percent
|
|
CURRENT_HIGH_REG = 0x0E # bit 7 set => running on battery (not plugged in)
|
|
UPDATE_INTERVAL_MS = 5000
|
|
|
|
# Auto-shutdown while running on battery (mirrors the oneUpPower module).
|
|
SHUTDOWN_SOC = 5 # power off at or below this percentage
|
|
WARN_SOC = 10 # warn the user at or below this percentage
|
|
|
|
|
|
def read_battery(bus):
|
|
"""Return (percent, plugged_in) read over I2C, or (None, None) on error.
|
|
|
|
force=True bypasses the I2C_SLAVE busy check so the read still works if
|
|
the oneUpPower kernel module has claimed the address.
|
|
"""
|
|
try:
|
|
soc = bus.read_byte_data(BATTERY_ADDR, SOC_REG, force=True)
|
|
current_high = bus.read_byte_data(BATTERY_ADDR, CURRENT_HIGH_REG,
|
|
force=True)
|
|
except OSError:
|
|
return None, None
|
|
|
|
percent = max(0, min(100, soc))
|
|
plugged_in = (current_high & 0x80) == 0
|
|
return percent, plugged_in
|
|
|
|
|
|
def make_battery_icon(percent, charging):
|
|
"""Draw a battery icon with percentage text."""
|
|
size = 64
|
|
pix = QPixmap(size, size)
|
|
pix.fill(Qt.transparent)
|
|
p = QPainter(pix)
|
|
p.setRenderHint(QPainter.Antialiasing)
|
|
|
|
# Battery outline
|
|
bx, by, bw, bh = 8, 14, 48, 36
|
|
# Terminal nub
|
|
nx, ny, nw, nh = bx + bw, 24, 6, 16
|
|
|
|
p.setPen(QColor(200, 200, 200))
|
|
p.setBrush(Qt.NoBrush)
|
|
p.drawRoundedRect(bx, by, bw, bh, 4, 4)
|
|
p.drawRoundedRect(nx, ny, nw, nh, 2, 2)
|
|
|
|
# Fill level
|
|
margin = 3
|
|
fill_max_w = bw - 2 * margin
|
|
fill_w = max(1, int(fill_max_w * percent / 100))
|
|
fill_x = bx + margin
|
|
fill_y = by + margin
|
|
fill_h = bh - 2 * margin
|
|
|
|
if charging:
|
|
fill_color = QColor(100, 200, 255) # blue when charging
|
|
elif percent <= 10:
|
|
fill_color = QColor(255, 60, 60) # red when critical
|
|
elif percent <= 25:
|
|
fill_color = QColor(255, 180, 0) # orange when low
|
|
else:
|
|
fill_color = QColor(80, 220, 80) # green normal
|
|
|
|
p.setPen(Qt.NoPen)
|
|
p.setBrush(fill_color)
|
|
p.drawRoundedRect(fill_x, fill_y, fill_w, fill_h, 2, 2)
|
|
|
|
# Percentage text
|
|
font = QFont("Sans", 11, QFont.Bold)
|
|
p.setFont(font)
|
|
p.setPen(QColor(255, 255, 255))
|
|
text = f"{percent}%"
|
|
p.drawText(bx, by, bw, bh, Qt.AlignCenter, text)
|
|
|
|
# Charging indicator (lightning bolt)
|
|
if charging:
|
|
font2 = QFont("Sans", 9)
|
|
p.setFont(font2)
|
|
p.setPen(QColor(255, 255, 100))
|
|
p.drawText(0, 0, size, 16, Qt.AlignCenter, "⚡")
|
|
|
|
p.end()
|
|
return QIcon(pix)
|
|
|
|
|
|
class BatteryTray:
|
|
def __init__(self):
|
|
self.app = QApplication(sys.argv)
|
|
self.app.setQuitOnLastWindowClosed(False)
|
|
|
|
# At login the panel may not have started its system tray yet.
|
|
# Wait for it instead of giving up, so autostart works after a reboot.
|
|
deadline = time.monotonic() + 120
|
|
while not QSystemTrayIcon.isSystemTrayAvailable():
|
|
if time.monotonic() > deadline:
|
|
print("System tray not available after 120s", file=sys.stderr)
|
|
sys.exit(1)
|
|
time.sleep(2)
|
|
|
|
self.bus = smbus2.SMBus(I2C_BUS)
|
|
self.shutdown_triggered = False
|
|
self.warned = False
|
|
self.tray = QSystemTrayIcon()
|
|
|
|
# Context menu
|
|
menu = QMenu()
|
|
self.status_action = QAction("Battery: --")
|
|
self.status_action.setEnabled(False)
|
|
menu.addAction(self.status_action)
|
|
menu.addSeparator()
|
|
quit_action = QAction("Quit")
|
|
quit_action.triggered.connect(self.app.quit)
|
|
menu.addAction(quit_action)
|
|
self.tray.setContextMenu(menu)
|
|
|
|
# Initial update
|
|
self.update()
|
|
|
|
# Timer for periodic updates
|
|
self.timer = QTimer()
|
|
self.timer.timeout.connect(self.update)
|
|
self.timer.start(UPDATE_INTERVAL_MS)
|
|
|
|
self.tray.show()
|
|
|
|
def update(self):
|
|
percent, plugged_in = read_battery(self.bus)
|
|
|
|
if percent is not None:
|
|
charging = plugged_in and percent <= 95
|
|
icon = make_battery_icon(percent, charging)
|
|
self.tray.setIcon(icon)
|
|
|
|
if charging:
|
|
state = "Charging"
|
|
elif plugged_in:
|
|
state = "On AC"
|
|
else:
|
|
state = "Discharging"
|
|
tooltip = f"Battery: {percent}% ({state})"
|
|
self.tray.setToolTip(tooltip)
|
|
self.status_action.setText(tooltip)
|
|
|
|
self.check_power(percent, plugged_in)
|
|
else:
|
|
# I2C read failed - IC not reachable
|
|
icon = make_battery_icon(0, False)
|
|
self.tray.setIcon(icon)
|
|
self.tray.setToolTip("Battery: N/A (I2C read failed)")
|
|
self.status_action.setText("Battery: N/A (I2C read failed)")
|
|
|
|
def check_power(self, percent, plugged_in):
|
|
"""Warn on low battery and power off when critically low.
|
|
|
|
Only acts while running on battery. `systemctl poweroff` works for
|
|
the active desktop session without sudo.
|
|
"""
|
|
if plugged_in:
|
|
self.warned = False
|
|
return
|
|
|
|
if percent <= SHUTDOWN_SOC and not self.shutdown_triggered:
|
|
self.shutdown_triggered = True
|
|
self.tray.showMessage(
|
|
"Battery critical",
|
|
f"Battery at {percent}% - shutting down now.",
|
|
QSystemTrayIcon.Critical, 10000)
|
|
try:
|
|
subprocess.run(["systemctl", "poweroff"],
|
|
check=True, timeout=10)
|
|
except (subprocess.SubprocessError, OSError) as e:
|
|
print(f"poweroff failed: {e}", file=sys.stderr)
|
|
self.shutdown_triggered = False # allow retry next cycle
|
|
elif percent <= WARN_SOC and not self.warned:
|
|
self.warned = True
|
|
self.tray.showMessage(
|
|
"Battery low",
|
|
f"Battery at {percent}% - save your work.",
|
|
QSystemTrayIcon.Warning, 10000)
|
|
|
|
def run(self):
|
|
return self.app.exec()
|
|
|
|
|
|
def main():
|
|
bt = BatteryTray()
|
|
sys.exit(bt.run())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|