diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 696c491..8ccfe4b 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.10', '3.11', '3.12'] + python-version: ['3.11', '3.12'] steps: - uses: actions/checkout@v2 @@ -36,4 +36,4 @@ jobs: poetry run pytest - name: Test with mypy run: | - poetry run mypy src/ + poetry run mypy amdfan/ diff --git a/.gitignore b/.gitignore index b6e4761..95c7489 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,6 @@ __pycache__/ .Python build/ develop-eggs/ -dist/ downloads/ eggs/ .eggs/ @@ -27,6 +26,12 @@ share/python-wheels/ *.egg MANIFEST +# autoconf files +autom4te.cache/ +configure +config.status + + # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000..bed952f --- /dev/null +++ b/INSTALL.md @@ -0,0 +1,85 @@ + +# Dependencies +At build time: `autoconf` +At runtime: `python`, `numpy`, `click`, `rich` and `pyyaml` + +Our standard builds use pyproject, mainly with poetry. + +# Preparing the sources +The dist files are template files, which means you must first `autoconf` them. You could also just `sed` all the `.in` files if you don't want to use autotools. + +``` sh +git clone https://github.com/mcgillij/amdfan.git +cd amdfan + +autoconf +./configure --prefix=/usr --libdir=/usr/lib +``` + +# Service files +There are two ways to obtain the systemd service file. One is available during runtime, through the `amdfan --service` command, but takes some assumptions of your system. May not work. + +``` sh +amdfan print-default --service | sudo tee /usr/lib/systemd/system/amdfan.service +``` + +If you ran `./configure` succesfully, you'll also find the service files for OpenRC and systemd under `dist/${init}/`. + + +# Configuration files +Similiar to above, you can obtain the sources from the runtime. This is not necessary, as this file is generated automatically after the daemon runs for the first time. + +``` bash +amdfan print-default --configuration | sudo tee /etc/amdfan.yml +``` + + +# Executable files +The executable files are contained within amdfan. This directory is a python module, and can either be loaded as `python -m amdgpu`. If you want to import the module, you may be interested in checking out `amdfan.commands`, which contains most of the subcommands. + +Otherwise, just use python-exec with something like + +```python +import sys +from amdfan.__main__ import main + +if __name__ == '__main__': + sys.exit(main()) +``` + + + +# Installing from PyPi +You can also install amdfan from pypi using something like poetry. + +``` bash +poetry init +poetry add amdfan +poetry run amdfan --help +``` + +# Building Python package +Requires [poetry](https://python-poetry.org/) to be installed. + +``` bash +git clone git@github.com:mcgillij/amdfan.git +cd amdfan/ +poetry build +``` + +## Building Arch AUR package +Building the Arch package assumes you already have a chroot env setup to build packages. + +```bash +git clone https://aur.archlinux.org/amdfan.git +cd amdfan/ + +autoconf --libdir=/usr +./configure +cp dist/pacman/PKGBUILD ./PKGBUILD + +makechrootpkg -c -r $HOME/$CHROOT +sudo pacman -U --asdeps amdfan-*-any.pkg.tar.zst +``` + + diff --git a/README.md b/README.md index 81da0b2..f236b3a 100644 --- a/README.md +++ b/README.md @@ -34,102 +34,45 @@ Setting the card to system managed using the amdgpu_fan pegs your GPU fan at 100 These are all addressed in Amdfan, and as long as I’ve still got some AMD cards I intend to at least maintain this for myself. And anyone’s able to help out since this is open source. I would have liked to just contribute these fixes to the main project, but it’s now inactive. # Documentation -## Usage -``` bash -Usage: amdfan.py [OPTIONS] +```help +Usage: amdfan [OPTIONS] COMMAND [ARGS]... Options: - --daemon Run as daemon applying the fan curve - --monitor Run as a monitor showing temp and fan speed - --manual Manually set the fan speed value of a card - --configuration Prints out the default configuration for you to use - --service Prints out the amdfan.service file to use with systemd - --help Show this message and exit. -``` + --help Show this message and exit. -## Daemon +Commands: + daemon Run the controller + monitor View the current temperature and speed + print-default Convenient defaults + set Manually override the fan speed +``` -Amdfan is also runnable as a systemd service, with the provided ```amdfan.service```. +Each subcommand supports `--help` too, to get more details. -## Monitor +## Controlling the fans -You can use Amdfan to monitor your AMD video cards using the ```--monitor``` flag. +There are two ways to control your fans with Amdfan. Note that in order to control the fans, you will likely need to run either approach as root. -![screenshot](https://raw.githubusercontent.com/mcgillij/amdfan/main/images/screenshot.png) +The recommended way is through a system service started at boot. This will control the fans based on the detected temperature at a given interval. -## Manual +In case you don't want to use a service, you may also control the fans manually. While this is only adviced to do when first setting up your configuration, keep in mind you can also use it to temporarily take control away from the daemon until you revert the fan speed back to `auto`. -Alternatively if you don't want to set a fan curve, you can just apply a fan setting manually. -Also allows you to revert the fan control to the systems default behavior by using the "auto" parameter. ![screenshot](https://raw.githubusercontent.com/mcgillij/amdfan/main/images/manual.png) -## Configuration - -This will dump out the default configuration that would get generated for `/etc/amdfan.yml` when you first run it as a service. This allows you to configure the settings prior to running it as a daemon if you wish. - -Running `amdfan --configuration` will output the following block to STDOUT. - -``` bash -#Fan Control Matrix. -# [,] -speed_matrix: -- [4, 4] -- [30, 33] -- [45, 50] -- [60, 66] -- [65, 69] -- [70, 75] -- [75, 89] -- [80, 100] - -# Current Min supported value is 4 due to driver bug -# -# Optional configuration options -# -# Allows for some leeway +/- temp, as to not constantly change fan speed -# threshold: 4 -# -# Frequency will change how often we probe for the temp -# frequency: 5 -# -# While frequency and threshold are optional, I highly recommend finding -# settings that work for you. I've included the defaults I use above. -# -# cards: -# can be any card returned from `ls /sys/class/drm | grep "^card[[:digit:]]$"` -# - card0 -``` -You can use this to generate your configuration by doing ``amdfan --configuration > amdfan.yml``, you can then modify the settings and place it in ``/etc/amdfan.yml`` for when you would like to run it as a service. -## Service - -This is just a convenience method for dumping out the `amdfan.service` that would get installed if you used a package manager to install amdfan. Useful if you installed the module via `pip`, `pipenv` or `poetry`. - -Running `amdfan --service` will output the following block to STDOUT. - -``` bash -[Unit] -Description=amdfan controller - -[Service] -ExecStart=/usr/bin/amdfan --daemon -Restart=always +## Monitor -[Install] -WantedBy=multi-user.target -``` +You can use Amdfan to monitor your AMD video cards using the `monitor` flag. This does not require root privileges, usually. -# Note +![screenshot](https://raw.githubusercontent.com/mcgillij/amdfan/main/images/screenshot.png) -Monitoring fan speeds and temperatures can run with regular user permissions. -`root` permissions are required for changing the settings / running as a daemon. -# Recommended settings +## Configuration -Below is the settings that I use on my machines to control the fan curve without too much fuss, but you should find a frequency and threshold setting that works for your workloads. +Running `amdfan print-default --configuration` will dump out the default configuration that would get generated for `/etc/amdfan.yml` when you first run it as a service. If a value is not specified, it will use a default value if possible. -`/etc/amdfan.yml` +The following config is probably a reasonable setup: ``` bash speed_matrix: - [4, 4] @@ -143,68 +86,23 @@ speed_matrix: threshold: 4 frequency: 5 -``` - -## Installing the systemd service -If you installed via the AUR, the service is already installed, and you just need to *start/enable* it. If you installed via pip/pipenv or poetry, you can generate your systemd service file with the following command. - -``` bash -amdfan --service > amdfan.service && sudo mv amdfan.service /usr/lib/systemd/system/ -``` - -## Starting the systemd service - -To run the service, you can run the following commands to **start/enable** the service. - -``` bash -sudo systemctl start amdfan -sudo systemctl enable amdfan -``` - -After you've started it, you may want to edit the settings found in `/etc/amdfan.yml`. Once your happy with those, you can restart amdfan with the following command. - -``` bash -sudo systemctl restart amdfan -``` - -## Checking the status -You can check the systemd service status with the following command: -``` bash -systemctl status amdfan +# cards: +# - card0 ``` +If a configuration file is not found, a default one will be generated. If you want to make any changes to the default config before running it the daemon first, run `amdfan print-default --configuration | sudo tee /etc/amdfan.yml` and do your changes from there. -## Building Arch AUR package +- `speed_matrix` (required): a list of thresholds `[temperature, speed]` which are interpolated to calculate the fan speed. +- `threshold` (default `0`): allows for some leeway in temperatures, as to not constantly change fan speed +- `frequency` (default `5`): how often (in seconds) we wait between updates +- `cards` (required): a list of card names (from `/sys/class/drm`) which we want to control. -Building the Arch package assumes you already have a chroot env setup to build packages. +Note! You can send a SIGHUP signal to the daemon to request a reload of the config without restarting the whole service. -```bash -git clone https://aur.archlinux.org/amdfan.git -cd amdfan/ -makechrootpkg -c -r $HOME/$CHROOT -``` +# Install -## Installing the Arch package +Users: Use your package manager to install the package. It's available on Arch Linux and Gentoo. For other distributions, please request a maintainer to bring the package to your system, or read the installation notes at your own warranty. -``` bash -sudo pacman -U --asdeps amdfan-*-any.pkg.tar.zst -``` +Maintainers: Check the [installation notes](INSTALL.md). -# Installing from PyPi -You can also install amdfan from pypi using something like poetry. - -``` bash -poetry init -poetry add amdfan -poetry run amdfan --help -``` - -# Building Python package -Requires [poetry](https://python-poetry.org/) to be installed - -``` bash -git clone git@github.com:mcgillij/amdfan.git -cd amdfan/ -poetry build -``` diff --git a/src/amdfan/__init__.py b/amdfan/__init__.py similarity index 100% rename from src/amdfan/__init__.py rename to amdfan/__init__.py diff --git a/amdfan/__main__.py b/amdfan/__main__.py new file mode 100755 index 0000000..6a22a7b --- /dev/null +++ b/amdfan/__main__.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python +""" entry point for amdfan """ +# __main__.py +import click + +from .commands import cli, monitor_cards, run_daemon, set_fan_speed + + +@click.group() +def main(): + pass + + +main.add_command(cli) +main.add_command(run_daemon) +main.add_command(monitor_cards) +main.add_command(set_fan_speed) + +if __name__ == "__main__": + main() # pylint: disable=no-value-for-parameter diff --git a/amdfan/commands.py b/amdfan/commands.py new file mode 100644 index 0000000..e6259e9 --- /dev/null +++ b/amdfan/commands.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python +""" entry point for amdfan """ +# noqa: E501 +import os +import sys +import time +from typing import Dict + +import click +from rich.console import Console +from rich.live import Live +from rich.prompt import Prompt +from rich.table import Table +from rich.traceback import install + +from .config import LOGGER, PIDFILE_DIR +from .controller import FanController, Scanner +from .defaults import DEFAULT_FAN_CONFIG, SERVICES + +install() # install traceback formatter + +c: Console = Console(style="green on black") + + +@click.command(name="print-default", help="Convenient defaults") +@click.option( + "--configuration", + is_flag=True, + default=False, + help="Prints out the default configuration for you to use", +) +@click.option( + "--service", + type=click.Choice(["systemd"]), + help="Prints out a service file for the given init system to use", +) +@click.pass_context +def cli( + ctx: click.Context, + configuration: bool, + service: str, +) -> None: + if configuration: + print(DEFAULT_FAN_CONFIG) + elif service in SERVICES: + print(SERVICES[service]) + else: + print(ctx.get_help()) + sys.exit(1) + + +@click.command( + name="daemon", + help="Run the controller", +) +@click.option("--notification-fd", type=int) +# @click.option("--background", is_flag=True, default=True) +def run_daemon(notification_fd): + FanController.start_daemon( + notification_fd=notification_fd, pidfile=os.path.join(PIDFILE_DIR, "amdfan.pid") + ) + + +def show_table(cards: Dict) -> Table: + table = Table(title="amdgpu") + table.add_column("Card") + table.add_column("fan_speed (RPM)") + table.add_column("gpu_temp ℃") + for card, card_value in cards.items(): + table.add_row(f"{card}", f"{card_value.fan_speed}", f"{card_value.gpu_temp}") + return table + + +@click.command(name="monitor", help="View the current temperature and speed") +@click.option("--fps", default=5, help="Updates per second") +@click.option("--single-run", is_flag=True, default=False, help="Print and exit") +def monitor_cards(fps, single_run) -> None: + scanner = Scanner() + if not single_run: + c.print("AMD Fan Control - ctrl-c to quit") + + with Live(refresh_per_second=fps) as live: + while 1: + live.update(show_table(scanner.cards)) + if single_run: + return + time.sleep(1 / fps) + + +@click.command(name="set", help="Manually override the fan speed") +@click.option("--card", help="Specify which card to override") +@click.option("--speed", help="Specify which speed to change to") +def set_fan_speed(card, speed) -> None: + scanner = Scanner() + if card is None: + card_to_set = Prompt.ask("Which card?", choices=list(scanner.cards.keys())) + else: + card_to_set = card + + if speed is None: + input_fan_speed = Prompt.ask("Fan speed, [1..100]% or 'auto'", default="auto") + else: + input_fan_speed = speed + + while True: + if input_fan_speed.isdigit(): + if int(input_fan_speed) >= 1 and int(input_fan_speed) <= 100: + LOGGER.debug("good %d", int(input_fan_speed)) + break + elif input_fan_speed == "auto": + LOGGER.debug("fan speed set to auto") + break + c.print("maybe try picking one of the options") + + selected_card = scanner.cards.get(card_to_set) + if not selected_card: + LOGGER.error("Found no card to set speed of") + elif not input_fan_speed.isdigit() and input_fan_speed == "auto": + LOGGER.info("Setting fan speed to system controlled") + selected_card.set_system_controlled_fan(True) + else: + LOGGER.info("Setting fan speed to %d", int(input_fan_speed)) + c.print(selected_card.set_fan_speed(int(input_fan_speed))) + + +if __name__ == "__main__": + cli() # pylint: disable=no-value-for-parameter diff --git a/amdfan/config.py b/amdfan/config.py new file mode 100644 index 0000000..e874bcf --- /dev/null +++ b/amdfan/config.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python +""" holds the configuration for the amdfan runtime """ +import logging +import os +from typing import List + +from rich.logging import RichHandler + +CONFIG_LOCATIONS: List[str] = [ + "/etc/amdfan.yml", +] + +DEBUG: bool = bool(os.environ.get("DEBUG", False)) +LOGGER = logging.getLogger("rich") # type: ignore +logging.basicConfig( + level=logging.DEBUG if DEBUG else logging.INFO, + format="%(message)s", + datefmt="[%X]", + handlers=[RichHandler(rich_tracebacks=True)], +) + + +ROOT_DIR: str = "/sys/class/drm" +HWMON_DIR: str = "device/hwmon" +PIDFILE_DIR: str = "/var/run" diff --git a/amdfan/controller.py b/amdfan/controller.py new file mode 100644 index 0000000..4dcc39c --- /dev/null +++ b/amdfan/controller.py @@ -0,0 +1,305 @@ +#!/usr/bin/env python +""" manages the amd gpu fans on your system """ +import atexit +import os +import re +import signal +import sys +import time +from typing import Any, Callable, Dict, List, Optional, Self + +import numpy as np +import yaml +from numpy import ndarray + +from .config import CONFIG_LOCATIONS, HWMON_DIR, LOGGER, ROOT_DIR +from .defaults import DEFAULT_FAN_CONFIG + + +def create_pidfile(pidfile: str) -> None: + LOGGER.info("Creating pifile %s", pidfile) + pid = os.getpid() + with open(pidfile, "w", encoding="utf8") as file: + file.write(str(pid)) + + def remove_pidfile() -> None: + if os.path.isfile(pidfile): + os.remove(pidfile) + + atexit.register(remove_pidfile) + LOGGER.info("Saved pidfile with running pid=%s", pid) + + +def report_ready(fd: int) -> None: + os.write(fd, b"READY=1\n") + + +class Curve: # pylint: disable=too-few-public-methods + """ + creates a fan curve based on user defined points + """ + + def __init__(self, points: list) -> None: + self.points = np.array(points) + self.temps = self.points[:, 0] + self.speeds = self.points[:, 1] + + if np.min(self.speeds) < 0: + raise ValueError( + "Fan curve contains negative speeds, \ + speed should be in [0,100]" + ) + if np.max(self.speeds) > 100: + raise ValueError( + "Fan curve contains speeds greater than 100, \ + speed should be in [0,100]" + ) + if np.any(np.diff(self.temps) <= 0): + raise ValueError( + "Fan curve points should be strictly monotonically increasing, \ + configuration error ?" + ) + if np.any(np.diff(self.speeds) < 0): + raise ValueError( + "Curve fan speeds should be monotonically increasing, \ + configuration error ?" + ) + if np.min(self.speeds) <= 3: + raise ValueError("Lowest speed value to be set to 4") # Driver BUG + + def get_speed(self, temp: int) -> ndarray[Any, Any]: + """ + returns a speed for a given temperature + :param temp: int + :return: + """ + + return np.interp(x=temp, xp=self.temps, fp=self.speeds) + + +class Card: + """ + This class is used to map to each card that supports HWMON + """ + + HWMON_REGEX: str = r"^hwmon\d+$" + AMD_FIELDS: List[str] = [ + "temp1_input", + "pwm1_max", + "pwm1_min", + "pwm1_enable", + "pwm1", + ] + + def __init__(self, card_id: str) -> None: + self._id = card_id + + for node in os.listdir(os.path.join(ROOT_DIR, self._id, HWMON_DIR)): + if re.match(self.HWMON_REGEX, node): + self._monitor = node + self._endpoints = self._load_endpoints() + + def _verify_card(self) -> None: + for endpoint in self.AMD_FIELDS: + if endpoint not in self._endpoints: + LOGGER.info("skipping card: %s missing endpoint %s", self._id, endpoint) + raise FileNotFoundError + + def _load_endpoints(self) -> Dict: + _endpoints = {} + _dir = os.path.join(ROOT_DIR, self._id, HWMON_DIR, self._monitor) + for endpoint in os.listdir(_dir): + if endpoint not in ("device", "power", "subsystem", "uevent"): + _endpoints[endpoint] = os.path.join(_dir, endpoint) + return _endpoints + + def read_endpoint(self, endpoint: str) -> str: + with open(self._endpoints[endpoint], "r", encoding="utf8") as endpoint_file: + return endpoint_file.read() + + def write_endpoint(self, endpoint: str, data: int) -> int: + try: + with open(self._endpoints[endpoint], "w", encoding="utf8") as endpoint_file: + return endpoint_file.write(str(data)) + except PermissionError: + LOGGER.error("Failed writing to devfs file, are you running as root?") + sys.exit(1) + + @property + def fan_speed(self) -> int: + try: + return int(self.read_endpoint("fan1_input")) + except (KeyError, OSError): # better to return no speed then explode + return 0 + + @property + def gpu_temp(self) -> float: + return float(self.read_endpoint("temp1_input")) / 1000 + + @property + def fan_max(self) -> int: + return int(self.read_endpoint("pwm1_max")) + + @property + def fan_min(self) -> int: + return int(self.read_endpoint("pwm1_min")) + + def set_system_controlled_fan(self, state: bool) -> None: + + system_controlled_fan = 2 + manual_control = 1 + + self.write_endpoint( + "pwm1_enable", system_controlled_fan if state else manual_control + ) + + def set_fan_speed(self, speed: int) -> int: + if speed >= 100: + speed = self.fan_max + elif speed <= 0: + speed = self.fan_min + else: + speed = int(self.fan_max / 100 * speed) + self.set_system_controlled_fan(False) + return self.write_endpoint("pwm1", speed) + + +class Scanner: # pylint: disable=too-few-public-methods + """Used to scan the available cards to see if they are usable""" + + CARD_REGEX: str = r"^card+\d$" + cards: Dict[str, Card] + + def __init__(self, cards=None) -> None: + self.cards = self._get_cards(cards) + + def _get_cards(self, cards_to_scan): + """ + only directories in ROOT_DIR that are card1, card0, card3 etc. + :return: a list of initialized Card objects + """ + cards = {} + for node in os.listdir(ROOT_DIR): + if re.match(self.CARD_REGEX, node): + if cards_to_scan and node.lower() not in [ + c.lower() for c in cards_to_scan + ]: + continue + try: + cards[node] = Card(node) + except FileNotFoundError: + # if card lacks hwmon or required devfs files, its not + # amdgpu, and definitely not compatible with this software + continue + return cards + + +class FanController: # pylint: disable=too-few-public-methods + """Used to apply the curve at regular intervals""" + + _scanner: Scanner + _curve: Curve + _threshold: int + _frequency: int + + def __init__(self, config_path, notification_fd=None) -> None: + self.config_path = config_path + self.reload_config() + self._last_temp = 0 + self._ready_fd = notification_fd + + def reload_config(self, *_) -> None: + config = load_config(self.config_path) + self.apply(config) + + def apply(self, config) -> None: + self._scanner = Scanner(config.get("cards")) + if len(self._scanner.cards) < 1: + LOGGER.error("no compatible cards found, exiting") + sys.exit(1) + self._curve = Curve(config.get("speed_matrix")) + self._threshold = config.get("threshold", 0) + self._frequency = config.get("frequency", 5) + + def main(self) -> None: + LOGGER.info("Starting amdfan") + if self._ready_fd is not None: + report_ready(self._ready_fd) + + while True: + for name, card in self._scanner.cards.items(): + self.refresh_card(name, card) + + time.sleep(self._frequency) + + def refresh_card(self, name, card): + apply = True + temp = card.gpu_temp + speed = int(self._curve.get_speed(int(temp))) + if speed < 0: + speed = 4 # due to driver bug + + LOGGER.debug( + "%s: Temp %d, \ + last temp: %d \ + target fan speed: %d, \ + fan speed %d, \ + min: %d, max: %d", + name, + temp, + self._last_temp, + speed, + card.fan_speed, + card.fan_min, + card.fan_max, + ) + if self._threshold and self._last_temp: + + LOGGER.debug("threshold and last temp, checking") + low = self._last_temp - self._threshold + high = self._last_temp + self._threshold + + LOGGER.debug("%d and %d and %d", low, high, temp) + if int(temp) in range(int(low), int(high)): + LOGGER.debug("temp in range, doing nothing") + apply = False + else: + LOGGER.debug("temp out of range, setting") + card.set_fan_speed(speed) + self._last_temp = temp + return + + if apply: + card.set_fan_speed(speed) + self._last_temp = temp + + @classmethod + def start_daemon( + cls, notification_fd: Optional[int], pidfile: Optional[str] = None + ) -> Self: + if pidfile: + create_pidfile(pidfile) + + config_path = None + for location in CONFIG_LOCATIONS: + if os.path.isfile(location): + config_path = location + break + + if config_path is None: + LOGGER.info("No config found, creating one in %s", CONFIG_LOCATIONS[-1]) + with open(CONFIG_LOCATIONS[-1], "w", encoding="utf8") as config_file: + config_file.write(DEFAULT_FAN_CONFIG) + config_file.flush() + + controller = cls(config_path, notification_fd=notification_fd) + signal.signal(signal.SIGHUP, controller.reload_config) + controller.main() + + return controller + + +def load_config(path) -> Callable: + LOGGER.debug("loading config from %s", path) + with open(path, encoding="utf8") as config_file: + return yaml.safe_load(config_file) diff --git a/amdfan/defaults.py b/amdfan/defaults.py new file mode 100644 index 0000000..a76f8a9 --- /dev/null +++ b/amdfan/defaults.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python +""" includes strings to suggest default files """ + +from typing import Dict + +DEFAULT_FAN_CONFIG: str = """#Fan Control Matrix. +# [,] +speed_matrix: +- [4, 4] +- [30, 33] +- [45, 50] +- [60, 66] +- [65, 69] +- [70, 75] +- [75, 89] +- [80, 100] + +# Current Min supported value is 4 due to driver bug +# +# Optional configuration options +# +# Allows for some leeway +/- temp, as to not constantly change fan speed +# threshold: 4 +# +# Frequency will change how often we probe for the temp +# frequency: 5 +# +# While frequency and threshold are optional, I highly recommend finding +# settings that work for you. I've included the defaults I use above. +# +# cards: +# can be any card returned from `ls /sys/class/drm | grep "^card[[:digit:]]$"` +# - card0 +""" + +SERVICES: Dict[str, str] = { + "systemd": """\ +[Unit] +Description=amdfan controller + +[Service] +ExecStart=/usr/bin/amdfan --daemon +Restart=always + +[Install] +WantedBy=multi-user.target +""" +} diff --git a/dist/.gitignore b/dist/.gitignore new file mode 100644 index 0000000..f479f66 --- /dev/null +++ b/dist/.gitignore @@ -0,0 +1,3 @@ +* +!**/*.in + diff --git a/dist/configure.ac b/dist/configure.ac new file mode 100644 index 0000000..94bcfb4 --- /dev/null +++ b/dist/configure.ac @@ -0,0 +1,6 @@ + +AC_INIT([amdfan], [0.1.28]) +AC_CONFIG_FILES([dist/pacman/PKGBUILD]) +AC_CONFIG_FILES([dist/openrc/amdfan dist/systemd/amdfan.service]) +AC_OUTPUT + diff --git a/dist/openrc/amdfan.in b/dist/openrc/amdfan.in new file mode 100755 index 0000000..3936ed0 --- /dev/null +++ b/dist/openrc/amdfan.in @@ -0,0 +1,12 @@ +#!/sbin/openrc-run + +description="amdfan controller" +command="@bindir@/amdfan" +command_args="--daemon" +command_background=true +pidfile="/var/run/${RC_SVCNAME}.pid" + +reload() { + start-stop-daemon --signal SIGHUP --pidfile "${pidfile}" +} + diff --git a/dist/pacman/PKGBUILD.in b/dist/pacman/PKGBUILD.in new file mode 100644 index 0000000..f7b507b --- /dev/null +++ b/dist/pacman/PKGBUILD.in @@ -0,0 +1,27 @@ +# Maintainer: Jason McGillivray < mcgillivray dot jason at gmail dot com> + + +pkgname=amdfan +pkgdesc="Python daemon for controlling the fans on amdgpu cards" +pkgver=0.1.28 +pkgrel=1 +arch=('any') +license=('GPL2') +depends=('python' 'python-yaml' 'python-numpy' 'python-rich' 'python-click') +makedepends=('python-setuptools' 'python-poetry' 'python-build' 'python-installer') +url="https://github.com/mcgillij/amdfan" +source=("https://github.com/mcgillij/amdfan/releases/download/$pkgver/amdfan-$pkgver.tar.gz") +#source=("amdfan-$pkgver.tar.gz") +md5sums=('7fe30661ba0d5117f32e4bab1db76888') + +build() { + cd "$srcdir/$pkgname-$pkgver" + python -m build --wheel --no-isolation +} + +package() { + cd "$srcdir/$pkgname-$pkgver" + python -m installer --destdir="$pkgdir" dist/*.whl + mkdir -p "$pkgdir/@libdir@/systemd/system" + install -Dm644 amdfan/packaging/pacman/amdfan.service "$pkgdir/@libdir@/systemd/system/" +} diff --git a/src/amdfan/amdfan.service b/dist/systemd/amdfan.service.in similarity index 69% rename from src/amdfan/amdfan.service rename to dist/systemd/amdfan.service.in index 940e96a..9fb87a4 100644 --- a/src/amdfan/amdfan.service +++ b/dist/systemd/amdfan.service.in @@ -4,7 +4,8 @@ After=multi-user.target Requires=multi-user.target [Service] -ExecStart=/usr/bin/amdfan --daemon +ExecStart=@bindir@/amdfan --daemon +ExecReload=kill -HUP $MAINPID Restart=always [Install] diff --git a/poetry.lock b/poetry.lock index f04a444..382b8b0 100644 --- a/poetry.lock +++ b/poetry.lock @@ -11,9 +11,6 @@ files = [ {file = "astroid-3.2.2.tar.gz", hash = "sha256:8ead48e31b92b2e217b6c9733a21afafe479d52d6e164dd25fb1a770c7c3cf94"}, ] -[package.dependencies] -typing-extensions = {version = ">=4.0.0", markers = "python_version < \"3.11\""} - [[package]] name = "black" version = "24.4.2" @@ -51,8 +48,6 @@ mypy-extensions = ">=0.4.3" packaging = ">=22.0" pathspec = ">=0.9.0" platformdirs = ">=2" -tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} -typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} [package.extras] colorama = ["colorama (>=0.4.3)"] @@ -100,20 +95,6 @@ files = [ graph = ["objgraph (>=1.7.2)"] profile = ["gprof2dot (>=2022.7.29)"] -[[package]] -name = "exceptiongroup" -version = "1.2.1" -description = "Backport of PEP 654 (exception groups)" -optional = false -python-versions = ">=3.7" -files = [ - {file = "exceptiongroup-1.2.1-py3-none-any.whl", hash = "sha256:5258b9ed329c5bbdd31a309f53cbfb0b155341807f6ff7606a1e801a891b29ad"}, - {file = "exceptiongroup-1.2.1.tar.gz", hash = "sha256:a4785e48b045528f5bfe627b6ad554ff32def154f42372786903b7abcfe1aa16"}, -] - -[package.extras] -test = ["pytest (>=6)"] - [[package]] name = "flake8" version = "7.1.0" @@ -239,7 +220,6 @@ files = [ [package.dependencies] mypy-extensions = ">=1.0.0" -tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} typing-extensions = ">=4.1.0" [package.extras] @@ -417,14 +397,12 @@ files = [ astroid = ">=3.2.2,<=3.3.0-dev0" colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} dill = [ - {version = ">=0.2", markers = "python_version < \"3.11\""}, {version = ">=0.3.7", markers = "python_version >= \"3.12\""}, {version = ">=0.3.6", markers = "python_version >= \"3.11\" and python_version < \"3.12\""}, ] isort = ">=4.2.5,<5.13.0 || >5.13.0,<6" mccabe = ">=0.6,<0.8" platformdirs = ">=2.2.0" -tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} tomlkit = ">=0.10.1" [package.extras] @@ -444,11 +422,9 @@ files = [ [package.dependencies] colorama = {version = "*", markers = "sys_platform == \"win32\""} -exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} iniconfig = "*" packaging = "*" pluggy = ">=1.5,<2.0" -tomli = {version = ">=1", markers = "python_version < \"3.11\""} [package.extras] dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] @@ -531,17 +507,6 @@ pygments = ">=2.13.0,<3.0.0" [package.extras] jupyter = ["ipywidgets (>=7.5.1,<9)"] -[[package]] -name = "tomli" -version = "2.0.1" -description = "A lil' TOML parser" -optional = false -python-versions = ">=3.7" -files = [ - {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, - {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, -] - [[package]] name = "tomlkit" version = "0.12.5" @@ -577,5 +542,5 @@ files = [ [metadata] lock-version = "2.0" -python-versions = "^3.10 || ^3.11 || ^3.12" -content-hash = "67ff723c81b71f1d44eea0a4d6d57394c81c56603fa17cf7cf76241422226a41" +python-versions = "^3.11 || ^3.12" +content-hash = "f09b8681bfe0ed67dccd8d9e4c792fc22c72a14d0601097ba3f9bb604161ca61" diff --git a/pyproject.toml b/pyproject.toml index e792037..d1b0e1b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,8 @@ classifiers = [ ] [tool.poetry.dependencies] -python = "^3.10 || ^3.11 || ^3.12" +python = "^3.11 || ^3.12" + numpy = "^2.0.0" pyyaml = "^6.0" click = "^8.1.3" @@ -38,7 +39,7 @@ mypy = "*" types-pyyaml = "*" [tool.poetry.scripts] -amdfan = 'amdfan.amdfan:cli' +amdfan = 'amdfan.__main__:main' [build-system] requires = ["poetry-core"] diff --git a/src/amdfan/amdfan.py b/src/amdfan/amdfan.py deleted file mode 100755 index a1f84be..0000000 --- a/src/amdfan/amdfan.py +++ /dev/null @@ -1,419 +0,0 @@ -#!/usr/bin/env python -""" Main amdfan script """ -# noqa: E501 -import logging -import os -import re -import sys -import time -from typing import Any, List, Dict, Callable -import yaml -import numpy as np -from numpy import ndarray -import click - -from rich.console import Console -from rich.traceback import install -from rich.prompt import Prompt -from rich.table import Table -from rich.live import Live -from rich.logging import RichHandler - - -install() # install traceback formatter - -CONFIG_LOCATIONS: List[str] = [ - "/etc/amdfan.yml", -] - -DEBUG: bool = bool(os.environ.get("DEBUG", False)) - -ROOT_DIR: str = "/sys/class/drm" -HWMON_DIR: str = "device/hwmon" - -LOGGER = logging.getLogger("rich") # type: ignore -DEFAULT_FAN_CONFIG: str = """#Fan Control Matrix. -# [,] -speed_matrix: -- [4, 4] -- [30, 33] -- [45, 50] -- [60, 66] -- [65, 69] -- [70, 75] -- [75, 89] -- [80, 100] - -# Current Min supported value is 4 due to driver bug -# -# Optional configuration options -# -# Allows for some leeway +/- temp, as to not constantly change fan speed -# threshold: 4 -# -# Frequency will change how often we probe for the temp -# frequency: 5 -# -# While frequency and threshold are optional, I highly recommend finding -# settings that work for you. I've included the defaults I use above. -# -# cards: -# can be any card returned from `ls /sys/class/drm | grep "^card[[:digit:]]$"` -# - card0 -""" - -SYSTEMD_SERVICE: str = """[Unit] -Description=amdfan controller - -[Service] -ExecStart=/usr/bin/amdfan --daemon -Restart=always - -[Install] -WantedBy=multi-user.target -""" - -logging.basicConfig( - level=logging.DEBUG if DEBUG else logging.INFO, - format="%(message)s", - datefmt="[%X]", - handlers=[RichHandler(rich_tracebacks=True)], -) - -c: Console = Console(style="green on black") - - -class Card: - """ - This class is used to map to each card that supports HWMON - """ - - HWMON_REGEX: str = r"^hwmon\d+$" - AMD_FIELDS: List[str] = [ - "temp1_input", - "pwm1_max", - "pwm1_min", - "pwm1_enable", - "pwm1", - ] - - def __init__(self, card_id: str) -> None: - self._id = card_id - - for node in os.listdir(os.path.join(ROOT_DIR, self._id, HWMON_DIR)): - if re.match(self.HWMON_REGEX, node): - self._monitor = node - self._endpoints = self._load_endpoints() - - def _verify_card(self) -> None: - for endpoint in self.AMD_FIELDS: - if endpoint not in self._endpoints: - LOGGER.info("skipping card: %s missing endpoint %s", self._id, endpoint) - raise FileNotFoundError - - def _load_endpoints(self) -> Dict: - _endpoints = {} - _dir = os.path.join(ROOT_DIR, self._id, HWMON_DIR, self._monitor) - for endpoint in os.listdir(_dir): - if endpoint not in ("device", "power", "subsystem", "uevent"): - _endpoints[endpoint] = os.path.join(_dir, endpoint) - return _endpoints - - def read_endpoint(self, endpoint: str) -> str: - with open(self._endpoints[endpoint], "r") as endpoint_file: - return endpoint_file.read() - - def write_endpoint(self, endpoint: str, data: int) -> int: - try: - with open(self._endpoints[endpoint], "w") as endpoint_file: - return endpoint_file.write(str(data)) - except PermissionError: - LOGGER.error("Failed writing to devfs file, are you running as root?") - sys.exit(1) - - @property - def fan_speed(self) -> int: - try: - return int(self.read_endpoint("fan1_input")) - except (KeyError, OSError): # better to return no speed then explode - return 0 - - @property - def gpu_temp(self) -> float: - return float(self.read_endpoint("temp1_input")) / 1000 - - @property - def fan_max(self) -> int: - return int(self.read_endpoint("pwm1_max")) - - @property - def fan_min(self) -> int: - return int(self.read_endpoint("pwm1_min")) - - def set_system_controlled_fan(self, state: bool) -> None: - - system_controlled_fan = 2 - manual_control = 1 - - self.write_endpoint( - "pwm1_enable", system_controlled_fan if state else manual_control - ) - - def set_fan_speed(self, speed: int) -> int: - if speed >= 100: - speed = self.fan_max - elif speed <= 0: - speed = self.fan_min - else: - speed = int(self.fan_max / 100 * speed) - self.set_system_controlled_fan(False) - return self.write_endpoint("pwm1", speed) - - -class Scanner: # pylint: disable=too-few-public-methods - """Used to scan the available cards to see if they are usable""" - - CARD_REGEX: str = r"^card\d$" - - def __init__(self, cards=None) -> None: - self.cards = self._get_cards(cards) - - def _get_cards(self, cards_to_scan): - """ - only directories in ROOT_DIR that are card1, card0, card3 etc. - :return: a list of initialized Card objects - """ - cards = {} - for node in os.listdir(ROOT_DIR): - if re.match(self.CARD_REGEX, node): - if cards_to_scan and node.lower() not in [ - c.lower() for c in cards_to_scan - ]: - continue - try: - cards[node] = Card(node) - except FileNotFoundError: - # if card lacks hwmon or required devfs files, its not - # amdgpu, and definitely not compatible with this software - continue - return cards - - -class FanController: # pylint: disable=too-few-public-methods - """Used to apply the curve at regular intervals""" - - def __init__(self, config) -> None: - self._scanner = Scanner(config.get("cards")) - if len(self._scanner.cards) < 1: - LOGGER.error("no compatible cards found, exiting") - sys.exit(1) - self.curve = Curve(config.get("speed_matrix")) - # default to 5 if frequency not set - self._threshold = config.get("threshold") - self._frequency = config.get("frequency", 5) - self._last_temp = 0 - - def main(self) -> None: - LOGGER.info("Starting amdfan") - while True: - for name, card in self._scanner.cards.items(): - apply = True - temp = card.gpu_temp - speed = int(self.curve.get_speed(int(temp))) - if speed < 0: - speed = 4 # due to driver bug - - LOGGER.debug( - "%s: Temp %d, \ - last temp: %d \ - target fan speed: %d, \ - fan speed %d, \ - min: %d, max: %d", - name, - temp, - self._last_temp, - speed, - card.fan_speed, - card.fan_min, - card.fan_max, - ) - if self._threshold and self._last_temp: - - LOGGER.debug("threshold and last temp, checking") - low = self._last_temp - self._threshold - high = self._last_temp + self._threshold - - LOGGER.debug("%d and %d and %d", low, high, temp) - if int(temp) in range(int(low), int(high)): - LOGGER.debug("temp in range doing nothing") - apply = False - else: - LOGGER.debug("temp out of range setting") - card.set_fan_speed(speed) - self._last_temp = temp - continue - - if apply: - card.set_fan_speed(speed) - self._last_temp = temp - - time.sleep(self._frequency) - - -class Curve: # pylint: disable=too-few-public-methods - """ - creates a fan curve based on user defined points - """ - - def __init__(self, points: list) -> None: - self.points = np.array(points) - self.temps = self.points[:, 0] - self.speeds = self.points[:, 1] - - if np.min(self.speeds) < 0: - raise ValueError( - "Fan curve contains negative speeds, \ - speed should be in [0,100]" - ) - if np.max(self.speeds) > 100: - raise ValueError( - "Fan curve contains speeds greater than 100, \ - speed should be in [0,100]" - ) - if np.any(np.diff(self.temps) <= 0): - raise ValueError( - "Fan curve points should be strictly monotonically increasing, \ - configuration error ?" - ) - if np.any(np.diff(self.speeds) < 0): - raise ValueError( - "Curve fan speeds should be monotonically increasing, \ - configuration error ?" - ) - if np.min(self.speeds) <= 3: - raise ValueError("Lowest speed value to be set to 4") # Driver BUG - - def get_speed(self, temp: int) -> ndarray[Any, Any]: - """ - returns a speed for a given temperature - :param temp: int - :return: - """ - - return np.interp(x=temp, xp=self.temps, fp=self.speeds) - - -def load_config(path) -> Callable: - LOGGER.debug("loading config from %s", path) - with open(path) as config_file: - return yaml.safe_load(config_file) - - -@click.command() -@click.option( - "--daemon", is_flag=True, default=False, help="Run as daemon applying the fan curve" -) -@click.option( - "--monitor", - is_flag=True, - default=False, - help="Run as a monitor showing temp and fan speed", -) -@click.option( - "--manual", - is_flag=True, - default=False, - help="Manually set the fan speed value of a card", -) -@click.option( - "--configuration", - is_flag=True, - default=False, - help="Prints out the default configuration for you to use", -) -@click.option( - "--service", - is_flag=True, - default=False, - help="Prints out the amdfan.service file to use with systemd", -) -def cli( - daemon: bool, monitor: bool, manual: bool, configuration: bool, service: bool -) -> None: - if daemon: - run_as_daemon() - elif monitor: - monitor_cards() - elif manual: - set_fan_speed() - elif configuration: - print(DEFAULT_FAN_CONFIG) - elif service: - print(SYSTEMD_SERVICE) - else: - c.print("Try: --help to see the options") - - -def run_as_daemon() -> None: - config = None - for location in CONFIG_LOCATIONS: - if os.path.isfile(location): - config = load_config(location) - break - - if config is None: - LOGGER.info("No config found, creating one in %s", CONFIG_LOCATIONS[-1]) - with open(CONFIG_LOCATIONS[-1], "w") as config_file: - config_file.write(DEFAULT_FAN_CONFIG) - config_file.flush() - - config = load_config(CONFIG_LOCATIONS[-1]) - - FanController(config).main() - - -def show_table(cards: Dict) -> Table: - table = Table(title="amdgpu") - table.add_column("Card") - table.add_column("fan_speed (RPM)") - table.add_column("gpu_temp ℃") - for card, card_value in cards.items(): - table.add_row(f"{card}", f"{card_value.fan_speed}", f"{card_value.gpu_temp}") - return table - - -def monitor_cards() -> None: - c.print("AMD Fan Control - ctrl-c to quit") - scanner = Scanner() - with Live(refresh_per_second=4) as live: - while 1: - time.sleep(0.4) - live.update(show_table(scanner.cards)) - - -def set_fan_speed() -> None: - scanner = Scanner() - card_to_set = Prompt.ask("Which card?", choices=scanner.cards.keys()) - while True: - input_fan_speed = Prompt.ask("Fan speed, [1..100]% or 'auto'", default="auto") - - if input_fan_speed.isdigit(): - if int(input_fan_speed) >= 1 and int(input_fan_speed) <= 100: - LOGGER.debug("good %d", int(input_fan_speed)) - break - elif input_fan_speed == "auto": - LOGGER.debug("fan speed set to auto") - break - c.print("maybe try picking one of the options") - - selected_card = scanner.cards.get(card_to_set) - if not input_fan_speed.isdigit() and input_fan_speed == "auto": - LOGGER.info("Setting fan speed to system controlled") - selected_card.set_system_controlled_fan(True) - else: - LOGGER.info("Setting fan speed to %d", int(input_fan_speed)) - c.print(selected_card.set_fan_speed(int(input_fan_speed))) - - -if __name__ == "__main__": - cli() # pylint: disable=no-value-for-parameter diff --git a/tests/test_card.py b/tests/test_card.py index fb19bb9..2da9daa 100644 --- a/tests/test_card.py +++ b/tests/test_card.py @@ -1,7 +1,6 @@ import unittest -from amdfan.amdfan import Card - +from amdfan.controller import Card pwm_max = 250 pwm_min = 0 diff --git a/tests/test_cli.py b/tests/test_cli.py index 4c2ab3a..bfc6935 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,26 +1,26 @@ import unittest from click.testing import CliRunner -from amdfan.amdfan import cli + +from amdfan.commands import cli, run_daemon, set_fan_speed class TestCli(unittest.TestCase): def test_params(self): - daemon_param = "--daemon" runner = CliRunner() - result = runner.invoke(cli, daemon_param) + result = runner.invoke(run_daemon) assert result.exception assert result.exit_code == 1 # should be permission denied for non-root - help_param = "--help" - result = runner.invoke(cli, help_param) + + result = runner.invoke(cli, ["--help"]) assert result.exit_code == 0 - config_param = "--configuration" - result = runner.invoke(cli, config_param) + + result = runner.invoke(cli, ["--configuration"]) assert result.exit_code == 0 - service_param = "--service" - result = runner.invoke(cli, service_param) + + result = runner.invoke(cli, ["--service=systemd"]) assert result.exit_code == 0 - manual_param = "--manual" - result = runner.invoke(cli, manual_param, input="\n".join(["card0", "25"])) + + result = runner.invoke(set_fan_speed, input="\n".join(["card0", "25"])) assert result.exception assert result.exit_code == 1 # should be permission denied for non-root diff --git a/tests/test_curve.py b/tests/test_curve.py index 0debeb7..3090947 100644 --- a/tests/test_curve.py +++ b/tests/test_curve.py @@ -1,6 +1,6 @@ import unittest -from amdfan.amdfan import Curve +from amdfan.controller import Curve class TestCurve(unittest.TestCase):