__init__.py
Python script, Unicode text, UTF-8 text executable
1""" 2D-Bus laptop battery monitor applet for the Panorama panel. 3Copyright 2025, roundabout-host.com <vlad@roundabout-host.com> 4 5This program is free software: you can redistribute it and/or modify 6it under the terms of the GNU General Public Licence as published by 7the Free Software Foundation, either version 3 of the Licence, or 8(at your option) any later version. 9 10This program is distributed in the hope that it will be useful, 11but WITHOUT ANY WARRANTY; without even the implied warranty of 12MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13GNU General Public Licence for more details. 14 15You should have received a copy of the GNU General Public Licence 16along with this program. If not, see <https://www.gnu.org/licenses/>. 17""" 18 19from __future__ import annotations 20 21import threading 22 23import panorama_panel 24import locale 25from pathlib import Path 26from pydbus import SystemBus, SessionBus 27 28import gi 29gi.require_version("Gtk", "4.0") 30 31from gi.repository import Gtk, Gdk, GLib 32 33 34module_directory = Path(__file__).resolve().parent 35 36locale.bindtextdomain("panorama-battery-monitor", module_directory / "locale") 37_ = lambda x: locale.dgettext("panorama-battery-monitor", x) 38 39 40def get_battery_icon(fraction, charging): 41if fraction < 1/32: 42base_icon = "battery-empty" 43elif fraction < 1/16: 44base_icon = "battery-caution" 45elif fraction < 1/4: 46base_icon = "battery-low" 47elif fraction < 1/2: 48base_icon = "battery-medium" 49elif fraction < 7/8: 50base_icon = "battery-good" 51else: 52base_icon = "battery-full" 53 54if charging: 55return base_icon + "-charging" 56 57return base_icon 58 59 60class BatteryMonitor(panorama_panel.Applet): 61name = _("Laptop battery") 62description = _("Check laptop battery charge") 63 64def __init__(self, orientation=Gtk.Orientation.HORIZONTAL, config=None): 65super().__init__(orientation=orientation, config=config) 66if config is None: 67config = {} 68 69self.bus = SystemBus() 70self.session_bus = SessionBus() 71self.notification_service = None 72self.upower = self.bus.get("org.freedesktop.UPower") 73 74self.button = Gtk.MenuButton(has_frame=False) 75self.icon = Gtk.Image(pixel_size=config.get("icon_size", 24)) 76self.button.set_child(self.icon) 77self.append(self.button) 78 79self.low_threshold = config.get("low_threshold", 15/100) 80self.low_notification = None 81self.critical_threshold = config.get("critical_threshold", 5/100) 82self.critical_notification = None 83 84self.popover = Gtk.Popover() 85self.popover_content = Gtk.ListBox(selection_mode=Gtk.SelectionMode.NONE) 86self.popover_content.set_size_request(320, -1) 87self.popover.set_child(self.popover_content) 88 89self.button.set_popover(self.popover) 90 91self.update_batteries() 92GLib.timeout_add_seconds(config.get("refresh_interval", 10), self.update_batteries) 93 94def update_batteries(self): 95self.popover_content.remove_all() 96 97total_energy = max_energy = 0 98any_charging = False 99 100for device_path in self.upower.EnumerateDevices(): 101device = self.bus.get("org.freedesktop.UPower", device_path) 102 103# Filter only batteries 104if device.Type == 2: 105# This is in Wh but we don't care 106total_energy += device.Energy 107max_energy += device.EnergyFull 108 109if device.State == 1: 110any_charging = True 111 112box = Gtk.Box.new(Gtk.Orientation.VERTICAL, 8) 113charge_fraction = device.Energy / device.EnergyFull 114meter = Gtk.ProgressBar(show_text=True, fraction=charge_fraction) 115box.append(meter) 116charge_label = Gtk.Label.new(_("{current} Wh of {full} Wh").format(current=locale.format_string("%.1f", device.Energy), full=locale.format_string("%.1f", device.EnergyFull))) 117charge_label.set_halign(Gtk.Align.CENTER) 118charge_label.set_xalign(0.5) 119box.append(charge_label) 120name_label = Gtk.Label.new(device.Vendor + " " + device.Model) 121name_label.set_halign(Gtk.Align.CENTER) 122name_label.set_xalign(0.5) 123box.prepend(name_label) 124box.set_margin_start(8) 125box.set_margin_end(8) 126box.set_margin_top(8) 127box.set_margin_bottom(8) 128row = Gtk.ListBoxRow(child=box, activatable=False) 129self.popover_content.append(row) 130 131if not max_energy: 132return False 133 134self.button.set_tooltip_text(f"{locale.format_string("%d", total_energy / max_energy * 1000)}‰") 135 136self.icon.set_from_icon_name(get_battery_icon(total_energy / max_energy, any_charging)) 137 138GLib.idle_add(self.do_notifications, total_energy, max_energy, any_charging) 139 140return True 141 142def do_notifications(self, total_energy, max_energy, any_charging): 143if self.session_bus.get("org.freedesktop.Notifications"): 144self.notification_service = self.session_bus.get("org.freedesktop.Notifications") 145else: 146return 147 148# This uses threading to make sure it doesn't hang while waiting to deliver the 149# notification 150if not any_charging and total_energy / max_energy < self.low_threshold: 151if not self.low_notification: 152def send_notification(): 153self.low_notification = self.notification_service.Notify( 154_("Battery"), 155self.low_notification or self.critical_notification or 0, 156"battery-low", 157_("The energy in the battery is low."), 158"", 159[], 160{}, 161-1 162) 163threading.Thread(target=send_notification, daemon=True).start() 164 165elif self.low_notification: 166threading.Thread( 167target=lambda: self.notification_service.CloseNotification(self.low_notification), 168daemon=True 169).start() 170 171self.low_notification = None 172 173if not any_charging and total_energy / max_energy < self.critical_threshold: 174if not self.critical_notification: 175def send_notification(): 176self.critical_notification = self.notification_service.Notify( 177_("Battery"), 178self.low_notification or self.critical_notification or 0, 179"battery-empty", 180_("The energy in the battery is very low; the device will shut down soon."), 181"", 182[], 183{}, 184-1 185) 186threading.Thread(target=send_notification, daemon=True).start() 187elif self.critical_notification: 188threading.Thread( 189target=lambda: self.notification_service.CloseNotification(self.critical_notification), 190daemon=True 191).start() 192 193self.critical_notification = None 194 195def get_config(self): 196return { 197"icon_size": self.icon.get_pixel_size(), 198"low_threshold": self.low_threshold, 199"critical_threshold": self.critical_threshold, 200} 201 202def shutdown(self, app): 203return 204