Skip to content

Commit

Permalink
Merge pull request #37 from mazunki/main
Browse files Browse the repository at this point in the history
add better daemon support
  • Loading branch information
mcgillij authored Jul 30, 2024
2 parents 52ad7fd + 0a2c791 commit 3b6811c
Show file tree
Hide file tree
Showing 5 changed files with 209 additions and 30 deletions.
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,12 @@ These are all addressed in Amdfan, and as long as I’ve still got some AMD card
Usage: amdfan [OPTIONS] COMMAND [ARGS]...
Options:
--help Show this message and exit.
-v, --version Show the version and exit.
--help Show this message and exit.
Commands:
daemon Run the controller
daemon Run the controller as a service
manage Run the fan controller
monitor View the current temperature and speed
print-default Convenient defaults
set Manually override the fan speed
Expand Down Expand Up @@ -91,14 +93,14 @@ frequency: 5
# - 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.
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 the daemon, 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.
Note! You can send a **SIGHUP** signal to the daemon to request a reload of the config without restarting the whole service. Additionally, if you're using a pidfile, you can send a signal to reload the config with `amdfan daemon --signal=reload`

# Install

Expand Down
4 changes: 3 additions & 1 deletion amdfan/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,17 @@
# __main__.py
import click

from .commands import cli, monitor_cards, run_daemon, set_fan_speed
from .commands import cli, monitor_cards, run_daemon, run_manager, set_fan_speed


@click.group()
@click.version_option(None, "-v", "--version", prog_name="amdfan")
def main():
pass


