battery_boost.app

Tkinter GUI for managing TLP battery charge profiles.

Provides a simple interface to toggle between normal and full-charge modes, refresh sudo authentication, and display battery statistics.

App

Bases: Tk

Tkinter GUI for toggling TLP battery charge profiles.

Supports switching between normal ('default') and full-charge ('recharge') modes and periodically refreshes display of battery statistics.

Source code in src/battery_boost/app.py
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
class App(tk.Tk):  # pylint: disable=too-many-instance-attributes
    """Tkinter GUI for toggling TLP battery charge profiles.

    Supports switching between normal ('default') and full-charge ('recharge') modes
    and periodically refreshes display of battery statistics.
    """
    def __init__(self,
                 theme: ThemeKeys = DEFAULT_THEME,
                 standard_font: tuple[str, int] = ('TkDefaultFont', 12),
                 small_font: tuple[str, int] = ('TkDefaultFont', 10),
                 scale_factor: float = 1.0,
                 ) -> None:
        """Initialize the Tkinter UI, state, and baseline TLP configuration.

        Args:
            theme: Theme colors to apply.
            standard_font: Font for main UI elements.
            small_font: Font for secondary UI elements.
            scale_factor: Scale factor for UI sizing.
        """
        super().__init__()
        self._refresh_job: str | None = None

        self.theme = theme
        self.standard_font = standard_font
        self.small_font = small_font
        self.scale_factor = scale_factor
        self.withdraw()
        self.protocol('WM_DELETE_WINDOW', self.quit_app)

        # Fail early if TLP not available.
        self._verify_tlp_ready()

        # Ensure AC power connected.
        while not self.is_on_ac_power():
            pass

        # Acquire root for commands.
        authenticate(self)

        self.ui_state: BatteryState = BatteryState.DEFAULT

        self._init_window()
        self._init_styles()
        self._init_widgets()
        self._layout_widgets()

        # Bind Ctrl+Q keyboard shortcut
        self.bind('<Control-KeyPress-q>', lambda e: self.quit_app())

        # Show main window.
        self.deiconify()

        # Ensure TLP is in a known (default enabled) state.
        initialise_tlp(self)
        self.apply_state()
        self.battery_stats = get_battery_stats()
        self.write_stats(self.battery_stats['info'])
        self.refresh_battery_stats()

    def _init_window(self) -> None:
        """Initialize the window."""
        self.title('Battery Boost')
        self.geometry(f'{int(400 * self.scale_factor)}x{int(450 * self.scale_factor)}')
        self.minsize(int(200 * self.scale_factor), int(150 * self.scale_factor))
        self.maxsize(int(600 * self.scale_factor), int(600 * self.scale_factor))

    def _init_styles(self) -> None:
        """ttk Style setup"""
        style = ttk.Style(self)
        style.theme_use('clam')  # 'clam' allows color customizations.

        # Button state styles.
        btn_common = {'relief': 'flat', 'font': self.standard_font}

        _opts = {**btn_common,
                 'background': self.theme['btn_normal'],
                 'foreground': self.theme['text']}
        style.configure('Default.TButton', **_opts)
        style.map('Default.TButton',
                  background=[('active', self.theme['btn_active_normal'])])

        _opts = {**btn_common,
                 'background': self.theme['btn_charge'],
                 'foreground': self.theme['text']}
        style.configure('Recharge.TButton', **_opts)
        style.map('Recharge.TButton',
                  background=[('active', self.theme['btn_active_charge'])])

        _opts = {**btn_common,
                 'foreground': self.theme['btn_discharge_text'],
                 'background': self.theme['btn_discharge']}
        style.configure('Discharge.TButton', **_opts)
        style.map('Discharge.TButton',
                  background=[('active', self.theme['btn_active_discharge'])])

        # Label styles - Top Label.
        top_label_common = {'foreground': self.theme['text'],
                            'font': self.standard_font}
        _opts = {**top_label_common, 'background': self.theme['default_bg']}
        style.configure('Default.TLabel', **_opts)

        _opts = {**top_label_common, 'background': self.theme['charge_bg']}
        style.configure('Recharge.TLabel', **_opts)

        # Instruction label.
        instruction_label_common = {'foreground': self.theme['text'],
                                    'font': self.small_font}
        _opts = {**instruction_label_common, 'background': self.theme['default_bg']}
        style.configure('DefaultInstruction.TLabel', **_opts)

        _opts = {**instruction_label_common, 'background': self.theme['charge_bg']}
        style.configure('RechargeInstruction.TLabel', **_opts)

        self.style = style

    def _init_widgets(self) -> None:
        self.top_label = ttk.Label(self, style='Default.TLabel')
        self.button = ttk.Button(self,
                                 style='Default.TButton',
                                 command=self.toggle_state)
        instructions = ("AC power must be connected.\n\n"
                        "You can close this api after\n"
                        "selecting the required profile.")
        self.instruction_label = ttk.Label(self,
                                           style='DefaultInstruction.TLabel',
                                           text=instructions,
                                           justify='center')
        self.text_box = tk.Text(self, height=2,
                                background=self.theme['default_bg'],
                                foreground=self.theme['text'],
                                font=self.small_font)
        # noinspection PyTypeChecker
        self.text_box.config(state=tk.DISABLED)

    def _layout_widgets(self) -> None:
        self.top_label.pack(pady=int(10 * self.scale_factor))
        self.button.pack()
        self.instruction_label.pack(pady=(int(5 * self.scale_factor),
                                          int(10 * self.scale_factor)))
        # noinspection PyTypeChecker
        self.text_box.pack(padx=int(10 * self.scale_factor),
                           pady=int(10 * self.scale_factor),
                           expand=True,
                           fill=tk.BOTH)

    def _verify_tlp_ready(self) -> None:
        """Verify that TLP is installed and active. Quit on fatal error."""
        if not command_on_path('tlp'):
            self.quit_on_error("TLP is not installed or not in PATH.",
                               "Fatal Error")
        if command_on_path('systemctl'):
            if not tlp_running():
                self.quit_on_error("TLP service is not active.",
                                   "Fatal Error")
        elif not tlp_active():  # Less reliable fallback.
            self.quit_on_error("TLP service is not active.",
                               "Fatal Error")

    def is_on_ac_power(self) -> bool:
        """Check if api is on AC power."""
        try:
            if on_ac_power():
                return True
        except RuntimeError as exc:
            self.quit_on_error(str(exc), "Unsupported system")
        retry = messagebox.askretrycancel(
            "AC Power Required",
            "Full charge mode requires AC power.\n"
            "Plug in your laptop and try again.",
            parent=self,
            icon='warning'
        )
        if not retry:  # Cancel button pressed.
            self.quit_app("AC power not connected.")
        return False  # Non-fatal failure

    def refresh_battery_stats(self) -> None:
        """Periodically refresh the battery statistics."""
        logger.debug("Refreshing battery statistics")
        new_battery_stats = get_battery_stats()
        current_battery_info = self.battery_stats['info']
        new_battery_info = new_battery_stats['info']
        # Handle updating button appearance on battery discharge.
        self.update_button(new_battery_stats['discharging'])
        # Update text widget info.
        if current_battery_info != new_battery_info:
            self.battery_stats = new_battery_stats
            self.write_stats(new_battery_info)

        # noinspection PyTypeChecker
        self._refresh_job = self.after(REFRESH_INTERVAL_MS, self.refresh_battery_stats)

    def update_button(self, is_discharging: bool) -> None:
        """Update button appearance to match battery status."""
        prev_btn_style = self.button.cget("style")
        if is_discharging:
            new_style = 'Discharge.TButton'
        elif self.ui_state is BatteryState.RECHARGE:
            new_style = 'Recharge.TButton'
        else:
            new_style = 'Default.TButton'
        if new_style != prev_btn_style:
            self.button.configure(style=new_style)

    def quit_on_error(self, error_message: str, title: str = "Error") -> NoReturn:
        """Display Error dialog and quit."""
        messagebox.showerror(title, error_message, parent=self)
        self.quit_app(f"Error: {error_message}")

    def quit_app(self, status: int | str = 0) -> NoReturn:
        """Terminate the application, cancel scheduled jobs, and exit.

        Args:
            status: Optional exit code or message.
        """
        if self._refresh_job:
            try:
                self.after_cancel(self._refresh_job)
            except (tk.TclError, RuntimeError) as exc:
                logger.critical("quit_app failed to cancel job %s", exc)
        self.destroy()
        sys.exit(status)

    def apply_state(self) -> None:
        """Update the UI to reflect the current battery profile state."""
        state = STATES[self.ui_state]

        # Window background (non-style background change)
        background = (self.theme['default_bg']
                      if self.ui_state is BatteryState.DEFAULT
                      else self.theme['charge_bg'])
        self.configure(background=background)

        # Update styles
        if self.ui_state is BatteryState.RECHARGE:
            top_label_style = 'Recharge.TLabel'
            instruction_label_style = 'RechargeInstruction.TLabel'
            button_style = 'Recharge.TButton'
        else:
            top_label_style = 'Default.TLabel'
            instruction_label_style = 'DefaultInstruction.TLabel'
            button_style = 'Default.TButton'

        self.top_label.configure(style=top_label_style, text=state['label_text'])
        self.instruction_label.configure(style=instruction_label_style)
        self.button.configure(style=button_style, text=state['button_text'])

        # Text box (tk widget does not have ttk style).
        self.text_box.config(background=background, foreground=self.theme['text'])

    def toggle_state(self) -> None:
        """Switch between default and full-charge profiles and update the UI."""
        if not tlp_toggle_state(self, self.ui_state):
            return
        # Flip UI state
        self.ui_state = (BatteryState.DEFAULT
                         if self.ui_state == BatteryState.RECHARGE
                         else BatteryState.RECHARGE)
        self.apply_state()

        # Update text widget.
        self.battery_stats = get_battery_stats()
        self.write_stats(self.battery_stats['info'])
        return

    def write_stats(self, stats: str) -> None:
        """Update the text area with the current TLP battery stats."""
        stats = STATES[self.ui_state]['action'] + stats
        logger.debug(stats)
        # noinspection PyTypeChecker
        self.text_box.config(state=tk.NORMAL)
        self.text_box.delete('1.0', tk.END)
        self.text_box.insert(tk.END, stats)
        # noinspection PyTypeChecker
        self.text_box.config(state=tk.DISABLED)

