From f3fcc5604ce8e049781e66cc1b6f7ba51efc27c6 Mon Sep 17 00:00:00 2001
From: Thomas Flori <thflori@gmail.com>
Date: Tue, 27 Dec 2022 17:26:11 +0100
Subject: [PATCH] add sensors and rules

---
 app/Command/AbstractCommand.php     |  13 ++
 app/Command/Config.php              | 191 +++++++++++++++++++++-------
 app/Command/ShowConfig.php          |  22 ++++
 app/FanConfig.php                   | 161 ++++++++++++++++++++---
 app/Kernel.php                      |   5 +-
 app/Model.php                       |  16 +++
 app/Model/Concerns/DetectsHwmon.php |  24 ++++
 app/Model/Fan.php                   |  14 ++
 app/Model/Fan/HwmonFan.php          | 130 +++++++++++++++++++
 app/Model/Pwm.php                   |  10 --
 app/Model/Rule.php                  |  18 +++
 app/Model/Rule/CurveRule.php        | 107 ++++++++++++++++
 app/Model/Sensor.php                |  10 ++
 app/Model/Sensor/CommandSensor.php  |  59 +++++++++
 app/Model/Sensor/HwmonSensor.php    |  58 +++++++++
 composer.json                       |   3 +-
 composer.lock                       |  53 +++++++-
 17 files changed, 813 insertions(+), 81 deletions(-)
 create mode 100644 app/Command/ShowConfig.php
 create mode 100644 app/Model.php
 create mode 100644 app/Model/Concerns/DetectsHwmon.php
 create mode 100644 app/Model/Fan.php
 create mode 100644 app/Model/Fan/HwmonFan.php
 delete mode 100644 app/Model/Pwm.php
 create mode 100644 app/Model/Rule.php
 create mode 100644 app/Model/Rule/CurveRule.php
 create mode 100644 app/Model/Sensor.php
 create mode 100644 app/Model/Sensor/CommandSensor.php
 create mode 100644 app/Model/Sensor/HwmonSensor.php

diff --git a/app/Command/AbstractCommand.php b/app/Command/AbstractCommand.php
index b24290b..8593577 100644
--- a/app/Command/AbstractCommand.php
+++ b/app/Command/AbstractCommand.php
@@ -8,6 +8,7 @@ use GetOpt\Command;
 use GetOpt\GetOpt;
 use GetOpt\Option;
 use Hugga\Console;
+use Hugga\QuestionInterface;
 
 abstract class AbstractCommand extends Command
 {
@@ -33,4 +34,16 @@ abstract class AbstractCommand extends Command
     }
 
     abstract public function handle(GetOpt $getOpt): int;
+
+    /**
+     * Ask a simple question or the given question.
+     *
+     * @param QuestionInterface|string $question
+     * @param mixed $default
+     * @return mixed
+     */
+    protected function ask($question, $default = null)
+    {
+        return $this->console->ask($question, $default);
+    }
 }
diff --git a/app/Command/Config.php b/app/Command/Config.php
index 2756c21..6b251f2 100644
--- a/app/Command/Config.php
+++ b/app/Command/Config.php
@@ -3,6 +3,7 @@
 namespace App\Command;
 
 use App\Application;
+use App\Model\Fan\HwmonFan;
 use GetOpt\GetOpt;
 use Hugga\Console;
 use Hugga\Input\Question\Choice;
@@ -32,6 +33,11 @@ class Config extends AbstractCommand
         }
 
         $fanConfig = $this->app->fanConfig;
+        try {
+            $fanConfig->readConfig();
+        } catch (\Throwable $e) {
+            $this->error('Unable to read config: ' . $e->getMessage());
+        }
         if (!$fanConfig->hasFans()) {
             if (!$this->initializeConfig()) {
                 return 1;
@@ -48,15 +54,20 @@ class Config extends AbstractCommand
             'exit without saving',
         ], 'What next?', 'save and exit');
         do {
-            $answer = $this->console->ask($actions);
+            $fanConfig->display($this->console);
+            $answer = $this->ask($actions);
             switch ($answer) {
+                case 'edit a fan':
+                    $fan = $this->selectFan($fanConfig->getFans());
+                    break;
                 case 'exit without saving':
                     break 2;
                 case 'save and exit':
-//                    $fanConfig->save();
+                    $fanConfig->save();
                     break 2;
                 default:
-                    $this->info('not implemented');
+                    $this->warn('not implemented');
+                    break;
             }
         } while (true);
 
