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