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) <noreply@anthropic.com>
This commit is contained in:
Joachim Hummel
2026-05-16 22:58:20 +02:00
parent 8b092d0ca6
commit 19f1e2962e

View File

@@ -1,29 +1,46 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Battery tray icon for Argon ONE UP on Wayland (labwc). 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 sys
import os
import time import time
import smbus2
from PyQt5.QtWidgets import QApplication, QSystemTrayIcon, QMenu, QAction from PyQt5.QtWidgets import QApplication, QSystemTrayIcon, QMenu, QAction
from PyQt5.QtGui import QIcon, QPainter, QColor, QFont, QPixmap from PyQt5.QtGui import QIcon, QPainter, QColor, QFont, QPixmap
from PyQt5.QtCore import QTimer, Qt from PyQt5.QtCore import QTimer, Qt
SYSFS_BAT_CAPACITY = "/sys/class/power_supply/BAT0/capacity" # I2C layout of the Argon ONE UP battery IC (see battery/oneUpPower.c).
SYSFS_BAT_STATUS = "/sys/class/power_supply/BAT0/status" I2C_BUS = 1
SYSFS_AC_ONLINE = "/sys/class/power_supply/AC0/online" 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 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: try:
with open(path) as f: soc = bus.read_byte_data(BATTERY_ADDR, SOC_REG, force=True)
return f.read().strip() current_high = bus.read_byte_data(BATTERY_ADDR, CURRENT_HIGH_REG,
except (FileNotFoundError, PermissionError, OSError): force=True)
return default 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): def make_battery_icon(percent, charging):
@@ -97,6 +114,7 @@ class BatteryTray:
sys.exit(1) sys.exit(1)
time.sleep(2) time.sleep(2)
self.bus = smbus2.SMBus(I2C_BUS)
self.tray = QSystemTrayIcon() self.tray = QSystemTrayIcon()
# Context menu # Context menu
@@ -121,22 +139,16 @@ class BatteryTray:
self.tray.show() self.tray.show()
def update(self): def update(self):
cap_str = read_sysfs(SYSFS_BAT_CAPACITY) percent, plugged_in = read_battery(self.bus)
status = read_sysfs(SYSFS_BAT_STATUS, "Unknown")
ac_str = read_sysfs(SYSFS_AC_ONLINE, "0")
if cap_str is not None: if percent is not None:
try: charging = plugged_in and percent <= 95
percent = int(cap_str)
except ValueError:
percent = 0
charging = (ac_str == "1" or status == "Charging")
icon = make_battery_icon(percent, charging) icon = make_battery_icon(percent, charging)
self.tray.setIcon(icon) self.tray.setIcon(icon)
if charging: if charging:
state = "Charging" state = "Charging"
elif ac_str == "1": elif plugged_in:
state = "On AC" state = "On AC"
else: else:
state = "Discharging" state = "Discharging"
@@ -144,11 +156,11 @@ class BatteryTray:
self.tray.setToolTip(tooltip) self.tray.setToolTip(tooltip)
self.status_action.setText(tooltip) self.status_action.setText(tooltip)
else: else:
# No battery sysfs - module not loaded # I2C read failed - IC not reachable
icon = make_battery_icon(0, False) icon = make_battery_icon(0, False)
self.tray.setIcon(icon) self.tray.setIcon(icon)
self.tray.setToolTip("Battery: N/A (module not loaded?)") self.tray.setToolTip("Battery: N/A (I2C read failed)")
self.status_action.setText("Battery: N/A (module not loaded?)") self.status_action.setText("Battery: N/A (I2C read failed)")
def run(self): def run(self):
return self.app.exec() return self.app.exec()