@@ -79,14 +90,56 @@ class Config extends AbstractCommand
         $this->info('No fans configured. Detecting fans...');
         $fans = $this->detectControllableFans();
         if (count($fans) > 0) {
-            // for each fan
-            // ask if it should be added
-            // ask for a name
-            // test min speed
-            // ask for a pwm limit
-            // add the fan to the config
-
-            $fanConfig->setFans($fans);
+            foreach ($fans as $fan) {
+                $this->line('');
+                $this->info('Fan ' . $fan->name);
+                $start = $this->detectStartValue($fan);
+                $fan->setStartValue($start);
+                do {
+                    $answer = $this->ask(new Choice([
+                        'name fan',
+                        'adjust start value',
+                        'define limit',
+                        'add fan',
+                        'skip fan',
+                    ], 'What next?', 'name fan'));
+                    switch ($answer) {
+                        case 'adjust start value':
+                            do {
+                                $fan->setPwm(0);
+                                $startValue = $this->ask('What value to try?', $start);
+                                $fan->setPwm($startValue);
+                            } while (!$this->ask(new Confirmation('Use that value?')));
+                            $fan->resetState();
+                            $fan->setStartValue($startValue);
+                            break;
+                        case 'define limit':
+                            do {
+                                $fan->setPwm(255);
+                                $maxValue = $this->ask('What value to try?', 255);
+                                $fan->setPwm($maxValue);
+                                $this->waitForStableRpm($fan);
+                                $this->line('fan runs at ' . $fan->getCurrentSpeed() . ' rpm now');
+                            } while (!$this->ask(new Confirmation('Use that value?')));
+                            $fan->resetState();
+                            $fan->setMaxValue($maxValue);
+                            break;
+                        case 'name fan':
+                            $name = $this->ask('Name of the fan: ', $fan->name);
+                            $fan->name = $name;
+                            break;
+                        case 'add fan':
+                            $fanConfig->addFan($fan);
+                            break 2;
+                        case 'skip fan':
+                            break 2;
+                        default:
+                            $this->warn('not implemented');
+                            break;
+                    }
+                } while (true);
+            }
+
             $fanConfig->save();
             return true;
         }
@@ -94,6 +147,9 @@ class Config extends AbstractCommand
         return false;
     }
 
+    /**
+     * @return array|HwmonFan[]
+     */
     protected function detectControllableFans(): array
     {
         $fans = [];
@@ -108,37 +164,35 @@ class Config extends AbstractCommand
             $pwm = preg_replace('~fan(\d+)~', 'pwm$1', $fan);
             $name = $hwmon . '/' . $fan;
 
+            $options = [
+                'hwmon' => $hwmon,
+                'fan' => $fan,
+                'pwm' => $pwm,
+            ];
             $inputs[$name] = [
-                'input' => $input,
-                'pwm' => preg_replace('~fan(\d+)_input~', 'pwm$1', $input),
+                'fan' => new HwmonFan($name, $options),
+                'options' => $options,
                 'hwmon' => 'hwmon' . $match[2],
-                'name' => $name,
-                'type' => 'hwmon',
-                'options' => [
-                    'hwmon' => $hwmon,
-                    'fan' => $fan,
-                    'pwm' => $pwm,
-                ],
             ];
             return $inputs;
         }, []);
 
         $unknownInputs = [];
         foreach ($inputs as $name => $input) {
-            [$min, $max] = $this->testPwm($input['pwm'], $input['input']);
+            [$min, $max] = $this->testPwm($input['fan']);
             if ($max === 0 || $min > $max*0.4) {
                 $unknownInputs[$name] = $input;
                 continue;
             }
-            $this->info(sprintf('%s at full speed: %d; stopped: %d', $name, $max, $min));
-            $fans[] = $input;
+            $this->line(sprintf('%s at full speed: %d; stopped: %d', $name, $max, $min));
+            $fans[] = $input['fan'];
         }
 
         if (count($unknownInputs) > 0) {
-            $this->info('Some fans could not be assigned to a pwm. Trying remaining pwms...');
-            $assignedPwms = array_map(fn($fan) => $fan['pwm'], $fans);
+            $this->info(PHP_EOL . 'Some fans could not be assigned to a pwm. Trying remaining pwms...');
+            $assignedPwms = array_map(fn(HwmonFan $fan) => $fan->toArray()['options']['pwm'], $fans);
             $pwms = array_reduce(glob(HWMON_PATH . '/hwmon*/pwm*'), function ($pwms, $pwm) use ($assignedPwms) {
-                if (!preg_match('~(hwmon(\d+)/pwm(\d+))$~', $pwm, $match) || in_array($pwm, $assignedPwms)) {
+                if (!preg_match('~(hwmon(\d+)/(pwm\d+))$~', $pwm, $match) || in_array(basename($pwm), $assignedPwms)) {
                     return $pwms;
                 }
 
@@ -146,7 +200,7 @@ class Config extends AbstractCommand
                 if (!isset($pwms[$hwmon])) {
                     $pwms[$hwmon] = [];
                 }
-                $pwms[$hwmon][] = $pwm;
+                $pwms[$hwmon][] = basename($pwm);
 
                 return $pwms;
             }, []);
@@ -155,49 +209,90 @@ class Config extends AbstractCommand
                     continue;
                 }
                 foreach ($pwms[$input['hwmon']] as $pwm) {
-                    if ($input['pwm'] !== $pwm) {
+                    $name = $input['fan']->name;
+                    $options = $input['options'];
+                    if ($options['pwm'] === $pwm) {
                         continue;
                     }
 
-                    [$min, $max] = $this->testPwm($pwm, $input['input']);
+                    $options['pwm'] = $pwm;
+                    $fan = new HwmonFan($name, $options);
+
+                    [$min, $max] = $this->testPwm($fan);
                     if ($max === 0 || $min > $max*0.4) {
                         continue;
                     }
 
-                    $input['pwm'] = $pwm;
-                    $input['options']['pwm'] = basename($pwm);
-                    $fans[] = $input;
+                    $fans[] = $fan;
                     continue 2;
                 }
                 $this->info($id . ' not connected or not controllable');
             }
         }
 
