Move battery script to its own project

Start new Python project for battery script
This commit is contained in:
Martin Blazik
2022-07-30 11:58:45 +02:00
parent 9f38b89bfd
commit ea2162cbe2
18 changed files with 512 additions and 192 deletions

View File

@@ -1,193 +1,3 @@
#!/usr/bin/env python3 #!/bin/bash
import sys python3 "$LWS/src/battery/battery/main.py" "$@"
import re
from sys import stdout
from os import path, environ
from os.path import join, isdir
from collections import namedtuple, OrderedDict
POWER_CLASS = '/sys/class/power_supply'
BATTERIES = (
environ.get('BATTERY', 'BAT0'),
'cw2015-battery'
)
DISCHARNGING = 'Discharging'
RESET = '0'
RED = '0;31'
GREEN = '0;32'
YELLOW = '0;33'
BLUE = '0;34'
MAGENTA = '0;35'
CYAN = '0;36'
BatteryInfo = namedtuple(
'BatteryInfo',
[
'name',
'model',
'manufacturer',
'technology',
'capacity',
'status',
'currrent_percent',
'time_to_empty',
'charging_current'
]
)
def print_help():
text = """
battery [-h] [-d] [-i]
-h ... help
-d ... dump all values
-i ... short info
""".strip()
pattern = re.compile(r'^\s+', re.MULTILINE)
text = pattern.sub('', text)
print(text)
def find_battery_uevent(batteries):
for battery in batteries:
path = join(POWER_CLASS, battery)
if isdir(path):
return join(path, 'uevent')
return None
def start_color(color):
return '\033[' + color + 'm'
def colored(text, color):
return start_color(color) + text + start_color(RESET)
def read_battery(filename):
with open(filename) as file:
lines = file.readlines()
items = map(lambda line: line.strip().split('=', 2), lines)
return OrderedDict(items)
def load_battery_info(info):
return BatteryInfo(
name = info['POWER_SUPPLY_NAME'],
model = info.get('POWER_SUPPLY_MODEL_NAME', ''),
manufacturer = info.get('POWER_SUPPLY_MANUFACTURER', ''),
technology = info['POWER_SUPPLY_TECHNOLOGY'],
capacity = float(info['POWER_SUPPLY_CHARGE_FULL']) / 1e6,
status = info['POWER_SUPPLY_STATUS'],
currrent_percent = int(info['POWER_SUPPLY_CAPACITY']),
time_to_empty = int(info.get('POWER_SUPPLY_TIME_TO_EMPTY_NOW', 0)),
charging_current = int(float(info['POWER_SUPPLY_CURRENT_NOW']) / 1e3)
)
def terminal_colored(text, color):
if sys.stdout.isatty():
return colored(text, color)
else:
return text
def format_status(info):
color = RED if info.status == DISCHARNGING else GREEN
return terminal_colored(info.status, color)
def format_percent(info):
color = GREEN if info.currrent_percent > 30 else RED
return terminal_colored(f'{info.currrent_percent}%', color)
def format_time_to_empty(info):
if info.time_to_empty == 0:
return '??:??'
else:
hours = info.time_to_empty // 60
minutes = info.time_to_empty % 60
return terminal_colored(f"{hours}:{minutes:02d}", CYAN)
def battery_model(info):
model = ''
if info.model:
model += info.model
if info.manufacturer:
if model:
model += ' - '
model += info.manufacturer
if model:
model += ': '
return model
def print_battery_detail(info):
model = battery_model(info)
capacity = terminal_colored(str(info.capacity), YELLOW)
print(f"Battery: {model}{info.technology} {capacity} Ah")
print("Battery level: " + format_percent(info))
print("Battery status: " + format_status(info))
if info.status == DISCHARNGING:
time_to_empty = format_time_to_empty(info)
print(f"Time to empty: {time_to_empty} [hour:minutes]")
print(f"{info.status} current: {info.charging_current} mA")
def print_battery_info(info):
percent = format_percent(info)
status = format_status(info)
state = f"level={percent} status={status}"
if info.status == DISCHARNGING:
time_to_empty = format_time_to_empty(info)
state += f" duration={time_to_empty}"
print(state)
def dump_battery_info(uevent, battery):
print(f"# {uevent}")
for key, value in battery.items():
print(f"{key} = {value}")
def main(args):
uevent = find_battery_uevent(BATTERIES)
if not uevent:
print("No battery found")
return
battery = read_battery(uevent)
info = load_battery_info(battery)
if '-h' in args:
print_help()
elif '-d' in args:
dump_battery_info(uevent, battery)
elif '-i' in args:
print_battery_info(info)
else:
print_battery_detail(info)
if __name__ == '__main__':
main(sys.argv)
"""
cat /sys/class/power_supply/cw2015-battery/uevent
POWER_SUPPLY_NAME=cw2015-battery
POWER_SUPPLY_TYPE=Battery
POWER_SUPPLY_CAPACITY=100
POWER_SUPPLY_STATUS=Full
POWER_SUPPLY_PRESENT=1
POWER_SUPPLY_VOLTAGE_NOW=4314000
POWER_SUPPLY_TIME_TO_EMPTY_NOW=0
POWER_SUPPLY_TECHNOLOGY=Li-ion
POWER_SUPPLY_CHARGE_FULL=9800000
POWER_SUPPLY_CHARGE_FULL_DESIGN=9800000
POWER_SUPPLY_CURRENT_NOW=0
"""