__init__

Initialize the Tkinter UI, state, and baseline TLP configuration.

Parameters:

Name Type Description Default
theme ThemeKeys

Theme colors to apply.

DEFAULT_THEME
standard_font tuple[str, int]

Font for main UI elements.

('TkDefaultFont', 12)
small_font tuple[str, int]

Font for secondary UI elements.

('TkDefaultFont', 10)
scale_factor float

Scale factor for UI sizing.

1.0
Source code in src/battery_boost/app.py
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
def __init__(self,
             theme: ThemeKeys = DEFAULT_THEME,
             standard_font: tuple[str, int] = ('TkDefaultFont', 12),
             small_font: tuple[str, int] = ('TkDefaultFont', 10),
             scale_factor: float = 1.0,
             ) -> None:
    """Initialize the Tkinter UI, state, and baseline TLP configuration.

    Args:
        theme: Theme colors to apply.
        standard_font: Font for main UI elements.
        small_font: Font for secondary UI elements.
        scale_factor: Scale factor for UI sizing.
    """
    super().__init__()
    self._refresh_job: str | None = None

    self.theme = theme
    self.standard_font = standard_font
    self.small_font = small_font
    self.scale_factor = scale_factor
    self.withdraw()
    self.protocol('WM_DELETE_WINDOW', self.quit_app)

    # Fail early if TLP not available.
    self._verify_tlp_ready()

    # Ensure AC power connected.
    while not self.is_on_ac_power():
        pass

    # Acquire root for commands.
    authenticate(self)

    self.ui_state: BatteryState = BatteryState.DEFAULT

    self._init_window()
    self._init_styles()
    self._init_widgets()
    self._layout_widgets()

    # Bind Ctrl+Q keyboard shortcut
    self.bind('<Control-KeyPress-q>', lambda e: self.quit_app())

    # Show main window.
    self.deiconify()

    # Ensure TLP is in a known (default enabled) state.
    initialise_tlp(self)
    self.apply_state()
    self.battery_stats = get_battery_stats()
    self.write_stats(self.battery_stats['info'])
    self.refresh_battery_stats()