-        return array_map(function ($fan) {
-            unset($fan['input'], $fan['pwm']);
-            return $fan;
-        }, $fans);
+        return $fans;
     }
 
-    protected function testPwm($pwm, $input): array
+    protected function testPwm(HwmonFan $fan): array
     {
-        $currentEnable = file_get_contents($pwm . '_enable');
-
-        file_put_contents($pwm . '_enable', '1');
-        file_put_contents($pwm, '255');
-        sleep(4);
-        $fullSpeed = (int)file_get_contents($input);
+        $fan->setPwm(255);
+        $this->waitForStableRpm($fan);
+        $fullSpeed = $fan->getCurrentSpeed();
 
         if ($fullSpeed === 0) {
-            file_put_contents($pwm . '_enable', $currentEnable);
+            $fan->resetState();
             return [0, 0];
         }
 
-        file_put_contents($pwm, '0');
-        sleep(10);
-        $stopSpeed = (int)file_get_contents($input);
+        $fan->setPwm(0);
+        $this->waitForStableRpm($fan);
+        $stopSpeed = $fan->getCurrentSpeed();
 
-        file_put_contents($pwm . '_enable', $currentEnable);
+        $fan->resetState();
         return [$stopSpeed, $fullSpeed];
     }
+
+    protected function detectStartValue(HwmonFan $fan)
+    {
+        $this->info('Detecting start value for ' . $fan->name);
+        $fan->setPwm(0);
+        $this->waitForStableRpm($fan);
+
+        $minSpeed = $fan->getCurrentSpeed() * 1.1;
+        $pwm = 0;
+        do {
+            $fan->setPwm($pwm += 5);
+            $this->waitForStableRpm($fan);
+            $currentSpeed = $fan->getCurrentSpeed();
+            $this->line(sprintf('%d rpm with pwm value %d', $currentSpeed, $pwm));
+        } while ($minSpeed >= $currentSpeed);
+
+        $fan->resetState();
+        return $pwm;
+    }
+
+    protected function waitForStableRpm(HwmonFan $fan)
+    {
+        static $waitTime = 600_000; // in micro seconds
+        static $count = 4;
+        $readings = [];
+        for ($i = 0; $i < $count; $i++) {
+            $readings[] = $fan->getCurrentSpeed();
+            usleep($waitTime);
+        }
+        while (array_sum($readings) > 0 && // fan has stopped
+            (max($readings) - min($readings)) / (array_sum($readings) / $count) * 100 > 2
+        ) {
+            $this->line(sprintf(
+                'min: %d rpm, max: %d rpm, avg: %d rpm, variance: %f%%',
+                min($readings),
+                max($readings),
+                array_sum($readings) / $count,
+                (max($readings) - min($readings)) / (array_sum($readings) / $count) * 100
+            ), Console::WEIGHT_DEBUG);
+            array_shift($readings);
+            $readings[] = $fan->getCurrentSpeed();
+            usleep($waitTime);
+        }
+    }
 }
diff --git a/app/Command/ShowConfig.php b/app/Command/ShowConfig.php
new file mode 100644
index 0000000..d002120
--- /dev/null
+++ b/app/Command/ShowConfig.php
@@ -0,0 +1,22 @@
+<?php
+
+namespace App\Command;
+
+use GetOpt\GetOpt;
+
+class ShowConfig extends AbstractCommand
+{
+    protected $name = 'config:show';
+
+
+    public function handle(GetOpt $getOpt): int
+    {
+        $fanConfig = $this->app->fanConfig;
+        if (!$fanConfig->hasFans()) {
+            $this->error('No fans configured');
+            return 1;
+        }
+        $fanConfig->display($this->console);
+        return 0;
+    }
+}
diff --git a/app/FanConfig.php b/app/FanConfig.php
index 910caa8..e9760eb 100644
--- a/app/FanConfig.php
+++ b/app/FanConfig.php
@@ -2,56 +2,117 @@
 
 namespace App;
 
