diff --git a/bin/battery b/bin/battery index 958a1ec..8e0a504 100755 --- a/bin/battery +++ b/bin/battery @@ -1,193 +1,3 @@ -#!/usr/bin/env python3 +#!/bin/bash -import sys -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 -""" +python3 "$LWS/src/battery/battery/main.py" "$@" diff --git a/src/battery/.editorconfig b/src/battery/.editorconfig new file mode 100644 index 0000000..17b61f1 --- /dev/null +++ b/src/battery/.editorconfig @@ -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 diff --git a/src/battery/.gitignore b/src/battery/.gitignore new file mode 100644 index 0000000..6435c56 --- /dev/null +++ b/src/battery/.gitignore @@ -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 diff --git a/src/battery/Makefile b/src/battery/Makefile new file mode 100644 index 0000000..648a375 --- /dev/null +++ b/src/battery/Makefile @@ -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[a-zA-Z0-9_//-]+):(.*?## (?P.*))?', 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 diff --git a/src/battery/README.md b/src/battery/README.md new file mode 100644 index 0000000..114cfa7 --- /dev/null +++ b/src/battery/README.md @@ -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) diff --git a/src/battery/battery/__init__.py b/src/battery/battery/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/battery/battery/colorterm.py b/src/battery/battery/colorterm.py new file mode 100644 index 0000000..d6a1e91 --- /dev/null +++ b/src/battery/battery/colorterm.py @@ -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 diff --git a/src/battery/battery/main.py b/src/battery/battery/main.py new file mode 100644 index 0000000..c4ebb29 --- /dev/null +++ b/src/battery/battery/main.py @@ -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) diff --git a/src/battery/battery/power.py b/src/battery/battery/power.py new file mode 100644 index 0000000..f3e43dc --- /dev/null +++ b/src/battery/battery/power.py @@ -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), + ) diff --git a/src/battery/mypy.ini b/src/battery/mypy.ini new file mode 100644 index 0000000..5d7842a --- /dev/null +++ b/src/battery/mypy.ini @@ -0,0 +1,6 @@ +[mypy] +plugins = pydantic.mypy +disallow_untyped_defs = True +warn_redundant_casts = True +strict_equality = True +ignore_missing_imports = True diff --git a/src/battery/requirements-dev.txt b/src/battery/requirements-dev.txt new file mode 100644 index 0000000..d1136f7 --- /dev/null +++ b/src/battery/requirements-dev.txt @@ -0,0 +1,7 @@ +isort +pylint +mypy +pydantic +flake8 +black +pytest diff --git a/src/battery/tests/__init__.py b/src/battery/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/battery/tests/fixtures/dellbook.txt b/src/battery/tests/fixtures/dellbook.txt new file mode 100644 index 0000000..9bdd84e --- /dev/null +++ b/src/battery/tests/fixtures/dellbook.txt @@ -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 diff --git a/src/battery/tests/fixtures/empty.txt b/src/battery/tests/fixtures/empty.txt new file mode 100644 index 0000000..e69de29 diff --git a/src/battery/tests/fixtures/pinebook.txt b/src/battery/tests/fixtures/pinebook.txt new file mode 100644 index 0000000..8e96d02 --- /dev/null +++ b/src/battery/tests/fixtures/pinebook.txt @@ -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 diff --git a/src/battery/tests/fixtures/thinkpad.txt b/src/battery/tests/fixtures/thinkpad.txt new file mode 100644 index 0000000..a869460 --- /dev/null +++ b/src/battery/tests/fixtures/thinkpad.txt @@ -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 diff --git a/src/battery/tests/test_colorterm.py b/src/battery/tests/test_colorterm.py new file mode 100644 index 0000000..55a4dc2 --- /dev/null +++ b/src/battery/tests/test_colorterm.py @@ -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") diff --git a/src/battery/tests/test_power.py b/src/battery/tests/test_power.py new file mode 100644 index 0000000..88dc217 --- /dev/null +++ b/src/battery/tests/test_power.py @@ -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)