406 lines
15 KiB
Python
406 lines
15 KiB
Python
import bisect
|
|
import configparser
|
|
import os
|
|
import re
|
|
|
|
from i3pystatus import IntervalModule, formatp
|
|
from i3pystatus.core.command import run_through_shell
|
|
from i3pystatus.core.desktop import DesktopNotification
|
|
from i3pystatus.core.util import lchop, TimeWrapper, make_bar, make_glyph, make_vertical_bar
|
|
|
|
|
|
class UEventParser(configparser.ConfigParser):
|
|
@staticmethod
|
|
def parse_file(file):
|
|
parser = UEventParser()
|
|
with open(file, "rb") as file:
|
|
parser.read_string(file.read().decode(errors="replace"))
|
|
return dict(parser.items("id10t"))
|
|
|
|
def __init__(self):
|
|
super().__init__(default_section="id10t", strict=False)
|
|
|
|
def optionxform(self, key):
|
|
return lchop(key, "POWER_SUPPLY_")
|
|
|
|
def read_string(self, string):
|
|
super().read_string("[id10t]\n" + string)
|
|
|
|
|
|
class Battery:
|
|
@staticmethod
|
|
def create(from_file):
|
|
battery_info = UEventParser.parse_file(from_file)
|
|
if "POWER_NOW" in battery_info:
|
|
return BatteryEnergy(battery_info)
|
|
else:
|
|
return BatteryCharge(battery_info)
|
|
|
|
def __init__(self, battery_info):
|
|
self.battery_info = battery_info
|
|
self.normalize_micro()
|
|
|
|
def normalize_micro(self):
|
|
for key, micro_value in self.battery_info.items():
|
|
if re.match(r"(VOLTAGE|CHARGE|CURRENT|POWER|ENERGY)_(NOW|FULL|MIN)(_DESIGN)?", key):
|
|
self.battery_info[key] = float(micro_value) / 1000000.0
|
|
|
|
def percentage(self, design=False):
|
|
return self._percentage("_DESIGN" if design else "") * 100
|
|
|
|
def status(self):
|
|
if self.consumption() is None:
|
|
return self.battery_info["STATUS"]
|
|
elif self.consumption() > 0.1 and self.percentage() < 99.9:
|
|
return "Discharging" if self.battery_info["STATUS"] == "Discharging" else "Charging"
|
|
elif self.consumption() == 0 and self.percentage() == 0.00:
|
|
return "Depleted"
|
|
else:
|
|
return "Full"
|
|
|
|
def consumption(self, val):
|
|
return val if val > 0.1 else 0
|
|
|
|
|
|
class BatteryCharge(Battery):
|
|
def __init__(self, bi):
|
|
bi["CHARGE_FULL"] = bi["CHARGE_FULL_DESIGN"] if bi["CHARGE_NOW"] > bi["CHARGE_FULL"] else bi["CHARGE_FULL"]
|
|
super().__init__(bi)
|
|
|
|
def consumption(self):
|
|
if "VOLTAGE_NOW" in self.battery_info and "CURRENT_NOW" in self.battery_info:
|
|
return super().consumption(self.battery_info["VOLTAGE_NOW"] * abs(self.battery_info["CURRENT_NOW"])) # V * A = W
|
|
else:
|
|
return None
|
|
|
|
def _percentage(self, design):
|
|
return self.battery_info["CHARGE_NOW"] / self.battery_info["CHARGE_FULL" + design]
|
|
|
|
def wh_remaining(self):
|
|
return self.battery_info['CHARGE_NOW'] * self.battery_info['VOLTAGE_NOW']
|
|
|
|
def wh_total(self):
|
|
return self.battery_info['CHARGE_FULL'] * self.battery_info['VOLTAGE_NOW']
|
|
|
|
def wh_depleted(self):
|
|
return (self.battery_info['CHARGE_FULL'] - self.battery_info['CHARGE_NOW']) * self.battery_info['VOLTAGE_NOW']
|
|
|
|
def remaining(self):
|
|
if self.status() == "Discharging":
|
|
if "CHARGE_NOW" in self.battery_info and "CURRENT_NOW" in self.battery_info:
|
|
# Ah / A = h * 60 min = min
|
|
return self.battery_info["CHARGE_NOW"] / self.battery_info["CURRENT_NOW"] * 60
|
|
else:
|
|
return -1
|
|
else:
|
|
return (self.battery_info["CHARGE_FULL"] - self.battery_info["CHARGE_NOW"]) / self.battery_info[
|
|
"CURRENT_NOW"] * 60
|
|
|
|
|
|
class BatteryEnergy(Battery):
|
|
def consumption(self):
|
|
return super().consumption(self.battery_info["POWER_NOW"])
|
|
|
|
def _percentage(self, design):
|
|
return self.battery_info["ENERGY_NOW"] / self.battery_info["ENERGY_FULL" + design]
|
|
|
|
def wh_remaining(self):
|
|
return self.battery_info['ENERGY_NOW']
|
|
|
|
def wh_total(self):
|
|
return self.battery_info['ENERGY_FULL']
|
|
|
|
def wh_depleted(self):
|
|
return self.battery_info['ENERGY_FULL'] - self.battery_info['ENERGY_NOW']
|
|
|
|
def remaining(self):
|
|
if self.status() == "Discharging":
|
|
# Wh / W = h * 60 min = min
|
|
return self.battery_info["ENERGY_NOW"] / self.battery_info["POWER_NOW"] * 60
|
|
else:
|
|
return (self.battery_info["ENERGY_FULL"] - self.battery_info["ENERGY_NOW"]) / self.battery_info[
|
|
"POWER_NOW"] * 60
|
|
|
|
|
|
class BatteryChecker(IntervalModule):
|
|
"""
|
|
This class uses the /sys/class/power_supply/…/uevent interface to check for the
|
|
battery status.
|
|
|
|
Setting ``battery_ident`` to ``ALL`` will summarise all available batteries
|
|
and aggregate the % as well as the time remaining on the charge. This is
|
|
helpful when the machine has more than one battery available.
|
|
|
|
.. rubric:: Available formatters
|
|
|
|
* `{remaining}` — remaining time for charging or discharging, uses TimeWrapper formatting, default format is `%E%h:%M`
|
|
* `{percentage}` — battery percentage relative to the last full value
|
|
* `{percentage_design}` — absolute battery charge percentage
|
|
* `{consumption (Watts)}` — current power flowing into/out of the battery
|
|
* `{status}`
|
|
* `{no_of_batteries}` — The number of batteries included
|
|
* `{battery_ident}` — the same as the setting
|
|
* `{bar}` —bar displaying the relative percentage graphically
|
|
* `{bar_design}` —bar displaying the absolute percentage graphically
|
|
* `{glyph}` — A single character or string (selected from 'glyphs') representing the current battery percentage
|
|
|
|
This module supports the :ref:`formatp <formatp>` extended string format
|
|
syntax. By setting the ``FULL`` status to an empty string, and including
|
|
brackets around the ``{status}`` formatter, the text within the brackets
|
|
will be hidden when the battery is full, as can be seen in the below
|
|
example:
|
|
|
|
.. code-block:: python
|
|
|
|
from i3pystatus import Status
|
|
|
|
status = Status()
|
|
|
|
status.register(
|
|
'battery',
|
|
interval=5,
|
|
format='{battery_ident}: [{status} ]{percentage_design:.2f}%',
|
|
alert=True,
|
|
alert_percentage=15,
|
|
status={
|
|
'DPL': 'DPL',
|
|
'CHR': 'CHR',
|
|
'DIS': 'DIS',
|
|
'FULL': '',
|
|
}
|
|
)
|
|
|
|
# status.register(
|
|
# 'battery',
|
|
# format='{status} {percentage:.0f}%',
|
|
# levels={
|
|
# 25: "<=25",
|
|
# 50: "<=50",
|
|
# 75: "<=75",
|
|
# },
|
|
# )
|
|
|
|
status.run()
|
|
|
|
"""
|
|
|
|
settings = (
|
|
("battery_ident", "The name of your battery, usually BAT0 or BAT1"),
|
|
"format",
|
|
("not_present_text", "Text displayed if the battery is not present. No formatters are available"),
|
|
("alert", "Display a libnotify-notification on low battery"),
|
|
("critical_level_command", "Runs a shell command in the case of a critical power state"),
|
|
"critical_level_percentage",
|
|
"alert_percentage",
|
|
"alert_timeout",
|
|
("alert_format_title", "The title of the notification, all formatters can be used"),
|
|
("alert_format_body", "The body text of the notification, all formatters can be used"),
|
|
("path", "Override the default-generated path and specify the full path for a single battery"),
|
|
("base_path", "Override the default base path for searching for batteries"),
|
|
("battery_prefix", "Override the default battery prefix"),
|
|
("status", "A dictionary mapping ('DPL', 'DIS', 'CHR', 'FULL') to alternative names"),
|
|
("levels", "A dictionary mapping percentages of charge levels to corresponding names."),
|
|
("color", "The text color"),
|
|
("full_color", "The full color"),
|
|
("charging_color", "The charging color"),
|
|
("critical_color", "The critical color"),
|
|
("not_present_color", "The not present color."),
|
|
("not_present_text",
|
|
"The text to display when the battery is not present. Provides {battery_ident} as formatting option"),
|
|
("no_text_full", "Don't display text when battery is full - 100%"),
|
|
("glyphs", "Arbitrarily long string of characters (or array of strings) to represent battery charge percentage"),
|
|
("use_design_percentage", "Use design percentage rather then absolute percentage for alerts")
|
|
)
|
|
|
|
battery_ident = "ALL"
|
|
format = "{status} {remaining}"
|
|
status = {
|
|
"DPL": "DPL",
|
|
"CHR": "CHR",
|
|
"DIS": "DIS",
|
|
"FULL": "FULL",
|
|
}
|
|
levels = None
|
|
not_present_text = "Battery {battery_ident} not present"
|
|
|
|
alert = False
|
|
critical_level_command = ""
|
|
critical_level_percentage = 1
|
|
alert_percentage = 10
|
|
alert_timeout = -1
|
|
alert_format_title = "Low battery"
|
|
alert_format_body = "Battery {battery_ident} has only {percentage:.2f}% ({remaining:%E%hh:%Mm}) remaining!"
|
|
color = "#ffffff"
|
|
full_color = "#00ff00"
|
|
charging_color = "#00ff00"
|
|
critical_color = "#ff0000"
|
|
not_present_color = "#ffffff"
|
|
no_text_full = False
|
|
glyphs = "▁▂▃▄▅▆▇█"
|
|
use_design_percentage = False
|
|
|
|
battery_prefix = 'BAT'
|
|
base_path = '/sys/class/power_supply'
|
|
path = None
|
|
paths = []
|
|
|
|
notification = None
|
|
|
|
def percentage(self, batteries, design=False):
|
|
total_now = [battery.wh_remaining() for battery in batteries]
|
|
total_full = [battery.wh_total() for battery in batteries]
|
|
return sum(total_now) / sum(total_full) * 100
|
|
|
|
def consumption(self, batteries):
|
|
consumption = 0
|
|
for battery in batteries:
|
|
if battery.consumption() is not None:
|
|
consumption += battery.consumption()
|
|
return consumption
|
|
|
|
def abs_consumption(self, batteries):
|
|
abs_consumption = 0
|
|
for battery in batteries:
|
|
if battery.consumption() is None:
|
|
continue
|
|
if battery.status() == 'Discharging':
|
|
abs_consumption -= battery.consumption()
|
|
elif battery.status() == 'Charging':
|
|
abs_consumption += battery.consumption()
|
|
return abs_consumption
|
|
|
|
def battery_status(self, batteries):
|
|
abs_consumption = self.abs_consumption(batteries)
|
|
if abs_consumption > 0:
|
|
return 'Charging'
|
|
elif abs_consumption < 0:
|
|
return 'Discharging'
|
|
else:
|
|
return batteries[-1].status()
|
|
|
|
def remaining(self, batteries):
|
|
wh_depleted = 0
|
|
wh_remaining = 0
|
|
abs_consumption = self.abs_consumption(batteries)
|
|
for battery in batteries:
|
|
wh_remaining += battery.wh_remaining()
|
|
wh_depleted += battery.wh_depleted()
|
|
if abs_consumption == 0:
|
|
return 0
|
|
elif abs_consumption > 0:
|
|
return wh_depleted / self.consumption(batteries) * 60
|
|
elif abs_consumption < 0:
|
|
return wh_remaining / self.consumption(batteries) * 60
|
|
|
|
def init(self):
|
|
if not self.paths or (self.path and self.path not in self.paths):
|
|
bat_dir = self.base_path
|
|
if os.path.exists(bat_dir) and not self.path:
|
|
_, dirs, _ = next(os.walk(bat_dir))
|
|
all_bats = [x for x in dirs if x.startswith(self.battery_prefix)]
|
|
for bat in all_bats:
|
|
self.paths.append(os.path.join(bat_dir, bat, 'uevent'))
|
|
if self.path:
|
|
self.paths = [self.path]
|
|
|
|
def run(self):
|
|
urgent = False
|
|
color = self.color
|
|
batteries = []
|
|
|
|
for path in self.paths:
|
|
if self.battery_ident == 'ALL' or path.find(self.battery_ident) >= 0:
|
|
try:
|
|
batteries.append(Battery.create(path))
|
|
except FileNotFoundError:
|
|
pass
|
|
|
|
if not batteries:
|
|
format_dict = {'battery_ident': self.battery_ident}
|
|
self.output = {
|
|
"full_text": formatp(self.not_present_text, **format_dict),
|
|
"color": self.not_present_color,
|
|
}
|
|
return
|
|
if self.no_text_full:
|
|
if self.battery_status(batteries) == "Full":
|
|
self.output = {
|
|
"full_text": ""
|
|
}
|
|
return
|
|
|
|
fdict = {
|
|
"battery_ident": self.battery_ident,
|
|
"no_of_batteries": len(batteries),
|
|
"percentage": self.percentage(batteries),
|
|
"percentage_design": self.percentage(batteries, design=True),
|
|
"consumption": self.consumption(batteries),
|
|
"remaining": TimeWrapper(0, "%E%h:%M"),
|
|
"glyph": make_glyph(self.percentage(batteries), self.glyphs),
|
|
"bar": make_bar(self.percentage(batteries)),
|
|
"bar_design": make_bar(self.percentage(batteries, design=True)),
|
|
"vertical_bar": make_vertical_bar(self.percentage(batteries)),
|
|
"vertical_bar_design": make_vertical_bar(self.percentage(batteries, design=True)),
|
|
}
|
|
|
|
status = self.battery_status(batteries)
|
|
if status in ["Charging", "Discharging"]:
|
|
remaining = self.remaining(batteries)
|
|
fdict["remaining"] = TimeWrapper(remaining * 60, "%E%h:%M")
|
|
if status == "Discharging":
|
|
fdict["status"] = "DIS"
|
|
if self.percentage(batteries) <= self.alert_percentage:
|
|
urgent = True
|
|
color = self.critical_color
|
|
else:
|
|
fdict["status"] = "CHR"
|
|
color = self.charging_color
|
|
elif status == 'Depleted':
|
|
fdict["status"] = "DPL"
|
|
color = self.critical_color
|
|
else:
|
|
fdict["status"] = "FULL"
|
|
color = self.full_color
|
|
if self.critical_level_command and fdict["status"] == "DIS" and fdict["percentage"] <= self.critical_level_percentage:
|
|
run_through_shell(self.critical_level_command, enable_shell=True)
|
|
|
|
self.alert_if_low_battery(fdict)
|
|
|
|
if self.levels and fdict['status'] == 'DIS':
|
|
self.levels.setdefault(0, self.status.get('DPL', 'DPL'))
|
|
self.levels.setdefault(100, self.status.get('FULL', 'FULL'))
|
|
keys = sorted(self.levels.keys())
|
|
index = bisect.bisect_left(keys, int(fdict['percentage']))
|
|
fdict["status"] = self.levels[keys[index]]
|
|
else:
|
|
fdict["status"] = self.status[fdict["status"]]
|
|
|
|
self.data = fdict
|
|
self.output = {
|
|
"full_text": formatp(self.format, **fdict),
|
|
"instance": self.battery_ident,
|
|
"urgent": urgent,
|
|
"color": color,
|
|
}
|
|
|
|
def alert_if_low_battery(self, fdict):
|
|
if self.use_design_percentage:
|
|
percentage = fdict['percentage_design']
|
|
else:
|
|
percentage = fdict['percentage']
|
|
|
|
if self.alert and fdict["status"] == "DIS" and percentage <= self.alert_percentage:
|
|
title, body = formatp(self.alert_format_title, **fdict), formatp(self.alert_format_body, **fdict)
|
|
if self.notification is None:
|
|
self.notification = DesktopNotification(
|
|
title=title,
|
|
body=body,
|
|
icon="battery-caution",
|
|
urgency=2,
|
|
timeout=self.alert_timeout,
|
|
)
|
|
self.notification.display()
|
|
else:
|
|
self.notification.update(title=title,
|
|
body=body)
|