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.
@@ -0,0 +1,10 @@
1
+ __pycache__/
2
+ .ipynb_checkpoints/
3
+ .mypy_cache/
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ .venv/
7
+ build/
8
+ dist/
9
+ docs/_build/
10
+ .claude/settings.local.json
@@ -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,5 @@
1
+ from battery_tray import __version__
2
+
3
+
4
+ def test_version_defined() -> None:
5
+ assert bool(__version__)
@@ -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)