diff --git a/monitor/batteryIcon.py b/monitor/batteryIcon.py index 7ee7345..089892b 100755 --- a/monitor/batteryIcon.py +++ b/monitor/batteryIcon.py @@ -1,29 +1,46 @@ #!/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. + +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. """ import sys -import os import time + +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 -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" +# 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 -def read_sysfs(path, default=None): +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: - with open(path) as f: - return f.read().strip() - except (FileNotFoundError, PermissionError, OSError): - return default + 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): @@ -97,6 +114,7 @@ class BatteryTray: sys.exit(1) time.sleep(2) + self.bus = smbus2.SMBus(I2C_BUS) self.tray = QSystemTrayIcon() # Context menu @@ -121,22 +139,16 @@ class BatteryTray: 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") + percent, plugged_in = read_battery(self.bus) - if cap_str is not None: - try: - percent = int(cap_str) - except ValueError: - percent = 0 - charging = (ac_str == "1" or status == "Charging") + 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 ac_str == "1": + elif plugged_in: state = "On AC" else: state = "Discharging" @@ -144,11 +156,11 @@ class BatteryTray: self.tray.setToolTip(tooltip) self.status_action.setText(tooltip) else: - # No battery sysfs - module not loaded + # I2C read failed - IC not reachable 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?)") + self.tray.setToolTip("Battery: N/A (I2C read failed)") + self.status_action.setText("Battery: N/A (I2C read failed)") def run(self): return self.app.exec()