main.add_command(cli)
main.add_command(run_manager)
main.add_command(run_daemon)
main.add_command(monitor_cards)
main.add_command(set_fan_speed)
Expand Down
123 changes: 112 additions & 11 deletions amdfan/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
""" entry point for amdfan """
# noqa: E501
import os
import signal
import sys
import time
from typing import Dict
Expand Down Expand Up @@ -49,15 +50,99 @@ def cli(
sys.exit(1)


@click.command(
name="manage",
short_help="Run the controller",
help="Run the controller in the foreground.\n\nThis command mostly exists for short-lived invocations on a manual shell. For more options and customization, consider using the *daemon* subcommand instead.",
)
def run_manager():
FanController.start_manager(daemon=False)


class FileDescriptorOpt(click.ParamType):
name = "fd"


@click.command(
name="daemon",
help="Run the controller",
help="Run the controller as a service",
)
@click.option(
"-f",
"--notification-fd",
type=FileDescriptorOpt(),
help="Specify file descriptor for ready state",
)
@click.option(
"--pidfile",
"-p",
type=click.Path(),
default=os.path.join(PIDFILE_DIR, "amdfan.pid"),
help="Pidfile path",
show_default=True,
)
@click.option(
"--no-pidfile",
is_flag=True,
help="Disable pidfile",
)
@click.option(
"-l",
"--logfile",
type=click.Path(),
default="/var/log/amdfan.log",
help="Logging path",
show_default=True,
)
@click.option(
"--no-logfile",
"--stdout",
is_flag=True,
help="Disable logging file (prints to stdout instead)",
)
@click.option(
"-b",
"--daemon",
"--background",
is_flag=True,
help="Fork into the background as a daemon. Note that this detaches amdfan from your terminal.",
)
@click.option(
"-s",
"--signal",
"action",
type=click.Choice(["stop", "reload"]),
default=None,
help="Stop or reload a running instance from the given pidfile",
)
@click.option("--notification-fd", type=int)
def run_daemon(notification_fd):
FanController.start_daemon(
notification_fd=notification_fd, pidfile=os.path.join(PIDFILE_DIR, "amdfan.pid")
)
def run_daemon(
notification_fd, logfile, pidfile, daemon, no_pidfile, no_logfile, action
):
if no_pidfile:
pidfile = None
if no_logfile:
logfile = None

if action:
try:
with open(pidfile) as f:
pid = int(f.read())
if action == "stop":
os.kill(pid, signal.SIGTERM)
elif action == "reload":
os.kill(pid, signal.SIGHUP)

except FileNotFoundError:
LOGGER.warning("Could not find pidfile=%s", pidfile)
except ValueError:
LOGGER.warning("Invalid PID value in pidfile=%s", pidfile)
else:
FanController.start_manager(
notification_fd=notification_fd,
pidfile=pidfile,
logfile=logfile,
daemon=daemon,
)


def show_table(cards: Dict) -> Table:
Expand All @@ -71,8 +156,8 @@ def show_table(cards: Dict) -> 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")
@click.option("--fps", default=5, help="Updates per second", show_default=True)
@click.option("-1", "--single-run", is_flag=True, default=False, help="Print and exit")
def monitor_cards(fps, single_run) -> None:
scanner = Scanner()
if not single_run:
Expand All @@ -86,9 +171,25 @@ def monitor_cards(fps, single_run) -> None:
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")
class CardOpt(click.ParamType):
name = "CARD"


class SpeedOpt(click.ParamType):
name = "SPEED"


@click.command(
name="set",
short_help="Manually override the fan speed",
help="Manually override the fan speed.\n\nIf insufficient properties are passed, goes into interactive mode. If both values are valid, interactive mode is not necessary\n\nSpeed values are set with integer values representing a percentage, and can be reverted back to the automatic controller by setting the speed to 'auto'.",
)
@click.option("--card", type=CardOpt(), help="Specify which card to override")
@click.option(
"--speed",
type=SpeedOpt(),
help="Specify which speed to change to",
)
def set_fan_speed(card, speed) -> None:
scanner = Scanner()
if card is None:
Expand Down
93 changes: 82 additions & 11 deletions amdfan/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
import re
import signal
import sys
import time
from typing import Any, Callable, Dict, List, Optional, Self
import threading
from typing import Any, Callable, Dict, List, Optional

import numpy as np
import yaml
Expand Down Expand Up @@ -34,6 +34,40 @@ def report_ready(fd: int) -> None:
os.write(fd, b"READY=1\n")


def daemonize(stdin="/dev/null", stdout="/dev/null", stderr="/dev/null") -> None:
try:
pid = os.fork()
if pid > 0:
os._exit(0)
except OSError as e:
raise Exception("Unable to background amdfan: %s" % e)

os.chdir("/")
os.setsid()
os.umask(0)

try:
pid = os.fork()
if pid > 0:
os._exit(0)
except OSError as e:
raise Exception("Unable to daemonize amdfan: %s" % e)

redirect_fd(stdin, stdout, stderr)


def redirect_fd(stdin="/dev/null", stdout="/dev/null", stderr="/dev/null"):
sys.stdout.flush()
sys.stderr.flush()
with open(stdin, "r") as f:
os.dup2(f.fileno(), sys.stdin.fileno())

with open(stdout, "a+") as f:
os.dup2(f.fileno(), sys.stdout.fileno())
with open(stderr, "a+") as f:
os.dup2(f.fileno(), sys.stderr.fileno())


class Curve: # pylint: disable=too-few-public-methods
"""
creates a fan curve based on user defined points
Expand Down Expand Up @@ -204,13 +238,26 @@ class FanController: # pylint: disable=too-few-public-methods

def __init__(self, config_path, notification_fd=None) -> None:
self.config_path = config_path
self.reload_config()
self.load_config()
self._last_temp = 0
self._ready_fd = notification_fd
self._running = False
self._stop_event = threading.Event()

def reload_config(self, *_) -> None:
LOGGER.info("Received request to reload config")
self.load_config()

def terminate(self, *_) -> None:
LOGGER.info("Shutting down controller")
self._running = False
self._stop_event.set()

def load_config(self) -> None:
LOGGER.info("Loading configuration")
config = load_config(self.config_path)
self.apply(config)
LOGGER.info("Configuration succesfully loaded")

def apply(self, config) -> None:
self._scanner = Scanner(config.get("cards"))
Expand All @@ -222,15 +269,17 @@ def apply(self, config) -> None:
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:
self._running = True
LOGGER.info("Controller is running")
while self._running:
for name, card in self._scanner.cards.items():
self.refresh_card(name, card)

time.sleep(self._frequency)
self._stop_event.wait(self._frequency)
LOGGER.info("Stopped controller")

def refresh_card(self, name, card):
apply = True
Expand Down Expand Up @@ -274,16 +323,37 @@ def refresh_card(self, name, card):
self._last_temp = temp

@classmethod
def start_daemon(
cls, notification_fd: Optional[int], pidfile: Optional[str] = None
) -> Self:
def start_manager(
cls,
notification_fd: Optional[int] = None,
pidfile: Optional[str] = None,
daemon=False,
logfile=None,
) -> None:
if daemon:
daemonize(stdout=logfile, stderr=logfile)
elif logfile:
redirect_fd(stdout=logfile, stderr=logfile)

if logfile:
open(logfile, "w").close() # delete old logs

LOGGER.info("Launching the amdfan controller")

if pidfile:
if os.path.isfile(pidfile):
with open(pidfile, "r") as f:
LOGGER.warning(
"Already found a pidfile for amdfan. Old PID was: %s",
f.read(),
)
create_pidfile(pidfile)

config_path = None
for location in CONFIG_LOCATIONS:
if os.path.isfile(location):
config_path = location
LOGGER.info("Found configuration file at %s", config_path)
break

if config_path is None:
Expand All @@ -294,9 +364,10 @@ def start_daemon(

controller = cls(config_path, notification_fd=notification_fd)
signal.signal(signal.SIGHUP, controller.reload_config)
signal.signal(signal.SIGTERM, controller.terminate)
signal.signal(signal.SIGINT, controller.terminate)
controller.main()

return controller
LOGGER.info("Goodbye")


def load_config(path) -> Callable:
Expand Down
9 changes: 6 additions & 3 deletions dist/openrc/amdfan.in
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
#!/sbin/openrc-run

description="amdfan controller"
command="@bindir@/amdfan"
command_args="--daemon"
command_background=true
pidfile="/var/run/${RC_SVCNAME}.pid"

command="@bindir@/amdfan"
command_args="daemon --logfile=/var/log/amdfan.log --pidfile=${pidfile}"
command_args_background="--background"

extra_started_commands="reload"

reload() {
start-stop-daemon --signal SIGHUP --pidfile "${pidfile}"
}
Expand Down

0 comments on commit 3b6811c

Please sign in to comment.