From 907476d664f87c837193c8b85daa71ef96d75de7 Mon Sep 17 00:00:00 2001 From: Tomasz Kramkowski Date: Wed, 17 May 2023 17:57:03 +0100 Subject: Initial release 1.0.0 --- .gitignore | 3 ++ LICENSE | 20 +++++++ README.md | 57 ++++++++++++++++++++ brightness.1.scd | 71 +++++++++++++++++++++++++ brightness/__init__.py | 85 ++++++++++++++++++++++++++++++ pyproject.toml | 15 ++++++ tests/__init__.py | 0 tests/test_cli.py | 140 +++++++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 391 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 brightness.1.scd create mode 100644 brightness/__init__.py create mode 100644 pyproject.toml create mode 100644 tests/__init__.py create mode 100644 tests/test_cli.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ba178b6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +/dist/ +/brightness.1 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..062ef55 --- /dev/null +++ b/LICENSE @@ -0,0 +1,20 @@ +Copyright 2023 Tomasz Kramkowski + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a187f0b --- /dev/null +++ b/README.md @@ -0,0 +1,57 @@ +# brightness + +A basic logarithmic scale sysfs brightness adjuster + +# Usage + +Increase by 10%: + +```shell +# brightness /sys/class/backlight/your_backlight +10 +``` + +Decrease by 6.25%: + +```shell +# brightness /sys/class/backlight/your_backlight -6.25 +``` + +Set to 50%: + +```shell +# brightness /sys/class/backlight/your_backlight 50 +``` + +For more information, read the brightness(1) man page. + +# Building and Installation + +While the package itself has no dependencies outside of python 3.4, the build +and installation process depends on `install`, `scdoc` and the `build` and +`installer` python packages. + +To generate the man page run `scdoc brightness.1` + +To generate the wheel run `python -m build --wheel` + +To install the resulting files run: + +```shell +$ python -m installer --destdir "$destdir" --prefix "$prefix" dist/*.whl +$ install -Dm644 brightness.1 "$destdir$prefix/share/man/man1/brightness.1" +``` + +Please note, using the `installer` from a venv will result in the brightness +script inheriting the venv python interpreter path. If this is undesirable, edit +the script after installation or don't use a virtualenv for the `installer`. + +# Contributing + +Please add tests where appropriate. To run the unit tests run `python -m +unittest`. + +Please use `black` and `isort` to format the code. + +While I consider this project feature-complete, new features will be considered +assuming they are easy to maintain and are not breaking changes. Adding new +dependencies is considered a breaking change. diff --git a/brightness.1.scd b/brightness.1.scd new file mode 100644 index 0000000..8804032 --- /dev/null +++ b/brightness.1.scd @@ -0,0 +1,71 @@ +BRIGHTNESS(1) + +# NAME + +brightness - adjust brightness via sysfs + +# SYNOPSIS + +*brightness* [*-h*] [*-m* _minimum_[*%*]] [*-M* _maximum_[*%*]] _backlight_ [*+*|*-*]_adjustment_ + +# DESCRIPTION + +The *brightness* utility is used to perform log-scale brightness adjustments of +a sysfs exposed backlight. + +The options are as follows: + +*-m* _minimum_[*%*] or *--min* _minimum_[*%*] + Specify an artificial minimum raw value or log-scale percentage. + +*-M* _maximum_[*%*] or *--max* _maximum_[*%*] + Specify an artificial maximum raw value or log-scale percentage. + +*-h* or *--help* + Print out the *brightness* help text and exit. + +_backlight_ must be the path to the directory of a backlight exposed over sysfs. + +_adjustment_ is a real number optionally prefixed by a sign. Numbers prefixed +with either a *+* or a *-* are relative percentage changes and un-prefixed +numbers are treated as absolute percentages. If *--max* or *--min* are +specified, the meanings of a percent of change and a percentage setpoint are +unaffected. This means that attempting to set 15% brightness when a 10% is set +will set the brightness to 15%, not 23.5%. + +# EXAMPLES + +Increase the brightness by 10%: + +``` +$ brightness /sys/class/backlight/amdgpu_bl0 +10 +``` + +Decrease the brightness by 6.25%: + +``` +$ brightness /sys/class/backlight/amdgpu_bl0 -6.25 +``` + +Set the backlight to maximum brightness: + +``` +$ brightness /sys/class/backlight/amdgpu_bl0 100 +``` + +This tool is best paired with _acpid_(8) and used as part of an event handler. +For example: + +``` +#!/bin/sh +backlight=/sys/class/backlight/amdgpu_bl0 +pct=6.25 +case "$1" in +video/brightnessup*) brightness "$backlight" "+$pct" ;; +video/brightnessdown*) brightness "$backlight" "-$pct" ;; +esac +``` + +# SEE ALSO + +_sysfs_(5), _acpid_(8) diff --git a/brightness/__init__.py b/brightness/__init__.py new file mode 100644 index 0000000..c5b497f --- /dev/null +++ b/brightness/__init__.py @@ -0,0 +1,85 @@ +# Copyright (C) 2023 Tomasz Kramkowski +# SPDX-License-Identifier: MIT + +import argparse +import math +import sys +from pathlib import Path +from typing import Type, TypeVar + +T = TypeVar("T", bound="LogFloat") + + +class LogFloat(float): + @classmethod + def from_val(cls: Type[T], val: int) -> T: + return cls(math.log(val + 1)) + + @classmethod + def from_pct(cls: Type[T], pct: float, log_max: "LogFloat") -> T: + return cls(pct / 100.0 * log_max) + + def to_val(self) -> int: + return round(math.e**self) - 1 + + def __add__(self, other: "LogFloat") -> "LogFloat": + return LogFloat(super().__add__(other)) + + +def _parse_limit(limit: str, log_max_brightness: LogFloat) -> int: + if limit[-1] == "%": + return LogFloat.from_pct(float(limit[:-1]), log_max_brightness).to_val() + return int(limit) + + +def main(argv: list[str] = sys.argv) -> int: + ap = argparse.ArgumentParser(description="Adjust sysfs backlight with a log scale") + ap.add_argument( + "-m", + "--min", + help="An artificial minimum brightness (percentage or raw value) limit", + default="0", + ) + ap.add_argument( + "-M", + "--max", + help="An artificial maximum brightness (percentage or raw value) limit", + ) + ap.add_argument( + "backlight", + help="Path to sysfs backlight (e.g. /sys/class/backlight/amdgpu_bl0)", + ) + ap.add_argument( + "adjustment", + help="Percentage adjustment (e.g. +10 or -6.25) or absolute value (e.g. 50)", + ) + args = ap.parse_args(argv[1:]) + + backlight = Path(args.backlight) + + with open(backlight / "brightness") as f: + brightness = int(f.read().rstrip()) + log_brightness = LogFloat.from_val(brightness) + + with open(backlight / "max_brightness") as f: + max_brightness = int(f.read().rstrip()) + log_max_brightness = LogFloat.from_val(max_brightness) + + if args.adjustment[0] in {"-", "+"}: + diff = LogFloat.from_pct(float(args.adjustment), log_max_brightness) + new_brightness = (log_brightness + diff).to_val() + if diff != 0 and new_brightness == brightness: + new_brightness += int(math.copysign(1, diff)) + else: + new_brightness = LogFloat.from_pct( + float(args.adjustment), log_max_brightness + ).to_val() + + max_limit = max_brightness + if args.max is not None: + max_limit = _parse_limit(args.max, log_max_brightness) + min_limit = _parse_limit(args.min, log_max_brightness) + new_brightness = max(min_limit, min(new_brightness, max_limit)) + with open(backlight / "brightness", "w") as f: + f.write(str(new_brightness)) + return 0 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a078486 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "brightness" +version = "1.0.0" +description = "A basic log-scale sysfs brightness adjuster" +readme = "README.md" +requires-python = ">=3.4" +license = { file = "LICENSE" } +authors = [{ name = "Tomasz Kramkowski", email = "tomasz@kramkow.ski" }] + +[project.scripts] +brightness = "brightness:main" + +[build-system] +requires = ["whey"] +build-backend = "whey" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..82d0e00 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,140 @@ +# Copyright (C) 2023 Tomasz Kramkowski +# SPDX-License-Identifier: MIT +import unittest +from collections.abc import Iterator +from contextlib import contextmanager +from tempfile import TemporaryDirectory + +from brightness import main + + +class TempSysfs: + def __init__(self, path: str) -> None: + self.path = path + + def read(self, name) -> str: + with open(self.path + "/" + name) as f: + return f.read() + + @property + def brightness(self) -> int: + return int(self.read("brightness").rstrip()) + + def max_brightness(self) -> int: + return int(self.read("max_brightness").rstrip()) + + +@contextmanager +def fake_sysfs(brightness: int, max_brightness: int) -> Iterator[TempSysfs]: + with TemporaryDirectory() as d: + with open(d + "/brightness", "w") as f: + f.write(str(brightness) + "\n") + with open(d + "/max_brightness", "w") as f: + f.write(str(max_brightness) + "\n") + yield TempSysfs(d) + + +class TestCLI(unittest.TestCase): + def test_increment_10(self): + with fake_sysfs(brightness=70, max_brightness=255) as sysfs: + main(["brightness", sysfs.path, "+10"]) + self.assertGreater(sysfs.brightness, 70) + self.assertLessEqual(sysfs.brightness, 255) + + def test_decrement_10(self): + with fake_sysfs(brightness=70, max_brightness=255) as sysfs: + main(["brightness", sysfs.path, "-10"]) + self.assertGreaterEqual(sysfs.brightness, 0) + self.assertLess(sysfs.brightness, 70) + + def test_increase_small(self): + with fake_sysfs(brightness=0, max_brightness=255) as sysfs: + main(["brightness", sysfs.path, "+1"]) + self.assertEqual(sysfs.brightness, 1) + + def test_decrease_small(self): + with fake_sysfs(brightness=1, max_brightness=255) as sysfs: + main(["brightness", sysfs.path, "-1"]) + self.assertEqual(sysfs.brightness, 0) + + def test_increase_zero(self): + with fake_sysfs(brightness=127, max_brightness=255) as sysfs: + main(["brightness", sysfs.path, "+0"]) + self.assertEqual(sysfs.brightness, 127) + + def test_decrease_zero(self): + with fake_sysfs(brightness=127, max_brightness=255) as sysfs: + main(["brightness", sysfs.path, "-0"]) + self.assertEqual(sysfs.brightness, 127) + + def test_double(self): + with fake_sysfs(brightness=15, max_brightness=255) as sysfs: + main(["brightness", sysfs.path, "+" + str(1 / 8 * 100)]) + self.assertEqual(sysfs.brightness, 31) + + def test_halve(self): + with fake_sysfs(brightness=15, max_brightness=255) as sysfs: + main(["brightness", sysfs.path, str(-(1 / 8 * 100))]) + self.assertEqual(sysfs.brightness, 7) + + def test_clamp_low(self): + with fake_sysfs(brightness=127, max_brightness=255) as sysfs: + main(["brightness", sysfs.path, "-100"]) + self.assertEqual(sysfs.brightness, 0) + + def test_clamp_high(self): + with fake_sysfs(brightness=127, max_brightness=255) as sysfs: + main(["brightness", sysfs.path, "+100"]) + self.assertEqual(sysfs.brightness, 255) + + def test_absolute(self): + with fake_sysfs(brightness=127, max_brightness=255) as sysfs: + main(["brightness", sysfs.path, "0"]) + self.assertEqual(sysfs.brightness, 0) + main(["brightness", sysfs.path, "12.5"]) + self.assertEqual(sysfs.brightness, 1) + main(["brightness", sysfs.path, "25.0"]) + self.assertEqual(sysfs.brightness, 3) + main(["brightness", sysfs.path, "100"]) + self.assertEqual(sysfs.brightness, 255) + + def test_min_value(self): + with fake_sysfs(brightness=127, max_brightness=255) as sysfs: + main(["brightness", "--min", "0", sysfs.path, "0"]) + self.assertEqual(sysfs.brightness, 0) + main(["brightness", "--min", "10", sysfs.path, "0"]) + self.assertEqual(sysfs.brightness, 10) + main(["brightness", "--min", "0%", sysfs.path, "0"]) + self.assertEqual(sysfs.brightness, 0) + main(["brightness", "--min", "12.5%", sysfs.path, "0"]) + self.assertEqual(sysfs.brightness, 1) + + def test_max_value(self): + with fake_sysfs(brightness=127, max_brightness=255) as sysfs: + main(["brightness", "--max", "255", sysfs.path, "100"]) + self.assertEqual(sysfs.brightness, 255) + main(["brightness", "--max", "245", sysfs.path, "100"]) + self.assertEqual(sysfs.brightness, 245) + main(["brightness", "--max", "100%", sysfs.path, "100"]) + self.assertEqual(sysfs.brightness, 255) + main(["brightness", "--max", "87.5%", sysfs.path, "100"]) + self.assertEqual(sysfs.brightness, 127) + + def test_min_max(self): + with fake_sysfs(brightness=127, max_brightness=255) as sysfs: + main(["brightness", "--min", "0", "--max", "255", sysfs.path, "0"]) + self.assertEqual(sysfs.brightness, 0) + main(["brightness", "--min", "0", "--max", "255", sysfs.path, "100"]) + self.assertEqual(sysfs.brightness, 255) + main(["brightness", "--min", "10", "--max", "245", sysfs.path, "0"]) + self.assertEqual(sysfs.brightness, 10) + main(["brightness", "--min", "10", "--max", "245", sysfs.path, "100"]) + self.assertEqual(sysfs.brightness, 245) + main(["brightness", "--min", "0%", "--max", "100%", sysfs.path, "0"]) + self.assertEqual(sysfs.brightness, 0) + main(["brightness", "--min", "0%", "--max", "100%", sysfs.path, "100"]) + self.assertEqual(sysfs.brightness, 255) + main(["brightness", "--min", "12.5%", "--max", "87.5%", sysfs.path, "0"]) + self.assertEqual(sysfs.brightness, 1) + main(["brightness", "--min", "12.5%", "--max", "87.5%", sysfs.path, "100"]) + self.assertEqual(sysfs.brightness, 127) -- cgit v1.2.3-54-g00ecf