diff --git a/src/adjustor/core/charge_full_once.py b/src/adjustor/core/charge_full_once.py new file mode 100644 index 00000000..a67e0bf5 --- /dev/null +++ b/src/adjustor/core/charge_full_once.py @@ -0,0 +1,90 @@ +import logging +import os +from typing import Callable + +logger = logging.getLogger(__name__) + + +def detect_ac_online() -> bool | None: + base = "/sys/class/power_supply" + try: + for name in os.listdir(base): + type_path = os.path.join(base, name, "type") + online_path = os.path.join(base, name, "online") + try: + with open(type_path) as f: + if f.read().strip() != "Mains": + continue + with open(online_path) as f: + return f.read().strip() == "1" + except OSError: + continue + except Exception as e: + logger.error(f"charge_once: AC detection failed: {e}") + return None + + +class ChargeFullOncePolicy: + def __init__(self) -> None: + self.override_active: bool = False + self.ac_online: bool | None = None + self._last_applied: bool | None = None + + def open(self) -> None: + self.override_active = False + self.ac_online = None + self._last_applied = None + + def sync(self, enabled: bool) -> None: + self._last_applied = enabled + + def _apply(self, enabled: bool, set_fn: Callable[[bool], None]) -> None: + if self._last_applied != enabled: + set_fn(enabled) + self._last_applied = enabled + + def update( + self, + normal_limit_enabled: bool, + action_pressed: bool, + set_fn: Callable[[bool], None], + ) -> None: + self.ac_online = detect_ac_online() + + if not normal_limit_enabled: + if self.override_active: + logger.info( + "charge_once: Canceling override, charge limit disabled by user." + ) + self.override_active = False + self._apply(False, set_fn) + return + + if self.ac_online is not True: + if self.override_active: + logger.info( + "charge_once: AC disconnected or unknown, restoring charge limit." + ) + self._apply(True, set_fn) + self.override_active = False + else: + self._apply(True, set_fn) + return + + if action_pressed: + if self.override_active: + logger.info( + "charge_once: User canceled full charge, restoring charge limit." + ) + self._apply(True, set_fn) + self.override_active = False + else: + logger.info( + "charge_once: Starting full-charge override, disabling charge limit." + ) + self._apply(False, set_fn) + self.override_active = True + return + + if not self.override_active: + self._apply(True, set_fn) diff --git a/src/adjustor/drivers/battery/__init__.py b/src/adjustor/drivers/battery/__init__.py index 524220c6..2848f41e 100644 --- a/src/adjustor/drivers/battery/__init__.py +++ b/src/adjustor/drivers/battery/__init__.py @@ -7,6 +7,7 @@ from hhd.plugins import Context, Event, HHDPlugin, load_relative_yaml from hhd.plugins.conf import Config +from adjustor.core.charge_once import ChargeFullOncePolicy from adjustor.core.alib import AlibParams, DeviceParams, alib from adjustor.core.fan import fan_worker, get_fan_info from adjustor.core.platform import get_platform_choices, set_platform_profile @@ -16,6 +17,15 @@ APPLY_DELAY = 0.7 +CHARGE_LIMITS = { + "p65": 65, + "p70": 70, + "p80": 80, + "p85": 85, + "p90": 90, + "p95": 95, +} + def set_charge_limit(bat: str, lim: int): try: @@ -99,8 +109,20 @@ def __init__(self, always_enable: bool = False) -> None: self.charge_bypass_prev = None self.charge_limit_prev = None + self.charge_policy = ChargeFullOncePolicy() + self.charge_once_action = None + self.emit = None + self.always_enable = always_enable + def _set_charge_once_action(self, action: str | None) -> None: + if self.charge_once_action == action: + return + + self.charge_once_action = action + if self.emit is not None: + self.emit({"type": "settings"}) + def settings(self): if not self.enabled: self.initialized = False @@ -111,6 +133,16 @@ def settings(self): if not self.charge_limit_fn: del out["tdp"]["battery"]["children"]["charge_limit"] + del out["tdp"]["battery"]["children"]["charge_once"] + del out["tdp"]["battery"]["children"]["charge_once_restore"] + elif self.charge_once_action == "start": + del out["tdp"]["battery"]["children"]["charge_once_restore"] + elif self.charge_once_action == "restore": + del out["tdp"]["battery"]["children"]["charge_once"] + else: + del out["tdp"]["battery"]["children"]["charge_once"] + del out["tdp"]["battery"]["children"]["charge_once_restore"] + if not self.charge_bypass_fn: del out["tdp"]["battery"]["children"]["charge_bypass"] elif not self.bypass_awake: @@ -125,6 +157,7 @@ def open( ): self.emit = emit self.startup = True + self.charge_policy.open() for bat in os.listdir("/sys/class/power_supply"): if not bat.startswith("BAT"): @@ -189,34 +222,65 @@ def update(self, conf: Config): # Charge limit if self.charge_limit_fn: lim = conf["tdp.battery.charge_limit"].to(str) + limit = CHARGE_LIMITS.get(lim) + charge_limit_enabled = limit is not None + start_pressed = conf.get_action("tdp.battery.charge_once") + restore_pressed = conf.get_action("tdp.battery.charge_once_restore") + action_pressed = start_pressed or restore_pressed + if lim != self.charge_limit_prev: self.queue_charge_limit = curr + APPLY_DELAY self.charge_limit_prev = lim + def apply_charge_limit(enabled: bool): + set_charge_limit( + self.charge_limit_fn, + limit if enabled and limit is not None else 100, + ) + + if self.startup and not charge_limit_enabled: + self.charge_policy.sync(False) + if self.startup or ( self.queue_charge_limit and self.queue_charge_limit < curr ): self.queue_charge_limit = None self.charge_limit_prev = lim - match lim: - case "p65": - set_charge_limit(self.charge_limit_fn, 65) - case "p70": - set_charge_limit(self.charge_limit_fn, 70) - case "p80": - set_charge_limit(self.charge_limit_fn, 80) - case "p85": - set_charge_limit(self.charge_limit_fn, 85) - case "p90": - set_charge_limit(self.charge_limit_fn, 90) - case "p95": - set_charge_limit(self.charge_limit_fn, 95) - case "disabled": - # Avoid writing charge limit on startup if - # disabled, so that if user does not use us - # we do not overwrite their setting. - if not self.startup: - set_charge_limit(self.charge_limit_fn, 100) + if charge_limit_enabled: + if not self.charge_policy.override_active: + apply_charge_limit(True) + self.charge_policy.sync(not self.charge_policy.override_active) + elif lim == "disabled": + # Avoid writing charge limit on startup if disabled, so + # that if user does not use us we do not overwrite their + # setting. + if not self.startup: + apply_charge_limit(False) + self.charge_policy.sync(False) + else: + logger.error(f"Invalid charge limit: {lim}") + + charge_limit_pending = self.queue_charge_limit is not None + if ( + not charge_limit_pending + or action_pressed + or self.charge_policy.override_active + ): + self.charge_policy.update( + charge_limit_enabled, + action_pressed, + apply_charge_limit, + ) + + if self.charge_policy.override_active: + action = "restore" + elif charge_limit_enabled and self.charge_policy.ac_online is True: + action = "start" + else: + action = None + self._set_charge_once_action(action) + else: + self._set_charge_once_action(None) self.startup = False diff --git a/src/adjustor/drivers/battery/battery.yml b/src/adjustor/drivers/battery/battery.yml index c23615ae..261834ab 100644 --- a/src/adjustor/drivers/battery/battery.yml +++ b/src/adjustor/drivers/battery/battery.yml @@ -17,6 +17,20 @@ children: disabled: Unset default: disabled + charge_once: + type: action + title: Charge to 100% Once + tags: [ non-essential ] + hint: >- + Temporarily disables the charge limit until the charger is unplugged. + + charge_once_restore: + type: action + title: Restore Charge Limit + tags: [ non-essential ] + hint: >- + Re-enables the charge limit now, before unplugging. + charge_bypass: type: multiple title: Charge Bypass