The wait loop polled QSystemTrayIcon.isSystemTrayAvailable(), but Qt5 caches that value from the moment QApplication is constructed. At login the autostart wins the race against wf-panel-pi's tray, so Qt caches "no tray" permanently, the loop never escapes, and the process hangs until the 120s timeout and exits. Add wait_for_tray(), which polls the session bus directly (dbus-send NameHasOwner for org.kde.StatusNotifierWatcher) before QApplication is constructed, so Qt reports the tray correctly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
252 lines
8.3 KiB
Python
Executable File
252 lines
8.3 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)
|
|
|
|
|
|
def tray_host_ready():
|
|
"""True once a StatusNotifier tray host is registered on the session bus.
|
|
|
|
Queried over D-Bus directly instead of via
|
|
QSystemTrayIcon.isSystemTrayAvailable(): Qt caches that result from the
|
|
moment QApplication is constructed. At login the icon's autostart usually
|
|
wins the race against wf-panel-pi, so Qt caches "no tray" forever and the
|
|
icon never appears even after the panel's tray comes up. Checking the bus
|
|
directly dodges that cache -- we build QApplication only once the host is
|
|
actually present.
|
|
"""
|
|
try:
|
|
result = subprocess.run(
|
|
["dbus-send", "--session", "--print-reply",
|
|
"--dest=org.freedesktop.DBus", "/org/freedesktop/DBus",
|
|
"org.freedesktop.DBus.NameHasOwner",
|
|
"string:org.kde.StatusNotifierWatcher"],
|
|
capture_output=True, text=True, timeout=5)
|
|
except (subprocess.SubprocessError, OSError):
|
|
return False
|
|
return "boolean true" in result.stdout
|
|
|
|
|
|
def wait_for_tray(timeout=180):
|
|
"""Block until a tray host appears, so autostart survives a cold boot."""
|
|
deadline = time.monotonic() + timeout
|
|
while not tray_host_ready():
|
|
if time.monotonic() > deadline:
|
|
print(f"Tray host not available after {timeout}s", file=sys.stderr)
|
|
sys.exit(1)
|
|
time.sleep(2)
|
|
|
|
|
|
class BatteryTray:
|
|
def __init__(self):
|
|
self.app = QApplication(sys.argv)
|
|
self.app.setQuitOnLastWindowClosed(False)
|
|
|
|
# wait_for_tray() already confirmed a tray host on the bus, so Qt now
|
|
# reports the tray correctly. Keep a short re-check as a guard against
|
|
# the host still finishing its own registration.
|
|
deadline = time.monotonic() + 60
|
|
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():
|
|
wait_for_tray()
|
|
bt = BatteryTray()
|
|
sys.exit(bt.run())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|