From c5ec1c01c0b4cbb33e75261a62f237bd901738dc Mon Sep 17 00:00:00 2001 From: Sven Ziegler <43136984+Svenum@users.noreply.github.com> Date: Mon, 26 Aug 2024 00:36:52 +0200 Subject: [PATCH] Rebase Packaging/nix (#77) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Adding @Svenum as an assignee to nix-related issues (#43) * Fixing adding @Svenum as an assignee to nix-related issues (#44) (non contributors cannot be assigned to issues) * Reload will report if config couldn't be parsed and the service keeps running. (#46) Authored-by: Nina Alexandra Klama * Removing binary blobs from the project (#51) * removing binary blobs from the project. we now fetch the ectool from the gitlab artifacts and confirm the checksum. * remove bin references from README.md * extracting $TEMP_FOLDER from installEctool * Fix README spelling/grammer, fix "FrameWork" capitalization in service description (#52) * Review README spelling/grammar * Fix "FrameWork" capitalization in service * Clarify behaviour on service stop or pause (#53) (#55) * Separating FanController into different subclasses to allow HardwareController and SocketController diversity. 2 (Repost of #50) (#58) * separating `FanController` into different subclasses to allow `HardwareController` and `SocketController` diversity * adding the new arguments into the README.md * fixing an indentation error causing `--strategy ` not to work (the simple `` still worked) * fixing missing print for command execution (#63) * forcing utf-8 encoding for socket messages and usage of stopServerSocket method instead of manual closing, as well as updating error detection pattern (#64) * README.md documentation update (#65) * forcing utf-8 encoding for socket messages and usage of stopServerSocket method instead of manual closing, as well as updating error detection pattern * README.md documentation update * change log format on fatal crash * fix badges links * adding windows platform badge and issue template * fix `:` instead of `=` * disabling part of the README.md while waiting for merge * Command arguments refactoring Pt.1 (#66) * forcing utf-8 encoding for socket messages and usage of stopServerSocket method instead of manual closing, as well as updating error detection pattern * README.md documentation update * change log format on fatal crash * fix badges links * adding windows platform badge and issue template * fix `:` instead of `=` * first part of the command argument refactoring. the old argument format is deprecated but still usable. improved feedback when executing commands. #31 * trim blank lines in README.md * finishing touches (#67) * add no battery mode for mainboards without battery (#69) * add configuration for no battery mode in hardware controller * fix wrong line getting noBatteryMode * dynamically fetching battery sensor on init/reload * add --no-battery flag for install * update readme with --no-battery flag * rework no battery config to come from service args * change sensors to be ectool specific - reword the argument to be more clear about battery sensors - move `noBatteryMode` and `nonBatterySensors` to EctoolHardwareController - update `getNonBatterySensors` to be able to handle more than one sensor - update installer and readme accordingly * update grep command for checking existing `--no-battery-sensors` * combine getTemperature functions to one * add documentation for run option `--no-battery-sensors` * rename variable `NO_BATTERY` to `NO_BATTERY_SENSOR` * update the installer to use existing placeholder format * rename noBatterySensorMode variables and functions for clarity * rename placeholder to `NO_BATTERY_SENSOR_OPTION` for clarity * update comments in installer to reflect new argument name * adding ectool sub-dependency to documentation (#70) * typo "tempurature" => "temperature" (#71) typo "tempurature" => "temperature" * typo "tempurature" => "temperature" (#72) typo "tempurature" => "temperature" * Add ToC + link to NixOS Documentation (#75) * add doc folder * update nix link * add toc * add link * add missin # * add doc * fix link * add new line under titles * add --no-sudo option (#76) * Add choice to print fan speed percentage (#78) * Add option to print current speed percentage * Update README.md * Update commands.md * Add print choice descriptions to help text * add missing no_sudo check (#79) * Add NixOS Flake (#26) * initial * update gitignore * update inputs * add fw-ectool dependencie * add module * fix tabs * fix package * fix typo * fix service * fix type * add options * fix service * fix build inputs * add Readme + add suspend script * remove unneeded }; * fix pkgs.writeShellScript * remvoe \ * try * add self * fix module * update package * fix package * use sleep script * add config options * fix typo * fix typo * add defaults * fix type * add prettyier * remove beautifyer * udpate readme * update installer script * add missing path * Update README.md Co-authored-by: Thomas Eizinger * Update flake.nix Co-authored-by: Thomas Eizinger * Update nix/module.nix Co-authored-by: Thomas Eizinger * add descriptions * fix uninstall * update readme * add description * remove requiremetns.txt + add github actions * update action * rename workflow test * fix service * try * try * Update README.md * Update README.md * chagne flake description * fix suspend script * fix script * fix path * fix install.sh * fix --no-sudo * add --no-sudo to other scripts * fix check * add option check * add missing " * Rename nix action --------- Co-authored-by: Thomas Eizinger * Update branch to main branch (#54) * Adding @Svenum as an assignee to nix-related issues (#43) * Fixing adding @Svenum as an assignee to nix-related issues (#44) (non contributors cannot be assigned to issues) * Reload will report if config couldn't be parsed and the service keeps running. (#46) Authored-by: Nina Alexandra Klama * Removing binary blobs from the project (#51) * removing binary blobs from the project. we now fetch the ectool from the gitlab artifacts and confirm the checksum. * remove bin references from README.md * extracting $TEMP_FOLDER from installEctool * Fix README spelling/grammer, fix "FrameWork" capitalization in service description (#52) * Review README spelling/grammar * Fix "FrameWork" capitalization in service * use ectool form nixpkgs * update flake * remove old deps * remove duplicated pkgs --------- Co-authored-by: Léopold Hubert Co-authored-by: Nina Alexandra Klama Co-authored-by: DeflateAwning <11021263+DeflateAwning@users.noreply.github.com> * Update to main branch + switch to fw-ectool (#61) * Adding @Svenum as an assignee to nix-related issues (#43) * Fixing adding @Svenum as an assignee to nix-related issues (#44) (non contributors cannot be assigned to issues) * Reload will report if config couldn't be parsed and the service keeps running. (#46) Authored-by: Nina Alexandra Klama * Removing binary blobs from the project (#51) * removing binary blobs from the project. we now fetch the ectool from the gitlab artifacts and confirm the checksum. * remove bin references from README.md * extracting $TEMP_FOLDER from installEctool * Fix README spelling/grammer, fix "FrameWork" capitalization in service description (#52) * Review README spelling/grammar * Fix "FrameWork" capitalization in service * Clarify behaviour on service stop or pause (#53) (#55) * Separating FanController into different subclasses to allow HardwareController and SocketController diversity. 2 (Repost of #50) (#58) * separating `FanController` into different subclasses to allow `HardwareController` and `SocketController` diversity * adding the new arguments into the README.md * fixing an indentation error causing `--strategy ` not to work (the simple `` still worked) * add fw-ectool in module * fixing missing print for command execution (#63) --------- Co-authored-by: Léopold Hubert Co-authored-by: Nina Alexandra Klama Co-authored-by: DeflateAwning <11021263+DeflateAwning@users.noreply.github.com> * add doc + .gitignore * Add NixOS Flake (#26) * initial * update gitignore * update inputs * add fw-ectool dependencie * add module * fix tabs * fix package * fix typo * fix service * fix type * add options * fix service * fix build inputs * add Readme + add suspend script * remove unneeded }; * fix pkgs.writeShellScript * remvoe \ * try * add self * fix module * update package * fix package * use sleep script * add config options * fix typo * fix typo * add defaults * fix type * add prettyier * remove beautifyer * udpate readme * update installer script * add missing path * Update README.md Co-authored-by: Thomas Eizinger * Update flake.nix Co-authored-by: Thomas Eizinger * Update nix/module.nix Co-authored-by: Thomas Eizinger * add descriptions * fix uninstall * update readme * add description * remove requiremetns.txt + add github actions * update action * rename workflow test * fix service * try * try * Update README.md * Update README.md * chagne flake description * fix suspend script * fix script * fix path * fix install.sh * fix --no-sudo * add --no-sudo to other scripts * fix check * add option check * add missing " * Rename nix action --------- Co-authored-by: Thomas Eizinger * Update branch to main branch (#54) * Adding @Svenum as an assignee to nix-related issues (#43) * Fixing adding @Svenum as an assignee to nix-related issues (#44) (non contributors cannot be assigned to issues) * Reload will report if config couldn't be parsed and the service keeps running. (#46) Authored-by: Nina Alexandra Klama * Removing binary blobs from the project (#51) * removing binary blobs from the project. we now fetch the ectool from the gitlab artifacts and confirm the checksum. * remove bin references from README.md * extracting $TEMP_FOLDER from installEctool * Fix README spelling/grammer, fix "FrameWork" capitalization in service description (#52) * Review README spelling/grammar * Fix "FrameWork" capitalization in service * use ectool form nixpkgs * update flake * remove old deps * remove duplicated pkgs --------- Co-authored-by: Léopold Hubert Co-authored-by: Nina Alexandra Klama Co-authored-by: DeflateAwning <11021263+DeflateAwning@users.noreply.github.com> * Update to main branch + switch to fw-ectool (#61) * Adding @Svenum as an assignee to nix-related issues (#43) * Fixing adding @Svenum as an assignee to nix-related issues (#44) (non contributors cannot be assigned to issues) * Reload will report if config couldn't be parsed and the service keeps running. (#46) Authored-by: Nina Alexandra Klama * Removing binary blobs from the project (#51) * removing binary blobs from the project. we now fetch the ectool from the gitlab artifacts and confirm the checksum. * remove bin references from README.md * extracting $TEMP_FOLDER from installEctool * Fix README spelling/grammer, fix "FrameWork" capitalization in service description (#52) * Review README spelling/grammar * Fix "FrameWork" capitalization in service * Clarify behaviour on service stop or pause (#53) (#55) * Separating FanController into different subclasses to allow HardwareController and SocketController diversity. 2 (Repost of #50) (#58) * separating `FanController` into different subclasses to allow `HardwareController` and `SocketController` diversity * adding the new arguments into the README.md * fixing an indentation error causing `--strategy ` not to work (the simple `` still worked) * add fw-ectool in module * fixing missing print for command execution (#63) --------- Co-authored-by: Léopold Hubert Co-authored-by: Nina Alexandra Klama Co-authored-by: DeflateAwning <11021263+DeflateAwning@users.noreply.github.com> --------- Co-authored-by: Léopold Hubert Co-authored-by: Nina Alexandra Klama Co-authored-by: DeflateAwning <11021263+DeflateAwning@users.noreply.github.com> Co-authored-by: Oli Thornton <50975890+saizo80@users.noreply.github.com> Co-authored-by: Ryan Co-authored-by: Thomas Eizinger --- .../bug-report---packaging-windows.md | 37 ++ README.md | 178 ++++---- doc/README.md | 6 + doc/commands.md | 68 +++ doc/configuration.md | 104 +++++ doc/nix-flake.md | 54 +++ fanctrl.py | 388 ++++++++++++++---- install.sh | 18 +- post-install.sh | 2 +- pre-uninstall.sh | 4 +- services/fw-fanctrl.service | 2 +- services/system-sleep/fw-fanctrl-suspend | 4 +- 12 files changed, 683 insertions(+), 182 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug-report---packaging-windows.md create mode 100644 doc/README.md create mode 100644 doc/commands.md create mode 100644 doc/configuration.md create mode 100644 doc/nix-flake.md diff --git a/.github/ISSUE_TEMPLATE/bug-report---packaging-windows.md b/.github/ISSUE_TEMPLATE/bug-report---packaging-windows.md new file mode 100644 index 0000000..fe63495 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report---packaging-windows.md @@ -0,0 +1,37 @@ +--- +name: Bug report | packaging/windows +about: Report a bug involving the windows packaging +title: "[BUG] [packaging/windows]" +labels: bug, packaging/windows +assignees: 'leopoldhub' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Error message** +```txt +If applicable, add the full error message. +``` + +**Environment (please complete the following information):** + - OS: [e.g. Windows 11] + - Version [e.g. commit 176d34b] + - Configuration file [config.json] + +**Additional context** +Add any other context about the problem here. diff --git a/README.md b/README.md index 1dafd35..013bc4a 100644 --- a/README.md +++ b/README.md @@ -1,111 +1,111 @@ # fw-fanctrl -This is a simple Python service for Linux that drives Framework Laptop's fan(s) speed according to a configurable speed/temp curve. -Its default configuration targets very silent fan operation, but it's easy to configure it for a different comfort/performance trade-off. -Its possible to specify two separate fan curves depending on whether the Laptop is charging/discharging. -Under the hood, it uses [ectool](https://gitlab.howett.net/DHowett/ectool) to change parameters in Framework's embedded controller (EC). +[![Static Badge](https://img.shields.io/badge/Linux%2FGlobal-FCC624?style=flat&logo=linux&logoColor=FFFFFF&label=Platform&link=https%3A%2F%2Fgithub.com%2FTamtamHero%2Ffw-fanctrl%2Ftree%2Fmain)](https://github.com/TamtamHero/fw-fanctrl/tree/main) +![Static Badge](https://img.shields.io/badge/no%20binary%20blobs-30363D?style=flat&logo=GitHub-Sponsors&logoColor=4dff61) -It is compatible with all kinds of 13" and 16" models, both AMD/Intel CPUs, with or without a discrete GPU. +[![Static Badge](https://img.shields.io/badge/Python__3.12-FFDE57?style=flat&label=Requirement&link=https%3A%2F%2Fwww.python.org%2Fdownloads)](https://www.python.org/downloads) + +## Additional platforms: + +[![Static Badge](https://img.shields.io/badge/NixOS-5277C3?style=flat&logo=nixos&logoColor=FFFFFF&label=Platform&link=https%3A%2F%2Fgithub.com%2FTamtamHero%2Ffw-fanctrl%2Ftree%2Fpackaging%2Fnix)](https://github.com/TamtamHero/fw-fanctrl/tree/packaging/nix/doc/nix-flake.md) + +## Description + +Fw-fanctrl is a simple Python CLI service that controls Framework Laptop's fan(s) +speed according to a configurable speed/temperature curve. + +Its default strategy aims for very quiet fan operation, but you can choose amongst the other provided strategies, or +easily configure your own for a different comfort/performance trade-off. + +It also is possible to assign separate strategies depending on whether the laptop is charging or discharging. + +Under the hood, it uses [ectool](https://gitlab.howett.net/DHowett/ectool) +to change parameters in Framework's embedded controller (EC). + +It is compatible with all 13" and 16" models, both AMD/Intel CPUs, with or without a discrete GPU. If the service is paused or stopped, the fans will revert to their default behaviour. -# Install -For NixOS this repo contains an Flake. You could add it to your config like this: - -```nix -{ - inputs = { - nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.05"; - fw-fanctrl = { - url = "github:TamtamHero/fw-fanctrl/packaging/nix"; - inputs.nixpkgs.follows = "nixpkgs"; - }; - }; - outputs = {nixpkgs, fw-fanctrl}: { - nixosConfigurations.foo = nixpkgs.lib.nixosSystem { - system = "x86_64-linux"; - modules = [ - fw-fanctrl.nixosModules.default - configuration.nix - ]; - }; - } -} -``` -and then add in your *configuration.nix*: -```nix -# Enable fw-fanctrl -programs.fw-fanctrl.enable = true; - -# Add a custom config -programs.fw-fanctrl.config = { - defaultStrategy = "lazy"; - strategies = { - "lazy" = { - fanSpeedUpdateFrequency = 5; - movingAverageInterval = 30; - speedCurve = [ - { temp = 0; speed = 15; } - { temp = 50; speed = 15; } - { temp = 65; speed = 25; } - { temp = 70; speed = 35; } - { temp = 75; speed = 50; } - { temp = 85; speed = 100; } - ]; - }; - }; -}; - -# Add a custom config from an existing JSON file -programs.fw-fanctrl.config = builtins.fromJSON (builtins.readFile ./config.json) - -# Or just change the default strategy form the default config -programs.fw-fanctrl.config.defaultStrategy = "medium"; -``` +## Table of Content -Non NixOS install is described [here](https://github.com/TamtamHero/fw-fanctrl/blob/main/README.md#Install) +- [Documentation](#documentation) +- [Installation](#installation) + * [Requirements](#requirements) + * [Dependencies](#dependencies) + * [Instructions](#instructions) +- [Update](#update) +- [Uninstall](#uninstall) +## Documentation -# Configuration +More documentation could be found [here](./doc/README.md). -The default config contains different strategies, ranked from the most silent to the noisiest. It is possible to specify two different strategies for charging/discharging allowing for different optimization goals. -On discharging one could have fan curve optimized for low fan speeds in order to save power while accepting a bit more heat. -On charging one could have a fan curve that focuses on keeping the CPU from throttling and the system cool, at the expense of fan noise. -You can add new strategies, and if you think you have one that deserves to be shared, feel free to make a PR to this repo :) +## Installation -Strategies can be configured with the following parameters: +### Other Platforms +| name | branch | documentation | +|-------|---------------|---------------| +| NixOS | [packaging/nix](https://github.com/TamtamHero/fw-fanctrl/tree/packaging/nix) | [packaging/nix/doc/nix-flake](https://github.com/TamtamHero/fw-fanctrl/tree/packaging/nix/doc/nix-flake.md) | -- **SpeedCurve**: +### Requirements - This is the curve points for `f(temperature) = fan speeds` +| name | version | url | +|--------|---------|----------------------------------------------------------------------| +| Python | 3.12.x | [https://www.python.org/downloads](https://www.python.org/downloads) | - `fw-fanctrl` measures the CPU temperature, compute a moving average of it, and then find an appropriate `fan speed` value by interpolation on the curve. +### Dependencies -- **FanSpeedUpdateFrequency**: +Dependencies are downloaded and installed automatically, but can be excluded from the installation script if you wish to +do this manually. - Time interval between every update to the fan's speed. `fw-fanctrl` measures temperature every second and add it to its moving average, but the actual update to fan speed is controlled using this configuration. This is for comfort, otherwise the speed is changed too often and it is noticeable and annoying, especially at low speed. - For a more reactive fan, you can lower this setting. **Defaults to 5 seconds.** +| name | version | url | sub-dependencies | exclusion argument | +|----------------|-----------|--------------------------------------------------------------------------------------|------------------|--------------------| +| DHowett@ectool | build#899 | [https://gitlab.howett.net/DHowett/ectool](https://gitlab.howett.net/DHowett/ectool) | libftdi | `--no-ectool` | -- **MovingAverageInterval**: +### Instructions - Number of seconds on which the moving average of temperature is computed. Increase it, and the fan speed will change more gradually. Lower it, and it will gain in reactivity. **Defaults to 20 seconds.** +First, make sure that you have disabled secure boot in your BIOS/UEFI settings. +(more details on why [here](https://www.howett.net/posts/2021-12-framework-ec/#using-fw-ectool)) -## Charging/Discharging strategies +[Download the repo](https://github.com/TamtamHero/fw-fanctrl/archive/refs/heads/main.zip) and extract it manually, or +download/clone it with the appropriate tools: -The strategy active by default is the one specified in the `defaultStrategy` entry. Optionally a separate strategy only active during discharge can be defined, using the `strategyOnDischarging` entry. By default no extra strategy for discharging is provided, the default strategy is active during all times. +```shell +git clone "https://github.com/TamtamHero/fw-fanctrl.git" +``` -# Commands +```shell +curl -L "https://github.com/TamtamHero/fw-fanctrl/archive/refs/heads/main.zip" -o "./fw-fanctrl.zip" && unzip "./fw-fanctrl.zip" -d "./fw-fanctrl" && rm -rf "./fw-fanctrl.zip" +``` + +Then run the installation script with administrator privileges + +```bash +sudo ./install.sh +``` + +You can add a number of arguments to the installation command to suit your needs + +| argument | description | +|---------------------------------------------------------------------------------|----------------------------------------------------| +| `--dest-dir ` | specify an installation destination directory | +| `--prefix-dir ` | specify an installation prefix directory | +| `--sysconf-dir ` | specify a default configuration directory | +| `--no-ectool` | disable ectool installation and service activation | +| `--no-post-install` | disable post-install process | +| `--no-pre-uninstall` | disable pre-uninstall process | +| `--no-battery-sensors` | disable checking battery temperature sensors | + +## Update + +To update, you can download or pull the appropriate branch from this repository, and run the installation script again. + +## Uninstall + +To uninstall, run the installation script with the `--remove` argument, as well as other +corresponding [arguments if necessary](#instructions) + +```bash +sudo ./install.sh --remove +``` -| Option | Context | Description | -|-----------------------------|-----------------|-------------------------------------------------------------------------------| -| \ | run & configure | the name of the strategy to use | -| --run | run | run the service | -| --config | run | specify the configuration path | -| --no-log | run | disable state logging | -| --query, -q | configure | print the current strategy name | -| --list-strategies | configure | print the available strategies | -| --reload, -r | configure | reload the configuration file | -| --pause | configure | temporarily disable the service and reset the fans to their default behaviour | -| --resume | configure | resume the service | -| --hardware-controller, --hc | run | select the hardware controller. choices: ectool | -| --socket-controller, --sc | run & configure | select the socket controller. choices: unix | diff --git a/doc/README.md b/doc/README.md new file mode 100644 index 0000000..823fbff --- /dev/null +++ b/doc/README.md @@ -0,0 +1,6 @@ +# Table of Content + +- [Default Installation](../README.md#installation) +- [NixOS Flake](https://github.com/TamtamHero/fw-fanctrl/tree/packaging/nix/doc/nix-flake.md) +- [Commands](./commands.md) +- [Configuration](./configuration.md) diff --git a/doc/commands.md b/doc/commands.md new file mode 100644 index 0000000..5ef3294 --- /dev/null +++ b/doc/commands.md @@ -0,0 +1,68 @@ +# Commands + +Here is a list of commands and options used to interact with the service. + +the base of all commands is the following + +```shell +fw-fanctrl [commands and options] +``` + +First, the global options + +| Option | Optional | Choices | Default | Description | +|---------------------------|----------|---------|---------|--------------------------------------------------------------------------------| +| --socket-controller, --sc | yes | unix | unix | the socket controller to use for communication between the cli and the service | + +**run** + +run the service manually + +If you have installed it correctly, the systemd `fw-fanctrl.service` service will do this for you, so you probably will +never need those. + +| Option | Optional | Choices | Default | Description | +|-----------------------------|----------|----------------|----------------------|-----------------------------------------------------------------------------------| +| \ | yes | | the default strategy | the name of the strategy to use | +| --config | yes | \[CONFIG_PATH] | | the configuration file path | +| --silent, -s | yes | | | disable printing speed/temp status to stdout | +| --hardware-controller, --hc | yes | ectool | ectool | the hardware controller to use for fetching and setting the temp and fan(s) speed | +| --no-battery-sensors | yes | | | disable checking battery temperature sensors (for mainboards without batteries) | + +**use** + +change the current strategy + +| Option | Optional | Description | +|-------------|----------|---------------------------------| +| \ | no | the name of the strategy to use | + +**reset** + +reset to the default strategy + +**reload** + +reload the configuration file + +**pause** + +pause the service + +**resume** + +resume the service + +**print** + +print the selected information + +| Option | Optional | Choices | Default | Description | +|--------------------|----------|----------------------|---------|------------------------| +| \ | yes | current, list, speed | current | what should be printed | + +| Choice | Description | +|---------|----------------------------------| +| current | The current strategy being used | +| list | List available strategies | +| speed | The current fan speed percentage | diff --git a/doc/configuration.md b/doc/configuration.md new file mode 100644 index 0000000..6f381fb --- /dev/null +++ b/doc/configuration.md @@ -0,0 +1,104 @@ +# Table of Content + +- [Configuration](#configuration) + * [Default strategy](#default-strategy) + * [Charging/Discharging strategies](#chargingdischarging-strategies) + * [Editing strategies](#editing-strategies) + +# Configuration + +After installation, you will find the configuration file in the following location: + +`/etc/fw-fanctrl/config.json` + +If you have modified the `dest-dir` or `sysconf-dir`, here is the corresponding pattern + +`[dest-dir(/)][sysconf-dir(/etc)]/fw-fanctrl/config.json` + +It contains a list of strategies, ranked from the quietest to loudest, as well as the default and discharging +strategies. + +For example, one could use a lower fan speed strategy on discharging to optimise battery life (- noise, + heat), +and a high fan speed strategy on AC (+ noise, - heat). + +You can add or edit strategies, and if you think you have one that deserves to be shared, feel free to make a PR to this +repo :) + +## Default strategy + +The default strategy is the one used when the service is started. + +It can be changed by replacing the value of the `defaultStrategy` field with one of the strategies present in the +configuration. + +```json +"defaultStrategy": "[STRATEGY NAME]" +``` + +## Charging/Discharging strategies + +The discharging strategy is the one that will be used when the laptop is not on AC, +Otherwise the default strategy is used. + +It can be changed by replacing the value of the `strategyOnDischarging` field with one of the strategies present in the +configuration. + +```json +"strategyOnDischarging": "[STRATEGY NAME]" +``` + +This is optional and can be left empty to have the same strategy at all times. + +## Editing strategies + +Strategies can be configured with the following parameters: + +> **SpeedCurve**: +> +> It is represented by the curve points for `f(temperature) = fan(s) speed`. +> +> ```json +> "speedCurve": [ +> { "temp": [TEMPERATURE POINT], "speed": [PERCENTAGE SPEED] }, +> ... +> ] +> ``` +> +> `fw-fanctrl` measures the CPU temperature, calculates a moving average of it, and then finds an +> appropriate `fan speed` +> value by interpolating on the curve. + +> **FanSpeedUpdateFrequency**: +> +> It is the interval in seconds between fan speed calculations. +> +> ```json +> "fanSpeedUpdateFrequency": [UPDATE FREQUENCY] +> ``` +> +> This is for comfort, otherwise the speed will change too often, which is noticeable and annoying, especially at low +> speed. +> +> For a more responsive fan, you can reduce this setting. +> +> **Defaults to 5 seconds.** (minimum 1) + +> **MovingAverageInterval**: +> +> It is the number of seconds over which the moving average of temperature is calculated. +> +> ```json +> "movingAverageInterval": [AVERAGING INTERVAL] +> ``` +> +> Increase it, and the fan speed changes more gradually. Lower it, and it becomes more responsive. +> +> **Defaults to 20 seconds.** (minimum 1) + +--- + +Once the configuration has been changed, you must reload it with the following command + +```bash +fw-fanctrl reload +``` diff --git a/doc/nix-flake.md b/doc/nix-flake.md new file mode 100644 index 0000000..9345195 --- /dev/null +++ b/doc/nix-flake.md @@ -0,0 +1,54 @@ +# Install + +For NixOS this repo contains an Flake. You could add it to your config like this: + +```nix +{ + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.05"; + fw-fanctrl = { + url = "github:TamtamHero/fw-fanctrl/packaging/nix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + }; + outputs = {nixpkgs, fw-fanctrl}: { + nixosConfigurations.foo = nixpkgs.lib.nixosSystem { + system = "x86_64-linux"; + modules = [ + fw-fanctrl.nixosModules.default + configuration.nix + ]; + }; + } +} +``` +and then add in your *configuration.nix*: +```nix +# Enable fw-fanctrl +programs.fw-fanctrl.enable = true; + +# Add a custom config +programs.fw-fanctrl.config = { + defaultStrategy = "lazy"; + strategies = { + "lazy" = { + fanSpeedUpdateFrequency = 5; + movingAverageInterval = 30; + speedCurve = [ + { temp = 0; speed = 15; } + { temp = 50; speed = 15; } + { temp = 65; speed = 25; } + { temp = 70; speed = 35; } + { temp = 75; speed = 50; } + { temp = 85; speed = 100; } + ]; + }; + }; +}; + +# Add a custom config from an existing JSON file +programs.fw-fanctrl.config = builtins.fromJSON (builtins.readFile ./config.json) + +# Or just change the default strategy form the default config +programs.fw-fanctrl.config.defaultStrategy = "medium"; +``` diff --git a/fanctrl.py b/fanctrl.py index 9c75bbd..703e465 100644 --- a/fanctrl.py +++ b/fanctrl.py @@ -1,6 +1,7 @@ #! /usr/bin/python3 import argparse import collections +import io import json import os import re @@ -11,12 +12,239 @@ import threading from time import sleep from abc import ABC, abstractmethod +import textwrap DEFAULT_CONFIGURATION_FILE_PATH = "/etc/fw-fanctrl/config.json" SOCKETS_FOLDER_PATH = "/run/fw-fanctrl" COMMANDS_SOCKET_FILE_PATH = os.path.join(SOCKETS_FOLDER_PATH, ".fw-fanctrl.commands.sock") -parser = None + +class CommandParser: + isRemote = True + + legacyParser = None + parser = None + + def __init__(self, isRemote=False): + self.isRemote = isRemote + self.initParser() + self.initLegacyParser() + + def initParser(self): + self.parser = argparse.ArgumentParser( + prog="fw-fanctrl", + description="control Framework's laptop fan(s) with a speed curve", + epilog=textwrap.dedent( + "obtain more help about a command or subcommand using `fw-fanctrl [subcommand...] -h/--help`"), + formatter_class=argparse.RawTextHelpFormatter + ) + self.parser.add_argument( + "--socket-controller", + "--sc", + help="the socket controller to use for communication between the cli and the service", + type=str, + choices=["unix"], + default="unix" + ) + + commandsSubParser = self.parser.add_subparsers(dest="command") + + if not self.isRemote: + runCommand = commandsSubParser.add_parser( + "run", + description="run the service", + formatter_class=argparse.RawTextHelpFormatter + ) + runCommand.add_argument( + "strategy", + help='name of the strategy to use e.g: "lazy" (use `print strategies` to list available strategies)', + nargs=argparse.OPTIONAL + ) + runCommand.add_argument( + "--config", + "-c", + help=f"the configuration file path (default: {DEFAULT_CONFIGURATION_FILE_PATH})", + type=str, + default=DEFAULT_CONFIGURATION_FILE_PATH + ) + runCommand.add_argument( + "--silent", + "-s", + help="disable printing speed/temp status to stdout", + action="store_true" + ) + runCommand.add_argument( + "--hardware-controller", + "--hc", + help="the hardware controller to use for fetching and setting the temp and fan(s) speed", + type=str, + choices=["ectool"], + default="ectool" + ) + runCommand.add_argument( + "--no-battery-sensors", + help="disable checking battery temperature sensors", + action="store_true", + ) + + useCommand = commandsSubParser.add_parser( + "use", + description="change the current strategy" + ) + useCommand.add_argument( + "strategy", + help='name of the strategy to use e.g: "lazy". (use `print strategies` to list available strategies)' + ) + + commandsSubParser.add_parser( + "reset", + description="reset to the default strategy" + ) + commandsSubParser.add_parser( + "reload", + description="reload the configuration file" + ) + commandsSubParser.add_parser( + "pause", + description="pause the service" + ) + commandsSubParser.add_parser( + "resume", + description="resume the service" + ) + + printCommand = commandsSubParser.add_parser( + "print", + description="print the selected information", + formatter_class=argparse.RawTextHelpFormatter + ) + printCommand.add_argument( + "print_selection", + help="current - The current strategy\nlist - List available strategies\nspeed - The current fan speed percentage", + nargs="?", + type=str, + choices=["current", + "list", + "speed"], + default="current" + ) + + def initLegacyParser(self): + self.legacyParser = argparse.ArgumentParser(add_help=False) + + # avoid collision with the new parser commands + def excludedPositionalArguments(value): + if value in ["run", "use", "reload", "reset", "pause", "resume", "print"]: + raise argparse.ArgumentTypeError("%s is an excluded value" % value) + return value + + bothGroup = self.legacyParser.add_argument_group("both") + bothGroup.add_argument( + "_strategy", + nargs="?", + type=excludedPositionalArguments + ) + bothGroup.add_argument( + "--strategy", + nargs="?" + ) + + runGroup = self.legacyParser.add_argument_group("run") + runGroup.add_argument( + "--run", + action="store_true" + ) + runGroup.add_argument( + "--config", + type=str, + default=DEFAULT_CONFIGURATION_FILE_PATH + ) + runGroup.add_argument( + "--no-log", + action="store_true" + ) + commandGroup = self.legacyParser.add_argument_group("configure") + commandGroup.add_argument( + "--query", + "-q", + action="store_true" + ) + commandGroup.add_argument( + "--list-strategies", + action="store_true" + ) + commandGroup.add_argument( + "--reload", + "-r", + action="store_true" + ) + commandGroup.add_argument( + "--pause", + action="store_true" + ) + commandGroup.add_argument( + "--resume", + action="store_true" + ) + commandGroup.add_argument( + "--hardware-controller", + "--hc", + type=str, + choices=["ectool"], + default="ectool" + ) + commandGroup.add_argument( + "--socket-controller", + "--sc", + type=str, + choices=["unix"], + default="unix" + ) + + def parseArgs(self, args=None): + values = None + original_stderr = sys.stderr + # silencing legacy parser output + sys.stderr = open(os.devnull, 'w') + try: + legacy_values = self.legacyParser.parse_args(args) + if legacy_values.strategy is None: + legacy_values.strategy = legacy_values._strategy + # converting legacy values into new ones + values = argparse.Namespace() + values.socket_controller = legacy_values.socket_controller + if legacy_values.query: + values.command = "print" + values.print_selection = "current" + if legacy_values.list_strategies: + values.command = "print" + values.print_selection = "list" + if legacy_values.resume: + values.command = "resume" + if legacy_values.pause: + values.command = "pause" + if legacy_values.reload: + values.command = "reload" + if legacy_values.run: + values.command = "run" + values.silent = legacy_values.no_log + values.hardware_controller = legacy_values.hardware_controller + values.config = legacy_values.config + values.strategy = legacy_values.strategy + if not hasattr(values, "command") and legacy_values.strategy is not None: + values.command = "use" + values.strategy = legacy_values.strategy + if not hasattr(values, "command"): + raise Exception("not a valid legacy command") + if self.isRemote or values.command == "run": + print( + "[Warning] > this command is deprecated and will be removed soon, please use the new command format instead ('fw-fanctrl -h' for more details).") + except (SystemExit, Exception): + sys.stderr = original_stderr + values = self.parser.parse_args(args) + finally: + sys.stderr = original_stderr + return values class JSONException(Exception): @@ -54,7 +282,7 @@ def __init__(self, name, parameters): class Configuration: path = None - data: None + data = None def __init__(self, path): self.path = path @@ -125,21 +353,36 @@ def startServerSocket(self, commandCallback=None): self.server_socket.listen(1) while True: client_socket, _ = self.server_socket.accept() + parsePrintCapture = io.StringIO() try: # Receive data from the client data = client_socket.recv(4096).decode() - args = parser.parse_args(shlex.split(data)) + original_stderr = sys.stderr + original_stdout = sys.stdout + # capture parsing std outputs for the client + sys.stderr = parsePrintCapture + sys.stdout = parsePrintCapture + try: + args = CommandParser(True).parseArgs(shlex.split(data)) + finally: + sys.stderr = original_stderr + sys.stdout = original_stdout commandReturn = commandCallback(args) if not commandReturn: commandReturn = "Success!" - client_socket.sendall(commandReturn.encode()) + if parsePrintCapture.getvalue().strip(): + commandReturn = parsePrintCapture.getvalue() + commandReturn + client_socket.sendall(commandReturn.encode('utf-8')) + except SystemExit: + client_socket.sendall(f"{parsePrintCapture.getvalue()}".encode('utf-8')) except Exception as e: - client_socket.sendall(f"[Error] > An error occurred: {e}".encode()) + print(f"[Error] > An error occurred while treating a socket command: {e}", file=sys.stderr) + client_socket.sendall(f"[Error] > An error occurred: {e}".encode('utf-8')) finally: client_socket.shutdown(socket.SHUT_WR) client_socket.close() finally: - self.server_socket.close() + self.stopServerSocket() def stopServerSocket(self): if self.server_socket: @@ -153,7 +396,7 @@ def sendViaClientSocket(self, command): client_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) try: client_socket.connect(COMMANDS_SOCKET_FILE_PATH) - client_socket.sendall(command.encode()) + client_socket.sendall(command.encode('utf-8')) received_data = b"" while True: data_chunk = client_socket.recv(1024) @@ -162,7 +405,7 @@ def sendViaClientSocket(self, command): received_data += data_chunk # Receive data from the server data = received_data.decode() - if data.startswith("Error:"): + if data.startswith("[Error] > "): raise Exception(data) return data finally: @@ -193,9 +436,31 @@ def isOnAC(self): class EctoolHardwareController(HardwareController, ABC): + noBatterySensorMode = False + nonBatterySensors = None + + def __init__(self, noBatterySensorMode=False): + if noBatterySensorMode: + self.noBatterySensorMode = True + self.populateNonBatterySensors() + + def populateNonBatterySensors(self): + self.nonBatterySensors = [] + rawOut = subprocess.run("ectool tempsinfo all", stdout=subprocess.PIPE, shell=True, text=True).stdout + batterySensorsRaw = re.findall(r"\d+ Battery", rawOut, re.MULTILINE) + batterySensors = [x.split(" ")[0] for x in batterySensorsRaw] + for x in re.findall(r"^\d+", rawOut, re.MULTILINE): + if x not in batterySensors: + self.nonBatterySensors.append(x) def getTemperature(self): - rawOut = subprocess.run("ectool temps all", stdout=subprocess.PIPE, shell=True, text=True).stdout + if self.noBatterySensorMode: + rawOut = "".join([ + subprocess.run("ectool temps " + x, stdout=subprocess.PIPE, shell=True, text=True).stdout + for x in self.nonBatterySensors + ]) + else: + rawOut = subprocess.run("ectool temps all", stdout=subprocess.PIPE, shell=True, text=True).stdout rawTemps = re.findall(r'\(= (\d+) C\)', rawOut) temps = sorted([x for x in [int(x) for x in rawTemps] if x > 0], reverse=True) # safety fallback to avoid damaging hardware @@ -273,33 +538,37 @@ def getCurrentStrategy(self): return self.configuration.getDefaultStrategy() return self.configuration.getDischargingStrategy() - def commandManager(self, command): - if command.strategy or command._strategy: - strategy = command.strategy - if strategy is None: - strategy = command._strategy + def commandManager(self, args): + if args.command == "reset" or (args.command == "use" and args.strategy == "defaultStrategy"): + self.clearOverwrittenStrategy() + return + elif args.command == "use": try: - if strategy == "defaultStrategy": - self.clearOverwrittenStrategy() - else: - self.overwriteStrategy(strategy) + self.overwriteStrategy(args.strategy) return self.getCurrentStrategy().name except InvalidStrategyException: - raise InvalidStrategyException(f"The specified strategy is invalid: {strategy}") - elif command.pause: - self.pause() - elif command.resume: - self.resume() - elif command.query: - return self.getCurrentStrategy().name - elif command.list_strategies: - return '\n'.join(self.configuration.getStrategies()) - elif command.reload: + raise InvalidStrategyException(f"The specified strategy is invalid: {args.strategy}") + elif args.command == "reload": if self.configuration.reload(): if self.overwrittenStrategy is not None: self.overwriteStrategy(self.overwrittenStrategy.name) else: raise JSONException("Config file could not be parsed due to JSON Error") + return + elif args.command == "pause": + self.pause() + return + elif args.command == "resume": + self.resume() + return + elif args.command == "print": + if args.print_selection == "current": + return self.getCurrentStrategy().name + elif args.print_selection == "list": + return '\n'.join(self.configuration.getStrategies()) + elif args.print_selection == "speed": + return str(self.speed) + '%' + return "Unknown command, unexpected." # return mean temperature over a given time interval (in seconds) def getMovingAverageTemperature(self, timeInterval): @@ -360,70 +629,27 @@ def run(self, debug=True): else: sleep(5) except InvalidStrategyException as e: - print("Error: missing strategy, exiting for safety reasons: " + e.args[0]) - exit(1) + print(f"[Error] > Missing strategy, exiting for safety reasons: {e.args[0]}", file=sys.stderr) + except Exception as e: + print(f"[Error] > Critical error, exiting for safety reasons: {e}", file=sys.stderr) + exit(1) def main(): - global parser - parser = argparse.ArgumentParser( - description="Control Framework's laptop fan with a speed curve", - ) - - bothGroup = parser.add_argument_group("both") - bothGroup.add_argument( - "_strategy", - nargs="?", - help='Name of the strategy to use e.g: "lazy" (check config.json for others). Use "defaultStrategy" to go ' - 'back to the default strategy', - ) - bothGroup.add_argument( - "--strategy", - nargs="?", - help='Name of the strategy to use e.g: "lazy" (check config.json for others). Use "defaultStrategy" to go ' - 'back to the default strategy', - ) - - runGroup = parser.add_argument_group("run") - runGroup.add_argument("--run", help="run the service", action="store_true") - runGroup.add_argument("--config", type=str, help="Path to config file", default=DEFAULT_CONFIGURATION_FILE_PATH) - runGroup.add_argument( - "--no-log", help="Disable print speed/meanTemp to stdout", action="store_true" - ) - commandGroup = parser.add_argument_group("configure") - commandGroup.add_argument( - "--query", "-q", help="Query the currently active strategy", action="store_true" - ) - commandGroup.add_argument( - "--list-strategies", help="List the available strategies", action="store_true" - ) - commandGroup.add_argument( - "--reload", "-r", help="Reload the configuration from file", action="store_true" - ) - commandGroup.add_argument("--pause", help="Pause the program", action="store_true") - commandGroup.add_argument("--resume", help="Resume the program", action="store_true") - commandGroup.add_argument("--hardware-controller", "--hc", help="Select the hardware controller", type=str, - choices=["ectool"], default="ectool") - commandGroup.add_argument("--socket-controller", "--sc", help="Select the socket controller", type=str, - choices=["unix"], default="unix") - - args = parser.parse_args() + args = CommandParser().parseArgs() - socketController = None + socketController = UnixSocketController() if args.socket_controller == "unix": socketController = UnixSocketController() - if args.run: - hardwareController = None + if args.command == "run": + hardwareController = EctoolHardwareController(noBatterySensorMode=args.no_battery_sensors) if args.hardware_controller == "ectool": - hardwareController = EctoolHardwareController() + hardwareController = EctoolHardwareController(noBatterySensorMode=args.no_battery_sensors) - strategy = args.strategy - if strategy is None: - strategy = args._strategy fan = FanController(hardwareController=hardwareController, socketController=socketController, configPath=args.config, strategyName=args.strategy) - fan.run(debug=not args.no_log) + fan.run(debug=not args.silent) else: try: commandResult = socketController.sendViaClientSocket(' '.join(sys.argv[1:])) diff --git a/install.sh b/install.sh index b437bbb..4a63e78 100755 --- a/install.sh +++ b/install.sh @@ -1,10 +1,9 @@ #!/bin/bash set -e - # Argument parsing SHORT=r,d:,p:,s:,h -LONG=remove,dest-dir:,prefix-dir:,sysconf-dir:,no-ectool,no-pre-uninstall,no-post-install,no-sudo,help +LONG=remove,dest-dir:,prefix-dir:,sysconf-dir:,no-ectool,no-pre-uninstall,no-post-install,no-battery-sensors,no-sudo,help VALID_ARGS=$(getopt -a --options $SHORT --longoptions $LONG -- "$@") if [[ $? -ne 0 ]]; then exit 1; @@ -20,6 +19,7 @@ SHOULD_INSTALL_ECTOOL=true SHOULD_PRE_UNINSTALL=true SHOULD_POST_INSTALL=true SHOULD_REMOVE=false +NO_BATTERY_SENSOR=false NO_SUDO=false eval set -- "$VALID_ARGS" @@ -49,6 +49,9 @@ while true; do '--no-post-install') SHOULD_POST_INSTALL=false ;; + '--no-battery-sensors') + NO_BATTERY_SENSOR=true + ;; '--no-sudo') NO_SUDO=true ;; @@ -65,7 +68,7 @@ done # Root check if [ "$EUID" -ne 0 ] && [ "$NO_SUDO" = false ] - then echo "This program requires root permissions" + then echo "This program requires root permissions ore use the '--no-sudo' option" exit 1 fi @@ -144,6 +147,11 @@ function install() { cp -n "./config.json" "$DEST_DIR$SYSCONF_DIR/fw-fanctrl" 2> "/dev/null" || true + # add --no-battery-sensors flag to the fanctrl service if specified + if [ "$NO_BATTERY_SENSOR" = true ]; then + NO_BATTERY_SENSOR_OPTION="--no-battery-sensors" + fi + # create program services based on the services present in the './services' folder echo "creating '$DEST_DIR$PREFIX_DIR/lib/systemd/system'" mkdir -p "$DEST_DIR$PREFIX_DIR/lib/systemd/system" @@ -155,7 +163,7 @@ function install() { systemctl stop "$SERVICE" fi echo "creating '$DEST_DIR$PREFIX_DIR/lib/systemd/system/$SERVICE$SERVICE_EXTENSION'" - cat "$SERVICES_DIR/$SERVICE$SERVICE_EXTENSION" | sed -e "s/%PREFIX_DIRECTORY%/${PREFIX_DIR//\//\\/}/" | sed -e "s/%SYSCONF_DIRECTORY%/${SYSCONF_DIR//\//\\/}/" | tee "$DEST_DIR$PREFIX_DIR/lib/systemd/system/$SERVICE$SERVICE_EXTENSION" > "/dev/null" + cat "$SERVICES_DIR/$SERVICE$SERVICE_EXTENSION" | sed -e "s/%PREFIX_DIRECTORY%/${PREFIX_DIR//\//\\/}/" | sed -e "s/%SYSCONF_DIRECTORY%/${SYSCONF_DIR//\//\\/}/" | sed -e "s/%NO_BATTERY_SENSOR_OPTION%/${NO_BATTERY_SENSOR_OPTION}/" | tee "$DEST_DIR$PREFIX_DIR/lib/systemd/system/$SERVICE$SERVICE_EXTENSION" > "/dev/null" done # add program services sub-configurations based on the sub-configurations present in the './services' folder @@ -181,7 +189,7 @@ function install() { done done if [ "$SHOULD_POST_INSTALL" = true ]; then - ./post-install.sh --dest-dir "$DEST_DIR" --sysconf-dir "$SYSCONF_DIR" "$([ "$NO_SUDO" = true ] && echo "--no-sudo")" + ./post-install.sh --dest-dir "$DEST_DIR" --sysconf-dir "$SYSCONF_DIR" "$([ "$NO_SUDO" = true ] && echo "--no-sudo")" fi } diff --git a/post-install.sh b/post-install.sh index f3108db..0a2f252 100755 --- a/post-install.sh +++ b/post-install.sh @@ -42,7 +42,7 @@ done # Root check if [ "$EUID" -ne 0 ] && [ "$NO_SUDO" = false ] - then echo "This program requires root permissions" + then echo "This program requires root permissions ore use the '--no-sudo' option" exit 1 fi diff --git a/pre-uninstall.sh b/pre-uninstall.sh index ad21f5d..8607db6 100755 --- a/pre-uninstall.sh +++ b/pre-uninstall.sh @@ -29,13 +29,11 @@ while true; do shift done -# Root check if [ "$EUID" -ne 0 ] && [ "$NO_SUDO" = false ] - then echo "This program requires root permissions" + then echo "This program requires root permissions ore use the '--no-sudo' option" exit 1 fi - SERVICES_DIR="./services" SERVICE_EXTENSION=".service" diff --git a/services/fw-fanctrl.service b/services/fw-fanctrl.service index 7f70d58..ec7d46c 100644 --- a/services/fw-fanctrl.service +++ b/services/fw-fanctrl.service @@ -4,7 +4,7 @@ After=multi-user.target [Service] Type=simple Restart=always -ExecStart=/usr/bin/python3 "%PREFIX_DIRECTORY%/bin/fw-fanctrl" --run --config "%SYSCONF_DIRECTORY%/fw-fanctrl/config.json" --no-log +ExecStart=/usr/bin/python3 "%PREFIX_DIRECTORY%/bin/fw-fanctrl" run --config "%SYSCONF_DIRECTORY%/fw-fanctrl/config.json" --silent %NO_BATTERY_SENSOR_OPTION% ExecStopPost=/bin/sh -c "ectool autofanctrl" [Install] WantedBy=multi-user.target diff --git a/services/system-sleep/fw-fanctrl-suspend b/services/system-sleep/fw-fanctrl-suspend index 1fb3952..3cf2697 100644 --- a/services/system-sleep/fw-fanctrl-suspend +++ b/services/system-sleep/fw-fanctrl-suspend @@ -1,6 +1,6 @@ #!/bin/sh case $1 in - pre) /usr/bin/python3 "%PREFIX_DIRECTORY%/bin/fw-fanctrl" --pause ;; - post) /usr/bin/python3 "%PREFIX_DIRECTORY%/bin/fw-fanctrl" --resume ;; + pre) /usr/bin/python3 "%PREFIX_DIRECTORY%/bin/fw-fanctrl" pause ;; + post) /usr/bin/python3 "%PREFIX_DIRECTORY%/bin/fw-fanctrl" resume ;; esac