Last active
August 8, 2023 11:06
-
-
Save gitcrtn/3a28c7fffa97ca3d53dce56034bc5057 to your computer and use it in GitHub Desktop.
Battery Monitor for Windows Laptop
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
""" | |
Battery Monitor | |
Author: Carotene | |
License: LGPLv3 | |
Requirements: | |
PySide6 | |
pyqtgraph (optional) | |
How to setup: | |
python.exe -m venv venv | |
call venv\Scripts\activate | |
pip install pyside6 pyqtgraph | |
deactivate | |
How to run: | |
start /MIN python.exe battery_monitor.py | |
""" | |
import sys | |
import os | |
import json | |
import time | |
import textwrap | |
from datetime import datetime | |
import subprocess as sp | |
BASE_DIR = os.path.dirname(__file__) | |
VENV_LIB_PATH = os.path.join(BASE_DIR, 'venv', 'Lib', 'site-packages') | |
if os.path.isdir(VENV_LIB_PATH): | |
sys.path.append(VENV_LIB_PATH) | |
from PySide6.QtGui import QIcon, QAction | |
from PySide6.QtWidgets import ( | |
QApplication, QSystemTrayIcon, QMenu, | |
QMessageBox, QDialog, QVBoxLayout, QLabel, QPushButton, | |
) | |
from PySide6.QtCore import QTimer, QObject, Signal, Qt | |
BATTERY_API_CMD_PREFIX = 'WMIC Path Win32_Battery Get ' | |
ICON_PATH = os.path.join(BASE_DIR, 'icon.svg') | |
CONFIG_PATH = os.path.join(BASE_DIR, 'config.json') | |
PERCENT_CSV = os.path.join(BASE_DIR, 'percent.csv') | |
ICON_CONTENT = '''\ | |
<svg xmlns="http://www.w3.org/2000/svg" height="1em" viewBox="0 0 512 512"> | |
<!--! Font Awesome Free 6.4.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --> | |
<path d="M80 96c0-17.7 14.3-32 32-32h64c17.7 0 32 14.3 32 32l96 0c0-17.7 14.3-32 32-32h64c17.7 0 32 14.3 32 32h16c35.3 0 64 28.7 64 64V384c0 35.3-28.7 64-64 64H64c-35.3 0-64-28.7-64-64V160c0-35.3 28.7-64 64-64l16 0zm304 96c0-8.8-7.2-16-16-16s-16 7.2-16 16v32H320c-8.8 0-16 7.2-16 16s7.2 16 16 16h32v32c0 8.8 7.2 16 16 16s16-7.2 16-16V256h32c8.8 0 16-7.2 16-16s-7.2-16-16-16H384V192zM80 240c0 8.8 7.2 16 16 16h96c8.8 0 16-7.2 16-16s-7.2-16-16-16H96c-8.8 0-16 7.2-16 16z"/> | |
</svg> | |
''' | |
CONFIG_CONTENT = '''\ | |
{ | |
"interval": 60, | |
"warnPercent": 20, | |
"warnTime": 20, | |
"dumpPercent": false, | |
"debug": false | |
} | |
''' | |
class BatteryAPI: | |
STATUS_UNPLUGGED = 1 | |
STATUS_PLUGGED = 2 | |
def _get_output(self, prop): | |
try: | |
cmd = (BATTERY_API_CMD_PREFIX+prop).split() | |
for line in sp.check_output(cmd).decode().splitlines()[1:]: | |
line = line.strip() | |
if line: | |
return int(line) | |
except Exception: | |
pass | |
return None | |
def get_status(self): | |
return self._get_output('BatteryStatus') | |
def get_run_time(self): | |
return self._get_output('EstimatedRunTime') | |
def get_charge_remaining(self): | |
return self._get_output('EstimatedChargeRemaining') | |
def mins_to_hours_mins(self, mins): | |
hours = mins // 60 | |
mins = mins % 60 | |
return f'{hours} h {mins} m' | |
class Monitor(QObject): | |
warn = Signal() | |
def __init__(self): | |
super().__init__() | |
self.api = BatteryAPI() | |
self.config = None | |
self.checking = False | |
self.timer = None | |
self.prevPercent = 0 | |
def setup(self): | |
self.prepare_config_file() | |
self.load_config() | |
def setup_timer(self, parent): | |
self.timer = QTimer(parent) | |
self.timer.timeout.connect(self.check_battery) | |
self.restart() | |
def prepare_config_file(self): | |
if os.path.isfile(CONFIG_PATH): | |
return | |
with open(CONFIG_PATH, 'w') as f: | |
f.write(CONFIG_CONTENT) | |
def load_config(self): | |
with open(CONFIG_PATH, 'r') as f: | |
self.config = json.load(f) | |
def open_config(self): | |
try: | |
os.system(f'start notepad {CONFIG_PATH}') | |
except Exception: | |
pass | |
def restart(self): | |
self.prevPercent = 0 | |
self.check_battery() | |
interval = self.config.get('interval', 60) * 1000 | |
if not self.timer.isActive(): | |
self.timer.start(interval) | |
return | |
self.timer.setInterval(interval) | |
def _dump_percent(self, percent): | |
if not self.config.get('dumpPercent', False): | |
return | |
if self.prevPercent == percent: | |
return | |
self.prevPercent = percent | |
with open(PERCENT_CSV, 'a') as f: | |
timestamp = int(time.time()) | |
f.write(f'{timestamp},{percent}\n') | |
def check_battery(self): | |
if self.checking: | |
return | |
self.checking = True | |
try: | |
percent = self.api.get_charge_remaining() | |
self._dump_percent(percent) | |
status = self.api.get_status() | |
if status != self.api.STATUS_UNPLUGGED: | |
return | |
warn_percent = self.config.get('warnPercent', 20) | |
if percent <= warn_percent: | |
self.warn.emit() | |
return | |
run_time = self.api.get_run_time() | |
warn_time = self.config.get('warnTime', 20) | |
if run_time <= warn_time: | |
self.warn.emit() | |
return | |
finally: | |
self.checking = False | |
class DebugDialog(QDialog): | |
def __init__(self, api): | |
super().__init__() | |
self.api = api | |
self.setWindowTitle('Debug') | |
self.layout = QVBoxLayout() | |
self.bt_reload = QPushButton('Reload') | |
self.bt_reload.clicked.connect(self.reload) | |
self.label = QLabel('') | |
self.layout.addWidget(self.label) | |
self.label_graph = QLabel('percent graph:') | |
self.layout.addWidget(self.label_graph) | |
self.graph, self.exists_graph = self.make_graph() | |
self.layout.addWidget(self.graph) | |
if self.exists_graph: | |
self.reload_graph() | |
self.layout.addWidget(self.bt_reload) | |
self.setLayout(self.layout) | |
self.reload() | |
def make_graph(self): | |
try: | |
from pyqtgraph import PlotWidget, AxisItem | |
except ImportError: | |
return QLabel('pyqtgraph not found'), False | |
class TimeAxisItem(AxisItem): | |
def __init__(self, *args, **kwargs): | |
super().__init__(*args, **kwargs) | |
self.enableAutoSIPrefix(False) | |
def tickStrings(self, values, scale, spacing): | |
return [ | |
datetime.fromtimestamp(value).strftime('%H:%M:%S') | |
for value in values | |
] | |
graph = PlotWidget(axisItems={ | |
'bottom': TimeAxisItem(orientation='bottom'), | |
}) | |
bottom = graph.getAxis('bottom') | |
bottom.setLabel('Timestamp') | |
left = graph.getAxis('left') | |
left.setLabel('Percent') | |
return graph, True | |
def reload_graph(self): | |
self.graph.clear() | |
if not os.path.isfile(PERCENT_CSV): | |
return | |
with open(PERCENT_CSV, 'r') as f: | |
lines = f.readlines() | |
if not lines: | |
return | |
def to_item(line): | |
timestamp, percent = line.strip().split(',') | |
return int(timestamp), int(percent) | |
items = [ | |
to_item(line) | |
for line in lines | |
if line.strip() | |
] | |
if not items: | |
return | |
timestamps, percents = zip(*items) | |
self.graph.plot(timestamps, percents) | |
def reload(self): | |
status = self.api.get_status() | |
percent = self.api.get_charge_remaining() | |
mins = self.api.get_run_time() | |
message = textwrap.dedent(f'''\ | |
status: {status} | |
charge: {percent} | |
time: {mins}\ | |
''') | |
self.label.setText(message) | |
if self.exists_graph: | |
self.reload_graph() | |
class SystemTrayIcon: | |
def __init__(self): | |
self.app = None | |
self.action_debug = None | |
self.tray = None | |
self.warn_dialog = None | |
self.debug_dialog = None | |
self.keep_objects = [] | |
self.monitor = Monitor() | |
self.monitor.warn.connect(self.on_battery_warn) | |
def on_battery_warn(self): | |
if self.warn_dialog: | |
return | |
percent = self.monitor.api.get_charge_remaining() | |
mins = self.monitor.api.get_run_time() | |
run_time = self.monitor.api.mins_to_hours_mins(mins) | |
message = textwrap.dedent(f'''\ | |
Battery is low. | |
Estimated Charge: {percent} % | |
Estimated Run Time: {run_time}\ | |
''') | |
self.warn_dialog = QMessageBox( | |
QMessageBox.Warning, | |
'Warning', | |
message) | |
self.set_on_top(self.warn_dialog) | |
self.warn_dialog.exec() | |
self.warn_dialog = None | |
@staticmethod | |
def set_on_top(dialog): | |
dialog.setWindowFlags(dialog.windowFlags() | Qt.WindowStaysOnTopHint) | |
def setup(self): | |
self.monitor.setup() | |
self.setup_app() | |
self.prepare_icon_file() | |
self.make_icon() | |
self.monitor.setup_timer(self.tray) | |
def setup_app(self): | |
self.app = QApplication(sys.argv) | |
self.app.setQuitOnLastWindowClosed(False) | |
def prepare_icon_file(self): | |
if os.path.isfile(ICON_PATH): | |
return | |
with open(ICON_PATH, 'w') as f: | |
f.write(ICON_CONTENT) | |
def open_config(self): | |
self.monitor.open_config() | |
def open_debug_dialog(self): | |
if self.debug_dialog: | |
return | |
self.debug_dialog = DebugDialog(self.monitor.api) | |
self.set_on_top(self.debug_dialog) | |
self.debug_dialog.exec() | |
self.debug_dialog = None | |
def reload(self): | |
self.monitor.load_config() | |
self.monitor.restart() | |
self.action_debug.setVisible( | |
self.monitor.config.get('debug', False)) | |
def make_icon(self): | |
icon = QIcon('icon.svg') | |
tray = QSystemTrayIcon() | |
tray.setIcon(icon) | |
tray.setVisible(True) | |
menu = QMenu() | |
action_config = QAction('Open config') | |
action_config.triggered.connect(self.open_config) | |
menu.addAction(action_config) | |
action_reload = QAction('Reload') | |
action_reload.triggered.connect(self.reload) | |
menu.addAction(action_reload) | |
action_debug = QAction('Debug') | |
action_debug.triggered.connect(self.open_debug_dialog) | |
menu.addAction(action_debug) | |
action_debug.setVisible( | |
self.monitor.config.get('debug', False)) | |
menu.addSeparator() | |
action_quit = QAction('Quit') | |
action_quit.triggered.connect(self.app.quit) | |
menu.addAction(action_quit) | |
self.keep_objects += [ | |
icon, | |
action_config, | |
action_reload, | |
action_quit, | |
menu, | |
] | |
self.action_debug = action_debug | |
self.tray = tray | |
tray.setContextMenu(menu) | |
tray.setToolTip('Battery Monitor') | |
tray.show() | |
def run(self): | |
self.setup() | |
sys.exit(self.app.exec()) | |
def main(): | |
ui = SystemTrayIcon() | |
ui.run() | |
if __name__ == '__main__': | |
main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment