From 8fbda51eeb68bdab437dbd2e9280f6497ad1db06 Mon Sep 17 00:00:00 2001 From: Mazunki Hoksaas Date: Fri, 28 Jun 2024 16:29:57 +0200 Subject: [PATCH 01/19] write pidfile when running daemon --- src/amdfan/amdfan.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/amdfan/amdfan.py b/src/amdfan/amdfan.py index a1f84be..3b03a8e 100755 --- a/src/amdfan/amdfan.py +++ b/src/amdfan/amdfan.py @@ -5,6 +5,7 @@ import os import re import sys +import atexit import time from typing import Any, List, Dict, Callable import yaml @@ -30,6 +31,7 @@ ROOT_DIR: str = "/sys/class/drm" HWMON_DIR: str = "device/hwmon" +PIDFILE_DIR: str = "/var/run" LOGGER = logging.getLogger("rich") # type: ignore DEFAULT_FAN_CONFIG: str = """#Fan Control Matrix. @@ -354,7 +356,21 @@ def cli( c.print("Try: --help to see the options") +def create_pidfile(pidfile: str) -> None: + pid = os.getpid() + with open(pidfile, "w") as file: + file.write(str(pid)) + + def remove_pidfile() -> None: + if os.path.isfile(pidfile): + os.remove(pidfile) + + atexit.register(remove_pidfile) + + def run_as_daemon() -> None: + create_pidfile(os.path.join(PIDFILE_DIR, "amdfan.pid")) + config = None for location in CONFIG_LOCATIONS: if os.path.isfile(location): From e0ac6e2994f6f5ea6593a0ce5d54b1b1a16ec9a2 Mon Sep 17 00:00:00 2001 From: Mazunki Hoksaas Date: Fri, 28 Jun 2024 16:35:59 +0200 Subject: [PATCH 02/19] add logger info to pidfile --- src/amdfan/amdfan.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/amdfan/amdfan.py b/src/amdfan/amdfan.py index 3b03a8e..f70cb3a 100755 --- a/src/amdfan/amdfan.py +++ b/src/amdfan/amdfan.py @@ -357,6 +357,7 @@ def cli( def create_pidfile(pidfile: str) -> None: + LOGGER.info("Creating pifile %s", pidfile) pid = os.getpid() with open(pidfile, "w") as file: file.write(str(pid)) @@ -366,6 +367,7 @@ def remove_pidfile() -> None: os.remove(pidfile) atexit.register(remove_pidfile) + LOGGER.info("Saved pidfile with running pid=%s", pid) def run_as_daemon() -> None: From dd46279923318f5dd5e2b673229c75a5dfc13096 Mon Sep 17 00:00:00 2001 From: Mazunki Hoksaas Date: Fri, 28 Jun 2024 16:56:08 +0200 Subject: [PATCH 03/19] catch SIGHUP signal to reload config during runtime --- src/amdfan/amdfan.py | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/src/amdfan/amdfan.py b/src/amdfan/amdfan.py index f70cb3a..cd7959f 100755 --- a/src/amdfan/amdfan.py +++ b/src/amdfan/amdfan.py @@ -6,6 +6,7 @@ import re import sys import atexit +import signal import time from typing import Any, List, Dict, Callable import yaml @@ -204,7 +205,16 @@ def _get_cards(self, cards_to_scan): class FanController: # pylint: disable=too-few-public-methods """Used to apply the curve at regular intervals""" - def __init__(self, config) -> None: + def __init__(self, config_path) -> None: + self.config_path = config_path + self.reload_config() + self._last_temp = 0 + + 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") @@ -213,7 +223,6 @@ def __init__(self, config) -> None: # 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") @@ -373,21 +382,21 @@ def remove_pidfile() -> None: def run_as_daemon() -> None: create_pidfile(os.path.join(PIDFILE_DIR, "amdfan.pid")) - config = None + config_path = None for location in CONFIG_LOCATIONS: if os.path.isfile(location): - config = load_config(location) + config_path = location break - if config is None: + if config_path 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() + controller = FanController(config_path) + signal.signal(signal.SIGHUP, controller.reload_config) + controller.main() def show_table(cards: Dict) -> Table: From 24daed8c2e9238143cb0d298d9b4025e19e7cfda Mon Sep 17 00:00:00 2001 From: Mazunki Hoksaas Date: Fri, 28 Jun 2024 17:17:58 +0200 Subject: [PATCH 04/19] use dist/ for packaging purposes --- .gitignore | 1 - {src/amdfan => dist/systemd}/amdfan.service | 0 2 files changed, 1 deletion(-) rename {src/amdfan => dist/systemd}/amdfan.service (100%) diff --git a/.gitignore b/.gitignore index b6e4761..e05bb24 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,6 @@ __pycache__/ .Python build/ develop-eggs/ -dist/ downloads/ eggs/ .eggs/ diff --git a/src/amdfan/amdfan.service b/dist/systemd/amdfan.service similarity index 100% rename from src/amdfan/amdfan.service rename to dist/systemd/amdfan.service From 1b4805d28cd631f3302d66ed4744e6536af285ec Mon Sep 17 00:00:00 2001 From: Mazunki Hoksaas Date: Fri, 28 Jun 2024 17:18:08 +0200 Subject: [PATCH 05/19] add openrc service --- dist/openrc/amdfan | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100755 dist/openrc/amdfan diff --git a/dist/openrc/amdfan b/dist/openrc/amdfan new file mode 100755 index 0000000..dfdc4eb --- /dev/null +++ b/dist/openrc/amdfan @@ -0,0 +1,8 @@ +#!/sbin/openrc-run + +description="amdfan controller" +command="/usr/bin/amdfan" +command_args="--daemon" +command_background=true +pidfile="/var/run/${RC_SVCNAME}.pid" + From 77e557854c7e35795ab5a8fdcf16e50f7ed49487 Mon Sep 17 00:00:00 2001 From: Mazunki Hoksaas Date: Fri, 28 Jun 2024 20:49:06 +0200 Subject: [PATCH 06/19] autoconf ignore --- .gitignore | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.gitignore b/.gitignore index e05bb24..95c7489 100644 --- a/.gitignore +++ b/.gitignore @@ -26,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. From f85c57b6411208c4a54f532eb0c2d55cc10d5af2 Mon Sep 17 00:00:00 2001 From: Mazunki Hoksaas Date: Fri, 28 Jun 2024 20:50:58 +0200 Subject: [PATCH 07/19] organize files, support autoconf --- {src/amdfan => amdfan}/__init__.py | 0 amdfan/__main__.py | 117 ++++++ amdfan/config.py | 25 ++ src/amdfan/amdfan.py => amdfan/controller.py | 352 +++++------------- amdfan/defaults.py | 48 +++ dist/.gitignore | 3 + dist/configure.ac | 6 + dist/openrc/{amdfan => amdfan.in} | 2 +- PKGBUILD => dist/pacman/PKGBUILD.in | 4 +- .../{amdfan.service => amdfan.service.in} | 2 +- 10 files changed, 301 insertions(+), 258 deletions(-) rename {src/amdfan => amdfan}/__init__.py (100%) create mode 100755 amdfan/__main__.py create mode 100644 amdfan/config.py rename src/amdfan/amdfan.py => amdfan/controller.py (58%) mode change 100755 => 100644 create mode 100644 amdfan/defaults.py create mode 100644 dist/.gitignore create mode 100644 dist/configure.ac rename dist/openrc/{amdfan => amdfan.in} (84%) rename PKGBUILD => dist/pacman/PKGBUILD.in (84%) rename dist/systemd/{amdfan.service => amdfan.service.in} (80%) 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..e0da0a2 --- /dev/null +++ b/amdfan/__main__.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python +""" entry point for amdfan """ +# noqa: E501 +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 +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() +@click.option( + "--daemon", is_flag=True, default=False, help="Run as daemon applying the fan curve" +) +@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, + daemon: bool, + configuration: bool, + service: bool, +) -> None: + if daemon: + FanController.start_daemon() + elif configuration: + print(DEFAULT_FAN_CONFIG) + elif service in SERVICES: + print(SERVICES[service]) + else: + print(ctx.get_help()) + sys.exit(1) + + +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() +@click.option( + "--monitor", + is_flag=True, + default=False, + help="Run as a monitor showing temp and fan speed", +) +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)) + + +@click.command() +@click.option( + "--manual", + is_flag=True, + default=False, + help="Manually set the fan speed value of a card", +) +def set_fan_speed() -> None: + scanner = Scanner() + card_to_set = Prompt.ask("Which card?", choices=list(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 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/src/amdfan/amdfan.py b/amdfan/controller.py old mode 100755 new mode 100644 similarity index 58% rename from src/amdfan/amdfan.py rename to amdfan/controller.py index cd7959f..650eb4d --- a/src/amdfan/amdfan.py +++ b/amdfan/controller.py @@ -1,89 +1,76 @@ #!/usr/bin/env python -""" Main amdfan script """ -# noqa: E501 -import logging +""" manages the amd gpu fans on your system """ +import atexit import os import re -import sys -import atexit import signal +import sys import time -from typing import Any, List, Dict, Callable -import yaml +from typing import Any, Callable, Dict, List, Self + import numpy as np +import yaml 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" -PIDFILE_DIR: str = "/var/run" - -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") + +from .config import CONFIG_LOCATIONS, HWMON_DIR, LOGGER, PIDFILE_DIR, 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) + + +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: @@ -123,12 +110,12 @@ def _load_endpoints(self) -> Dict: return _endpoints def read_endpoint(self, endpoint: str) -> str: - with open(self._endpoints[endpoint], "r") as endpoint_file: + 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") as endpoint_file: + 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?") @@ -176,7 +163,8 @@ def set_fan_speed(self, speed: int) -> int: 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$" + CARD_REGEX: str = r"^card+\d$" + cards: Dict[str, Card] def __init__(self, cards=None) -> None: self.cards = self._get_cards(cards) @@ -205,6 +193,11 @@ def _get_cards(self, cards_to_scan): 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) -> None: self.config_path = config_path self.reload_config() @@ -219,9 +212,8 @@ def apply(self, config) -> None: 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._curve = Curve(config.get("speed_matrix")) + self._threshold = config.get("threshold", 0) self._frequency = config.get("frequency", 5) def main(self) -> None: @@ -230,7 +222,7 @@ def main(self) -> None: for name, card in self._scanner.cards.items(): apply = True temp = card.gpu_temp - speed = int(self.curve.get_speed(int(temp))) + speed = int(self._curve.get_speed(int(temp))) if speed < 0: speed = 4 # due to driver bug @@ -256,10 +248,10 @@ def main(self) -> None: 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") + LOGGER.debug("temp in range, doing nothing") apply = False else: - LOGGER.debug("temp out of range setting") + LOGGER.debug("temp out of range, setting") card.set_fan_speed(speed) self._last_temp = temp continue @@ -270,177 +262,29 @@ def main(self) -> None: time.sleep(self._frequency) + @classmethod + def start_daemon(cls) -> Self: + create_pidfile(os.path.join(PIDFILE_DIR, "amdfan.pid")) -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 + config_path = None + for location in CONFIG_LOCATIONS: + if os.path.isfile(location): + config_path = location + break - def get_speed(self, temp: int) -> ndarray[Any, Any]: - """ - returns a speed for a given temperature - :param temp: int - :return: - """ + 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() - return np.interp(x=temp, xp=self.temps, fp=self.speeds) + controller = cls(config_path) + 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) as config_file: + with open(path, encoding="utf8") 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 create_pidfile(pidfile: str) -> None: - LOGGER.info("Creating pifile %s", pidfile) - pid = os.getpid() - with open(pidfile, "w") 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 run_as_daemon() -> None: - create_pidfile(os.path.join(PIDFILE_DIR, "amdfan.pid")) - - 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") as config_file: - config_file.write(DEFAULT_FAN_CONFIG) - config_file.flush() - - controller = FanController(config_path) - signal.signal(signal.SIGHUP, controller.reload_config) - controller.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/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..20e21ea --- /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 b/dist/openrc/amdfan.in similarity index 84% rename from dist/openrc/amdfan rename to dist/openrc/amdfan.in index dfdc4eb..c249460 100755 --- a/dist/openrc/amdfan +++ b/dist/openrc/amdfan.in @@ -1,7 +1,7 @@ #!/sbin/openrc-run description="amdfan controller" -command="/usr/bin/amdfan" +command="@bindir@/amdfan" command_args="--daemon" command_background=true pidfile="/var/run/${RC_SVCNAME}.pid" diff --git a/PKGBUILD b/dist/pacman/PKGBUILD.in similarity index 84% rename from PKGBUILD rename to dist/pacman/PKGBUILD.in index 4e1be89..f7b507b 100644 --- a/PKGBUILD +++ b/dist/pacman/PKGBUILD.in @@ -22,6 +22,6 @@ build() { package() { cd "$srcdir/$pkgname-$pkgver" python -m installer --destdir="$pkgdir" dist/*.whl - mkdir -p "$pkgdir/usr/lib/systemd/system" - install -Dm644 src/amdfan/amdfan.service "$pkgdir/usr/lib/systemd/system/" + mkdir -p "$pkgdir/@libdir@/systemd/system" + install -Dm644 amdfan/packaging/pacman/amdfan.service "$pkgdir/@libdir@/systemd/system/" } diff --git a/dist/systemd/amdfan.service b/dist/systemd/amdfan.service.in similarity index 80% rename from dist/systemd/amdfan.service rename to dist/systemd/amdfan.service.in index 940e96a..685f835 100644 --- a/dist/systemd/amdfan.service +++ b/dist/systemd/amdfan.service.in @@ -4,7 +4,7 @@ After=multi-user.target Requires=multi-user.target [Service] -ExecStart=/usr/bin/amdfan --daemon +ExecStart=@bindir@/amdfan --daemon Restart=always [Install] From 803483a7c71b6f382859b5afbd995870ca65de4b Mon Sep 17 00:00:00 2001 From: Mazunki Hoksaas Date: Fri, 28 Jun 2024 22:55:58 +0200 Subject: [PATCH 08/19] change some flags to subcommands --- amdfan/__main__.py | 117 ++++-------------------------------------- amdfan/commands.py | 123 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+), 107 deletions(-) create mode 100644 amdfan/commands.py diff --git a/amdfan/__main__.py b/amdfan/__main__.py index e0da0a2..6a22a7b 100755 --- a/amdfan/__main__.py +++ b/amdfan/__main__.py @@ -1,117 +1,20 @@ #!/usr/bin/env python """ entry point for amdfan """ -# noqa: E501 -import sys -import time -from typing import Dict - +# __main__.py 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 -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() -@click.option( - "--daemon", is_flag=True, default=False, help="Run as daemon applying the fan curve" -) -@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, - daemon: bool, - configuration: bool, - service: bool, -) -> None: - if daemon: - FanController.start_daemon() - elif configuration: - print(DEFAULT_FAN_CONFIG) - elif service in SERVICES: - print(SERVICES[service]) - else: - print(ctx.get_help()) - sys.exit(1) - - -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() -@click.option( - "--monitor", - is_flag=True, - default=False, - help="Run as a monitor showing temp and fan speed", -) -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)) +from .commands import cli, monitor_cards, run_daemon, set_fan_speed -@click.command() -@click.option( - "--manual", - is_flag=True, - default=False, - help="Manually set the fan speed value of a card", -) -def set_fan_speed() -> None: - scanner = Scanner() - card_to_set = Prompt.ask("Which card?", choices=list(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") +@click.group() +def main(): + pass - 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))) +main.add_command(cli) +main.add_command(run_daemon) +main.add_command(monitor_cards) +main.add_command(set_fan_speed) if __name__ == "__main__": - cli() # pylint: disable=no-value-for-parameter + main() # pylint: disable=no-value-for-parameter diff --git a/amdfan/commands.py b/amdfan/commands.py new file mode 100644 index 0000000..f4e9174 --- /dev/null +++ b/amdfan/commands.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python +""" entry point for amdfan """ +# noqa: E501 +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 +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: bool, +) -> 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("--background", is_flag=True, default=True) +def run_daemon(): + FanController.start_daemon() + + +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 From c518f925a33322194391a05196152c0d26fa68b8 Mon Sep 17 00:00:00 2001 From: Mazunki Hoksaas Date: Fri, 28 Jun 2024 22:56:26 +0200 Subject: [PATCH 09/19] ignore wheels --- dist/.gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dist/.gitignore b/dist/.gitignore index 20e21ea..f479f66 100644 --- a/dist/.gitignore +++ b/dist/.gitignore @@ -1,3 +1,3 @@ -*/* +* !**/*.in From 453f130f0854a2fcd9da02d60f9abf5ff780b0f7 Mon Sep 17 00:00:00 2001 From: Mazunki Hoksaas Date: Fri, 28 Jun 2024 22:56:52 +0200 Subject: [PATCH 10/19] change entry point to main --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index b622496..809168c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,7 +38,7 @@ mypy = "*" types-pyyaml = "*" [tool.poetry.scripts] -amdfan = 'amdfan.amdfan:cli' +amdfan = 'amdfan.__main__:main' [build-system] requires = ["poetry-core"] From 019ef899eec624094460482338ad1ad08a2033cc Mon Sep 17 00:00:00 2001 From: Mazunki Hoksaas Date: Fri, 28 Jun 2024 22:57:05 +0200 Subject: [PATCH 11/19] update documentation --- INSTALL.md | 73 ++++++++++++++++++++++++ README.md | 164 +++++++++-------------------------------------------- 2 files changed, 101 insertions(+), 136 deletions(-) create mode 100644 INSTALL.md diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000..bc5a8dc --- /dev/null +++ b/INSTALL.md @@ -0,0 +1,73 @@ + +# 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 +`src/amdfan` cont + +# 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..d31420e 100644 --- a/README.md +++ b/README.md @@ -34,102 +34,42 @@ 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 - -Amdfan is also runnable as a systemd service, with the provided ```amdfan.service```. +Commands: + daemon Run the controller + monitor View the current temperature and speed + print-default Convenient defaults + set Manually override the fan speed +``` -## 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. Commented -`/etc/amdfan.yml` ``` bash speed_matrix: - [4, 4] @@ -143,68 +83,20 @@ 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 ``` +You can use this to generate your configuration by doing `amdfan print-default --configuration | sudo tee amdfan.yml`, which you can manually edit. +`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 Arch AUR package - -Building the Arch package assumes you already have a chroot env setup to build packages. +# Install -```bash -git clone https://aur.archlinux.org/amdfan.git -cd amdfan/ -makechrootpkg -c -r $HOME/$CHROOT -``` - -## 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 -``` From c62bbe36e516ee7995df01e7d6d71078e1162205 Mon Sep 17 00:00:00 2001 From: Mazunki Hoksaas Date: Fri, 28 Jun 2024 23:07:22 +0200 Subject: [PATCH 12/19] update tests. permission denied claims 2==1? --- tests/test_card.py | 3 +-- tests/test_cli.py | 17 +++++++++++------ tests/test_curve.py | 2 +- 3 files changed, 13 insertions(+), 9 deletions(-) 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..81e7637 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,26 +1,31 @@ import unittest from click.testing import CliRunner -from amdfan.amdfan import cli + +from amdfan.commands import cli class TestCli(unittest.TestCase): def test_params(self): - daemon_param = "--daemon" + daemon_param = ["daemon"] runner = CliRunner() result = runner.invoke(cli, daemon_param) assert result.exception assert result.exit_code == 1 # should be permission denied for non-root - help_param = "--help" + + help_param = ["--help"] result = runner.invoke(cli, help_param) assert result.exit_code == 0 - config_param = "--configuration" + + config_param = ["print-default", "--configuration"] result = runner.invoke(cli, config_param) assert result.exit_code == 0 - service_param = "--service" + + service_param = ["print-default", "--service=systemd"] result = runner.invoke(cli, service_param) assert result.exit_code == 0 - manual_param = "--manual" + + manual_param = "set" result = runner.invoke(cli, manual_param, 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): From 3e81354201d4d908bed990852aa95521cc940497 Mon Sep 17 00:00:00 2001 From: Mazunki Hoksaas Date: Fri, 28 Jun 2024 23:21:27 +0200 Subject: [PATCH 13/19] reload configuraiton with SIGHUP --- README.md | 2 ++ dist/openrc/amdfan.in | 4 ++++ dist/systemd/amdfan.service.in | 1 + 3 files changed, 7 insertions(+) diff --git a/README.md b/README.md index d31420e..73731c4 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,8 @@ You can use this to generate your configuration by doing `amdfan print-default - `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. +Note! You can send a SIGHUP signal to the daemon to request a reload of the config without restarting the whole service. + # Install 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. diff --git a/dist/openrc/amdfan.in b/dist/openrc/amdfan.in index c249460..3936ed0 100755 --- a/dist/openrc/amdfan.in +++ b/dist/openrc/amdfan.in @@ -6,3 +6,7 @@ command_args="--daemon" command_background=true pidfile="/var/run/${RC_SVCNAME}.pid" +reload() { + start-stop-daemon --signal SIGHUP --pidfile "${pidfile}" +} + diff --git a/dist/systemd/amdfan.service.in b/dist/systemd/amdfan.service.in index 685f835..9fb87a4 100644 --- a/dist/systemd/amdfan.service.in +++ b/dist/systemd/amdfan.service.in @@ -5,6 +5,7 @@ Requires=multi-user.target [Service] ExecStart=@bindir@/amdfan --daemon +ExecReload=kill -HUP $MAINPID Restart=always [Install] From 54ad6c624141128805f7e8004a7c5873637c25ac Mon Sep 17 00:00:00 2001 From: Mazunki Hoksaas Date: Fri, 28 Jun 2024 23:54:14 +0200 Subject: [PATCH 14/19] support notification-fd for ready state --- amdfan/commands.py | 10 +++-- amdfan/controller.py | 105 ++++++++++++++++++++++++------------------- 2 files changed, 67 insertions(+), 48 deletions(-) diff --git a/amdfan/commands.py b/amdfan/commands.py index f4e9174..082bbca 100644 --- a/amdfan/commands.py +++ b/amdfan/commands.py @@ -1,6 +1,7 @@ #!/usr/bin/env python """ entry point for amdfan """ # noqa: E501 +import os import sys import time from typing import Dict @@ -12,7 +13,7 @@ from rich.table import Table from rich.traceback import install -from .config import LOGGER +from .config import LOGGER, PIDFILE_DIR from .controller import FanController, Scanner from .defaults import DEFAULT_FAN_CONFIG, SERVICES @@ -52,9 +53,12 @@ def cli( name="daemon", help="Run the controller", ) +@click.option("--notification-fd", type=int) # @click.option("--background", is_flag=True, default=True) -def run_daemon(): - FanController.start_daemon() +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: diff --git a/amdfan/controller.py b/amdfan/controller.py index 650eb4d..4dcc39c 100644 --- a/amdfan/controller.py +++ b/amdfan/controller.py @@ -6,13 +6,13 @@ import signal import sys import time -from typing import Any, Callable, Dict, List, Self +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, PIDFILE_DIR, ROOT_DIR +from .config import CONFIG_LOCATIONS, HWMON_DIR, LOGGER, ROOT_DIR from .defaults import DEFAULT_FAN_CONFIG @@ -30,6 +30,10 @@ def remove_pidfile() -> None: 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 @@ -198,10 +202,11 @@ class FanController: # pylint: disable=too-few-public-methods _threshold: int _frequency: int - def __init__(self, config_path) -> None: + 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) @@ -218,53 +223,62 @@ def apply(self, config) -> None: 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(): - 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 + 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) -> Self: - create_pidfile(os.path.join(PIDFILE_DIR, "amdfan.pid")) + 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: @@ -278,9 +292,10 @@ def start_daemon(cls) -> Self: config_file.write(DEFAULT_FAN_CONFIG) config_file.flush() - controller = cls(config_path) + controller = cls(config_path, notification_fd=notification_fd) signal.signal(signal.SIGHUP, controller.reload_config) controller.main() + return controller From 55416b27b7926c0df88e261102cefd14408eb760 Mon Sep 17 00:00:00 2001 From: Mazunki Hoksaas Date: Sat, 29 Jun 2024 00:47:02 +0200 Subject: [PATCH 15/19] clarifications and typos --- INSTALL.md | 14 +++++++++++++- README.md | 16 ++++++++++------ 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/INSTALL.md b/INSTALL.md index bc5a8dc..bed952f 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -35,7 +35,19 @@ amdfan print-default --configuration | sudo tee /etc/amdfan.yml # Executable files -`src/amdfan` cont +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. diff --git a/README.md b/README.md index 73731c4..f236b3a 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,8 @@ Commands: set Manually override the fan speed ``` +Each subcommand supports `--help` too, to get more details. + ## Controlling the fans 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. @@ -68,8 +70,9 @@ You can use Amdfan to monitor your AMD video cards using the `monitor` flag. Thi ## Configuration -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. Commented +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. +The following config is probably a reasonable setup: ``` bash speed_matrix: - [4, 4] @@ -87,12 +90,13 @@ frequency: 5 # cards: # - card0 ``` -You can use this to generate your configuration by doing `amdfan print-default --configuration | sudo tee amdfan.yml`, which you can manually edit. -`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. +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. + +- `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. Note! You can send a SIGHUP signal to the daemon to request a reload of the config without restarting the whole service. From 00cf6eaf68f690dfe7b8ef2f07285a75b626140e Mon Sep 17 00:00:00 2001 From: mcgillij Date: Sun, 7 Jul 2024 03:21:35 -0300 Subject: [PATCH 16/19] revert the PKGBUILD temporarily since it's blocking the build and fix the broken tests, will have to redo how the commands are put togther, however this will work for now. --- PKGBUILD | 27 +++++++++++++++++++++++++++ tests/test_cli.py | 17 ++++++----------- 2 files changed, 33 insertions(+), 11 deletions(-) create mode 100644 PKGBUILD diff --git a/PKGBUILD b/PKGBUILD new file mode 100644 index 0000000..68f3b6e --- /dev/null +++ b/PKGBUILD @@ -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.2.0 +pkgrel=1 +arch=('any') +license=('GPL2') +depends=('python' 'python-yaml' 'python-numpy' 'python-rich' 'python-click') +makedepends=('python-poetry-core' '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=('58db7ccf6255d4866efc75cc9c89a66a') + +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/usr/lib/systemd/system" + install -Dm644 src/amdfan/amdfan.service "$pkgdir/usr/lib/systemd/system/" +} diff --git a/tests/test_cli.py b/tests/test_cli.py index 81e7637..bfc6935 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2,30 +2,25 @@ from click.testing import CliRunner -from amdfan.commands 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 = ["print-default", "--configuration"] - result = runner.invoke(cli, config_param) + result = runner.invoke(cli, ["--configuration"]) assert result.exit_code == 0 - service_param = ["print-default", "--service=systemd"] - result = runner.invoke(cli, service_param) + result = runner.invoke(cli, ["--service=systemd"]) assert result.exit_code == 0 - manual_param = "set" - 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 From 1de3738164a6c38b20990d00aeae6efb775edcb0 Mon Sep 17 00:00:00 2001 From: mcgillij Date: Sun, 7 Jul 2024 03:38:12 -0300 Subject: [PATCH 17/19] fix type error, and make mypy check the new source dir --- .github/workflows/python-package.yml | 2 +- amdfan/commands.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 696c491..e831549 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -36,4 +36,4 @@ jobs: poetry run pytest - name: Test with mypy run: | - poetry run mypy src/ + poetry run mypy amdfan/ diff --git a/amdfan/commands.py b/amdfan/commands.py index 082bbca..e6259e9 100644 --- a/amdfan/commands.py +++ b/amdfan/commands.py @@ -38,7 +38,7 @@ def cli( ctx: click.Context, configuration: bool, - service: bool, + service: str, ) -> None: if configuration: print(DEFAULT_FAN_CONFIG) From 80683d8190da9698bcb799333f3de7db621ccea4 Mon Sep 17 00:00:00 2001 From: mcgillij Date: Sun, 7 Jul 2024 03:47:36 -0300 Subject: [PATCH 18/19] remove python3.10 from the fix_tests_and_build since typing.Self was introduced in 3.11 not going to deal with backports --- .github/workflows/python-package.yml | 2 +- pyproject.toml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index e831549..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 diff --git a/pyproject.toml b/pyproject.toml index 809168c..3ebe25a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,8 +22,8 @@ classifiers = [ ] [tool.poetry.dependencies] -python = "^3.10 || ^3.11 || ^3.12" -numpy = "^1.23.5" +python = "^3.11 || ^3.12" +numpy = "^2.0.0" pyyaml = "^6.0" click = "^8.1.3" rich = "^13.0.0" From a774d49d218073c99aecefd388c7303d8e3adede Mon Sep 17 00:00:00 2001 From: mcgillij Date: Sun, 7 Jul 2024 03:52:16 -0300 Subject: [PATCH 19/19] regenerate lock without python3.10 --- poetry.lock | 39 ++------------------------------------- 1 file changed, 2 insertions(+), 37 deletions(-) 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"