battery-tray 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- battery_tray-1.0.0/.gitignore +10 -0
- battery_tray-1.0.0/LICENSE +24 -0
- battery_tray-1.0.0/PKG-INFO +61 -0
- battery_tray-1.0.0/README.md +34 -0
- battery_tray-1.0.0/pyproject.toml +94 -0
- battery_tray-1.0.0/src/battery_tray/__init__.py +33 -0
- battery_tray-1.0.0/src/battery_tray/__main__.py +205 -0
- battery_tray-1.0.0/src/battery_tray/app.py +167 -0
- battery_tray-1.0.0/src/battery_tray/battery.py +137 -0
- battery_tray-1.0.0/src/battery_tray/config.py +65 -0
- battery_tray-1.0.0/src/battery_tray/py.typed +0 -0
- battery_tray-1.0.0/src/battery_tray/render.py +162 -0
- battery_tray-1.0.0/src/battery_tray/window.py +136 -0
- battery_tray-1.0.0/tests/__init__.py +0 -0
- battery_tray-1.0.0/tests/test_battery.py +81 -0
- battery_tray-1.0.0/tests/test_config.py +41 -0
- battery_tray-1.0.0/tests/test_package_version.py +5 -0
- battery_tray-1.0.0/tests/test_render.py +22 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
This is free and unencumbered software released into the public domain.
|
|
2
|
+
|
|
3
|
+
Anyone is free to copy, modify, publish, use, compile, sell, or
|
|
4
|
+
distribute this software, either in source code form or as a compiled
|
|
5
|
+
binary, for any purpose, commercial or non-commercial, and by any
|
|
6
|
+
means.
|
|
7
|
+
|
|
8
|
+
In jurisdictions that recognize copyright laws, the author or authors
|
|
9
|
+
of this software dedicate any and all copyright interest in the
|
|
10
|
+
software to the public domain. We make this dedication for the benefit
|
|
11
|
+
of the public at large and to the detriment of our heirs and
|
|
12
|
+
successors. We intend this dedication to be an overt act of
|
|
13
|
+
relinquishment in perpetuity of all present and future rights to this
|
|
14
|
+
software under copyright law.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
|
19
|
+
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
|
|
20
|
+
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
|
21
|
+
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
|
22
|
+
OTHER DEALINGS IN THE SOFTWARE.
|
|
23
|
+
|
|
24
|
+
For more information, please refer to <https://unlicense.org>
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: battery-tray
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A system-tray battery monitor with a battery-selection window.
|
|
5
|
+
Project-URL: Homepage, https://github.com/sevaht/battery-tray
|
|
6
|
+
Project-URL: Source, https://github.com/sevaht/battery-tray
|
|
7
|
+
Project-URL: Issues, https://github.com/sevaht/battery-tray/issues
|
|
8
|
+
Author-email: Jacob McIntosh <nacitar.sevaht@gmail.com>
|
|
9
|
+
License-Expression: Unlicense
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: battery,linux,monitor,power,tkinter,tray
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
14
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Topic :: Utilities
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.12
|
|
23
|
+
Requires-Dist: pillow>=12.2.0
|
|
24
|
+
Requires-Dist: platformdirs>=4.10.0
|
|
25
|
+
Requires-Dist: sevaht-gui
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# battery-tray
|
|
29
|
+
|
|
30
|
+
A small Linux system-tray battery monitor, built on
|
|
31
|
+
[sevaht-gui](https://github.com/sevaht/sevaht-gui).
|
|
32
|
+
|
|
33
|
+
- A tray icon shows the selected battery's charge as a colour-graded battery
|
|
34
|
+
glyph (green → red) with charge/charging state in the tooltip.
|
|
35
|
+
- Left-click (or the tray menu's **Configure…**) opens a window to pick which
|
|
36
|
+
battery to monitor and set the refresh interval; both are saved under
|
|
37
|
+
`sevaht/battery-tray`.
|
|
38
|
+
- Desktop notifications (via `notify-send`) fire when the charge level or
|
|
39
|
+
charging state changes.
|
|
40
|
+
- If no system tray is available, the window simply runs on its own.
|
|
41
|
+
|
|
42
|
+
## Running
|
|
43
|
+
|
|
44
|
+
```console
|
|
45
|
+
$ battery-tray # start in the tray (window hidden)
|
|
46
|
+
$ battery-tray --show # start with the window open
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Configuring from the CLI
|
|
50
|
+
|
|
51
|
+
These update the saved config and exit (handy for scripts/keybinds); the
|
|
52
|
+
running app picks the same values up from config:
|
|
53
|
+
|
|
54
|
+
```console
|
|
55
|
+
$ battery-tray --list-batteries # show detected batteries and exit
|
|
56
|
+
$ battery-tray --battery BAT0 # set the monitored battery by name
|
|
57
|
+
$ battery-tray --select-battery # choose interactively from a list
|
|
58
|
+
$ battery-tray --set-poll-seconds 10 # set the refresh interval (1-3600s)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Reads battery state from `/sys/class/power_supply` (Linux only).
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# battery-tray
|
|
2
|
+
|
|
3
|
+
A small Linux system-tray battery monitor, built on
|
|
4
|
+
[sevaht-gui](https://github.com/sevaht/sevaht-gui).
|
|
5
|
+
|
|
6
|
+
- A tray icon shows the selected battery's charge as a colour-graded battery
|
|
7
|
+
glyph (green → red) with charge/charging state in the tooltip.
|
|
8
|
+
- Left-click (or the tray menu's **Configure…**) opens a window to pick which
|
|
9
|
+
battery to monitor and set the refresh interval; both are saved under
|
|
10
|
+
`sevaht/battery-tray`.
|
|
11
|
+
- Desktop notifications (via `notify-send`) fire when the charge level or
|
|
12
|
+
charging state changes.
|
|
13
|
+
- If no system tray is available, the window simply runs on its own.
|
|
14
|
+
|
|
15
|
+
## Running
|
|
16
|
+
|
|
17
|
+
```console
|
|
18
|
+
$ battery-tray # start in the tray (window hidden)
|
|
19
|
+
$ battery-tray --show # start with the window open
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Configuring from the CLI
|
|
23
|
+
|
|
24
|
+
These update the saved config and exit (handy for scripts/keybinds); the
|
|
25
|
+
running app picks the same values up from config:
|
|
26
|
+
|
|
27
|
+
```console
|
|
28
|
+
$ battery-tray --list-batteries # show detected batteries and exit
|
|
29
|
+
$ battery-tray --battery BAT0 # set the monitored battery by name
|
|
30
|
+
$ battery-tray --select-battery # choose interactively from a list
|
|
31
|
+
$ battery-tray --set-poll-seconds 10 # set the refresh interval (1-3600s)
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Reads battery state from `/sys/class/power_supply` (Linux only).
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling (>=1.29.0,<2.0.0)", "hatch-vcs (>=0.5.0,<1.0.0)"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "battery-tray"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "A system-tray battery monitor with a battery-selection window."
|
|
9
|
+
authors = [
|
|
10
|
+
{name = "Jacob McIntosh", email = "nacitar.sevaht@gmail.com"}
|
|
11
|
+
]
|
|
12
|
+
readme = "README.md"
|
|
13
|
+
license = "Unlicense"
|
|
14
|
+
requires-python = ">=3.12"
|
|
15
|
+
keywords = ["battery", "tray", "monitor", "power", "tkinter", "linux"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Intended Audience :: End Users/Desktop",
|
|
19
|
+
"Operating System :: POSIX :: Linux",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Programming Language :: Python :: 3.14",
|
|
25
|
+
"Topic :: Utilities",
|
|
26
|
+
"Typing :: Typed",
|
|
27
|
+
]
|
|
28
|
+
dependencies = [
|
|
29
|
+
"Pillow>=12.2.0",
|
|
30
|
+
"sevaht-gui",
|
|
31
|
+
"platformdirs>=4.10.0",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[project.scripts]
|
|
35
|
+
battery-tray = "battery_tray.__main__:main"
|
|
36
|
+
|
|
37
|
+
[dependency-groups]
|
|
38
|
+
dev = [
|
|
39
|
+
"black (>=26.3.1,<27.0.0)",
|
|
40
|
+
"build (>=1.4.2,<2.0.0)",
|
|
41
|
+
"mypy (>=1.20.0,<2.0.0)",
|
|
42
|
+
"pytest (>=9.0.2,<10.0.0)",
|
|
43
|
+
"ruff (>=0.15.8,<0.16.0)",
|
|
44
|
+
"twine (>=6.2.0,<7.0.0)",
|
|
45
|
+
"vulture (>=2.14,<3.0.0)",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
[project.urls]
|
|
49
|
+
Homepage = "https://github.com/sevaht/battery-tray"
|
|
50
|
+
Source = "https://github.com/sevaht/battery-tray"
|
|
51
|
+
Issues = "https://github.com/sevaht/battery-tray/issues"
|
|
52
|
+
|
|
53
|
+
[tool.hatch.version]
|
|
54
|
+
source = "vcs"
|
|
55
|
+
|
|
56
|
+
[tool.hatch.version.raw-options]
|
|
57
|
+
fallback_version = "0.0.0"
|
|
58
|
+
|
|
59
|
+
[tool.hatch.build.targets.wheel]
|
|
60
|
+
packages = ["src/battery_tray"]
|
|
61
|
+
|
|
62
|
+
[tool.hatch.build.targets.sdist]
|
|
63
|
+
include = ["/src", "/tests", "/LICENSE"]
|
|
64
|
+
|
|
65
|
+
[tool.black]
|
|
66
|
+
line-length = 79
|
|
67
|
+
skip-magic-trailing-comma = true
|
|
68
|
+
|
|
69
|
+
[tool.mypy]
|
|
70
|
+
strict = true
|
|
71
|
+
exclude = ["^docs/", "^dist/"]
|
|
72
|
+
|
|
73
|
+
[tool.pytest.ini_options]
|
|
74
|
+
testpaths = ["tests"]
|
|
75
|
+
|
|
76
|
+
[tool.ruff]
|
|
77
|
+
line-length = 79
|
|
78
|
+
|
|
79
|
+
[tool.ruff.lint]
|
|
80
|
+
extend-ignore = ["COM812", "COM819", "E203", "E501", "TD003"]
|
|
81
|
+
select = [
|
|
82
|
+
"A", "ANN", "ARG", "ASYNC", "B", "BLE", "C4", "C90", "COM",
|
|
83
|
+
"DTZ", "E", "EM", "ERA", "F", "FA", "FLY", "FURB", "I",
|
|
84
|
+
"ISC", "LOG", "N", "PERF", "PGH", "PIE", "PLR", "PLW", "PTH",
|
|
85
|
+
"Q", "RET", "RSE", "RUF", "S", "SIM", "TC", "TCH", "TD",
|
|
86
|
+
"TID", "TRY", "UP"
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
[tool.ruff.lint.per-file-ignores]
|
|
90
|
+
"checks" = ["S603"]
|
|
91
|
+
"tests/**/*" = ["PLR2004", "S101"]
|
|
92
|
+
|
|
93
|
+
[tool.vulture]
|
|
94
|
+
min_confidence = 70
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""A system-tray battery monitor built on sevaht-gui.
|
|
2
|
+
|
|
3
|
+
Attributes:
|
|
4
|
+
__version__: The installed distribution version.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import importlib.metadata
|
|
10
|
+
from functools import cache
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from platformdirs import PlatformDirs
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
__version__ = importlib.metadata.version(__package__)
|
|
19
|
+
|
|
20
|
+
APP_NAME = "battery-tray"
|
|
21
|
+
APP_AUTHOR = "sevaht"
|
|
22
|
+
CONFIG_FILE_NAME = "config.json"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@cache
|
|
26
|
+
def user_config_path() -> Path:
|
|
27
|
+
"""Return the per-user config directory (``<author>/<appname>``)."""
|
|
28
|
+
path = PlatformDirs(APP_NAME, appauthor=APP_AUTHOR).user_config_path
|
|
29
|
+
# platformdirs omits appauthor from the path on non-Windows platforms;
|
|
30
|
+
# insert it so all platforms use <author>/<appname>.
|
|
31
|
+
if path.parent.name != APP_AUTHOR:
|
|
32
|
+
path = path.parent / APP_AUTHOR / APP_NAME
|
|
33
|
+
return path
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Command-line entry point for battery-tray."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import logging
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from . import CONFIG_FILE_NAME, user_config_path
|
|
10
|
+
from .battery import list_batteries, read_battery
|
|
11
|
+
from .config import (
|
|
12
|
+
MAX_POLL_SECONDS,
|
|
13
|
+
MIN_POLL_SECONDS,
|
|
14
|
+
load_config,
|
|
15
|
+
save_config,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from collections.abc import Sequence
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
24
|
+
parser = argparse.ArgumentParser(
|
|
25
|
+
prog="battery-tray",
|
|
26
|
+
description="Monitor battery status from the system tray.",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
config_group = parser.add_argument_group(
|
|
30
|
+
"config mode",
|
|
31
|
+
"Update saved settings and exit."
|
|
32
|
+
" Cannot be combined with run mode arguments.",
|
|
33
|
+
)
|
|
34
|
+
config_group.add_argument(
|
|
35
|
+
"--list-batteries",
|
|
36
|
+
action="store_true",
|
|
37
|
+
help="List available batteries and exit.",
|
|
38
|
+
)
|
|
39
|
+
config_group.add_argument(
|
|
40
|
+
"--battery",
|
|
41
|
+
metavar="NAME",
|
|
42
|
+
help="Save the battery to monitor by name and exit.",
|
|
43
|
+
)
|
|
44
|
+
config_group.add_argument(
|
|
45
|
+
"--select-battery",
|
|
46
|
+
action="store_true",
|
|
47
|
+
help="Interactively choose a battery to monitor, save it, and exit.",
|
|
48
|
+
)
|
|
49
|
+
config_group.add_argument(
|
|
50
|
+
"--set-poll-seconds",
|
|
51
|
+
type=float,
|
|
52
|
+
metavar="SECONDS",
|
|
53
|
+
help="Save the refresh interval"
|
|
54
|
+
f" ({MIN_POLL_SECONDS:g}-{MAX_POLL_SECONDS:g}s) and exit.",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
run_group = parser.add_argument_group(
|
|
58
|
+
"run mode",
|
|
59
|
+
"Start the tray. Cannot be combined with config mode arguments.",
|
|
60
|
+
)
|
|
61
|
+
run_group.add_argument(
|
|
62
|
+
"--show",
|
|
63
|
+
action="store_true",
|
|
64
|
+
help="Show the window at startup instead of starting tray-only.",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
verbosity = parser.add_mutually_exclusive_group()
|
|
68
|
+
verbosity.add_argument(
|
|
69
|
+
"-v", "--verbose", action="store_true", help="Enable debug logging."
|
|
70
|
+
)
|
|
71
|
+
verbosity.add_argument(
|
|
72
|
+
"-q", "--quiet", action="store_true", help="Only log warnings."
|
|
73
|
+
)
|
|
74
|
+
return parser
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _configure_logging(args: argparse.Namespace) -> None:
|
|
78
|
+
level = logging.INFO
|
|
79
|
+
if args.verbose:
|
|
80
|
+
level = logging.DEBUG
|
|
81
|
+
elif args.quiet:
|
|
82
|
+
level = logging.WARNING
|
|
83
|
+
logging.basicConfig(level=level, format="%(levelname)s: %(message)s")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _battery_label(name: str) -> str:
|
|
87
|
+
status = read_battery(name)
|
|
88
|
+
if status is None:
|
|
89
|
+
return f"{name} (unavailable)"
|
|
90
|
+
state = "charging" if status.charging else "discharging"
|
|
91
|
+
return f"{name} - {status.capacity}% ({state})"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _print_batteries() -> int:
|
|
95
|
+
batteries = list_batteries()
|
|
96
|
+
if not batteries:
|
|
97
|
+
print("No batteries detected.")
|
|
98
|
+
return 0
|
|
99
|
+
for name in batteries:
|
|
100
|
+
print(_battery_label(name))
|
|
101
|
+
return 0
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _save_battery(name: str, config_path: Path) -> int:
|
|
105
|
+
config = load_config(config_path)
|
|
106
|
+
config.battery = name
|
|
107
|
+
save_config(config_path, config)
|
|
108
|
+
note = "" if name in list_batteries() else " (not currently detected)"
|
|
109
|
+
print(f"Battery set to {name}{note}.")
|
|
110
|
+
return 0
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _select_and_save_battery(config_path: Path) -> int:
|
|
114
|
+
batteries = list_batteries()
|
|
115
|
+
if not batteries:
|
|
116
|
+
print("No batteries detected.")
|
|
117
|
+
return 1
|
|
118
|
+
print("Available batteries:")
|
|
119
|
+
for menu_number, name in enumerate(batteries, start=1):
|
|
120
|
+
print(f" {menu_number}) {_battery_label(name)}")
|
|
121
|
+
while True:
|
|
122
|
+
try:
|
|
123
|
+
choice = input("Select battery number (blank to cancel): ").strip()
|
|
124
|
+
except (KeyboardInterrupt, EOFError):
|
|
125
|
+
print()
|
|
126
|
+
return 1
|
|
127
|
+
if choice == "":
|
|
128
|
+
print("No battery selected.")
|
|
129
|
+
return 1
|
|
130
|
+
if choice.isdigit() and 1 <= int(choice) <= len(batteries):
|
|
131
|
+
return _save_battery(batteries[int(choice) - 1], config_path)
|
|
132
|
+
print("Invalid selection.")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _save_poll_seconds(
|
|
136
|
+
seconds: float, parser: argparse.ArgumentParser, config_path: Path
|
|
137
|
+
) -> int:
|
|
138
|
+
if not (MIN_POLL_SECONDS <= seconds <= MAX_POLL_SECONDS):
|
|
139
|
+
parser.error(
|
|
140
|
+
"--set-poll-seconds must be between"
|
|
141
|
+
f" {MIN_POLL_SECONDS:g} and {MAX_POLL_SECONDS:g}"
|
|
142
|
+
)
|
|
143
|
+
config = load_config(config_path)
|
|
144
|
+
config.poll_seconds = seconds
|
|
145
|
+
save_config(config_path, config)
|
|
146
|
+
print(f"Refresh interval set to {seconds:g}s.")
|
|
147
|
+
return 0
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _validate_mode_combination(
|
|
151
|
+
args: argparse.Namespace, parser: argparse.ArgumentParser
|
|
152
|
+
) -> None:
|
|
153
|
+
config_flags = [
|
|
154
|
+
label
|
|
155
|
+
for active, label in (
|
|
156
|
+
(args.list_batteries, "--list-batteries"),
|
|
157
|
+
(args.battery is not None, "--battery"),
|
|
158
|
+
(args.select_battery, "--select-battery"),
|
|
159
|
+
(args.set_poll_seconds is not None, "--set-poll-seconds"),
|
|
160
|
+
)
|
|
161
|
+
if active
|
|
162
|
+
]
|
|
163
|
+
if len(config_flags) > 1:
|
|
164
|
+
parser.error(
|
|
165
|
+
f"these arguments cannot be combined: {', '.join(config_flags)}"
|
|
166
|
+
)
|
|
167
|
+
if config_flags and args.show:
|
|
168
|
+
parser.error(
|
|
169
|
+
f"config argument {config_flags[0]} cannot be combined with --show"
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _run_config_mode(
|
|
174
|
+
args: argparse.Namespace,
|
|
175
|
+
parser: argparse.ArgumentParser,
|
|
176
|
+
config_path: Path,
|
|
177
|
+
) -> int | None:
|
|
178
|
+
"""Handle a config-mode argument, or return None if none is active."""
|
|
179
|
+
if args.list_batteries:
|
|
180
|
+
return _print_batteries()
|
|
181
|
+
if args.select_battery:
|
|
182
|
+
return _select_and_save_battery(config_path)
|
|
183
|
+
if args.battery is not None:
|
|
184
|
+
return _save_battery(args.battery, config_path)
|
|
185
|
+
if args.set_poll_seconds is not None:
|
|
186
|
+
return _save_poll_seconds(args.set_poll_seconds, parser, config_path)
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
191
|
+
parser = _build_parser()
|
|
192
|
+
args = parser.parse_args(argv)
|
|
193
|
+
_configure_logging(args)
|
|
194
|
+
|
|
195
|
+
config_path = user_config_path() / CONFIG_FILE_NAME
|
|
196
|
+
_validate_mode_combination(args, parser)
|
|
197
|
+
|
|
198
|
+
config_result = _run_config_mode(args, parser, config_path)
|
|
199
|
+
if config_result is not None:
|
|
200
|
+
return config_result
|
|
201
|
+
|
|
202
|
+
# Imported lazily so config mode does not require a display / sevaht-gui.
|
|
203
|
+
from .app import BatteryTray
|
|
204
|
+
|
|
205
|
+
return BatteryTray(config_path=config_path).run(start_hidden=not args.show)
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Wires the tray icon, selection window, and battery polling together."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import logging
|
|
7
|
+
from tkinter import TclError
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from sevaht_gui import TkApp
|
|
11
|
+
|
|
12
|
+
from . import APP_NAME, CONFIG_FILE_NAME, user_config_path
|
|
13
|
+
from .battery import list_batteries, read_battery, status_lines
|
|
14
|
+
from .config import clamp_poll_seconds, load_config, save_config
|
|
15
|
+
from .render import IconStyle, battery_renderer
|
|
16
|
+
from .window import BatterySelectorWindow
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from PIL import Image
|
|
23
|
+
|
|
24
|
+
from .battery import BatteryStatus
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
# Charge buckets (every 5%) at which the icon is re-rendered; finer changes do
|
|
29
|
+
# not visibly alter the icon.
|
|
30
|
+
_ICON_BUCKET = 5
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class BatteryTray:
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
config_path: Path | None = None,
|
|
37
|
+
*,
|
|
38
|
+
style: IconStyle | None = None,
|
|
39
|
+
) -> None:
|
|
40
|
+
self.config_path = config_path or user_config_path() / CONFIG_FILE_NAME
|
|
41
|
+
self.style = style or IconStyle()
|
|
42
|
+
self._config = load_config(self.config_path)
|
|
43
|
+
self.poll_ms = int(self._config.poll_seconds * 1000)
|
|
44
|
+
|
|
45
|
+
self._batteries = list_batteries()
|
|
46
|
+
self._selected = self._initial_selection()
|
|
47
|
+
self._status: BatteryStatus | None = (
|
|
48
|
+
read_battery(self._selected) if self._selected else None
|
|
49
|
+
)
|
|
50
|
+
self._icon_key = self._current_icon_key()
|
|
51
|
+
self._notified_level = self._status.level if self._status else None
|
|
52
|
+
self._notified_charging = (
|
|
53
|
+
self._status.charging if self._status else None
|
|
54
|
+
)
|
|
55
|
+
self._poll_after: str | None = None
|
|
56
|
+
|
|
57
|
+
# TkApp defaults handle theme, centering/position, quit confirmation,
|
|
58
|
+
# the window icon (matched to the tray icon), and window-close behavior.
|
|
59
|
+
self.app = TkApp()
|
|
60
|
+
self.tray_icon = self.app.create_tray_icon(
|
|
61
|
+
APP_NAME,
|
|
62
|
+
self._tooltip(),
|
|
63
|
+
self._render(),
|
|
64
|
+
activate_label="Configure...",
|
|
65
|
+
)
|
|
66
|
+
self.window = BatterySelectorWindow(
|
|
67
|
+
self.app,
|
|
68
|
+
on_select=self._on_select,
|
|
69
|
+
on_poll_change=self._on_poll_change,
|
|
70
|
+
has_tray=self.app.has_tray,
|
|
71
|
+
)
|
|
72
|
+
self.window.set_batteries(self._batteries, self._selected)
|
|
73
|
+
self.window.set_poll_seconds(self._config.poll_seconds)
|
|
74
|
+
self.window.set_status(self._status)
|
|
75
|
+
|
|
76
|
+
def _initial_selection(self) -> str | None:
|
|
77
|
+
saved = self._config.battery
|
|
78
|
+
if saved is not None and saved in self._batteries:
|
|
79
|
+
return saved
|
|
80
|
+
return self._batteries[0] if self._batteries else None
|
|
81
|
+
|
|
82
|
+
def _current_icon_key(self) -> tuple[int, bool] | None:
|
|
83
|
+
if self._status is None:
|
|
84
|
+
return None
|
|
85
|
+
return (self._status.capacity // _ICON_BUCKET, self._status.charging)
|
|
86
|
+
|
|
87
|
+
def _render(self) -> Callable[[int], Image.Image]:
|
|
88
|
+
percent = self._status.capacity if self._status else 0
|
|
89
|
+
connected = self._status.charging if self._status else False
|
|
90
|
+
return battery_renderer(percent, connected=connected, style=self.style)
|
|
91
|
+
|
|
92
|
+
def _tooltip(self) -> str:
|
|
93
|
+
if self._status is None:
|
|
94
|
+
return "Battery Tray\nNo battery"
|
|
95
|
+
# First line is the header; the rest render as the tooltip body
|
|
96
|
+
# (multiline where the tray host supports it).
|
|
97
|
+
return f"{self._status.name}\n" + "\n".join(status_lines(self._status))
|
|
98
|
+
|
|
99
|
+
def _on_select(self, name: str) -> None:
|
|
100
|
+
self._selected = name
|
|
101
|
+
self._config.battery = name
|
|
102
|
+
save_config(self.config_path, self._config)
|
|
103
|
+
self._refresh()
|
|
104
|
+
|
|
105
|
+
def _on_poll_change(self, seconds: float) -> None:
|
|
106
|
+
seconds = clamp_poll_seconds(seconds)
|
|
107
|
+
self.poll_ms = int(seconds * 1000)
|
|
108
|
+
self._config.poll_seconds = seconds
|
|
109
|
+
save_config(self.config_path, self._config)
|
|
110
|
+
self._schedule_poll()
|
|
111
|
+
|
|
112
|
+
def _refresh(self) -> None:
|
|
113
|
+
# Runs on the UI thread (poll timer or a window callback).
|
|
114
|
+
batteries = list_batteries()
|
|
115
|
+
if batteries != self._batteries:
|
|
116
|
+
self._batteries = batteries
|
|
117
|
+
if self._selected not in batteries:
|
|
118
|
+
self._selected = batteries[0] if batteries else None
|
|
119
|
+
self.window.set_batteries(batteries, self._selected)
|
|
120
|
+
self._status = read_battery(self._selected) if self._selected else None
|
|
121
|
+
self._apply_status()
|
|
122
|
+
self.window.set_status(self._status)
|
|
123
|
+
self._maybe_notify()
|
|
124
|
+
|
|
125
|
+
def _apply_status(self) -> None:
|
|
126
|
+
# Push the current status to the tray tooltip and to both the tray and
|
|
127
|
+
# window icons (set_app_icon keeps them in sync).
|
|
128
|
+
if self.tray_icon is not None:
|
|
129
|
+
self.tray_icon.title = self._tooltip()
|
|
130
|
+
key = self._current_icon_key()
|
|
131
|
+
if key != self._icon_key:
|
|
132
|
+
self.app.set_app_icon(self._render())
|
|
133
|
+
self._icon_key = key
|
|
134
|
+
|
|
135
|
+
def _maybe_notify(self) -> None:
|
|
136
|
+
status = self._status
|
|
137
|
+
if status is None:
|
|
138
|
+
return
|
|
139
|
+
if (
|
|
140
|
+
status.level == self._notified_level
|
|
141
|
+
and status.charging == self._notified_charging
|
|
142
|
+
):
|
|
143
|
+
return
|
|
144
|
+
# app.notify uses the tray when present and falls back to a standalone
|
|
145
|
+
# notifier otherwise -- no need to handle the no-tray case here.
|
|
146
|
+
self.app.notify(
|
|
147
|
+
f"Battery - {status.level.name.title()}",
|
|
148
|
+
"\n".join(status_lines(status)),
|
|
149
|
+
icon=status.level.value,
|
|
150
|
+
)
|
|
151
|
+
self._notified_level = status.level
|
|
152
|
+
self._notified_charging = status.charging
|
|
153
|
+
|
|
154
|
+
def _schedule_poll(self) -> None:
|
|
155
|
+
if self._poll_after is not None:
|
|
156
|
+
with contextlib.suppress(TclError):
|
|
157
|
+
self.app.root.after_cancel(self._poll_after)
|
|
158
|
+
self._poll_after = self.app.root.after(self.poll_ms, self._poll)
|
|
159
|
+
|
|
160
|
+
def _poll(self) -> None:
|
|
161
|
+
self._refresh()
|
|
162
|
+
self._poll_after = self.app.root.after(self.poll_ms, self._poll)
|
|
163
|
+
|
|
164
|
+
def run(self, *, start_hidden: bool = True) -> int:
|
|
165
|
+
self._schedule_poll()
|
|
166
|
+
self.app.run(self.tray_icon, start_hidden=start_hidden)
|
|
167
|
+
return 0
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Reading battery state from the Linux ``/sys/class/power_supply`` tree."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import timedelta
|
|
8
|
+
from enum import StrEnum
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
POWER_SUPPLY_ROOT = Path("/sys/class/power_supply")
|
|
14
|
+
|
|
15
|
+
# Above this charge, a battery reporting "Not charging" (e.g. a charge-limit
|
|
16
|
+
# threshold reached) is treated as on AC rather than discharging.
|
|
17
|
+
_NOT_CHARGING_FULL_PERCENT = 75
|
|
18
|
+
|
|
19
|
+
# Lower bounds (exclusive) for each coarse charge level.
|
|
20
|
+
_HIGH_MIN_PERCENT = 90
|
|
21
|
+
_GOOD_MIN_PERCENT = 50
|
|
22
|
+
_LOW_MIN_PERCENT = 10
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class BatteryLevel(StrEnum):
|
|
26
|
+
"""Coarse charge level, mapped to a freedesktop icon name for notices."""
|
|
27
|
+
|
|
28
|
+
HIGH = "battery-full"
|
|
29
|
+
GOOD = "battery-good"
|
|
30
|
+
LOW = "battery-low"
|
|
31
|
+
CAUTION = "battery-caution"
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def for_capacity(cls, capacity: int) -> BatteryLevel:
|
|
35
|
+
if capacity > _HIGH_MIN_PERCENT:
|
|
36
|
+
return cls.HIGH
|
|
37
|
+
if capacity > _GOOD_MIN_PERCENT:
|
|
38
|
+
return cls.GOOD
|
|
39
|
+
if capacity > _LOW_MIN_PERCENT:
|
|
40
|
+
return cls.LOW
|
|
41
|
+
return cls.CAUTION
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True, kw_only=True)
|
|
45
|
+
class BatteryStatus:
|
|
46
|
+
name: str
|
|
47
|
+
capacity: int
|
|
48
|
+
charging: bool
|
|
49
|
+
status: str
|
|
50
|
+
remaining: timedelta
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def level(self) -> BatteryLevel:
|
|
54
|
+
return BatteryLevel.for_capacity(self.capacity)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _clamp_percent(percent: int) -> int:
|
|
58
|
+
return max(0, min(percent, 100))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _read_text(path: Path) -> str | None:
|
|
62
|
+
try:
|
|
63
|
+
return path.read_text(encoding="utf-8").strip()
|
|
64
|
+
except OSError:
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _read_int(path: Path) -> int | None:
|
|
69
|
+
text = _read_text(path)
|
|
70
|
+
if text is None:
|
|
71
|
+
return None
|
|
72
|
+
try:
|
|
73
|
+
return int(text)
|
|
74
|
+
except ValueError:
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def list_batteries(root: Path = POWER_SUPPLY_ROOT) -> list[str]:
|
|
79
|
+
"""Return the names of power supplies of type ``Battery``, sorted."""
|
|
80
|
+
if not root.is_dir():
|
|
81
|
+
return []
|
|
82
|
+
return [
|
|
83
|
+
entry.name
|
|
84
|
+
for entry in sorted(root.iterdir())
|
|
85
|
+
if _read_text(entry / "type") == "Battery"
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _time_remaining(base: Path) -> timedelta:
|
|
90
|
+
# Prefer energy/power (µWh / µW); fall back to charge/current (µAh / µA).
|
|
91
|
+
for amount_name, rate_name in (
|
|
92
|
+
("energy_now", "power_now"),
|
|
93
|
+
("charge_now", "current_now"),
|
|
94
|
+
):
|
|
95
|
+
amount = _read_int(base / amount_name)
|
|
96
|
+
rate = _read_int(base / rate_name)
|
|
97
|
+
if amount is not None and rate:
|
|
98
|
+
return timedelta(hours=amount / rate)
|
|
99
|
+
return timedelta(0)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def read_battery(
|
|
103
|
+
name: str, *, root: Path = POWER_SUPPLY_ROOT
|
|
104
|
+
) -> BatteryStatus | None:
|
|
105
|
+
"""Read the current status of battery ``name``, or None if unavailable."""
|
|
106
|
+
base = root / name
|
|
107
|
+
capacity = _read_int(base / "capacity")
|
|
108
|
+
if capacity is None:
|
|
109
|
+
return None
|
|
110
|
+
capacity = _clamp_percent(capacity)
|
|
111
|
+
status = _read_text(base / "status") or "Unknown"
|
|
112
|
+
charging = status in {"Charging", "Full"} or (
|
|
113
|
+
status == "Not charging" and capacity > _NOT_CHARGING_FULL_PERCENT
|
|
114
|
+
)
|
|
115
|
+
# Truncate to whole minutes: it is the granularity we display and makes
|
|
116
|
+
# change-detection stable across polls.
|
|
117
|
+
remaining = timedelta(minutes=_time_remaining(base).total_seconds() // 60)
|
|
118
|
+
return BatteryStatus(
|
|
119
|
+
name=name,
|
|
120
|
+
capacity=capacity,
|
|
121
|
+
charging=charging,
|
|
122
|
+
status=status,
|
|
123
|
+
remaining=remaining,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def status_lines(status: BatteryStatus) -> list[str]:
|
|
128
|
+
"""Human-readable status lines (tooltip / notification body)."""
|
|
129
|
+
lines = [
|
|
130
|
+
f"Charge: {status.capacity}%",
|
|
131
|
+
f"Power: {'' if status.charging else 'dis'}connected",
|
|
132
|
+
]
|
|
133
|
+
total_minutes = int(status.remaining.total_seconds() // 60)
|
|
134
|
+
if total_minutes:
|
|
135
|
+
hours, minutes = divmod(total_minutes, 60)
|
|
136
|
+
lines.append(f"Life: {f'{hours}h ' if hours else ''}{minutes}m")
|
|
137
|
+
return lines
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Persisting battery-tray settings to a small JSON config file."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
DEFAULT_POLL_SECONDS = 5.0
|
|
16
|
+
MIN_POLL_SECONDS = 1.0
|
|
17
|
+
MAX_POLL_SECONDS = 3600.0
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def clamp_poll_seconds(seconds: float) -> float:
|
|
21
|
+
return max(MIN_POLL_SECONDS, min(seconds, MAX_POLL_SECONDS))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Config:
|
|
26
|
+
battery: str | None = None
|
|
27
|
+
poll_seconds: float = DEFAULT_POLL_SECONDS
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def load_config(config_path: Path) -> Config:
|
|
31
|
+
"""Load settings, falling back to defaults for anything missing/invalid."""
|
|
32
|
+
try:
|
|
33
|
+
data = json.loads(config_path.read_text(encoding="utf-8"))
|
|
34
|
+
except (OSError, ValueError):
|
|
35
|
+
return Config()
|
|
36
|
+
if not isinstance(data, dict):
|
|
37
|
+
return Config()
|
|
38
|
+
battery = data.get("battery")
|
|
39
|
+
if not isinstance(battery, str):
|
|
40
|
+
battery = None
|
|
41
|
+
poll = data.get("poll_seconds")
|
|
42
|
+
poll_seconds = (
|
|
43
|
+
clamp_poll_seconds(float(poll))
|
|
44
|
+
if isinstance(poll, (int, float)) and not isinstance(poll, bool)
|
|
45
|
+
else DEFAULT_POLL_SECONDS
|
|
46
|
+
)
|
|
47
|
+
return Config(battery=battery, poll_seconds=poll_seconds)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def save_config(config_path: Path, config: Config) -> None:
|
|
51
|
+
"""Persist settings, creating the config directory if needed."""
|
|
52
|
+
try:
|
|
53
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
config_path.write_text(
|
|
55
|
+
json.dumps(
|
|
56
|
+
{
|
|
57
|
+
"battery": config.battery,
|
|
58
|
+
"poll_seconds": config.poll_seconds,
|
|
59
|
+
},
|
|
60
|
+
indent=2,
|
|
61
|
+
),
|
|
62
|
+
encoding="utf-8",
|
|
63
|
+
)
|
|
64
|
+
except OSError:
|
|
65
|
+
logger.warning("Could not save config", exc_info=True)
|
|
File without changes
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Draws the battery tray/window icon with PIL.
|
|
2
|
+
|
|
3
|
+
The icon is a battery outline (optionally with a nub) filled from the bottom in
|
|
4
|
+
proportion to the charge, colored on a green-to-red gradient. It renders at any
|
|
5
|
+
requested square size so the tray host can scale it cleanly.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import colorsys
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
from PIL import Image, ImageDraw
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
|
|
19
|
+
_FULL_PERCENT = 100
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _clamp_percent(percent: int) -> int:
|
|
23
|
+
return max(0, min(percent, _FULL_PERCENT))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def power_gradient_color(percent: int) -> tuple[int, int, int]:
|
|
27
|
+
"""Green (100%) to red (0%) through yellow/orange, as an RGB tuple."""
|
|
28
|
+
percent = _clamp_percent(percent)
|
|
29
|
+
# Hue 120deg (green) at full, 0deg (red) at empty.
|
|
30
|
+
hue = (percent / _FULL_PERCENT) * 120 / 360
|
|
31
|
+
red, green, blue = colorsys.hsv_to_rgb(hue, 1.0, 1.0)
|
|
32
|
+
return (int(red * 255), int(green * 255), int(blue * 255))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(kw_only=True)
|
|
36
|
+
class IconStyle:
|
|
37
|
+
show_nub: bool = True
|
|
38
|
+
rounded: bool = True
|
|
39
|
+
connected_border_color: str = "rgb(211,215,207)"
|
|
40
|
+
disconnected_border_color: str = "rgb(255,255,0)"
|
|
41
|
+
gap_units: int = 1
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(kw_only=True)
|
|
45
|
+
class _Rectangle:
|
|
46
|
+
left: int = 0
|
|
47
|
+
top: int = 0
|
|
48
|
+
right: int = 0
|
|
49
|
+
bottom: int = 0
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def components(self) -> list[int]:
|
|
53
|
+
return [self.left, self.top, self.right - 1, self.bottom - 1]
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def height(self) -> int:
|
|
57
|
+
return abs(self.bottom - self.top)
|
|
58
|
+
|
|
59
|
+
def offset(self, *, top: int = 0) -> _Rectangle:
|
|
60
|
+
return _Rectangle(
|
|
61
|
+
left=self.left,
|
|
62
|
+
top=self.top + top,
|
|
63
|
+
right=self.right,
|
|
64
|
+
bottom=self.bottom,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
def offset_edges(self, amount: int) -> _Rectangle:
|
|
68
|
+
return _Rectangle(
|
|
69
|
+
left=self.left + amount,
|
|
70
|
+
top=self.top + amount,
|
|
71
|
+
right=self.right - amount,
|
|
72
|
+
bottom=self.bottom - amount,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def draw(
|
|
76
|
+
self,
|
|
77
|
+
draw: ImageDraw.ImageDraw,
|
|
78
|
+
*,
|
|
79
|
+
radius: int = 0,
|
|
80
|
+
width: int = 1,
|
|
81
|
+
fill: str | tuple[int, int, int] | None = None,
|
|
82
|
+
outline: str | tuple[int, int, int] | None = None,
|
|
83
|
+
) -> None:
|
|
84
|
+
# Skip empty or inverted rectangles (can occur at degenerate sizes).
|
|
85
|
+
if self.right <= self.left or self.bottom <= self.top:
|
|
86
|
+
return
|
|
87
|
+
if radius:
|
|
88
|
+
draw.rounded_rectangle(
|
|
89
|
+
self.components,
|
|
90
|
+
radius=radius,
|
|
91
|
+
fill=fill,
|
|
92
|
+
outline=outline,
|
|
93
|
+
width=width,
|
|
94
|
+
)
|
|
95
|
+
else:
|
|
96
|
+
draw.rectangle(
|
|
97
|
+
self.components, fill=fill, outline=outline, width=width
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def render_battery(
|
|
102
|
+
size: int, percent: int, *, connected: bool, style: IconStyle
|
|
103
|
+
) -> Image.Image:
|
|
104
|
+
"""Render the battery icon at ``size`` x ``size`` pixels."""
|
|
105
|
+
size = max(1, size)
|
|
106
|
+
percent = _clamp_percent(percent)
|
|
107
|
+
image = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
|
108
|
+
draw = ImageDraw.Draw(image)
|
|
109
|
+
|
|
110
|
+
unit = max(1, size // 16)
|
|
111
|
+
border_width = unit * 2
|
|
112
|
+
body_width = (size * 3) // 4
|
|
113
|
+
padding = (size - body_width) // 2
|
|
114
|
+
border_color = (
|
|
115
|
+
style.connected_border_color
|
|
116
|
+
if connected
|
|
117
|
+
else style.disconnected_border_color
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
border = _Rectangle(
|
|
121
|
+
left=padding, right=padding + body_width, top=0, bottom=size
|
|
122
|
+
)
|
|
123
|
+
if style.show_nub:
|
|
124
|
+
nub_width = border_width * 2
|
|
125
|
+
nub_padding = (size - nub_width) // 2
|
|
126
|
+
nub = _Rectangle(
|
|
127
|
+
left=nub_padding,
|
|
128
|
+
right=nub_padding + nub_width,
|
|
129
|
+
top=0,
|
|
130
|
+
bottom=border_width
|
|
131
|
+
* 3
|
|
132
|
+
// 2, # overlaps the body border on purpose
|
|
133
|
+
)
|
|
134
|
+
nub.draw(
|
|
135
|
+
draw,
|
|
136
|
+
fill=border_color,
|
|
137
|
+
radius=border_width // 2 if style.rounded else 0,
|
|
138
|
+
)
|
|
139
|
+
border = border.offset(top=border_width)
|
|
140
|
+
|
|
141
|
+
border.draw(
|
|
142
|
+
draw,
|
|
143
|
+
outline=border_color,
|
|
144
|
+
width=border_width,
|
|
145
|
+
radius=border_width * 3 // 2 if style.rounded else 0,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
inner = border.offset_edges(border_width + style.gap_units * unit)
|
|
149
|
+
inner.top += inner.height * (_FULL_PERCENT - percent) // _FULL_PERCENT
|
|
150
|
+
inner.draw(draw, fill=power_gradient_color(percent))
|
|
151
|
+
return image
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def battery_renderer(
|
|
155
|
+
percent: int, *, connected: bool, style: IconStyle
|
|
156
|
+
) -> Callable[[int], Image.Image]:
|
|
157
|
+
"""Return an icon renderer (``size -> image``) for a fixed battery state."""
|
|
158
|
+
|
|
159
|
+
def render(size: int) -> Image.Image:
|
|
160
|
+
return render_battery(size, percent, connected=connected, style=style)
|
|
161
|
+
|
|
162
|
+
return render
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""The battery-selection window."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import tkinter as tk
|
|
6
|
+
from tkinter import ttk
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from sevaht_gui import LabelGrooveFrame
|
|
10
|
+
|
|
11
|
+
from .battery import status_lines
|
|
12
|
+
from .config import MAX_POLL_SECONDS, MIN_POLL_SECONDS
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
|
|
17
|
+
from sevaht_gui import TkApp
|
|
18
|
+
|
|
19
|
+
from .battery import BatteryStatus
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class BatterySelectorWindow:
|
|
23
|
+
"""Lets the user pick which battery to monitor and shows its status.
|
|
24
|
+
|
|
25
|
+
Built on the app's root window; the app drives :meth:`set_batteries` /
|
|
26
|
+
:meth:`set_status` from its poll, and :meth:`show` from the tray.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
app: TkApp,
|
|
32
|
+
*,
|
|
33
|
+
on_select: Callable[[str], None],
|
|
34
|
+
on_poll_change: Callable[[float], None],
|
|
35
|
+
has_tray: bool,
|
|
36
|
+
) -> None:
|
|
37
|
+
self.app = app
|
|
38
|
+
self.root = app.root
|
|
39
|
+
self._on_select = on_select
|
|
40
|
+
self._on_poll_change = on_poll_change
|
|
41
|
+
self._has_tray = has_tray
|
|
42
|
+
self._battery_var = tk.StringVar(master=self.root)
|
|
43
|
+
self._poll_var = tk.StringVar(master=self.root)
|
|
44
|
+
self._build_ui()
|
|
45
|
+
|
|
46
|
+
def _build_ui(self) -> None:
|
|
47
|
+
self.root.title("Battery Tray")
|
|
48
|
+
self.root.resizable(False, False)
|
|
49
|
+
self.root.minsize(260, 0)
|
|
50
|
+
|
|
51
|
+
content = ttk.Frame(self.root, padding=16)
|
|
52
|
+
content.pack(fill=tk.BOTH, expand=True)
|
|
53
|
+
|
|
54
|
+
box = LabelGrooveFrame(content, text="Battery")
|
|
55
|
+
box.pack(fill=tk.X)
|
|
56
|
+
|
|
57
|
+
self._combo = ttk.Combobox(
|
|
58
|
+
box.interior,
|
|
59
|
+
textvariable=self._battery_var,
|
|
60
|
+
state="readonly",
|
|
61
|
+
width=24,
|
|
62
|
+
)
|
|
63
|
+
self._combo.pack(fill=tk.X, padx=6, pady=(0, 8))
|
|
64
|
+
self._combo.bind("<<ComboboxSelected>>", self._on_combo_selected)
|
|
65
|
+
|
|
66
|
+
self._status_label = ttk.Label(box.interior, justify=tk.LEFT, text="")
|
|
67
|
+
self._status_label.pack(anchor="w", padx=6, pady=(0, 4))
|
|
68
|
+
|
|
69
|
+
settings = LabelGrooveFrame(content, text="Settings")
|
|
70
|
+
settings.pack(fill=tk.X, pady=(8, 0))
|
|
71
|
+
ttk.Label(settings.interior, text="Refresh every (seconds):").pack(
|
|
72
|
+
side=tk.LEFT, padx=(6, 6)
|
|
73
|
+
)
|
|
74
|
+
poll_spinbox = ttk.Spinbox(
|
|
75
|
+
settings.interior,
|
|
76
|
+
textvariable=self._poll_var,
|
|
77
|
+
from_=MIN_POLL_SECONDS,
|
|
78
|
+
to=MAX_POLL_SECONDS,
|
|
79
|
+
increment=1,
|
|
80
|
+
width=7,
|
|
81
|
+
command=self._on_poll_committed,
|
|
82
|
+
)
|
|
83
|
+
poll_spinbox.pack(side=tk.LEFT, padx=(0, 6))
|
|
84
|
+
# Commit on spin (command), on Enter, and on leaving the field.
|
|
85
|
+
poll_spinbox.bind("<Return>", self._on_poll_committed)
|
|
86
|
+
poll_spinbox.bind("<FocusOut>", self._on_poll_committed)
|
|
87
|
+
|
|
88
|
+
buttons = ttk.Frame(content)
|
|
89
|
+
buttons.pack(fill=tk.X, pady=(12, 0))
|
|
90
|
+
# The window-close (X) is handled by TkApp: hide when a tray houses the
|
|
91
|
+
# window, quit otherwise.
|
|
92
|
+
if self._has_tray:
|
|
93
|
+
ttk.Button(buttons, text="Hide", command=self.app.hide).pack(
|
|
94
|
+
side=tk.RIGHT
|
|
95
|
+
)
|
|
96
|
+
ttk.Button(buttons, text="Quit", command=self.app.quit).pack(
|
|
97
|
+
side=tk.RIGHT, padx=(0, 6) if self._has_tray else (0, 0)
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def _on_combo_selected(self, _event: tk.Event[ttk.Combobox]) -> None:
|
|
101
|
+
name = self._battery_var.get()
|
|
102
|
+
if name:
|
|
103
|
+
self._on_select(name)
|
|
104
|
+
|
|
105
|
+
def _on_poll_committed(
|
|
106
|
+
self, _event: tk.Event[ttk.Spinbox] | None = None
|
|
107
|
+
) -> None:
|
|
108
|
+
try:
|
|
109
|
+
seconds = float(self._poll_var.get())
|
|
110
|
+
except ValueError:
|
|
111
|
+
return # leave the field as-is so the user can correct it
|
|
112
|
+
seconds = max(MIN_POLL_SECONDS, min(seconds, MAX_POLL_SECONDS))
|
|
113
|
+
self.set_poll_seconds(seconds)
|
|
114
|
+
self._on_poll_change(seconds)
|
|
115
|
+
|
|
116
|
+
def set_poll_seconds(self, seconds: float) -> None:
|
|
117
|
+
"""Show the current refresh interval (UI thread)."""
|
|
118
|
+
self._poll_var.set(f"{seconds:g}")
|
|
119
|
+
|
|
120
|
+
def set_batteries(self, names: list[str], selected: str | None) -> None:
|
|
121
|
+
"""Update the battery choices and the current selection (UI thread)."""
|
|
122
|
+
self._combo["values"] = names
|
|
123
|
+
if selected is not None and selected in names:
|
|
124
|
+
self._battery_var.set(selected)
|
|
125
|
+
else:
|
|
126
|
+
self._battery_var.set(names[0] if names else "")
|
|
127
|
+
|
|
128
|
+
def set_status(self, status: BatteryStatus | None) -> None:
|
|
129
|
+
"""Update the shown status for the selected battery (UI thread)."""
|
|
130
|
+
if status is None:
|
|
131
|
+
self.root.title("Battery Tray - No battery")
|
|
132
|
+
self._status_label.configure(text="No battery detected.")
|
|
133
|
+
return
|
|
134
|
+
charge_state = "charging" if status.charging else "discharging"
|
|
135
|
+
self.root.title(f"Battery Tray - {status.capacity}% ({charge_state})")
|
|
136
|
+
self._status_label.configure(text="\n".join(status_lines(status)))
|
|
File without changes
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from battery_tray.battery import (
|
|
6
|
+
BatteryLevel,
|
|
7
|
+
list_batteries,
|
|
8
|
+
read_battery,
|
|
9
|
+
status_lines,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _make_supply(root: Path, name: str, **files: str) -> None:
|
|
17
|
+
supply = root / name
|
|
18
|
+
supply.mkdir(parents=True)
|
|
19
|
+
for key, value in files.items():
|
|
20
|
+
(supply / key).write_text(value, encoding="utf-8")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_list_batteries_filters_non_batteries(tmp_path: Path) -> None:
|
|
24
|
+
_make_supply(tmp_path, "AC", type="Mains")
|
|
25
|
+
_make_supply(tmp_path, "BAT1", type="Battery", capacity="50")
|
|
26
|
+
_make_supply(tmp_path, "BAT0", type="Battery", capacity="80")
|
|
27
|
+
assert list_batteries(tmp_path) == ["BAT0", "BAT1"]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_list_batteries_missing_root(tmp_path: Path) -> None:
|
|
31
|
+
assert list_batteries(tmp_path / "nope") == []
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_read_battery_discharging_with_remaining(tmp_path: Path) -> None:
|
|
35
|
+
_make_supply(
|
|
36
|
+
tmp_path,
|
|
37
|
+
"BAT0",
|
|
38
|
+
type="Battery",
|
|
39
|
+
capacity="80",
|
|
40
|
+
status="Discharging",
|
|
41
|
+
energy_now="50000000",
|
|
42
|
+
power_now="10000000", # 5 hours remaining
|
|
43
|
+
)
|
|
44
|
+
status = read_battery("BAT0", root=tmp_path)
|
|
45
|
+
assert status is not None
|
|
46
|
+
assert status.capacity == 80
|
|
47
|
+
assert status.charging is False
|
|
48
|
+
assert status.remaining.total_seconds() == 5 * 3600
|
|
49
|
+
assert status.level is BatteryLevel.GOOD
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_read_battery_charging(tmp_path: Path) -> None:
|
|
53
|
+
_make_supply(
|
|
54
|
+
tmp_path, "BAT0", type="Battery", capacity="95", status="Charging"
|
|
55
|
+
)
|
|
56
|
+
status = read_battery("BAT0", root=tmp_path)
|
|
57
|
+
assert status is not None
|
|
58
|
+
assert status.charging is True
|
|
59
|
+
assert status.level is BatteryLevel.HIGH
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_read_battery_missing(tmp_path: Path) -> None:
|
|
63
|
+
assert read_battery("BAT9", root=tmp_path) is None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_status_lines(tmp_path: Path) -> None:
|
|
67
|
+
_make_supply(
|
|
68
|
+
tmp_path,
|
|
69
|
+
"BAT0",
|
|
70
|
+
type="Battery",
|
|
71
|
+
capacity="42",
|
|
72
|
+
status="Discharging",
|
|
73
|
+
charge_now="4200000",
|
|
74
|
+
current_now="2100000", # 2 hours
|
|
75
|
+
)
|
|
76
|
+
status = read_battery("BAT0", root=tmp_path)
|
|
77
|
+
assert status is not None
|
|
78
|
+
lines = status_lines(status)
|
|
79
|
+
assert lines[0] == "Charge: 42%"
|
|
80
|
+
assert "disconnected" in lines[1]
|
|
81
|
+
assert lines[2] == "Life: 2h 0m"
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from battery_tray.config import (
|
|
6
|
+
DEFAULT_POLL_SECONDS,
|
|
7
|
+
MAX_POLL_SECONDS,
|
|
8
|
+
Config,
|
|
9
|
+
load_config,
|
|
10
|
+
save_config,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_load_missing_returns_defaults(tmp_path: Path) -> None:
|
|
18
|
+
config = load_config(tmp_path / "config.json")
|
|
19
|
+
assert config.battery is None
|
|
20
|
+
assert config.poll_seconds == DEFAULT_POLL_SECONDS
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_save_then_load_roundtrip(tmp_path: Path) -> None:
|
|
24
|
+
# Parent dir does not exist yet; save should create it.
|
|
25
|
+
path = tmp_path / "sevaht" / "battery-tray" / "config.json"
|
|
26
|
+
save_config(path, Config(battery="BAT0", poll_seconds=10.0))
|
|
27
|
+
loaded = load_config(path)
|
|
28
|
+
assert loaded.battery == "BAT0"
|
|
29
|
+
assert loaded.poll_seconds == 10.0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_load_ignores_garbage(tmp_path: Path) -> None:
|
|
33
|
+
path = tmp_path / "config.json"
|
|
34
|
+
path.write_text("not json", encoding="utf-8")
|
|
35
|
+
assert load_config(path).battery is None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_poll_seconds_clamped_on_load(tmp_path: Path) -> None:
|
|
39
|
+
path = tmp_path / "config.json"
|
|
40
|
+
save_config(path, Config(poll_seconds=99999.0))
|
|
41
|
+
assert load_config(path).poll_seconds == MAX_POLL_SECONDS
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from battery_tray.render import IconStyle, power_gradient_color, render_battery
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_gradient_endpoints() -> None:
|
|
5
|
+
assert power_gradient_color(100) == (0, 255, 0) # full -> green
|
|
6
|
+
assert power_gradient_color(0) == (255, 0, 0) # empty -> red
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_gradient_clamps_out_of_range() -> None:
|
|
10
|
+
assert power_gradient_color(150) == power_gradient_color(100)
|
|
11
|
+
assert power_gradient_color(-10) == power_gradient_color(0)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_render_battery_size_and_mode() -> None:
|
|
15
|
+
image = render_battery(48, 50, connected=True, style=IconStyle())
|
|
16
|
+
assert image.size == (48, 48)
|
|
17
|
+
assert image.mode == "RGBA"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_render_battery_tiny_size_does_not_crash() -> None:
|
|
21
|
+
image = render_battery(1, 0, connected=False, style=IconStyle())
|
|
22
|
+
assert image.size == (1, 1)
|