├── src └── fw_fanctrl │ ├── dto │ ├── __init__.py │ ├── command_result │ │ ├── __init__.py │ │ ├── ServicePauseCommandResult.py │ │ ├── PrintActiveCommandResult.py │ │ ├── PrintFanSpeedCommandResult.py │ │ ├── CommandResult.py │ │ ├── StrategyChangeCommandResult.py │ │ ├── PrintCurrentStrategyCommandResult.py │ │ ├── PrintStrategyListCommandResult.py │ │ ├── StrategyResetCommandResult.py │ │ ├── ConfigurationReloadCommandResult.py │ │ ├── ServiceResumeCommandResult.py │ │ └── SetConfigurationCommandResult.py │ ├── runtime_result │ │ ├── __init__.py │ │ ├── RuntimeResult.py │ │ └── StatusRuntimeResult.py │ └── Printable.py │ ├── enum │ ├── __init__.py │ ├── OutputFormat.py │ └── CommandStatus.py │ ├── exception │ ├── __init__.py │ ├── SocketCallException.py │ ├── UnimplementedException.py │ ├── InvalidStrategyException.py │ ├── UnknownCommandException.py │ ├── ConfigurationParsingException.py │ └── SocketAlreadyRunningException.py │ ├── socketController │ ├── __init__.py │ ├── SocketController.py │ └── UnixSocketController.py │ ├── hardwareController │ ├── __init__.py │ ├── HardwareController.py │ └── EctoolHardwareController.py │ ├── __init__.py │ ├── Strategy.py │ ├── __main__.py │ ├── Configuration.py │ ├── _resources │ ├── config.json │ └── config.schema.json │ ├── CommandParser.py │ └── FanController.py ├── fetch └── ectool │ ├── linux │ ├── gitlab_job_id │ └── hash.sha256 │ └── LICENSE ├── .github ├── ISSUE_TEMPLATE │ ├── other.md │ ├── feature_request.md │ ├── bug_report.md │ ├── bug-report---packaging-windows.md │ ├── bug-report---packaging-aur.md │ └── bug-report---packaging-nix.md └── workflows │ ├── pr-check-formatting.yml │ └── auto-tag-and-release.yml ├── services ├── system-sleep │ └── fw-fanctrl-suspend └── fw-fanctrl.service ├── .editorconfig ├── doc ├── README.md ├── commands.md └── configuration.md ├── pre-uninstall.sh ├── LICENSE ├── pyproject.toml ├── post-install.sh ├── .gitignore ├── install.sh └── README.md /src/fw_fanctrl/dto/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/fw_fanctrl/enum/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fetch/ectool/linux/gitlab_job_id: -------------------------------------------------------------------------------- 1 | 899 -------------------------------------------------------------------------------- /src/fw_fanctrl/exception/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/fw_fanctrl/socketController/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/fw_fanctrl/dto/command_result/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/fw_fanctrl/dto/runtime_result/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/fw_fanctrl/hardwareController/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fetch/ectool/linux/hash.sha256: -------------------------------------------------------------------------------- 1 | ab94a1e9a33f592d5482dbfd4f42ad351ef91227ee3b3707333c0107d7f2b1b0 -------------------------------------------------------------------------------- /src/fw_fanctrl/exception/SocketCallException.py: -------------------------------------------------------------------------------- 1 | class SocketCallException(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /src/fw_fanctrl/exception/UnimplementedException.py: -------------------------------------------------------------------------------- 1 | class UnimplementedException(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /src/fw_fanctrl/exception/InvalidStrategyException.py: -------------------------------------------------------------------------------- 1 | class InvalidStrategyException(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /src/fw_fanctrl/exception/UnknownCommandException.py: -------------------------------------------------------------------------------- 1 | class UnknownCommandException(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /src/fw_fanctrl/exception/ConfigurationParsingException.py: -------------------------------------------------------------------------------- 1 | class ConfigurationParsingException(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /src/fw_fanctrl/exception/SocketAlreadyRunningException.py: -------------------------------------------------------------------------------- 1 | class SocketAlreadyRunningException(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /src/fw_fanctrl/enum/OutputFormat.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class OutputFormat(str, Enum): 5 | NATURAL = "NATURAL" 6 | JSON = "JSON" 7 | -------------------------------------------------------------------------------- /src/fw_fanctrl/enum/CommandStatus.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class CommandStatus(str, Enum): 5 | SUCCESS = "success" 6 | ERROR = "error" 7 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/other.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Other 3 | about: Any other issue/question 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | -------------------------------------------------------------------------------- /services/system-sleep/fw-fanctrl-suspend: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | case $1 in 4 | pre) "%PYTHON_SCRIPT_INSTALLATION_PATH%" pause ;; 5 | post) "%PYTHON_SCRIPT_INSTALLATION_PATH%" resume ;; 6 | esac 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 4 6 | max_line_length = 120 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | end_of_line = lf 11 | 12 | [.github/workflows/**/*] 13 | indent_size = 2 14 | -------------------------------------------------------------------------------- /doc/README.md: -------------------------------------------------------------------------------- 1 | # Table of Content 2 | 3 | - [Default Installation](../README.md#installation) 4 | - [Development Setup](../README.md#development-setup) 5 | - [NixOS Flake](https://github.com/TamtamHero/fw-fanctrl/tree/packaging/nix/doc/nix-flake.md) 6 | - [Commands](./commands.md) 7 | - [Configuration](./configuration.md) 8 | -------------------------------------------------------------------------------- /src/fw_fanctrl/__init__.py: -------------------------------------------------------------------------------- 1 | import importlib.resources 2 | import os 3 | 4 | INTERNAL_RESOURCES_PATH = importlib.resources.files("fw_fanctrl").joinpath("_resources") 5 | 6 | DEFAULT_CONFIGURATION_FILE_PATH = "/etc/fw-fanctrl/config.json" 7 | SOCKETS_FOLDER_PATH = "/run/fw-fanctrl" 8 | COMMANDS_SOCKET_FILE_PATH = os.path.join(SOCKETS_FOLDER_PATH, ".fw-fanctrl.commands.sock") 9 | -------------------------------------------------------------------------------- /src/fw_fanctrl/dto/Printable.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from fw_fanctrl.enum.OutputFormat import OutputFormat 4 | 5 | 6 | class Printable: 7 | def __init__(self): 8 | super().__init__() 9 | 10 | def to_output_format(self, output_format): 11 | if output_format == OutputFormat.JSON: 12 | return json.dumps(self.__dict__) 13 | else: 14 | return str(self) 15 | -------------------------------------------------------------------------------- /services/fw-fanctrl.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Framework Fan Controller 3 | After=multi-user.target 4 | [Service] 5 | Type=simple 6 | Restart=always 7 | ExecStart="%PYTHON_SCRIPT_INSTALLATION_PATH%" --output-format "JSON" run --config "%SYSCONF_DIRECTORY%/fw-fanctrl/config.json" --silent %NO_BATTERY_SENSOR_OPTION% 8 | ExecStopPost=/bin/sh -c "ectool autofanctrl" 9 | [Install] 10 | WantedBy=multi-user.target 11 | -------------------------------------------------------------------------------- /src/fw_fanctrl/dto/command_result/ServicePauseCommandResult.py: -------------------------------------------------------------------------------- 1 | from fw_fanctrl.dto.command_result.CommandResult import CommandResult 2 | from fw_fanctrl.enum.CommandStatus import CommandStatus 3 | 4 | 5 | class ServicePauseCommandResult(CommandResult): 6 | def __init__(self): 7 | super().__init__(CommandStatus.SUCCESS) 8 | 9 | def __str__(self): 10 | return "Service paused! The hardware fan control will take over" 11 | -------------------------------------------------------------------------------- /src/fw_fanctrl/dto/command_result/PrintActiveCommandResult.py: -------------------------------------------------------------------------------- 1 | from fw_fanctrl.dto.command_result.CommandResult import CommandResult 2 | from fw_fanctrl.enum.CommandStatus import CommandStatus 3 | 4 | 5 | class PrintActiveCommandResult(CommandResult): 6 | def __init__(self, active): 7 | super().__init__(CommandStatus.SUCCESS) 8 | self.active = active 9 | 10 | def __str__(self): 11 | return f"Active: {self.active}" 12 | -------------------------------------------------------------------------------- /src/fw_fanctrl/dto/command_result/PrintFanSpeedCommandResult.py: -------------------------------------------------------------------------------- 1 | from fw_fanctrl.dto.command_result.CommandResult import CommandResult 2 | from fw_fanctrl.enum.CommandStatus import CommandStatus 3 | 4 | 5 | class PrintFanSpeedCommandResult(CommandResult): 6 | def __init__(self, speed): 7 | super().__init__(CommandStatus.SUCCESS) 8 | self.speed = speed 9 | 10 | def __str__(self): 11 | return f"Current fan speed: '{self.speed}%'" 12 | -------------------------------------------------------------------------------- /src/fw_fanctrl/dto/command_result/CommandResult.py: -------------------------------------------------------------------------------- 1 | from fw_fanctrl.dto.Printable import Printable 2 | from fw_fanctrl.enum.CommandStatus import CommandStatus 3 | 4 | 5 | class CommandResult(Printable): 6 | def __init__(self, status, reason="Unexpected"): 7 | super().__init__() 8 | self.status = status 9 | if status == CommandStatus.ERROR: 10 | self.reason = reason 11 | 12 | def __str__(self): 13 | return "Success!" if self.status == CommandStatus.SUCCESS else f"[Error] > An error occurred: {self.reason}" 14 | -------------------------------------------------------------------------------- /src/fw_fanctrl/dto/runtime_result/RuntimeResult.py: -------------------------------------------------------------------------------- 1 | from fw_fanctrl.dto.Printable import Printable 2 | from fw_fanctrl.enum.CommandStatus import CommandStatus 3 | 4 | 5 | class RuntimeResult(Printable): 6 | def __init__(self, status, reason="Unexpected"): 7 | super().__init__() 8 | self.status = status 9 | if status == CommandStatus.ERROR: 10 | self.reason = reason 11 | 12 | def __str__(self): 13 | return "Success!" if self.status == CommandStatus.SUCCESS else f"[Error] > An error occurred: {self.reason}" 14 | -------------------------------------------------------------------------------- /src/fw_fanctrl/dto/command_result/StrategyChangeCommandResult.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from fw_fanctrl.dto.command_result.CommandResult import CommandResult 4 | from fw_fanctrl.enum.CommandStatus import CommandStatus 5 | 6 | 7 | class StrategyChangeCommandResult(CommandResult): 8 | def __init__(self, strategy, default): 9 | super().__init__(CommandStatus.SUCCESS) 10 | self.strategy = strategy 11 | self.default = default 12 | 13 | def __str__(self): 14 | return f"Strategy in use: '{self.strategy}'{os.linesep}" f"Default: {self.default}" 15 | -------------------------------------------------------------------------------- /src/fw_fanctrl/dto/command_result/PrintCurrentStrategyCommandResult.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from fw_fanctrl.dto.command_result.CommandResult import CommandResult 4 | from fw_fanctrl.enum.CommandStatus import CommandStatus 5 | 6 | 7 | class PrintCurrentStrategyCommandResult(CommandResult): 8 | def __init__(self, strategy, default): 9 | super().__init__(CommandStatus.SUCCESS) 10 | self.strategy = strategy 11 | self.default = default 12 | 13 | def __str__(self): 14 | return f"Strategy in use: '{self.strategy}'{os.linesep}" f"Default: {self.default}" 15 | -------------------------------------------------------------------------------- /src/fw_fanctrl/dto/command_result/PrintStrategyListCommandResult.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from fw_fanctrl.dto.command_result.CommandResult import CommandResult 4 | from fw_fanctrl.enum.CommandStatus import CommandStatus 5 | 6 | 7 | class PrintStrategyListCommandResult(CommandResult): 8 | def __init__(self, strategies): 9 | super().__init__(CommandStatus.SUCCESS) 10 | self.strategies = strategies 11 | 12 | def __str__(self): 13 | printable_list = f"{os.linesep}- ".join(self.strategies) 14 | return f"Strategy list: {os.linesep}- {printable_list}" 15 | -------------------------------------------------------------------------------- /src/fw_fanctrl/dto/command_result/StrategyResetCommandResult.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from fw_fanctrl.dto.command_result.CommandResult import CommandResult 4 | from fw_fanctrl.enum.CommandStatus import CommandStatus 5 | 6 | 7 | class StrategyResetCommandResult(CommandResult): 8 | def __init__(self, strategy, default): 9 | super().__init__(CommandStatus.SUCCESS) 10 | self.strategy = strategy 11 | self.default = default 12 | 13 | def __str__(self): 14 | return f"Strategy reset to default! Strategy in use: '{self.strategy}'{os.linesep}" f"Default: {self.default}" 15 | -------------------------------------------------------------------------------- /src/fw_fanctrl/dto/command_result/ConfigurationReloadCommandResult.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from fw_fanctrl.dto.command_result.CommandResult import CommandResult 4 | from fw_fanctrl.enum.CommandStatus import CommandStatus 5 | 6 | 7 | class ConfigurationReloadCommandResult(CommandResult): 8 | def __init__(self, strategy, default): 9 | super().__init__(CommandStatus.SUCCESS) 10 | self.strategy = strategy 11 | self.default = default 12 | 13 | def __str__(self): 14 | return f"Reloaded with success! Strategy in use: '{self.strategy}'{os.linesep}" f"Default: {self.default}" 15 | -------------------------------------------------------------------------------- /src/fw_fanctrl/dto/command_result/ServiceResumeCommandResult.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from fw_fanctrl.dto.command_result.CommandResult import CommandResult 4 | from fw_fanctrl.enum.CommandStatus import CommandStatus 5 | 6 | 7 | class ServiceResumeCommandResult(CommandResult): 8 | def __init__(self, strategy, default): 9 | super().__init__(CommandStatus.SUCCESS) 10 | self.strategy = strategy 11 | self.default = default 12 | 13 | def __str__(self): 14 | return ( 15 | f"Service resumed!{os.linesep}" f"Strategy in use: '{self.strategy}'{os.linesep}" f"Default: {self.default}" 16 | ) 17 | -------------------------------------------------------------------------------- /src/fw_fanctrl/hardwareController/HardwareController.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | from fw_fanctrl.exception.UnimplementedException import UnimplementedException 4 | 5 | 6 | class HardwareController(ABC): 7 | @abstractmethod 8 | def get_temperature(self): 9 | raise UnimplementedException() 10 | 11 | @abstractmethod 12 | def set_speed(self, speed): 13 | raise UnimplementedException() 14 | 15 | @abstractmethod 16 | def pause(self): 17 | pass 18 | 19 | @abstractmethod 20 | def resume(self): 21 | pass 22 | 23 | @abstractmethod 24 | def is_on_ac(self): 25 | raise UnimplementedException() 26 | -------------------------------------------------------------------------------- /src/fw_fanctrl/socketController/SocketController.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | from fw_fanctrl.exception.UnimplementedException import UnimplementedException 4 | 5 | 6 | class SocketController(ABC): 7 | @abstractmethod 8 | def start_server_socket(self, command_callback=None): 9 | raise UnimplementedException() 10 | 11 | @abstractmethod 12 | def stop_server_socket(self): 13 | raise UnimplementedException() 14 | 15 | @abstractmethod 16 | def is_server_socket_running(self): 17 | raise UnimplementedException() 18 | 19 | @abstractmethod 20 | def send_via_client_socket(self, command): 21 | raise UnimplementedException() 22 | -------------------------------------------------------------------------------- /src/fw_fanctrl/Strategy.py: -------------------------------------------------------------------------------- 1 | class Strategy: 2 | name = None 3 | fan_speed_update_frequency = None 4 | moving_average_interval = None 5 | speed_curve = None 6 | 7 | def __init__(self, name, parameters): 8 | self.name = name 9 | self.fan_speed_update_frequency = parameters["fanSpeedUpdateFrequency"] 10 | if self.fan_speed_update_frequency is None or self.fan_speed_update_frequency == "": 11 | self.fan_speed_update_frequency = 5 12 | self.moving_average_interval = parameters["movingAverageInterval"] 13 | if self.moving_average_interval is None or self.moving_average_interval == "": 14 | self.moving_average_interval = 20 15 | self.speed_curve = parameters["speedCurve"] 16 | -------------------------------------------------------------------------------- /.github/workflows/pr-check-formatting.yml: -------------------------------------------------------------------------------- 1 | name: Check Formatting on PR 2 | 3 | on: 4 | pull_request: 5 | types: 6 | - synchronize 7 | branches: 8 | - main 9 | 10 | jobs: 11 | check: 12 | name: Check Formatting 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout shallow repository 16 | uses: actions/checkout@v4 17 | with: 18 | fetch-depth: 1 19 | 20 | - name: Set up Python 21 | uses: actions/setup-python@v5 22 | with: 23 | python-version: '3.12' 24 | 25 | - name: Install dependencies 26 | run: | 27 | python -m pip install -e ".[dev]" 28 | 29 | - name: Check formatting 30 | run: | 31 | black --check --diff . 32 | -------------------------------------------------------------------------------- /src/fw_fanctrl/dto/command_result/SetConfigurationCommandResult.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | 4 | from fw_fanctrl.dto.command_result.CommandResult import CommandResult 5 | from fw_fanctrl.enum.CommandStatus import CommandStatus 6 | 7 | 8 | class SetConfigurationCommandResult(CommandResult): 9 | def __init__(self, strategy, default, configuration): 10 | super().__init__(CommandStatus.SUCCESS) 11 | self.strategy = strategy 12 | self.configuration = configuration 13 | self.default = default 14 | 15 | def __str__(self): 16 | return ( 17 | f"Configuration updated with success: {json.dumps(self.configuration)}.{os.linesep}" 18 | f"Strategy in use: {self.strategy}{os.linesep}" 19 | f"Default: {self.default}" 20 | ) 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[FEATURE]" 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | 22 | **Would you like to be involved in the development?** 23 | [yes/no] 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Report a bug in the program 4 | title: "[BUG] " 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Error message** 27 | ```txt 28 | If applicable, add the full error message. 29 | ``` 30 | 31 | **Environment (please complete the following information):** 32 | - OS: [e.g. Arch] 33 | - Version [e.g. commit 176d34b] 34 | - Configuration file [config.json] 35 | - Installation method [full command)/package manager/other...] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report---packaging-windows.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report | packaging/windows 3 | about: Report a bug involving the windows packaging 4 | title: "[BUG] [packaging/windows]" 5 | labels: bug, packaging/windows 6 | assignees: 'leopoldhub' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Error message** 27 | ```txt 28 | If applicable, add the full error message. 29 | ``` 30 | 31 | **Environment (please complete the following information):** 32 | - OS: [e.g. Windows 11] 33 | - Version [e.g. commit 176d34b] 34 | - Configuration file [config.json] 35 | 36 | **Additional context** 37 | Add any other context about the problem here. 38 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report---packaging-aur.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report | packaging/AUR 3 | about: Report a bug involving the AUR packaging 4 | title: "[BUG] [packaging/AUR]" 5 | labels: bug, packaging/AUR 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Error message** 27 | ```txt 28 | If applicable, add the full error message. 29 | ``` 30 | 31 | **Environment (please complete the following information):** 32 | - OS: [e.g. Arch] 33 | - Version [e.g. commit 176d34b] 34 | - Configuration file [config.json] 35 | - Installation method [full command)/package manager/other...] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report---packaging-nix.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report | packaging/nix 3 | about: Report a bug involving the nix packaging 4 | title: "[BUG] [packaging/nix]" 5 | labels: bug, packaging/nix 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Error message** 27 | ```txt 28 | If applicable, add the full error message. 29 | ``` 30 | 31 | **Environment (please complete the following information):** 32 | - OS: [e.g. Arch] 33 | - Version [e.g. commit 176d34b] 34 | - Configuration file [config.json] 35 | - Installation method [full command)/package manager/other...] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | 40 | **Assigned maintainers** 41 | - @Svenum 42 | -------------------------------------------------------------------------------- /pre-uninstall.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # Argument parsing 5 | NO_SUDO=false 6 | SHORT=h 7 | LONG=no-sudo,help 8 | VALID_ARGS=$(getopt -a --options $SHORT --longoptions $LONG -- "$@") 9 | if [[ $? -ne 0 ]]; then 10 | exit 1; 11 | fi 12 | 13 | eval set -- "$VALID_ARGS" 14 | while true; do 15 | case "$1" in 16 | '--no-sudo') 17 | NO_SUDO=true 18 | ;; 19 | '--help' | '-h') 20 | echo "Usage: $0 [--no-sudo]" 1>&2 21 | exit 0 22 | ;; 23 | --) 24 | break 25 | ;; 26 | esac 27 | shift 28 | done 29 | 30 | if [ "$EUID" -ne 0 ] && [ "$NO_SUDO" = false ] 31 | then echo "This program requires root permissions or use the '--no-sudo' option" 32 | exit 1 33 | fi 34 | 35 | SERVICES_DIR="./services" 36 | SERVICE_EXTENSION=".service" 37 | 38 | SERVICES="$(cd "$SERVICES_DIR" && find . -maxdepth 1 -maxdepth 1 -type f -name "*$SERVICE_EXTENSION" -exec basename {} "$SERVICE_EXTENSION" \;)" 39 | 40 | function sanitizePath() { 41 | local SANITIZED_PATH="$1" 42 | local SANITIZED_PATH=${SANITIZED_PATH//..\//} 43 | local SANITIZED_PATH=${SANITIZED_PATH#./} 44 | local SANITIZED_PATH=${SANITIZED_PATH#/} 45 | echo "$SANITIZED_PATH" 46 | } 47 | 48 | echo "disabling services" 49 | systemctl daemon-reload 50 | for SERVICE in $SERVICES ; do 51 | SERVICE=$(sanitizePath "$SERVICE") 52 | echo "stopping [$SERVICE]" 53 | systemctl stop "$SERVICE" 2> "/dev/null" || true 54 | echo "disabling [$SERVICE]" 55 | systemctl disable "$SERVICE" 2> "/dev/null" || true 56 | done 57 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2022, TamTamHero 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /src/fw_fanctrl/dto/runtime_result/StatusRuntimeResult.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from fw_fanctrl.dto.runtime_result.RuntimeResult import RuntimeResult 4 | from fw_fanctrl.enum.CommandStatus import CommandStatus 5 | 6 | 7 | class StatusRuntimeResult(RuntimeResult): 8 | def __init__( 9 | self, 10 | strategy, 11 | default, 12 | speed, 13 | temperature, 14 | moving_average_temperature, 15 | effective_temperature, 16 | active, 17 | configuration, 18 | ): 19 | super().__init__(CommandStatus.SUCCESS) 20 | self.strategy = strategy 21 | self.default = default 22 | self.speed = speed 23 | self.temperature = temperature 24 | self.movingAverageTemperature = moving_average_temperature 25 | self.effectiveTemperature = effective_temperature 26 | self.active = active 27 | self.configuration = configuration 28 | 29 | def __str__(self): 30 | return ( 31 | f"Strategy: '{self.strategy}'{os.linesep}" 32 | f"Default: {self.default}{os.linesep}" 33 | f"Speed: {self.speed}%{os.linesep}" 34 | f"Temp: {self.temperature}°C{os.linesep}" 35 | f"MovingAverageTemp: {self.movingAverageTemperature}°C{os.linesep}" 36 | f"EffectiveTemp: {self.effectiveTemperature}°C{os.linesep}" 37 | f"Active: {self.active}{os.linesep}" 38 | f"DefaultStrategy: '{self.configuration["data"]["defaultStrategy"]}'{os.linesep}" 39 | f"DischargingStrategy: '{self.configuration["data"]["strategyOnDischarging"]}'{os.linesep}" 40 | ) 41 | -------------------------------------------------------------------------------- /fetch/ectool/LICENSE: -------------------------------------------------------------------------------- 1 | // Copyright 2010 The Chromium OS Authors. All rights reserved. 2 | // 3 | // Redistribution and use in source and binary forms, with or without 4 | // modification, are permitted provided that the following conditions are 5 | // met: 6 | // 7 | // * Redistributions of source code must retain the above copyright 8 | // notice, this list of conditions and the following disclaimer. 9 | // * Redistributions in binary form must reproduce the above 10 | // copyright notice, this list of conditions and the following disclaimer 11 | // in the documentation and/or other materials provided with the 12 | // distribution. 13 | // * Neither the name of Google Inc. nor the names of its 14 | // contributors may be used to endorse or promote products derived from 15 | // this software without specific prior written permission. 16 | // 17 | // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 18 | // "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 19 | // LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 20 | // A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 21 | // OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 22 | // SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 23 | // LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 24 | // DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 25 | // THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | // (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | // OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 120 3 | exclude = ''' 4 | /( 5 | \.git 6 | | \.github 7 | | \.idea 8 | | \.vscode 9 | | \.hg 10 | | \.mypy_cache 11 | | \.tox 12 | | \.venv 13 | | _build 14 | | buck-out 15 | | build 16 | | dist 17 | | services 18 | | fetch 19 | | \.temp 20 | )/ 21 | ''' 22 | 23 | [build-system] 24 | requires = ["setuptools>=75.2.0"] 25 | build-backend = "setuptools.build_meta" 26 | 27 | [project] 28 | name = "fw-fanctrl" 29 | version = "1.0.3" 30 | description = "A simple systemd service to better control Framework Laptop's fan(s)." 31 | keywords = ["framework", "laptop", "fan", "control", "cli", "service"] 32 | readme = "README.md" 33 | authors = [ 34 | { name = "TamtamHero" }, 35 | ] 36 | maintainers = [ 37 | { name = "TamtamHero" }, 38 | { name = "leopoldhub" }, 39 | ] 40 | license = { file = "LICENSE" } 41 | classifiers = [ 42 | "License :: OSI Approved :: BSD License", 43 | "Programming Language :: Python :: 3", 44 | "Operating System :: POSIX :: Linux", 45 | "Topic :: System :: Hardware", 46 | ] 47 | requires-python = ">=3.12" 48 | dependencies = [ 49 | "jsonschema~=4.23.0" 50 | ] 51 | optional-dependencies = { dev = [ 52 | "black==24.8.0", 53 | "build>=1.2.2.post1", 54 | "setuptools>=75.2.0", 55 | ] } 56 | 57 | [project.urls] 58 | Homepage = "https://github.com/TamtamHero/fw-fanctrl" 59 | Documentation = "https://github.com/TamtamHero/fw-fanctrl" 60 | Repository = "https://github.com/TamtamHero/fw-fanctrl.git" 61 | Issues = "https://github.com/TamtamHero/fw-fanctrl/issues" 62 | 63 | [tool.setuptools] 64 | package-dir = { "" = "src" } 65 | 66 | [tool.setuptools.package-data] 67 | "fw_fanctrl" = ["_resources/**/*"] 68 | 69 | [project.scripts] 70 | fw-fanctrl = "fw_fanctrl.__main__:main" 71 | -------------------------------------------------------------------------------- /post-install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | HOME_DIR="$(eval echo "~$(logname)")" 5 | 6 | # Argument parsing 7 | NO_SUDO=false 8 | SHORT=d:,s:,h 9 | LONG=dest-dir:,sysconf-dir:,no-sudo,help 10 | VALID_ARGS=$(getopt -a --options $SHORT --longoptions $LONG -- "$@") 11 | if [[ $? -ne 0 ]]; then 12 | exit 1; 13 | fi 14 | 15 | DEST_DIR="/usr" 16 | SYSCONF_DIR="/etc" 17 | 18 | eval set -- "$VALID_ARGS" 19 | while true; do 20 | case "$1" in 21 | '--dest-dir' | '-d') 22 | DEST_DIR=$2 23 | shift 24 | ;; 25 | '--sysconf-dir' | '-s') 26 | SYSCONF_DIR=$2 27 | shift 28 | ;; 29 | '--no-sudo') 30 | NO_SUDO=true 31 | ;; 32 | '--help' | '-h') 33 | echo "Usage: $0 [--dest-dir,-d ] [--sysconf-dir,-s system configuration destination directory (defaults to $SYSCONF_DIR)] [--no-sudo]" 1>&2 34 | exit 0 35 | ;; 36 | --) 37 | break 38 | ;; 39 | esac 40 | shift 41 | done 42 | 43 | # Root check 44 | if [ "$EUID" -ne 0 ] && [ "$NO_SUDO" = false ] 45 | then echo "This program requires root permissions or use the '--no-sudo' option" 46 | exit 1 47 | fi 48 | 49 | SERVICES_DIR="./services" 50 | SERVICE_EXTENSION=".service" 51 | 52 | SERVICES="$(cd "$SERVICES_DIR" && find . -maxdepth 1 -maxdepth 1 -type f -name "*$SERVICE_EXTENSION" -exec basename {} "$SERVICE_EXTENSION" \;)" 53 | 54 | function sanitizePath() { 55 | local SANITIZED_PATH="$1" 56 | local SANITIZED_PATH=${SANITIZED_PATH//..\//} 57 | local SANITIZED_PATH=${SANITIZED_PATH#./} 58 | local SANITIZED_PATH=${SANITIZED_PATH#/} 59 | echo "$SANITIZED_PATH" 60 | } 61 | 62 | # move remaining legacy files 63 | function move_legacy() { 64 | echo "moving legacy files to their new destination" 65 | (cp "$HOME_DIR/.config/fw-fanctrl"/* "$DEST_DIR$SYSCONF_DIR/fw-fanctrl/" && rm -rf "$HOME_DIR/.config/fw-fanctrl") 2> "/dev/null" || true 66 | } 67 | 68 | move_legacy 69 | 70 | echo "enabling services" 71 | systemctl daemon-reload 72 | for SERVICE in $SERVICES ; do 73 | SERVICE=$(sanitizePath "$SERVICE") 74 | echo "enabling [$SERVICE]" 75 | systemctl enable "$SERVICE" 76 | echo "starting [$SERVICE]" 77 | systemctl start "$SERVICE" 78 | done 79 | -------------------------------------------------------------------------------- /src/fw_fanctrl/__main__.py: -------------------------------------------------------------------------------- 1 | import shlex 2 | import sys 3 | 4 | from fw_fanctrl.CommandParser import CommandParser 5 | from fw_fanctrl.FanController import FanController 6 | from fw_fanctrl.dto.command_result.CommandResult import CommandResult 7 | from fw_fanctrl.enum.CommandStatus import CommandStatus 8 | from fw_fanctrl.enum.OutputFormat import OutputFormat 9 | from fw_fanctrl.hardwareController.EctoolHardwareController import EctoolHardwareController 10 | from fw_fanctrl.socketController.UnixSocketController import UnixSocketController 11 | 12 | 13 | def main(): 14 | try: 15 | args = CommandParser().parse_args(shlex.split(shlex.join(sys.argv[1:]))) 16 | except Exception as e: 17 | _cre = CommandResult(CommandStatus.ERROR, str(e)) 18 | print(_cre.to_output_format(OutputFormat.NATURAL), file=sys.stderr) 19 | exit(1) 20 | 21 | socket_controller = UnixSocketController() 22 | if args.socket_controller == "unix": 23 | socket_controller = UnixSocketController() 24 | 25 | if args.command == "run": 26 | hardware_controller = EctoolHardwareController(no_battery_sensor_mode=args.no_battery_sensors) 27 | if args.hardware_controller == "ectool": 28 | hardware_controller = EctoolHardwareController(no_battery_sensor_mode=args.no_battery_sensors) 29 | 30 | fan = FanController( 31 | hardware_controller=hardware_controller, 32 | socket_controller=socket_controller, 33 | config_path=args.config, 34 | strategy_name=args.strategy, 35 | output_format=getattr(args, "output_format", None), 36 | ) 37 | fan.run(debug=not args.silent) 38 | else: 39 | try: 40 | command_result = socket_controller.send_via_client_socket(shlex.join(sys.argv[1:])) 41 | if command_result: 42 | print(command_result) 43 | except Exception as e: 44 | if str(e).startswith("[Error] >"): 45 | print(str(e), file=sys.stderr) 46 | else: 47 | _cre = CommandResult(CommandStatus.ERROR, str(e)) 48 | print(_cre.to_output_format(getattr(args, "output_format", None)), file=sys.stderr) 49 | exit(1) 50 | 51 | 52 | if __name__ == "__main__": 53 | main() 54 | -------------------------------------------------------------------------------- /.github/workflows/auto-tag-and-release.yml: -------------------------------------------------------------------------------- 1 | name: Auto Tag and Release on Version Change 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | tag: 10 | name: Tag if Version Changed 11 | runs-on: ubuntu-latest 12 | outputs: 13 | current_version: ${{ steps.extract.outputs.current }} 14 | version_changed: ${{ steps.extract.outputs.version_changed }} 15 | steps: 16 | - name: Checkout repository with history 17 | uses: actions/checkout@v4 18 | with: 19 | fetch-depth: 2 20 | 21 | - name: Set up Python 22 | uses: actions/setup-python@v5 23 | with: 24 | python-version: '3.12' 25 | 26 | - name: Install dependencies extraction dependencies 27 | run: | 28 | python -m pip install --upgrade pip toml 29 | 30 | - name: Extract versions and determine change 31 | id: extract 32 | run: | 33 | PREVIOUS_VERSION=$(git show HEAD^:pyproject.toml | python -c "import sys, toml; print(toml.loads(sys.stdin.read())['project']['version'])") 34 | CURRENT_VERSION=$(cat pyproject.toml | python -c "import sys, toml; print(toml.loads(sys.stdin.read())['project']['version'])") 35 | echo "previous=$PREVIOUS_VERSION" >> $GITHUB_OUTPUT 36 | echo "current=$CURRENT_VERSION" >> $GITHUB_OUTPUT 37 | if [ "$PREVIOUS_VERSION" != "$CURRENT_VERSION" ]; then 38 | echo "version_changed=true" >> $GITHUB_OUTPUT 39 | else 40 | echo "version_changed=false" >> $GITHUB_OUTPUT 41 | fi 42 | 43 | - name: Create git tag if version changed 44 | if: ${{ steps.extract.outputs.version_changed == 'true' }} 45 | run: | 46 | git config user.name "${{ github.actor }}" 47 | git config user.email "${{ github.actor }}@users.noreply.github.com" 48 | git tag "v${{ steps.extract.outputs.current }}" 49 | git push origin "v${{ steps.extract.outputs.current }}" 50 | 51 | release: 52 | name: Release with New Tag 53 | runs-on: ubuntu-latest 54 | needs: tag 55 | if: ${{ needs.tag.outputs.version_changed == 'true' }} 56 | steps: 57 | - name: Checkout repository 58 | uses: actions/checkout@v4 59 | 60 | - name: Set up Python 61 | uses: actions/setup-python@v5 62 | with: 63 | python-version: '3.12' 64 | 65 | - name: Install build dependencies 66 | run: | 67 | python -m pip install --upgrade pip build 68 | 69 | - name: Build distribution packages 70 | run: | 71 | python -m build -s 72 | 73 | - name: Create GitHub Release 74 | uses: softprops/action-gh-release@v2 75 | with: 76 | files: 'dist/*' 77 | tag_name: 'v${{needs.tag.outputs.current_version}}' 78 | -------------------------------------------------------------------------------- /src/fw_fanctrl/hardwareController/EctoolHardwareController.py: -------------------------------------------------------------------------------- 1 | import re 2 | import subprocess 3 | from abc import ABC 4 | 5 | from fw_fanctrl.hardwareController.HardwareController import HardwareController 6 | 7 | 8 | class EctoolHardwareController(HardwareController, ABC): 9 | noBatterySensorMode = False 10 | nonBatterySensors = None 11 | 12 | def __init__(self, no_battery_sensor_mode=False): 13 | if no_battery_sensor_mode: 14 | self.noBatterySensorMode = True 15 | self.populate_non_battery_sensors() 16 | 17 | def populate_non_battery_sensors(self): 18 | self.nonBatterySensors = [] 19 | raw_out = subprocess.run( 20 | "ectool tempsinfo all", 21 | stdout=subprocess.PIPE, 22 | shell=True, 23 | text=True, 24 | ).stdout 25 | battery_sensors_raw = re.findall(r"\d+ Battery", raw_out, re.MULTILINE) 26 | battery_sensors = [x.split(" ")[0] for x in battery_sensors_raw] 27 | for x in re.findall(r"^\d+", raw_out, re.MULTILINE): 28 | if x not in battery_sensors: 29 | self.nonBatterySensors.append(x) 30 | 31 | def get_temperature(self): 32 | if self.noBatterySensorMode: 33 | raw_out = "".join( 34 | [ 35 | subprocess.run( 36 | "ectool temps " + x, 37 | stdout=subprocess.PIPE, 38 | shell=True, 39 | text=True, 40 | ).stdout 41 | for x in self.nonBatterySensors 42 | ] 43 | ) 44 | else: 45 | raw_out = subprocess.run( 46 | "ectool temps all", 47 | stdout=subprocess.PIPE, 48 | shell=True, 49 | text=True, 50 | ).stdout 51 | raw_temps = re.findall(r"\(= (\d+) C\)", raw_out) 52 | temps = sorted([x for x in [int(x) for x in raw_temps] if x > 0], reverse=True) 53 | # safety fallback to avoid damaging hardware 54 | if len(temps) == 0: 55 | return 50 56 | return float(round(temps[0], 2)) 57 | 58 | def set_speed(self, speed): 59 | subprocess.run(f"ectool fanduty {speed}", stdout=subprocess.PIPE, shell=True) 60 | 61 | def is_on_ac(self): 62 | raw_out = subprocess.run( 63 | "ectool battery", 64 | stdout=subprocess.PIPE, 65 | stderr=subprocess.DEVNULL, 66 | shell=True, 67 | text=True, 68 | ).stdout 69 | return len(re.findall(r"Flags.*(AC_PRESENT)", raw_out)) > 0 70 | 71 | def pause(self): 72 | subprocess.run("ectool autofanctrl", stdout=subprocess.PIPE, shell=True) 73 | 74 | def resume(self): 75 | # Empty for ectool, as setting an arbitrary speed disables the automatic fan control 76 | pass 77 | -------------------------------------------------------------------------------- /src/fw_fanctrl/Configuration.py: -------------------------------------------------------------------------------- 1 | import json 2 | from json import JSONDecodeError 3 | from os.path import isfile 4 | from shutil import copyfile 5 | 6 | import jsonschema 7 | 8 | from fw_fanctrl import INTERNAL_RESOURCES_PATH 9 | from fw_fanctrl.Strategy import Strategy 10 | from fw_fanctrl.exception.ConfigurationParsingException import ConfigurationParsingException 11 | from fw_fanctrl.exception.InvalidStrategyException import InvalidStrategyException 12 | 13 | VALIDATION_SCHEMA_PATH = INTERNAL_RESOURCES_PATH.joinpath("config.schema.json") 14 | ORIGINAL_CONFIG_PATH = INTERNAL_RESOURCES_PATH.joinpath("config.json") 15 | 16 | 17 | class Configuration: 18 | path = None 19 | data = None 20 | 21 | def __init__(self, path): 22 | self.path = path 23 | self.reload() 24 | 25 | def parse(self, raw_config): 26 | try: 27 | config = json.loads(raw_config) 28 | if "$schema" not in config: 29 | original_config = json.load(ORIGINAL_CONFIG_PATH.open("r")) 30 | config["$schema"] = original_config["$schema"] 31 | jsonschema.Draft202012Validator(json.load(VALIDATION_SCHEMA_PATH.open("r"))).validate(config) 32 | if config["defaultStrategy"] not in config["strategies"]: 33 | raise ConfigurationParsingException( 34 | f"Default strategy '{config["defaultStrategy"]}' is not a valid strategy." 35 | ) 36 | if config["strategyOnDischarging"] != "" and config["strategyOnDischarging"] not in config["strategies"]: 37 | raise ConfigurationParsingException( 38 | f"Discharging strategy '{config['strategyOnDischarging']}' is not a valid strategy." 39 | ) 40 | return config 41 | except JSONDecodeError as e: 42 | raise ConfigurationParsingException(f"Error parsing configuration file: {e}") 43 | 44 | def reload(self): 45 | if not isfile(self.path): 46 | copyfile(ORIGINAL_CONFIG_PATH, self.path) 47 | with open(self.path, "r") as fp: 48 | raw_config = fp.read() 49 | self.data = self.parse(raw_config) 50 | 51 | def save(self): 52 | string_config = json.dumps(self.data, indent=4) 53 | with open(self.path, "w") as fp: 54 | fp.write(string_config) 55 | 56 | def get_strategies(self): 57 | return self.data["strategies"].keys() 58 | 59 | def get_strategy(self, strategy_name): 60 | if strategy_name == "strategyOnDischarging": 61 | strategy_name = self.data[strategy_name] 62 | if strategy_name == "": 63 | strategy_name = "defaultStrategy" 64 | if strategy_name == "defaultStrategy": 65 | strategy_name = self.data[strategy_name] 66 | if strategy_name is None or strategy_name not in self.data["strategies"]: 67 | raise InvalidStrategyException(strategy_name) 68 | return Strategy(strategy_name, self.data["strategies"][strategy_name]) 69 | 70 | def get_default_strategy(self): 71 | return self.get_strategy("defaultStrategy") 72 | 73 | def get_discharging_strategy(self): 74 | return self.get_strategy("strategyOnDischarging") 75 | -------------------------------------------------------------------------------- /src/fw_fanctrl/_resources/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./config.schema.json", 3 | "defaultStrategy": "lazy", 4 | "strategyOnDischarging" : "", 5 | "strategies": { 6 | "laziest": { 7 | "fanSpeedUpdateFrequency": 5, 8 | "movingAverageInterval": 40, 9 | "speedCurve": [ 10 | { "temp": 0, "speed": 0 }, 11 | { "temp": 45, "speed": 0 }, 12 | { "temp": 65, "speed": 25 }, 13 | { "temp": 70, "speed": 35 }, 14 | { "temp": 75, "speed": 50 }, 15 | { "temp": 85, "speed": 100 } 16 | ] 17 | }, 18 | "lazy": { 19 | "fanSpeedUpdateFrequency": 5, 20 | "movingAverageInterval": 30, 21 | "speedCurve": [ 22 | { "temp": 0, "speed": 15 }, 23 | { "temp": 50, "speed": 15 }, 24 | { "temp": 65, "speed": 25 }, 25 | { "temp": 70, "speed": 35 }, 26 | { "temp": 75, "speed": 50 }, 27 | { "temp": 85, "speed": 100 } 28 | ] 29 | }, 30 | "medium": { 31 | "fanSpeedUpdateFrequency": 5, 32 | "movingAverageInterval": 30, 33 | "speedCurve": [ 34 | { "temp": 0, "speed": 15 }, 35 | { "temp": 40, "speed": 15 }, 36 | { "temp": 60, "speed": 30 }, 37 | { "temp": 70, "speed": 40 }, 38 | { "temp": 75, "speed": 80 }, 39 | { "temp": 85, "speed": 100 } 40 | ] 41 | }, 42 | "agile": { 43 | "fanSpeedUpdateFrequency": 3, 44 | "movingAverageInterval": 15, 45 | "speedCurve": [ 46 | { "temp": 0, "speed": 15 }, 47 | { "temp": 40, "speed": 15 }, 48 | { "temp": 60, "speed": 30 }, 49 | { "temp": 70, "speed": 40 }, 50 | { "temp": 75, "speed": 80 }, 51 | { "temp": 85, "speed": 100 } 52 | ] 53 | }, 54 | "very-agile": { 55 | "fanSpeedUpdateFrequency": 2, 56 | "movingAverageInterval": 5, 57 | "speedCurve": [ 58 | { "temp": 0, "speed": 15 }, 59 | { "temp": 40, "speed": 15 }, 60 | { "temp": 60, "speed": 30 }, 61 | { "temp": 70, "speed": 40 }, 62 | { "temp": 75, "speed": 80 }, 63 | { "temp": 85, "speed": 100 } 64 | ] 65 | }, 66 | "deaf": { 67 | "fanSpeedUpdateFrequency": 2, 68 | "movingAverageInterval": 5, 69 | "speedCurve": [ 70 | { "temp": 0, "speed": 20 }, 71 | { "temp": 40, "speed": 30 }, 72 | { "temp": 50, "speed": 50 }, 73 | { "temp": 60, "speed": 100 } 74 | ] 75 | }, 76 | "aeolus": { 77 | "fanSpeedUpdateFrequency": 2, 78 | "movingAverageInterval": 5, 79 | "speedCurve": [ 80 | { "temp": 0, "speed": 20 }, 81 | { "temp": 40, "speed": 50 }, 82 | { "temp": 65, "speed": 100 } 83 | ] 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /doc/commands.md: -------------------------------------------------------------------------------- 1 | # Commands 2 | 3 | Here is a list of commands and options used to interact with the service. 4 | 5 | the base of all commands is the following 6 | 7 | ```shell 8 | fw-fanctrl [commands and options] 9 | ``` 10 | 11 | First, the global options 12 | 13 | | Option | Optional | Choices | Default | Description | 14 | |---------------------------|----------|---------------|---------|--------------------------------------------------------------------------------| 15 | | --socket-controller, --sc | yes | unix | unix | the socket controller to use for communication between the cli and the service | 16 | | --output-format | yes | NATURAL, JSON | NATURAL | the client socket controller output format | 17 | 18 | **run** 19 | 20 | run the service manually 21 | 22 | If you have installed it correctly, the systemd `fw-fanctrl.service` service will do this for you, so you probably will 23 | never need those. 24 | 25 | | Option | Optional | Choices | Default | Description | 26 | |-----------------------------|----------|----------------|----------------------|-----------------------------------------------------------------------------------| 27 | | \ | yes | | the default strategy | the name of the strategy to use | 28 | | --config | yes | \[CONFIG_PATH] | | the configuration file path | 29 | | --silent, -s | yes | | | disable printing speed/temp status to stdout | 30 | | --hardware-controller, --hc | yes | ectool | ectool | the hardware controller to use for fetching and setting the temp and fan(s) speed | 31 | | --no-battery-sensors | yes | | | disable checking battery temperature sensors (for mainboards without batteries) | 32 | 33 | **use** 34 | 35 | change the current strategy 36 | 37 | | Option | Optional | Description | 38 | |-------------|----------|---------------------------------| 39 | | \ | no | the name of the strategy to use | 40 | 41 | **reset** 42 | 43 | reset to the default strategy 44 | 45 | **reload** 46 | 47 | reload the configuration file 48 | 49 | **pause** 50 | 51 | pause the service 52 | 53 | **resume** 54 | 55 | resume the service 56 | 57 | **print** 58 | 59 | print the selected information 60 | 61 | | Option | Optional | Choices | Default | Description | 62 | |--------------------|----------|---------------------------|---------|------------------------| 63 | | \ | yes | all, current, list, speed | all | what should be printed | 64 | 65 | | Choice | Description | 66 | |---------|----------------------------------| 67 | | all | All details | 68 | | current | The current strategy being used | 69 | | list | List available strategies | 70 | | speed | The current fan speed percentage | 71 | -------------------------------------------------------------------------------- /doc/configuration.md: -------------------------------------------------------------------------------- 1 | # Table of Content 2 | 3 | 4 | * [Table of Content](#table-of-content) 5 | * [Configuration](#configuration) 6 | * [Default Strategy](#default-strategy) 7 | * [Discharging Strategy](#discharging-strategy) 8 | * [Strategies](#strategies) 9 | * [Requirements](#requirements) 10 | * [Speed Curve](#speed-curve) 11 | * [Fan Speed Update Frequency](#fan-speed-update-frequency) 12 | * [Moving Average Interval](#moving-average-interval) 13 | 14 | 15 | # Configuration 16 | 17 | The service uses these configuration files by default: 18 | 19 | - Main configuration: `/etc/fw-fanctrl/config.json` 20 | - JSON Schema: `/etc/fw-fanctrl/config.schema.json` 21 | 22 | For custom installations using dest-dir or sysconf-dir parameters: 23 | 24 | - `[dest-dir(/)][sysconf-dir(/etc)]/fw-fanctrl/config.json` 25 | - `[dest-dir(/)][sysconf-dir(/etc)]/fw-fanctrl/config.schema.json` 26 | 27 | The configuration contains a list of strategies, ranked from the quietest to loudest, 28 | as well as the default and discharging strategies. 29 | 30 | For example, one could use a lower fan speed strategy on discharging to optimize battery life (- noise, + heat), 31 | and a high fan speed strategy on AC (+ noise, - heat). 32 | 33 | **The schema contains the structure and restrictions for a valid configuration.** 34 | 35 | You can add or edit strategies, and if you think you have one that deserves to be shared, 36 | feel free to share it in [#110](https://github.com/TamtamHero/fw-fanctrl/issues/110). 37 | 38 | ## Default Strategy 39 | 40 | The default strategy serves as the initial fan control profile when the service starts. 41 | 42 | It remains active unless you manually select a different strategy, 43 | at which point your chosen strategy takes precedence until you reset to the default strategy explicitly, 44 | or the service restarts. 45 | 46 | It can be changed by replacing the value of the `defaultStrategy` field with one of the strategies present in the 47 | configuration. 48 | 49 | e.g.: 50 | 51 | ``` 52 | "defaultStrategy": "medium" 53 | ``` 54 | 55 | ## Discharging Strategy 56 | 57 | The discharging strategy will be used when on default strategy behavior and battery power. 58 | 59 | It can be changed by replacing the value of the `strategyOnDischarging` field with one of the strategies present in the 60 | configuration. 61 | 62 | ``` 63 | "strategyOnDischarging": "laziest" 64 | ``` 65 | 66 | This field is optional and can be left empty for it to have the same behavior as on AC. 67 | 68 | ## Strategies 69 | 70 | Define strategies under strategies object using this format: 71 | 72 | ``` 73 | "strategies": { 74 | "strategy-name": { 75 | "speedCurve": [ ... ], 76 | "fanSpeedUpdateFrequency": 5, 77 | "movingAverageInterval": 20 78 | } 79 | } 80 | ``` 81 | 82 | ### Requirements 83 | 84 | Strategies must have unique names composed of upper/lower case letters, numbers, underscores or hyphens. 85 | 86 | `[a-zA-Z0-9_-]+` 87 | 88 | And, at least have the `speedCurve` property defined. 89 | 90 | ### Speed Curve 91 | 92 | It represents by the curve points for `f(temperature) = fan(s) speed`. 93 | 94 | The `temp` field value is a number with precision of up to 0.01°C (e.g., 15.23), 95 | while the `speed` is a positive integer between 0 and 100 %. 96 | 97 | It should contain at least a single temperature point. 98 | 99 | ``` 100 | "speedCurve": [ 101 | { "temp": 40, "speed": 20 }, 102 | { "temp": 60.5, "speed": 50 }, 103 | { "temp": 80.7, "speed": 100 } 104 | ] 105 | ``` 106 | 107 | > `fw-fanctrl` measures the CPU temperature, calculates a moving average of it, and then finds an 108 | > appropriate `fan speed` value by interpolating on the curve. 109 | 110 | ### Fan Speed Update Frequency 111 | 112 | It is the interval between fan speed adjustments. 113 | 114 | - Lower values → faster response to temperature changes 115 | - Higher values → smoother transitions 116 | 117 | It is an optional positive integer comprised between 1 and 10 and defaults to 5. 118 | 119 | ``` 120 | "fanSpeedUpdateFrequency": 5 121 | ``` 122 | 123 | > This is for comfort, otherwise the speed will change too often, which is noticeable and annoying, especially at low 124 | > speed. 125 | 126 | ### Moving Average Interval 127 | 128 | It is the time window in seconds over which the moving average of temperature is calculated. 129 | 130 | - Lower values → immediate reaction to spikes 131 | - Higher values → stabilized readings 132 | 133 | It is an optional positive integer comprised between 1 and 100 and defaults to 20. 134 | 135 | ``` 136 | "movingAverageInterval": 20 137 | ``` 138 | 139 | --- 140 | 141 | Once the configuration has been changed, you must reload it with the following command 142 | 143 | ```bash 144 | fw-fanctrl reload 145 | ``` 146 | -------------------------------------------------------------------------------- /src/fw_fanctrl/socketController/UnixSocketController.py: -------------------------------------------------------------------------------- 1 | import io 2 | import os 3 | import shlex 4 | import socket 5 | import sys 6 | from abc import ABC 7 | 8 | from fw_fanctrl import COMMANDS_SOCKET_FILE_PATH, SOCKETS_FOLDER_PATH 9 | from fw_fanctrl.CommandParser import CommandParser 10 | from fw_fanctrl.dto.command_result.CommandResult import CommandResult 11 | from fw_fanctrl.enum.CommandStatus import CommandStatus 12 | from fw_fanctrl.enum.OutputFormat import OutputFormat 13 | from fw_fanctrl.exception.SocketAlreadyRunningException import SocketAlreadyRunningException 14 | from fw_fanctrl.exception.SocketCallException import SocketCallException 15 | from fw_fanctrl.socketController.SocketController import SocketController 16 | 17 | 18 | class UnixSocketController(SocketController, ABC): 19 | server_socket = None 20 | 21 | def start_server_socket(self, command_callback=None): 22 | if self.server_socket: 23 | raise SocketAlreadyRunningException(self.server_socket) 24 | self.server_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) 25 | if os.path.exists(COMMANDS_SOCKET_FILE_PATH): 26 | os.remove(COMMANDS_SOCKET_FILE_PATH) 27 | try: 28 | if not os.path.exists(SOCKETS_FOLDER_PATH): 29 | os.makedirs(SOCKETS_FOLDER_PATH) 30 | self.server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 31 | self.server_socket.bind(COMMANDS_SOCKET_FILE_PATH) 32 | os.chmod(COMMANDS_SOCKET_FILE_PATH, 0o777) 33 | self.server_socket.listen(1) 34 | while True: 35 | client_socket, _ = self.server_socket.accept() 36 | parse_print_capture = io.StringIO() 37 | args = None 38 | try: 39 | # Receive data from the client 40 | data = client_socket.recv(4096).decode() 41 | original_stderr = sys.stderr 42 | original_stdout = sys.stdout 43 | # capture parsing std outputs for the client 44 | sys.stderr = parse_print_capture 45 | sys.stdout = parse_print_capture 46 | 47 | try: 48 | args = CommandParser(True).parse_args(shlex.split(data)) 49 | finally: 50 | sys.stderr = original_stderr 51 | sys.stdout = original_stdout 52 | 53 | command_result = command_callback(args) 54 | 55 | if args.output_format == OutputFormat.JSON: 56 | if parse_print_capture.getvalue().strip(): 57 | command_result.info = parse_print_capture.getvalue() 58 | client_socket.sendall(command_result.to_output_format(args.output_format).encode("utf-8")) 59 | else: 60 | natural_result = command_result.to_output_format(args.output_format) 61 | if parse_print_capture.getvalue().strip(): 62 | natural_result = parse_print_capture.getvalue() + natural_result 63 | client_socket.sendall(natural_result.encode("utf-8")) 64 | except (SystemExit, Exception) as e: 65 | _cre = CommandResult( 66 | CommandStatus.ERROR, f"An error occurred while treating a socket command: {e}" 67 | ).to_output_format(getattr(args, "output_format", None)) 68 | print(_cre, file=sys.stderr) 69 | client_socket.sendall(_cre.encode("utf-8")) 70 | finally: 71 | client_socket.shutdown(socket.SHUT_WR) 72 | client_socket.close() 73 | finally: 74 | self.stop_server_socket() 75 | 76 | def stop_server_socket(self): 77 | if self.server_socket: 78 | self.server_socket.close() 79 | self.server_socket = None 80 | 81 | def is_server_socket_running(self): 82 | return self.server_socket is not None 83 | 84 | def send_via_client_socket(self, command): 85 | client_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) 86 | try: 87 | client_socket.connect(COMMANDS_SOCKET_FILE_PATH) 88 | client_socket.sendall(command.encode("utf-8")) 89 | received_data = b"" 90 | while True: 91 | data_chunk = client_socket.recv(1024) 92 | if not data_chunk: 93 | break 94 | received_data += data_chunk 95 | # Receive data from the server 96 | data = received_data.decode() 97 | if data.startswith("[Error] > "): 98 | raise SocketCallException(data) 99 | return data 100 | finally: 101 | if client_socket: 102 | client_socket.close() 103 | -------------------------------------------------------------------------------- /src/fw_fanctrl/_resources/config.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json-schema.org/draft/2020-12/schema", 3 | "$id": "file://config.schema.json", 4 | "title": "fw-fanctrl configuration", 5 | "description": "Configuration for the fw-fanctrl service, defining fan control strategies based on temperature thresholds.", 6 | "type": "object", 7 | "patternProperties": { 8 | "^\\$schema$": { 9 | "type": "string", 10 | "format": "uri", 11 | "description": "Points to the JSON Schema to use for validation.", 12 | "pattern": "^\\./config\\.schema\\.json$" 13 | } 14 | }, 15 | "properties": { 16 | "defaultStrategy": { 17 | "description": "The default strategy to use.", 18 | "$comment": "Must match a key in the `strategies` object.", 19 | "$ref": "#/$defs/strategyKey" 20 | }, 21 | "strategyOnDischarging": { 22 | "description": "The strategy to use when the system is on battery power. Use an empty string to disable special behavior.", 23 | "oneOf": [ 24 | { 25 | "type": "string", 26 | "const": "", 27 | "description": "Disables battery-specific behavior (use default strategy)." 28 | }, 29 | { 30 | "$comment": "Must match a key in the `strategies` object.", 31 | "$ref": "#/$defs/strategyKey" 32 | } 33 | ] 34 | }, 35 | "strategies": { 36 | "description": "A collection of named fan control strategies.", 37 | "type": "object", 38 | "minProperties": 1, 39 | "additionalProperties": { 40 | "$ref": "#/$defs/strategy" 41 | }, 42 | "propertyNames": { 43 | "$ref": "#/$defs/strategyKey" 44 | } 45 | } 46 | }, 47 | "required": [ 48 | "defaultStrategy", 49 | "strategyOnDischarging", 50 | "strategies", 51 | "$schema" 52 | ], 53 | "additionalProperties": false, 54 | "$defs": { 55 | "strategyKey": { 56 | "type": "string", 57 | "pattern": "^[a-zA-Z0-9_-]+$", 58 | "description": "A unique identifier for a strategy (alphanumeric, underscores, and hyphens allowed).", 59 | "examples": [ 60 | "lazy", 61 | "agile", 62 | "deaf" 63 | ] 64 | }, 65 | "strategy": { 66 | "type": "object", 67 | "description": "A strategy defines how fan speed is adjusted based on temperature readings.", 68 | "properties": { 69 | "fanSpeedUpdateFrequency": { 70 | "type": "integer", 71 | "minimum": 1, 72 | "maximum": 10, 73 | "description": "How frequently (in seconds) to update the fan speed." 74 | }, 75 | "movingAverageInterval": { 76 | "type": "integer", 77 | "minimum": 1, 78 | "maximum": 100, 79 | "description": "The time window (in seconds) over which temperature readings are averaged." 80 | }, 81 | "speedCurve": { 82 | "type": "array", 83 | "minItems": 1, 84 | "description": "A list of temperature-speed pairs defining the fan response curve. Should be sorted by ascending `temp`.", 85 | "items": { 86 | "type": "object", 87 | "properties": { 88 | "temp": { 89 | "type": "number", 90 | "multipleOf" : 0.01, 91 | "minimum": 0, 92 | "maximum": 100, 93 | "description": "Temperature threshold (in degrees Celsius) up to two digit precision (e.g. 15.23)." 94 | }, 95 | "speed": { 96 | "type": "integer", 97 | "minimum": 0, 98 | "maximum": 100, 99 | "description": "Fan speed (in percent) to set when the temperature reaches this threshold." 100 | } 101 | }, 102 | "required": [ 103 | "temp", 104 | "speed" 105 | ], 106 | "additionalProperties": false 107 | }, 108 | "uniqueItems": true, 109 | "minProperties": 1, 110 | "examples": [ 111 | { 112 | "temp": 0, 113 | "speed": 0 114 | }, 115 | { 116 | "temp": 65, 117 | "speed": 25 118 | }, 119 | { 120 | "temp": 85, 121 | "speed": 100 122 | } 123 | ] 124 | } 125 | }, 126 | "required": [ 127 | "speedCurve" 128 | ], 129 | "additionalProperties": false 130 | } 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Python template 2 | # Byte-compiled / optimized / DLL files 3 | __pycache__/ 4 | *.py[cod] 5 | *$py.class 6 | 7 | # C extensions 8 | *.so 9 | 10 | # Distribution / packaging 11 | .Python 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | cover/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | db.sqlite3-journal 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | 75 | # PyBuilder 76 | .pybuilder/ 77 | target/ 78 | 79 | # Jupyter Notebook 80 | .ipynb_checkpoints 81 | 82 | # IPython 83 | profile_default/ 84 | ipython_config.py 85 | 86 | # pyenv 87 | # For a library or package, you might want to ignore these files since the code is 88 | # intended to run in multiple environments; otherwise, check them in: 89 | # .python-version 90 | 91 | # pipenv 92 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 93 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 94 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 95 | # install all needed dependencies. 96 | #Pipfile.lock 97 | 98 | # poetry 99 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 100 | # This is especially recommended for binary packages to ensure reproducibility, and is more 101 | # commonly ignored for libraries. 102 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 103 | #poetry.lock 104 | 105 | # pdm 106 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 107 | #pdm.lock 108 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 109 | # in version control. 110 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 111 | .pdm.toml 112 | .pdm-python 113 | .pdm-build/ 114 | 115 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 116 | __pypackages__/ 117 | 118 | # Celery stuff 119 | celerybeat-schedule 120 | celerybeat.pid 121 | 122 | # SageMath parsed files 123 | *.sage.py 124 | 125 | # Environments 126 | .env 127 | .venv 128 | env/ 129 | venv/ 130 | ENV/ 131 | env.bak/ 132 | venv.bak/ 133 | 134 | # Spyder project settings 135 | .spyderproject 136 | .spyproject 137 | 138 | # Rope project settings 139 | .ropeproject 140 | 141 | # mkdocs documentation 142 | /site 143 | 144 | # mypy 145 | .mypy_cache/ 146 | .dmypy.json 147 | dmypy.json 148 | 149 | # Pyre type checker 150 | .pyre/ 151 | 152 | # pytype static type analyzer 153 | .pytype/ 154 | 155 | # Cython debug symbols 156 | cython_debug/ 157 | 158 | # PyCharm 159 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 160 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 161 | # and can be added to the global gitignore or merged into this file. For a more nuclear 162 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 163 | #.idea/ 164 | 165 | ### VisualStudioCode template 166 | .vscode/* 167 | !.vscode/settings.json 168 | !.vscode/tasks.json 169 | !.vscode/launch.json 170 | !.vscode/extensions.json 171 | !.vscode/*.code-snippets 172 | 173 | # Local History for Visual Studio Code 174 | .history/ 175 | 176 | # Built Visual Studio Code Extensions 177 | *.vsix 178 | 179 | ### JetBrains template 180 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 181 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 182 | 183 | # User-specific stuff 184 | .idea/**/workspace.xml 185 | .idea/**/tasks.xml 186 | .idea/**/usage.statistics.xml 187 | .idea/**/dictionaries 188 | .idea/**/shelf 189 | 190 | # AWS User-specific 191 | .idea/**/aws.xml 192 | 193 | # Generated files 194 | .idea/**/contentModel.xml 195 | 196 | # Sensitive or high-churn files 197 | .idea/**/dataSources/ 198 | .idea/**/dataSources.ids 199 | .idea/**/dataSources.local.xml 200 | .idea/**/sqlDataSources.xml 201 | .idea/**/dynamic.xml 202 | .idea/**/uiDesigner.xml 203 | .idea/**/dbnavigator.xml 204 | 205 | # Gradle 206 | .idea/**/gradle.xml 207 | .idea/**/libraries 208 | 209 | # Gradle and Maven with auto-import 210 | # When using Gradle or Maven with auto-import, you should exclude module files, 211 | # since they will be recreated, and may cause churn. Uncomment if using 212 | # auto-import. 213 | # .idea/artifacts 214 | # .idea/compiler.xml 215 | # .idea/jarRepositories.xml 216 | # .idea/modules.xml 217 | # .idea/*.iml 218 | # .idea/modules 219 | # *.iml 220 | # *.ipr 221 | 222 | # CMake 223 | cmake-build-*/ 224 | 225 | # Mongo Explorer plugin 226 | .idea/**/mongoSettings.xml 227 | 228 | # File-based project format 229 | *.iws 230 | 231 | # IntelliJ 232 | out/ 233 | 234 | # mpeltonen/sbt-idea plugin 235 | .idea_modules/ 236 | 237 | # JIRA plugin 238 | atlassian-ide-plugin.xml 239 | 240 | # Cursive Clojure plugin 241 | .idea/replstate.xml 242 | 243 | # SonarLint plugin 244 | .idea/sonarlint/ 245 | 246 | # Crashlytics plugin (for Android Studio and IntelliJ) 247 | com_crashlytics_export_strings.xml 248 | crashlytics.properties 249 | crashlytics-build.properties 250 | fabric.properties 251 | 252 | # Editor-based Rest Client 253 | .idea/httpRequests 254 | 255 | # Android studio 3.1+ serialized cache file 256 | .idea/caches/build_file_checksums.ser 257 | 258 | ### Project ignores 259 | 260 | .idea/ 261 | .temp/ 262 | -------------------------------------------------------------------------------- /src/fw_fanctrl/CommandParser.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import os 3 | import sys 4 | import textwrap 5 | 6 | from fw_fanctrl import DEFAULT_CONFIGURATION_FILE_PATH 7 | from fw_fanctrl.enum.OutputFormat import OutputFormat 8 | from fw_fanctrl.exception.UnknownCommandException import UnknownCommandException 9 | 10 | 11 | class CommandParser: 12 | is_remote = True 13 | 14 | legacy_parser = None 15 | parser = None 16 | 17 | def __init__(self, is_remote=False): 18 | self.is_remote = is_remote 19 | self.init_parser() 20 | self.init_legacy_parser() 21 | 22 | def init_parser(self): 23 | self.parser = argparse.ArgumentParser( 24 | prog="fw-fanctrl", 25 | description="control Framework's laptop fan(s) with a speed curve", 26 | epilog=textwrap.dedent( 27 | "obtain more help about a command or subcommand using `fw-fanctrl [subcommand...] -h/--help`" 28 | ), 29 | formatter_class=argparse.RawTextHelpFormatter, 30 | ) 31 | self.parser.add_argument( 32 | "--socket-controller", 33 | "--sc", 34 | help="the socket controller to use for communication between the cli and the service", 35 | type=str, 36 | choices=["unix"], 37 | default="unix", 38 | ) 39 | self.parser.add_argument( 40 | "--output-format", 41 | help="the output format to use for the command result", 42 | type=lambda s: (lambda: OutputFormat[s])() if hasattr(OutputFormat, s) else s, 43 | choices=list(OutputFormat._member_names_), 44 | default=OutputFormat.NATURAL, 45 | ) 46 | 47 | commands_sub_parser = self.parser.add_subparsers(dest="command") 48 | commands_sub_parser.required = True 49 | 50 | if not self.is_remote: 51 | run_command = commands_sub_parser.add_parser( 52 | "run", 53 | description="run the service", 54 | formatter_class=argparse.RawTextHelpFormatter, 55 | ) 56 | run_command.add_argument( 57 | "strategy", 58 | help='name of the strategy to use e.g: "lazy" (use `print strategies` to list available strategies)', 59 | nargs=argparse.OPTIONAL, 60 | ) 61 | run_command.add_argument( 62 | "--config", 63 | "-c", 64 | help=f"the configuration file path (default: {DEFAULT_CONFIGURATION_FILE_PATH})", 65 | type=str, 66 | default=DEFAULT_CONFIGURATION_FILE_PATH, 67 | ) 68 | run_command.add_argument( 69 | "--silent", 70 | "-s", 71 | help="disable printing speed/temp status to stdout", 72 | action="store_true", 73 | ) 74 | run_command.add_argument( 75 | "--hardware-controller", 76 | "--hc", 77 | help="the hardware controller to use for fetching and setting the temp and fan(s) speed", 78 | type=str, 79 | choices=["ectool"], 80 | default="ectool", 81 | ) 82 | run_command.add_argument( 83 | "--no-battery-sensors", 84 | help="disable checking battery temperature sensors", 85 | action="store_true", 86 | ) 87 | 88 | use_command = commands_sub_parser.add_parser("use", description="change the current strategy") 89 | use_command.add_argument( 90 | "strategy", 91 | help='name of the strategy to use e.g: "lazy". (use `print list` to list available strategies)', 92 | ) 93 | 94 | commands_sub_parser.add_parser("reset", description="reset to the default strategy") 95 | commands_sub_parser.add_parser("reload", description="reload the configuration file") 96 | commands_sub_parser.add_parser("pause", description="pause the service") 97 | commands_sub_parser.add_parser("resume", description="resume the service") 98 | 99 | print_command = commands_sub_parser.add_parser( 100 | "print", 101 | description="print the selected information", 102 | formatter_class=argparse.RawTextHelpFormatter, 103 | ) 104 | print_command.add_argument( 105 | "print_selection", 106 | help=f"all - All details{os.linesep}current - The current strategy{os.linesep}list - List available strategies{os.linesep}speed - The current fan speed percentage{os.linesep}active - The service activity status", 107 | nargs="?", 108 | type=str, 109 | choices=["all", "active", "current", "list", "speed"], 110 | default="all", 111 | ) 112 | 113 | set_config_command = commands_sub_parser.add_parser( 114 | "set_config", description="replace the service configuration with the provided one" 115 | ) 116 | set_config_command.add_argument( 117 | "provided_config", 118 | help="must be a valid JSON configuration", 119 | type=str, 120 | ) 121 | 122 | def init_legacy_parser(self): 123 | self.legacy_parser = argparse.ArgumentParser(add_help=False) 124 | 125 | # avoid collision with the new parser commands 126 | def excluded_positional_arguments(value): 127 | if value in [ 128 | "run", 129 | "use", 130 | "reload", 131 | "reset", 132 | "pause", 133 | "resume", 134 | "print", 135 | "set_config", 136 | ]: 137 | raise argparse.ArgumentTypeError("%s is an excluded value" % value) 138 | return value 139 | 140 | both_group = self.legacy_parser.add_argument_group("both") 141 | both_group.add_argument("_strategy", nargs="?", type=excluded_positional_arguments) 142 | both_group.add_argument("--strategy", nargs="?") 143 | 144 | run_group = self.legacy_parser.add_argument_group("run") 145 | run_group.add_argument("--run", action="store_true") 146 | run_group.add_argument("--config", type=str, default=DEFAULT_CONFIGURATION_FILE_PATH) 147 | run_group.add_argument("--no-log", action="store_true") 148 | command_group = self.legacy_parser.add_argument_group("configure") 149 | command_group.add_argument("--query", "-q", action="store_true") 150 | command_group.add_argument("--list-strategies", action="store_true") 151 | command_group.add_argument("--reload", "-r", action="store_true") 152 | command_group.add_argument("--pause", action="store_true") 153 | command_group.add_argument("--resume", action="store_true") 154 | command_group.add_argument( 155 | "--hardware-controller", 156 | "--hc", 157 | type=str, 158 | choices=["ectool"], 159 | default="ectool", 160 | ) 161 | command_group.add_argument( 162 | "--socket-controller", 163 | "--sc", 164 | type=str, 165 | choices=["unix"], 166 | default="unix", 167 | ) 168 | 169 | def parse_args(self, args=None): 170 | original_stderr = sys.stderr 171 | # silencing legacy parser output 172 | sys.stderr = open(os.devnull, "w") 173 | try: 174 | legacy_values = self.legacy_parser.parse_args(args) 175 | if legacy_values.strategy is None: 176 | legacy_values.strategy = legacy_values._strategy 177 | # converting legacy values into new ones 178 | values = argparse.Namespace() 179 | values.socket_controller = legacy_values.socket_controller 180 | values.output_format = OutputFormat.NATURAL 181 | if legacy_values.query: 182 | values.command = "print" 183 | values.print_selection = "current" 184 | if legacy_values.list_strategies: 185 | values.command = "print" 186 | values.print_selection = "list" 187 | if legacy_values.resume: 188 | values.command = "resume" 189 | if legacy_values.pause: 190 | values.command = "pause" 191 | if legacy_values.reload: 192 | values.command = "reload" 193 | if legacy_values.run: 194 | values.command = "run" 195 | values.silent = legacy_values.no_log 196 | values.hardware_controller = legacy_values.hardware_controller 197 | values.config = legacy_values.config 198 | values.strategy = legacy_values.strategy 199 | if not hasattr(values, "command") and legacy_values.strategy is not None: 200 | values.command = "use" 201 | values.strategy = legacy_values.strategy 202 | if not hasattr(values, "command"): 203 | raise UnknownCommandException("not a valid legacy command") 204 | if self.is_remote or values.command == "run": 205 | # Legacy commands do not support other formats than NATURAL, so there is no need to use a CommandResult. 206 | print( 207 | "[Warning] > this command is deprecated and will be removed soon, please use the new command format instead ('fw-fanctrl -h' for more details)." 208 | ) 209 | except (SystemExit, Exception): 210 | sys.stderr = original_stderr 211 | values = self.parser.parse_args(args) 212 | finally: 213 | sys.stderr = original_stderr 214 | return values 215 | -------------------------------------------------------------------------------- /src/fw_fanctrl/FanController.py: -------------------------------------------------------------------------------- 1 | import collections 2 | import sys 3 | import threading 4 | from time import sleep 5 | 6 | from fw_fanctrl.Configuration import Configuration 7 | from fw_fanctrl.dto.command_result.ConfigurationReloadCommandResult import ConfigurationReloadCommandResult 8 | from fw_fanctrl.dto.command_result.PrintActiveCommandResult import PrintActiveCommandResult 9 | from fw_fanctrl.dto.command_result.PrintCurrentStrategyCommandResult import PrintCurrentStrategyCommandResult 10 | from fw_fanctrl.dto.command_result.PrintFanSpeedCommandResult import PrintFanSpeedCommandResult 11 | from fw_fanctrl.dto.command_result.PrintStrategyListCommandResult import PrintStrategyListCommandResult 12 | from fw_fanctrl.dto.command_result.ServicePauseCommandResult import ServicePauseCommandResult 13 | from fw_fanctrl.dto.command_result.ServiceResumeCommandResult import ServiceResumeCommandResult 14 | from fw_fanctrl.dto.command_result.SetConfigurationCommandResult import SetConfigurationCommandResult 15 | from fw_fanctrl.dto.command_result.StrategyChangeCommandResult import StrategyChangeCommandResult 16 | from fw_fanctrl.dto.command_result.StrategyResetCommandResult import StrategyResetCommandResult 17 | from fw_fanctrl.dto.runtime_result.RuntimeResult import RuntimeResult 18 | from fw_fanctrl.dto.runtime_result.StatusRuntimeResult import StatusRuntimeResult 19 | from fw_fanctrl.enum.CommandStatus import CommandStatus 20 | from fw_fanctrl.exception.InvalidStrategyException import InvalidStrategyException 21 | from fw_fanctrl.exception.UnknownCommandException import UnknownCommandException 22 | 23 | 24 | class FanController: 25 | hardware_controller = None 26 | socket_controller = None 27 | configuration = None 28 | overwritten_strategy = None 29 | output_format = None 30 | speed = 0 31 | temp_history = collections.deque([0] * 100, maxlen=100) 32 | active = True 33 | timecount = 0 34 | 35 | def __init__(self, hardware_controller, socket_controller, config_path, strategy_name, output_format): 36 | self.hardware_controller = hardware_controller 37 | self.socket_controller = socket_controller 38 | self.configuration = Configuration(config_path) 39 | 40 | if strategy_name is not None and strategy_name != "": 41 | self.overwrite_strategy(strategy_name) 42 | 43 | self.output_format = output_format 44 | 45 | t = threading.Thread( 46 | target=self.socket_controller.start_server_socket, 47 | args=[self.command_manager], 48 | ) 49 | t.daemon = True 50 | t.start() 51 | 52 | def get_actual_temperature(self): 53 | return self.hardware_controller.get_temperature() 54 | 55 | def set_speed(self, speed): 56 | self.speed = speed 57 | self.hardware_controller.set_speed(speed) 58 | 59 | def is_on_ac(self): 60 | return self.hardware_controller.is_on_ac() 61 | 62 | def pause(self): 63 | self.active = False 64 | self.hardware_controller.pause() 65 | 66 | def resume(self): 67 | self.active = True 68 | self.hardware_controller.resume() 69 | 70 | def overwrite_strategy(self, strategy_name): 71 | if strategy_name not in self.configuration.get_strategies(): 72 | self.clear_overwritten_strategy() 73 | return 74 | self.overwritten_strategy = self.configuration.get_strategy(strategy_name) 75 | self.timecount = 0 76 | 77 | def clear_overwritten_strategy(self): 78 | self.overwritten_strategy = None 79 | self.timecount = 0 80 | 81 | def get_current_strategy(self): 82 | if self.overwritten_strategy is not None: 83 | return self.overwritten_strategy 84 | if self.is_on_ac(): 85 | return self.configuration.get_default_strategy() 86 | return self.configuration.get_discharging_strategy() 87 | 88 | def command_manager(self, args): 89 | if args.command == "reset" or (args.command == "use" and args.strategy == "defaultStrategy"): 90 | self.clear_overwritten_strategy() 91 | return StrategyResetCommandResult(self.get_current_strategy().name, self.overwritten_strategy is None) 92 | elif args.command == "use": 93 | if args.strategy not in self.configuration.get_strategies(): 94 | raise InvalidStrategyException(f"The specified strategy is invalid: {args.strategy}") 95 | self.overwrite_strategy(args.strategy) 96 | return StrategyChangeCommandResult(self.get_current_strategy().name, self.overwritten_strategy is None) 97 | elif args.command == "reload": 98 | self.configuration.reload() 99 | if self.overwritten_strategy is not None: 100 | self.overwrite_strategy(self.overwritten_strategy.name) 101 | return ConfigurationReloadCommandResult(self.get_current_strategy().name, self.overwritten_strategy is None) 102 | elif args.command == "pause": 103 | self.pause() 104 | return ServicePauseCommandResult() 105 | elif args.command == "resume": 106 | self.resume() 107 | return ServiceResumeCommandResult(self.get_current_strategy().name, self.overwritten_strategy is None) 108 | elif args.command == "print": 109 | if args.print_selection == "all": 110 | return self.dump_details() 111 | elif args.print_selection == "active": 112 | return PrintActiveCommandResult(self.active) 113 | elif args.print_selection == "current": 114 | return PrintCurrentStrategyCommandResult( 115 | self.get_current_strategy().name, self.overwritten_strategy is None 116 | ) 117 | elif args.print_selection == "list": 118 | return PrintStrategyListCommandResult(list(self.configuration.get_strategies())) 119 | elif args.print_selection == "speed": 120 | return PrintFanSpeedCommandResult(str(self.speed)) 121 | elif args.command == "set_config": 122 | self.configuration.data = self.configuration.parse(args.provided_config) 123 | if self.overwritten_strategy is not None: 124 | self.overwrite_strategy(self.overwritten_strategy.name) 125 | self.configuration.save() 126 | return SetConfigurationCommandResult( 127 | self.get_current_strategy().name, vars(self.configuration), self.overwritten_strategy is None 128 | ) 129 | raise UnknownCommandException(f"Unknown command: '{args.command}', unexpected.") 130 | 131 | # return mean temperature over a given time interval (in seconds) 132 | def get_moving_average_temperature(self, time_interval): 133 | sliced_temp_history = [x for x in self.temp_history if x > 0][-time_interval:] 134 | if len(sliced_temp_history) == 0: 135 | return self.get_actual_temperature() 136 | return float(round(sum(sliced_temp_history) / len(sliced_temp_history), 2)) 137 | 138 | def get_effective_temperature(self, current_temp, time_interval): 139 | # the moving average temperature count for 2/3 of the effective temperature 140 | return float(round(min(self.get_moving_average_temperature(time_interval), current_temp), 2)) 141 | 142 | def adapt_speed(self, current_temp): 143 | current_strategy = self.get_current_strategy() 144 | current_temp = self.get_effective_temperature(current_temp, current_strategy.moving_average_interval) 145 | min_point = current_strategy.speed_curve[0] 146 | max_point = current_strategy.speed_curve[-1] 147 | for e in current_strategy.speed_curve: 148 | if current_temp > e["temp"]: 149 | min_point = e 150 | else: 151 | max_point = e 152 | break 153 | 154 | if min_point == max_point: 155 | new_speed = min_point["speed"] 156 | else: 157 | slope = (max_point["speed"] - min_point["speed"]) / (max_point["temp"] - min_point["temp"]) 158 | new_speed = int(min_point["speed"] + (current_temp - min_point["temp"]) * slope) 159 | if self.active: 160 | self.set_speed(new_speed) 161 | 162 | def dump_details(self): 163 | current_strategy = self.get_current_strategy() 164 | current_temperature = self.get_actual_temperature() 165 | moving_average_temp = self.get_moving_average_temperature(current_strategy.moving_average_interval) 166 | effective_temp = self.get_effective_temperature(current_temperature, current_strategy.moving_average_interval) 167 | 168 | return StatusRuntimeResult( 169 | current_strategy.name, 170 | self.overwritten_strategy is None, 171 | self.speed, 172 | current_temperature, 173 | moving_average_temp, 174 | effective_temp, 175 | self.active, 176 | vars(self.configuration), 177 | ) 178 | 179 | def print_state(self): 180 | print(self.dump_details().to_output_format(self.output_format)) 181 | 182 | def run(self, debug=True): 183 | try: 184 | while True: 185 | if self.active: 186 | temp = self.get_actual_temperature() 187 | # update fan speed every "fanSpeedUpdateFrequency" seconds 188 | if self.timecount % self.get_current_strategy().fan_speed_update_frequency == 0: 189 | self.adapt_speed(temp) 190 | self.timecount = 0 191 | 192 | self.temp_history.append(temp) 193 | 194 | if debug: 195 | self.print_state() 196 | self.timecount += 1 197 | sleep(1) 198 | else: 199 | sleep(5) 200 | except InvalidStrategyException as e: 201 | _rte = RuntimeResult(CommandStatus.ERROR, f"Missing strategy, exiting for safety reasons: {e.args[0]}") 202 | print(_rte.to_output_format(self.output_format), file=sys.stderr) 203 | except Exception as e: 204 | _rte = RuntimeResult(CommandStatus.ERROR, f"Critical error, exiting for safety reasons: {e}") 205 | print(_rte.to_output_format(self.output_format), file=sys.stderr) 206 | exit(1) 207 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # Argument parsing 5 | SHORT=r,d:,p:,s:,h 6 | LONG=remove,dest-dir:,prefix-dir:,sysconf-dir:,no-ectool,no-pre-uninstall,no-post-install,no-battery-sensors,no-sudo,no-pip-install,pipx,python-prefix-dir:,effective-installation-dir:,help 7 | VALID_ARGS=$(getopt -a --options $SHORT --longoptions $LONG -- "$@") 8 | if [[ $? -ne 0 ]]; then 9 | exit 1; 10 | fi 11 | 12 | TEMP_FOLDER='./.temp' 13 | trap 'rm -rf $TEMP_FOLDER' EXIT 14 | 15 | PREFIX_DIR="/usr" 16 | DEST_DIR="" 17 | SYSCONF_DIR="/etc" 18 | SHOULD_INSTALL_ECTOOL=true 19 | SHOULD_PRE_UNINSTALL=true 20 | SHOULD_POST_INSTALL=true 21 | SHOULD_REMOVE=false 22 | NO_BATTERY_SENSOR=false 23 | NO_SUDO=false 24 | NO_PIP_INSTALL=false 25 | PIPX=false 26 | PYTHON_PREFIX_DIRECTORY_OVERRIDE= 27 | EFFECTIVE_INSTALLATION_DIRECTORY_OVERRIDE= 28 | 29 | eval set -- "$VALID_ARGS" 30 | while true; do 31 | case "$1" in 32 | '--remove' | '-r') 33 | SHOULD_REMOVE=true 34 | ;; 35 | '--prefix-dir' | '-p') 36 | PREFIX_DIR=$2 37 | shift 38 | ;; 39 | '--dest-dir' | '-d') 40 | DEST_DIR=$2 41 | shift 42 | ;; 43 | '--sysconf-dir' | '-s') 44 | SYSCONF_DIR=$2 45 | shift 46 | ;; 47 | '--no-ectool') 48 | SHOULD_INSTALL_ECTOOL=false 49 | ;; 50 | '--no-pre-uninstall') 51 | SHOULD_PRE_UNINSTALL=false 52 | ;; 53 | '--no-post-install') 54 | SHOULD_POST_INSTALL=false 55 | ;; 56 | '--no-battery-sensors') 57 | NO_BATTERY_SENSOR=true 58 | ;; 59 | '--no-sudo') 60 | NO_SUDO=true 61 | ;; 62 | '--no-pip-install') 63 | NO_PIP_INSTALL=true 64 | ;; 65 | '--pipx') 66 | PIPX=true 67 | ;; 68 | '--python-prefix-dir') 69 | PYTHON_PREFIX_DIRECTORY_OVERRIDE=$2 70 | shift 71 | ;; 72 | '--effective-installation-dir') 73 | EFFECTIVE_INSTALLATION_DIRECTORY_OVERRIDE=$2 74 | shift 75 | ;; 76 | '--help' | '-h') 77 | echo "Usage: $0 [--remove,-r] [--dest-dir,-d ] [--prefix-dir,-p ] [--sysconf-dir,-s system configuration destination directory (defaults to $SYSCONF_DIR)] [--no-ectool] [--no-post-install] [--no-pre-uninstall] [--no-sudo] [--no-pip-install] [--pipx] [--python-prefix-dir (defaults to $DEST_DIR$PREFIX_DIR)]" 1>&2 78 | exit 0 79 | ;; 80 | --) 81 | break 82 | ;; 83 | esac 84 | shift 85 | done 86 | 87 | PYTHON_PREFIX_DIRECTORY="$DEST_DIR$PREFIX_DIR" 88 | if [ -n "$PYTHON_PREFIX_DIRECTORY_OVERRIDE" ]; then 89 | PYTHON_PREFIX_DIRECTORY=$PYTHON_PREFIX_DIRECTORY_OVERRIDE 90 | fi 91 | 92 | INSTALLATION_DIRECTORY="$PYTHON_PREFIX_DIRECTORY/bin" 93 | if [ -n "$EFFECTIVE_INSTALLATION_DIRECTORY_OVERRIDE" ]; then 94 | INSTALLATION_DIRECTORY=$EFFECTIVE_INSTALLATION_DIRECTORY_OVERRIDE 95 | fi 96 | 97 | PYTHON_SCRIPT_INSTALLATION_PATH="$INSTALLATION_DIRECTORY/fw-fanctrl" 98 | 99 | if ! python3 -h 1>/dev/null 2>&1; then 100 | echo "Missing package 'python3'!" 101 | exit 1 102 | fi 103 | 104 | if [ "$NO_PIP_INSTALL" = false ]; then 105 | if ! python3 -m pip -h 1>/dev/null 2>&1; then 106 | echo "Missing python package 'pip'!" 107 | exit 1 108 | fi 109 | fi 110 | 111 | if [ "$PIPX" = true ]; then 112 | if ! pipx -h >/dev/null 2>&1; then 113 | echo "Missing package 'pipx'!" 114 | exit 1 115 | fi 116 | fi 117 | 118 | if [ "$SHOULD_REMOVE" = false ]; then 119 | if ! python3 -m build -h 1>/dev/null 2>&1; then 120 | echo "Missing python package 'build'!" 121 | exit 1 122 | fi 123 | fi 124 | 125 | # Root check 126 | if [ "$EUID" -ne 0 ] && [ "$NO_SUDO" = false ] 127 | then echo "This program requires root permissions or use the '--no-sudo' option" 128 | exit 1 129 | fi 130 | 131 | SERVICES_DIR="./services" 132 | SERVICE_EXTENSION=".service" 133 | 134 | SERVICES="$(cd "$SERVICES_DIR" && find . -maxdepth 1 -maxdepth 1 -type f -name "*$SERVICE_EXTENSION" -exec basename {} "$SERVICE_EXTENSION" \;)" 135 | SERVICES_SUBCONFIGS="$(cd "$SERVICES_DIR" && find . -mindepth 1 -maxdepth 1 -type d -exec basename {} \;)" 136 | 137 | function sanitizePath() { 138 | local SANITIZED_PATH="$1" 139 | local SANITIZED_PATH=${SANITIZED_PATH//..\//} 140 | local SANITIZED_PATH=${SANITIZED_PATH#./} 141 | local SANITIZED_PATH=${SANITIZED_PATH#/} 142 | echo "$SANITIZED_PATH" 143 | } 144 | 145 | function build() { 146 | echo "building package" 147 | remove_target "dist/" 148 | python3 -m build -s 149 | find . -type d -name "*.egg-info" -exec rm -rf {} + 2> "/dev/null" || true 150 | } 151 | 152 | # safe remove function 153 | function remove_target() { 154 | local target="$1" 155 | if [ -e "$target" ] || [ -L "$target" ]; then 156 | if ! rm -rf "$target" 2> "/dev/null"; then 157 | echo "Failed to remove: $target" 158 | echo "Please run:" 159 | echo " sudo ./install.sh --remove" 160 | exit 1 161 | fi 162 | fi 163 | } 164 | 165 | # remove remaining legacy files 166 | function uninstall_legacy() { 167 | echo "removing legacy files" 168 | remove_target "/usr/local/bin/fw-fanctrl" 169 | remove_target "/usr/local/bin/ectool" 170 | remove_target "/usr/local/bin/fanctrl.py" 171 | remove_target "/etc/systemd/system/fw-fanctrl.service" 172 | remove_target "$DEST_DIR$PREFIX_DIR/bin/fw-fanctrl" 173 | } 174 | 175 | function uninstall() { 176 | if [ "$SHOULD_PRE_UNINSTALL" = true ]; then 177 | if ! ./pre-uninstall.sh "$([ "$NO_SUDO" = true ] && echo "--no-sudo")"; then 178 | echo "Failed to run ./pre-uninstall.sh. Run the script with root permissions," 179 | echo "or skip this step by using the --no-pre-uninstall option." 180 | exit 1 181 | fi 182 | fi 183 | # remove program services based on the services present in the './services' folder 184 | echo "removing services" 185 | for SERVICE in $SERVICES ; do 186 | SERVICE=$(sanitizePath "$SERVICE") 187 | # be EXTRA CAREFUL about the validity of the paths (dont wanna delete something important, right?... O_O) 188 | remove_target "$DEST_DIR$PREFIX_DIR/lib/systemd/system/$SERVICE$SERVICE_EXTENSION" 189 | done 190 | 191 | # remove program services sub-configurations based on the sub-configurations present in the './services' folder 192 | echo "removing services sub-configurations" 193 | for SERVICE in $SERVICES_SUBCONFIGS ; do 194 | SERVICE=$(sanitizePath "$SERVICE") 195 | echo "removing sub-configurations for [$SERVICE]" 196 | SUBCONFIGS="$(cd "$SERVICES_DIR/$SERVICE" && find . -mindepth 1 -type f)" 197 | for SUBCONFIG in $SUBCONFIGS ; do 198 | SUBCONFIG=$(sanitizePath "$SUBCONFIG") 199 | echo "removing '$DEST_DIR$PREFIX_DIR/lib/systemd/$SERVICE/$SUBCONFIG'" 200 | remove_target "$DEST_DIR$PREFIX_DIR/lib/systemd/$SERVICE/$SUBCONFIG" 201 | done 202 | done 203 | 204 | if [ "$NO_PIP_INSTALL" = false ]; then 205 | echo "uninstalling python package" 206 | if [ "$PIPX" = false ]; then 207 | python3 -m pip uninstall -y fw-fanctrl 2> "/dev/null" || true 208 | else 209 | pipx --global uinistall fw-fanctrl 2> "/dev/null" || true 210 | fi 211 | fi 212 | 213 | ectool autofanctrl 2> "/dev/null" || true # restore default fan manager 214 | if [ "$SHOULD_INSTALL_ECTOOL" = true ]; then 215 | remove_target "$DEST_DIR$PREFIX_DIR/bin/ectool" 216 | fi 217 | remove_target "$DEST_DIR$SYSCONF_DIR/fw-fanctrl" 218 | remove_target "/run/fw-fanctrl" 219 | 220 | uninstall_legacy 221 | } 222 | 223 | function install() { 224 | uninstall_legacy 225 | 226 | remove_target "$TEMP_FOLDER" 227 | mkdir -p "$DEST_DIR$PREFIX_DIR/bin" 228 | if [ "$SHOULD_INSTALL_ECTOOL" = true ]; then 229 | mkdir "$TEMP_FOLDER" 230 | installEctool "$TEMP_FOLDER" || (echo "an error occurred when installing ectool." && echo "please check your internet connection or consider installing it manually and using --no-ectool on the installation script." && exit 1) 231 | remove_target "$TEMP_FOLDER" 232 | fi 233 | mkdir -p "$DEST_DIR$SYSCONF_DIR/fw-fanctrl" 234 | 235 | build 236 | 237 | if [ "$NO_PIP_INSTALL" = false ]; then 238 | echo "installing python package" 239 | if [ "$PIPX" = false ]; then 240 | python3 -m pip install --prefix="$PYTHON_PREFIX_DIRECTORY" dist/*.tar.gz 241 | which python3 242 | else 243 | pipx install --global --force dist/*.tar.gz 244 | fi 245 | which 'fw-fanctrl' 2> "/dev/null" || true 246 | remove_target "dist/" 247 | fi 248 | 249 | cp -pn "./src/fw_fanctrl/_resources/config.json" "$DEST_DIR$SYSCONF_DIR/fw-fanctrl" 2> "/dev/null" || true 250 | cp -f "./src/fw_fanctrl/_resources/config.schema.json" "$DEST_DIR$SYSCONF_DIR/fw-fanctrl" 2> "/dev/null" || true 251 | 252 | # add --no-battery-sensors flag to the fanctrl service if specified 253 | if [ "$NO_BATTERY_SENSOR" = true ]; then 254 | NO_BATTERY_SENSOR_OPTION="--no-battery-sensors" 255 | fi 256 | 257 | # create program services based on the services present in the './services' folder 258 | echo "creating '$DEST_DIR$PREFIX_DIR/lib/systemd/system'" 259 | mkdir -p "$DEST_DIR$PREFIX_DIR/lib/systemd/system" 260 | echo "creating services" 261 | for SERVICE in $SERVICES ; do 262 | SERVICE=$(sanitizePath "$SERVICE") 263 | if [ "$SHOULD_PRE_UNINSTALL" = true ] && [ "$(systemctl is-active "$SERVICE")" == "active" ]; then 264 | echo "stopping [$SERVICE]" 265 | systemctl stop "$SERVICE" 266 | fi 267 | echo "creating '$DEST_DIR$PREFIX_DIR/lib/systemd/system/$SERVICE$SERVICE_EXTENSION'" 268 | cat "$SERVICES_DIR/$SERVICE$SERVICE_EXTENSION" | sed -e "s/%PYTHON_SCRIPT_INSTALLATION_PATH%/${PYTHON_SCRIPT_INSTALLATION_PATH//\//\\/}/" | 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" 269 | done 270 | 271 | # add program services sub-configurations based on the sub-configurations present in the './services' folder 272 | echo "adding services sub-configurations" 273 | for SERVICE in $SERVICES_SUBCONFIGS ; do 274 | SERVICE=$(sanitizePath "$SERVICE") 275 | echo "adding sub-configurations for [$SERVICE]" 276 | SUBCONFIG_FOLDERS="$(cd "$SERVICES_DIR/$SERVICE" && find . -mindepth 1 -maxdepth 1 -type d -exec basename {} \;)" 277 | # ensure folders exists 278 | mkdir -p "$DEST_DIR$PREFIX_DIR/lib/systemd/$SERVICE" 279 | for SUBCONFIG_FOLDER in $SUBCONFIG_FOLDERS ; do 280 | SUBCONFIG_FOLDER=$(sanitizePath "$SUBCONFIG_FOLDER") 281 | echo "creating '$DEST_DIR$PREFIX_DIR/lib/systemd/$SERVICE/$SUBCONFIG_FOLDER'" 282 | mkdir -p "$DEST_DIR$PREFIX_DIR/lib/systemd/$SERVICE/$SUBCONFIG_FOLDER" 283 | done 284 | SUBCONFIGS="$(cd "$SERVICES_DIR/$SERVICE" && find . -mindepth 1 -type f)" 285 | # add sub-configurations 286 | for SUBCONFIG in $SUBCONFIGS ; do 287 | SUBCONFIG=$(sanitizePath "$SUBCONFIG") 288 | echo "adding '$DEST_DIR$PREFIX_DIR/lib/systemd/$SERVICE/$SUBCONFIG'" 289 | cat "$SERVICES_DIR/$SERVICE/$SUBCONFIG" | sed -e "s/%PYTHON_SCRIPT_INSTALLATION_PATH%/${PYTHON_SCRIPT_INSTALLATION_PATH//\//\\/}/" | tee "$DEST_DIR$PREFIX_DIR/lib/systemd/$SERVICE/$SUBCONFIG" > "/dev/null" 290 | chmod +x "$DEST_DIR$PREFIX_DIR/lib/systemd/$SERVICE/$SUBCONFIG" 291 | done 292 | done 293 | if [ "$SHOULD_POST_INSTALL" = true ]; then 294 | if ! ./post-install.sh --dest-dir "$DEST_DIR" --sysconf-dir "$SYSCONF_DIR" "$([ "$NO_SUDO" = true ] && echo "--no-sudo")"; then 295 | echo "Failed to run ./post-install.sh. Run the script with root permissions," 296 | echo "or skip this step by using the --no-post-install option." 297 | exit 1 298 | fi 299 | fi 300 | } 301 | 302 | function installEctool() { 303 | workingDirectory=$1 304 | echo "installing ectool" 305 | 306 | ectoolDestPath="$DEST_DIR$PREFIX_DIR/bin/ectool" 307 | 308 | ectoolJobId="$(cat './fetch/ectool/linux/gitlab_job_id')" 309 | ectoolSha256Hash="$(cat './fetch/ectool/linux/hash.sha256')" 310 | 311 | artifactsZipFile="$workingDirectory/artifact.zip" 312 | 313 | echo "downloading artifact from gitlab" 314 | curl -s -S -o "$artifactsZipFile" -L "https://gitlab.howett.net/DHowett/ectool/-/jobs/${ectoolJobId}/artifacts/download?file_type=archive" || (echo "failed to download the artifact." && return 1) 315 | if [[ $? -ne 0 ]]; then return 1; fi 316 | 317 | echo "checking artifact sha256 sum" 318 | actualEctoolSha256Hash=$(sha256sum "$artifactsZipFile" | cut -d ' ' -f 1) 319 | if [[ "$actualEctoolSha256Hash" != "$ectoolSha256Hash" ]]; then 320 | echo "Incorrect sha256 sum for ectool gitlab artifact '$ectoolJobId' : '$ectoolSha256Hash' != '$actualEctoolSha256Hash'" 321 | return 1 322 | fi 323 | 324 | echo "extracting artifact" 325 | { 326 | unzip -q -j "$artifactsZipFile" '_build/src/ectool' -d "$workingDirectory" && 327 | cp "$workingDirectory/ectool" "$ectoolDestPath" && 328 | chmod +x "$ectoolDestPath" 329 | } || (echo "failed to extract the artifact to its designated location." && return 1) 330 | if [[ $? -ne 0 ]]; then return 1; fi 331 | 332 | echo "ectool installed" 333 | } 334 | 335 | if [ "$SHOULD_REMOVE" = true ]; then 336 | uninstall 337 | else 338 | install 339 | fi 340 | exit 0 341 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fw-fanctrl 2 | 3 | [![Static Badge](https://img.shields.io/badge/Linux%E2%80%AF%2F%E2%80%AFGlobal-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) 4 | ![Static Badge](https://img.shields.io/badge/no%20binary%20blobs-30363D?style=flat&logo=GitHub-Sponsors&logoColor=4dff61) 5 | 6 | [![Static Badge](https://img.shields.io/badge/Python%203.12-FFDE57?style=flat&label=Requirement&link=https%3A%2F%2Fwww.python.org%2Fdownloads)](https://www.python.org/downloads) 7 | 8 | ## Platforms 9 | 10 | [![Static Badge](https://img.shields.io/badge/Linux%E2%80%AF%2F%E2%80%AFGlobal-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) 11 | [![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) 12 | 13 | **Third-party**
14 | 15 | [![Static Badge](https://img.shields.io/badge/Arch%20Linux-1793D1?style=flat&logo=archlinux&logoColor=FFFFFF&label=Platform&link=https%3A%2F%2Faur.archlinux.org%2Fpackages%2Ffw-fanctrl-git)](https://aur.archlinux.org/packages/fw-fanctrl-git) 16 | [![Static Badge](https://img.shields.io/badge/Fedora-51A2DA?style=flat&logo=fedora&logoColor=FFFFFF&label=Platform&link=https%3A%2F%2Fgithub.com%2Ftulilirockz%2Ffw-fanctrl-rpm)](https://github.com/tulilirockz/fw-fanctrl-rpm) 17 | 18 | _You are a package manager? Add your platform here!_ 19 | 20 | ## Description 21 | 22 | Fw-fanctrl is a simple Python CLI service that controls Framework Laptop's fan(s) 23 | speed according to a configurable speed/temperature curve. 24 | 25 | Its default strategy aims for very quiet fan operation, but you can choose amongst the other provided strategies, or 26 | easily configure your own for a different comfort/performance trade-off. 27 | 28 | It also is possible to assign separate strategies depending on whether the laptop is charging or discharging. 29 | 30 | Under the hood, it uses [ectool](https://gitlab.howett.net/DHowett/ectool) 31 | to change parameters in Framework's embedded controller (EC). 32 | 33 | It is compatible with all 13" and 16" models, both AMD/Intel CPUs, with or without a discrete GPU. 34 | 35 | If the service is paused or stopped, the fans will revert to their default behaviour. 36 | 37 | ## Table of Content 38 | 39 | 40 | * [fw-fanctrl](#fw-fanctrl) 41 | * [Platforms](#platforms) 42 | * [Description](#description) 43 | * [Table of Content](#table-of-content) 44 | * [Third-party projects](#third-party-projects) 45 | * [Documentation](#documentation) 46 | * [Installation](#installation) 47 | * [Platforms](#platforms-1) 48 | * [Requirements](#requirements) 49 | * [Dependencies](#dependencies) 50 | * [Instructions](#instructions) 51 | * [Update](#update) 52 | * [Uninstall](#uninstall) 53 | * [Development Setup](#development-setup) 54 | 55 | 56 | ## Third-party projects 57 | 58 | _Have some cool project to show? Add yours to the list!_ 59 | 60 | | Name | Description | Picture | 61 | |-------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| 62 | | [fw‑fanctrl‑gui](https://github.com/leopoldhub/fw-fanctrl-gui) | Simple customtkinter python gui with system tray for fw‑fanctrl | [](https://github.com/leopoldhub/fw-fanctrl-gui) | 63 | | [fw-fanctrl-revived-gnome-shell-extension](https://github.com/ghostdevv/fw-fanctrl-revived-gnome-shell-extension) | A Gnome extension that provides a convenient way to control your framework laptop fan profile when using fw-fanctrl | [](https://github.com/ghostdevv/fw-fanctrl-revived-gnome-shell-extension) | 64 | | [fw_fanctrl_applet](https://github.com/not-a-feature/fw_fanctrl_applet) | Cinnamon applet to control the framework fan-speed strategy using fw-fanctrl | [](https://github.com/not-a-feature/fw_fanctrl_applet) | 65 | | [ulauncher-fw-fanctrl](https://github.com/ghostdevv/ulauncher-fw-fanctrl) | A fw-fanctrl extension for the app launcher [ulauncher](https://ulauncher.io) | [](https://github.com/ghostdevv/ulauncher-fw-fanctrl) | 66 | 67 | ## Documentation 68 | 69 | More documentation could be found [here](./doc/README.md). 70 | 71 | ## Installation 72 | 73 | ### Platforms 74 | 75 | | Name                   | Package                   | Branch                   | Documentation | 76 | |------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------| 77 | | Linux / Global | [installation script](https://github.com/TamtamHero/fw-fanctrl/blob/main/install.sh) | [main](https://github.com/TamtamHero/fw-fanctrl/tree/main) | [instructions](https://github.com/TamtamHero/fw-fanctrl/tree/main?tab=readme-ov-file#instructions) | 78 | | NixOS | [flake](https://github.com/TamtamHero/fw-fanctrl/blob/packaging/nix/flake.nix) | [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) | 79 | 80 | **Third-party** 81 | 82 | | Name                   | Package                   | Documentation | 83 | |------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------| 84 | | Arch Linux | [AUR](https://aur.archlinux.org/packages/fw-fanctrl-git) | | 85 | | Fedora / RPM | [COPR](https://copr.fedorainfracloud.org/coprs/tulilirockz/fw-fanctrl/package/fw-fanctrl/) | [GIT repository](https://github.com/tulilirockz/fw-fanctrl-rpm) | 86 | 87 | ### Requirements 88 | 89 | | Name                   | Version                    | Url | 90 | |------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------| 91 | | Linux kernel | \>= 6.11.x | | 92 | | Python | \>= 3.12.x | [https://www.python.org/downloads](https://www.python.org/downloads) | 93 | 94 | ### Dependencies 95 | 96 | Dependencies are downloaded and installed automatically, but can be excluded from the installation script if you wish to 97 | do this manually. 98 | 99 | | Name                   | Version                    | Url                    | Sub‑dependencies | Exclusion argument | 100 | |------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------|------------------------|-------------------------| 101 | | DHowett@ectool | build#899 | [https://gitlab.howett.net/DHowett/ectool](https://gitlab.howett.net/DHowett/ectool) | libftdi | `--no-ectool` | 102 | 103 | ### Instructions 104 | 105 | [Download the repo](https://github.com/TamtamHero/fw-fanctrl/archive/refs/heads/main.zip) and extract it manually, or 106 | download/clone it with the appropriate tools: 107 | 108 | ```shell 109 | git clone "https://github.com/TamtamHero/fw-fanctrl.git" 110 | ``` 111 | 112 | ```shell 113 | 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" 114 | ``` 115 | 116 | Then run the installation script with administrator privileges 117 | 118 | > ⚠ **Linux Mint** users should add the `--effective-installation-dir "/usr/local/bin"` option. 119 | > 120 | > ⚠ **Fedora Atomic desktops** users should add the `--prefix-dir "/var/usrlocal/"` option. 121 | 122 | ```bash 123 | sudo ./install.sh 124 | ``` 125 | 126 | You can add a number of arguments to the installation command to suit your needs 127 | 128 | | argument | description | 129 | |---------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------| 130 | | `--dest-dir ` | specify an installation destination directory | 131 | | `--prefix-dir ` | specify an installation prefix directory | 132 | | `--sysconf-dir ` | specify a default configuration directory | 133 | | `--no-ectool` | disable ectool installation and service activation | 134 | | `--no-post-install` | disable post-install process | 135 | | `--no-pre-uninstall` | disable pre-uninstall process | 136 | | `--no-battery-sensors` | disable checking battery temperature sensors | 137 | | `--no-pip-install` | disable the pip installation (should be done manually instead) | 138 | | `--pipx` | install using pipx instead of pip (useful if os does not allow global pip install like debian ) | 139 | | `--python-prefix-dir ` | specify the python prefix directory for package installation | 140 | | `--effective-installation-dir ` | overrides the installation in which our `fw-fanctrl` executable is | 141 | 142 | ## Update 143 | 144 | To update, you can download or pull the appropriate branch from this repository, and run the installation script again. 145 | 146 | ## Uninstall 147 | 148 | To uninstall, run the installation script with the `--remove` argument, as well as other 149 | corresponding [arguments if necessary](#instructions) 150 | 151 | ```bash 152 | sudo ./install.sh --remove 153 | ``` 154 | 155 | ## Development Setup 156 | 157 | > It is recommended to use a virtual environment to install development dependencies 158 | 159 | Install the development dependencies with the following command: 160 | 161 | ```shell 162 | pip install -e ".[dev]" 163 | ``` 164 | 165 | The project uses the [black](https://github.com/psf/black) formatter. 166 | 167 | Please format your contributions before commiting them. 168 | 169 | ```shell 170 | python -m black . 171 | ``` 172 | --------------------------------------------------------------------------------