+use App\Model\Fan;
+use App\Model\Rule;
+use App\Model\Sensor;
+use Hugga\Console;
+use Hugga\Output\Drawing\Table;
+
 class FanConfig
 {
     protected $path = '/dev/null';
+
+    protected $loaded = false;
+
+    /** @var array|Fan[] */
     protected $fans = [];
 
+    /** @var array|Sensor[] */
+    protected $sensors = [];
+
+    /** @var array|Rule[] */
+    protected $rules = [];
+
+    public function __construct(string $path)
+    {
+        $this->path = $path;
+    }
+
     public function hasFans(): bool
     {
+        $this->load();
         return count($this->fans) > 0;
     }
 
-    public function setFans(array $fans): self
+    public function addFan(Fan $fan): self
     {
-        // do we validate that? or was it validated already?
-        $this->fans = $fans;
+        $this->load();
+        $this->fans[$fan->name] = $fan;
         return $this;
     }
 
-    public static function readConfig(string $path): self
+    /** @return Fan[]|array */
+    public function getFans(): array
+    {
+        $this->load();
+        return $this->fans;
+    }
+
+    protected function load()
     {
-        $self = new FanConfig();
-        $self->path = $path;
-        if (!file_exists($path)) {
-            return $self;
+        if (!$this->loaded) {
+            $this->readConfig();
         }
+    }
 
-        $cfg = include($path);
+    public function readConfig(): self
+    {
+        if (!file_exists($this->path)) {
+            return $this;
+        }
+
+        $this->loaded = true;
+        $cfg = include($this->path);
 
         // validate config
         if (!is_array($cfg)) {
             throw new \InvalidArgumentException('given configuration is not a fan-ctrl config');
         }
 
-        $self = new FanConfig();
-        $self->path = $path;
-        $self->fans = $cfg['fans'] ?? [];
-        return $self;
+        foreach ($cfg['fans'] ?? [] as $fanCfg) {
+            $class = $fanCfg['type'];
+            $fan = new $class($fanCfg['name'], $fanCfg['options']);
+            $this->fans[$fan->name] = $fan;
+        }
+
+        foreach ($cfg['sensors'] ?? [] as $sensorCfg) {
+            $class = $sensorCfg['type'];
+            $sensor = new $class($sensorCfg['name'], $sensorCfg['options']);
+            $this->sensors[$sensor->name] = $sensor;
+        }
+
+        foreach ($cfg['rules'] ?? [] as $ruleCfg) {
+            $class = $ruleCfg['type'];
+            $fans = array_map(fn($name) => $this->fans[$name] ?? null, $ruleCfg['fans']);
+            if (in_array(null, $fans, true)) {
+                continue;
+            }
+            $sensor = $this->sensors[$ruleCfg['sensor']] ?? null;
+            if (!$sensor) {
+                continue;
+            }
+            $rule = new $class($fans, $sensor, $ruleCfg['options']);
+            $this->rules[] = $rule;
+        }
+
+        return $this;
     }
 
     public function save()
     {
-//        ob_start();
-//        $fans = $this->fans;
-//        include __DIR__ . '/../resources/config-template.php';
-//        $code = ob_get_clean();
         $code = '<?php' . PHP_EOL . PHP_EOL .
             'return ' . $this->exportArray([
-                'fans' => $this->fans,
+                'fans' => array_map(function (Fan $fan) {
+                    return $fan->toArray();
+                }, array_values($this->fans)),
+                'sensors' => array_map(function (Sensor $sensor) {
+                    return $sensor->toArray();
+                }, array_values($this->sensors)),
+                'rules' => array_map(function (Rule $rule) {
+                    return $rule->toArray();
+                }, $this->rules),
             ]) . ';' . PHP_EOL;
         file_put_contents($this->path, $code);
-//        exec(__DIR__ . '/../vendor/bin/php-cs-fixer fix ' . $this->path . ' --rules');
     }
 
     protected function exportArray(array $array, $indentation = 0)
@@ -71,4 +132,66 @@ class FanConfig
         $var .= str_repeat(' ', $indentation) . ']';
         return $var;
     }
+
+    public function display(Console $console)
+    {
+        $this->load();
+
+        $console->info('Fans');
+        $table = new Table($console, array_map(function (Fan $fan) {
+            if ($fan instanceof Fan\HwmonFan) {
+                $options = $fan->toArray()['options'];
+                return [
+                    $fan->name,
+                    $options['hwmon'],
+                    $options['fan'],
+                    $options['pwm'],
+                    $options['start'],
+                    $options['max'],
+                ];
+            }
+
+            return [
+                $fan->name,
+                null,
+                null,
+                null,
+                null,
+            ];
+        }, $this->fans), ['Name', 'hwmon', 'Fan', 'PWM', 'start', 'max']);
+        $table->column(4, ['width' => 5]);
+        $table->draw();
+
+        $console->line('');
+
+        $console->info('Sensors');
+        $table = new Table($console, array_map(function (Sensor $sensor) {
+            $options = $sensor->toArray()['options'];
+            $result = [$sensor->name];
+            if ($sensor instanceof Sensor\HwmonSensor) {
+                $result[] = $options['hwmon'];
+                $result[] = $options['temp'];
+            } elseif ($sensor instanceof Sensor\CommandSensor) {
+                $result[] = null;
+                $result[] = $options['command'];
+            } else {
+                $result[] = null;
+                $result[] = null;
+            }
+            return $result;
+        }, $this->sensors), ['Name', 'hwmon', 'sensor / command']);
+        $table->draw();
+
+        $console->line('');
+
+        $console->info('Rules');
+        $table = new Table($console, array_map(function (Rule $rule) {
+            return [
+                implode(', ', array_map(fn($fan) => $fan->name, $rule->fans)),
+                $rule->sensor->name,
+                $rule->describe(),
+            ];
+        }, $this->rules), ['Fans', 'Sensor', 'Description']);
+        $table->draw();
+    }
 }
