Add battery tray icon with reliable autostart

Add batteryIcon.py, a PyQt5 system tray icon for the Argon ONE UP
that shows battery SOC and charging status, plus an XDG autostart
entry (batteryIcon.desktop).

The icon now waits up to 120s for the system tray to become
available instead of exiting immediately. At login the autostart
entry runs before the labwc panel (wf-panel-pi) provides its tray,
which previously caused the script to sys.exit(1) and the icon to
be missing after every reboot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joachim Hummel
2026-05-16 22:31:12 +02:00
parent b8650dc702
commit 462dc2d380
2 changed files with 172 additions and 0 deletions

View File

@@ -0,0 +1,9 @@
[Desktop Entry]
Type=Application
Name=Battery Icon
Comment=Battery tray icon for Argon ONE UP
Exec=/usr/bin/python3 /home/joachim/git/battery-argon40/monitor/batteryIcon.py
Icon=battery
Terminal=false
Categories=System;Monitor;
X-GNOME-Autostart-enabled=true

163
monitor/batteryIcon.py Executable file
View File

@@ -0,0 +1,163 @@
#!/usr/bin/env python3
"""
Battery tray icon for Argon ONE UP on Wayland (labwc).
Reads SOC from /sys/class/power_supply/BAT0/capacity and charging
status from AC0/online. Updates icon every 5 seconds.
"""
import sys
import os
import time
from PyQt5.QtWidgets import QApplication, QSystemTrayIcon, QMenu, QAction
from PyQt5.QtGui import QIcon, QPainter, QColor, QFont, QPixmap
from PyQt5.QtCore import QTimer, Qt
SYSFS_BAT_CAPACITY = "/sys/class/power_supply/BAT0/capacity"
SYSFS_BAT_STATUS = "/sys/class/power_supply/BAT0/status"
SYSFS_AC_ONLINE = "/sys/class/power_supply/AC0/online"
UPDATE_INTERVAL_MS = 5000
def read_sysfs(path, default=None):
try:
with open(path) as f:
return f.read().strip()
except (FileNotFoundError, PermissionError, OSError):
return default
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.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):
cap_str = read_sysfs(SYSFS_BAT_CAPACITY)
status = read_sysfs(SYSFS_BAT_STATUS, "Unknown")
ac_str = read_sysfs(SYSFS_AC_ONLINE, "0")
if cap_str is not None:
try:
percent = int(cap_str)
except ValueError:
percent = 0
charging = (ac_str == "1" or status == "Charging")
icon = make_battery_icon(percent, charging)
self.tray.setIcon(icon)
if charging:
state = "Charging"
elif ac_str == "1":
state = "On AC"
else:
state = "Discharging"
tooltip = f"Battery: {percent}% ({state})"
self.tray.setToolTip(tooltip)
self.status_action.setText(tooltip)
else:
# No battery sysfs - module not loaded
icon = make_battery_icon(0, False)
self.tray.setIcon(icon)
self.tray.setToolTip("Battery: N/A (module not loaded?)")
self.status_action.setText("Battery: N/A (module not loaded?)")
def run(self):
return self.app.exec()
def main():
bt = BatteryTray()
sys.exit(bt.run())
if __name__ == "__main__":
main()