Skip to content

Commit

Permalink
Add popups, system tray and improve codebase (#1)
Browse files Browse the repository at this point in the history
* Add GTK3 dialogs

* Update dependencies

* Added a simple system tray

* Add enable/disable feature

* Add system tray, and some notifications as popups

* Improve config documentation

* Improve code for mantainability and added dialogs for battery alerts
- Created Settings class that is fed from load_settings to improve readability and typing for settings
- Separated tresholds checks into a separate function for readability
- Improve toml settings and comments
- Remind now uses a timestamp to manage remind time instead of a time.sleep. More of that on the source

* Add padding and increase font size to popups

* Add title param to popups and added battery action popup

* Dont report when battery is plugged
  • Loading branch information
fer-hnndz authored Oct 2, 2024
1 parent 7384fcb commit 677fbfc
Show file tree
Hide file tree
Showing 14 changed files with 531 additions and 163 deletions.
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,9 @@

A simple tool to monitor and notify about battery status. Built with Python.

# Dependencies (Arch only)
- libnotify
- python-gobject
- python-pillow
- python-toml
- python-psutil
3 changes: 1 addition & 2 deletions battery_advisor/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,2 @@
from .utils import get_battery_status, notify, notify_with_actions, execute_action
from .settings_loader import load_settings
from .entry import main
from .settings_loader import load_settings
4 changes: 0 additions & 4 deletions battery_advisor/__main__.py

This file was deleted.

134 changes: 134 additions & 0 deletions battery_advisor/battery_advisor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import threading
import time

from pystray import Menu, MenuItem
from .settings_loader import Settings
from .tray import get_icon
from .utils import execute_action, get_battery_status
from .notifications import notify, alert_with_options, alert
from .types import BatteryReport
from typing import Optional
from datetime import datetime

settings = Settings.load()


class BatteryAdvisor:
"""Base Program that manages SysTray Icon and the battery checker service."""

def __init__(self):
self.running = True

def get_battery_reports(self) -> Optional[BatteryReport]:
"""Returns thet status that needs to be reported to the user"""

batt_percent, plugged = get_battery_status()

if plugged:
return None

if batt_percent <= settings.battery_action_treshold:
return BatteryReport.ACTION

if batt_percent <= settings.critical_battery_treshold:
return BatteryReport.CRITICAL

if batt_percent <= 100:
return BatteryReport.LOW

return None

def _battery_checker(self):
"""
Service that checks the battery status and notifies the user.
This one runs in a separate thread because the SysTray icon must run on the main thread.
"""

print("Starting battery checker...")

# Always get initial status to avoid false notifications
_, was_plugged = get_battery_status()
remind_timestamp = datetime.now()

while True:
if not self.running:
time.sleep(3)
continue

print("Checking battery status...")
_, plugged = get_battery_status()

# Battery Plugged in notifications
if plugged != was_plugged:
was_plugged = plugged
if plugged and settings.notify_plugged:
notify("Battery Plugged In", "Battery is now charging")
elif not plugged and settings.notify_unplugged:
notify("Battery Unplugged", "Battery is now discharging.")

report = self.get_battery_reports()
print("Battery Report:", report)

if report is None:
time.sleep(settings.check_interval)
continue

if report == BatteryReport.ACTION:
battery_action = settings.battery_action
alert(message=f"Your battery will {battery_action.capitalize()} soon.")
time.sleep(3)
execute_action(battery_action, settings.actions)

if report == BatteryReport.CRITICAL:
alert_with_options(
message="Your battery is critically low. Please plug in your charger.",
options=settings.critical_battery_options,
title="Battery Critically Low",
)

# Don't sleep for the amount of time to remind the user again.
# Instead, check if the time has passed using timestamps.

# This is to avoid excluding critical notifications or if a long remind time is set
# or plugged/unplugged notifications because of sleep.
# More like a QoL feature. ;)
if (
report == BatteryReport.LOW
and remind_timestamp.timestamp() <= datetime.now().timestamp()
):
r = alert_with_options(
"Battery is low. Please plug in your charger.",
settings.low_battery_options,
title="Battery Low",
)

selected_action = settings.low_battery_options[r]

if selected_action == "remind":
remind_timestamp = datetime.fromtimestamp(
remind_timestamp.timestamp() + settings.remind_time
)

else:
execute_action(selected_action, settings.actions)

time.sleep(settings.check_interval)

def _on_enabled_click(self, icon, item):
self.running = not self.running
print("Battery Advisor is now", "enabled" if self.running else "disabled")

def start(self):
batt_thread = threading.Thread(target=self._battery_checker, daemon=True)
batt_thread.start()

menu = Menu(
MenuItem(
text="Enabled",
checked=lambda item: self.running,
action=self._on_enabled_click,
)
)
get_icon(menu).run()
print("Exiting...")
return 0
84 changes: 2 additions & 82 deletions battery_advisor/entry.py
Original file line number Diff line number Diff line change
@@ -1,85 +1,5 @@
import time
from .utils import get_battery_status, notify, notify_with_actions, execute_action
from .settings_loader import load_settings

settings = load_settings()

LOW_BATTERY_TRESHOLD = settings["tresholds"]["low_battery_treshold"]
CRITICAL_BATTERY_TRESHOLD = settings["tresholds"]["critical_battery_treshold"]
BATTERY_ACTION_TRESHOLD = settings["tresholds"]["battery_action_treshold"]
CHECK_INTERVAL = settings["advisor"]["check_interval"]

# Configs
NOTIFY_PLUGGED = settings["advisor"]["notify_plugged"]
NOTIFY_UNPLUGGED = settings["advisor"]["notify_unplugged"]

# Actions
LOW_BATTERY_OPTIONS = settings["advisor"]["low_battery_options"]
CRITICAL_BATTERY_OPTIONS = settings["advisor"]["critical_battery_options"]
from .battery_advisor import BatteryAdvisor


def main():
_, was_plugged = get_battery_status()

while True:
print("Checking battery status...")
remind_time = 0
batt_percent, plugged = get_battery_status()

# Battery Plugged in notifications
if plugged != was_plugged:
was_plugged = plugged
if plugged and NOTIFY_PLUGGED:
notify("Battery Plugged In", "Battery is now charging")
elif not plugged and NOTIFY_UNPLUGGED:
notify("Battery Unplugged", "Battery is now discharging.")

if plugged:
print("Battery is charging. Skipping checks.")
print("Sleeping...")
time.sleep(CHECK_INTERVAL)
continue

# Battery Low notifications
if batt_percent <= BATTERY_ACTION_TRESHOLD:
notify(
"Battery Action",
f"Your battery is at {batt_percent}%. Your system will {settings['advisor']['battery_action'].capitalize()} in a few.",
)
print("Reporting battery action.")
print("Sleeping...")
time.sleep(5)
configured_action = settings["advisor"]["battery_action"]
action_cmd = settings["actions"][configured_action]
print("Executing Battery Action. Goodbye...")
execute_action(action_cmd)

if batt_percent <= CRITICAL_BATTERY_TRESHOLD:
print("Reporting critical battery.")
remind_time = notify_with_actions(
title="CRITICAL BATTERY",
message=f"Your battery is at {int(batt_percent)}%. Consider plugging your device.",
options=CRITICAL_BATTERY_OPTIONS,
actions=settings["actions"],
remind_time=round(
settings["advisor"]["remind_time"] / 2
), # Remind in half the remind time
)

elif batt_percent <= LOW_BATTERY_TRESHOLD:
print("Reporting low battery.")
remind_time = notify_with_actions(
title="Low Battery",
message=f"Consider plugging your device.",
options=LOW_BATTERY_OPTIONS,
actions=settings["actions"],
remind_time=settings["advisor"]["remind_time"],
)

if remind_time > 0:
print("A function returned a remind time!")
time.sleep(remind_time)
continue

print("Sleeping...")
time.sleep(CHECK_INTERVAL)
BatteryAdvisor().start()
52 changes: 52 additions & 0 deletions battery_advisor/gui/alerts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import gi

gi.require_version("Gtk", "3.0")
gi.require_version("Pango", "1.0")
from gi.repository import Gtk, Pango

from ..utils import execute_action


class MessageAlert(Gtk.Dialog):
def __init__(
self,
title: str,
message: str,
):
super().__init__(title, transient_for=None)

content = self.get_content_area()
_title_label = Gtk.Label(margin_bottom=13, margin_top=10)
_title_label.set_markup(f"<b>{title}</b>")
_msg_label = Gtk.Label(
message, margin_bottom=12, margin_start=10, margin_end=10
)

# Increase text size
_title_label.override_font(Pango.FontDescription("Ubuntu 12"))
_msg_label.override_font(Pango.FontDescription("Ubuntu 10"))

content.add(_title_label)
content.add(_msg_label)

if self.__class__ == MessageAlert:
self.add_button("Close", Gtk.ResponseType.CLOSE)

self.show_all()

def run(self):
response = super().run()
if response == -4 or response == -7 or response == -1:
response = 0

return response


class AlertWithButtons(MessageAlert):
def __init__(self, title: str, message, actions: list[str]):
super().__init__(message=message, title=title)

for i, action in enumerate(actions):
self.add_button(action.capitalize(), i)

self.show_all()
35 changes: 35 additions & 0 deletions battery_advisor/notifications.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import subprocess

from .utils import _get_path_icon, execute_action
from .gui.alerts import AlertWithButtons, MessageAlert

EXPIRE_TIME = 160000


def notify(title: str, message: str):
"""Sends a notification to the user"""
subprocess.run(["notify-send", title, message, f"--icon={_get_path_icon()}"])


def alert(message: str, title: str = "Battery Advisor"):
"""Sends a popup to the user"""
dialog = MessageAlert(message=message, title=title)
dialog.run()
dialog.destroy()


def alert_with_options(
message: str, options: list[str], title: str = "Battery Advisor"
) -> int:
"""Sends a popup with a close option to the user.
Returns
-------
int
The selected option index
"""

dialog = AlertWithButtons(title=title, message=message, actions=options)
selection = dialog.run()
dialog.destroy()
return selection
42 changes: 39 additions & 3 deletions battery_advisor/settings_loader.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import toml
import os
from .types import Settings
import toml
from typing import Type
from .types import SettingsFile
from .utils import _get_project_root

user_settings_path = os.path.expanduser("~/.config/battery-advisor/settings.toml")


def load_settings() -> Settings:
def load_settings() -> SettingsFile:
path = (
user_settings_path
if os.path.exists(user_settings_path)
Expand All @@ -18,3 +19,38 @@ def load_settings() -> Settings:

with open(path) as f:
return toml.load(f)


class Settings:
def __init__(self, settings: SettingsFile):
"""Creates a settings object based on the settings.toml file.
NOTE: This class is not meant to be instantiated directly. Use the load() method instead.
"""

self.check_interval: int = int(settings["advisor"]["check_interval"])
self.remind_time: int = int(settings["advisor"]["remind_time"])

# Tresholds
# self.low_battery_treshold = settings["tresholds"]["low_battery_treshold"]
self.low_battery_treshold = 100
self.critical_battery_treshold = settings["tresholds"][
"critical_battery_treshold"
]
self.battery_action_treshold = settings["tresholds"]["battery_action_treshold"]

# Alert Options
self.low_battery_options = settings["advisor"]["low_battery_options"]
self.critical_battery_options = settings["advisor"]["critical_battery_options"]
self.battery_action = settings["advisor"]["battery_action"]

self.actions: dict[str, list[str]] = settings["actions"]

# Notification Settings
self.notify_plugged = settings["advisor"]["notify_plugged"]
self.notify_unplugged = settings["advisor"]["notify_unplugged"]

@classmethod
def load(cls: Type["Settings"]) -> "Settings":
"""Loads the settings from the settings.toml file and returns a Settings object"""

return cls(load_settings())
Loading

0 comments on commit 677fbfc

Please sign in to comment.