Skip to content

Instantly share code, notes, and snippets.

@gitcrtn
Last active August 8, 2023 11:06
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save gitcrtn/3a28c7fffa97ca3d53dce56034bc5057 to your computer and use it in GitHub Desktop.
Save gitcrtn/3a28c7fffa97ca3d53dce56034bc5057 to your computer and use it in GitHub Desktop.
Battery Monitor for Windows Laptop
"""
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