apply_state

Update the UI to reflect the current battery profile state.

Source code in src/battery_boost/app.py
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
def apply_state(self) -> None:
    """Update the UI to reflect the current battery profile state."""
    state = STATES[self.ui_state]

    # Window background (non-style background change)
    background = (self.theme['default_bg']
                  if self.ui_state is BatteryState.DEFAULT
                  else self.theme['charge_bg'])
    self.configure(background=background)

    # Update styles
    if self.ui_state is BatteryState.RECHARGE:
        top_label_style = 'Recharge.TLabel'
        instruction_label_style = 'RechargeInstruction.TLabel'
        button_style = 'Recharge.TButton'
    else:
        top_label_style = 'Default.TLabel'
        instruction_label_style = 'DefaultInstruction.TLabel'
        button_style = 'Default.TButton'

    self.top_label.configure(style=top_label_style, text=state['label_text'])
    self.instruction_label.configure(style=instruction_label_style)
    self.button.configure(style=button_style, text=state['button_text'])

    # Text box (tk widget does not have ttk style).
    self.text_box.config(background=background, foreground=self.theme['text'])

is_on_ac_power

Check if api is on AC power.

Source code in src/battery_boost/app.py
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
def is_on_ac_power(self) -> bool:
    """Check if api is on AC power."""
    try:
        if on_ac_power():
            return True
    except RuntimeError as exc:
        self.quit_on_error(str(exc), "Unsupported system")
    retry = messagebox.askretrycancel(
        "AC Power Required",
        "Full charge mode requires AC power.\n"
        "Plug in your laptop and try again.",
        parent=self,
        icon='warning'
    )
    if not retry:  # Cancel button pressed.
        self.quit_app("AC power not connected.")
    return False  # Non-fatal failure

quit_app

Terminate the application, cancel scheduled jobs, and exit.

Parameters:

Name Type Description Default
status int | str

Optional exit code or message.

0
Source code in src/battery_boost/app.py
247
248
249
250
251
252
253
254
255
256
257
258
259
def quit_app(self, status: int | str = 0) -> NoReturn:
    """Terminate the application, cancel scheduled jobs, and exit.

    Args:
        status: Optional exit code or message.
    """
    if self._refresh_job:
        try:
            self.after_cancel(self._refresh_job)
        except (tk.TclError, RuntimeError) as exc:
            logger.critical("quit_app failed to cancel job %s", exc)
    self.destroy()
    sys.exit(status)

quit_on_error

Display Error dialog and quit.

Source code in src/battery_boost/app.py
242
243
244
245
def quit_on_error(self, error_message: str, title: str = "Error") -> NoReturn:
    """Display Error dialog and quit."""
    messagebox.showerror(title, error_message, parent=self)
    self.quit_app(f"Error: {error_message}")

refresh_battery_stats

Periodically refresh the battery statistics.

Source code in src/battery_boost/app.py
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
def refresh_battery_stats(self) -> None:
    """Periodically refresh the battery statistics."""
    logger.debug("Refreshing battery statistics")
    new_battery_stats = get_battery_stats()
    current_battery_info = self.battery_stats['info']
    new_battery_info = new_battery_stats['info']
    # Handle updating button appearance on battery discharge.
    self.update_button(new_battery_stats['discharging'])
    # Update text widget info.
    if current_battery_info != new_battery_info:
        self.battery_stats = new_battery_stats
        self.write_stats(new_battery_info)

    # noinspection PyTypeChecker
    self._refresh_job = self.after(REFRESH_INTERVAL_MS, self.refresh_battery_stats)

toggle_state

Switch between default and full-charge profiles and update the UI.

Source code in src/battery_boost/app.py
288
289
290
291
292
293
294
295
296
297
298
299
300
301
def toggle_state(self) -> None:
    """Switch between default and full-charge profiles and update the UI."""
    if not tlp_toggle_state(self, self.ui_state):
        return
    # Flip UI state
    self.ui_state = (BatteryState.DEFAULT
                     if self.ui_state == BatteryState.RECHARGE
                     else BatteryState.RECHARGE)
    self.apply_state()

    # Update text widget.
    self.battery_stats = get_battery_stats()
    self.write_stats(self.battery_stats['info'])
    return

update_button

Update button appearance to match battery status.

Source code in src/battery_boost/app.py
230
231
232
233
234
235
236
237
238
239
240
def update_button(self, is_discharging: bool) -> None:
    """Update button appearance to match battery status."""
    prev_btn_style = self.button.cget("style")
    if is_discharging:
        new_style = 'Discharge.TButton'
    elif self.ui_state is BatteryState.RECHARGE:
        new_style = 'Recharge.TButton'
    else:
        new_style = 'Default.TButton'
    if new_style != prev_btn_style:
        self.button.configure(style=new_style)

write_stats

Update the text area with the current TLP battery stats.

Source code in src/battery_boost/app.py
303
304
305
306
307
308
309
310
311
312
def write_stats(self, stats: str) -> None:
    """Update the text area with the current TLP battery stats."""
    stats = STATES[self.ui_state]['action'] + stats
    logger.debug(stats)
    # noinspection PyTypeChecker
    self.text_box.config(state=tk.NORMAL)
    self.text_box.delete('1.0', tk.END)
    self.text_box.insert(tk.END, stats)
    # noinspection PyTypeChecker
    self.text_box.config(state=tk.DISABLED)