diff --git a/app/Kernel.php b/app/Kernel.php
index cf1d790..dfbbd04 100644
--- a/app/Kernel.php
+++ b/app/Kernel.php
@@ -15,7 +15,8 @@ class Kernel extends \Riki\Kernel
 {
     /** @var string[] */
     protected static $commands = [
-        \App\Command\Config::class,
+        Command\Config::class,
+        Command\ShowConfig::class,
     ];
 
     /** @var \Riki\Application|Application */
@@ -65,7 +66,7 @@ class Kernel extends \Riki\Kernel
         }
 
         $configPath = $getOpt->getOption('config') ?: $this->app->config->defaultConfigPath;
-        $this->app->instance('fanConfig', FanConfig::readConfig($configPath));
+        $this->app->instance('fanConfig', new FanConfig($configPath));
 
         $command = $getOpt->getCommand();
         if (!$command || $getOpt->getOption('help')) {
diff --git a/app/Model.php b/app/Model.php
new file mode 100644
index 0000000..ee87cc2
--- /dev/null
+++ b/app/Model.php
@@ -0,0 +1,16 @@
+<?php
+
+namespace App;
+
+abstract class Model
+{
+    /** @var string */
+    public $name;
+
+    public function __construct(string $name)
+    {
+        $this->name = $name;
+    }
+
+    abstract public function toArray(): array;
+}
diff --git a/app/Model/Concerns/DetectsHwmon.php b/app/Model/Concerns/DetectsHwmon.php
new file mode 100644
index 0000000..840f3bc
--- /dev/null
+++ b/app/Model/Concerns/DetectsHwmon.php
@@ -0,0 +1,24 @@
+<?php
+
+namespace App\Model\Concerns;
+
+trait DetectsHwmon
+{
+    /**
+     * Detect the base path of the hwmon with $name
+     *
+     * @param string $name
+     * @return ?string
+     */
+    protected function detectBasePath(string $name): ?string
+    {
+        foreach (glob(HWMON_PATH . '/hwmon*/name') as $nameFile) {
+            $current = trim(file_get_contents($nameFile));
+            if ($current === $name) {
+                return substr($nameFile, 0, -4);
+            }
+        }
+
+        return null;
+    }
+}
diff --git a/app/Model/Fan.php b/app/Model/Fan.php
new file mode 100644
index 0000000..557ea92
--- /dev/null
+++ b/app/Model/Fan.php
@@ -0,0 +1,14 @@
+<?php
+
+namespace App\Model;
+
+use App\Model;
+
+abstract class Fan extends Model
+{
+    abstract public function getCurrentSpeed(): int;
+
+    abstract public function setSpeed($percentage);
+
+    abstract public function stopFan();
+}
diff --git a/app/Model/Fan/HwmonFan.php b/app/Model/Fan/HwmonFan.php
new file mode 100644
index 0000000..63a1dd5
--- /dev/null
+++ b/app/Model/Fan/HwmonFan.php
@@ -0,0 +1,130 @@
+<?php
+
+namespace App\Model\Fan;
+
+use App\Model\Concerns\DetectsHwmon;
+use App\Model\Fan;
+
+class HwmonFan extends Fan
+{
+    use DetectsHwmon;
+
+    const ENABLE_FULL_SPEED = 0;
+    const ENABLE_MANUAL = 1;
+    const ENABLE_MIN_AUTOMATIC = 2;
+
+    /** @var string */
+    protected $hwmon;
+
+    /** @var string */
+    protected $basePath;
+
+    /** @var string */
+    protected $fan;
+
+    /** @var string */
+    protected $pwm;
+
+    /** @var int */
+    protected $startValue;
+
+    /** @var int */
+    protected $maxValue;
+
+    /** @var array */
+    protected $currentState = [];
+
+    public function __construct(string $name, array $options)
+    {
+        parent::__construct($name);
+        if (!isset($options['hwmon']) || !isset($options['fan']) || !isset($options['pwm'])) {
+            throw new \InvalidArgumentException('The options hwmon, fan and pwm are required');
+        }
+        $this->hwmon = $options['hwmon'];
+        $this->basePath = $this->detectBasePath($this->hwmon);
+        $this->fan = $options['fan'];
+        $this->pwm = $options['pwm'];
+        $this->startValue = $options['start'] ?? 35; // do we need to configure a default start value?
+        $this->maxValue = $options['max'] ?? 255;
+
+        if (!$this->basePath) {
+            throw new \InvalidArgumentException('The hardware monitor ' . $this->hwmon . ' is not available');
+        }
+
+        if (!file_exists($this->basePath . $this->fan . '_input')) {
+            throw new \InvalidArgumentException('The fan ' . $this->fan . ' is not available');
+        }
+        if (!file_exists($this->basePath . $this->pwm)) {
+            throw new \InvalidArgumentException('The pwm ' . $this->pwm . ' is not available');
+        }
+
+        $this->updateCurrentState();
+    }
+
+    public function getCurrentSpeed(): int
+    {
+        return (int)file_get_contents($this->basePath . $this->fan . '_input');
+    }
+
+    public function setStartValue(int $start): self
+    {
+        $this->startValue = $start;
+        return $this;
+    }
+
+    public function setMaxValue(int $max): self
+    {
+        $this->maxValue = $max;
+        return $this;
+    }
+
+    public function setPwm(int $value)
+    {
+        if ($value < 0 or $value > 255) {
+            throw new \InvalidArgumentException('$value has to be between 0 and 255');
+        }
+        file_put_contents($this->basePath . $this->pwm . '_enable', 1);
+        file_put_contents($this->basePath . $this->pwm, $value);
+    }
+
+    public function setSpeed($percentage)
+    {
+        $this->setPwm($this->startValue + ($this->maxValue - $this->startValue) * $percentage / 100);
+    }
+
+    public function stopFan()
+    {
+        $this->setPwm(0);
+    }
+
+    public function toArray(): array
+    {
+        return [
+            'name' => $this->name,
+            'type' => HwmonFan::class,
+            'options' => [
+                'hwmon' => $this->hwmon,
+                'fan' => $this->fan,
+                'pwm' => $this->pwm,
+                'start' => $this->startValue,
+                'max' => $this->maxValue,
+            ],
+        ];
+    }
+
+    protected function updateCurrentState()
+    {
+        $this->currentState = [
+            '' => file_get_contents($this->basePath . $this->pwm),
+            '_enable' => file_get_contents($this->basePath . $this->pwm . '_enable'),
+            '_mode' => file_get_contents($this->basePath . $this->pwm . '_mode'),
+        ];
+    }
+
+    public function resetState()
+    {
+        foreach ($this->currentState as $name => $value) {
+            file_put_contents($this->basePath . $this->pwm . $name, $value);
+        }
+    }
+}
diff --git a/app/Model/Pwm.php b/app/Model/Pwm.php
deleted file mode 100644
index d874a37..0000000
--- a/app/Model/Pwm.php
+++ /dev/null
@@ -1,10 +0,0 @@
-<?php
-
-namespace App\Model;
-
-class Pwm
-{
-    const ENABLE_FULL_SPEED = 0;
-    const ENABLE_MANUAL = 1;
-    const ENABLE_MIN_AUTOMATIC = 2;
-}
diff --git a/app/Model/Rule.php b/app/Model/Rule.php
new file mode 100644
index 0000000..622c51f
--- /dev/null
+++ b/app/Model/Rule.php
@@ -0,0 +1,18 @@
+<?php
+
+namespace App\Model;
+
+use App\Model;
+
+abstract class Rule extends Model
+{
+    /** @var Fan[]|array */
+    public $fans;
+
+    /** @var Sensor */
+    public $sensor;
+
+    abstract public function apply();
+
+    abstract public function describe(): string;
+}
diff --git a/app/Model/Rule/CurveRule.php b/app/Model/Rule/CurveRule.php
new file mode 100644
index 0000000..29635cb
--- /dev/null
+++ b/app/Model/Rule/CurveRule.php
@@ -0,0 +1,107 @@
+<?php
+
+namespace App\Model\Rule;
+
+use App\Model\Fan;
+use App\Model\Rule;
+use App\Model\Sensor;
+
+class CurveRule extends Rule
+{
+    /** @var array|Fan[] */
+    public $fans = [];
+
+    /** @var Sensor */
+    public $sensor;
+
+    /** @var bool */
+    protected $alwaysOn = true;
+
+    /** @var array */
+    protected $points = [];
+
+    public function __construct(array $fans, Sensor $sensor, array $options)
+    {
+        $name = implode(',', array_map(fn($fan) => $fan->name, $fans));
+        parent::__construct($name);
+
+        if (!isset($options['points']) || !is_array($options['points'])) {
+            throw new \InvalidArgumentException('The option points is required and has to be an array');
+        }
+
+        $this->fans = $fans;
+        $this->sensor = $sensor;
+
+        $this->alwaysOn = $options['alwaysOn'] ?? true;
+        $this->points = $options['points'];
+        ksort($this->points);
+        $lastLimit = null;
+        foreach ($this->points as $percentage => $limit) {
+            if ($lastLimit && $lastLimit > $limit) {
+                throw new \InvalidArgumentException('Each point has to have a higher temperature limit than the last');
+            }
+        }
+    }
+
+    public function apply()
+    {
+        $temp = $this->sensor->getTemperature();
+        $lastPercentage = null;
+        $lastLimit = null;
+        foreach ($this->points as $percentage => $limit) {
+            if ($temp >= $limit) {
+                $lastPercentage = $percentage;
+                $lastLimit = $limit;
+                continue;
+                // somewhere between $lastV and $v
+
+            }
+            if (!$this->alwaysOn && $lastPercentage === null) {
+                // stop the fans if they should not run all the time and first limit not reached
+                $this->stopFans();
+            } elseif ($lastPercentage === null) {
+                // run the fans with the lowest percentage value before the first point
+                $this->setSpeed($percentage);
+            } else {
+                $factor = $temp - $lastLimit / ($limit - $lastLimit);
+                $this->setSpeed(($percentage - $lastPercentage) * $factor);
+            }
+        }
+    }
+
+    public function describe(): string
+    {
+        $initialSpeed = array_keys($this->points)[0];
+        $minLimit = array_values($this->points)[0];
+        $description = $this->alwaysOn ? 'With ' . $initialSpeed . '% speed' : 'Off';
+        $description .= ' until ' . $minLimit . '° C.';
+        return $description;
+    }
+
+    public function toArray(): array
+    {
+        return [
+            'fans' => array_map(fn($fan) => $fan->name, $this->fans),
+            'sensor' => $this->sensor->name,
+            'type' => CurveRule::class,
+            'options' => [
+                'alwaysOn' => $this->alwaysOn,
+                'points' => $this->points,
+            ],
+        ];
+    }
+
+    protected function stopFans()
+    {
+        foreach ($this->fans as $fan) {
+            $fan->stopFan();
+        }
+    }
+
+    protected function setSpeed(float $percentage)
+    {
+        foreach ($this->fans as $fan) {
+            $fan->setSpeed($percentage);
+        }
+    }
+}
diff --git a/app/Model/Sensor.php b/app/Model/Sensor.php
new file mode 100644
index 0000000..534b682
--- /dev/null
+++ b/app/Model/Sensor.php
@@ -0,0 +1,10 @@
+<?php
+
+namespace App\Model;
+
+use App\Model;
+
+abstract class Sensor extends Model
+{
+    abstract public function getTemperature(): int;
+}
diff --git a/app/Model/Sensor/CommandSensor.php b/app/Model/Sensor/CommandSensor.php
new file mode 100644
index 0000000..c2bb405
--- /dev/null
+++ b/app/Model/Sensor/CommandSensor.php
@@ -0,0 +1,59 @@
+<?php
+
+namespace App\Model\Sensor;
+
+use App\Model\Sensor;
+use MathParser\Interpreting\Evaluator;
+use MathParser\Parsing\Nodes\Node;
+use MathParser\StdMathParser;
+
+class CommandSensor extends Sensor
+{
+    /** @var string */
+    protected $command;
+
+    /** @var string */
+    protected $conversion;
+
+    /** @var Node */
+    protected $ast;
+
+    public function __construct(string $name, array $options)
+    {
+        parent::__construct($name);
+        if (!isset($options['command'])) {
+            throw new \InvalidArgumentException('The option command is required');
+        }
+
+        $this->command = $options['command'];
+
+        if (isset($options['conversion'])) {
+            $parser = new StdMathParser();
+            $this->ast = $parser->parse($options['conversion']);
+        }
+        $this->conversion = $options['conversion'] ?? null;
+    }
+
+
+    public function getTemperature(): int
+    {
+        $result = exec(escapeshellcmd($this->command));
+        if ($this->ast) {
+            $eval = new Evaluator(['x' => (double)$result]);
+            $result = $this->ast->accept($eval);
+        }
+        return (int)round($result);
+    }
+
+    public function toArray(): array
+    {
+        return [
+            'name' => $this->name,
+            'type' => CommandSensor::class,
+            'options' => [
+                'command' => $this->command,
+                'conversion' => $this->conversion,
+            ]
+        ];
+    }
+}
diff --git a/app/Model/Sensor/HwmonSensor.php b/app/Model/Sensor/HwmonSensor.php
new file mode 100644
index 0000000..3401643
--- /dev/null
+++ b/app/Model/Sensor/HwmonSensor.php
@@ -0,0 +1,58 @@
+<?php
+
+namespace App\Model\Sensor;
+
+use App\Model\Concerns\DetectsHwmon;
+use App\Model\Sensor;
+
+class HwmonSensor extends Sensor
+{
+    use DetectsHwmon;
+
+    /** @var string */
+    protected $hwmon;
+
+    /** @var string */
+    protected $basePath;
+
+    /** @var string */
+    protected $temp;
+
+    public function __construct(string $name, array $options)
+    {
+        parent::__construct($name);
+        if (!isset($options['hwmon']) || !isset($options['temp'])) {
+            throw new \InvalidArgumentException('The options hwmon and temp are required');
+        }
+
+        $this->hwmon = $options['hwmon'];
+        $this->basePath = $this->detectBasePath($this->hwmon);
+        $this->temp = $options['temp'];
+
+        if (!$this->basePath) {
+            throw new \InvalidArgumentException('The hardware monitor ' . $this->hwmon . ' is not available');
+        }
+
+        if (!file_exists($this->basePath . $this->temp . '_input')) {
+            throw new \InvalidArgumentException('The sensor ' . $this->temp . ' is not available');
+        }
+    }
+
+    public function getTemperature(): int
+    {
+        $temp = (int)file_get_contents($this->basePath . $this->temp . '_input');
+        return (int)round($temp/1000);
+    }
+
+    public function toArray(): array
+    {
+        return [
+            'name' => $this->name,
+            'type' => HwmonSensor::class,
+            'options' => [
+                'hwmon' => $this->hwmon,
+                'temp' => $this->temp,
+            ],
+        ];
+    }
+}
diff --git a/composer.json b/composer.json
index 3c1888e..d98f179 100644
--- a/composer.json
+++ b/composer.json
@@ -8,7 +8,8 @@
         "filp/whoops": "^2.14",
         "monolog/monolog": "^1.9",
         "ext-pcntl": "*",
-        "php": ">=7.4"
+        "php": ">=7.4",
+        "mossadal/math-parser": "^1.3"
     },
     "autoload":          {
         "psr-4": {
diff --git a/composer.lock b/composer.lock
index b5683fa..ff722eb 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "8ae650b3e47d3fef6f9aba60ffeb5b60",
+    "content-hash": "cddd1ddd64b18141de6444950d1076f4",
     "packages": [
         {
             "name": "filp/whoops",
@@ -163,6 +163,57 @@
             ],
             "time": "2022-06-09T08:53:42+00:00"
         },
+        {
+            "name": "mossadal/math-parser",
+            "version": "v1.3.16",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/mossadal/math-parser.git",
+                "reference": "981b03ca603fd281049e092d75245ac029e13dec"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/mossadal/math-parser/zipball/981b03ca603fd281049e092d75245ac029e13dec",
+                "reference": "981b03ca603fd281049e092d75245ac029e13dec",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=5.4.0"
+            },
+            "require-dev": {
+                "phpdocumentor/phpdocumentor": "2.*",
+                "phpunit/php-code-coverage": "6.0.*",
+                "phpunit/phpunit": "7.3.*"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "MathParser\\": "src/MathParser"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "LGPL-3.0"
+            ],
+            "authors": [
+                {
+                    "name": "Frank Wikström",
+                    "email": "frank@mossadal.se",
+                    "role": "Developer"
+                }
+            ],
+            "description": "PHP parser for mathematical expressions, including elementary functions, variables and implicit multiplication. Also supports symbolic differentiation.",
+            "homepage": "https://github.com/mossadal/math-parser",
+            "keywords": [
+                "mathematics",
+                "parser"
+            ],
+            "support": {
+                "issues": "https://github.com/mossadal/math-parser/issues",
+                "source": "https://github.com/mossadal/math-parser/tree/master"
+            },
+            "time": "2018-09-15T22:20:34+00:00"
+        },
         {
             "name": "nicmart/string-template",
             "version": "v0.1.3",
-- 
GitLab