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