32
src/battery/.editorconfig Normal file
View File

@@ -0,0 +1,32 @@
# defaults
[*]
charset=utf-8
trim_trailing_whitespace = true
insert_final_newline = true
end_of_line=lf
indent_size = 4
indent_style = space
[Makefile]
indent_style = tab
[*.xml]
continuation_indent_size = 8
# YAML
# http://yaml.org/spec/1.2/2009-07-21/spec.html#id2576668
[*.{yaml,yml}]
indent_size = 2
# Shell
# https://google.github.io/styleguide/shell.xml#Indentation
[*.{bash,sh,zsh}]
indent_size = 2
# HTML
# https://google.github.io/styleguide/htmlcssguide.xml#General_Formatting_Rules
[*.{htm,html}]
indent_size = 2
[*.json]
indent_size=2

71
src/battery/.gitignore vendored Normal file
View File

@@ -0,0 +1,71 @@
# IDE
.vscode/
.idea/
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
env/
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
*.egg-info/
.installed.cfg
*.egg
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*,cover
.hypothesis/
venv/
.venv/
.python-version
.pytest_cache
# Translations
*.mo
*.pot
# Django stuff:
*.log
# Sphinx documentation
docs/_build/
docs/_autosummary/
# PyBuilder
target/
#Ipython Notebook
.ipynb_checkpoints

65
src/battery/Makefile Normal file
View File

@@ -0,0 +1,65 @@
VENV_NAME=.venv
VENV_BIN=$(VENV_NAME)/bin
PYTHON=$(VENV_BIN)/python3
PIP=$(VENV_BIN)/pip3
BLACK=$(VENV_BIN)/black
ISORT=$(VENV_BIN)/isort
.PHONY: install-dev venv lint mypy test
.DEFAULT_GOAL := help
define PRINT_HELP_PYSCRIPT
import re, sys
for line in sys.stdin:
match = re.match(r'^(?P<target>[a-zA-Z0-9_//-]+):(.*?## (?P<help>.*))?', line)
if match:
target = match.group('target')
help = match.group('help')
if not help:
help = ''
print(f"{target:20s} {help}")
endef
export PRINT_HELP_PYSCRIPT
help: ## list all commands
@python3 -c "$$PRINT_HELP_PYSCRIPT" < $(MAKEFILE_LIST)
install-dev: ## prepare virtual environment and install all dependencies
python3 -m venv $(VENV_NAME) --upgrade-deps
$(PIP) install -r requirements-dev.txt
uninstall-dev:
rm -rf $(VENV_NAME)
find -iname "*.pyc" -delete
lint: venv lint/flake8 lint/black lint/isort mypy ## lint source codes
lint/flake8: venv
$(VENV_BIN)/flake8 battery/ tests/
lint/black: venv
$(BLACK) --check .
lint/isort:
$(ISORT) --check .
format-diff: venv ## show invalid source code formatting
$(BLACK) --diff .
$(ISORT) --diff .
format-fix: venv ## fix source code formatting
$(BLACK) .
$(ISORT) .
mypy: venv ## run mypy
$(PYTHON) -m mypy battery/ tests/
test: venv ## run tests
$(PYTHON) -m unittest discover -v -s tests/
pytest: venv ## run pytests
$(VENV_BIN)/pytest -v
venv:
. $(VENV_NAME)/bin/activate

