feat(battery): add sysfs charge limit detection with threshold support

- Read charge_control_end/charge_control from /sys/class/power_supply/BAT*
- Store threshold value and sysfs path in ChargeLimit dataclass
- Fall back to UPower D-Bus when sysfs unavailable
This commit is contained in:
sankalp committed 2026-07-23 11:32:03 +05:45
1 parent b8bde92042
commit dda54bab19
1 file changed
+107 -7
+107 -7
View File
@@ -1,4 +1,6 @@
import os
from enum import IntEnum
from glob import glob
from fabric.core.service import Property, Service, Signal
from fabric.utils import Gio, GLib, logger
@@ -161,11 +163,19 @@ class Peripheral:
class ChargeLimit:
__slots__ = ("device_path", "enabled")
__slots__ = ("device_path", "enabled", "sysfs_path", "threshold")
def __init__(self, enabled: bool, device_path: str):
def __init__(
self,
enabled: bool,
device_path: str,
threshold: int | None = None,
sysfs_path: str | None = None,
):
self.enabled = enabled
self.device_path = device_path
self.threshold = threshold
self.sysfs_path = sysfs_path
class Battery(Service):
@@ -563,6 +573,28 @@ class Battery(Service):
def _do_recalculate_charge_limit(self) -> None:
self._charge_limit = None
# Try sysfs first
sysfs_path = self._find_sysfs_charge_control()
if sysfs_path is not None:
try:
with open(sysfs_path) as f:
current = int(f.read().strip())
enabled = current < 100
self._charge_limit = ChargeLimit(
enabled=enabled,
device_path="sysfs",
threshold=current if enabled else None,
sysfs_path=sysfs_path,
)
logger.info(
f"[Battery] Charge limit via sysfs: {sysfs_path} = {current}"
)
return
except Exception as e:
logger.warning(f"[Battery] Failed to read sysfs charge control: {e}")
# Fall back to UPower D-Bus
for path in self._system_battery_paths:
proxy = self._device_proxies.get(path)
if proxy is None:
@@ -597,6 +629,16 @@ class Battery(Service):
except Exception as e:
logger.warning(f"[Battery] Failed to check charge limit on {path}: {e}")
@staticmethod
def _find_sysfs_charge_control() -> str | None:
candidates = sorted(glob("/sys/class/power_supply/BAT*"))
for bat_dir in candidates:
for name in ("charge_control_end", "charge_control"):
path = os.path.join(bat_dir, name)
if os.path.isfile(path) and os.access(path, os.R_OK | os.W_OK):
return path
return None
def _do_recalculate_peripherals(self) -> None:
peripherals = []
for path, proxy in self._device_proxies.items():
@@ -710,11 +752,31 @@ class Battery(Service):
def toggle_charge_limit(self) -> bool:
if self._charge_limit is None:
return False
cl = self._charge_limit
# sysfs path: write directly
if cl.sysfs_path is not None:
try:
target = 100 if cl.enabled else (cl.threshold or 80)
with open(cl.sysfs_path, "w") as f:
f.write(str(target))
self._charge_limit = ChargeLimit(
enabled=target < 100,
device_path=cl.device_path,
threshold=target if target < 100 else None,
sysfs_path=cl.sysfs_path,
)
return True
except Exception as e:
logger.error(f"[Battery] Failed to toggle sysfs charge limit: {e}")
return False
# UPower D-Bus path
try:
proxy = self._device_proxies.get(self._charge_limit.device_path)
proxy = self._device_proxies.get(cl.device_path)
if proxy is None:
return False
target = not self._charge_limit.enabled
target = not cl.enabled
self._toggling_charge_limit = True
try:
proxy.call_sync(
@@ -726,14 +788,52 @@ class Battery(Service):
)
finally:
GLib.idle_add(self._clear_toggling_flag)
self._charge_limit = ChargeLimit(
enabled=target, device_path=self._charge_limit.device_path
)
self._charge_limit = ChargeLimit(enabled=target, device_path=cl.device_path)
return True
except Exception as e:
logger.error(f"[Battery] Failed to toggle charge limit: {e}")
return False
def get_charge_threshold(self) -> int | None:
cl = self._charge_limit
if cl is None:
return None
if cl.threshold is not None:
return cl.threshold
if cl.enabled:
return 80
return 100
def set_charge_threshold(self, threshold: int) -> bool:
cl = self._charge_limit
if cl is None:
return False
threshold = max(20, min(100, threshold))
# sysfs path
if cl.sysfs_path is not None:
try:
with open(cl.sysfs_path, "w") as f:
f.write(str(threshold))
self._charge_limit = ChargeLimit(
enabled=threshold < 100,
device_path=cl.device_path,
threshold=threshold if threshold < 100 else None,
sysfs_path=cl.sysfs_path,
)
return True
except Exception as e:
logger.error(f"[Battery] Failed to set sysfs charge threshold: {e}")
return False
# UPower D-Bus: only supports on/off, not percentage
if threshold < 100 and not cl.enabled:
return self.toggle_charge_limit()
if threshold >= 100 and cl.enabled:
return self.toggle_charge_limit()
return True
def _clear_toggling_flag(self) -> bool:
self._toggling_charge_limit = False
return False