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