aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.gitignore3
-rw-r--r--LICENSE20
-rw-r--r--README.md57
-rw-r--r--brightness.1.scd71
-rw-r--r--brightness/__init__.py85
-rw-r--r--pyproject.toml15
-rw-r--r--tests/__init__.py0
-rw-r--r--tests/test_cli.py140
8 files changed, 391 insertions, 0 deletions
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 <tomasz@kramkow.ski>
+
+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.scd >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 <tomasz@kramkow.ski>
+# 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
--- /dev/null
+++ b/tests/__init__.py
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 <tomasz@kramkow.ski>
+# 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)