From d6e54f2fa1b33f358717770ab2a34053f5e5e8f1 Mon Sep 17 00:00:00 2001 From: Sven Ziegler <43136984+Svenum@users.noreply.github.com> Date: Sat, 1 Feb 2025 20:14:11 +0100 Subject: [PATCH] Update packaging to the pip install method (#99) > merging from main --- .editorconfig | 10 + .gitignore | 418 +++++------ README.md | 63 +- doc/README.md | 1 + doc/commands.md | 14 +- doc/configuration.md | 15 +- fanctrl.py | 664 ------------------ flake.lock | 14 +- flake.nix | 2 +- install.sh | 34 +- nix/module.nix | 2 +- nix/packages/fw-fanctrl.nix | 43 +- post-install.sh | 2 +- pre-uninstall.sh | 4 +- pyproject.toml | 66 ++ services/fw-fanctrl.service | 2 +- src/fw_fanctrl/CommandParser.py | 204 ++++++ src/fw_fanctrl/Configuration.py | 41 ++ src/fw_fanctrl/FanController.py | 189 +++++ src/fw_fanctrl/Strategy.py | 15 + src/fw_fanctrl/__init__.py | 5 + src/fw_fanctrl/__main__.py | 52 ++ src/fw_fanctrl/dto/Printable.py | 14 + src/fw_fanctrl/dto/__init__.py | 0 .../dto/command_result/CommandResult.py | 13 + .../ConfigurationReloadCommandResult.py | 11 + .../PrintActiveCommandResult.py | 11 + .../PrintCurrentStrategyCommandResult.py | 11 + .../PrintFanSpeedCommandResult.py | 11 + .../PrintStrategyListCommandResult.py | 14 + .../ServicePauseCommandResult.py | 10 + .../ServiceResumeCommandResult.py | 11 + .../StrategyChangeCommandResult.py | 11 + .../StrategyResetCommandResult.py | 11 + src/fw_fanctrl/dto/command_result/__init__.py | 0 .../dto/runtime_result/RuntimeResult.py | 13 + .../dto/runtime_result/StatusRuntimeResult.py | 18 + src/fw_fanctrl/dto/runtime_result/__init__.py | 0 src/fw_fanctrl/enum/CommandStatus.py | 6 + src/fw_fanctrl/enum/OutputFormat.py | 6 + src/fw_fanctrl/enum/__init__.py | 0 .../exception/InvalidStrategyException.py | 2 + src/fw_fanctrl/exception/JSONException.py | 2 + .../SocketAlreadyRunningException.py | 2 + .../exception/SocketCallException.py | 2 + .../exception/UnimplementedException.py | 2 + .../exception/UnknownCommandException.py | 2 + src/fw_fanctrl/exception/__init__.py | 0 .../EctoolHardwareController.py | 76 ++ .../hardwareController/HardwareController.py | 25 + src/fw_fanctrl/hardwareController/__init__.py | 0 .../socketController/SocketController.py | 21 + .../socketController/UnixSocketController.py | 102 +++ src/fw_fanctrl/socketController/__init__.py | 0 54 files changed, 1272 insertions(+), 995 deletions(-) create mode 100644 .editorconfig delete mode 100644 fanctrl.py create mode 100644 pyproject.toml create mode 100644 src/fw_fanctrl/CommandParser.py create mode 100644 src/fw_fanctrl/Configuration.py create mode 100644 src/fw_fanctrl/FanController.py create mode 100644 src/fw_fanctrl/Strategy.py create mode 100644 src/fw_fanctrl/__init__.py create mode 100644 src/fw_fanctrl/__main__.py create mode 100644 src/fw_fanctrl/dto/Printable.py create mode 100644 src/fw_fanctrl/dto/__init__.py create mode 100644 src/fw_fanctrl/dto/command_result/CommandResult.py create mode 100644 src/fw_fanctrl/dto/command_result/ConfigurationReloadCommandResult.py create mode 100644 src/fw_fanctrl/dto/command_result/PrintActiveCommandResult.py create mode 100644 src/fw_fanctrl/dto/command_result/PrintCurrentStrategyCommandResult.py create mode 100644 src/fw_fanctrl/dto/command_result/PrintFanSpeedCommandResult.py create mode 100644 src/fw_fanctrl/dto/command_result/PrintStrategyListCommandResult.py create mode 100644 src/fw_fanctrl/dto/command_result/ServicePauseCommandResult.py create mode 100644 src/fw_fanctrl/dto/command_result/ServiceResumeCommandResult.py create mode 100644 src/fw_fanctrl/dto/command_result/StrategyChangeCommandResult.py create mode 100644 src/fw_fanctrl/dto/command_result/StrategyResetCommandResult.py create mode 100644 src/fw_fanctrl/dto/command_result/__init__.py create mode 100644 src/fw_fanctrl/dto/runtime_result/RuntimeResult.py create mode 100644 src/fw_fanctrl/dto/runtime_result/StatusRuntimeResult.py create mode 100644 src/fw_fanctrl/dto/runtime_result/__init__.py create mode 100644 src/fw_fanctrl/enum/CommandStatus.py create mode 100644 src/fw_fanctrl/enum/OutputFormat.py create mode 100644 src/fw_fanctrl/enum/__init__.py create mode 100644 src/fw_fanctrl/exception/InvalidStrategyException.py create mode 100644 src/fw_fanctrl/exception/JSONException.py create mode 100644 src/fw_fanctrl/exception/SocketAlreadyRunningException.py create mode 100644 src/fw_fanctrl/exception/SocketCallException.py create mode 100644 src/fw_fanctrl/exception/UnimplementedException.py create mode 100644 src/fw_fanctrl/exception/UnknownCommandException.py create mode 100644 src/fw_fanctrl/exception/__init__.py create mode 100644 src/fw_fanctrl/hardwareController/EctoolHardwareController.py create mode 100644 src/fw_fanctrl/hardwareController/HardwareController.py create mode 100644 src/fw_fanctrl/hardwareController/__init__.py create mode 100644 src/fw_fanctrl/socketController/SocketController.py create mode 100644 src/fw_fanctrl/socketController/UnixSocketController.py create mode 100644 src/fw_fanctrl/socketController/__init__.py diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..b85eb4d --- /dev/null +++ b/.editorconfig @@ -0,0 +1,10 @@ +root = true + +[*] +indent_style = space +indent_size = 4 +max_line_length = 120 +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true +end_of_line = lf diff --git a/.gitignore b/.gitignore index 7bb0dc6..000fa92 100644 --- a/.gitignore +++ b/.gitignore @@ -1,84 +1,169 @@ # Nix Build dir /result -### Intellij template -# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider -# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 - -# User-specific stuff -.idea/**/workspace.xml -.idea/**/tasks.xml -.idea/**/usage.statistics.xml -.idea/**/dictionaries -.idea/**/shelf - -# AWS User-specific -.idea/**/aws.xml - -# Generated files -.idea/**/contentModel.xml - -# Sensitive or high-churn files -.idea/**/dataSources/ -.idea/**/dataSources.ids -.idea/**/dataSources.local.xml -.idea/**/sqlDataSources.xml -.idea/**/dynamic.xml -.idea/**/uiDesigner.xml -.idea/**/dbnavigator.xml - -# Gradle -.idea/**/gradle.xml -.idea/**/libraries - -# Gradle and Maven with auto-import -# When using Gradle or Maven with auto-import, you should exclude module files, -# since they will be recreated, and may cause churn. Uncomment if using -# auto-import. -# .idea/artifacts -# .idea/compiler.xml -# .idea/jarRepositories.xml -# .idea/modules.xml -# .idea/*.iml -# .idea/modules -# *.iml -# *.ipr - -# CMake -cmake-build-*/ - -# Mongo Explorer plugin -.idea/**/mongoSettings.xml - -# File-based project format -*.iws - -# IntelliJ -out/ - -# mpeltonen/sbt-idea plugin -.idea_modules/ - -# JIRA plugin -atlassian-ide-plugin.xml - -# Cursive Clojure plugin -.idea/replstate.xml - -# SonarLint plugin -.idea/sonarlint/ - -# Crashlytics plugin (for Android Studio and IntelliJ) -com_crashlytics_export_strings.xml -crashlytics.properties -crashlytics-build.properties -fabric.properties - -# Editor-based Rest Client -.idea/httpRequests - -# Android studio 3.1+ serialized cache file -.idea/caches/build_file_checksums.ser +### Python template +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ ### VisualStudioCode template .vscode/* @@ -94,27 +179,11 @@ fabric.properties # Built Visual Studio Code Extensions *.vsix -### Git template -# Created by git for backups. To disable backups in Git: -# $ git config --global mergetool.keepBackup false -*.orig - -# Created by git when using merge tools for conflicts -*.BACKUP.* -*.BASE.* -*.LOCAL.* -*.REMOTE.* -*_BACKUP_*.txt -*_BASE_*.txt -*_LOCAL_*.txt -*_REMOTE_*.txt - ### JetBrains template # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 # User-specific stuff -.idea/** .idea/**/workspace.xml .idea/**/tasks.xml .idea/**/usage.statistics.xml @@ -189,162 +258,7 @@ fabric.properties # Android studio 3.1+ serialized cache file .idea/caches/build_file_checksums.ser -### CLion template -# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider -# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 - -# User-specific stuff -.idea/**/workspace.xml -.idea/**/tasks.xml -.idea/**/usage.statistics.xml -.idea/**/dictionaries -.idea/**/shelf - -# AWS User-specific -.idea/**/aws.xml - -# Generated files -.idea/**/contentModel.xml - -# Sensitive or high-churn files -.idea/**/dataSources/ -.idea/**/dataSources.ids -.idea/**/dataSources.local.xml -.idea/**/sqlDataSources.xml -.idea/**/dynamic.xml -.idea/**/uiDesigner.xml -.idea/**/dbnavigator.xml - -# Gradle -.idea/**/gradle.xml -.idea/**/libraries - -# Gradle and Maven with auto-import -# When using Gradle or Maven with auto-import, you should exclude module files, -# since they will be recreated, and may cause churn. Uncomment if using -# auto-import. -# .idea/artifacts -# .idea/compiler.xml -# .idea/jarRepositories.xml -# .idea/modules.xml -# .idea/*.iml -# .idea/modules -# *.iml -# *.ipr - -# CMake -cmake-build-*/ - -# Mongo Explorer plugin -.idea/**/mongoSettings.xml - -# File-based project format -*.iws - -# IntelliJ -out/ - -# mpeltonen/sbt-idea plugin -.idea_modules/ - -# JIRA plugin -atlassian-ide-plugin.xml - -# Cursive Clojure plugin -.idea/replstate.xml - -# SonarLint plugin -.idea/sonarlint/ - -# Crashlytics plugin (for Android Studio and IntelliJ) -com_crashlytics_export_strings.xml -crashlytics.properties -crashlytics-build.properties -fabric.properties - -# Editor-based Rest Client -.idea/httpRequests - -# Android studio 3.1+ serialized cache file -.idea/caches/build_file_checksums.ser - -### WebStorm template -# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider -# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 - -# User-specific stuff -.idea/**/workspace.xml -.idea/**/tasks.xml -.idea/**/usage.statistics.xml -.idea/**/dictionaries -.idea/**/shelf - -# AWS User-specific -.idea/**/aws.xml - -# Generated files -.idea/**/contentModel.xml - -# Sensitive or high-churn files -.idea/**/dataSources/ -.idea/**/dataSources.ids -.idea/**/dataSources.local.xml -.idea/**/sqlDataSources.xml -.idea/**/dynamic.xml -.idea/**/uiDesigner.xml -.idea/**/dbnavigator.xml - -# Gradle -.idea/**/gradle.xml -.idea/**/libraries - -# Gradle and Maven with auto-import -# When using Gradle or Maven with auto-import, you should exclude module files, -# since they will be recreated, and may cause churn. Uncomment if using -# auto-import. -# .idea/artifacts -# .idea/compiler.xml -# .idea/jarRepositories.xml -# .idea/modules.xml -# .idea/*.iml -# .idea/modules -# *.iml -# *.ipr - -# CMake -cmake-build-*/ - -# Mongo Explorer plugin -.idea/**/mongoSettings.xml - -# File-based project format -*.iws - -# IntelliJ -out/ - -# mpeltonen/sbt-idea plugin -.idea_modules/ - -# JIRA plugin -atlassian-ide-plugin.xml - -# Cursive Clojure plugin -.idea/replstate.xml - -# SonarLint plugin -.idea/sonarlint/ - -# Crashlytics plugin (for Android Studio and IntelliJ) -com_crashlytics_export_strings.xml -crashlytics.properties -crashlytics-build.properties -fabric.properties - -# Editor-based Rest Client -.idea/httpRequests - -# Android studio 3.1+ serialized cache file -.idea/caches/build_file_checksums.ser +### Project ignores -.temp +.idea/ +.temp/ diff --git a/README.md b/README.md index 013bc4a..0dac6f9 100644 --- a/README.md +++ b/README.md @@ -28,13 +28,21 @@ If the service is paused or stopped, the fans will revert to their default behav ## Table of Content -- [Documentation](#documentation) -- [Installation](#installation) - * [Requirements](#requirements) - * [Dependencies](#dependencies) - * [Instructions](#instructions) -- [Update](#update) -- [Uninstall](#uninstall) + +* [fw-fanctrl](#fw-fanctrl) + * [Additional platforms:](#additional-platforms) + * [Description](#description) + * [Table of Content](#table-of-content) + * [Documentation](#documentation) + * [Installation](#installation) + * [Other Platforms](#other-platforms) + * [Requirements](#requirements) + * [Dependencies](#dependencies) + * [Instructions](#instructions) + * [Update](#update) + * [Uninstall](#uninstall) + * [Development Setup](#development-setup) + ## Documentation @@ -43,8 +51,9 @@ More documentation could be found [here](./doc/README.md). ## Installation ### Other Platforms -| name | branch | documentation | -|-------|---------------|---------------| + +| name | branch | documentation | +|-------|------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------| | NixOS | [packaging/nix](https://github.com/TamtamHero/fw-fanctrl/tree/packaging/nix) | [packaging/nix/doc/nix-flake](https://github.com/TamtamHero/fw-fanctrl/tree/packaging/nix/doc/nix-flake.md) | ### Requirements @@ -86,15 +95,16 @@ sudo ./install.sh You can add a number of arguments to the installation command to suit your needs -| argument | description | -|---------------------------------------------------------------------------------|----------------------------------------------------| -| `--dest-dir ` | specify an installation destination directory | -| `--prefix-dir ` | specify an installation prefix directory | -| `--sysconf-dir ` | specify a default configuration directory | -| `--no-ectool` | disable ectool installation and service activation | -| `--no-post-install` | disable post-install process | -| `--no-pre-uninstall` | disable pre-uninstall process | -| `--no-battery-sensors` | disable checking battery temperature sensors | +| argument | description | +|---------------------------------------------------------------------------------|----------------------------------------------------------------| +| `--dest-dir ` | specify an installation destination directory | +| `--prefix-dir ` | specify an installation prefix directory | +| `--sysconf-dir ` | specify a default configuration directory | +| `--no-ectool` | disable ectool installation and service activation | +| `--no-post-install` | disable post-install process | +| `--no-pre-uninstall` | disable pre-uninstall process | +| `--no-battery-sensors` | disable checking battery temperature sensors | +| `--no-pip-install` | disable the pip installation (should be done manually instead) | ## Update @@ -109,3 +119,20 @@ corresponding [arguments if necessary](#instructions) sudo ./install.sh --remove ``` +## Development Setup + +> It is recommended to use a virtual environment to install development dependencies + +Install the development dependencies with the following command: + +```shell +pip install -e ".[dev]" +``` + +The project uses the [black](https://github.com/psf/black) formatter. + +Please format your contributions before commiting them. + +```shell +python -m black . +``` diff --git a/doc/README.md b/doc/README.md index 823fbff..003734c 100644 --- a/doc/README.md +++ b/doc/README.md @@ -1,6 +1,7 @@ # Table of Content - [Default Installation](../README.md#installation) +- [Development Setup](../README.md#development-setup) - [NixOS Flake](https://github.com/TamtamHero/fw-fanctrl/tree/packaging/nix/doc/nix-flake.md) - [Commands](./commands.md) - [Configuration](./configuration.md) diff --git a/doc/commands.md b/doc/commands.md index 5ef3294..35eb31e 100644 --- a/doc/commands.md +++ b/doc/commands.md @@ -10,9 +10,10 @@ fw-fanctrl [commands and options] First, the global options -| Option | Optional | Choices | Default | Description | -|---------------------------|----------|---------|---------|--------------------------------------------------------------------------------| -| --socket-controller, --sc | yes | unix | unix | the socket controller to use for communication between the cli and the service | +| Option | Optional | Choices | Default | Description | +|---------------------------|----------|---------------|---------|--------------------------------------------------------------------------------| +| --socket-controller, --sc | yes | unix | unix | the socket controller to use for communication between the cli and the service | +| --output-format | yes | NATURAL, JSON | NATURAL | the client socket controller output format | **run** @@ -57,12 +58,13 @@ resume the service print the selected information -| Option | Optional | Choices | Default | Description | -|--------------------|----------|----------------------|---------|------------------------| -| \ | yes | current, list, speed | current | what should be printed | +| Option | Optional | Choices | Default | Description | +|--------------------|----------|---------------------------|---------|------------------------| +| \ | yes | all, current, list, speed | all | what should be printed | | Choice | Description | |---------|----------------------------------| +| all | All details | | current | The current strategy being used | | list | List available strategies | | speed | The current fan speed percentage | diff --git a/doc/configuration.md b/doc/configuration.md index 6f381fb..586d1a5 100644 --- a/doc/configuration.md +++ b/doc/configuration.md @@ -1,9 +1,12 @@ # Table of Content -- [Configuration](#configuration) + +* [Table of Content](#table-of-content) +* [Configuration](#configuration) * [Default strategy](#default-strategy) * [Charging/Discharging strategies](#chargingdischarging-strategies) * [Editing strategies](#editing-strategies) + # Configuration @@ -31,7 +34,7 @@ The default strategy is the one used when the service is started. It can be changed by replacing the value of the `defaultStrategy` field with one of the strategies present in the configuration. -```json +``` "defaultStrategy": "[STRATEGY NAME]" ``` @@ -43,7 +46,7 @@ Otherwise the default strategy is used. It can be changed by replacing the value of the `strategyOnDischarging` field with one of the strategies present in the configuration. -```json +``` "strategyOnDischarging": "[STRATEGY NAME]" ``` @@ -57,7 +60,7 @@ Strategies can be configured with the following parameters: > > It is represented by the curve points for `f(temperature) = fan(s) speed`. > -> ```json +> ``` > "speedCurve": [ > { "temp": [TEMPERATURE POINT], "speed": [PERCENTAGE SPEED] }, > ... @@ -72,7 +75,7 @@ Strategies can be configured with the following parameters: > > It is the interval in seconds between fan speed calculations. > -> ```json +> ``` > "fanSpeedUpdateFrequency": [UPDATE FREQUENCY] > ``` > @@ -87,7 +90,7 @@ Strategies can be configured with the following parameters: > > It is the number of seconds over which the moving average of temperature is calculated. > -> ```json +> ``` > "movingAverageInterval": [AVERAGING INTERVAL] > ``` > diff --git a/fanctrl.py b/fanctrl.py deleted file mode 100644 index 703e465..0000000 --- a/fanctrl.py +++ /dev/null @@ -1,664 +0,0 @@ -#! /usr/bin/python3 -import argparse -import collections -import io -import json -import os -import re -import shlex -import socket -import subprocess -import sys -import threading -from time import sleep -from abc import ABC, abstractmethod -import textwrap - -DEFAULT_CONFIGURATION_FILE_PATH = "/etc/fw-fanctrl/config.json" -SOCKETS_FOLDER_PATH = "/run/fw-fanctrl" -COMMANDS_SOCKET_FILE_PATH = os.path.join(SOCKETS_FOLDER_PATH, ".fw-fanctrl.commands.sock") - - -class CommandParser: - isRemote = True - - legacyParser = None - parser = None - - def __init__(self, isRemote=False): - self.isRemote = isRemote - self.initParser() - self.initLegacyParser() - - def initParser(self): - self.parser = argparse.ArgumentParser( - prog="fw-fanctrl", - description="control Framework's laptop fan(s) with a speed curve", - epilog=textwrap.dedent( - "obtain more help about a command or subcommand using `fw-fanctrl [subcommand...] -h/--help`"), - formatter_class=argparse.RawTextHelpFormatter - ) - self.parser.add_argument( - "--socket-controller", - "--sc", - help="the socket controller to use for communication between the cli and the service", - type=str, - choices=["unix"], - default="unix" - ) - - commandsSubParser = self.parser.add_subparsers(dest="command") - - if not self.isRemote: - runCommand = commandsSubParser.add_parser( - "run", - description="run the service", - formatter_class=argparse.RawTextHelpFormatter - ) - runCommand.add_argument( - "strategy", - help='name of the strategy to use e.g: "lazy" (use `print strategies` to list available strategies)', - nargs=argparse.OPTIONAL - ) - runCommand.add_argument( - "--config", - "-c", - help=f"the configuration file path (default: {DEFAULT_CONFIGURATION_FILE_PATH})", - type=str, - default=DEFAULT_CONFIGURATION_FILE_PATH - ) - runCommand.add_argument( - "--silent", - "-s", - help="disable printing speed/temp status to stdout", - action="store_true" - ) - runCommand.add_argument( - "--hardware-controller", - "--hc", - help="the hardware controller to use for fetching and setting the temp and fan(s) speed", - type=str, - choices=["ectool"], - default="ectool" - ) - runCommand.add_argument( - "--no-battery-sensors", - help="disable checking battery temperature sensors", - action="store_true", - ) - - useCommand = commandsSubParser.add_parser( - "use", - description="change the current strategy" - ) - useCommand.add_argument( - "strategy", - help='name of the strategy to use e.g: "lazy". (use `print strategies` to list available strategies)' - ) - - commandsSubParser.add_parser( - "reset", - description="reset to the default strategy" - ) - commandsSubParser.add_parser( - "reload", - description="reload the configuration file" - ) - commandsSubParser.add_parser( - "pause", - description="pause the service" - ) - commandsSubParser.add_parser( - "resume", - description="resume the service" - ) - - printCommand = commandsSubParser.add_parser( - "print", - description="print the selected information", - formatter_class=argparse.RawTextHelpFormatter - ) - printCommand.add_argument( - "print_selection", - help="current - The current strategy\nlist - List available strategies\nspeed - The current fan speed percentage", - nargs="?", - type=str, - choices=["current", - "list", - "speed"], - default="current" - ) - - def initLegacyParser(self): - self.legacyParser = argparse.ArgumentParser(add_help=False) - - # avoid collision with the new parser commands - def excludedPositionalArguments(value): - if value in ["run", "use", "reload", "reset", "pause", "resume", "print"]: - raise argparse.ArgumentTypeError("%s is an excluded value" % value) - return value - - bothGroup = self.legacyParser.add_argument_group("both") - bothGroup.add_argument( - "_strategy", - nargs="?", - type=excludedPositionalArguments - ) - bothGroup.add_argument( - "--strategy", - nargs="?" - ) - - runGroup = self.legacyParser.add_argument_group("run") - runGroup.add_argument( - "--run", - action="store_true" - ) - runGroup.add_argument( - "--config", - type=str, - default=DEFAULT_CONFIGURATION_FILE_PATH - ) - runGroup.add_argument( - "--no-log", - action="store_true" - ) - commandGroup = self.legacyParser.add_argument_group("configure") - commandGroup.add_argument( - "--query", - "-q", - action="store_true" - ) - commandGroup.add_argument( - "--list-strategies", - action="store_true" - ) - commandGroup.add_argument( - "--reload", - "-r", - action="store_true" - ) - commandGroup.add_argument( - "--pause", - action="store_true" - ) - commandGroup.add_argument( - "--resume", - action="store_true" - ) - commandGroup.add_argument( - "--hardware-controller", - "--hc", - type=str, - choices=["ectool"], - default="ectool" - ) - commandGroup.add_argument( - "--socket-controller", - "--sc", - type=str, - choices=["unix"], - default="unix" - ) - - def parseArgs(self, args=None): - values = None - original_stderr = sys.stderr - # silencing legacy parser output - sys.stderr = open(os.devnull, 'w') - try: - legacy_values = self.legacyParser.parse_args(args) - if legacy_values.strategy is None: - legacy_values.strategy = legacy_values._strategy - # converting legacy values into new ones - values = argparse.Namespace() - values.socket_controller = legacy_values.socket_controller - if legacy_values.query: - values.command = "print" - values.print_selection = "current" - if legacy_values.list_strategies: - values.command = "print" - values.print_selection = "list" - if legacy_values.resume: - values.command = "resume" - if legacy_values.pause: - values.command = "pause" - if legacy_values.reload: - values.command = "reload" - if legacy_values.run: - values.command = "run" - values.silent = legacy_values.no_log - values.hardware_controller = legacy_values.hardware_controller - values.config = legacy_values.config - values.strategy = legacy_values.strategy - if not hasattr(values, "command") and legacy_values.strategy is not None: - values.command = "use" - values.strategy = legacy_values.strategy - if not hasattr(values, "command"): - raise Exception("not a valid legacy command") - if self.isRemote or values.command == "run": - print( - "[Warning] > this command is deprecated and will be removed soon, please use the new command format instead ('fw-fanctrl -h' for more details).") - except (SystemExit, Exception): - sys.stderr = original_stderr - values = self.parser.parse_args(args) - finally: - sys.stderr = original_stderr - return values - - -class JSONException(Exception): - pass - - -class UnimplementedException(Exception): - pass - - -class InvalidStrategyException(Exception): - pass - - -class SocketAlreadyRunningException(Exception): - pass - - -class Strategy: - name = None - fanSpeedUpdateFrequency = None - movingAverageInterval = None - speedCurve = None - - def __init__(self, name, parameters): - self.name = name - self.fanSpeedUpdateFrequency = parameters["fanSpeedUpdateFrequency"] - if self.fanSpeedUpdateFrequency is None or self.fanSpeedUpdateFrequency == "": - self.fanSpeedUpdateFrequency = 5 - self.movingAverageInterval = parameters["movingAverageInterval"] - if self.movingAverageInterval is None or self.movingAverageInterval == "": - self.movingAverageInterval = 20 - self.speedCurve = parameters["speedCurve"] - - -class Configuration: - path = None - data = None - - def __init__(self, path): - self.path = path - self.reload() - - def reload(self): - with open(self.path, "r") as fp: - try: - self.data = json.load(fp) - except json.JSONDecodeError: - return False - return True - - def getStrategies(self): - return self.data["strategies"].keys() - - def getStrategy(self, strategyName): - if strategyName == "strategyOnDischarging": - strategyName = self.data[strategyName] - if strategyName == "": - strategyName = "defaultStrategy" - if strategyName == "defaultStrategy": - strategyName = self.data[strategyName] - if strategyName is None or strategyName not in self.data["strategies"]: - raise InvalidStrategyException(strategyName) - return Strategy(strategyName, self.data["strategies"][strategyName]) - - def getDefaultStrategy(self): - return self.getStrategy("defaultStrategy") - - def getDischargingStrategy(self): - return self.getStrategy("strategyOnDischarging") - - -class SocketController(ABC): - @abstractmethod - def startServerSocket(self, commandCallback=None): - raise UnimplementedException() - - @abstractmethod - def stopServerSocket(self): - raise UnimplementedException() - - @abstractmethod - def isServerSocketRunning(self): - raise UnimplementedException() - - @abstractmethod - def sendViaClientSocket(self, command): - raise UnimplementedException() - - -class UnixSocketController(SocketController, ABC): - server_socket = None - - def startServerSocket(self, commandCallback=None): - if self.server_socket: - raise SocketAlreadyRunningException(self.server_socket) - self.server_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - if os.path.exists(COMMANDS_SOCKET_FILE_PATH): - os.remove(COMMANDS_SOCKET_FILE_PATH) - try: - if not os.path.exists(SOCKETS_FOLDER_PATH): - os.makedirs(SOCKETS_FOLDER_PATH) - self.server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - self.server_socket.bind(COMMANDS_SOCKET_FILE_PATH) - os.chmod(COMMANDS_SOCKET_FILE_PATH, 0o777) - self.server_socket.listen(1) - while True: - client_socket, _ = self.server_socket.accept() - parsePrintCapture = io.StringIO() - try: - # Receive data from the client - data = client_socket.recv(4096).decode() - original_stderr = sys.stderr - original_stdout = sys.stdout - # capture parsing std outputs for the client - sys.stderr = parsePrintCapture - sys.stdout = parsePrintCapture - try: - args = CommandParser(True).parseArgs(shlex.split(data)) - finally: - sys.stderr = original_stderr - sys.stdout = original_stdout - commandReturn = commandCallback(args) - if not commandReturn: - commandReturn = "Success!" - if parsePrintCapture.getvalue().strip(): - commandReturn = parsePrintCapture.getvalue() + commandReturn - client_socket.sendall(commandReturn.encode('utf-8')) - except SystemExit: - client_socket.sendall(f"{parsePrintCapture.getvalue()}".encode('utf-8')) - except Exception as e: - print(f"[Error] > An error occurred while treating a socket command: {e}", file=sys.stderr) - client_socket.sendall(f"[Error] > An error occurred: {e}".encode('utf-8')) - finally: - client_socket.shutdown(socket.SHUT_WR) - client_socket.close() - finally: - self.stopServerSocket() - - def stopServerSocket(self): - if self.server_socket: - self.server_socket.close() - self.server_socket = None - - def isServerSocketRunning(self): - return self.server_socket is not None - - def sendViaClientSocket(self, command): - client_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - try: - client_socket.connect(COMMANDS_SOCKET_FILE_PATH) - client_socket.sendall(command.encode('utf-8')) - received_data = b"" - while True: - data_chunk = client_socket.recv(1024) - if not data_chunk: - break - received_data += data_chunk - # Receive data from the server - data = received_data.decode() - if data.startswith("[Error] > "): - raise Exception(data) - return data - finally: - if client_socket: - client_socket.close() - - -class HardwareController(ABC): - @abstractmethod - def getTemperature(self): - raise UnimplementedException() - - @abstractmethod - def setSpeed(self, speed): - raise UnimplementedException() - - @abstractmethod - def pause(self): - pass - - @abstractmethod - def resume(self): - pass - - @abstractmethod - def isOnAC(self): - raise UnimplementedException() - - -class EctoolHardwareController(HardwareController, ABC): - noBatterySensorMode = False - nonBatterySensors = None - - def __init__(self, noBatterySensorMode=False): - if noBatterySensorMode: - self.noBatterySensorMode = True - self.populateNonBatterySensors() - - def populateNonBatterySensors(self): - self.nonBatterySensors = [] - rawOut = subprocess.run("ectool tempsinfo all", stdout=subprocess.PIPE, shell=True, text=True).stdout - batterySensorsRaw = re.findall(r"\d+ Battery", rawOut, re.MULTILINE) - batterySensors = [x.split(" ")[0] for x in batterySensorsRaw] - for x in re.findall(r"^\d+", rawOut, re.MULTILINE): - if x not in batterySensors: - self.nonBatterySensors.append(x) - - def getTemperature(self): - if self.noBatterySensorMode: - rawOut = "".join([ - subprocess.run("ectool temps " + x, stdout=subprocess.PIPE, shell=True, text=True).stdout - for x in self.nonBatterySensors - ]) - else: - rawOut = subprocess.run("ectool temps all", stdout=subprocess.PIPE, shell=True, text=True).stdout - rawTemps = re.findall(r'\(= (\d+) C\)', rawOut) - temps = sorted([x for x in [int(x) for x in rawTemps] if x > 0], reverse=True) - # safety fallback to avoid damaging hardware - if len(temps) == 0: - return 50 - return round(temps[0], 1) - - def setSpeed(self, speed): - subprocess.run(f"ectool fanduty {speed}", stdout=subprocess.PIPE, shell=True) - - def isOnAC(self): - rawOut = subprocess.run("ectool battery", stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, shell=True, - text=True).stdout - return len(re.findall(r"Flags.*(AC_PRESENT)", rawOut)) > 0 - - def pause(self): - subprocess.run("ectool autofanctrl", stdout=subprocess.PIPE, shell=True) - - def resume(self): - pass - - -class FanController: - hardwareController = None - socketController = None - configuration = None - overwrittenStrategy = None - speed = 0 - tempHistory = collections.deque([0] * 100, maxlen=100) - active = True - timecount = 0 - - def __init__(self, hardwareController, socketController, configPath, strategyName): - self.hardwareController = hardwareController - self.socketController = socketController - self.configuration = Configuration(configPath) - - if strategyName is not None and strategyName != "": - self.overwriteStrategy(strategyName) - - t = threading.Thread(target=self.socketController.startServerSocket, args=[self.commandManager]) - t.daemon = True - t.start() - - def getActualTemperature(self): - return self.hardwareController.getTemperature() - - def setSpeed(self, speed): - self.speed = speed - self.hardwareController.setSpeed(speed) - - def isOnAC(self): - return self.hardwareController.isOnAC() - - def pause(self): - self.active = False - self.hardwareController.pause() - - def resume(self): - self.active = True - self.hardwareController.resume() - - def overwriteStrategy(self, strategyName): - self.overwrittenStrategy = self.configuration.getStrategy(strategyName) - self.timecount = 0 - - def clearOverwrittenStrategy(self): - self.overwrittenStrategy = None - self.timecount = 0 - - def getCurrentStrategy(self): - if self.overwrittenStrategy is not None: - return self.overwrittenStrategy - if self.isOnAC(): - return self.configuration.getDefaultStrategy() - return self.configuration.getDischargingStrategy() - - def commandManager(self, args): - if args.command == "reset" or (args.command == "use" and args.strategy == "defaultStrategy"): - self.clearOverwrittenStrategy() - return - elif args.command == "use": - try: - self.overwriteStrategy(args.strategy) - return self.getCurrentStrategy().name - except InvalidStrategyException: - raise InvalidStrategyException(f"The specified strategy is invalid: {args.strategy}") - elif args.command == "reload": - if self.configuration.reload(): - if self.overwrittenStrategy is not None: - self.overwriteStrategy(self.overwrittenStrategy.name) - else: - raise JSONException("Config file could not be parsed due to JSON Error") - return - elif args.command == "pause": - self.pause() - return - elif args.command == "resume": - self.resume() - return - elif args.command == "print": - if args.print_selection == "current": - return self.getCurrentStrategy().name - elif args.print_selection == "list": - return '\n'.join(self.configuration.getStrategies()) - elif args.print_selection == "speed": - return str(self.speed) + '%' - return "Unknown command, unexpected." - - # return mean temperature over a given time interval (in seconds) - def getMovingAverageTemperature(self, timeInterval): - slicedTempHistory = [x for x in self.tempHistory if x > 0][-timeInterval:] - if len(slicedTempHistory) == 0: - return self.getActualTemperature() - return round(sum(slicedTempHistory) / len(slicedTempHistory), 1) - - def getEffectiveTemperature(self, currentTemp, timeInterval): - # the moving average temperature count for 2/3 of the effective temperature - return round(min(self.getMovingAverageTemperature(timeInterval), currentTemp), 1) - - def adaptSpeed(self, currentTemp): - currentStrategy = self.getCurrentStrategy() - currentTemp = self.getEffectiveTemperature(currentTemp, currentStrategy.movingAverageInterval) - minPoint = currentStrategy.speedCurve[0] - maxPoint = currentStrategy.speedCurve[-1] - for e in currentStrategy.speedCurve: - if currentTemp > e["temp"]: - minPoint = e - else: - maxPoint = e - break - - if minPoint == maxPoint: - newSpeed = minPoint["speed"] - else: - slope = (maxPoint["speed"] - minPoint["speed"]) / ( - maxPoint["temp"] - minPoint["temp"] - ) - newSpeed = int(minPoint["speed"] + (currentTemp - minPoint["temp"]) * slope) - if self.active: - self.setSpeed(newSpeed) - - def printState(self): - currentStrategy = self.getCurrentStrategy() - currentTemperture = self.getActualTemperature() - print( - f"speed: {self.speed}%, temp: {currentTemperture}°C, movingAverageTemp: {self.getMovingAverageTemperature(currentStrategy.movingAverageInterval)}°C, effectureTemp: {self.getEffectiveTemperature(currentTemperture, currentStrategy.movingAverageInterval)}°C" - ) - - def run(self, debug=True): - try: - while True: - if self.active: - temp = self.getActualTemperature() - # update fan speed every "fanSpeedUpdateFrequency" seconds - if self.timecount % self.getCurrentStrategy().fanSpeedUpdateFrequency == 0: - self.adaptSpeed(temp) - self.timecount = 0 - - self.tempHistory.append(temp) - - if debug: - self.printState() - self.timecount += 1 - sleep(1) - else: - sleep(5) - except InvalidStrategyException as e: - print(f"[Error] > Missing strategy, exiting for safety reasons: {e.args[0]}", file=sys.stderr) - except Exception as e: - print(f"[Error] > Critical error, exiting for safety reasons: {e}", file=sys.stderr) - exit(1) - - -def main(): - args = CommandParser().parseArgs() - - socketController = UnixSocketController() - if args.socket_controller == "unix": - socketController = UnixSocketController() - - if args.command == "run": - hardwareController = EctoolHardwareController(noBatterySensorMode=args.no_battery_sensors) - if args.hardware_controller == "ectool": - hardwareController = EctoolHardwareController(noBatterySensorMode=args.no_battery_sensors) - - fan = FanController(hardwareController=hardwareController, socketController=socketController, - configPath=args.config, strategyName=args.strategy) - fan.run(debug=not args.silent) - else: - try: - commandResult = socketController.sendViaClientSocket(' '.join(sys.argv[1:])) - if commandResult: - print(commandResult) - except Exception as e: - print(f"[Error] > An error occurred: {e}", file=sys.stderr) - exit(1) - - -if __name__ == "__main__": - main() diff --git a/flake.lock b/flake.lock index 3f71c3e..96049f3 100644 --- a/flake.lock +++ b/flake.lock @@ -3,11 +3,11 @@ "flake-compat": { "flake": false, "locked": { - "lastModified": 1696426674, - "narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=", + "lastModified": 1733328505, + "narHash": "sha256-NeCCThCEP3eCl2l/+27kNNK7QrwZB1IJCrXfrbv5oqU=", "owner": "edolstra", "repo": "flake-compat", - "rev": "0f9255e01c2351cc7d116c072cb317785dd33b33", + "rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec", "type": "github" }, "original": { @@ -18,16 +18,16 @@ }, "nixpkgs": { "locked": { - "lastModified": 1719234068, - "narHash": "sha256-1AjSIedDC/aERt24KsCUftLpVppW61S7awfjGe7bMio=", + "lastModified": 1735563628, + "narHash": "sha256-OnSAY7XDSx7CtDoqNh8jwVwh4xNL/2HaJxGjryLWzX8=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "90bd1b26e23760742fdcb6152369919098f05417", + "rev": "b134951a4c9f3c995fd7be05f3243f8ecd65d798", "type": "github" }, "original": { "owner": "NixOS", - "ref": "nixos-23.11", + "ref": "nixos-24.05", "repo": "nixpkgs", "type": "github" } diff --git a/flake.nix b/flake.nix index 4f319cf..0199f8c 100644 --- a/flake.nix +++ b/flake.nix @@ -2,7 +2,7 @@ description = "A simple systemd service to better control Framework Laptop's fan(s)"; inputs = { - nixpkgs.url = "github:NixOS/nixpkgs/nixos-23.11"; + nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.05"; flake-compat = { url = "github:edolstra/flake-compat"; diff --git a/install.sh b/install.sh index 4a63e78..c3eb372 100755 --- a/install.sh +++ b/install.sh @@ -3,7 +3,7 @@ set -e # Argument parsing SHORT=r,d:,p:,s:,h -LONG=remove,dest-dir:,prefix-dir:,sysconf-dir:,no-ectool,no-pre-uninstall,no-post-install,no-battery-sensors,no-sudo,help +LONG=remove,dest-dir:,prefix-dir:,sysconf-dir:,no-ectool,no-pre-uninstall,no-post-install,no-battery-sensors,no-sudo,no-pip-install,help VALID_ARGS=$(getopt -a --options $SHORT --longoptions $LONG -- "$@") if [[ $? -ne 0 ]]; then exit 1; @@ -21,6 +21,7 @@ SHOULD_POST_INSTALL=true SHOULD_REMOVE=false NO_BATTERY_SENSOR=false NO_SUDO=false +NO_PIP_INSTALL=false eval set -- "$VALID_ARGS" while true; do @@ -55,8 +56,11 @@ while true; do '--no-sudo') NO_SUDO=true ;; + '--no-pip-install') + NO_PIP_INSTALL=true + ;; '--help' | '-h') - echo "Usage: $0 [--remove,-r] [--dest-dir,-d ] [--prefix-dir,-p ] [--sysconf-dir,-s system configuration destination directory (defaults to $SYSCONF_DIR)] [--no-ectool] [--no-post-install] [--no-pre-uninstall] [--no-sudo]" 1>&2 + echo "Usage: $0 [--remove,-r] [--dest-dir,-d ] [--prefix-dir,-p ] [--sysconf-dir,-s system configuration destination directory (defaults to $SYSCONF_DIR)] [--no-ectool] [--no-post-install] [--no-pre-uninstall] [--no-sudo] [--no-pip-install]" 1>&2 exit 0 ;; --) @@ -68,7 +72,7 @@ done # Root check if [ "$EUID" -ne 0 ] && [ "$NO_SUDO" = false ] - then echo "This program requires root permissions ore use the '--no-sudo' option" + then echo "This program requires root permissions or use the '--no-sudo' option" exit 1 fi @@ -86,6 +90,12 @@ function sanitizePath() { echo "$SANITIZED_PATH" } +function build() { + echo "building package" + python -m build -s + find . -type d -name "*.egg-info" -exec rm -rf {} + 2> "/dev/null" || true +} + # remove remaining legacy files function uninstall_legacy() { echo "removing legacy files" @@ -93,6 +103,7 @@ function uninstall_legacy() { rm "/usr/local/bin/ectool" 2> "/dev/null" || true rm "/usr/local/bin/fanctrl.py" 2> "/dev/null" || true rm "/etc/systemd/system/fw-fanctrl.service" 2> "/dev/null" || true + rm "$DEST_DIR$PREFIX_DIR/bin/fw-fanctrl" 2> "/dev/null" || true } function uninstall() { @@ -120,7 +131,11 @@ function uninstall() { done done - rm "$DEST_DIR$PREFIX_DIR/bin/fw-fanctrl" 2> "/dev/null" || true + if [ "$NO_PIP_INSTALL" = false ]; then + echo "uninstalling python package" + python -m pip uninstall -y fw-fanctrl 2> "/dev/null" || true + fi + ectool autofanctrl 2> "/dev/null" || true # restore default fan manager if [ "$SHOULD_INSTALL_ECTOOL" = true ]; then rm "$DEST_DIR$PREFIX_DIR/bin/ectool" 2> "/dev/null" || true @@ -142,8 +157,15 @@ function install() { rm -rf "$TEMP_FOLDER" fi mkdir -p "$DEST_DIR$SYSCONF_DIR/fw-fanctrl" - cp "./fanctrl.py" "$DEST_DIR$PREFIX_DIR/bin/fw-fanctrl" - chmod +x "$DEST_DIR$PREFIX_DIR/bin/fw-fanctrl" + + build + + if [ "$NO_PIP_INSTALL" = false ]; then + echo "installing python package" + python -m pip install --prefix="$DEST_DIR$PREFIX_DIR" dist/*.tar.gz + which python + rm -rf "dist/" 2> "/dev/null" || true + fi cp -n "./config.json" "$DEST_DIR$SYSCONF_DIR/fw-fanctrl" 2> "/dev/null" || true diff --git a/nix/module.nix b/nix/module.nix index 5cd2549..6e6b59f 100644 --- a/nix/module.nix +++ b/nix/module.nix @@ -107,7 +107,7 @@ in serviceConfig = { Type = "simple"; Restart = "always"; - ExecStart = "${fw-fanctrl}/bin/fw-fanctrl run --config /etc/fw-fanctrl/config.json --silent" + lib.strings.optionalString cfg.disableBatteryTempCheck " --no-battery-sensors"; + ExecStart= ''${fw-fanctrl}/bin/fw-fanctrl --output-format "JSON" run --config "/etc/fw-fanctrl/config.json" --silent ${lib.strings.optionalString cfg.disableBatteryTempCheck}''; ExecStopPost = "${pkgs.fw-ectool}/bin/ectool autofanctrl"; }; enable = true; diff --git a/nix/packages/fw-fanctrl.nix b/nix/packages/fw-fanctrl.nix index 749d56b..ea0a923 100644 --- a/nix/packages/fw-fanctrl.nix +++ b/nix/packages/fw-fanctrl.nix @@ -5,13 +5,24 @@ python3, bash, callPackage, getopt, -fw-ectool +fw-ectool, +fetchFromGitHub }: let pversion = "20-04-2024"; description = "A simple systemd service to better control Framework Laptop's fan(s)"; url = "https://github.com/TamtamHero/fw-fanctrl"; + setuptools_75_8_0 = python3Packages.setuptools.overrideAttrs (old: { + version = "75.8.0"; + src = fetchFromGitHub { + owner = "pypa"; + repo = "setuptools"; + rev = "v75.8.0"; + hash = "sha256-dSzsj0lnsc1Y+D/N0cnAPbS/ZYb+qC41b/KfPmL1zI4="; + }; + patches = []; + }); in python3Packages.buildPythonPackage rec{ pname = "fw-fanctrl"; @@ -21,23 +32,7 @@ python3Packages.buildPythonPackage rec{ outputs = [ "out" ]; - preBuild = '' - cat > setup.py << EOF - from setuptools import setup - - setup( - name="fw-fanctrl", - description="${description}", - url="${url}", - platforms=["linux"], - py_modules=[], - - scripts=[ - "fanctrl.py", - ], - ) - EOF - ''; + format = "pyproject"; nativeBuildInputs = [ python3 @@ -47,21 +42,11 @@ python3Packages.buildPythonPackage rec{ propagatedBuildInputs = [ fw-ectool + setuptools_75_8_0 ]; doCheck = false; - postPatch = '' - patchShebangs --build fanctrl.py - patchShebangs --build install.sh - ''; - - installPhase = '' - ./install.sh --dest-dir $out --prefix-dir "" --no-ectool --no-post-install --no-sudo - rm -rf $out/etc - rm -rf $out/lib - ''; - meta = with lib; { mainProgram = "fw-fanctrl"; homepage = url; diff --git a/post-install.sh b/post-install.sh index 0a2f252..b528406 100755 --- a/post-install.sh +++ b/post-install.sh @@ -42,7 +42,7 @@ done # Root check if [ "$EUID" -ne 0 ] && [ "$NO_SUDO" = false ] - then echo "This program requires root permissions ore use the '--no-sudo' option" + then echo "This program requires root permissions or use the '--no-sudo' option" exit 1 fi diff --git a/pre-uninstall.sh b/pre-uninstall.sh index 8607db6..270f8d2 100755 --- a/pre-uninstall.sh +++ b/pre-uninstall.sh @@ -1,8 +1,6 @@ #!/bin/bash set -e -HOME_DIR="$(eval echo "~$(logname)")" - # Argument parsing NO_SUDO=false SHORT=h @@ -30,7 +28,7 @@ while true; do done if [ "$EUID" -ne 0 ] && [ "$NO_SUDO" = false ] - then echo "This program requires root permissions ore use the '--no-sudo' option" + then echo "This program requires root permissions or use the '--no-sudo' option" exit 1 fi diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..d425f47 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,66 @@ +[tool.black] +line-length = 120 +exclude = ''' +/( + \.git + | \.github + | \.idea + | \.vscode + | \.hg + | \.mypy_cache + | \.tox + | \.venv + | _build + | buck-out + | build + | dist + | services + | fetch + | \.temp + | result +)/ +''' + +[build-system] +requires = ["setuptools>=75.2.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "fw-fanctrl" +version = "1.0.0" +description = "A simple systemd service to better control Framework Laptop's fan(s)." +keywords = ["framework", "laptop", "fan", "control", "cli", "service"] +readme = "README.md" +authors = [ + { name = "TamtamHero" }, +] +maintainers = [ + { name = "TamtamHero" }, + { name = "leopoldhub" }, +] +license = { file = "LICENSE" } +classifiers = [ + "License :: OSI Approved :: BSD License", + "Programming Language :: Python :: 3", + "Operating System :: POSIX :: Linux", + "Topic :: System :: Hardware", +] +requires-python = ">=3.12" +dependencies = [] +optional-dependencies = { dev = [ + "black==24.8.0", + "build>=1.2.2.post1", + "setuptools>=75.2.0", +] } + +[project.urls] +Homepage = "https://github.com/TamtamHero/fw-fanctrl" +Documentation = "https://github.com/TamtamHero/fw-fanctrl" +Repository = "https://github.com/TamtamHero/fw-fanctrl.git" +Issues = "https://github.com/TamtamHero/fw-fanctrl/issues" + +[tool.setuptools] +package-dir = { "" = "src" } + +[project.scripts] +fw-fanctrl = "fw_fanctrl.__main__:main" diff --git a/services/fw-fanctrl.service b/services/fw-fanctrl.service index ec7d46c..bd1f1f6 100644 --- a/services/fw-fanctrl.service +++ b/services/fw-fanctrl.service @@ -4,7 +4,7 @@ After=multi-user.target [Service] Type=simple Restart=always -ExecStart=/usr/bin/python3 "%PREFIX_DIRECTORY%/bin/fw-fanctrl" run --config "%SYSCONF_DIRECTORY%/fw-fanctrl/config.json" --silent %NO_BATTERY_SENSOR_OPTION% +ExecStart=/usr/bin/python3 "%PREFIX_DIRECTORY%/bin/fw-fanctrl" --output-format "JSON" run --config "%SYSCONF_DIRECTORY%/fw-fanctrl/config.json" --silent %NO_BATTERY_SENSOR_OPTION% ExecStopPost=/bin/sh -c "ectool autofanctrl" [Install] WantedBy=multi-user.target diff --git a/src/fw_fanctrl/CommandParser.py b/src/fw_fanctrl/CommandParser.py new file mode 100644 index 0000000..41cbc79 --- /dev/null +++ b/src/fw_fanctrl/CommandParser.py @@ -0,0 +1,204 @@ +import argparse +import os +import sys +import textwrap + +from fw_fanctrl import DEFAULT_CONFIGURATION_FILE_PATH +from fw_fanctrl.enum.OutputFormat import OutputFormat +from fw_fanctrl.exception.UnknownCommandException import UnknownCommandException + + +class CommandParser: + is_remote = True + + legacy_parser = None + parser = None + + def __init__(self, is_remote=False): + self.is_remote = is_remote + self.init_parser() + self.init_legacy_parser() + + def init_parser(self): + self.parser = argparse.ArgumentParser( + prog="fw-fanctrl", + description="control Framework's laptop fan(s) with a speed curve", + epilog=textwrap.dedent( + "obtain more help about a command or subcommand using `fw-fanctrl [subcommand...] -h/--help`" + ), + formatter_class=argparse.RawTextHelpFormatter, + ) + self.parser.add_argument( + "--socket-controller", + "--sc", + help="the socket controller to use for communication between the cli and the service", + type=str, + choices=["unix"], + default="unix", + ) + self.parser.add_argument( + "--output-format", + help="the output format to use for the command result", + type=lambda s: (lambda: OutputFormat[s])() if hasattr(OutputFormat, s) else s, + choices=list(OutputFormat._member_names_), + default=OutputFormat.NATURAL, + ) + + commands_sub_parser = self.parser.add_subparsers(dest="command") + commands_sub_parser.required = True + + if not self.is_remote: + run_command = commands_sub_parser.add_parser( + "run", + description="run the service", + formatter_class=argparse.RawTextHelpFormatter, + ) + run_command.add_argument( + "strategy", + help='name of the strategy to use e.g: "lazy" (use `print strategies` to list available strategies)', + nargs=argparse.OPTIONAL, + ) + run_command.add_argument( + "--config", + "-c", + help=f"the configuration file path (default: {DEFAULT_CONFIGURATION_FILE_PATH})", + type=str, + default=DEFAULT_CONFIGURATION_FILE_PATH, + ) + run_command.add_argument( + "--silent", + "-s", + help="disable printing speed/temp status to stdout", + action="store_true", + ) + run_command.add_argument( + "--hardware-controller", + "--hc", + help="the hardware controller to use for fetching and setting the temp and fan(s) speed", + type=str, + choices=["ectool"], + default="ectool", + ) + run_command.add_argument( + "--no-battery-sensors", + help="disable checking battery temperature sensors", + action="store_true", + ) + + use_command = commands_sub_parser.add_parser("use", description="change the current strategy") + use_command.add_argument( + "strategy", + help='name of the strategy to use e.g: "lazy". (use `print strategies` to list available strategies)', + ) + + commands_sub_parser.add_parser("reset", description="reset to the default strategy") + commands_sub_parser.add_parser("reload", description="reload the configuration file") + commands_sub_parser.add_parser("pause", description="pause the service") + commands_sub_parser.add_parser("resume", description="resume the service") + + print_command = commands_sub_parser.add_parser( + "print", + description="print the selected information", + formatter_class=argparse.RawTextHelpFormatter, + ) + print_command.add_argument( + "print_selection", + help=f"all - All details{os.linesep}current - The current strategy{os.linesep}list - List available strategies{os.linesep}speed - The current fan speed percentage{os.linesep}active - The service activity status", + nargs="?", + type=str, + choices=["all", "active", "current", "list", "speed"], + default="all", + ) + + def init_legacy_parser(self): + self.legacy_parser = argparse.ArgumentParser(add_help=False) + + # avoid collision with the new parser commands + def excluded_positional_arguments(value): + if value in [ + "run", + "use", + "reload", + "reset", + "pause", + "resume", + "print", + ]: + raise argparse.ArgumentTypeError("%s is an excluded value" % value) + return value + + both_group = self.legacy_parser.add_argument_group("both") + both_group.add_argument("_strategy", nargs="?", type=excluded_positional_arguments) + both_group.add_argument("--strategy", nargs="?") + + run_group = self.legacy_parser.add_argument_group("run") + run_group.add_argument("--run", action="store_true") + run_group.add_argument("--config", type=str, default=DEFAULT_CONFIGURATION_FILE_PATH) + run_group.add_argument("--no-log", action="store_true") + command_group = self.legacy_parser.add_argument_group("configure") + command_group.add_argument("--query", "-q", action="store_true") + command_group.add_argument("--list-strategies", action="store_true") + command_group.add_argument("--reload", "-r", action="store_true") + command_group.add_argument("--pause", action="store_true") + command_group.add_argument("--resume", action="store_true") + command_group.add_argument( + "--hardware-controller", + "--hc", + type=str, + choices=["ectool"], + default="ectool", + ) + command_group.add_argument( + "--socket-controller", + "--sc", + type=str, + choices=["unix"], + default="unix", + ) + + def parse_args(self, args=None): + original_stderr = sys.stderr + # silencing legacy parser output + sys.stderr = open(os.devnull, "w") + try: + legacy_values = self.legacy_parser.parse_args(args) + if legacy_values.strategy is None: + legacy_values.strategy = legacy_values._strategy + # converting legacy values into new ones + values = argparse.Namespace() + values.socket_controller = legacy_values.socket_controller + values.output_format = OutputFormat.NATURAL + if legacy_values.query: + values.command = "print" + values.print_selection = "current" + if legacy_values.list_strategies: + values.command = "print" + values.print_selection = "list" + if legacy_values.resume: + values.command = "resume" + if legacy_values.pause: + values.command = "pause" + if legacy_values.reload: + values.command = "reload" + if legacy_values.run: + values.command = "run" + values.silent = legacy_values.no_log + values.hardware_controller = legacy_values.hardware_controller + values.config = legacy_values.config + values.strategy = legacy_values.strategy + if not hasattr(values, "command") and legacy_values.strategy is not None: + values.command = "use" + values.strategy = legacy_values.strategy + if not hasattr(values, "command"): + raise UnknownCommandException("not a valid legacy command") + if self.is_remote or values.command == "run": + # Legacy commands do not support other formats than NATURAL, so there is no need to use a CommandResult. + print( + "[Warning] > this command is deprecated and will be removed soon, please use the new command format instead ('fw-fanctrl -h' for more details)." + ) + except (SystemExit, Exception): + sys.stderr = original_stderr + values = self.parser.parse_args(args) + finally: + sys.stderr = original_stderr + return values diff --git a/src/fw_fanctrl/Configuration.py b/src/fw_fanctrl/Configuration.py new file mode 100644 index 0000000..3149da1 --- /dev/null +++ b/src/fw_fanctrl/Configuration.py @@ -0,0 +1,41 @@ +import json + +from fw_fanctrl.Strategy import Strategy +from fw_fanctrl.exception.InvalidStrategyException import InvalidStrategyException + + +class Configuration: + path = None + data = None + + def __init__(self, path): + self.path = path + self.reload() + + def reload(self): + with open(self.path, "r") as fp: + try: + self.data = json.load(fp) + except json.JSONDecodeError: + return False + return True + + def get_strategies(self): + return self.data["strategies"].keys() + + def get_strategy(self, strategy_name): + if strategy_name == "strategyOnDischarging": + strategy_name = self.data[strategy_name] + if strategy_name == "": + strategy_name = "defaultStrategy" + if strategy_name == "defaultStrategy": + strategy_name = self.data[strategy_name] + if strategy_name is None or strategy_name not in self.data["strategies"]: + raise InvalidStrategyException(strategy_name) + return Strategy(strategy_name, self.data["strategies"][strategy_name]) + + def get_default_strategy(self): + return self.get_strategy("defaultStrategy") + + def get_discharging_strategy(self): + return self.get_strategy("strategyOnDischarging") diff --git a/src/fw_fanctrl/FanController.py b/src/fw_fanctrl/FanController.py new file mode 100644 index 0000000..ee12f47 --- /dev/null +++ b/src/fw_fanctrl/FanController.py @@ -0,0 +1,189 @@ +import collections +import sys +import threading +from time import sleep + +from fw_fanctrl.Configuration import Configuration +from fw_fanctrl.dto.command_result.ConfigurationReloadCommandResult import ConfigurationReloadCommandResult +from fw_fanctrl.dto.command_result.PrintActiveCommandResult import PrintActiveCommandResult +from fw_fanctrl.dto.command_result.PrintCurrentStrategyCommandResult import PrintCurrentStrategyCommandResult +from fw_fanctrl.dto.command_result.PrintFanSpeedCommandResult import PrintFanSpeedCommandResult +from fw_fanctrl.dto.command_result.PrintStrategyListCommandResult import PrintStrategyListCommandResult +from fw_fanctrl.dto.command_result.ServicePauseCommandResult import ServicePauseCommandResult +from fw_fanctrl.dto.command_result.ServiceResumeCommandResult import ServiceResumeCommandResult +from fw_fanctrl.dto.command_result.StrategyChangeCommandResult import StrategyChangeCommandResult +from fw_fanctrl.dto.command_result.StrategyResetCommandResult import StrategyResetCommandResult +from fw_fanctrl.dto.runtime_result.RuntimeResult import RuntimeResult +from fw_fanctrl.dto.runtime_result.StatusRuntimeResult import StatusRuntimeResult +from fw_fanctrl.enum.CommandStatus import CommandStatus +from fw_fanctrl.exception.InvalidStrategyException import InvalidStrategyException +from fw_fanctrl.exception.JSONException import JSONException +from fw_fanctrl.exception.UnknownCommandException import UnknownCommandException + + +class FanController: + hardware_controller = None + socket_controller = None + configuration = None + overwritten_strategy = None + output_format = None + speed = 0 + temp_history = collections.deque([0] * 100, maxlen=100) + active = True + timecount = 0 + + def __init__(self, hardware_controller, socket_controller, config_path, strategy_name, output_format): + self.hardware_controller = hardware_controller + self.socket_controller = socket_controller + self.configuration = Configuration(config_path) + + if strategy_name is not None and strategy_name != "": + self.overwrite_strategy(strategy_name) + + self.output_format = output_format + + t = threading.Thread( + target=self.socket_controller.start_server_socket, + args=[self.command_manager], + ) + t.daemon = True + t.start() + + def get_actual_temperature(self): + return self.hardware_controller.get_temperature() + + def set_speed(self, speed): + self.speed = speed + self.hardware_controller.set_speed(speed) + + def is_on_ac(self): + return self.hardware_controller.is_on_ac() + + def pause(self): + self.active = False + self.hardware_controller.pause() + + def resume(self): + self.active = True + self.hardware_controller.resume() + + def overwrite_strategy(self, strategy_name): + self.overwritten_strategy = self.configuration.get_strategy(strategy_name) + self.timecount = 0 + + def clear_overwritten_strategy(self): + self.overwritten_strategy = None + self.timecount = 0 + + def get_current_strategy(self): + if self.overwritten_strategy is not None: + return self.overwritten_strategy + if self.is_on_ac(): + return self.configuration.get_default_strategy() + return self.configuration.get_discharging_strategy() + + def command_manager(self, args): + if args.command == "reset" or (args.command == "use" and args.strategy == "defaultStrategy"): + self.clear_overwritten_strategy() + return StrategyResetCommandResult(self.get_current_strategy().name) + elif args.command == "use": + try: + self.overwrite_strategy(args.strategy) + return StrategyChangeCommandResult(self.get_current_strategy().name) + except InvalidStrategyException: + raise InvalidStrategyException(f"The specified strategy is invalid: {args.strategy}") + elif args.command == "reload": + if self.configuration.reload(): + if self.overwritten_strategy is not None: + self.overwrite_strategy(self.overwritten_strategy.name) + else: + raise JSONException("Config file could not be parsed due to JSON Error") + return ConfigurationReloadCommandResult(self.get_current_strategy().name) + elif args.command == "pause": + self.pause() + return ServicePauseCommandResult() + elif args.command == "resume": + self.resume() + return ServiceResumeCommandResult(self.get_current_strategy().name) + elif args.command == "print": + if args.print_selection == "all": + return self.dump_details() + elif args.print_selection == "active": + return PrintActiveCommandResult(self.active) + elif args.print_selection == "current": + return PrintCurrentStrategyCommandResult(self.get_current_strategy().name) + elif args.print_selection == "list": + return PrintStrategyListCommandResult(list(self.configuration.get_strategies())) + elif args.print_selection == "speed": + return PrintFanSpeedCommandResult(str(self.speed)) + raise UnknownCommandException(f"Unknown command: '{args.command}', unexpected.") + + # return mean temperature over a given time interval (in seconds) + def get_moving_average_temperature(self, time_interval): + sliced_temp_history = [x for x in self.temp_history if x > 0][-time_interval:] + if len(sliced_temp_history) == 0: + return self.get_actual_temperature() + return round(sum(sliced_temp_history) / len(sliced_temp_history), 1) + + def get_effective_temperature(self, current_temp, time_interval): + # the moving average temperature count for 2/3 of the effective temperature + return round(min(self.get_moving_average_temperature(time_interval), current_temp), 1) + + def adapt_speed(self, current_temp): + current_strategy = self.get_current_strategy() + current_temp = self.get_effective_temperature(current_temp, current_strategy.moving_average_interval) + min_point = current_strategy.speed_curve[0] + max_point = current_strategy.speed_curve[-1] + for e in current_strategy.speed_curve: + if current_temp > e["temp"]: + min_point = e + else: + max_point = e + break + + if min_point == max_point: + new_speed = min_point["speed"] + else: + slope = (max_point["speed"] - min_point["speed"]) / (max_point["temp"] - min_point["temp"]) + new_speed = int(min_point["speed"] + (current_temp - min_point["temp"]) * slope) + if self.active: + self.set_speed(new_speed) + + def dump_details(self): + current_strategy = self.get_current_strategy() + current_temperature = self.get_actual_temperature() + moving_average_temp = self.get_moving_average_temperature(current_strategy.moving_average_interval) + effective_temp = self.get_effective_temperature(current_temperature, current_strategy.moving_average_interval) + + return StatusRuntimeResult( + current_strategy.name, self.speed, current_temperature, moving_average_temp, effective_temp, self.active + ) + + def print_state(self): + print(self.dump_details().to_output_format(self.output_format)) + + def run(self, debug=True): + try: + while True: + if self.active: + temp = self.get_actual_temperature() + # update fan speed every "fanSpeedUpdateFrequency" seconds + if self.timecount % self.get_current_strategy().fan_speed_update_frequency == 0: + self.adapt_speed(temp) + self.timecount = 0 + + self.temp_history.append(temp) + + if debug: + self.print_state() + self.timecount += 1 + sleep(1) + else: + sleep(5) + except InvalidStrategyException as e: + _rte = RuntimeResult(CommandStatus.ERROR, f"Missing strategy, exiting for safety reasons: {e.args[0]}") + print(_rte.to_output_format(self.output_format), file=sys.stderr) + except Exception as e: + _rte = RuntimeResult(CommandStatus.ERROR, f"Critical error, exiting for safety reasons: {e}") + print(_rte.to_output_format(self.output_format), file=sys.stderr) + exit(1) diff --git a/src/fw_fanctrl/Strategy.py b/src/fw_fanctrl/Strategy.py new file mode 100644 index 0000000..db2abfe --- /dev/null +++ b/src/fw_fanctrl/Strategy.py @@ -0,0 +1,15 @@ +class Strategy: + name = None + fan_speed_update_frequency = None + moving_average_interval = None + speed_curve = None + + def __init__(self, name, parameters): + self.name = name + self.fan_speed_update_frequency = parameters["fanSpeedUpdateFrequency"] + if self.fan_speed_update_frequency is None or self.fan_speed_update_frequency == "": + self.fan_speed_update_frequency = 5 + self.moving_average_interval = parameters["movingAverageInterval"] + if self.moving_average_interval is None or self.moving_average_interval == "": + self.moving_average_interval = 20 + self.speed_curve = parameters["speedCurve"] diff --git a/src/fw_fanctrl/__init__.py b/src/fw_fanctrl/__init__.py new file mode 100644 index 0000000..9c4d719 --- /dev/null +++ b/src/fw_fanctrl/__init__.py @@ -0,0 +1,5 @@ +import os + +DEFAULT_CONFIGURATION_FILE_PATH = "/etc/fw-fanctrl/config.json" +SOCKETS_FOLDER_PATH = "/run/fw-fanctrl" +COMMANDS_SOCKET_FILE_PATH = os.path.join(SOCKETS_FOLDER_PATH, ".fw-fanctrl.commands.sock") diff --git a/src/fw_fanctrl/__main__.py b/src/fw_fanctrl/__main__.py new file mode 100644 index 0000000..69995c4 --- /dev/null +++ b/src/fw_fanctrl/__main__.py @@ -0,0 +1,52 @@ +import sys + +from fw_fanctrl.CommandParser import CommandParser +from fw_fanctrl.FanController import FanController +from fw_fanctrl.dto.command_result.CommandResult import CommandResult +from fw_fanctrl.enum.CommandStatus import CommandStatus +from fw_fanctrl.enum.OutputFormat import OutputFormat +from fw_fanctrl.hardwareController.EctoolHardwareController import EctoolHardwareController +from fw_fanctrl.socketController.UnixSocketController import UnixSocketController + + +def main(): + try: + args = CommandParser().parse_args() + except Exception as e: + _cre = CommandResult(CommandStatus.ERROR, str(e)) + print(_cre.to_output_format(OutputFormat.NATURAL), file=sys.stderr) + exit(1) + + socket_controller = UnixSocketController() + if args.socket_controller == "unix": + socket_controller = UnixSocketController() + + if args.command == "run": + hardware_controller = EctoolHardwareController(no_battery_sensor_mode=args.no_battery_sensors) + if args.hardware_controller == "ectool": + hardware_controller = EctoolHardwareController(no_battery_sensor_mode=args.no_battery_sensors) + + fan = FanController( + hardware_controller=hardware_controller, + socket_controller=socket_controller, + config_path=args.config, + strategy_name=args.strategy, + output_format=getattr(args, "output_format", None), + ) + fan.run(debug=not args.silent) + else: + try: + command_result = socket_controller.send_via_client_socket(" ".join(sys.argv[1:])) + if command_result: + print(command_result) + except Exception as e: + if str(e).startswith("[Error] >"): + print(str(e), file=sys.stderr) + else: + _cre = CommandResult(CommandStatus.ERROR, str(e)) + print(_cre.to_output_format(getattr(args, "output_format", None)), file=sys.stderr) + exit(1) + + +if __name__ == "__main__": + main() diff --git a/src/fw_fanctrl/dto/Printable.py b/src/fw_fanctrl/dto/Printable.py new file mode 100644 index 0000000..a47c3e9 --- /dev/null +++ b/src/fw_fanctrl/dto/Printable.py @@ -0,0 +1,14 @@ +import json + +from fw_fanctrl.enum.OutputFormat import OutputFormat + + +class Printable: + def __init__(self): + super().__init__() + + def to_output_format(self, output_format): + if output_format == OutputFormat.JSON: + return json.dumps(self.__dict__) + else: + return str(self) diff --git a/src/fw_fanctrl/dto/__init__.py b/src/fw_fanctrl/dto/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/fw_fanctrl/dto/command_result/CommandResult.py b/src/fw_fanctrl/dto/command_result/CommandResult.py new file mode 100644 index 0000000..da42830 --- /dev/null +++ b/src/fw_fanctrl/dto/command_result/CommandResult.py @@ -0,0 +1,13 @@ +from fw_fanctrl.dto.Printable import Printable +from fw_fanctrl.enum.CommandStatus import CommandStatus + + +class CommandResult(Printable): + def __init__(self, status, reason="Unexpected"): + super().__init__() + self.status = status + if status == CommandStatus.ERROR: + self.reason = reason + + def __str__(self): + return "Success!" if self.status == CommandStatus.SUCCESS else f"[Error] > An error occurred: {self.reason}" diff --git a/src/fw_fanctrl/dto/command_result/ConfigurationReloadCommandResult.py b/src/fw_fanctrl/dto/command_result/ConfigurationReloadCommandResult.py new file mode 100644 index 0000000..f2f044a --- /dev/null +++ b/src/fw_fanctrl/dto/command_result/ConfigurationReloadCommandResult.py @@ -0,0 +1,11 @@ +from fw_fanctrl.dto.command_result.CommandResult import CommandResult +from fw_fanctrl.enum.CommandStatus import CommandStatus + + +class ConfigurationReloadCommandResult(CommandResult): + def __init__(self, strategy): + super().__init__(CommandStatus.SUCCESS) + self.strategy = strategy + + def __str__(self): + return f"Reloaded with success! Strategy in use: '{self.strategy}'" diff --git a/src/fw_fanctrl/dto/command_result/PrintActiveCommandResult.py b/src/fw_fanctrl/dto/command_result/PrintActiveCommandResult.py new file mode 100644 index 0000000..f1b853e --- /dev/null +++ b/src/fw_fanctrl/dto/command_result/PrintActiveCommandResult.py @@ -0,0 +1,11 @@ +from fw_fanctrl.dto.command_result.CommandResult import CommandResult +from fw_fanctrl.enum.CommandStatus import CommandStatus + + +class PrintActiveCommandResult(CommandResult): + def __init__(self, active): + super().__init__(CommandStatus.SUCCESS) + self.active = active + + def __str__(self): + return f"Active: {self.active}" diff --git a/src/fw_fanctrl/dto/command_result/PrintCurrentStrategyCommandResult.py b/src/fw_fanctrl/dto/command_result/PrintCurrentStrategyCommandResult.py new file mode 100644 index 0000000..9296d03 --- /dev/null +++ b/src/fw_fanctrl/dto/command_result/PrintCurrentStrategyCommandResult.py @@ -0,0 +1,11 @@ +from fw_fanctrl.dto.command_result.CommandResult import CommandResult +from fw_fanctrl.enum.CommandStatus import CommandStatus + + +class PrintCurrentStrategyCommandResult(CommandResult): + def __init__(self, strategy): + super().__init__(CommandStatus.SUCCESS) + self.strategy = strategy + + def __str__(self): + return f"Strategy in use: '{self.strategy}'" diff --git a/src/fw_fanctrl/dto/command_result/PrintFanSpeedCommandResult.py b/src/fw_fanctrl/dto/command_result/PrintFanSpeedCommandResult.py new file mode 100644 index 0000000..3fc1967 --- /dev/null +++ b/src/fw_fanctrl/dto/command_result/PrintFanSpeedCommandResult.py @@ -0,0 +1,11 @@ +from fw_fanctrl.dto.command_result.CommandResult import CommandResult +from fw_fanctrl.enum.CommandStatus import CommandStatus + + +class PrintFanSpeedCommandResult(CommandResult): + def __init__(self, speed): + super().__init__(CommandStatus.SUCCESS) + self.speed = speed + + def __str__(self): + return f"Current fan speed: '{self.speed}%'" diff --git a/src/fw_fanctrl/dto/command_result/PrintStrategyListCommandResult.py b/src/fw_fanctrl/dto/command_result/PrintStrategyListCommandResult.py new file mode 100644 index 0000000..d7f5be5 --- /dev/null +++ b/src/fw_fanctrl/dto/command_result/PrintStrategyListCommandResult.py @@ -0,0 +1,14 @@ +import os + +from fw_fanctrl.dto.command_result.CommandResult import CommandResult +from fw_fanctrl.enum.CommandStatus import CommandStatus + + +class PrintStrategyListCommandResult(CommandResult): + def __init__(self, strategies): + super().__init__(CommandStatus.SUCCESS) + self.strategies = strategies + + def __str__(self): + printable_list = f"{os.linesep}- ".join(self.strategies) + return f"Strategy list: {os.linesep}- {printable_list}" diff --git a/src/fw_fanctrl/dto/command_result/ServicePauseCommandResult.py b/src/fw_fanctrl/dto/command_result/ServicePauseCommandResult.py new file mode 100644 index 0000000..0811267 --- /dev/null +++ b/src/fw_fanctrl/dto/command_result/ServicePauseCommandResult.py @@ -0,0 +1,10 @@ +from fw_fanctrl.dto.command_result.CommandResult import CommandResult +from fw_fanctrl.enum.CommandStatus import CommandStatus + + +class ServicePauseCommandResult(CommandResult): + def __init__(self): + super().__init__(CommandStatus.SUCCESS) + + def __str__(self): + return "Service paused! The hardware fan control will take over" diff --git a/src/fw_fanctrl/dto/command_result/ServiceResumeCommandResult.py b/src/fw_fanctrl/dto/command_result/ServiceResumeCommandResult.py new file mode 100644 index 0000000..d5d170a --- /dev/null +++ b/src/fw_fanctrl/dto/command_result/ServiceResumeCommandResult.py @@ -0,0 +1,11 @@ +from fw_fanctrl.dto.command_result.CommandResult import CommandResult +from fw_fanctrl.enum.CommandStatus import CommandStatus + + +class ServiceResumeCommandResult(CommandResult): + def __init__(self, strategy): + super().__init__(CommandStatus.SUCCESS) + self.strategy = strategy + + def __str__(self): + return f"Service resumed! Strategy in use: '{self.strategy}'" diff --git a/src/fw_fanctrl/dto/command_result/StrategyChangeCommandResult.py b/src/fw_fanctrl/dto/command_result/StrategyChangeCommandResult.py new file mode 100644 index 0000000..32fbc0c --- /dev/null +++ b/src/fw_fanctrl/dto/command_result/StrategyChangeCommandResult.py @@ -0,0 +1,11 @@ +from fw_fanctrl.dto.command_result.CommandResult import CommandResult +from fw_fanctrl.enum.CommandStatus import CommandStatus + + +class StrategyChangeCommandResult(CommandResult): + def __init__(self, strategy): + super().__init__(CommandStatus.SUCCESS) + self.strategy = strategy + + def __str__(self): + return f"Strategy in use: '{self.strategy}'" diff --git a/src/fw_fanctrl/dto/command_result/StrategyResetCommandResult.py b/src/fw_fanctrl/dto/command_result/StrategyResetCommandResult.py new file mode 100644 index 0000000..7c48793 --- /dev/null +++ b/src/fw_fanctrl/dto/command_result/StrategyResetCommandResult.py @@ -0,0 +1,11 @@ +from fw_fanctrl.dto.command_result.CommandResult import CommandResult +from fw_fanctrl.enum.CommandStatus import CommandStatus + + +class StrategyResetCommandResult(CommandResult): + def __init__(self, strategy): + super().__init__(CommandStatus.SUCCESS) + self.strategy = strategy + + def __str__(self): + return f"Strategy reset to default! Strategy in use: '{self.strategy}'" diff --git a/src/fw_fanctrl/dto/command_result/__init__.py b/src/fw_fanctrl/dto/command_result/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/fw_fanctrl/dto/runtime_result/RuntimeResult.py b/src/fw_fanctrl/dto/runtime_result/RuntimeResult.py new file mode 100644 index 0000000..0982ae2 --- /dev/null +++ b/src/fw_fanctrl/dto/runtime_result/RuntimeResult.py @@ -0,0 +1,13 @@ +from fw_fanctrl.dto.Printable import Printable +from fw_fanctrl.enum.CommandStatus import CommandStatus + + +class RuntimeResult(Printable): + def __init__(self, status, reason="Unexpected"): + super().__init__() + self.status = status + if status == CommandStatus.ERROR: + self.reason = reason + + def __str__(self): + return "Success!" if self.status == CommandStatus.SUCCESS else f"[Error] > An error occurred: {self.reason}" diff --git a/src/fw_fanctrl/dto/runtime_result/StatusRuntimeResult.py b/src/fw_fanctrl/dto/runtime_result/StatusRuntimeResult.py new file mode 100644 index 0000000..d0ac043 --- /dev/null +++ b/src/fw_fanctrl/dto/runtime_result/StatusRuntimeResult.py @@ -0,0 +1,18 @@ +import os + +from fw_fanctrl.dto.runtime_result.RuntimeResult import RuntimeResult +from fw_fanctrl.enum.CommandStatus import CommandStatus + + +class StatusRuntimeResult(RuntimeResult): + def __init__(self, strategy, speed, temperature, moving_average_temperature, effective_temperature, active): + super().__init__(CommandStatus.SUCCESS) + self.strategy = strategy + self.speed = speed + self.temperature = temperature + self.movingAverageTemperature = moving_average_temperature + self.effectiveTemperature = effective_temperature + self.active = active + + def __str__(self): + return f"Strategy: '{self.strategy}'{os.linesep}Speed: {self.speed}%{os.linesep}Temp: {self.temperature}°C{os.linesep}MovingAverageTemp: {self.movingAverageTemperature}°C{os.linesep}EffectiveTemp: {self.effectiveTemperature}°C{os.linesep}Active: {self.active}" diff --git a/src/fw_fanctrl/dto/runtime_result/__init__.py b/src/fw_fanctrl/dto/runtime_result/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/fw_fanctrl/enum/CommandStatus.py b/src/fw_fanctrl/enum/CommandStatus.py new file mode 100644 index 0000000..a5acb4f --- /dev/null +++ b/src/fw_fanctrl/enum/CommandStatus.py @@ -0,0 +1,6 @@ +from enum import Enum + + +class CommandStatus(str, Enum): + SUCCESS = "success" + ERROR = "error" diff --git a/src/fw_fanctrl/enum/OutputFormat.py b/src/fw_fanctrl/enum/OutputFormat.py new file mode 100644 index 0000000..c88384b --- /dev/null +++ b/src/fw_fanctrl/enum/OutputFormat.py @@ -0,0 +1,6 @@ +from enum import Enum + + +class OutputFormat(str, Enum): + NATURAL = "NATURAL" + JSON = "JSON" diff --git a/src/fw_fanctrl/enum/__init__.py b/src/fw_fanctrl/enum/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/fw_fanctrl/exception/InvalidStrategyException.py b/src/fw_fanctrl/exception/InvalidStrategyException.py new file mode 100644 index 0000000..a147929 --- /dev/null +++ b/src/fw_fanctrl/exception/InvalidStrategyException.py @@ -0,0 +1,2 @@ +class InvalidStrategyException(Exception): + pass diff --git a/src/fw_fanctrl/exception/JSONException.py b/src/fw_fanctrl/exception/JSONException.py new file mode 100644 index 0000000..bf6dd06 --- /dev/null +++ b/src/fw_fanctrl/exception/JSONException.py @@ -0,0 +1,2 @@ +class JSONException(Exception): + pass diff --git a/src/fw_fanctrl/exception/SocketAlreadyRunningException.py b/src/fw_fanctrl/exception/SocketAlreadyRunningException.py new file mode 100644 index 0000000..bc2e0c8 --- /dev/null +++ b/src/fw_fanctrl/exception/SocketAlreadyRunningException.py @@ -0,0 +1,2 @@ +class SocketAlreadyRunningException(Exception): + pass diff --git a/src/fw_fanctrl/exception/SocketCallException.py b/src/fw_fanctrl/exception/SocketCallException.py new file mode 100644 index 0000000..dd8a28c --- /dev/null +++ b/src/fw_fanctrl/exception/SocketCallException.py @@ -0,0 +1,2 @@ +class SocketCallException(Exception): + pass diff --git a/src/fw_fanctrl/exception/UnimplementedException.py b/src/fw_fanctrl/exception/UnimplementedException.py new file mode 100644 index 0000000..9e621db --- /dev/null +++ b/src/fw_fanctrl/exception/UnimplementedException.py @@ -0,0 +1,2 @@ +class UnimplementedException(Exception): + pass diff --git a/src/fw_fanctrl/exception/UnknownCommandException.py b/src/fw_fanctrl/exception/UnknownCommandException.py new file mode 100644 index 0000000..5398a60 --- /dev/null +++ b/src/fw_fanctrl/exception/UnknownCommandException.py @@ -0,0 +1,2 @@ +class UnknownCommandException(Exception): + pass diff --git a/src/fw_fanctrl/exception/__init__.py b/src/fw_fanctrl/exception/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/fw_fanctrl/hardwareController/EctoolHardwareController.py b/src/fw_fanctrl/hardwareController/EctoolHardwareController.py new file mode 100644 index 0000000..436ed2a --- /dev/null +++ b/src/fw_fanctrl/hardwareController/EctoolHardwareController.py @@ -0,0 +1,76 @@ +import re +import subprocess +from abc import ABC + +from fw_fanctrl.hardwareController.HardwareController import HardwareController + + +class EctoolHardwareController(HardwareController, ABC): + noBatterySensorMode = False + nonBatterySensors = None + + def __init__(self, no_battery_sensor_mode=False): + if no_battery_sensor_mode: + self.noBatterySensorMode = True + self.populate_non_battery_sensors() + + def populate_non_battery_sensors(self): + self.nonBatterySensors = [] + raw_out = subprocess.run( + "ectool tempsinfo all", + stdout=subprocess.PIPE, + shell=True, + text=True, + ).stdout + battery_sensors_raw = re.findall(r"\d+ Battery", raw_out, re.MULTILINE) + battery_sensors = [x.split(" ")[0] for x in battery_sensors_raw] + for x in re.findall(r"^\d+", raw_out, re.MULTILINE): + if x not in battery_sensors: + self.nonBatterySensors.append(x) + + def get_temperature(self): + if self.noBatterySensorMode: + raw_out = "".join( + [ + subprocess.run( + "ectool temps " + x, + stdout=subprocess.PIPE, + shell=True, + text=True, + ).stdout + for x in self.nonBatterySensors + ] + ) + else: + raw_out = subprocess.run( + "ectool temps all", + stdout=subprocess.PIPE, + shell=True, + text=True, + ).stdout + raw_temps = re.findall(r"\(= (\d+) C\)", raw_out) + temps = sorted([x for x in [int(x) for x in raw_temps] if x > 0], reverse=True) + # safety fallback to avoid damaging hardware + if len(temps) == 0: + return 50 + return round(temps[0], 1) + + def set_speed(self, speed): + subprocess.run(f"ectool fanduty {speed}", stdout=subprocess.PIPE, shell=True) + + def is_on_ac(self): + raw_out = subprocess.run( + "ectool battery", + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + shell=True, + text=True, + ).stdout + return len(re.findall(r"Flags.*(AC_PRESENT)", raw_out)) > 0 + + def pause(self): + subprocess.run("ectool autofanctrl", stdout=subprocess.PIPE, shell=True) + + def resume(self): + # Empty for ectool, as setting an arbitrary speed disables the automatic fan control + pass diff --git a/src/fw_fanctrl/hardwareController/HardwareController.py b/src/fw_fanctrl/hardwareController/HardwareController.py new file mode 100644 index 0000000..1eb57c2 --- /dev/null +++ b/src/fw_fanctrl/hardwareController/HardwareController.py @@ -0,0 +1,25 @@ +from abc import ABC, abstractmethod + +from fw_fanctrl.exception.UnimplementedException import UnimplementedException + + +class HardwareController(ABC): + @abstractmethod + def get_temperature(self): + raise UnimplementedException() + + @abstractmethod + def set_speed(self, speed): + raise UnimplementedException() + + @abstractmethod + def pause(self): + pass + + @abstractmethod + def resume(self): + pass + + @abstractmethod + def is_on_ac(self): + raise UnimplementedException() diff --git a/src/fw_fanctrl/hardwareController/__init__.py b/src/fw_fanctrl/hardwareController/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/fw_fanctrl/socketController/SocketController.py b/src/fw_fanctrl/socketController/SocketController.py new file mode 100644 index 0000000..a317040 --- /dev/null +++ b/src/fw_fanctrl/socketController/SocketController.py @@ -0,0 +1,21 @@ +from abc import ABC, abstractmethod + +from fw_fanctrl.exception.UnimplementedException import UnimplementedException + + +class SocketController(ABC): + @abstractmethod + def start_server_socket(self, command_callback=None): + raise UnimplementedException() + + @abstractmethod + def stop_server_socket(self): + raise UnimplementedException() + + @abstractmethod + def is_server_socket_running(self): + raise UnimplementedException() + + @abstractmethod + def send_via_client_socket(self, command): + raise UnimplementedException() diff --git a/src/fw_fanctrl/socketController/UnixSocketController.py b/src/fw_fanctrl/socketController/UnixSocketController.py new file mode 100644 index 0000000..5c6974a --- /dev/null +++ b/src/fw_fanctrl/socketController/UnixSocketController.py @@ -0,0 +1,102 @@ +import io +import os +import shlex +import socket +import sys +from abc import ABC + +from fw_fanctrl import COMMANDS_SOCKET_FILE_PATH, SOCKETS_FOLDER_PATH +from fw_fanctrl.CommandParser import CommandParser +from fw_fanctrl.dto.command_result.CommandResult import CommandResult +from fw_fanctrl.enum.CommandStatus import CommandStatus +from fw_fanctrl.enum.OutputFormat import OutputFormat +from fw_fanctrl.exception.SocketAlreadyRunningException import SocketAlreadyRunningException +from fw_fanctrl.exception.SocketCallException import SocketCallException +from fw_fanctrl.socketController.SocketController import SocketController + + +class UnixSocketController(SocketController, ABC): + server_socket = None + + def start_server_socket(self, command_callback=None): + if self.server_socket: + raise SocketAlreadyRunningException(self.server_socket) + self.server_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + if os.path.exists(COMMANDS_SOCKET_FILE_PATH): + os.remove(COMMANDS_SOCKET_FILE_PATH) + try: + if not os.path.exists(SOCKETS_FOLDER_PATH): + os.makedirs(SOCKETS_FOLDER_PATH) + self.server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self.server_socket.bind(COMMANDS_SOCKET_FILE_PATH) + os.chmod(COMMANDS_SOCKET_FILE_PATH, 0o777) + self.server_socket.listen(1) + while True: + client_socket, _ = self.server_socket.accept() + parse_print_capture = io.StringIO() + args = None + try: + # Receive data from the client + data = client_socket.recv(4096).decode() + original_stderr = sys.stderr + original_stdout = sys.stdout + # capture parsing std outputs for the client + sys.stderr = parse_print_capture + sys.stdout = parse_print_capture + + try: + args = CommandParser(True).parse_args(shlex.split(data)) + finally: + sys.stderr = original_stderr + sys.stdout = original_stdout + + command_result = command_callback(args) + + if args.output_format == OutputFormat.JSON: + if parse_print_capture.getvalue().strip(): + command_result.info = parse_print_capture.getvalue() + client_socket.sendall(command_result.to_output_format(args.output_format).encode("utf-8")) + else: + natural_result = command_result.to_output_format(args.output_format) + if parse_print_capture.getvalue().strip(): + natural_result = parse_print_capture.getvalue() + natural_result + client_socket.sendall(natural_result.encode("utf-8")) + except (SystemExit, Exception) as e: + _cre = CommandResult( + CommandStatus.ERROR, f"An error occurred while treating a socket command: {e}" + ).to_output_format(getattr(args, "output_format", None)) + print(_cre, file=sys.stderr) + client_socket.sendall(_cre.encode("utf-8")) + finally: + client_socket.shutdown(socket.SHUT_WR) + client_socket.close() + finally: + self.stop_server_socket() + + def stop_server_socket(self): + if self.server_socket: + self.server_socket.close() + self.server_socket = None + + def is_server_socket_running(self): + return self.server_socket is not None + + def send_via_client_socket(self, command): + client_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + try: + client_socket.connect(COMMANDS_SOCKET_FILE_PATH) + client_socket.sendall(command.encode("utf-8")) + received_data = b"" + while True: + data_chunk = client_socket.recv(1024) + if not data_chunk: + break + received_data += data_chunk + # Receive data from the server + data = received_data.decode() + if data.startswith("[Error] > "): + raise SocketCallException(data) + return data + finally: + if client_socket: + client_socket.close() diff --git a/src/fw_fanctrl/socketController/__init__.py b/src/fw_fanctrl/socketController/__init__.py new file mode 100644 index 0000000..e69de29