refactor(battery): consolidate button styles, refactor service, remove ddcutil
- Merge game-mode/charge-limit CSS into shared selectors - Rewrite Battery service with IntEnum states and charge-limit support - Add ChargeLimitButton with active-state styling - Drop ddcutil from dependencies (unused)
This commit is contained in:
5 files changed
+617
-123
No files matched your search
@@ -49,7 +49,7 @@ The interactive installer handles dependencies, builds C-extensions, and configu
|
||||
1. **Install dependencies:**
|
||||
|
||||
```bash
|
||||
paru -S uv fabric-cli-git uwsm cliphist slurp grim swappy wl-clipboard wtype libnotify playerctl matugen-bin hypridle hyprsunset hyprpicker hyprshot gtk-session-lock awww otf-apple-sf-pro webp-pixbuf-loader cinnamon-desktop libmediaart acpi brightnessctl power-profiles-daemon ddcutil at-spi2-core networkmanager network-manager-applet blueman pipewire libpulse gcc make pkgconf meson ninja wayland-protocols gobject-introspection gtk-layer-shell librsvg libqalculate appmenu-gtk-module libdbusmenu-gtk3 libdbusmenu-qt5 pciutils wf-recorder ffmpeg --needed
|
||||
paru -S uv fabric-cli-git uwsm cliphist slurp grim swappy wl-clipboard wtype libnotify playerctl matugen-bin hypridle hyprsunset hyprpicker hyprshot gtk-session-lock awww otf-apple-sf-pro webp-pixbuf-loader cinnamon-desktop libmediaart acpi brightnessctl power-profiles-daemon at-spi2-core networkmanager network-manager-applet blueman pipewire libpulse gcc make pkgconf meson ninja wayland-protocols gobject-introspection gtk-layer-shell librsvg libqalculate appmenu-gtk-module libdbusmenu-gtk3 libdbusmenu-qt5 pciutils wf-recorder ffmpeg --needed
|
||||
```
|
||||
|
||||
2. **Clone and sync:**
|
||||
|
||||
@@ -66,7 +66,6 @@ PACKAGES=(
|
||||
acpi
|
||||
brightnessctl
|
||||
power-profiles-daemon
|
||||
ddcutil
|
||||
at-spi2-core
|
||||
networkmanager
|
||||
network-manager-applet
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="#fff" stroke="#aaaaaa" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-zap"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
|
||||
|
After Width: | Height: | Size: 277 B |
+595
-77
@@ -1,24 +1,172 @@
|
||||
from enum import IntEnum
|
||||
|
||||
from fabric.core.service import Property, Service, Signal
|
||||
from fabric.utils import Gio, GLib, logger
|
||||
|
||||
BATTERY_BUS_NAME = "org.freedesktop.UPower"
|
||||
BATTERY_BUS_PATH = "/org/freedesktop/UPower/devices/DisplayDevice"
|
||||
BATTERY_INTERFACE = "org.freedesktop.UPower.Device"
|
||||
UPOWER_BUS = "org.freedesktop.UPower"
|
||||
UPOWER_PATH = "/org/freedesktop/UPower"
|
||||
DEVICE_IFACE = "org.freedesktop.UPower.Device"
|
||||
DEVICE_DISPLAY_PATH = "/org/freedesktop/UPower/devices/DisplayDevice"
|
||||
|
||||
POWER_PROFILE_BUS_NAME = "net.hadess.PowerProfiles"
|
||||
POWER_PROFILE_BUS_PATH = "/net/hadess/PowerProfiles"
|
||||
POWER_PROFILE_INTERFACE = "net.hadess.PowerProfiles"
|
||||
POWER_PROFILE_BUS = "org.freedesktop.UPower.PowerProfiles"
|
||||
POWER_PROFILE_PATH = "/org/freedesktop/UPower/PowerProfiles"
|
||||
|
||||
DEVICE_STATE = {
|
||||
0: "UNKNOWN",
|
||||
1: "CHARGING",
|
||||
2: "DISCHARGING",
|
||||
3: "EMPTY",
|
||||
4: "FULLY_CHARGED",
|
||||
5: "PENDING_CHARGE",
|
||||
6: "PENDING_DISCHARGE",
|
||||
|
||||
class DeviceState(IntEnum):
|
||||
UNKNOWN = 0
|
||||
CHARGING = 1
|
||||
DISCHARGING = 2
|
||||
EMPTY = 3
|
||||
FULLY_CHARGED = 4
|
||||
PENDING_CHARGE = 5
|
||||
PENDING_DISCHARGE = 6
|
||||
|
||||
|
||||
class UpDeviceKind(IntEnum):
|
||||
UNKNOWN = 0
|
||||
LINE_POWER = 1
|
||||
BATTERY = 2
|
||||
UPS = 3
|
||||
MONITOR = 4
|
||||
MOUSE = 5
|
||||
KEYBOARD = 6
|
||||
PDA = 7
|
||||
PHONE = 8
|
||||
MEDIA_PLAYER = 9
|
||||
TABLET = 10
|
||||
COMPUTER = 11
|
||||
GAMING_INPUT = 12
|
||||
PEN = 13
|
||||
TOUCHPAD = 14
|
||||
HEADSET = 17
|
||||
SPEAKERS = 18
|
||||
HEADPHONES = 19
|
||||
|
||||
def is_peripheral(self) -> bool:
|
||||
return self in (
|
||||
self.MOUSE,
|
||||
self.KEYBOARD,
|
||||
self.GAMING_INPUT,
|
||||
self.HEADSET,
|
||||
self.HEADPHONES,
|
||||
self.TOUCHPAD,
|
||||
)
|
||||
|
||||
def is_power_source(self) -> bool:
|
||||
return self == self.BATTERY
|
||||
|
||||
|
||||
class PeripheralDeviceKind(IntEnum):
|
||||
KEYBOARD = 0
|
||||
MOUSE = 1
|
||||
HEADPHONES = 2
|
||||
GAMEPAD = 3
|
||||
|
||||
def __str__(self) -> str:
|
||||
return _PERIPHERAL_NAMES[self]
|
||||
|
||||
|
||||
_PERIPHERAL_NAMES = {
|
||||
PeripheralDeviceKind.KEYBOARD: "Keyboard",
|
||||
PeripheralDeviceKind.MOUSE: "Mouse",
|
||||
PeripheralDeviceKind.HEADPHONES: "Headphones",
|
||||
PeripheralDeviceKind.GAMEPAD: "Gamepad",
|
||||
}
|
||||
|
||||
_DEVICE_KIND_TO_PERIPHERAL = {
|
||||
UpDeviceKind.MOUSE: PeripheralDeviceKind.MOUSE,
|
||||
UpDeviceKind.TOUCHPAD: PeripheralDeviceKind.MOUSE,
|
||||
UpDeviceKind.KEYBOARD: PeripheralDeviceKind.KEYBOARD,
|
||||
UpDeviceKind.HEADPHONES: PeripheralDeviceKind.HEADPHONES,
|
||||
UpDeviceKind.HEADSET: PeripheralDeviceKind.HEADPHONES,
|
||||
UpDeviceKind.GAMING_INPUT: PeripheralDeviceKind.GAMEPAD,
|
||||
}
|
||||
|
||||
|
||||
class BatteryData:
|
||||
__slots__ = ("capacity", "is_discharging", "status")
|
||||
|
||||
def __init__(self, capacity: int, status: "BatteryStatus", is_discharging: bool):
|
||||
self.capacity = capacity
|
||||
self.status = status
|
||||
self.is_discharging = is_discharging
|
||||
|
||||
def get_icon_name(self) -> str:
|
||||
if self.status == BatteryStatus.CHARGING:
|
||||
return "battery-level-04-charging-symbolic"
|
||||
if self.status == BatteryStatus.FULL:
|
||||
return "battery-full-charged-symbolic"
|
||||
return _level_icon(self.capacity)
|
||||
|
||||
|
||||
class BatteryStatus(IntEnum):
|
||||
UNKNOWN = 0
|
||||
CHARGING = 1
|
||||
DISCHARGING = 2
|
||||
NOT_CHARGING = 3
|
||||
FULL = 4
|
||||
|
||||
def __str__(self) -> str:
|
||||
return _STATUS_NAMES[self]
|
||||
|
||||
|
||||
_STATUS_NAMES = {
|
||||
BatteryStatus.UNKNOWN: "UNKNOWN",
|
||||
BatteryStatus.CHARGING: "CHARGING",
|
||||
BatteryStatus.DISCHARGING: "DISCHARGING",
|
||||
BatteryStatus.NOT_CHARGING: "NOT_CHARGING",
|
||||
BatteryStatus.FULL: "FULLY_CHARGED",
|
||||
}
|
||||
|
||||
|
||||
def _level_icon(capacity: int) -> str:
|
||||
if capacity < 20:
|
||||
return "battery-level-00-symbolic"
|
||||
if capacity < 40:
|
||||
return "battery-level-02-symbolic"
|
||||
if capacity < 60:
|
||||
return "battery-level-04-symbolic"
|
||||
if capacity < 80:
|
||||
return "battery-level-06-symbolic"
|
||||
return "battery-level-08-symbolic"
|
||||
|
||||
|
||||
def _state_from_raw(state: int) -> BatteryStatus:
|
||||
return {
|
||||
DeviceState.CHARGING: BatteryStatus.CHARGING,
|
||||
DeviceState.DISCHARGING: BatteryStatus.DISCHARGING,
|
||||
DeviceState.FULLY_CHARGED: BatteryStatus.FULL,
|
||||
DeviceState.PENDING_CHARGE: BatteryStatus.NOT_CHARGING,
|
||||
DeviceState.PENDING_DISCHARGE: BatteryStatus.NOT_CHARGING,
|
||||
}.get(state, BatteryStatus.UNKNOWN)
|
||||
|
||||
|
||||
def _device_kind_from_u32(value: int) -> UpDeviceKind:
|
||||
try:
|
||||
return UpDeviceKind(value)
|
||||
except ValueError:
|
||||
return UpDeviceKind.UNKNOWN
|
||||
|
||||
|
||||
class Peripheral:
|
||||
__slots__ = ("data", "kind", "name", "path")
|
||||
|
||||
def __init__(
|
||||
self, name: str, kind: PeripheralDeviceKind, data: BatteryData, path: str
|
||||
):
|
||||
self.name = name
|
||||
self.kind = kind
|
||||
self.data = data
|
||||
self.path = path
|
||||
|
||||
|
||||
class ChargeLimit:
|
||||
__slots__ = ("device_path", "enabled")
|
||||
|
||||
def __init__(self, enabled: bool, device_path: str):
|
||||
self.enabled = enabled
|
||||
self.device_path = device_path
|
||||
|
||||
|
||||
class Battery(Service):
|
||||
"""A service for interacting with the battery's DBus and Power Profiles.
|
||||
@@ -42,157 +190,489 @@ class Battery(Service):
|
||||
|
||||
@Property(bool, "readable", default_value=False)
|
||||
def available(self) -> bool:
|
||||
return self.do_get_cached_property("IsPresent") or False
|
||||
if self._system_battery is not None:
|
||||
return True
|
||||
return self._do_get_cached_property("IsPresent") or False
|
||||
|
||||
@Property(str, "readable", default_value="")
|
||||
def vendor(self) -> str:
|
||||
return self.do_get_cached_property("Vendor") or ""
|
||||
return self._do_get_cached_property("Vendor") or ""
|
||||
|
||||
@Property(int, "readable", default_value=0)
|
||||
def percent(self) -> int:
|
||||
return self.do_get_cached_property("Percentage") or 0
|
||||
if self._system_battery is not None:
|
||||
return self._system_battery.capacity
|
||||
return self._do_get_cached_property("Percentage") or 0
|
||||
|
||||
@Property(bool, "readable", default_value=False)
|
||||
def charging(self) -> bool:
|
||||
val = self.do_get_cached_property("State")
|
||||
if val is None:
|
||||
return False
|
||||
return val == DEVICE_STATE.get("CHARGING", 1)
|
||||
if self._system_battery is not None:
|
||||
return self._system_battery.status == BatteryStatus.CHARGING
|
||||
return self._raw_state() == DeviceState.CHARGING
|
||||
|
||||
@Property(bool, "readable", default_value=False)
|
||||
def discharging(self) -> bool:
|
||||
val = self.do_get_cached_property("State")
|
||||
if val is None:
|
||||
return False
|
||||
return val == DEVICE_STATE.get("DISCHARGING", 2)
|
||||
if self._system_battery is not None:
|
||||
return self._system_battery.status == BatteryStatus.DISCHARGING
|
||||
return self._raw_state() == DeviceState.DISCHARGING
|
||||
|
||||
@Property(bool, "readable", default_value=False)
|
||||
def charged(self) -> bool:
|
||||
val = self.do_get_cached_property("State")
|
||||
if val is None:
|
||||
return False
|
||||
return val == DEVICE_STATE.get("FULLY_CHARGED", 4)
|
||||
if self._system_battery is not None:
|
||||
return self._system_battery.status == BatteryStatus.FULL
|
||||
return self._raw_state() == DeviceState.FULLY_CHARGED
|
||||
|
||||
@Property(str, "readable", default_value="")
|
||||
def icon_name(self) -> str:
|
||||
return self.do_get_cached_property("IconName") or ""
|
||||
if self._system_battery is not None:
|
||||
return self._system_battery.get_icon_name()
|
||||
return self._do_get_cached_property("IconName") or ""
|
||||
|
||||
@Property(int, "readable", default_value=0)
|
||||
def time_remaining(self) -> int:
|
||||
return self.do_get_cached_property("TimeToEmpty") or 0
|
||||
return self._do_get_cached_property("TimeToEmpty") or 0
|
||||
|
||||
@Property(int, "readable", default_value=0)
|
||||
def time_to_full(self) -> int:
|
||||
return self.do_get_cached_property("TimeToFull") or 0
|
||||
return self._do_get_cached_property("TimeToFull") or 0
|
||||
|
||||
@Property(float, "readable", default_value=0.0)
|
||||
def temperature(self) -> float:
|
||||
return self.do_get_cached_property("Temperature") or 0.0
|
||||
return self._do_get_cached_property("Temperature") or 0.0
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._bus: Gio.DBusConnection | None = None
|
||||
self._proxy = None
|
||||
self._power_profile_proxy = None
|
||||
# _prop_cache stores ONLY properties received via PropertiesChanged.
|
||||
# For fresh reads we always fall through to the GDBus proxy cache.
|
||||
self._display_proxy: Gio.DBusProxy | None = None
|
||||
self._power_profile_proxy: Gio.DBusProxy | None = None
|
||||
self._prop_cache: dict[str, object] = {}
|
||||
self.do_register()
|
||||
self._device_proxies: dict[str, Gio.DBusProxy] = {}
|
||||
self._system_battery: BatteryData | None = None
|
||||
self._system_battery_paths: list[str] = []
|
||||
self._peripherals: list[Peripheral] = []
|
||||
self._charge_limit: ChargeLimit | None = None
|
||||
self._power_profile: str = ""
|
||||
self._toggling_charge_limit: bool = False
|
||||
self._do_register()
|
||||
|
||||
def do_register(self) -> None:
|
||||
def _raw_state(self) -> DeviceState:
|
||||
val = self._do_get_cached_property("State")
|
||||
if val is None:
|
||||
return DeviceState.UNKNOWN
|
||||
try:
|
||||
return DeviceState(val)
|
||||
except ValueError:
|
||||
return DeviceState.UNKNOWN
|
||||
|
||||
def _do_register(self) -> None:
|
||||
self._bus = Gio.bus_get_sync(Gio.BusType.SYSTEM)
|
||||
self._proxy = Gio.DBusProxy.new_sync(
|
||||
|
||||
self._display_proxy = Gio.DBusProxy.new_sync(
|
||||
self._bus,
|
||||
Gio.DBusProxyFlags.NONE,
|
||||
None,
|
||||
BATTERY_BUS_NAME,
|
||||
BATTERY_BUS_PATH,
|
||||
BATTERY_INTERFACE,
|
||||
UPOWER_BUS,
|
||||
DEVICE_DISPLAY_PATH,
|
||||
DEVICE_IFACE,
|
||||
None,
|
||||
)
|
||||
|
||||
logger.info("[Battery] Proxy initialized")
|
||||
logger.info("[Battery] DisplayDevice proxy initialized")
|
||||
|
||||
self._bus.signal_subscribe(
|
||||
BATTERY_BUS_NAME,
|
||||
UPOWER_BUS,
|
||||
"org.freedesktop.DBus.Properties",
|
||||
"PropertiesChanged",
|
||||
BATTERY_BUS_PATH,
|
||||
DEVICE_DISPLAY_PATH,
|
||||
None,
|
||||
Gio.DBusSignalFlags.NONE,
|
||||
self.do_handle_property_change,
|
||||
self._do_handle_display_device_change,
|
||||
)
|
||||
|
||||
self._do_enumerate_devices()
|
||||
self._do_setup_upower_signals()
|
||||
self._do_setup_power_profile()
|
||||
|
||||
def _do_enumerate_devices(self) -> None:
|
||||
try:
|
||||
result = self._bus.call_sync(
|
||||
UPOWER_BUS,
|
||||
UPOWER_PATH,
|
||||
UPOWER_BUS,
|
||||
"EnumerateDevices",
|
||||
None,
|
||||
None,
|
||||
Gio.DBusCallFlags.NONE,
|
||||
-1,
|
||||
None,
|
||||
)
|
||||
device_paths = result.unpack()[0]
|
||||
except Exception as e:
|
||||
logger.warning(f"[Battery] Failed to enumerate devices: {e}")
|
||||
return
|
||||
|
||||
for path in device_paths:
|
||||
self._do_add_device(path)
|
||||
|
||||
self._do_recalculate_system_battery()
|
||||
|
||||
def _do_add_device(self, path: str) -> None:
|
||||
try:
|
||||
proxy = Gio.DBusProxy.new_sync(
|
||||
self._bus,
|
||||
Gio.DBusProxyFlags.NONE,
|
||||
None,
|
||||
UPOWER_BUS,
|
||||
path,
|
||||
DEVICE_IFACE,
|
||||
None,
|
||||
)
|
||||
self._device_proxies[path] = proxy
|
||||
|
||||
self._bus.signal_subscribe(
|
||||
UPOWER_BUS,
|
||||
"org.freedesktop.DBus.Properties",
|
||||
"PropertiesChanged",
|
||||
path,
|
||||
None,
|
||||
Gio.DBusSignalFlags.NONE,
|
||||
self._do_handle_device_change,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"[Battery] Failed to create proxy for {path}: {e}")
|
||||
|
||||
def _do_setup_upower_signals(self) -> None:
|
||||
try:
|
||||
self._bus.signal_subscribe(
|
||||
UPOWER_BUS,
|
||||
UPOWER_BUS,
|
||||
"DeviceAdded",
|
||||
UPOWER_PATH,
|
||||
None,
|
||||
Gio.DBusSignalFlags.NONE,
|
||||
self._do_handle_device_added,
|
||||
)
|
||||
self._bus.signal_subscribe(
|
||||
UPOWER_BUS,
|
||||
UPOWER_BUS,
|
||||
"DeviceRemoved",
|
||||
UPOWER_PATH,
|
||||
None,
|
||||
Gio.DBusSignalFlags.NONE,
|
||||
self._do_handle_device_removed,
|
||||
)
|
||||
logger.info("[Battery] UPower device signals subscribed")
|
||||
except Exception as e:
|
||||
logger.warning(f"[Battery] Failed to setup UPower signals: {e}")
|
||||
|
||||
def _do_setup_power_profile(self) -> None:
|
||||
try:
|
||||
self._power_profile_proxy = Gio.DBusProxy.new_sync(
|
||||
self._bus,
|
||||
Gio.DBusProxyFlags.NONE,
|
||||
None,
|
||||
POWER_PROFILE_BUS_NAME,
|
||||
POWER_PROFILE_BUS_PATH,
|
||||
POWER_PROFILE_INTERFACE,
|
||||
POWER_PROFILE_BUS,
|
||||
POWER_PROFILE_PATH,
|
||||
POWER_PROFILE_BUS,
|
||||
None,
|
||||
)
|
||||
result = self._power_profile_proxy.get_cached_property("ActiveProfile")
|
||||
if result is not None:
|
||||
self._power_profile = result.unpack()
|
||||
|
||||
self._bus.signal_subscribe(
|
||||
POWER_PROFILE_BUS_NAME,
|
||||
POWER_PROFILE_BUS,
|
||||
"org.freedesktop.DBus.Properties",
|
||||
"PropertiesChanged",
|
||||
POWER_PROFILE_BUS_PATH,
|
||||
POWER_PROFILE_PATH,
|
||||
None,
|
||||
Gio.DBusSignalFlags.NONE,
|
||||
self.do_handle_power_profile_change,
|
||||
self._do_handle_power_profile_change,
|
||||
)
|
||||
logger.info("[Battery] Power Profiles Proxy initialized")
|
||||
logger.info("[Battery] Power Profiles proxy initialized")
|
||||
except Exception as e:
|
||||
logger.warning(f"[Battery] Power Profiles daemon not available: {e}")
|
||||
|
||||
def do_handle_property_change(
|
||||
def _do_handle_display_device_change(
|
||||
self,
|
||||
_connection: Gio.DBusConnection,
|
||||
_sender_name: str,
|
||||
_object_path: str,
|
||||
_interface_name: str,
|
||||
_signal_name: str,
|
||||
_sender: str,
|
||||
_path: str,
|
||||
_interface: str,
|
||||
_signal: str,
|
||||
parameters: GLib.Variant,
|
||||
):
|
||||
if self._toggling_charge_limit:
|
||||
return
|
||||
try:
|
||||
_iface, changed_props, _invalidated = parameters.unpack()
|
||||
for prop_name, variant in changed_props.items():
|
||||
self._prop_cache[prop_name] = variant.unpack()
|
||||
except Exception as e:
|
||||
logger.warning(f"[Battery] Failed to parse PropertiesChanged: {e}")
|
||||
logger.warning(
|
||||
f"[Battery] Failed to parse DisplayDevice PropertiesChanged: {e}"
|
||||
)
|
||||
self.emit("changed")
|
||||
|
||||
def do_handle_power_profile_change(self, *_):
|
||||
def _do_handle_device_change(self, *_args):
|
||||
if self._toggling_charge_limit:
|
||||
return
|
||||
self._do_recalculate_system_battery()
|
||||
self._do_recalculate_peripherals()
|
||||
self.emit("changed")
|
||||
|
||||
def _do_handle_device_added(self, *_args):
|
||||
try:
|
||||
result = self._bus.call_sync(
|
||||
UPOWER_BUS,
|
||||
UPOWER_PATH,
|
||||
UPOWER_BUS,
|
||||
"EnumerateDevices",
|
||||
None,
|
||||
None,
|
||||
Gio.DBusCallFlags.NONE,
|
||||
-1,
|
||||
None,
|
||||
)
|
||||
for path in result.unpack()[0]:
|
||||
if path not in self._device_proxies:
|
||||
self._do_add_device(path)
|
||||
self._do_recalculate_system_battery()
|
||||
self._do_recalculate_peripherals()
|
||||
self.emit("changed")
|
||||
except Exception as e:
|
||||
logger.warning(f"[Battery] Failed to handle DeviceAdded: {e}")
|
||||
|
||||
def _do_handle_device_removed(self, *_args):
|
||||
try:
|
||||
result = self._bus.call_sync(
|
||||
UPOWER_BUS,
|
||||
UPOWER_PATH,
|
||||
UPOWER_BUS,
|
||||
"EnumerateDevices",
|
||||
None,
|
||||
None,
|
||||
Gio.DBusCallFlags.NONE,
|
||||
-1,
|
||||
None,
|
||||
)
|
||||
current_paths = set(result.unpack()[0])
|
||||
removed = [p for p in self._device_proxies if p not in current_paths]
|
||||
for path in removed:
|
||||
del self._device_proxies[path]
|
||||
if removed:
|
||||
self._do_recalculate_system_battery()
|
||||
self._do_recalculate_peripherals()
|
||||
self.emit("changed")
|
||||
except Exception as e:
|
||||
logger.warning(f"[Battery] Failed to handle DeviceRemoved: {e}")
|
||||
|
||||
def _do_recalculate_system_battery(self) -> None:
|
||||
charging = False
|
||||
discharging = False
|
||||
pending_charge = False
|
||||
pending_discharge = False
|
||||
fully_charged_count = 0
|
||||
total_devices = 0
|
||||
paths = []
|
||||
|
||||
for path, proxy in self._device_proxies.items():
|
||||
kind_raw = proxy.get_cached_property("Type")
|
||||
power_supply = proxy.get_cached_property("PowerSupply")
|
||||
if kind_raw is None or power_supply is None:
|
||||
continue
|
||||
kind = _device_kind_from_u32(kind_raw.unpack())
|
||||
if not kind.is_power_source() or not power_supply.unpack():
|
||||
continue
|
||||
|
||||
energy_full_variant = proxy.get_cached_property("EnergyFull")
|
||||
if energy_full_variant is not None and energy_full_variant.unpack() == 0.0:
|
||||
continue
|
||||
|
||||
state_variant = proxy.get_cached_property("State")
|
||||
if state_variant is None:
|
||||
continue
|
||||
|
||||
try:
|
||||
state = DeviceState(state_variant.unpack())
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
total_devices += 1
|
||||
paths.append(path)
|
||||
if state == DeviceState.CHARGING:
|
||||
charging = True
|
||||
elif state == DeviceState.DISCHARGING:
|
||||
discharging = True
|
||||
elif state == DeviceState.FULLY_CHARGED:
|
||||
fully_charged_count += 1
|
||||
elif state == DeviceState.PENDING_CHARGE:
|
||||
pending_charge = True
|
||||
elif state == DeviceState.PENDING_DISCHARGE:
|
||||
pending_discharge = True
|
||||
|
||||
self._system_battery_paths = paths
|
||||
|
||||
if total_devices == 0:
|
||||
self._system_battery = None
|
||||
self._charge_limit = None
|
||||
return
|
||||
|
||||
if fully_charged_count == total_devices:
|
||||
merged_state = DeviceState.FULLY_CHARGED
|
||||
elif charging:
|
||||
merged_state = DeviceState.CHARGING
|
||||
elif discharging:
|
||||
merged_state = DeviceState.DISCHARGING
|
||||
elif pending_charge:
|
||||
merged_state = DeviceState.PENDING_CHARGE
|
||||
elif pending_discharge:
|
||||
merged_state = DeviceState.PENDING_DISCHARGE
|
||||
else:
|
||||
merged_state = DeviceState.UNKNOWN
|
||||
|
||||
if len(paths) == 1:
|
||||
proxy = self._device_proxies[paths[0]]
|
||||
pct_variant = proxy.get_cached_property("Percentage")
|
||||
if pct_variant is None:
|
||||
self._system_battery = None
|
||||
return
|
||||
capacity = int(pct_variant.unpack())
|
||||
else:
|
||||
energy = 0.0
|
||||
energy_full = 0.0
|
||||
for path in paths:
|
||||
proxy = self._device_proxies[path]
|
||||
e = proxy.get_cached_property("Energy")
|
||||
ef = proxy.get_cached_property("EnergyFull")
|
||||
if e is not None:
|
||||
energy += e.unpack()
|
||||
if ef is not None:
|
||||
energy_full += ef.unpack()
|
||||
if energy_full == 0.0:
|
||||
self._system_battery = None
|
||||
return
|
||||
capacity = int(energy / energy_full * 100.0)
|
||||
|
||||
self._system_battery = BatteryData(
|
||||
capacity=capacity,
|
||||
status=_state_from_raw(merged_state),
|
||||
is_discharging=merged_state == DeviceState.DISCHARGING,
|
||||
)
|
||||
|
||||
self._do_recalculate_charge_limit()
|
||||
|
||||
def _do_recalculate_charge_limit(self) -> None:
|
||||
self._charge_limit = None
|
||||
for path in self._system_battery_paths:
|
||||
proxy = self._device_proxies.get(path)
|
||||
if proxy is None:
|
||||
continue
|
||||
try:
|
||||
introspection = proxy.call_sync(
|
||||
"org.freedesktop.DBus.Introspectable.Introspect",
|
||||
None,
|
||||
Gio.DBusCallFlags.NONE,
|
||||
-1,
|
||||
None,
|
||||
)
|
||||
xml = introspection.unpack()[0]
|
||||
if "EnableChargeThreshold" not in xml:
|
||||
continue
|
||||
|
||||
supported_variant = proxy.get_cached_property(
|
||||
"ChargeThresholdSupported"
|
||||
)
|
||||
if supported_variant is None or not supported_variant.unpack():
|
||||
continue
|
||||
|
||||
enabled_variant = proxy.get_cached_property("ChargeThresholdEnabled")
|
||||
if enabled_variant is None:
|
||||
continue
|
||||
|
||||
self._charge_limit = ChargeLimit(
|
||||
enabled=enabled_variant.unpack(),
|
||||
device_path=path,
|
||||
)
|
||||
return
|
||||
except Exception as e:
|
||||
logger.warning(f"[Battery] Failed to check charge limit on {path}: {e}")
|
||||
|
||||
def _do_recalculate_peripherals(self) -> None:
|
||||
peripherals = []
|
||||
for path, proxy in self._device_proxies.items():
|
||||
kind_raw = proxy.get_cached_property("Type")
|
||||
power_supply = proxy.get_cached_property("PowerSupply")
|
||||
if kind_raw is None or power_supply is None:
|
||||
continue
|
||||
kind = _device_kind_from_u32(kind_raw.unpack())
|
||||
if not kind.is_peripheral() or power_supply.unpack():
|
||||
continue
|
||||
|
||||
peripheral_kind = _DEVICE_KIND_TO_PERIPHERAL.get(kind)
|
||||
if peripheral_kind is None:
|
||||
continue
|
||||
|
||||
model_variant = proxy.get_cached_property("Model")
|
||||
name = (
|
||||
model_variant.unpack()
|
||||
if model_variant is not None
|
||||
else str(peripheral_kind)
|
||||
)
|
||||
|
||||
state_variant = proxy.get_cached_property("State")
|
||||
state = (
|
||||
DeviceState(state_variant.unpack())
|
||||
if state_variant is not None
|
||||
else DeviceState.UNKNOWN
|
||||
)
|
||||
|
||||
pct_variant = proxy.get_cached_property("Percentage")
|
||||
capacity = int(pct_variant.unpack()) if pct_variant is not None else 0
|
||||
|
||||
peripherals.append(
|
||||
Peripheral(
|
||||
name=name,
|
||||
kind=peripheral_kind,
|
||||
data=BatteryData(
|
||||
capacity=capacity,
|
||||
status=_state_from_raw(state),
|
||||
is_discharging=state == DeviceState.DISCHARGING,
|
||||
),
|
||||
path=path,
|
||||
)
|
||||
)
|
||||
self._peripherals = peripherals
|
||||
|
||||
def _do_handle_power_profile_change(self, *_args):
|
||||
if self._power_profile_proxy is not None:
|
||||
result = self._power_profile_proxy.get_cached_property("ActiveProfile")
|
||||
if result is not None:
|
||||
self._power_profile = result.unpack()
|
||||
self.emit("power_profile_changed")
|
||||
|
||||
def do_get_cached_property(self, property_name):
|
||||
"""Read from the GDBus proxy cache, which DBus keeps up-to-date."""
|
||||
result = self._proxy.get_cached_property(property_name)
|
||||
def _do_get_cached_property(self, property_name: str):
|
||||
result = self._display_proxy.get_cached_property(property_name)
|
||||
if result is not None:
|
||||
return result.unpack()
|
||||
# Fallback: value not in proxy cache yet (e.g. very early in startup)
|
||||
return self._prop_cache.get(property_name)
|
||||
|
||||
def get_power_profile(self) -> str | None:
|
||||
"""Get the current active power profile."""
|
||||
if not self._power_profile_proxy:
|
||||
return None
|
||||
result = self._power_profile_proxy.get_cached_property("ActiveProfile")
|
||||
return result.unpack() if result is not None else None
|
||||
|
||||
def set_power_profile(self, profile: str) -> bool:
|
||||
"""Set the active power profile."""
|
||||
if not self._power_profile_proxy:
|
||||
return False
|
||||
try:
|
||||
parameters = GLib.Variant(
|
||||
"(ssv)",
|
||||
(POWER_PROFILE_INTERFACE, "ActiveProfile", GLib.Variant("s", profile)),
|
||||
(POWER_PROFILE_BUS, "ActiveProfile", GLib.Variant("s", profile)),
|
||||
)
|
||||
self._bus.call_sync(
|
||||
POWER_PROFILE_BUS_NAME,
|
||||
POWER_PROFILE_BUS_PATH,
|
||||
POWER_PROFILE_BUS,
|
||||
POWER_PROFILE_PATH,
|
||||
"org.freedesktop.DBus.Properties",
|
||||
"Set",
|
||||
parameters,
|
||||
@@ -207,15 +687,53 @@ class Battery(Service):
|
||||
return False
|
||||
|
||||
def get_available_power_profiles(self) -> list | None:
|
||||
"""Get list of available power profiles."""
|
||||
if not self._power_profile_proxy:
|
||||
return None
|
||||
result = self._power_profile_proxy.get_cached_property("Profiles")
|
||||
if result is not None:
|
||||
profiles_data = result.unpack()
|
||||
profiles = []
|
||||
for profile_dict in profiles_data:
|
||||
if "Profile" in profile_dict:
|
||||
profiles.append(profile_dict["Profile"])
|
||||
return profiles
|
||||
return [p["Profile"] for p in profiles_data if "Profile" in p]
|
||||
return None
|
||||
|
||||
@property
|
||||
def charge_limit(self) -> ChargeLimit | None:
|
||||
return self._charge_limit
|
||||
|
||||
@property
|
||||
def peripherals(self) -> list[Peripheral]:
|
||||
return self._peripherals
|
||||
|
||||
@property
|
||||
def system_battery(self) -> BatteryData | None:
|
||||
return self._system_battery
|
||||
|
||||
def toggle_charge_limit(self) -> bool:
|
||||
if self._charge_limit is None:
|
||||
return False
|
||||
try:
|
||||
proxy = self._device_proxies.get(self._charge_limit.device_path)
|
||||
if proxy is None:
|
||||
return False
|
||||
target = not self._charge_limit.enabled
|
||||
self._toggling_charge_limit = True
|
||||
try:
|
||||
proxy.call_sync(
|
||||
"EnableChargeThreshold",
|
||||
GLib.Variant("(b)", (target,)),
|
||||
Gio.DBusCallFlags.NONE,
|
||||
-1,
|
||||
None,
|
||||
)
|
||||
finally:
|
||||
GLib.idle_add(self._clear_toggling_flag)
|
||||
self._charge_limit = ChargeLimit(
|
||||
enabled=target, device_path=self._charge_limit.device_path
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"[Battery] Failed to toggle charge limit: {e}")
|
||||
return False
|
||||
|
||||
def _clear_toggling_flag(self) -> bool:
|
||||
self._toggling_charge_limit = False
|
||||
return False
|
||||
@@ -35,7 +35,9 @@
|
||||
color: @glass-text;
|
||||
}
|
||||
|
||||
#energy-mode-button-clickable {
|
||||
#energy-mode-button-clickable,
|
||||
#charge-limit-button-clickable,
|
||||
#game-mode-button-clickable{
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: apply(radius-sm);
|
||||
@@ -43,15 +45,21 @@
|
||||
margin: 1px 0;
|
||||
}
|
||||
|
||||
#energy-mode-button-clickable:hover {
|
||||
#energy-mode-button-clickable:hover,
|
||||
#charge-limit-button-clickable:hover,
|
||||
#game-mode-button-clickable:hover {
|
||||
background: @glass-hover;
|
||||
}
|
||||
|
||||
#energy-mode-button-clickable:hover #energy-mode-icon {
|
||||
#energy-mode-button-clickable:hover #energy-mode-icon,
|
||||
#game-mode-button-clickable:hover #game-mode-icon,
|
||||
#charge-limit-button-clickable:hover #charge-limit-icon {
|
||||
background-color: @glass-active;
|
||||
}
|
||||
|
||||
#energy-mode-icon {
|
||||
#energy-mode-icon,
|
||||
#game-mode-icon,
|
||||
#charge-limit-icon{
|
||||
border-radius: apply(radius-full);
|
||||
padding: 4px;
|
||||
margin-top: -2px;
|
||||
@@ -67,11 +75,13 @@
|
||||
margin-bottom: -4px;
|
||||
color: @glass-text-secondary;
|
||||
}
|
||||
|
||||
.battery-section-title {
|
||||
font-size: apply(font-base);
|
||||
color: @glass-text-secondary;
|
||||
margin: 2px 0 0 8px;
|
||||
}
|
||||
|
||||
.battery-power-mode {
|
||||
font-size: apply(font-md);
|
||||
}
|
||||
@@ -82,66 +92,32 @@
|
||||
min-height: 30px;
|
||||
border-radius: apply(radius-sm);
|
||||
}
|
||||
|
||||
.battery-settings-button {
|
||||
margin-top: -5px;
|
||||
min-height: 30px;
|
||||
border-radius: apply(radius-sm);
|
||||
}
|
||||
|
||||
.battery-settings-button label {
|
||||
font-weight: 500;
|
||||
color: @glass-text;
|
||||
}
|
||||
|
||||
#energy-mode-button:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
#energy-mode-button {
|
||||
margin-top: -8px;
|
||||
margin-bottom: 4px;
|
||||
color: @glass-text-secondary;
|
||||
}
|
||||
.gamemode-button {
|
||||
font-size: apply(font-sm);
|
||||
margin-left: -6px;
|
||||
color: @glass-text-secondary;
|
||||
}
|
||||
|
||||
#game-mode-button {
|
||||
margin: 2px 0;
|
||||
}
|
||||
#game-mode-icon {
|
||||
margin-left: -6px;
|
||||
}
|
||||
|
||||
#game-mode-button-clickable {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: apply(radius-sm);
|
||||
padding: 6px 12px;
|
||||
margin: 1px 0;
|
||||
}
|
||||
|
||||
#game-mode-button-clickable:hover {
|
||||
background: @glass-hover;
|
||||
}
|
||||
|
||||
#game-mode-button-clickable:hover #game-mode-icon {
|
||||
background-color: @glass-active;
|
||||
}
|
||||
|
||||
#game-mode-icon {
|
||||
border-radius: apply(radius-full);
|
||||
padding: 4px;
|
||||
margin-right: 8px;
|
||||
background-color: @glass-hover;
|
||||
color: @glass-text-secondary;
|
||||
}
|
||||
|
||||
#energy-mode-icon.connected {
|
||||
#energy-mode-icon.connected,
|
||||
#charge-limit-icon.connected {
|
||||
background-color: @glass-active;
|
||||
color: @glass-text;
|
||||
}
|
||||
|
||||
#game-mode-icon.connected {
|
||||
background-color: @glass-active;
|
||||
color: @glass-text;
|
||||
}
|
||||
Reference in new issue
Block a user