10
src/battery/README.md Normal file
View File

@@ -0,0 +1,10 @@
# Notebook battery info
Read and print battery details
## Links
### Project structure tips
* [Cookiecutter](https://cookiecutter.readthedocs.io/)
* [Python Application Layouts: A Reference](https://realpython.com/python-application-layouts/)
* [Structuring Your Project](https://docs.python-guide.org/writing/structure/)
* [The Good way to structure a Python Project](https://towardsdatascience.com/the-good-way-to-structure-a-python-project-d914f27dfcc9)

View File

View File

@@ -0,0 +1,24 @@
import sys
RESET = "0"
RED = "0;31"
GREEN = "0;32"
YELLOW = "0;33"
BLUE = "0;34"
MAGENTA = "0;35"
CYAN = "0;36"
def start_color(color: str) -> str:
return "\033[" + color + "m"
def colored(text: str, color: str) -> str:
return start_color(color) + text + start_color(RESET)
def terminal_colored(text: str, color: str) -> str:
if sys.stdout.isatty():
return colored(text, color)
else:
return text

110
src/battery/battery/main.py Normal file
View File

@@ -0,0 +1,110 @@
import re
import sys
from os import environ
from typing import List
from colorterm import CYAN, GREEN, RED, YELLOW, terminal_colored
from power import Battery, battery_info, find_uevent, read_uevent
BATTERIES = (environ.get("BATTERY", "BAT0"), "cw2015-battery")
DISCHARGING = "Discharging"
def print_help(app: str) -> None:
text = f"""
{app} [-h] [-d] [-i]
-h ... help
-d ... dump all values
-i ... short info
""".strip()
pattern = re.compile(r"^\s+", re.MULTILINE)
text = pattern.sub("", text)
print(text)
def format_status(battery: Battery) -> str:
color = RED if battery.status == DISCHARGING else GREEN
return terminal_colored(battery.status, color)
def format_percent(battery: Battery) -> str:
color = GREEN if battery.percent > 30 else RED
return terminal_colored(f"{battery.percent}%", color)
def format_time_to_empty(battery: Battery) -> str:
if battery.time_to_empty == 0:
return "??:??"
else:
hours = battery.time_to_empty // 60
minutes = battery.time_to_empty % 60
return terminal_colored(f"{hours}:{minutes:02d}", CYAN)
def battery_model(battery: Battery) -> str:
model = ""
if battery.model:
model += battery.model
if battery.manufacturer:
if model:
model += " - "
model += battery.manufacturer
if model:
model += ": "
return model
def print_battery_detail(battery: Battery) -> None:
model = battery_model(battery)
capacity = terminal_colored(str(battery.capacity), YELLOW)
print(f"Battery: {model}{battery.technology} {capacity} Ah")
print("Battery level: " + format_percent(battery))
print("Battery status: " + format_status(battery))
if battery.status == DISCHARGING:
time_to_empty = format_time_to_empty(battery)
print(f"Time to empty: {time_to_empty} [hour:minutes]")
print(f"{battery.status} current: {battery.current} mA")
def print_battery_info(battery: Battery) -> None:
percent = format_percent(battery)
status = format_status(battery)
state = f"level={percent} status={status}"
if battery.status == DISCHARGING:
time_to_empty = format_time_to_empty(battery)
state += f" duration={time_to_empty}"
print(state)
def dump_battery_info(uevent: str, battery: Battery) -> None:
print(f"# {uevent}")
for key, value in battery.items():
print(f"{key} = {value}")
def main(args: List[str]) -> None:
if "-h" in args:
print_help(args[0])
return
if len(args) >= 3 and args[1] == "-b":
uevent = args[2]
else:
uevent = find_uevent(BATTERIES)
if not uevent:
print("No battery found")
return
battery = read_uevent(uevent)
info = battery_info(battery)
if "-d" in args:
dump_battery_info(uevent, battery)
elif "-i" in args:
print_battery_info(info)
else:
print_battery_detail(info)
if __name__ == "__main__":
main(sys.argv)

View File

@@ -0,0 +1,48 @@
from collections import OrderedDict, namedtuple
from pathlib import Path
from typing import Dict, List, Union
from dataclasses import dataclass
POWER_CLASS = Path("/sys/class/power_supply")
@dataclass
class Battery:
name: str
model: str
manufacturer: str
technology: str
capacity: float
percent: int
status: str
time_to_empty: int
current: int
def find_uevent(batteries: List[str]) -> Union[Path, None]:
for battery in batteries:
uevent_path = POWER_CLASS.joinpath(battery, "uevent")
if uevent_path.is_fifo():
return uevent_path
return None
def read_uevent(filename: Path) -> Dict[str, str]:
with open(filename) as file:
lines = file.readlines()
items = map(lambda line: line.strip().split("=", 2), lines)
return OrderedDict(items)
def battery_info(uevent: Dict[str, str]) -> Battery:
return Battery(
name=uevent.get("POWER_SUPPLY_NAME", "??"),
model=uevent.get("POWER_SUPPLY_MODEL_NAME", ""),
manufacturer=uevent.get("POWER_SUPPLY_MANUFACTURER", ""),
technology=uevent.get("POWER_SUPPLY_TECHNOLOGY", ""),
capacity=float(uevent.get("POWER_SUPPLY_CHARGE_FULL", 0)) / 1e6,
percent=int(uevent.get("POWER_SUPPLY_CAPACITY", 0)),
status=uevent.get("POWER_SUPPLY_STATUS", "??"),
time_to_empty=int(uevent.get("POWER_SUPPLY_TIME_TO_EMPTY_NOW", 0)),
current=int(float(uevent.get("POWER_SUPPLY_CURRENT_NOW", 0)) / 1e3),
)

6
src/battery/mypy.ini Normal file
View File

@@ -0,0 +1,6 @@
[mypy]
plugins = pydantic.mypy
disallow_untyped_defs = True
warn_redundant_casts = True
strict_equality = True
ignore_missing_imports = True

View File

@@ -0,0 +1,7 @@
isort
pylint
mypy
pydantic
flake8
black
pytest

View File

17
src/battery/tests/fixtures/dellbook.txt vendored Normal file
View File

@@ -0,0 +1,17 @@
POWER_SUPPLY_NAME=BAT0
POWER_SUPPLY_TYPE=Battery
POWER_SUPPLY_STATUS=Discharging
POWER_SUPPLY_PRESENT=1
POWER_SUPPLY_TECHNOLOGY=Li-ion
POWER_SUPPLY_CYCLE_COUNT=0
POWER_SUPPLY_VOLTAGE_MIN_DESIGN=11100000
POWER_SUPPLY_VOLTAGE_NOW=11766000
POWER_SUPPLY_CURRENT_NOW=2991000
POWER_SUPPLY_CHARGE_FULL_DESIGN=4400000
POWER_SUPPLY_CHARGE_FULL=4400000
POWER_SUPPLY_CHARGE_NOW=4292000
POWER_SUPPLY_CAPACITY=97
POWER_SUPPLY_CAPACITY_LEVEL=Normal
POWER_SUPPLY_MODEL_NAME=DELL CP2848C
POWER_SUPPLY_MANUFACTURER=Samsung SDI
POWER_SUPPLY_SERIAL_NUMBER=20797

0
src/battery/tests/fixtures/empty.txt vendored Normal file
View File

12
src/battery/tests/fixtures/pinebook.txt vendored Normal file
View File

@@ -0,0 +1,12 @@
POWER_SUPPLY_NAME=cw2015-battery
POWER_SUPPLY_TYPE=Battery
POWER_SUPPLY_CAPACITY=100
POWER_SUPPLY_STATUS=Discharging
POWER_SUPPLY_PRESENT=1
POWER_SUPPLY_VOLTAGE_NOW=4200000
POWER_SUPPLY_TIME_TO_EMPTY_NOW=606
POWER_SUPPLY_TECHNOLOGY=Li-ion
POWER_SUPPLY_CHARGE_COUNTER=0
POWER_SUPPLY_CHARGE_FULL=9800000
POWER_SUPPLY_CHARGE_FULL_DESIGN=9800000
POWER_SUPPLY_CURRENT_NOW=970297

17
src/battery/tests/fixtures/thinkpad.txt vendored Normal file
View File

@@ -0,0 +1,17 @@
POWER_SUPPLY_NAME=BAT0
POWER_SUPPLY_TYPE=Battery
POWER_SUPPLY_STATUS=Discharging
POWER_SUPPLY_PRESENT=1
POWER_SUPPLY_TECHNOLOGY=Li-poly
POWER_SUPPLY_CYCLE_COUNT=70
POWER_SUPPLY_VOLTAGE_MIN_DESIGN=11550000
POWER_SUPPLY_VOLTAGE_NOW=12495000
POWER_SUPPLY_POWER_NOW=10008000
POWER_SUPPLY_ENERGY_FULL_DESIGN=50500000
POWER_SUPPLY_ENERGY_FULL=51480000
POWER_SUPPLY_ENERGY_NOW=51110000
POWER_SUPPLY_CAPACITY=99
POWER_SUPPLY_CAPACITY_LEVEL=Normal
POWER_SUPPLY_MODEL_NAME=02DL007
POWER_SUPPLY_MANUFACTURER=LGC
POWER_SUPPLY_SERIAL_NUMBER= 487

View File

@@ -0,0 +1,8 @@
import unittest
from battery.colorterm import RED, colored
class TestColorTerm(unittest.TestCase):
def test_colored_text(self) -> None:
self.assertEqual(colored("TEXT", RED), "\033[0;31mTEXT\033[0m")

View File

@@ -0,0 +1,83 @@
import unittest
from pathlib import Path
from battery.power import Battery, battery_info, read_uevent
def load_fixture(filename: str) -> dict[str, str]:
directory = Path(__file__).parent.resolve()
fixture = directory.joinpath("fixtures", filename)
return read_uevent(fixture)
batteries = (
(
"empty",
"empty.txt",
Battery(
name="??",
model="",
manufacturer="",
technology="",
capacity=0.0,
percent=0,
status="??",
time_to_empty=0,
current=0.0,
),
),
(
"dell",
"dellbook.txt",
Battery(
name="BAT0",
model="DELL CP2848C",
manufacturer="Samsung SDI",
technology="Li-ion",
capacity=4.4,
percent=97,
status="Discharging",
time_to_empty=0,
current=2991,
),
),
(
"pine",
"pinebook.txt",
Battery(
name="cw2015-battery",
model="",
manufacturer="",
technology="Li-ion",
capacity=9.8,
percent=100,
status="Discharging",
time_to_empty=606,
current=970,
),
),
(
"lenovi",
"thinkpad.txt",
Battery(
name="BAT0",
model="02DL007",
manufacturer="LGC",
technology="Li-poly",
capacity=0.0,
percent=99,
status="Discharging",
time_to_empty=0,
current=0,
),
),
)
class TestPower(unittest.TestCase):
def test_battery_uevent(self) -> None:
for name, filename, expected_info in batteries:
with self.subTest(name):
uevent = load_fixture(filename)
info = battery_info(uevent)
self.assertEqual(info, expected_info)