From 19f1e2962ed51e6d185014dde37980b35d316a67 Mon Sep 17 00:00:00 2001 From: Joachim Hummel Date: Sat, 16 May 2026 22:58:20 +0200 Subject: [PATCH] Read battery state over I2C instead of the kernel module batteryIcon.py read SOC and AC status from /sys/class/power_supply, which only exists when the oneUpPower kernel module is loaded. When the module fails to load (e.g. a DKMS vermagic mismatch after a kernel update), the icon showed only "N/A". Read the battery IC directly over I2C (bus 1, addr 0x64, regs 0x04 and 0x0E -- the same registers oneUpPower.c uses) via smbus2. force=True bypasses the I2C_SLAVE busy check so the read still works whether or not the kernel module has claimed the address. The icon now works for an ordinary user in the i2c group, with no sudo and no kernel module. Co-Authored-By: Claude Opus 4.7 (1M context) --- monitor/batteryIcon.py | 60 +++++++++++++++++++++++++----------------- 1 file changed, 36 insertions(+), 24 deletions(-) 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()