Files
argon40-battery-display/monitor/batteryIcon.py
Joachim Hummel 2e65dadfa9 Add low-battery warning and auto-shutdown to the tray icon
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>
2026-05-16 23:03:54 +02:00

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