feat(src/services): rename BatteryService to Battery and add availability properties

Rename service to Battery, expose availability, charging, discharging properties and simplify interface for clarity
This commit is contained in:
sankalp committed 2026-06-17 01:45:24 +05:45
1 parent 400b8db954
commit b8d3e74afd
4 files changed
+509 -228

No files matched your search

+1 -1
View File
@@ -1,5 +1,5 @@
from fabric.audio import Audio # noqa: F401
from .battery import BatteryService # noqa: F401
from .battery import Battery # noqa: F401
from .brightness import Brightness # noqa: F401
from .capslock import CapsLock # noqa: F401
from .keyboard_layout import KeyboardLayout # noqa: F401
+179 -152
View File
@@ -1,11 +1,19 @@
from typing import Literal, Optional
import gi
from fabric.core.service import Property, Service, Signal
from fabric.utils import Gio, GLib, logger
from gi.repository import Gio, GLib
from loguru import logger
from utils.dbus_helper import GioDBusHelper
gi.require_version("Gtk", "3.0")
DeviceState = {
BATTERY_BUS_NAME = "org.freedesktop.UPower"
BATTERY_BUS_PATH = "/org/freedesktop/UPower/devices/DisplayDevice"
BATTERY_INTERFACE = "org.freedesktop.UPower.Device"
POWER_PROFILE_BUS_NAME = "net.hadess.PowerProfiles"
POWER_PROFILE_BUS_PATH = "/net/hadess/PowerProfiles"
POWER_PROFILE_INTERFACE = "net.hadess.PowerProfiles"
DEVICE_STATE = {
0: "UNKNOWN",
1: "CHARGING",
2: "DISCHARGING",
@@ -15,188 +23,207 @@ DeviceState = {
6: "PENDING_DISCHARGE",
}
PowerProfile = {
"power-saver": "Power Saver",
"balanced": "Balanced",
"performance": "Performance",
}
class BatteryService(Service):
"""Service to interact with UPower and Power Profiles via GIO D-Bus"""
class Battery(Service):
"""A service for interacting with the battery's DBus and Power Profiles"""
@Signal
def changed(self) -> None:
"""Signal emitted when battery changes."""
def changed(self) -> None: ...
@Signal
def power_profile_changed(self) -> None:
"""Signal emitted when power profile changes."""
def power_profile_changed(self) -> None: ...
_instance = None
@Property(bool, "readable", default_value=False)
def available(self) -> bool:
return self.do_get_cached_property("IsPresent") or False
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
@Property(str, "readable", default_value="")
def vendor(self) -> str:
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
@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)
@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)
@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)
@Property(str, "readable", default_value="")
def icon_name(self) -> str:
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
@Property(int, "readable", default_value=0)
def time_to_full(self) -> int:
return self.do_get_cached_property("TimeToFull") or 0
@Property(float, "readable", default_value=0.0)
def energy(self) -> float:
return self.do_get_cached_property("Energy") or 0.0
@Property(float, "readable", default_value=0.0)
def energy_full(self) -> float:
return self.do_get_cached_property("EnergyFull") or 0.0
@Property(float, "readable", default_value=0.0)
def energy_rate(self) -> float:
return self.do_get_cached_property("EnergyRate") or 0.0
@Property(float, "readable", default_value=0.0)
def temperature(self) -> float:
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
self.do_register()
# UPower D-Bus configuration
self.bus_name = "org.freedesktop.UPower"
self.object_path = "/org/freedesktop/UPower/devices/DisplayDevice"
self.interface_name = "org.freedesktop.UPower.Device"
self.dbus_helper = GioDBusHelper(
bus_type=Gio.BusType.SYSTEM,
bus_name=self.bus_name,
object_path=self.object_path,
interface_name=self.interface_name,
def do_register(self) -> None:
self._bus = Gio.bus_get_sync(Gio.BusType.SYSTEM)
self._proxy = Gio.DBusProxy.new_sync(
self._bus,
Gio.DBusProxyFlags.NONE,
None,
BATTERY_BUS_NAME,
BATTERY_BUS_PATH,
BATTERY_INTERFACE,
None,
)
self.proxy = self.dbus_helper.proxy
logger.info("[Battery] Proxy initialized")
# Listen for PropertiesChanged signals from UPower
self.dbus_helper.listen_signal(
member="PropertiesChanged",
callback=self.handle_property_change,
self._bus.signal_subscribe(
BATTERY_BUS_NAME,
"org.freedesktop.DBus.Properties",
"PropertiesChanged",
BATTERY_BUS_PATH,
None,
Gio.DBusSignalFlags.NONE,
self.do_handle_property_change,
)
# Power Profiles D-Bus configuration
self.power_profile_bus_name = "net.hadess.PowerProfiles"
self.power_profile_object_path = "/net/hadess/PowerProfiles"
self.power_profile_interface_name = "net.hadess.PowerProfiles"
try:
self.power_profile_helper = GioDBusHelper(
bus_type=Gio.BusType.SYSTEM,
bus_name=self.power_profile_bus_name,
object_path=self.power_profile_object_path,
interface_name=self.power_profile_interface_name,
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,
None,
)
self.power_profile_proxy = self.power_profile_helper.proxy
# Listen for PropertiesChanged signals from Power Profiles
self.power_profile_helper.listen_signal(
member="PropertiesChanged",
callback=self.handle_power_profile_change,
self._bus.signal_subscribe(
POWER_PROFILE_BUS_NAME,
"org.freedesktop.DBus.Properties",
"PropertiesChanged",
POWER_PROFILE_BUS_PATH,
None,
Gio.DBusSignalFlags.NONE,
self.do_handle_power_profile_change,
)
self._power_profiles_available = True
logger.info("[Battery] Power Profiles Proxy initialized")
except Exception as e:
logger.warning(f"[Battery] Power Profiles daemon not available: {e}")
self.power_profile_helper = None
self.power_profile_proxy = None
self._power_profiles_available = False
def get_property(
def do_handle_property_change(self, *_):
self.emit("changed")
def do_handle_power_profile_change(self, *_):
self.emit("power_profile_changed")
def do_call_proxy_method(
self,
property: Literal[
"Percentage",
"Temperature",
"TimeToEmpty",
"TimeToFull",
"IconName",
"State",
"Capacity",
"IsPresent",
"Vendor",
],
bus_name,
object_path,
interface_name,
method_name,
parameters=None,
timeout=-1,
):
try:
result = self.proxy.get_cached_property(property)
return result.unpack() if result is not None else None
except Exception as e:
logger.exception(f"[Battery] Error retrieving '{property}': {e}")
return None
if parameters is None:
parameters = GLib.Variant("()", ())
result = self._bus.call_sync(
bus_name,
object_path,
interface_name,
method_name,
parameters,
None,
Gio.DBusCallFlags.NONE,
timeout,
None,
)
return result.unpack()
def get_power_profile(self) -> Optional[str]:
def do_get_cached_property(self, property_name):
result = self._proxy.get_cached_property(property_name)
return result.unpack() if result is not None else None
def get_power_profile(self) -> str | None:
"""Get the current active power profile."""
if not self._power_profiles_available:
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
try:
result = self.power_profile_proxy.get_cached_property("ActiveProfile")
return result.unpack() if result is not None else None
except Exception as e:
logger.exception(f"[Battery] Error retrieving active power profile: {e}")
return None
def set_power_profile(
self, profile: Literal["power-saver", "balanced", "performance"]
) -> bool:
def set_power_profile(self, profile: str) -> bool:
"""Set the active power profile."""
if not self._power_profiles_available:
logger.warning("[Battery] Power Profiles daemon not available")
if not self._power_profile_proxy:
return False
if profile not in PowerProfile:
return False
try:
self.power_profile_helper.set_property(
interface_name=self.power_profile_interface_name,
property_name="ActiveProfile",
value_variant=GLib.Variant("s", profile),
parameters = GLib.Variant(
"(ssv)",
(POWER_PROFILE_INTERFACE, "ActiveProfile", GLib.Variant("s", profile)),
)
self._bus.call_sync(
POWER_PROFILE_BUS_NAME,
POWER_PROFILE_BUS_PATH,
"org.freedesktop.DBus.Properties",
"Set",
parameters,
None,
Gio.DBusCallFlags.NONE,
-1,
None,
)
return True
except Exception as e:
logger.exception(
f"[Battery] Error setting power profile to '{profile}': {e}"
)
logger.error(f"[Battery] Failed to set power profile: {e}")
return False
def get_available_power_profiles(self) -> Optional[list]:
def get_available_power_profiles(self) -> list | None:
"""Get list of available power profiles."""
if not self._power_profiles_available:
if not self._power_profile_proxy:
return None
try:
result = self.power_profile_proxy.get_cached_property("Profiles")
if result is not None:
profiles_data = result.unpack()
# Extract profile names from the array of dictionaries
profiles = []
for profile_dict in profiles_data:
if "Profile" in profile_dict:
profiles.append(profile_dict["Profile"])
return profiles
return None
except Exception as e:
logger.exception(
f"[Battery] Error retrieving available power profiles: {e}"
)
return None
def is_power_profiles_available(self) -> bool:
"""Check if power profiles daemon is available."""
return self._power_profiles_available
def get_power_profile_display_name(self, profile: str) -> str:
"""Get display name for a power profile."""
return PowerProfile.get(profile, profile.title())
@Property(int, "readable", default_value=0)
def percentage(self) -> int:
return self.get_property("Percentage") or 0
@Property(int, "readable", default_value=0)
def state(self) -> int:
return self.get_property("State") or 0
@Property(bool, "readable", default_value=False)
def is_present(self) -> bool:
return self.get_property("IsPresent") or False
def handle_property_change(self, *args):
# Notify about property changes for OSD and other listeners
self.notify("percentage")
self.notify("state")
self.notify("is-present")
self.emit("changed")
def handle_power_profile_change(self, *_):
"""Handle power profile property changes."""
self.emit("power_profile_changed")
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 None
+312 -65
View File
@@ -1,91 +1,338 @@
import subprocess
import time
import shutil
from fabric.core.service import Property, Service, Signal
from fabric.utils import GLib, exec_shell_command_async, logger, monitor_file, os
def exec_brightnessctl_async(args: str):
exec_shell_command_async(f"brightnessctl {args}", lambda _: None)
# Discover screen backlight device
try:
screen_device = os.listdir("/sys/class/backlight")
screen_device = screen_device[0] if screen_device else ""
except FileNotFoundError:
logger.error("No backlight devices found, brightness control disabled")
screen_device = ""
from fabric.utils import GLib, exec_shell_command_async, logger, os, re
class Brightness(Service):
"""Service to manage screen brightness levels."""
"""Service for controlling screen brightness using ddcutil or brightnessctl backends.
instance = None
The service works with RAW values (0 to max_screen) for both backends:
- brightnessctl: raw values are device-specific (e.g., 0-96000)
- ddcutil: raw values are percentages (0-100)
@staticmethod
def get_initial():
if Brightness.instance is None:
Brightness.instance = Brightness()
The 'screen' signal emits percentage values (0-100) for UI display.
"""
return Brightness.instance
_instance = None
DDCUTIL_PARAMS = "--disable-dynamic-sleep --sleep-multiplier=0.05"
MIN_CHANGE_THRESHOLD = 2
CACHE_INTERVAL = 3
POLL_INTERVAL = 500
def __new__(cls, *args, **kwargs):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
@Signal
def screen(self, value: int) -> None:
"""Signal emitted when screen brightness changes."""
# Implement as needed for your application
"""Signal emitted when screen brightness changes (value: percentage from 0 to 100)."""
def __init__(self, **kwargs):
super().__init__(**kwargs)
# Path for screen backlight control
self.screen_backlight_path = f"/sys/class/backlight/{screen_device}"
# Initialize maximum brightness level
self.max_screen = self.do_read_max_brightness(self.screen_backlight_path)
if screen_device == "":
def __init__(self, backend=None, **kwargs):
"""Initialize service with automatic backend detection."""
if getattr(self, "_initialized", False):
return
self._initialized = True
super().__init__(**kwargs)
self._pending_raw = None
self._timer_id = None
self._poll_timer_id = None
self._lock = GLib.Mutex()
self._last_percent = -1
self._last_raw = -1
self._last_update_time = 0
self._last_file_mtime = 0
# Monitor screen brightness file
self.screen_monitor = monitor_file(f"{self.screen_backlight_path}/brightness")
self.backend = self._detect_backend(backend)
self.screen_monitor.connect(
"changed",
lambda _, file, *args: self.emit(
"screen",
round(int(file.load_bytes()[0].get_data())),
),
self.max_screen = self._read_max_brightness() or 100
if self.backend:
if self.backend == "ddcutil":
GLib.timeout_add(100, lambda: self._update_brightness_cache())
else:
self._setup_polling()
def _setup_polling(self):
"""Setup periodic polling of brightness file."""
try:
file_path = f"/sys/class/backlight/{self._get_screen_device()}/brightness"
if os.path.exists(file_path):
with open(file_path) as f:
self._last_raw = int(f.readline().strip())
self._last_percent = (
int((self._last_raw / self.max_screen) * 100)
if self.max_screen > 0
else 0
)
self._last_file_mtime = os.path.getmtime(file_path)
self._poll_timer_id = GLib.timeout_add(
self.POLL_INTERVAL, self._check_brightness_file
)
except Exception as e:
logger.error(f"Error setting up brightness polling: {e}")
def _check_brightness_file(self):
"""Periodically check brightness file for changes."""
try:
file_path = f"/sys/class/backlight/{self._get_screen_device()}/brightness"
if os.path.exists(file_path):
current_mtime = os.path.getmtime(file_path)
if current_mtime > self._last_file_mtime:
self._last_file_mtime = current_mtime
with open(file_path) as f:
raw = int(f.readline().strip())
if raw != self._last_raw:
self._last_raw = raw
percent = (
int((raw / self.max_screen) * 100)
if self.max_screen > 0
else 0
)
if (
abs(percent - self._last_percent)
>= self.MIN_CHANGE_THRESHOLD
):
self._last_percent = percent
self.emit("screen", percent)
return True
except Exception as e:
logger.error(f"Error checking brightness file: {e}")
return True
def _detect_backend(self, backend):
"""Detect appropriate backend for brightness control."""
if backend:
logger.info(f"Using forced backend: {backend}")
return backend
if shutil.which("brightnessctl"):
device = self._get_screen_device()
if device:
logger.info(f"Using brightnessctl backend with device: {device}")
return "brightnessctl"
else:
logger.debug(
"brightnessctl is available but no backlight devices found in /sys/class/backlight/"
)
if shutil.which("ddcutil"):
bus = self._detect_ddcutil_bus()
if bus != -1:
self.ddcutil_bus = bus
logger.info(f"Using ddcutil backend with I2C bus: {bus}")
return "ddcutil"
else:
logger.debug(
"ddcutil is available but no DDC/CI capable monitors detected"
)
logger.warning(
"No available backend for brightness control - no backlight devices or DDC/CI monitors found"
)
return None
# Log the initialization of the service
logger.info(f"Brightness service initialized for device: {screen_device}")
def _get_screen_device(self):
"""Return first backlight device from sysfs."""
try:
return os.listdir("/sys/class/backlight")[0]
except Exception:
return ""
def do_read_max_brightness(self, path: str) -> int:
# Reads the maximum brightness value from the specified path.
max_brightness_path = os.path.join(path, "max_brightness")
if os.path.exists(max_brightness_path):
with open(max_brightness_path) as f:
return int(f.readline())
return -1 # Return -1 if file doesn't exist, indicating an error.
def _detect_ddcutil_bus(self):
"""Detect I2C bus number for ddcutil."""
try:
process = subprocess.run(
["ddcutil", "detect"], text=True, capture_output=True, timeout=2
)
if process.returncode == 0:
match = re.search(r"I2C bus:\s*/dev/i2c-(\d+)", process.stdout)
return int(match.group(1)) if match else -1
return -1
except Exception:
return -1
def _read_max_brightness(self):
"""Read maximum brightness value"""
if self.backend:
if self.backend == "ddcutil":
try:
process = subprocess.run(
[
"ddcutil",
"--bus",
str(self.ddcutil_bus),
*self.DDCUTIL_PARAMS.split(),
"getvcp",
"10",
],
text=True,
capture_output=True,
timeout=2,
)
if process.returncode == 0:
match = re.search(
r"current value\s*=\s*(\d+)\s*,\s*max value\s*=\s*(\d+)",
process.stdout,
)
if match:
return int(match.group(2))
except Exception as e:
logger.error(f"Error executing ddcutil: {e}")
else:
try:
with open(
f"/sys/class/backlight/{self._get_screen_device()}/max_brightness"
) as f:
return int(f.readline().strip())
except Exception:
return None
def _update_brightness_cache(self):
"""Update brightness cache with current value."""
if self.backend == "ddcutil":
self.screen_brightness
return False
@Property(int, "read-write")
def screen_brightness(self) -> int:
# Property to get or set the screen brightness.
brightness_path = os.path.join(self.screen_backlight_path, "brightness")
if os.path.exists(brightness_path):
with open(brightness_path) as f:
return int(f.readline())
logger.warning(f"Brightness file does not exist: {brightness_path}")
return -1 # Return -1 if file doesn't exist, indicating error.
def screen_brightness(self):
"""Getter returns current brightness in RAW value (0 to max_screen)."""
if not self.backend:
return -1
if self.backend == "brightnessctl":
if self._last_raw != -1:
return self._last_raw
try:
with open(
f"/sys/class/backlight/{self._get_screen_device()}/brightness"
) as f:
raw = int(f.readline().strip())
self._last_raw = raw
return raw
except Exception as e:
logger.error(f"Error reading brightness file: {e}")
return -1
elif self.backend == "ddcutil":
if (
time.time() - self._last_update_time < self.CACHE_INTERVAL
and self._last_raw != -1
):
return self._last_raw
try:
process = subprocess.run(
[
"ddcutil",
"--bus",
str(self.ddcutil_bus),
*self.DDCUTIL_PARAMS.split(),
"getvcp",
"10",
],
text=True,
capture_output=True,
timeout=2,
)
if process.returncode == 0:
match = re.search(
r"current value\s*=\s*(\d+)\s*,\s*max value\s*=\s*(\d+)",
process.stdout,
)
if match:
current = int(match.group(1))
self._last_raw = current
self._last_update_time = time.time()
return current
except Exception as e:
logger.error(f"Error executing ddcutil: {e}")
return self._last_raw if self._last_raw != -1 else -1
@screen_brightness.setter
def screen_brightness(self, value: int):
# Setter for screen brightness property.
if not (0 <= value <= self.max_screen):
"""Setter accepts brightness value in RAW (0 to max_screen)."""
self._lock.lock()
try:
value = max(0, min(value, self.max_screen))
current_percent = (
int((self._last_raw / self.max_screen) * 100)
if self._last_raw != -1 and self.max_screen > 0
else -1
)
new_percent = (
int((value / self.max_screen) * 100) if self.max_screen > 0 else 0
)
if (
abs(new_percent - current_percent) < self.MIN_CHANGE_THRESHOLD
and self._last_raw != -1
):
return
self._pending_raw = value
if self._timer_id:
GLib.source_remove(self._timer_id)
self._timer_id = GLib.timeout_add(50, self._apply_brightness)
finally:
self._lock.unlock()
def _apply_brightness(self):
"""Apply pending brightness change with optimized debouncing."""
self._lock.lock()
try:
exec_brightnessctl_async(f"--device '{screen_device}' set {value}")
self.emit("screen", int((value / self.max_screen) * 100))
except GLib.Error as e:
logger.error(f"Error setting screen brightness: {e.message}")
if self._pending_raw is None:
self._timer_id = None
return False
raw = self._pending_raw
self._pending_raw = None
self._timer_id = None
finally:
self._lock.unlock()
try:
self._last_raw = raw
percent = int((raw / self.max_screen) * 100) if self.max_screen > 0 else 0
if self.backend == "brightnessctl":
self.emit("screen", percent)
exec_shell_command_async(
f"brightnessctl --device '{self._get_screen_device()}' set {raw}"
)
elif self.backend == "ddcutil":
self._last_update_time = time.time()
self.emit("screen", percent)
exec_shell_command_async(
f"ddcutil --bus {self.ddcutil_bus} {self.DDCUTIL_PARAMS} --terse setvcp 10 {raw}",
lambda exit_code, stdout, stderr: (
logger.error(f"ddcutil error (code {exit_code}): {stderr}")
if exit_code != 0
else None
),
)
except Exception as e:
logger.exception(f"Unexpected error setting screen brightness: {e}")
logger.error(f"Error setting brightness: {e}")
return False
def cleanup(self):
"""Clean up resources when service is stopped."""
if self._timer_id:
GLib.source_remove(self._timer_id)
self._timer_id = None
if self._poll_timer_id:
GLib.source_remove(self._poll_timer_id)
self._poll_timer_id = None
+17 -10
View File
@@ -271,17 +271,24 @@ class CachedNotifications(Notifications):
max_cache_id = 0
for notification in data:
cached_notification = CachedNotification.create_from_dict(notification)
cache_id = cached_notification.cache_id
max_cache_id = max(max_cache_id, cache_id)
try:
cached_notification = CachedNotification.create_from_dict(notification)
cache_id = cached_notification.cache_id
max_cache_id = max(max_cache_id, cache_id)
handler_id = cached_notification.connect(
"removed-from-cache",
lambda *args: self.remove_cached_notification(notification_id=cache_id),
)
self._signal_handlers[cache_id] = handler_id
self._cached_notifications[cache_id] = cached_notification
self._count += 1
handler_id = cached_notification.connect(
"removed-from-cache",
lambda *args: self.remove_cached_notification(
notification_id=cache_id
),
)
self._signal_handlers[cache_id] = handler_id
self._cached_notifications[cache_id] = cached_notification
self._count += 1
except Exception as e:
logger.warning(
f"Failed to load a cached notification (might be from an old schema): {e}"
)
# Set next cache ID to be higher than any existing ID
self._next_cache_id = max_cache_id + 1