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