From 27b4629279e7a559cea19f8f123f3310724dec9a Mon Sep 17 00:00:00 2001 From: Mario Fetka Date: Tue, 26 Oct 2021 12:58:36 +0200 Subject: [PATCH] Imported Upstream version 3.0.0 --- .dockerignore | 1 + .gitignore | 16 + .travis.yml | 37 ++ CONTRIBUTING.md | 43 ++ Dockerfile.debian | 27 + LICENSE | 21 + MANIFEST.in | 5 + Makefile | 81 +++ README.rst | 23 + arduino/Readme.md | 3 + arduino/micro.ino | 237 ++++++++ arduino/micro_schematics.svg | 1 + debian/.gitignore | 3 + debian/changelog | 78 +++ debian/compat | 1 + debian/control | 38 ++ debian/copyright | 33 ++ debian/install | 2 + .../patches/remove-setup-py-data-files.patch | 15 + debian/patches/series | 1 + debian/rules | 12 + debian/source/format | 1 + debian/source/options | 1 + debian/watch | 3 + docs/Makefile | 20 + docs/_static/arctic_motherboard.svg | 1 + docs/_static/custom.css | 10 + docs/_static/micro_schematics.svg | 1 + docs/_static/noctua_arduino.svg | 1 + docs/_static/noctua_motherboard.svg | 1 + docs/_static/pc_case_example.svg | 170 ++++++ docs/conf.py | 79 +++ docs/index.rst | 511 ++++++++++++++++++ pkg/afancontrol.conf | 269 +++++++++ pkg/afancontrol.service | 12 + setup.cfg | 112 ++++ setup.py | 16 + src/afancontrol/__init__.py | 1 + src/afancontrol/__main__.py | 21 + src/afancontrol/arduino.py | 308 +++++++++++ src/afancontrol/config.py | 389 +++++++++++++ src/afancontrol/configparser.py | 129 +++++ src/afancontrol/daemon.py | 162 ++++++ src/afancontrol/exec.py | 50 ++ src/afancontrol/fans.py | 150 +++++ src/afancontrol/fantest.py | 333 ++++++++++++ src/afancontrol/filters.py | 127 +++++ src/afancontrol/logger.py | 3 + src/afancontrol/manager.py | 116 ++++ src/afancontrol/metrics.py | 392 ++++++++++++++ src/afancontrol/pwmfan/__init__.py | 131 +++++ src/afancontrol/pwmfan/arduino.py | 133 +++++ src/afancontrol/pwmfan/base.py | 86 +++ src/afancontrol/pwmfan/ipmi.py | 49 ++ src/afancontrol/pwmfan/linux.py | 90 +++ src/afancontrol/pwmfannorm.py | 234 ++++++++ src/afancontrol/report.py | 17 + src/afancontrol/temp/__init__.py | 55 ++ src/afancontrol/temp/base.py | 44 ++ src/afancontrol/temp/command.py | 76 +++ src/afancontrol/temp/file.py | 109 ++++ src/afancontrol/temp/hdd.py | 102 ++++ src/afancontrol/temps.py | 77 +++ src/afancontrol/trigger.py | 210 +++++++ tests/__init__.py | 0 tests/conftest.py | 38 ++ tests/data/afancontrol-example.conf | 53 ++ tests/pwmfan/__init__.py | 0 tests/pwmfan/test_arduino.py | 177 ++++++ tests/pwmfan/test_ipmi.py | 41 ++ tests/pwmfan/test_linux.py | 153 ++++++ tests/temp/__init__.py | 0 tests/temp/test_base.py | 46 ++ tests/temp/test_command.py | 40 ++ tests/temp/test_file.py | 88 +++ tests/temp/test_hdd.py | 95 ++++ tests/test_config.py | 498 +++++++++++++++++ tests/test_daemon.py | 98 ++++ tests/test_exec.py | 31 ++ tests/test_fans.py | 69 +++ tests/test_fantest.py | 105 ++++ tests/test_filters.py | 74 +++ tests/test_manager.py | 252 +++++++++ tests/test_metrics.py | 156 ++++++ tests/test_report.py | 20 + tests/test_trigger.py | 180 ++++++ tox.ini | 28 + 87 files changed, 7722 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 .travis.yml create mode 100644 CONTRIBUTING.md create mode 100644 Dockerfile.debian create mode 100644 LICENSE create mode 100644 MANIFEST.in create mode 100644 Makefile create mode 100644 README.rst create mode 100644 arduino/Readme.md create mode 100644 arduino/micro.ino create mode 100644 arduino/micro_schematics.svg create mode 100644 debian/.gitignore create mode 100644 debian/changelog create mode 100644 debian/compat create mode 100644 debian/control create mode 100644 debian/copyright create mode 100644 debian/install create mode 100644 debian/patches/remove-setup-py-data-files.patch create mode 100644 debian/patches/series create mode 100644 debian/rules create mode 100644 debian/source/format create mode 100644 debian/source/options create mode 100644 debian/watch create mode 100644 docs/Makefile create mode 100644 docs/_static/arctic_motherboard.svg create mode 100644 docs/_static/custom.css create mode 120000 docs/_static/micro_schematics.svg create mode 100644 docs/_static/noctua_arduino.svg create mode 100644 docs/_static/noctua_motherboard.svg create mode 100644 docs/_static/pc_case_example.svg create mode 100644 docs/conf.py create mode 100644 docs/index.rst create mode 100644 pkg/afancontrol.conf create mode 100644 pkg/afancontrol.service create mode 100644 setup.cfg create mode 100644 setup.py create mode 100644 src/afancontrol/__init__.py create mode 100644 src/afancontrol/__main__.py create mode 100644 src/afancontrol/arduino.py create mode 100644 src/afancontrol/config.py create mode 100644 src/afancontrol/configparser.py create mode 100644 src/afancontrol/daemon.py create mode 100644 src/afancontrol/exec.py create mode 100644 src/afancontrol/fans.py create mode 100644 src/afancontrol/fantest.py create mode 100644 src/afancontrol/filters.py create mode 100644 src/afancontrol/logger.py create mode 100644 src/afancontrol/manager.py create mode 100644 src/afancontrol/metrics.py create mode 100644 src/afancontrol/pwmfan/__init__.py create mode 100644 src/afancontrol/pwmfan/arduino.py create mode 100644 src/afancontrol/pwmfan/base.py create mode 100644 src/afancontrol/pwmfan/ipmi.py create mode 100644 src/afancontrol/pwmfan/linux.py create mode 100644 src/afancontrol/pwmfannorm.py create mode 100644 src/afancontrol/report.py create mode 100644 src/afancontrol/temp/__init__.py create mode 100644 src/afancontrol/temp/base.py create mode 100644 src/afancontrol/temp/command.py create mode 100644 src/afancontrol/temp/file.py create mode 100644 src/afancontrol/temp/hdd.py create mode 100644 src/afancontrol/temps.py create mode 100644 src/afancontrol/trigger.py create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/data/afancontrol-example.conf create mode 100644 tests/pwmfan/__init__.py create mode 100644 tests/pwmfan/test_arduino.py create mode 100644 tests/pwmfan/test_ipmi.py create mode 100644 tests/pwmfan/test_linux.py create mode 100644 tests/temp/__init__.py create mode 100644 tests/temp/test_base.py create mode 100644 tests/temp/test_command.py create mode 100644 tests/temp/test_file.py create mode 100644 tests/temp/test_hdd.py create mode 100644 tests/test_config.py create mode 100644 tests/test_daemon.py create mode 100644 tests/test_exec.py create mode 100644 tests/test_fans.py create mode 100644 tests/test_fantest.py create mode 100644 tests/test_filters.py create mode 100644 tests/test_manager.py create mode 100644 tests/test_metrics.py create mode 100644 tests/test_report.py create mode 100644 tests/test_trigger.py create mode 100644 tox.ini diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..172bf57 --- /dev/null +++ b/.dockerignore @@ -0,0 +1 @@ +.tox diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..66dc3b0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +*.egg-info +dist +build + +.py[co] +__pycache__/ + +.coverage +.pytest_cache + +.python-version + +.tox + +docs/_build + diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..b0242ed --- /dev/null +++ b/.travis.yml @@ -0,0 +1,37 @@ +language: python +dist: xenial + +python: + - "3.6" + - "3.7" + - "3.8" + # TODO add 3.9 + - "3.9-dev" + +install: pip install tox-travis tox tox-venv + +# Used by the `test` stage. +script: tox + +stages: + - test + - lint + +jobs: + allow_failures: + - python: "3.9-dev" + + include: + + # The `test` stage using the `python` matrix defined above + # is included implicitly. + + - stage: lint + name: "Code Linting" + python: "3.7" + script: TOXENV=lint tox + + - stage: check-docs + name: "Docs check" + python: "3.7" + script: TOXENV=check-docs tox diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..afce1f1 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,43 @@ +# Contributing to afancontrol + +I started afancontrol in 2013 in an attempt to make my custom PC case quiet. +It's been working 24/7 ever since with no issues, and eventually I started using +it on my other machines as well. + +I'm quite happy with how this package serves my needs, and I hope +it can be useful for someone else too. + +Contributions are welcome, however, keep in mind, that: +* Complex features and large diffs would probably be rejected, + because it would make maintenance more complicated for me, +* I don't have any plans for active development and promotion + of the package. + + +## Dev workflow + +Prepare a virtualenv: + + mkvirtualenv afancontrol + make develop + +I use [TDD](https://en.wikipedia.org/wiki/Test-driven_development) for development. + +Run tests: + + make test + +Autoformat the code and imports: + + make format + +Run linters: + + make lint + +So essentially after writing a small part of code and tests I call these +three commands and fix the errors until they stop failing. + +To build docs: + + make docs diff --git a/Dockerfile.debian b/Dockerfile.debian new file mode 100644 index 0000000..009d018 --- /dev/null +++ b/Dockerfile.debian @@ -0,0 +1,27 @@ +# Docker image for building an `afancontrol` package for Debian. + +FROM debian:unstable + +RUN apt-get update \ + && apt-get install -y \ + build-essential \ + debhelper \ + devscripts \ + python3 \ + vim-tiny + +# https://github.com/inversepath/usbarmory-debian-base_image/issues/9#issuecomment-451635505 +RUN mkdir ~/.gnupg && echo "disable-ipv6" >> ~/.gnupg/dirmngr.conf + +# Import the GPG key used to sign the PyPI releases of `afancontrol`: +RUN gpg --recv-keys "AA7B5406547AF062" + +COPY debian /build/afancontrol/debian +WORKDIR /build/afancontrol/ + +RUN mkdir -p debian/upstream \ + && gpg --export --export-options export-minimal --armor \ + 'A18FE9F6F570D5B4E1E1853FAA7B5406547AF062' \ + > debian/upstream/signing-key.asc + +RUN apt-get -y build-dep . diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5785e46 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 Kostya Esmukov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..f40c181 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,5 @@ +graft pkg +graft tests +recursive-exclude * *.py[co] +recursive-exclude * .DS_Store +recursive-exclude * __pycache__ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..5883390 --- /dev/null +++ b/Makefile @@ -0,0 +1,81 @@ + +.PHONY: format +format: + black src tests *.py && isort src tests *.py + +.PHONY: lint +lint: + flake8 src tests *.py && isort --check-only src tests *.py && black --check src tests *.py && mypy src tests + +.PHONY: test +test: + coverage run -m py.test + coverage report + +.PHONY: clean +clean: + find . -name "*.pyc" -print0 | xargs -0 rm -f + rm -Rf dist + rm -Rf *.egg-info + +.PHONY: develop +develop: + pip install -U setuptools wheel + pip install -e '.[arduino,metrics,dev]' + +.PHONY: sdist +sdist: + python setup.py sdist + +.PHONY: wheel +wheel: + python setup.py bdist_wheel + +.PHONY: release +release: clean sdist wheel + twine --version + twine upload -s dist/* + +.PHONY: docs +docs: + make -C docs html + +.PHONY: check-docs +check-docs: + # Doesn't generate any output but prints out errors and warnings. + make -C docs dummy + +.PHONY: deb-local +deb-local: clean sdist + docker build -t afancontrol-debuild -f ./Dockerfile.debian . + docker run -it --rm \ + -e DEBFULLNAME="`git config --global user.name`" \ + -e DEBEMAIL="`git config --global user.email`" \ + -v `pwd`/dist:/afancontrol/dist \ + -v `pwd`/debian:/afancontrol/debian \ + afancontrol-debuild sh -ex -c '\ + tar xaf /afancontrol/dist/afancontrol-*.tar.gz --strip 1; \ + dch -v `python3 setup.py --version` -b --distribution=unstable; \ + debuild -us -uc -b; \ + cp debian/changelog /afancontrol/debian/; \ + cd ../; \ + ls -alh; \ + mkdir /afancontrol/dist/debian; \ + cp afancontrol?* /afancontrol/dist/debian/; \ + dpkg --contents afancontrol*.deb; \ + ' + +.PHONY: deb-from-pypi +deb-from-pypi: clean + docker build -t afancontrol-debuild -f ./Dockerfile.debian . + docker run -it --rm \ + -v `pwd`/dist:/afancontrol/dist \ + afancontrol-debuild sh -ex -c '\ + uscan --download --overwrite-download --verbose; \ + tar xaf ../afancontrol_*.orig.tar.gz --strip 1; \ + debuild -us -uc; \ + cd ../; \ + ls -alh; \ + mkdir /afancontrol/dist/debian; \ + cp afancontrol?* /afancontrol/dist/debian/; \ + ' diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..5cb3a8b --- /dev/null +++ b/README.rst @@ -0,0 +1,23 @@ +afancontrol +=========== + +.. image:: https://img.shields.io/pypi/v/afancontrol.svg?style=flat-square + :target: https://pypi.python.org/pypi/afancontrol/ + :alt: Latest Version + +.. image:: https://img.shields.io/travis/KostyaEsmukov/afancontrol.svg?style=flat-square + :target: https://travis-ci.org/KostyaEsmukov/afancontrol + :alt: Build Status + +.. image:: https://img.shields.io/github/license/KostyaEsmukov/afancontrol.svg?style=flat-square + :target: https://pypi.python.org/pypi/afancontrol/ + :alt: License + +`afancontrol` stands for "Advanced fancontrol". Think of it as +`fancontrol `_ +with more advanced configuration abilities. + +`afancontrol` measures temperatures from sensors, computes the required +airflow and sets PWM fan speeds accordingly. + +The docs are available at ``_. diff --git a/arduino/Readme.md b/arduino/Readme.md new file mode 100644 index 0000000..6a351f1 --- /dev/null +++ b/arduino/Readme.md @@ -0,0 +1,3 @@ +![Arduino Micro schematics](./micro_schematics.svg) + +See the docs at https://afancontrol.readthedocs.io/#pwm-fans-via-arduino diff --git a/arduino/micro.ino b/arduino/micro.ino new file mode 100644 index 0000000..482473e --- /dev/null +++ b/arduino/micro.ino @@ -0,0 +1,237 @@ +// The pins defined in this program are for Arduino Micro. + +///////////////////////// +// FAN PWM outputs: + +// https://www.pjrc.com/teensy/td_libs_TimerOne.html +// https://github.com/PaulStoffregen/TimerOne +// https://github.com/PaulStoffregen/TimerThree +#include +#include + +// TimerOne/Three accepts PWM duty in range from 0 to 1023. +// The standard range for PWM fans on Linux is, however, from 0 to 255. +// This macros below does the conversion from 255 to 1023. +#define PWM_255_TO_1023(PWM) ((PWM == 0) ? 0 : (1L * PWM + 1) * 4 - 1) + +// These are the pins connected to the Timers 1 and 3 on Arduino Micro. +// See https://www.pjrc.com/teensy/td_libs_TimerOne.html +byte currentPWM5; +byte currentPWM9; +byte currentPWM10; +byte currentPWM11; + +#define SET_PWM(PIN, PWM) currentPWM##PIN = (PWM); +#define SET_PWM_HIGH(PIN) SET_PWM(PIN, 255) + +#define PRINT_PWM_JSON(PIN) \ +Serial.print("\""); \ +Serial.print(PIN, DEC); \ +Serial.print("\":"); \ +Serial.print(currentPWM##PIN, DEC); + +///////////////////////// +// FAN tachometer (RPM) inputs: + +// FAN speed from tachometer is measured by counting the number +// of interrupts (pulses) for a small period of time `MEASUREMENT_TIME_MS`. +// +// Between the periods the current status (a JSON) is reported and +// an incoming command is read (if any) from the Serial port. +// +// The number of pulses for each time period is written to a ring buffer, +// which allows to compute the RPM on a larger time interval than a single +// small period of `MEASUREMENT_TIME_MS`, which yields a smoother RPM. +// +// The number of periods of the ring buffer is `PULSES_BUFFER_LEN`, +// so the total amount of time the pulses are measured for would be +// `PULSES_BUFFER_LEN` * `MEASUREMENT_TIME_MS`. +// +#define MEASUREMENT_TIME_MS 250 +#define PULSES_BUFFER_LEN 6 +int pulsesBufferPosition = 0; + +// * 60 seconds in 1 minute (we count revolutions per *minute*); +// * 1000 to go from seconds to milliseconds; +// * Divided by the amount of time the pulses are measured. +// +// Be sure to select the dividers which yield an integer result after each division. +#define PULSES_MULTIPLIER (1L * 60 * 1000 / MEASUREMENT_TIME_MS / PULSES_BUFFER_LEN) + +// When a PWM wire goes near the Tachometer wire, the Tachometer one might +// receive interference, which would be sensed by the interruptions, +// spoiling the RPM measurements. +// +// PWM works at 25kHz, Tachometer is in the range ~4hz - ~200hz (120 RPM - 6000 RPM), +// so the extraneous PWM pulses would have delay ~0.04 - 1ms, while +// the genuine Tachometer pulses would have delay 5ms-250ms. +// +// This problem is similar to the common one occurring with the switches +// (http://www.gammon.com.au/switches), when a click on a switch produces +// many short pulses instead of a single long one. +// +// This var defines the minimum delay (in ms) between the two RISING +// interrupts, which should be treated as a valid Tachometer pulse. +#define PULSES_ACCEPT_MIN_DURATION_MS 3 + +#define TACHO_PULSES_INT_FUNCTION(PIN) \ +volatile int tachoPulses##PIN [PULSES_BUFFER_LEN]; \ +volatile unsigned long lastPulse##PIN; \ +void incTachoPulses##PIN () { \ + unsigned long now = millis(); \ + if (now - lastPulse##PIN < PULSES_ACCEPT_MIN_DURATION_MS) { lastPulse##PIN = now; return; } \ + lastPulse##PIN = now; \ + tachoPulses##PIN [pulsesBufferPosition] ++; \ +} + +#define TACHO_PULSES_ATTACH_INT(PIN) \ +pinMode(PIN, INPUT); \ +attachInterrupt(digitalPinToInterrupt(PIN), incTachoPulses##PIN, RISING); \ +{ \ + for (int i = 0; i < PULSES_BUFFER_LEN; i++) tachoPulses##PIN[i] = 0; \ + lastPulse##PIN = 0; \ +} + +#define TACHO_PULSES_NEXT_BUCKET \ +pulsesBufferPosition = (pulsesBufferPosition + 1) % PULSES_BUFFER_LEN; + +#define TACHO_PULSES_RESET_CURRENT_BUCKET(PIN) \ +tachoPulses##PIN[pulsesBufferPosition] = 0; + +#define PRINT_RPM_JSON(PIN) \ +Serial.print("\""); \ +Serial.print(PIN, DEC); \ +Serial.print("\":"); \ +Serial.print(PULSES_MULTIPLIER * sumPulses(tachoPulses##PIN) / 2, DEC); +// ^^^ Regarding division by 2: PC fans do 2 pulses per each revolution, +// see https://electronics.stackexchange.com/q/8295 + +int sumPulses(volatile int tachoPulses [PULSES_BUFFER_LEN]) { + int sum = 0; + for (int i = 0; i < PULSES_BUFFER_LEN; i++) { + sum += tachoPulses[i]; + } + return sum; +} + +// These are the pins on Arduino Micro which support interrupts. +// See https://www.arduino.cc/reference/en/language/functions/external-interrupts/attachinterrupt/ +TACHO_PULSES_INT_FUNCTION(0); +TACHO_PULSES_INT_FUNCTION(1); +TACHO_PULSES_INT_FUNCTION(2); +TACHO_PULSES_INT_FUNCTION(3); +TACHO_PULSES_INT_FUNCTION(7); + + +///////////////////////// +// Serial commands: + +char setSpeedCommand = '\xf1'; // The only supported command currently. +char commandBuffer[3]; // Buffer for the incoming command: [command; pin; speed]. +int commandPosition = 0; // The current position in the `commandBuffer` + +///////////////////////// + +void setup() { + // https://github.com/PaulStoffregen/TimerOne/blob/master/examples/FanSpeed/FanSpeed.pde + Timer1.initialize(40); // 40us == 25kHz + Timer3.initialize(40); + + SET_PWM_HIGH(5); + SET_PWM_HIGH(9); + SET_PWM_HIGH(10); + SET_PWM_HIGH(11); + + TACHO_PULSES_ATTACH_INT(0); + TACHO_PULSES_ATTACH_INT(1); + TACHO_PULSES_ATTACH_INT(2); + TACHO_PULSES_ATTACH_INT(3); + TACHO_PULSES_ATTACH_INT(7); + + Serial.begin(115200); +} + +void loop () { + + Timer3.pwm(5, PWM_255_TO_1023(currentPWM5)); + Timer1.pwm(9, PWM_255_TO_1023(currentPWM9)); + Timer1.pwm(10, PWM_255_TO_1023(currentPWM10)); + Timer1.pwm(11, PWM_255_TO_1023(currentPWM11)); + + // Measure RPM from tachometers: + TACHO_PULSES_RESET_CURRENT_BUCKET(0); + TACHO_PULSES_RESET_CURRENT_BUCKET(1); + TACHO_PULSES_RESET_CURRENT_BUCKET(2); + TACHO_PULSES_RESET_CURRENT_BUCKET(3); + TACHO_PULSES_RESET_CURRENT_BUCKET(7); + interrupts(); + delay (MEASUREMENT_TIME_MS); + noInterrupts(); + TACHO_PULSES_NEXT_BUCKET; + + readSerialCommand(); + + // Print the status (in JSON): + + Serial.print("{"); + + Serial.print("\"fan_inputs\": {"); + PRINT_RPM_JSON(0); + Serial.print(","); + PRINT_RPM_JSON(1); + Serial.print(","); + PRINT_RPM_JSON(2); + Serial.print(","); + PRINT_RPM_JSON(3); + Serial.print(","); + PRINT_RPM_JSON(7); + Serial.print("}, "); + + Serial.print("\"fan_pwm\": {"); + PRINT_PWM_JSON(5); + Serial.print(","); + PRINT_PWM_JSON(9); + Serial.print(","); + PRINT_PWM_JSON(10); + Serial.print(","); + PRINT_PWM_JSON(11); + Serial.print("}"); + + Serial.print("}\n"); +} + +void readSerialCommand() { + while (Serial.available()) { + char c = Serial.read(); + if (commandPosition == 0 && c != setSpeedCommand) { + Serial.print("{\"error\": \"Unknown command "); + Serial.print(c, HEX); + Serial.print("\"}\n"); + continue; + } + commandBuffer[commandPosition] = c; + commandPosition++; + if (commandPosition >= 3) { + // The command buffer is now complete, process it: + processSerialCommand(); + + commandPosition = 0; + } + } +} + +void processSerialCommand() { + // assert (commandBuffer[0] == setSpeedCommand); + + byte pwm = (byte)commandBuffer[2]; + switch (commandBuffer[1]) { + case 5: SET_PWM(5, pwm); break; + case 9: SET_PWM(9, pwm); break; + case 10: SET_PWM(10, pwm); break; + case 11: SET_PWM(11, pwm); break; + default: + Serial.print("{\"error\": \"Unknown pin "); + Serial.print((int)commandBuffer[1], DEC); + Serial.print(" for the set speed command\"}\n"); + } +} diff --git a/arduino/micro_schematics.svg b/arduino/micro_schematics.svg new file mode 100644 index 0000000..c556eb8 --- /dev/null +++ b/arduino/micro_schematics.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/debian/.gitignore b/debian/.gitignore new file mode 100644 index 0000000..38e9bd5 --- /dev/null +++ b/debian/.gitignore @@ -0,0 +1,3 @@ +afancontrol/ +debhelper-build-stamp +files diff --git a/debian/changelog b/debian/changelog new file mode 100644 index 0000000..8b0f696 --- /dev/null +++ b/debian/changelog @@ -0,0 +1,78 @@ +afancontrol (3.0.0-1) unstable; urgency=medium + + * Drop support for prometheus-client < 0.1.0 (debian stretch) + * Drop support for Python 3.5 (debian stretch) + * Add support for Python 3.9 + * config: add `ipmi_sensors` location property + * Add dh-systemd (would automatically (re)start the systemd service upon + package (re)installation) + + -- Kostya Esmukov Sat, 10 Oct 2020 14:43:01 +0000 + +afancontrol (2.2.1-1) unstable; urgency=medium + + * Fix compatibility with py3.5 + + -- Kostya Esmukov Mon, 28 Sep 2020 22:39:46 +0000 + +afancontrol (2.2.0-1) unstable; urgency=medium + + * FileTemp: add support for glob patterns in paths + + * Add `readonly_fan` section, allow exporter-only mode (i.e. no fans and mappings) + + * Add a readonly IPMI speed fan + + * Add filters, collect temperatures simultaneously + + -- Kostya Esmukov Mon, 28 Sep 2020 22:12:04 +0000 + +afancontrol (2.1.0-1) unstable; urgency=medium + + * Move PID file under /run (#3) + + -- Kostya Esmukov Fri, 12 Jun 2020 15:12:15 +0000 + +afancontrol (2.0.0-1) unstable; urgency=medium + + * Switch Debian distribution from stretch to unstable + + -- Kostya Esmukov Sat, 09 May 2020 13:30:44 +0000 + +afancontrol (2.0.0~b5-1) unstable; urgency=medium + + * Fix LinuxPWMFan spuriously raising "Couldn't disable PWM on the fan" + + -- Kostya Esmukov Sat, 15 Jun 2019 23:18:33 +0000 + +afancontrol (2.0.0~b4-1) unstable; urgency=medium + + * Fix Arduino connection recovery not working + + * Fantest: fix arduino pins being asked when they equal 0 + + -- Kostya Esmukov Thu, 02 May 2019 11:55:29 +0000 + +afancontrol (2.0.0~b3-1) unstable; urgency=medium + + * Manager: remove a redundant processing of the fans which are absent in mappings + + * Fans: fix a bug when a single failing fan would prevent other fans' speed change + + * afancontrol daemon: remove the `--daemon` switch (it doesn't work correctly) + + * Config parser: strip spaces around fan name and speed modifier in mappings + + -- Kostya Esmukov Wed, 01 May 2019 12:40:13 +0000 + +afancontrol (2.0.0~b2-1) unstable; urgency=medium + + * Fix hddtemp not expanding glob + + -- Kostya Esmukov Mon, 29 Apr 2019 19:04:42 +0000 + +afancontrol (2.0.0~b1-1) unstable; urgency=medium + + * Initial release + + -- Kostya Esmukov Sun, 28 Apr 2019 11:58:16 +0000 diff --git a/debian/compat b/debian/compat new file mode 100644 index 0000000..ec63514 --- /dev/null +++ b/debian/compat @@ -0,0 +1 @@ +9 diff --git a/debian/control b/debian/control new file mode 100644 index 0000000..81186dd --- /dev/null +++ b/debian/control @@ -0,0 +1,38 @@ +Source: afancontrol +Section: utils +Priority: optional +Maintainer: Kostya Esmukov +Build-Depends: debhelper (>= 9), + dh-python, + dh-systemd, + python3-all, + python3-setuptools +Build-Depends-Indep: python3-pytest, + python3-requests, + python3-click, + python3-prometheus-client (>= 0.1.0), + python3-serial +Standards-Version: 3.9.8 +Homepage: https://github.com/KostyaEsmukov/afancontrol +X-Python3-Version: >= 3.5 +#Vcs-Git: https://anonscm.debian.org/git/python-modules/packages/python3-afancontrol.git +#Vcs-Browser: https://anonscm.debian.org/cgit/python-modules/packages/python3-afancontrol.git/ +#Testsuite: autopkgtest-pkg-python + + +Package: afancontrol +Architecture: all +Depends: ${python3:Depends}, + ${misc:Depends}, + hddtemp, + lm-sensors, + python3-click, + python3-pkg-resources, + python3-prometheus-client (>= 0.1.0), + python3-serial +Suggests: freeipmi-tools, +Description: Advanced Fan Control program (Python 3) + afancontrol is an Advanced Fan Control program, which controls PWM + fans according to the current temperatures of the system components. + . + This package installs the library for Python 3. diff --git a/debian/copyright b/debian/copyright new file mode 100644 index 0000000..6d85a0e --- /dev/null +++ b/debian/copyright @@ -0,0 +1,33 @@ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ +Upstream-Name: afancontrol +Source: https://github.com/KostyaEsmukov/afancontrol +Files-Excluded: *.pyc + +Files: * +Copyright: 2019 Kostya Esmukov +License: Expat + +Files: debian/* +Copyright: 2019 Kostya Esmukov +License: Expat + + +License: Expat + Permission is hereby granted, free of charge, to any person obtaining + a copy of this software and associated documentation files (the + "Software"), to deal in the Software without restriction, including + without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to + permit persons to whom the Software is furnished to do so, subject to + the following conditions: + . + The above copyright notice and this permission notice shall be included + in all copies or substantial portions of the Software. + . + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/debian/install b/debian/install new file mode 100644 index 0000000..61661ff --- /dev/null +++ b/debian/install @@ -0,0 +1,2 @@ +pkg/afancontrol.conf /etc/afancontrol +pkg/afancontrol.service /lib/systemd/system diff --git a/debian/patches/remove-setup-py-data-files.patch b/debian/patches/remove-setup-py-data-files.patch new file mode 100644 index 0000000..0f63099 --- /dev/null +++ b/debian/patches/remove-setup-py-data-files.patch @@ -0,0 +1,15 @@ +The files in the data_files list are already installed by the deb +package, so they need not to be installed by the Python package. +Index: afancontrol/setup.py +=================================================================== +--- afancontrol.orig/setup.py ++++ afancontrol/setup.py +@@ -9,8 +9,4 @@ with open("src/afancontrol/__init__.py", + + setup( + version=version, +- data_files=[ +- ("etc/afancontrol", ["pkg/afancontrol.conf"]), +- ("etc/systemd/system", ["pkg/afancontrol.service"]), +- ], + ) diff --git a/debian/patches/series b/debian/patches/series new file mode 100644 index 0000000..977061b --- /dev/null +++ b/debian/patches/series @@ -0,0 +1 @@ +remove-setup-py-data-files.patch diff --git a/debian/rules b/debian/rules new file mode 100644 index 0000000..c52aafe --- /dev/null +++ b/debian/rules @@ -0,0 +1,12 @@ +#!/usr/bin/make -f + +export LC_ALL=C.UTF-8 +export LANG=C.UTF-8 + +export PYTHONWARNINGS=d +export PYBUILD_NAME=afancontrol +export PYBUILD_TEST_PYTEST=1 +export PYBUILD_TEST_ARGS={dir}/tests/ + +%: + dh $@ --with systemd,python3 --buildsystem=pybuild diff --git a/debian/source/format b/debian/source/format new file mode 100644 index 0000000..163aaf8 --- /dev/null +++ b/debian/source/format @@ -0,0 +1 @@ +3.0 (quilt) diff --git a/debian/source/options b/debian/source/options new file mode 100644 index 0000000..cb61fa5 --- /dev/null +++ b/debian/source/options @@ -0,0 +1 @@ +extend-diff-ignore = "^[^/]*[.]egg-info/" diff --git a/debian/watch b/debian/watch new file mode 100644 index 0000000..40f8ed4 --- /dev/null +++ b/debian/watch @@ -0,0 +1,3 @@ +version=3 +opts=uversionmangle=s/(rc|a|b|c)/~$1/,pgpsigurlmangle=s/$/.asc/ \ +https://pypi.debian.net/afancontrol/afancontrol-(.+)\.(?:zip|tgz|tbz|txz|(?:tar\.(?:gz|bz2|xz))) diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..747126b --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= -n +SPHINXBUILD ?= sphinx-build +SOURCEDIR = . +BUILDDIR = _build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/_static/arctic_motherboard.svg b/docs/_static/arctic_motherboard.svg new file mode 100644 index 0000000..e68970b --- /dev/null +++ b/docs/_static/arctic_motherboard.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/_static/custom.css b/docs/_static/custom.css new file mode 100644 index 0000000..5197665 --- /dev/null +++ b/docs/_static/custom.css @@ -0,0 +1,10 @@ + +/* https://github.com/bitprophet/alabaster/issues/139#issuecomment-450294226 */ +div.body { + min-width: auto; + max-width: auto; +} + +dl { + min-width: 450px; +} diff --git a/docs/_static/micro_schematics.svg b/docs/_static/micro_schematics.svg new file mode 120000 index 0000000..38b3c5a --- /dev/null +++ b/docs/_static/micro_schematics.svg @@ -0,0 +1 @@ +../../arduino/micro_schematics.svg \ No newline at end of file diff --git a/docs/_static/noctua_arduino.svg b/docs/_static/noctua_arduino.svg new file mode 100644 index 0000000..e82e10f --- /dev/null +++ b/docs/_static/noctua_arduino.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/_static/noctua_motherboard.svg b/docs/_static/noctua_motherboard.svg new file mode 100644 index 0000000..18f2d6c --- /dev/null +++ b/docs/_static/noctua_motherboard.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/_static/pc_case_example.svg b/docs/_static/pc_case_example.svg new file mode 100644 index 0000000..99d2b34 --- /dev/null +++ b/docs/_static/pc_case_example.svg @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..e9088c2 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,79 @@ +# Configuration file for the Sphinx documentation builder. +# +# This file only contains a selection of the most common options. For a full +# list see the documentation: +# http://www.sphinx-doc.org/en/master/config + +# -- Path setup -------------------------------------------------------------- + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# +import os +import subprocess +# import sys +# sys.path.insert(0, os.path.abspath('.')) + + +# -- Project information ----------------------------------------------------- + + +def get_metadata_value(property_name): + # Requires python >=3.5 + + setup_py_dir = os.path.join(os.path.dirname(__file__), "..") + setup_py_file = os.path.join(setup_py_dir, "setup.py") + + out = subprocess.run( + ["python3", setup_py_file, "-q", "--%s" % property_name], + stdout=subprocess.PIPE, + cwd=setup_py_dir, + check=True, + ) + property_value = out.stdout.decode().strip() + return property_value + + +project = get_metadata_value("name") +author = get_metadata_value("author") + +_copyright_year = 2020 +copyright = "%s, %s" % (_copyright_year, author) + +# The full version, including alpha/beta/rc tags +release = get_metadata_value("version") +# The short X.Y version +version = release.rsplit(".", 1)[0] # `1.0.16+g40b2401` -> `1.0` + +# -- General configuration --------------------------------------------------- + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This pattern also affects html_static_path and html_extra_path. +exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] + + +# -- Options for HTML output ------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_theme = 'alabaster' + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + + +master_doc = 'index' diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..24709b8 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,511 @@ +Welcome to afancontrol's documentation! +======================================= + +:Docs: https://afancontrol.readthedocs.io/ +:Source Code: https://github.com/KostyaEsmukov/afancontrol +:Issue Tracker: https://github.com/KostyaEsmukov/afancontrol/issues +:PyPI: https://pypi.org/project/afancontrol/ + + +Introduction +~~~~~~~~~~~~ + +`afancontrol` stands for "Advanced fancontrol". Think of it as +`fancontrol `_ +with more advanced configuration abilities. + +`afancontrol` measures temperature from the sensors, computes the required +airflow and sets the PWM fan speeds accordingly. + +Key features: + +- Configurable temperature sources (currently supported ones are `lm-sensors` + temps, `hddtemp` and arbitrary shell commands); +- Configurable PWM fan implementations (currently supported ones are + `lm-sensors` PWM fans, `freeipmi` (readonly) and + `a custom Arduino-based solution `_); +- Configurable mappings between the temp sensors and the fans (e.g. fans + would be more sensitive to the closely located sensors than to + the farther-located ones); +- Temperature filters to smoothen fan reactions; +- Prometheus-compatible metrics exporter; +- Custom shell commands might be run when temperature reaches configured + thresholds; +- OS-agnostic (`afancontrol` is written in Python3 and might be run on any OS + which can run Python). + +`afancontrol` might be helpful in the following scenarios: + +- You have built a custom PC case with many different heat-generating parts + (like HDDs and GPUs) which you want to keep as quiet as possible, yet + being kept cool enough when required (at the cost of increased fan noise); +- You need to control more 4-pin PWM fans than there're connectors + available on your motherboard (with an Arduino board + connected via USB); +- You simply want to control a PWM fan with HDD temperatures. + + +How it works +~~~~~~~~~~~~ + +`afancontrol` should be run as a background service. Every 5 seconds +(configurable) a single `tick` is performed. During a `tick` +the temperatures are gathered and the required fan speeds are calculated +and set to the fans. Upon receiving a SIGTERM signal the program would +exit and the fans would be restored to the maximum speeds. + + +PWM Fan Line +------------ + +Each PWM fan has a PWM value associated with it which sets the speed of +the fan, where ``0`` PWM means that the fan is stopped, and ``255`` PWM +means that the fan is running at full speed. + +The correlation between the PWM value and the speed is usually not linear. +When computing the PWM value from a temperature, `afancontrol` uses +a specified range of the PWM values where the correlation between speed +and PWM is close to linear (these are the ``pwm_line_start`` and +``pwm_line_end`` config params). + +The bundled ``afancontrol fantest`` interactive command helps to determine +that range, which is specific to a pair of a PWM fan and a motherboard. +Here are some examples to give you an idea of the difference: + +1) A Noctua fan connected to an Arduino board. The correct settings in +this case would be: + +- ``pwm_line_start = 40`` +- ``pwm_line_end = 245`` + +.. image:: ./_static/noctua_arduino.svg + :target: ./_static/noctua_arduino.svg + +2) The same fan connected to a motherboard. The correct settings in this +case would be: + +- ``pwm_line_start = 110`` +- ``pwm_line_end = 235`` + +.. image:: ./_static/noctua_motherboard.svg + :target: ./_static/noctua_motherboard.svg + +3) Another fan connected to the same motherboard. The correct settings +in this case would be: + +- ``pwm_line_start = 70`` +- ``pwm_line_end = 235`` + +.. image:: ./_static/arctic_motherboard.svg + :target: ./_static/arctic_motherboard.svg + + +Mappings +-------- + +Consider the following almost typical PC case as an example: + +.. image:: ./_static/pc_case_example.svg + :target: ./_static/pc_case_example.svg + +Assuming that `Intake Fans` share the same PWM wire and are connected to +a `Fan 2` connector on the motherboard, and `Outtake Fans` share the PWM +wire of a `Fan 3` motherboard connector, the fans config might look +like the following: + +:: + + [fan: intake] + type = linux + pwm = /sys/class/hwmon/hwmon0/device/pwm2 + fan_input = /sys/class/hwmon/hwmon0/device/fan2_input + pwm_line_start = 100 + pwm_line_end = 240 + never_stop = no + + [fan: outtake] + type = linux + pwm = /sys/class/hwmon/hwmon0/device/pwm3 + fan_input = /sys/class/hwmon/hwmon0/device/fan3_input + pwm_line_start = 100 + pwm_line_end = 240 + never_stop = yes + +The temperature sensors might look like this: + +:: + + [temp: cpu] + type = file + path = /sys/class/hwmon/hwmon1/temp1_input + min = 50 + max = 65 + panic = 80 + + [temp: mobo] + type = file + path = /sys/class/hwmon/hwmon0/temp1_input + min = 55 + max = 65 + panic = 80 + + [temp: gpu] + type = exec + command = nvidia-smi --query-gpu=temperature.gpu --format=csv,noheader,nounits -i 0 + min = 55 + max = 65 + panic = 85 + + [temp: hdds] + type = hdd + path = /dev/sd? + min = 38 + max = 45 + panic = 50 + +Now we need to create the mappings between the temps and the fans. +The simplest mapping would be: + +:: + + [mapping: all] + fans = intake, outtake + temps = cpu, mobo, gpu, hdds + + +The more fine-grained mappings configuration: + +:: + + [mapping: hdd] + fans = intake, outtake * 0.6 + temps = hdds + + [mapping: mobo] + fans = intake, outtake + temps = cpu, mobo, gpu + +Fan speeds are calculated as following (this is a simplified +version for the matter of brevity): + +- For each temperature compute a desired `temperature speed` as + ``(current_temperature - min) / (max - min)``. +- For each mapping compute a desired `mapping speed` as a maximum across + all of the mapping's `temperature speeds`. +- For each fan compute a desired `fan speed` as a maximum across + all of the `mapping speeds`, multiplied by the fan modifier of that + mapping. +- For each fan apply a PWM value computed roughly + as ``max(pwm_line_start, fan_speed * pwm_line_end)``. + +If at least one fan reports a zero RPM when non-zero PWM is set (i.e. +the fan has jammed) or at least one temperature sensor reaches its `panic` +value, the `panic` mode is activated, which would cause all fans to run +at full speed until the issue is resolved. + + +Installation +~~~~~~~~~~~~ + +Debian package +-------------- + +There's a Dockerfile which can be used to build a Debian `.deb` package: + +:: + + # Build the .deb from the latest PyPI release: + git clone https://github.com/KostyaEsmukov/afancontrol.git + cd afancontrol + make deb-from-pypi + + # Install the package: + sudo apt install ./dist/debian/*.deb + +Perhaps one day the package might get published to the Debian repos, +so a simple ``apt install afancontrol`` would work. But for now, given +that the package is not popular enough yet, I believe it doesn't worth +the hassle. + +To upgrade, the similar steps should be performed on an up to date +`master` branch. + + +From PyPI +--------- + +`afancontrol` might be installed with `pip` like a typical Python package, +however, some extra steps are required to get the service running. + +Note that this section assumes that systemd is used for managing system +processes. If this is not the case, skip all the commands related to systemd +and make sure to create a similar service for your init system. + +:: + + # Install the package: + pip install afancontrol + # Or, if Arduino or Prometheus support are required: + pip install 'afancontrol[arduino,metrics]' + + # To use the motherboard-based sensors and PWM fans on Linux, + # install lm-sensors: + apt install lm-sensors + # To use hddtemp for measuring HDD/SSD temperatures, install it: + apt install hddtemp + +The stock config and a systemd service files must be copied +manually: + +:: + + PYPREFIX=`python3 -c 'import sys; print(sys.prefix)'` + # Usually PYPREFIX equals to `/usr/local`. + + sudo mkdir -p /etc/afancontrol/ + cp "${PYPREFIX}"/etc/afancontrol/afancontrol.conf /etc/afancontrol/ + cp "${PYPREFIX}"/etc/systemd/system/afancontrol.service /etc/systemd/system/ + +.. note:: + Do not edit the files under ``$PYPREFIX``! The ``pip`` command might + overwrite these files without asking, so your changes would be lost. + +To upgrade, ``pip install --upgrade afancontrol`` and +``systemctl restart afancontrol`` should be enough. + + +Getting Started +~~~~~~~~~~~~~~~ + +The bundled `configuration file`_ is generously annotated, so you could just +refer to it. + +.. _configuration file: https://github.com/KostyaEsmukov/afancontrol/blob/master/pkg/afancontrol.conf + +Generally speaking, the following steps are required (assuming that +the package is already installed): + +- `Prepare an Arduino board `_, if + extra PWM fan connectors are needed; +- Prepare and connect the PWM fans and temperature sensors; +- `Set up lm-sensors `_, if you want to use + sensors or fans connected to a motherboard on Linux; +- Edit the configuration file; +- Start the daemon and enable autostart on system boot: + +:: + + sudo systemctl start afancontrol.service + sudo systemctl enable afancontrol.service + + +PWM fans via Arduino +-------------------- + +An Arduino board might be used to control some PWM fans. + +Here is a `firmware`_ and schematics for Arduino Micro: + +.. _firmware: https://github.com/KostyaEsmukov/afancontrol/blob/master/arduino/micro.ino + +.. image:: ./_static/micro_schematics.svg + :target: ./_static/micro_schematics.svg + +The given firmware can be flashed as-is on a Genuine Arduino Micro +without any tweaks. It is important to use Micro, because the firmware +was designed specifically for it. For other boards you might need to change +the pins in the firmware. Refer to its code for the hints on the places +which should be modified. + +Once the board is flashed and connected, you may start using its pins +in `afancontrol` to control the PWM fans connected to the board. + + +lm-sensors +---------- + +`lm-sensors` is a Linux package which provides an ability to access and +control the temperature and PWM fan sensors attached to a motherboard +in userspace. + +Run the following command to make `lm-sensors` detect the available +sensors hardware: + +:: + + sudo sensors-detect + +Once configured, use the ``sensors`` command to get the current measurements. + +Then you'd have to manually map the sensors with their actual physical location. + +For example: + +:: + + $ sensors + it8728-isa-0228 + Adapter: ISA adapter + in0: +0.92 V (min = +0.00 V, max = +3.06 V) + in1: +1.46 V (min = +0.00 V, max = +3.06 V) + in2: +2.03 V (min = +0.00 V, max = +3.06 V) + in3: +2.04 V (min = +0.00 V, max = +3.06 V) + in4: +2.03 V (min = +0.00 V, max = +3.06 V) + in5: +2.22 V (min = +0.00 V, max = +3.06 V) + in6: +2.22 V (min = +0.00 V, max = +3.06 V) + 3VSB: +3.34 V (min = +0.00 V, max = +6.12 V) + Vbat: +3.31 V + fan1: 571 RPM (min = 0 RPM) + fan2: 1268 RPM (min = 0 RPM) + fan3: 0 RPM (min = 0 RPM) + fan4: 0 RPM (min = 0 RPM) + fan5: 0 RPM (min = 0 RPM) + temp1: +34.0°C (low = +127.0°C, high = +127.0°C) sensor = thermistor + temp2: -8.0°C (low = +127.0°C, high = +127.0°C) sensor = thermistor + temp3: +16.0°C (low = +127.0°C, high = +127.0°C) sensor = Intel PECI + + +There ``fan1`` corresponds to the CPU fan which is managed by BIOS, +``fan2`` corresponds to the single PWM fan attached to the motherboard +(which is typically called a "case" fan), ``temp1`` is a sensor (probably +in a chipset) yielding reasonable measurements (unlike ``temp2`` and ``temp3``). + +So the case fan's settings would be: + +- ``pwm = /sys/class/hwmon/hwmon0/pwm2`` +- ``fan_input = /sys/class/hwmon/hwmon0/fan2_input`` + +The ``temp1`` temperature sensor: + +- ``path = /sys/class/hwmon/hwmon0/temp1_input`` + +This was an old cheap motherboard, so you would probably be more lucky +and have the sensors which are yielding more trustworthy measurements. + + +Metrics +------- + +`afancontrol` supports exposing some metrics (like PWM, RPM, temperatures, +etc) via a Prometheus-compatible interface. To enable it, +the ``exporter_listen_host`` configuration option should be set to +an address which should be bound for an HTTP server. + +The metrics response would look like this: + +:: + + $ curl http://127.0.0.1:8083/metrics + # HELP temperature_threshold The threshold temperature value (in Celsius) for a temperature sensor + # TYPE temperature_threshold gauge + temperature_threshold{temp_name="mobo"} NaN + temperature_threshold{temp_name="hdds"} NaN + # HELP fan_pwm Current fan's PWM value (from 0 to 255) + # TYPE fan_pwm gauge + fan_pwm{fan_name="hdd"} 0.0 + # HELP fan_rpm Fan speed (in RPM) as reported by the fan + # TYPE fan_rpm gauge + fan_rpm{fan_name="hdd"} 0.0 + # HELP temperature_is_threshold Is threshold temperature reached for a temperature sensor + # TYPE temperature_is_threshold gauge + temperature_is_threshold{temp_name="mobo"} 0.0 + temperature_is_threshold{temp_name="hdds"} 0.0 + # HELP is_panic Is in panic mode + # TYPE is_panic gauge + is_panic 0.0 + # HELP temperature_current The current temperature value (in Celsius) from a temperature sensor + # TYPE temperature_current gauge + temperature_current{temp_name="mobo"} 35.0 + temperature_current{temp_name="hdds"} 38.0 + # HELP is_threshold Is in threshold mode + # TYPE is_threshold gauge + is_threshold 0.0 + # HELP temperature_is_panic Is panic temperature reached for a temperature sensor + # TYPE temperature_is_panic gauge + temperature_is_panic{temp_name="mobo"} 0.0 + temperature_is_panic{temp_name="hdds"} 0.0 + # HELP fan_pwm_normalized Current fan's normalized PWM value (from 0.0 to 1.0, within the `fan_pwm_line_start` and `fan_pwm_line_end` interval) + # TYPE fan_pwm_normalized gauge + fan_pwm_normalized{fan_name="hdd"} 0.0 + # HELP process_virtual_memory_bytes Virtual memory size in bytes. + # TYPE process_virtual_memory_bytes gauge + process_virtual_memory_bytes 227667968.0 + # HELP process_resident_memory_bytes Resident memory size in bytes. + # TYPE process_resident_memory_bytes gauge + process_resident_memory_bytes 22659072.0 + # HELP process_start_time_seconds Start time of the process since unix epoch in seconds. + # TYPE process_start_time_seconds gauge + process_start_time_seconds 1557312610.7 + # HELP process_cpu_seconds_total Total user and system CPU time spent in seconds. + # TYPE process_cpu_seconds_total counter + process_cpu_seconds_total 3850.62 + # HELP process_open_fds Number of open file descriptors. + # TYPE process_open_fds gauge + process_open_fds 7.0 + # HELP process_max_fds Maximum number of open file descriptors. + # TYPE process_max_fds gauge + process_max_fds 8192.0 + # HELP fan_pwm_line_start PWM value where a linear correlation with RPM starts for the fan + # TYPE fan_pwm_line_start gauge + fan_pwm_line_start{fan_name="hdd"} 70.0 + # HELP tick_duration Duration of a single tick + # TYPE tick_duration histogram + tick_duration_bucket{le="0.1"} 0.0 + tick_duration_bucket{le="0.25"} 369134.0 + tick_duration_bucket{le="0.5"} 532386.0 + tick_duration_bucket{le="0.75"} 532441.0 + tick_duration_bucket{le="1.0"} 532458.0 + tick_duration_bucket{le="2.5"} 532500.0 + tick_duration_bucket{le="5.0"} 532516.0 + tick_duration_bucket{le="10.0"} 532516.0 + tick_duration_bucket{le="+Inf"} 532516.0 + tick_duration_count 532516.0 + tick_duration_sum 130972.32457521433 + # HELP fan_pwm_line_end PWM value where a linear correlation with RPM ends for the fan + # TYPE fan_pwm_line_end gauge + fan_pwm_line_end{fan_name="hdd"} 235.0 + # HELP temperature_is_failing The temperature sensor is failing (it isn't returning any data) + # TYPE temperature_is_failing gauge + temperature_is_failing{temp_name="mobo"} 0.0 + temperature_is_failing{temp_name="hdds"} 0.0 + # HELP fan_is_stopped Is PWM fan stopped because the corresponding temperatures are already low + # TYPE fan_is_stopped gauge + fan_is_stopped{fan_name="hdd"} 1.0 + # HELP last_metrics_tick_seconds_ago The time in seconds since the last tick (which also updates these metrics) + # TYPE last_metrics_tick_seconds_ago gauge + last_metrics_tick_seconds_ago 4.541638209018856 + # HELP fan_is_failing Is PWM fan marked as failing (e.g. because it has jammed) + # TYPE fan_is_failing gauge + fan_is_failing{fan_name="hdd"} 0.0 + # HELP arduino_is_connected Is Arduino board connected via Serial + # TYPE arduino_is_connected gauge + # HELP temperature_min The min temperature value (in Celsius) for a temperature sensor + # TYPE temperature_min gauge + temperature_min{temp_name="mobo"} 40.0 + temperature_min{temp_name="hdds"} 38.0 + # HELP temperature_max The max temperature value (in Celsius) for a temperature sensor + # TYPE temperature_max gauge + temperature_max{temp_name="mobo"} 50.0 + temperature_max{temp_name="hdds"} 45.0 + # HELP temperature_panic The panic temperature value (in Celsius) for a temperature sensor + # TYPE temperature_panic gauge + temperature_panic{temp_name="mobo"} 60.0 + temperature_panic{temp_name="hdds"} 50.0 + # HELP arduino_status_age_seconds Seconds since the last `status` message from the Arduino board (measured at the latest tick) + # TYPE arduino_status_age_seconds gauge + + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` + +Table of contents +================= + +.. toctree:: + :maxdepth: 3 + + index diff --git a/pkg/afancontrol.conf b/pkg/afancontrol.conf new file mode 100644 index 0000000..8889fc0 --- /dev/null +++ b/pkg/afancontrol.conf @@ -0,0 +1,269 @@ +[daemon] +# Default: /run/afancontrol.pid +pidfile = /run/afancontrol.pid + +# Default: (empty value) +logfile = /var/log/afancontrol.log + +# The ticks interval in seconds. Tick is a single operation of retrieving +# temperature values from the sensors and setting the computed fan speeds. +# Default: 5 +interval = 5 + +# Hddtemp location. Used by the `type = hdd` temperature sensors. +# Default: hddtemp +;hddtemp = /usr/local/bin/hddtemp + +# `ipmi-sensors` location from the `freeipmi-tools` package. +# Used by the `type = freeipmi` fans. +# Default: ipmi-sensors +;ipmi_sensors = /usr/local/bin/ipmi-sensors + +# Prometheus exporter listening hostname and TCP port. +# Default: (empty value) +;exporter_listen_host = 127.0.0.1:8083 + +[actions] +# Temperature sensors have 2 limits: `threshold` and `panic` temperature. +# When any of the sensors reach their `threshold` value, the `threshold` mode +# is activated. Same for the `panic` mode. +# +# When any of the 2 modes is activated, all of the available fans would start +# working at full speed. +# +# In the default configuration there's no difference between the two modes. +# But it is possible to call different shell commands for each mode, which +# would allow to make different things. For example, in `threshold` mode +# you could stop some services which produce significant load, and in `panic` +# mode you could stop even more (if that didn't help to lower the temperatures). + +# Shell command which will be used to report important events. +# %REASON% will be replaced with report reason, %MESSAGE% with report message +# Examples: +# printf "Reason: %s\nMessage: %s" "%REASON%" "%MESSAGE%" | wall +# kdialog --title "afancontrol report" --error "Reason: %REASON%\nMessage: %MESSAGE%" +# Default: printf "Subject: %s\nTo: %s\n\n%b" "afancontrol daemon report: %REASON%" root "%MESSAGE%" | sendmail -t +;report_cmd = + +# Global panic enter shell command +# Default: (empty value) +;panic_enter_cmd = + +# Global panic leave shell command +# Default: (empty value) +;panic_leave_cmd = + +# Global threshold enter shell command +# Default: (empty value) +;threshold_enter_cmd = + +# Global threshold leave shell command +# Default: (empty value) +;threshold_leave_cmd = + +# `[filter:name]` - define a temperature filter. The `name` must be unique. +[filter: moving_median_p3] +# Temperature filters can be used to smoothen the observations to avoid +# rapid fan speed changes. +# +# Filter type. +# Possible values: +# `moving_median`: A moving median filter. Useful to ignore inadequately +# large individual measurements from unstable sensors. +# Recommended `window_size` is 3 or 5, because with a too +# large window size a sudden increase in temperature might +# not get a timely fan speed reaction. +# `moving_quantile`: A moving quantile filter. Useful to amplify high +# temperatures to make smoother reaction while being +# extra cautious (i.e. tending to assume that +# the actual temperature is higher than the one which +# is being reported). Recommended `window_size` is 10, +# `quantile` is 0.8 or 0.9. It is also possible to invert +# the reaction speed: to make it react slower just use a lower +# quantile value (such as 0.3). +type = moving_median + +# Number of observations kept in the moving window. +# Default: 3. +window_size = 3 + +# Quantile value for the `moving_quantile` filter, mandatory. +;quantile=0.8 + +# [temp:name] - is a temperature sensor section. The `name` must be unique. +[temp:mobo] +# Type of the sensor. +# Possible values: +# `file`: Read files like /sys/class/hwmon/hwmon0/device/temp1_input. +# These files contain temperature in Celsius multiplied by 1000. +# `hdd`: Query temperatures from HDD using `hddtemp`. If multiple drives +# are specified (with a glob pattern), the sensor would report +# the maximum temperature among all matched devices. +# `exec`: Shell command which will return temperature in Celsius +# (which might be float). Output might also contain +# the `min` and `max` temperatures separated by a newline. +# This field is mandatory. +type = file + +# Shell command which will return a temperature. +# Mandatory for the `type = exec`. +;command = nvidia-smi --query-gpu=temperature.gpu --format=csv,noheader,nounits -i 0 +;command = nvme smart-log /dev/nvme0 | grep "^temperature" | grep -oP '[0-9]+' +;command = iStats cpu temp --value-only + +# When `type = file`: this is the path to the file. The path might be a glob pattern, +# but it must expand to a single file. +# When `type = hdd`: this is the path to the target device (might be a glob pattern) +# Mandatory when `type` equals to `file` or `hdd`. +path = /sys/class/hwmon/hwmon0/device/temp1_input +;path = /sys/devices/pci0000:00/0000:00:01.3/0000:03:00.2/0000:20:08.0/0000:2a:00.0/hwmon/hwmon*/temp1_input +;path = /dev/sd? + +# Name of the temperature filter. +# Optional, unfiltered observations will be used if not specified. +filter = moving_median_p3 + +# Temperature at which a fan should be running at minimum speed +# Must be set for `hdd`. Can be detected automatically for `file` +# and `exec` (but not always). +min = 30 + +# Temperature at which a fan should be running at full speed +# Must be set for `hdd`. Can be detected automatically for `file` +# and `exec` (but not always). +max = 40 + +# Temperature at which this sensor will enter the panic mode +# Default: (empty value) +;panic = + +# Temperature at which this sensor will enter the threshold mode +# Default: (empty value) +;threshold = + +# Sensor panic enter shell command +# Default: None +;panic_enter_cmd = + +# Sensor panic leave shell command +# Default: None +;panic_leave_cmd = + +# Sensor threshold enter shell command +# Default: None +;threshold_enter_cmd = + +# Sensor threshold leave shell command +# Default: None +;threshold_leave_cmd = + +[readonly_fan: cpu] +# A readonly fan: i.e. just read RPM and never attempt to control it. +# Useful for exposing a CPU fan speed in metrics. +# +# The properties are the same as in `[fan: ...]`. Both sections share +# the same namespace, which means that a single fan name can be used +# only in one of the sections. +type = linux +fan_input = /sys/class/hwmon/hwmon0/device/fan1_input + + +# [fan:name] - is a PWM fan section. The `name` must be unique. +[fan: hdd] +# Type of the fan. +# Possible values: +# `linux`: The default fan type. This is a fan connected to +# the motherboard using a 4-pin connector and exposed by lm-sensors +# as a file like `/sys/class/hwmon/hwmon0/device/pwm2`. +# `arduino`: A PWM fan connected via an Arduino board. +# `freeipmi`: A PWM fan exposed via IPMI. Requires `freeipmi-tools` package. +# Currently supported only in the `[readonly_fan: ...]` sections. +# Default: linux +type = linux + +# Path to the PWM file of the fan. +# Mandatory when `type = linux`, optional in `[readonly_fan: ...]` sections. +pwm = /sys/class/hwmon/hwmon0/device/pwm2 + +# Path to the RPM file of the fan. +# Mandatory when `type = linux`. +fan_input = /sys/class/hwmon/hwmon0/device/fan2_input + +# Arduino board name as described by an `[arduino: name]` section. +# Mandatory when `type = arduino`. +;arduino_name = mymicro + +# The pin of the Arduino board where the PWM wire is connected to +# (usually the blue one). +# Mandatory when `type = arduino`, optional in `[readonly_fan: ...]` sections. +;pwm_pin = 9 + +# The pin of the Arduino board where the Tachometer wire is connected to +# (usually the yellow one). +# Mandatory when `type = arduino`. +;tacho_pin = 3 + +# The name of the fan as reported by the `ipmi-sensors --sensor-types Fan` command. +# Mandatory when `type = freeipmi`. +;name = FAN1 + +# Some fans have almost linear correlation between PWM and RPM, some haven't. +# `pwm_line_start` is the PWM value where the linear correlation starts, +# `pwm_line_end` is where it ends. +# You can use the `afancontrol fantest` command to run a test which would +# allow you to find out these values your your specific fans. +# +# Default: 100. Must not be set in the `[readonly_fan: ...]` sections. +pwm_line_start = 100 +# Default 240. Must not be set in the `[readonly_fan: ...]` sections. +pwm_line_end = 240 + +# Should the fan be stopped on speed 0% or not. If not, it would be running +# with the `pwm_line_start` PWM value. +# Default: yes. Must not be set in the `[readonly_fan: ...]` sections. +never_stop = no + +# [arduino:name] - a section describing an Arduino board with PWM fans connected to it. +;[arduino: mymicro] +# The Serial interface url. +# Mandatory. +;serial_url = /dev/ttyACM0 + +# The Serial interface Baudrate. +# Default: 115200 +;baudrate = 115200 + +# The Status command timeout in seconds. The board periodically sends +# a Status command -- the current RPM and PWM values. When the status +# haven't been received for that amount of time, the corresponding fans +# would be considered failing. +# Default: 5 +;status_ttl = 5 + + +# Relationships between fans and temps +[mapping:1] +# Comma-separated list of fans for this mapping. Fan names might be +# multiplied by float, e.g. `name * 0.55`. This means that the speed +# for that fan will be just 55% when the specified temperatures would be +# at their `max` value. +# +# You may want to apply a multiplier if the fan is far from +# the corresponding temperature sensors. +# +# Multiple mappings can be specified, each temp and fan might be used +# in different mappings multiple times. +# +# Readonly fans cannot be used in mappings. +# +# The resulting fan speed would be the maximum value calculated along +# all mappings. + +# Comma-separated list of fans with modifiers. +# Example: `fans = myfan, myfan2 * 0.6, myfan3`. +# Mandatory. +fans = hdd*0.6 + +# Comma-separated list of temp sensors. +# Mandatory. +temps = mobo diff --git a/pkg/afancontrol.service b/pkg/afancontrol.service new file mode 100644 index 0000000..f478758 --- /dev/null +++ b/pkg/afancontrol.service @@ -0,0 +1,12 @@ +[Unit] +Description=Advanced Fan Control program +After=lm-sensors.service + +[Service] +LimitNOFILE=8192 +ExecStartPre=/usr/bin/afancontrol daemon --test +ExecStart=/usr/bin/afancontrol daemon --pidfile /run/afancontrol.pid +PIDFile=/run/afancontrol.pid + +[Install] +WantedBy=multi-user.target diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..b046329 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,112 @@ +[coverage:run] +branch = True +source = + src + tests + +[coverage:report] +show_missing = True +# The total coverage is higher, but when running tests without extras, +# not all code is being tested, thus the coverage is lower. +fail_under = 60 +exclude_lines = + @abc.abstractmethod + @abc.abstractproperty + pragma: no cover + +[flake8] +; E203 -- ignore whitespace in slices. See https://github.com/ambv/black#slices +; W503 line break before binary operator +; C901 '***' is too complex (10) +ignore = E203,W503 +max-complexity = 13 +max-line-length = 90 +per-file-ignores = + src/afancontrol/config.py:C901 + +[isort] +; https://github.com/timothycrosley/isort#multi-line-output-modes +multi_line_output = 3 +; https://github.com/ambv/black#how-black-wraps-lines +include_trailing_comma = True +force_grid_wrap = 0 +combine_as_imports = True +line_length = 88 + +[metadata] +author = Kostya Esmukov +author_email = kostya@esmukov.ru +classifier = + Development Status :: 5 - Production/Stable + Intended Audience :: System Administrators + License :: OSI Approved :: MIT License + Natural Language :: English + Operating System :: POSIX :: Linux + Programming Language :: Python + Programming Language :: Python :: 3 + Programming Language :: Python :: 3 :: Only + Programming Language :: Python :: 3.6 + Programming Language :: Python :: 3.7 + Programming Language :: Python :: 3.8 + Programming Language :: Python :: 3.9 + Topic :: System :: Hardware + Topic :: System :: Monitoring + Topic :: System :: Systems Administration +description = Advanced fancontrol daemon +long_description = file: README.rst +name = afancontrol +url = https://github.com/KostyaEsmukov/afancontrol + +[mypy] +check_untyped_defs = True + +[mypy-prometheus_client.*] +ignore_missing_imports = True + +[mypy-pytest.*] +ignore_missing_imports = True + +[mypy-serial.*] +ignore_missing_imports = True + +[options] +include_package_data = True +install_requires = + click>=6 +package_dir = + = src +packages = find: +python_requires = >=3.6 + +[options.entry_points] +console_scripts = + afancontrol = afancontrol.__main__:main + +[options.extras_require] +arduino = + pyserial>=3.0 +metrics = + prometheus-client>=0.1.0 +dev = + black==20.8b1 + coverage==5.3 + flake8==3.8.4 + isort==5.5.4 + mypy==0.782 + pytest==6.1.0 + requests + sphinx==3.2.1 + wheel + +[options.packages.find] +where = src + +[tool:pytest] +log_level = INFO + +; Show warnings. Similar to `python -Wd`. +filterwarnings = d + +; Show skip reasons +; Print shorter tracebacks +addopts = -ra --tb=short diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..387955c --- /dev/null +++ b/setup.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python3 + +import re + +from setuptools import setup + +with open("src/afancontrol/__init__.py", "rt") as f: + version = re.search(r'^__version__ = "(.*?)"$', f.read()).group(1) + +setup( + version=version, + data_files=[ + ("etc/afancontrol", ["pkg/afancontrol.conf"]), + ("etc/systemd/system", ["pkg/afancontrol.service"]), + ], +) diff --git a/src/afancontrol/__init__.py b/src/afancontrol/__init__.py new file mode 100644 index 0000000..528787c --- /dev/null +++ b/src/afancontrol/__init__.py @@ -0,0 +1 @@ +__version__ = "3.0.0" diff --git a/src/afancontrol/__main__.py b/src/afancontrol/__main__.py new file mode 100644 index 0000000..6e51b66 --- /dev/null +++ b/src/afancontrol/__main__.py @@ -0,0 +1,21 @@ +import click + +import afancontrol +from afancontrol.daemon import daemon +from afancontrol.fantest import fantest + + +@click.group() +@click.version_option(version=afancontrol.__version__) +def main(): + """afancontrol is an Advanced Fan Control program, which controls PWM + fans according to the current temperatures of the system components. + """ + pass + + +main.add_command(daemon) +main.add_command(fantest) + +if __name__ == "__main__": + main(prog_name="afancontrol") diff --git a/src/afancontrol/arduino.py b/src/afancontrol/arduino.py new file mode 100644 index 0000000..2e87b54 --- /dev/null +++ b/src/afancontrol/arduino.py @@ -0,0 +1,308 @@ +import json +import queue +import struct +import threading +from timeit import default_timer +from typing import TYPE_CHECKING, Any, Dict, NewType, Optional + +from afancontrol.configparser import ConfigParserSection +from afancontrol.logger import logger + +if TYPE_CHECKING: + from afancontrol.pwmfan.base import PWMValue + +try: + from serial import serial_for_url + from serial.threaded import LineReader, ReaderThread + + pyserial_available = True +except ImportError: + LineReader = object + ReaderThread = object + + pyserial_available = False + +ArduinoName = NewType("ArduinoName", str) +ArduinoPin = NewType("ArduinoPin", int) + +DEFAULT_BAUDRATE = 115200 +DEFAULT_STATUS_TTL = 5 + + +class ArduinoConnection: + def __init__( + self, + name: ArduinoName, + serial_url: str, + *, + baudrate: int = DEFAULT_BAUDRATE, + status_ttl: int = DEFAULT_STATUS_TTL + ) -> None: + if not pyserial_available: + raise RuntimeError( + "`pyserial` is not installed. " + "Run `pip install 'afancontrol[arduino]'`." + ) + self.name = name + self.url = serial_url + self.baudrate = baudrate + self.status_ttl = status_ttl + self._reader_thread = _AutoRetriedReaderThread( + lambda: _StatusProtocol(self), url=serial_url, baudrate=baudrate + ) + self._context_manager_depth = 0 + self._status: Optional[Dict[str, Dict[str, int]]] = None + self._status_clock: Optional[float] = None + self._status_lock = threading.Lock() + self._status_event = threading.Event() + + @classmethod + def from_configparser( + cls, section: ConfigParserSection[ArduinoName] + ) -> "ArduinoConnection": + return cls( + name=section.name, + serial_url=section["serial_url"], + baudrate=section.getint("baudrate", fallback=DEFAULT_BAUDRATE), + status_ttl=section.getint("status_ttl", fallback=DEFAULT_STATUS_TTL), + ) + + def __eq__(self, other): + if isinstance(other, type(self)): + return ( + self.name == other.name + and self.url == other.url + and self.baudrate == other.baudrate + and self.status_ttl == other.status_ttl + ) + + return NotImplemented + + def __ne__(self, other): + return not (self == other) + + def __repr__(self): + return "%s(%r, %r, baudrate=%r, status_ttl=%r)" % ( + type(self).__name__, + self.name, + self.url, + self.baudrate, + self.status_ttl, + ) + + def __enter__(self): # reentrant + if self._context_manager_depth == 0: + self._reader_thread.__enter__() + self._context_manager_depth += 1 + return self + + def __exit__(self, exc_type, exc_value, exc_tb): + self._context_manager_depth -= 1 + if self._context_manager_depth == 0: + return self._reader_thread.__exit__(exc_type, exc_value, exc_tb) + return None + + def _clock(self): + return default_timer() + + def _incoming_message(self, message: Dict[str, Any]) -> None: + # Called by the pyserial Protocol `_StatusProtocol`. + if "error" in message: + logger.warning("Received an error from Arduino %s: %r", self.url, message) + else: + self._update_status(message) + + def _update_status(self, status: Dict[str, Dict[str, int]]) -> None: + with self._status_lock: + self._status = status + self._status_clock = self._clock() + self._status_event.set() + + @property + def is_connected(self) -> bool: + try: + with self._status_lock: + self._ensure_status_is_valid() + except Exception: + return False + else: + return True + + def get_rpm(self, pin: ArduinoPin) -> int: + if self._status is None: + self.wait_for_status() + with self._status_lock: + self._ensure_status_is_valid() + assert self._status is not None + return int(self._status["fan_inputs"][str(pin)]) + + def get_pwm(self, pin: ArduinoPin) -> int: + if self._status is None: + self.wait_for_status() + with self._status_lock: + self._ensure_status_is_valid() + assert self._status is not None + return int(self._status["fan_pwm"][str(pin)]) + + def _ensure_status_is_valid(self): + if self._status is None: + raise RuntimeError("No status from the Arduino board at %s" % self.url) + assert self._status_clock is not None + status_age = self._clock() - self._status_clock + if status_age > self.status_ttl: + self._reader_thread.check_connection() + raise RuntimeError( + "The last received status from the Arduino board " + "at %s was too long ago: %s seconds" % (self.url, status_age) + ) + + @property + def status_age_seconds(self) -> float: + with self._status_lock: + if self._status_clock is None: + return float("nan") + return self._clock() - self._status_clock + + def set_pwm(self, pin: ArduinoPin, pwm: "PWMValue") -> None: + command = SetPWMCommand(pwm_pin=pin, pwm=pwm).to_bytes() + transport = self._reader_thread.transport + try: + transport.write(command) + transport.flush() + except Exception: + self._reader_thread.check_connection() + raise + + def wait_for_status(self) -> None: + self._status_event.clear() + if self._status_event.wait(self.status_ttl) is not True: + raise RuntimeError( + "Timed out waiting for the status from Arduino board at %s" % self.url + ) + + +class SetPWMCommand: + command = b"\xf1" + + def __init__(self, *, pwm_pin: ArduinoPin, pwm: "PWMValue") -> None: + self.pwm_pin = pwm_pin + self.pwm = pwm + + def __repr__(self): + return "%s(pwm_pin=%r, pwm=%r)" % (type(self).__name__, self.pwm_pin, self.pwm) + + def to_bytes(self): + return struct.pack("sBB", self.command, self.pwm_pin, self.pwm) + + @classmethod + def parse(cls, b: bytes) -> "SetPWMCommand": + command, pwm_pin, pwm = struct.unpack("sBB", b) + if command != cls.command: + raise ValueError( + "Invalid command marker. Expected %r, got %r" % (cls.command, command) + ) + return cls(pwm_pin=ArduinoPin(pwm_pin), pwm=pwm) + + +class _StatusProtocol(LineReader): + TERMINATOR = b"\n" + + def __init__(self, arduino_connection: ArduinoConnection) -> None: + super().__init__() + self._arduino_connection = arduino_connection + + def handle_line(self, line: str) -> None: + try: + message = json.loads(line) + self._arduino_connection._incoming_message(message) + except Exception: # `handle_line` should not raise exceptions + logger.error( + "Unable to parse the status line from Arduino as json: %r", + line, + exc_info=True, + ) + + +class _AutoRetriedReaderThread: + _QUEUE_STOP = object() + _QUEUE_CHECK = object() + + def __init__(self, protocol_factory, **serial_for_url_kwargs) -> None: + self.protocol_factory = protocol_factory + self.serial_for_url_kwargs = serial_for_url_kwargs + self._reader_thread: Optional[ReaderThread] = None + self._transport: Optional[ReaderThread] = None + self._watchdog_thread: Optional[threading.Thread] = None + self._watchdog_queue: queue.Queue[Any] = queue.Queue() + + def __enter__(self): # reusable + # TODO ?? maybe clean the _watchdog_queue? + self._reader_thread, self._transport = self._new_reader_thread() + self._watchdog_thread = threading.Thread(target=self._thread_run, daemon=True) + self._watchdog_thread.start() + return self + + def __exit__(self, exc_type, exc_value, exc_tb): + assert self._reader_thread is not None + assert self._watchdog_thread is not None + self._watchdog_queue.put(self._QUEUE_STOP) + self._watchdog_thread.join() + self._reader_thread.close() + self._reader_thread = None + self._transport = None + + @property + def transport(self): + return self._transport + + def check_connection(self): + self._watchdog_queue.put(self._QUEUE_CHECK) + + def _new_reader_thread(self): + ser = serial_for_url(**self.serial_for_url_kwargs) + thread = _ReaderThreadWithFlush(ser, self.protocol_factory) + thread.start() + transport, _ = thread.connect() + return thread, transport + + def _thread_run(self): + while True: + item = self._watchdog_queue.get() + try: + if self._reader_thread is None: + break + if item is self._QUEUE_STOP: + break + elif item is self._QUEUE_CHECK: + if self._reader_thread.alive: + continue + try: + self._reader_thread.close() + except Exception: + logger.error( + "Unable to cleanly close the Serial connection", + exc_info=True, + ) + self._reader_thread, self._transport = self._new_reader_thread() + except Exception: # `_thread_run` should not raise + logger.error( + "Error in the Arduino connection watchdog thread", exc_info=True + ) + finally: + self._watchdog_queue.task_done() + + +class _ReaderThreadWithFlush(ReaderThread): + def flush(self): + with self._lock: + self.serial.flush() + + def close(self): + try: + super().close() + except Exception: + # `super().close()` also calls `self.stop()` which might raise + # and prevent `self.serial.close()` from being called. + with self._lock: + self.serial.close() + raise diff --git a/src/afancontrol/config.py b/src/afancontrol/config.py new file mode 100644 index 0000000..625073b --- /dev/null +++ b/src/afancontrol/config.py @@ -0,0 +1,389 @@ +import configparser +from pathlib import Path +from typing import ( + Dict, + Mapping, + NamedTuple, + NewType, + Optional, + Sequence, + Tuple, + TypeVar, +) + +import afancontrol.filters +from afancontrol.arduino import ArduinoConnection, ArduinoName +from afancontrol.configparser import ConfigParserSection, iter_sections +from afancontrol.exec import Programs +from afancontrol.filters import FilterName, TempFilter +from afancontrol.logger import logger +from afancontrol.pwmfan import FanName, ReadonlyFanName +from afancontrol.pwmfannorm import PWMFanNorm, ReadonlyPWMFanNorm +from afancontrol.temp import FilteredTemp, TempName + +DEFAULT_CONFIG = "/etc/afancontrol/afancontrol.conf" +DEFAULT_PIDFILE = "/run/afancontrol.pid" +DEFAULT_REPORT_CMD = ( + 'printf "Subject: %s\nTo: %s\n\n%b"' + ' "afancontrol daemon report: %REASON%" root "%MESSAGE%"' + " | sendmail -t" +) + +MappingName = NewType("MappingName", str) + +T = TypeVar("T") + + +class FanSpeedModifier(NamedTuple): + fan: FanName + modifier: float # [0..1] + + +class FansTempsRelation(NamedTuple): + temps: Sequence[TempName] + fans: Sequence[FanSpeedModifier] + + +class AlertCommands(NamedTuple): + enter_cmd: Optional[str] + leave_cmd: Optional[str] + + +class Actions(NamedTuple): + panic: AlertCommands + threshold: AlertCommands + + @classmethod + def from_configparser(cls, section: ConfigParserSection) -> "Actions": + panic = AlertCommands( + enter_cmd=section.get("panic_enter_cmd", fallback=None), + leave_cmd=section.get("panic_leave_cmd", fallback=None), + ) + + threshold = AlertCommands( + enter_cmd=section.get("threshold_enter_cmd", fallback=None), + leave_cmd=section.get("threshold_leave_cmd", fallback=None), + ) + + return cls(panic=panic, threshold=threshold) + + +class TriggerConfig(NamedTuple): + global_commands: Actions + temp_commands: Mapping[TempName, Actions] + + +class DaemonCLIConfig(NamedTuple): + pidfile: Optional[str] + logfile: Optional[str] + exporter_listen_host: Optional[str] + + +class DaemonConfig(NamedTuple): + pidfile: Optional[str] + logfile: Optional[str] + interval: int + exporter_listen_host: Optional[str] + + @classmethod + def from_configparser( + cls, section: ConfigParserSection, daemon_cli_config: DaemonCLIConfig + ) -> "DaemonConfig": + pidfile = first_not_none( + daemon_cli_config.pidfile, section.get("pidfile", fallback=DEFAULT_PIDFILE) + ) + if pidfile is not None and not pidfile.strip(): + pidfile = None + + logfile = first_not_none( + daemon_cli_config.logfile, section.get("logfile", fallback=None) + ) + + interval = section.getint("interval", fallback=5) + + exporter_listen_host = first_not_none( + daemon_cli_config.exporter_listen_host, + section.get("exporter_listen_host", fallback=None), + ) + + return cls( + pidfile=pidfile, + logfile=logfile, + interval=interval, + exporter_listen_host=exporter_listen_host, + ) + + +class ParsedConfig(NamedTuple): + daemon: DaemonConfig + report_cmd: str + triggers: TriggerConfig + arduino_connections: Mapping[ArduinoName, ArduinoConnection] + fans: Mapping[FanName, PWMFanNorm] + readonly_fans: Mapping[ReadonlyFanName, ReadonlyPWMFanNorm] + temps: Mapping[TempName, FilteredTemp] + mappings: Mapping[MappingName, FansTempsRelation] + + +def parse_config(config_path: Path, daemon_cli_config: DaemonCLIConfig) -> ParsedConfig: + config = configparser.ConfigParser(interpolation=None) + try: + config.read_string(config_path.read_text(), source=str(config_path)) + except Exception as e: + raise RuntimeError("Unable to parse %s:\n%s" % (config_path, e)) + + daemon, programs = _parse_daemon(config, daemon_cli_config) + report_cmd, global_commands = _parse_actions(config) + arduino_connections = _parse_arduino_connections(config) + filters = _parse_filters(config) + temps, temp_commands = _parse_temps(config, programs, filters) + fans = _parse_fans(config, arduino_connections) + readonly_fans = _parse_readonly_fans(config, arduino_connections, programs) + _check_fans_namespace(fans, readonly_fans) + mappings = _parse_mappings(config, fans, temps) + + return ParsedConfig( + daemon=daemon, + report_cmd=report_cmd, + triggers=TriggerConfig( + global_commands=global_commands, temp_commands=temp_commands + ), + arduino_connections=arduino_connections, + fans=fans, + readonly_fans=readonly_fans, + temps=temps, + mappings=mappings, + ) + + +def first_not_none(*parts: Optional[T]) -> Optional[T]: + for part in parts: + if part is not None: + return part + return parts[-1] # None + + +def _parse_daemon( + config: configparser.ConfigParser, daemon_cli_config: DaemonCLIConfig +) -> Tuple[DaemonConfig, Programs]: + section: ConfigParserSection[str] = ConfigParserSection(config["daemon"]) + daemon_config = DaemonConfig.from_configparser(section, daemon_cli_config) + programs = Programs.from_configparser(section) + section.ensure_no_unused_keys() + + return daemon_config, programs + + +def _parse_actions(config: configparser.ConfigParser) -> Tuple[str, Actions]: + section: ConfigParserSection[str] = ConfigParserSection(config["actions"]) + report_cmd = section.get("report_cmd", fallback=DEFAULT_REPORT_CMD) + actions = Actions.from_configparser(section) + section.ensure_no_unused_keys() + + return report_cmd, actions + + +def _parse_arduino_connections( + config: configparser.ConfigParser, +) -> Mapping[ArduinoName, ArduinoConnection]: + arduino_connections: Dict[ArduinoName, ArduinoConnection] = {} + for section in iter_sections(config, "arduino", ArduinoName): + if section.name in arduino_connections: + raise RuntimeError( + "Duplicate arduino section declaration for '%s'" % section.name + ) + arduino_connections[section.name] = ArduinoConnection.from_configparser(section) + section.ensure_no_unused_keys() + + # Empty arduino_connections is ok + return arduino_connections + + +def _parse_filters( + config: configparser.ConfigParser, +) -> Mapping[FilterName, TempFilter]: + filters: Dict[FilterName, TempFilter] = {} + for section in iter_sections(config, "filter", FilterName): + if section.name in filters: + raise RuntimeError( + "Duplicate filter section declaration for '%s'" % section.name + ) + filters[section.name] = afancontrol.filters.from_configparser(section) + section.ensure_no_unused_keys() + + # Empty filters is ok + return filters + + +def _parse_temps( + config: configparser.ConfigParser, + programs: Programs, + filters: Mapping[FilterName, TempFilter], +) -> Tuple[Mapping[TempName, FilteredTemp], Mapping[TempName, Actions]]: + temps: Dict[TempName, FilteredTemp] = {} + temp_commands: Dict[TempName, Actions] = {} + for section in iter_sections(config, "temp", TempName): + if section.name in temps: + raise RuntimeError( + "Duplicate temp section declaration for '%s'" % section.name + ) + temps[section.name] = FilteredTemp.from_configparser(section, filters, programs) + temp_commands[section.name] = Actions.from_configparser(section) + section.ensure_no_unused_keys() + + return temps, temp_commands + + +def _parse_fans( + config: configparser.ConfigParser, + arduino_connections: Mapping[ArduinoName, ArduinoConnection], +) -> Mapping[FanName, PWMFanNorm]: + fans: Dict[FanName, PWMFanNorm] = {} + for section in iter_sections(config, "fan", FanName): + if section.name in fans: + raise RuntimeError( + "Duplicate fan section declaration for '%s'" % section.name + ) + fans[section.name] = PWMFanNorm.from_configparser(section, arduino_connections) + section.ensure_no_unused_keys() + + return fans + + +def _parse_readonly_fans( + config: configparser.ConfigParser, + arduino_connections: Mapping[ArduinoName, ArduinoConnection], + programs: Programs, +) -> Mapping[ReadonlyFanName, ReadonlyPWMFanNorm]: + readonly_fans: Dict[ReadonlyFanName, ReadonlyPWMFanNorm] = {} + for section in iter_sections(config, "readonly_fan", ReadonlyFanName): + if section.name in readonly_fans: + raise RuntimeError( + "Duplicate readonly_fan section declaration for '%s'" % section.name + ) + readonly_fans[section.name] = ReadonlyPWMFanNorm.from_configparser( + section, arduino_connections, programs + ) + section.ensure_no_unused_keys() + + return readonly_fans + + +def _check_fans_namespace( + fans: Mapping[FanName, PWMFanNorm], + readonly_fans: Mapping[ReadonlyFanName, ReadonlyPWMFanNorm], +) -> None: + common_keys = fans.keys() & readonly_fans.keys() + if common_keys: + raise RuntimeError( + "Duplicate fan names has been found between `fan` " + "and `readonly_fan` sections: %r" % (list(common_keys),) + ) + + +def _parse_mappings( + config: configparser.ConfigParser, + fans: Mapping[FanName, PWMFanNorm], + temps: Mapping[TempName, FilteredTemp], +) -> Mapping[MappingName, FansTempsRelation]: + + mappings: Dict[MappingName, FansTempsRelation] = {} + for section in iter_sections(config, "mapping", MappingName): + + # temps: + + mapping_temps = [ + TempName(temp_name.strip()) for temp_name in section["temps"].split(",") + ] + mapping_temps = [s for s in mapping_temps if s] + if not mapping_temps: + raise RuntimeError( + "Temps must not be empty in the '%s' mapping" % section.name + ) + for temp_name in mapping_temps: + if temp_name not in temps: + raise RuntimeError( + "Unknown temp '%s' in mapping '%s'" % (temp_name, section.name) + ) + if len(mapping_temps) != len(set(mapping_temps)): + raise RuntimeError( + "There are duplicate temps in mapping '%s'" % section.name + ) + + # fans: + + fans_with_speed = [ + fan_with_speed.strip() for fan_with_speed in section["fans"].split(",") + ] + fans_with_speed = [s for s in fans_with_speed if s] + + fan_speed_pairs = [ + fan_with_speed.split("*") for fan_with_speed in fans_with_speed + ] + for fan_speed_pair in fan_speed_pairs: + if len(fan_speed_pair) not in (1, 2): + raise RuntimeError( + "Invalid fan specification '%s' in mapping '%s'" + % (fan_speed_pair, section.name) + ) + mapping_fans = [ + FanSpeedModifier( + fan=FanName(fan_speed_pair[0].strip()), + modifier=( + float( + fan_speed_pair[1].strip() if len(fan_speed_pair) == 2 else 1.0 + ) + ), + ) + for fan_speed_pair in fan_speed_pairs + ] + for fan_speed_modifier in mapping_fans: + if fan_speed_modifier.fan not in fans: + raise RuntimeError( + "Unknown fan '%s' in mapping '%s'" + % (fan_speed_modifier.fan, section.name) + ) + if not (0 < fan_speed_modifier.modifier <= 1.0): + raise RuntimeError( + "Invalid fan modifier '%s' in mapping '%s' for fan '%s': " + "the allowed range is (0.0;1.0]." + % ( + fan_speed_modifier.modifier, + section.name, + fan_speed_modifier.fan, + ) + ) + if len(mapping_fans) != len( + set(fan_speed_modifier.fan for fan_speed_modifier in mapping_fans) + ): + raise RuntimeError( + "There are duplicate fans in mapping '%s'" % section.name + ) + + if section.name in mappings: + raise RuntimeError( + "Duplicate mapping section declaration for '%s'" % section.name + ) + mappings[section.name] = FansTempsRelation( + temps=mapping_temps, fans=mapping_fans + ) + section.ensure_no_unused_keys() + + unused_temps = set(temps.keys()) + unused_fans = set(fans.keys()) + for relation in mappings.values(): + unused_temps -= set(relation.temps) + unused_fans -= set( + fan_speed_modifier.fan for fan_speed_modifier in relation.fans + ) + if unused_temps: + logger.warning( + "The following temps are defined but not used in any mapping: %s", + unused_temps, + ) + if unused_fans: + raise RuntimeError( + "The following fans are defined but not used in any mapping: %s" + % unused_fans + ) + return mappings diff --git a/src/afancontrol/configparser.py b/src/afancontrol/configparser.py new file mode 100644 index 0000000..77d53c4 --- /dev/null +++ b/src/afancontrol/configparser.py @@ -0,0 +1,129 @@ +import configparser +from typing import Any, Generic, Iterator, Optional, Type, TypeVar, Union, overload + +T = TypeVar("T", bound=str) +F = TypeVar("F", None, Any) + +_UNSET = object() + + +def iter_sections( + config: configparser.ConfigParser, section_type: str, name_typevar: Type[T] +) -> Iterator["ConfigParserSection[T]"]: + for section_name in config.sections(): + section_name_parts = section_name.split(":", 1) + + if section_name_parts[0].strip().lower() != section_type: + continue + + name = name_typevar(section_name_parts[1].strip()) + section = ConfigParserSection(config[section_name], name) + yield section + + +class ConfigParserSection(Generic[T]): + def __init__( + self, section: configparser.SectionProxy, name: Optional[T] = None + ) -> None: + self.__name = name + self.__section = section + self.__unused_keys = set(section.keys()) + + @property + def name(self) -> T: + assert self.__name is not None + return self.__name + + def ensure_no_unused_keys(self) -> None: + if self.__unused_keys: + raise RuntimeError( + "Unknown options in the [%s] section: %s" + % (self.__section.name, self.__unused_keys) + ) + + def __contains__(self, key): + return self.__section.__contains__(key) + + def __getitem__(self, key): + self.__unused_keys.discard(key) + return self.__section.__getitem__(key) + + @overload + def get(self, option: str) -> str: + ... + + @overload + def get(self, option: str, *, fallback: F) -> Union[str, F]: + ... + + def get(self, option: str, *, fallback=_UNSET) -> Union[str, F]: + kwargs = {} + if fallback is not _UNSET: + kwargs["fallback"] = fallback + self.__unused_keys.discard(option) + res = self.__section.get(option, **kwargs) + if res is None and fallback is _UNSET: + raise ValueError( + "[%s] %r option is expected to be set" % (self.__section.name, option) + ) + return res + + @overload + def getint(self, option: str) -> int: + ... + + @overload + def getint(self, option: str, *, fallback: F) -> Union[int, F]: + ... + + def getint(self, option: str, *, fallback=_UNSET) -> Union[int, F]: + kwargs = {} + if fallback is not _UNSET: + kwargs["fallback"] = fallback + self.__unused_keys.discard(option) + res = self.__section.getint(option, **kwargs) + if res is None and fallback is _UNSET: + raise ValueError( + "[%s] %r option is expected to be set" % (self.__section.name, option) + ) + return res + + @overload + def getfloat(self, option: str) -> float: + ... + + @overload + def getfloat(self, option: str, *, fallback: F) -> Union[float, F]: + ... + + def getfloat(self, option: str, *, fallback=_UNSET) -> Union[float, F]: + kwargs = {} + if fallback is not _UNSET: + kwargs["fallback"] = fallback + self.__unused_keys.discard(option) + res = self.__section.getfloat(option, **kwargs) + if res is None and fallback is _UNSET: + raise ValueError( + "[%s] %r option is expected to be set" % (self.__section.name, option) + ) + return res + + @overload + def getboolean(self, option: str) -> bool: + ... + + @overload + def getboolean(self, option: str, *, fallback: F) -> Union[bool, F]: + ... + + def getboolean(self, option: str, *, fallback=_UNSET) -> Union[bool, F]: + kwargs = {} + if fallback is not _UNSET: + kwargs["fallback"] = fallback + self.__unused_keys.discard(option) + res = self.__section.getboolean(option, **kwargs) + if res is None and fallback is _UNSET: + raise ValueError( + "[%s] %r option is expected to be set" % (self.__section.name, option) + ) + return res diff --git a/src/afancontrol/daemon.py b/src/afancontrol/daemon.py new file mode 100644 index 0000000..2d75c12 --- /dev/null +++ b/src/afancontrol/daemon.py @@ -0,0 +1,162 @@ +import logging +import os +import signal +import threading +from contextlib import ExitStack +from pathlib import Path +from typing import Optional + +import click + +from afancontrol.config import ( + DEFAULT_CONFIG, + DEFAULT_PIDFILE, + DaemonCLIConfig, + parse_config, +) +from afancontrol.manager import Manager +from afancontrol.metrics import Metrics, NullMetrics, PrometheusMetrics +from afancontrol.report import Report + + +@click.command() +@click.option("-t", "--test", is_flag=True, help="Test config") +@click.option("-v", "--verbose", is_flag=True, help="Increase logging verbosity") +@click.option( + "-c", + "--config", + help="Config path", + default=DEFAULT_CONFIG, + show_default=True, + type=click.Path(exists=True, dir_okay=False), +) +@click.option( + "--pidfile", + help="Pidfile path (default is %s)" % DEFAULT_PIDFILE, + # The default is set by the `config` module. + type=click.Path(exists=False), +) +@click.option( + "--logfile", + help="Logfile path (log to stdout by default)", + type=click.Path(exists=False), +) +@click.option( + "--exporter-listen-host", + help="Prometheus exporter listen host, e.g. `127.0.0.1:8000` (disabled by default)", + type=str, +) +def daemon( + *, + test: bool, + verbose: bool, + config: str, + pidfile: str, + logfile: str, + exporter_listen_host: str +): + """The main program of afancontrol.""" + + logging.basicConfig(level=logging.DEBUG if verbose else logging.INFO) + + config_path = Path(config) + daemon_cli_config = DaemonCLIConfig( + pidfile=pidfile, logfile=logfile, exporter_listen_host=exporter_listen_host + ) + parsed_config = parse_config(config_path, daemon_cli_config) + + if parsed_config.daemon.exporter_listen_host: + metrics: Metrics = PrometheusMetrics(parsed_config.daemon.exporter_listen_host) + else: + metrics = NullMetrics() + + manager = Manager( + arduino_connections=parsed_config.arduino_connections, + fans=parsed_config.fans, + readonly_fans=parsed_config.readonly_fans, + temps=parsed_config.temps, + mappings=parsed_config.mappings, + report=Report(report_command=parsed_config.report_cmd), + triggers_config=parsed_config.triggers, + metrics=metrics, + ) + + pidfile_instance: Optional[PidFile] = None + if parsed_config.daemon.pidfile is not None: + pidfile_instance = PidFile(parsed_config.daemon.pidfile) + + if test: + print("Config file '%s' is good" % config_path) + return + + if parsed_config.daemon.logfile: + # Logging to file should not be configured when running in + # the config test mode. + file_handler = logging.FileHandler(parsed_config.daemon.logfile) + file_handler.setFormatter( + logging.Formatter("[%(asctime)s] %(levelname)s:%(name)s:%(message)s") + ) + logging.getLogger().addHandler(file_handler) + + signals = Signals() + signal.signal(signal.SIGTERM, signals.sigterm) + signal.signal(signal.SIGQUIT, signals.sigterm) + signal.signal(signal.SIGINT, signals.sigterm) + signal.signal(signal.SIGHUP, signals.sigterm) + + with ExitStack() as stack: + if pidfile_instance is not None: + stack.enter_context(pidfile_instance) + pidfile_instance.save_pid(os.getpid()) + + stack.enter_context(manager) + + # Make a first tick. If something is wrong, (e.g. bad fan/temp + # file paths), an exception would be raised here. + manager.tick() + + while not signals.wait_for_term_queued(parsed_config.daemon.interval): + manager.tick() + + +class PidFile: + def __init__(self, pidfile: str) -> None: + self.pidfile = Path(pidfile) + + def __str__(self): + return "%s" % self.pidfile + + def __enter__(self): + self.raise_if_pidfile_exists() + return self + + def __exit__(self, exc_type, exc_value, exc_tb): + self.remove() + return None + + def save_pid(self, pid: int) -> None: + self.pidfile.write_text(str(pid)) + + def remove(self) -> None: + self.pidfile.unlink() + + def raise_if_pidfile_exists(self) -> None: + if self.pidfile.exists(): + raise RuntimeError( + "pidfile %s already exists. Is daemon already running? " + "Remove this file if it's not." % self + ) + + +class Signals: + def __init__(self): + self._term_event = threading.Event() + + def sigterm(self, signum, stackframe): + self._term_event.set() + + def wait_for_term_queued(self, seconds: float) -> bool: + is_set = self._term_event.wait(seconds) + if is_set: + return True + return False diff --git a/src/afancontrol/exec.py b/src/afancontrol/exec.py new file mode 100644 index 0000000..482541f --- /dev/null +++ b/src/afancontrol/exec.py @@ -0,0 +1,50 @@ +import subprocess +from typing import NamedTuple + +from afancontrol.configparser import ConfigParserSection +from afancontrol.logger import logger + + +class Programs(NamedTuple): + hddtemp: str + ipmi_sensors: str + + @classmethod + def from_configparser(cls, section: ConfigParserSection) -> "Programs": + return cls( + hddtemp=section.get("hddtemp", fallback="hddtemp"), + ipmi_sensors=section.get("ipmi_sensors", fallback="ipmi-sensors"), + ) + + +def exec_shell_command(shell_command: str, timeout: int = 5) -> str: + try: + p = subprocess.run( + shell_command, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + shell=True, + check=True, + timeout=timeout, + ) + out = p.stdout.decode("ascii") + err = p.stderr.decode().strip() + if err: + logger.warning( + "Shell command '%s' executed successfully, but printed to stderr:\n%s", + shell_command, + err, + ) + return out + except subprocess.CalledProcessError as e: + ec = e.returncode + out = e.stdout.decode().strip() + err = e.stderr.decode().strip() + logger.error( + "Shell command '%s' failed (exit code %s):\nstdout:\n%s\nstderr:\n%s\n", + shell_command, + ec, + out, + err, + ) + raise diff --git a/src/afancontrol/fans.py b/src/afancontrol/fans.py new file mode 100644 index 0000000..8cd7b43 --- /dev/null +++ b/src/afancontrol/fans.py @@ -0,0 +1,150 @@ +import itertools +from contextlib import ExitStack +from typing import Iterator, Mapping, MutableSet, Optional, Tuple, Union, cast + +from afancontrol.logger import logger +from afancontrol.pwmfan import AnyFanName, FanName, ReadonlyFanName +from afancontrol.pwmfannorm import PWMFanNorm, PWMValueNorm, ReadonlyPWMFanNorm +from afancontrol.report import Report + + +class Fans: + def __init__( + self, + *, + fans: Mapping[FanName, PWMFanNorm], + readonly_fans: Mapping[ReadonlyFanName, ReadonlyPWMFanNorm], + report: Report + ) -> None: + self.fans = fans + self.readonly_fans = readonly_fans + self.report = report + self._stack: Optional[ExitStack] = None + + # Set of fans marked as failing (which speed is 0) + self._failed_fans: MutableSet[AnyFanName] = set() + + # Set of fans that will be skipped on speed check + self._stopped_fans: MutableSet[AnyFanName] = set() + + def is_fan_failing(self, fan_name: AnyFanName) -> bool: + return fan_name in self._failed_fans + + def is_fan_stopped(self, fan_name: AnyFanName) -> bool: + return fan_name in self._stopped_fans + + def __enter__(self): # reusable + self._stack = ExitStack() + logger.info("Enabling PWM on fans...") + try: + for pwmfan in cast( + Iterator[Union[PWMFanNorm, ReadonlyPWMFanNorm]], + itertools.chain(self.fans.values(), self.readonly_fans.values()), + ): + self._stack.enter_context(pwmfan) + except Exception: + self._stack.close() + raise + return self + + def __exit__(self, exc_type, exc_value, exc_tb): + assert self._stack is not None + logger.info("Disabling PWM on fans...") + self._stack.close() + logger.info("Done. Fans should be returned to full speed") + return None + + def check_speeds(self) -> None: + for name, fan in cast( + Iterator[Tuple[AnyFanName, Union[PWMFanNorm, ReadonlyPWMFanNorm]]], + itertools.chain(self.fans.items(), self.readonly_fans.items()), + ): + if name in self._stopped_fans: + continue + try: + if fan.get_speed() <= 0: + raise RuntimeError("Fan speed is 0") + except Exception as e: + self._ensure_fan_is_failing(name, e) + else: + self._ensure_fan_is_not_failing(name) + + def set_all_to_full_speed(self) -> None: + for name, fan in self.fans.items(): + if name in self._failed_fans: + continue + try: + fan.set_full_speed() + except Exception as e: + logger.warning("Unable to set the fan '%s' to full speed:\n%s", name, e) + + def set_fan_speeds(self, speeds: Mapping[FanName, PWMValueNorm]) -> None: + assert speeds.keys() == self.fans.keys() + self._stopped_fans.clear() + for name, pwm_norm in speeds.items(): + fan = self.fans[name] + assert 0.0 <= pwm_norm <= 1.0 + + if name in self._failed_fans: + continue + + try: + pwm = fan.set(pwm_norm) + except Exception as e: + logger.warning( + "Unable to set the fan '%s' to speed %s:\n%s", name, pwm_norm, e + ) + else: + logger.debug( + "Fan status [%s]: speed: %.3f, pwm: %s", name, pwm_norm, pwm + ) + if fan.is_pwm_stopped(pwm): + self._stopped_fans.add(name) + for readonly_name, readonly_fan in self.readonly_fans.items(): + readonly_pwm_norm = readonly_fan.get() + readonly_pwm = readonly_fan.get_raw() + logger.debug( + "Readonly Fan status [%s]: speed: %.3f, pwm: %s", + readonly_name, + readonly_pwm_norm, + readonly_pwm, + ) + if readonly_fan.is_pwm_stopped(readonly_pwm): + self._stopped_fans.add(readonly_name) + + def _ensure_fan_is_failing( + self, name: AnyFanName, get_speed_exc: Exception + ) -> None: + if name in self._failed_fans: + return + self._failed_fans.add(name) + try: + fan = self.fans[cast(FanName, name)] + except KeyError: + self.readonly_fans[cast(ReadonlyFanName, name)] # assert + full_speed_result = "The fan is in the readonly mode" + else: + try: + # Perhaps it had jammed, so setting it to full speed might + # recover it? + fan.set_full_speed() + except Exception as e: + full_speed_result = "Setting fan speed to full has failed:\n%s" % e + else: + full_speed_result = "Fan has been set to full speed" + + self.report.report( + "fan stopped: %s" % name, + "Looks like the fan '%s' is failing:\n%s\n\n%s" + % (name, get_speed_exc, full_speed_result), + ) + + def _ensure_fan_is_not_failing(self, name: AnyFanName) -> None: + if name not in self._failed_fans: + return + self.report.report( + "fan started: %s" % name, + "Fan '%s' which had previously been reported as failing has just started." + % name, + ) + self._failed_fans.remove(name) diff --git a/src/afancontrol/fantest.py b/src/afancontrol/fantest.py new file mode 100644 index 0000000..2ff670b --- /dev/null +++ b/src/afancontrol/fantest.py @@ -0,0 +1,333 @@ +import abc +import sys +from time import sleep +from typing import Optional + +import click + +from afancontrol.arduino import ( + DEFAULT_BAUDRATE, + ArduinoConnection, + ArduinoName, + ArduinoPin, +) +from afancontrol.pwmfan import ( + ArduinoFanPWMRead, + ArduinoFanPWMWrite, + ArduinoFanSpeed, + FanInputDevice, + FanValue, + LinuxFanPWMRead, + LinuxFanPWMWrite, + LinuxFanSpeed, + PWMDevice, + PWMValue, + ReadWriteFan, +) + +# Time to wait before measuring fan speed after setting a PWM value. +STEP_INTERVAL_SECONDS = 2 + +# Time to wait before starting the test right after resetting the fan +# (i.e. setting it to full speed). +FAN_RESET_INTERVAL_SECONDS = 7 + +EXIT_CODE_CTRL_C = 130 # https://stackoverflow.com/a/1101969 + +HELP_FAN_TYPE = ( + "Linux -- a standard PWM fan connected to a motherboard; " + "Arduino -- a PWM fan connected to an Arduino board." +) + +HELP_LINUX_PWM_FILE = ( + "PWM file for a Linux PWM fan, e.g. `/sys/class/hwmon/hwmon0/device/pwm2`." +) +HELP_LINUX_FAN_INPUT_FILE = ( + "Fan input (tachometer) file for a Linux PWM fan, " + "e.g. `/sys/class/hwmon/hwmon0/device/fan2_input`." +) + +HELP_ARDUINO_SERIAL_URL = "URL for the Arduino's Serial port" +HELP_ARDUINO_BAUDRATE = "Arduino Serial connection baudrate" +HELP_ARDUINO_PWM_PIN = ( + "Arduino Board pin where the target fan's PWM wire is connected to." +) +HELP_ARDUINO_TACHO_PIN = ( + "Arduino Board pin where the target fan's tachometer wire is connected to." +) + +HELP_OUTPUT_FORMAT = ( + "Output format for the measurements. `csv` data could be used " + "to make a plot using a spreadsheet program like MS Excel." +) +HELP_TEST_DIRECTION = ( + "The default test is to stop the fan and then gracefully increase its speed. " + "You might want to reverse it, i.e. run the fan at full speed and then start " + "decreasing the speed. This would allow you to test the fan without fully " + "stopping it, if you abort the test with Ctrl+C when the speed becomes too low." +) +HELP_PWM_STEP_SIZE = ( + "A single step size for the PWM value. `accurate` equals to 5 and provides " + "more accurate results, but is a slower option. `fast` equals to 25 and completes " + "faster." +) + + +@click.command() +@click.option( + "--fan-type", + help="FAN type. %s" % HELP_FAN_TYPE, + default="linux", + type=click.Choice(["linux", "arduino"]), + prompt="\n%s\nFAN type (linux, arduino)" % HELP_FAN_TYPE, + # `show_choices` is supported since click 7.0 + show_default=True, +) +@click.option( + "--linux-fan-pwm", + help=HELP_LINUX_PWM_FILE, + type=click.Path(exists=True, dir_okay=False), +) +@click.option( + "--linux-fan-input", + help=HELP_LINUX_FAN_INPUT_FILE, + type=click.Path(exists=True, dir_okay=False), +) +@click.option("--arduino-serial-url", help=HELP_ARDUINO_SERIAL_URL, type=str) +@click.option( + "--arduino-baudrate", + help=HELP_ARDUINO_BAUDRATE, + type=int, + default=DEFAULT_BAUDRATE, + show_default=True, +) +@click.option("--arduino-pwm-pin", help=HELP_ARDUINO_PWM_PIN, type=int) +@click.option("--arduino-tacho-pin", help=HELP_ARDUINO_TACHO_PIN, type=int) +@click.option( + "-f", + "--output-format", + help=HELP_OUTPUT_FORMAT, + default="human", + type=click.Choice(["human", "csv"]), + prompt="\n%s\nOutput format (human, csv)" % HELP_OUTPUT_FORMAT, + show_default=True, +) +@click.option( + "-d", + "--direction", + help=HELP_TEST_DIRECTION, + default="increase", + type=click.Choice(["increase", "decrease"]), + prompt="\n%s\nTest direction (increase decrease)" % HELP_TEST_DIRECTION, + show_default=True, +) +@click.option( + "-s", + "--pwm-step-size", + help=HELP_PWM_STEP_SIZE, + default="accurate", + type=click.Choice(["accurate", "fast"]), + prompt="\n%s\nPWM step size (accurate fast)" % HELP_PWM_STEP_SIZE, + show_default=True, +) +def fantest( + *, + fan_type: str, + linux_fan_pwm: Optional[str], + linux_fan_input: Optional[str], + arduino_serial_url: Optional[str], + arduino_baudrate: int, + arduino_pwm_pin: Optional[int], + arduino_tacho_pin: Optional[int], + output_format: str, + direction: str, + pwm_step_size: str +) -> None: + """The PWM fan testing program. + + This program tests how changing the PWM value of a fan affects its speed. + + In the beginning the fan would be stopped (by setting it to a minimum PWM value), + and then the PWM value would be increased in small steps, while also + measuring the speed as reported by the fan. + + This data would help you to find the effective range of values + for the `pwm_line_start` and `pwm_line_end` settings where the correlation + between PWM and fan speed is close to linear. Usually its + `pwm_line_start = 100` and `pwm_line_end = 240`, but it is individual + for each fan. The allowed range for a PWM value is from 0 to 255. + + Note that the fan would be stopped for some time during the test. If you'll + feel nervous, press Ctrl+C to stop the test and return the fan to full speed. + + Before starting the test ensure that no fan control software is currently + controlling the fan you're going to test. + """ + try: + if fan_type == "linux": + if not linux_fan_pwm: + linux_fan_pwm = click.prompt( + "\n%s\nPWM file" % HELP_LINUX_PWM_FILE, + type=click.Path(exists=True, dir_okay=False), + ) + + if not linux_fan_input: + linux_fan_input = click.prompt( + "\n%s\nFan input file" % HELP_LINUX_FAN_INPUT_FILE, + type=click.Path(exists=True, dir_okay=False), + ) + + assert linux_fan_pwm is not None + assert linux_fan_input is not None + fan = ReadWriteFan( + fan_speed=LinuxFanSpeed(FanInputDevice(linux_fan_input)), + pwm_read=LinuxFanPWMRead(PWMDevice(linux_fan_pwm)), + pwm_write=LinuxFanPWMWrite(PWMDevice(linux_fan_pwm)), + ) + elif fan_type == "arduino": + if not arduino_serial_url: + arduino_serial_url = click.prompt( + "\n%s\nArduino Serial url" % HELP_ARDUINO_SERIAL_URL, type=str + ) + + # typeshed currently specifies `Optional[str]` for `default`, + # see https://github.com/python/typeshed/blob/5acc22d82aa01005ea47ef64f31cad7e16e78450/third_party/2and3/click/termui.pyi#L34 # noqa + # however the click docs say that `default` can be of any type, + # see https://click.palletsprojects.com/en/7.x/prompts/#input-prompts + # Hence the `type: ignore`. + arduino_baudrate = click.prompt( # type: ignore + "\n%s\nBaudrate" % HELP_ARDUINO_BAUDRATE, + type=int, + default=str(arduino_baudrate), + show_default=True, + ) + if not arduino_pwm_pin and arduino_pwm_pin != 0: + arduino_pwm_pin = click.prompt( + "\n%s\nArduino PWM pin" % HELP_ARDUINO_PWM_PIN, type=int + ) + if not arduino_tacho_pin and arduino_tacho_pin != 0: + arduino_tacho_pin = click.prompt( + "\n%s\nArduino Tachometer pin" % HELP_ARDUINO_TACHO_PIN, type=int + ) + + assert arduino_serial_url is not None + arduino_connection = ArduinoConnection( + name=ArduinoName("_fantest"), + serial_url=arduino_serial_url, + baudrate=arduino_baudrate, + ) + assert arduino_pwm_pin is not None + assert arduino_tacho_pin is not None + fan = ReadWriteFan( + fan_speed=ArduinoFanSpeed( + arduino_connection, tacho_pin=ArduinoPin(arduino_tacho_pin) + ), + pwm_read=ArduinoFanPWMRead( + arduino_connection, pwm_pin=ArduinoPin(arduino_pwm_pin) + ), + pwm_write=ArduinoFanPWMWrite( + arduino_connection, pwm_pin=ArduinoPin(arduino_pwm_pin) + ), + ) + else: + raise AssertionError( + "unreachable if the `fan_type`'s allowed `values` are in sync" + ) + + output = {"human": HumanMeasurementsOutput(), "csv": CSVMeasurementsOutput()}[ + output_format + ] + pwm_step_size_value = {"accurate": PWMValue(5), "fast": PWMValue(25)}[ + pwm_step_size + ] + if direction == "decrease": + pwm_step_size_value = PWMValue( + pwm_step_size_value * -1 # a bad PWM value, to be honest + ) + except KeyboardInterrupt: + click.echo("") + sys.exit(EXIT_CODE_CTRL_C) + + try: + run_fantest(fan=fan, pwm_step_size=pwm_step_size_value, output=output) + except KeyboardInterrupt: + click.echo("Fan has been returned to full speed") + sys.exit(EXIT_CODE_CTRL_C) + + +def run_fantest( + fan: ReadWriteFan, pwm_step_size: PWMValue, output: "MeasurementsOutput" +) -> None: + with fan.fan_speed, fan.pwm_read, fan.pwm_write: + start = fan.pwm_read.min_pwm + stop = fan.pwm_read.max_pwm + if pwm_step_size > 0: + print("Testing increase with step %s" % pwm_step_size) + print("Waiting %s seconds for fan to stop..." % FAN_RESET_INTERVAL_SECONDS) + else: + start, stop = stop, start + print("Testing decrease with step %s" % pwm_step_size) + print( + "Waiting %s seconds for fan to run in full speed..." + % FAN_RESET_INTERVAL_SECONDS + ) + + fan.pwm_write.set(start) + sleep(FAN_RESET_INTERVAL_SECONDS) + + print(output.header()) + + prev_rpm = None + for pwm_value in range(start, stop, pwm_step_size): + fan.pwm_write.set(PWMValue(pwm_value)) + sleep(STEP_INTERVAL_SECONDS) + rpm = fan.fan_speed.get_speed() + + rpm_delta = None # Optional[FanValue] + if prev_rpm is not None: + rpm_delta = rpm - prev_rpm + prev_rpm = rpm + + print( + output.data_row(pwm=PWMValue(pwm_value), rpm=rpm, rpm_delta=rpm_delta) + ) + + print("Test is complete, returning fan to full speed") + + +class MeasurementsOutput(abc.ABC): + @abc.abstractmethod + def header(self) -> str: + pass + + @abc.abstractmethod + def data_row( + self, pwm: PWMValue, rpm: FanValue, rpm_delta: Optional[FanValue] + ) -> str: + pass + + +class HumanMeasurementsOutput(MeasurementsOutput): + def header(self) -> str: + return """PWM -- PWM value; +RPM -- fan speed (as reported by the fan); +DELTA -- RPM increase since the last step.""" + + def data_row( + self, pwm: PWMValue, rpm: FanValue, rpm_delta: Optional[FanValue] + ) -> str: + return "PWM %s RPM %s DELTA %s" % ( + str(pwm).rjust(3), + str(rpm).rjust(4), + str(rpm_delta if rpm_delta is not None else "n/a").rjust(4), + ) + + +class CSVMeasurementsOutput(MeasurementsOutput): + def header(self) -> str: + return "pwm;rpm;rpm_delta" + + def data_row( + self, pwm: PWMValue, rpm: FanValue, rpm_delta: Optional[FanValue] + ) -> str: + return "%s;%s;%s" % (pwm, rpm, rpm_delta if rpm_delta is not None else "") diff --git a/src/afancontrol/filters.py b/src/afancontrol/filters.py new file mode 100644 index 0000000..7858467 --- /dev/null +++ b/src/afancontrol/filters.py @@ -0,0 +1,127 @@ +import abc +import collections +from typing import TYPE_CHECKING, Deque, NewType, Optional, TypeVar + +from afancontrol.configparser import ConfigParserSection + +if TYPE_CHECKING: + from afancontrol.temp import TempStatus + +T = TypeVar("T") +FilterName = NewType("FilterName", str) + + +def from_configparser(section: ConfigParserSection[FilterName]) -> "TempFilter": + filter_type = section["type"] + + if filter_type == "moving_median": + window_size = section.getint("window_size", fallback=3) + return MovingMedianFilter(window_size=window_size) + elif filter_type == "moving_quantile": + window_size = section.getint("window_size", fallback=3) + quantile = section.getfloat("quantile") + return MovingQuantileFilter(quantile=quantile, window_size=window_size) + else: + raise RuntimeError( + "Unsupported filter type '%s' for filter '%s'. " + "Supported types: `moving_median`, `moving_quantile`." + % (filter_type, section.name) + ) + + +class TempFilter(abc.ABC): + @abc.abstractmethod + def copy(self: T) -> T: + pass + + @abc.abstractmethod + def apply(self, status: Optional["TempStatus"]) -> Optional["TempStatus"]: + pass + + def __enter__(self): # reusable + return self + + def __exit__(self, exc_type, exc_value, exc_tb): + pass + + +class NullFilter(TempFilter): + def copy(self: T) -> T: + return type(self)() + + def apply(self, status: Optional["TempStatus"]) -> Optional["TempStatus"]: + return status + + def __eq__(self, other): + if isinstance(other, type(self)): + return True + + return NotImplemented + + def __ne__(self, other): + return not (self == other) + + def __repr__(self): + return "%s()" % (type(self).__name__,) + + +def _temp_status_sorting_key(status: Optional["TempStatus"]) -> float: + if status is None: + return float("+inf") + return status.temp + + +class MovingQuantileFilter(TempFilter): + def __init__(self, quantile: float, *, window_size: int) -> None: + self.quantile = quantile + self.window_size = window_size + self.history: Optional[Deque[Optional["TempStatus"]]] = None + + def copy(self: T) -> T: + return type(self)( # type: ignore + quantile=self.quantile, window_size=self.window_size # type: ignore + ) + + def apply(self, status: Optional["TempStatus"]) -> Optional["TempStatus"]: + assert self.history is not None + self.history.append(status) + + observations = sorted(self.history, key=_temp_status_sorting_key) + target_idx = int(len(observations) * self.quantile) + return observations[target_idx] + + def __enter__(self): # reusable + assert self.history is None + self.history = collections.deque(maxlen=self.window_size) + return self + + def __exit__(self, exc_type, exc_value, exc_tb): + assert self.history is not None + self.history = None + + def __eq__(self, other): + if isinstance(other, type(self)): + return ( + self.quantile == other.quantile + and self.window_size == other.window_size + ) + + return NotImplemented + + def __ne__(self, other): + return not (self == other) + + def __repr__(self): + return "%s(quantile=%r, window_size=%r)" % ( + type(self).__name__, + self.quantile, + self.window_size, + ) + + +class MovingMedianFilter(MovingQuantileFilter): + def __init__(self, window_size: int) -> None: + super().__init__(quantile=0.5, window_size=window_size) + + def copy(self: T) -> T: + return type(self)(window_size=self.window_size) # type: ignore diff --git a/src/afancontrol/logger.py b/src/afancontrol/logger.py new file mode 100644 index 0000000..4742234 --- /dev/null +++ b/src/afancontrol/logger.py @@ -0,0 +1,3 @@ +import logging + +logger = logging.getLogger("afancontrol") diff --git a/src/afancontrol/manager.py b/src/afancontrol/manager.py new file mode 100644 index 0000000..af85796 --- /dev/null +++ b/src/afancontrol/manager.py @@ -0,0 +1,116 @@ +from collections import defaultdict +from contextlib import ExitStack +from typing import Dict, Mapping, Optional + +from afancontrol.arduino import ArduinoConnection, ArduinoName +from afancontrol.config import ( + FanName, + FansTempsRelation, + MappingName, + ReadonlyFanName, + TempName, + TriggerConfig, +) +from afancontrol.fans import Fans +from afancontrol.logger import logger +from afancontrol.metrics import Metrics +from afancontrol.pwmfannorm import PWMFanNorm, PWMValueNorm, ReadonlyPWMFanNorm +from afancontrol.report import Report +from afancontrol.temp import TempStatus +from afancontrol.temps import FilteredTemp, Temps, filtered_temps +from afancontrol.trigger import Triggers + + +class Manager: + def __init__( + self, + *, + arduino_connections: Mapping[ArduinoName, ArduinoConnection], + fans: Mapping[FanName, PWMFanNorm], + readonly_fans: Mapping[ReadonlyFanName, ReadonlyPWMFanNorm], + temps: Mapping[TempName, FilteredTemp], + mappings: Mapping[MappingName, FansTempsRelation], + report: Report, + triggers_config: TriggerConfig, + metrics: Metrics + ) -> None: + self.report = report + self.arduino_connections = arduino_connections + self.fans = Fans(fans=fans, readonly_fans=readonly_fans, report=report) + self.temps = Temps(temps) + self.mappings = mappings + self.triggers = Triggers(triggers_config, report) + self.metrics = metrics + self._stack: Optional[ExitStack] = None + + def __enter__(self): # reusable + self._stack = ExitStack() + try: + self._stack.enter_context(self.fans) + self._stack.enter_context(self.temps) + self._stack.enter_context(self.triggers) + self._stack.enter_context(self.metrics) + except Exception: + self._stack.close() + raise + return self + + def __exit__(self, exc_type, exc_value, exc_tb): + assert self._stack is not None + self._stack.close() + return None + + def tick(self) -> None: + with self.metrics.measure_tick(): + temps = self.temps.get_temps() + _filtered_temps = filtered_temps(temps) + self.fans.check_speeds() + + self.triggers.check(_filtered_temps) + + if self.triggers.is_alerting: + self.fans.set_all_to_full_speed() + else: + speeds = self._map_temps_to_fan_speeds(_filtered_temps) + self.fans.set_fan_speeds(speeds) + + try: + self.metrics.tick(temps, self.fans, self.triggers, self.arduino_connections) + except Exception: + logger.warning("Failed to collect metrics", exc_info=True) + + def _map_temps_to_fan_speeds( + self, temps: Mapping[TempName, Optional[TempStatus]] + ) -> Mapping[FanName, PWMValueNorm]: + + temp_speeds = { + temp_name: self._temp_speed(temp_status) + for temp_name, temp_status in temps.items() + } + + fan_speeds: Dict[FanName, PWMValueNorm] = defaultdict(lambda: PWMValueNorm(0.0)) + + for mapping_name, relation in self.mappings.items(): + mapping_speed = max(temp_speeds[temp_name] for temp_name in relation.temps) + for fan_modifier in relation.fans: + pwm_norm = PWMValueNorm(mapping_speed * fan_modifier.modifier) + pwm_norm = max(pwm_norm, PWMValueNorm(0.0)) + pwm_norm = min(pwm_norm, PWMValueNorm(1.0)) + fan_speeds[fan_modifier.fan] = max( + pwm_norm, fan_speeds[fan_modifier.fan] + ) + + # Ensure that all fans have been referenced through the mappings. + # This is also enforced in the `config.py` module. + assert len(fan_speeds) == len(self.fans.fans) + + return fan_speeds + + def _temp_speed(self, temp: Optional[TempStatus]) -> PWMValueNorm: + if temp is None: + # Failing sensor -- this is the panic mode. + return PWMValueNorm(1.0) + speed = PWMValueNorm((temp.temp - temp.min) / (temp.max - temp.min)) + speed = max(speed, PWMValueNorm(0.0)) + speed = min(speed, PWMValueNorm(1.0)) + return speed diff --git a/src/afancontrol/metrics.py b/src/afancontrol/metrics.py new file mode 100644 index 0000000..0b51cd6 --- /dev/null +++ b/src/afancontrol/metrics.py @@ -0,0 +1,392 @@ +import abc +import contextlib +import threading +from http.server import HTTPServer +from socketserver import ThreadingMixIn +from timeit import default_timer +from typing import ContextManager, Mapping, Optional, Union + +from afancontrol.arduino import ArduinoConnection, ArduinoName +from afancontrol.config import TempName +from afancontrol.fans import Fans +from afancontrol.logger import logger +from afancontrol.pwmfan import AnyFanName, FanName, ReadonlyFanName +from afancontrol.pwmfannorm import PWMFanNorm, ReadonlyPWMFanNorm +from afancontrol.temps import ObservedTempStatus +from afancontrol.trigger import Triggers + +try: + import prometheus_client as prom + + prometheus_available = True +except ImportError: + prometheus_available = False + + +class Metrics(abc.ABC): + @abc.abstractmethod + def __enter__(self): + pass + + @abc.abstractmethod + def __exit__(self, exc_type, exc_value, exc_tb): + pass + + @abc.abstractmethod + def tick( + self, + temps: Mapping[TempName, ObservedTempStatus], + fans: Fans, + triggers: Triggers, + arduino_connections: Mapping[ArduinoName, ArduinoConnection], + ) -> None: + pass + + @abc.abstractmethod + def measure_tick(self) -> ContextManager[None]: + pass + + +class NullMetrics(Metrics): + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, exc_tb): + pass + + def tick( + self, + temps: Mapping[TempName, ObservedTempStatus], + fans: Fans, + triggers: Triggers, + arduino_connections: Mapping[ArduinoName, ArduinoConnection], + ) -> None: + pass + + def measure_tick(self) -> ContextManager[None]: + @contextlib.contextmanager + def null_context_manager(): + yield + + return null_context_manager() + + +class PrometheusMetrics(Metrics): + def __init__(self, listen_host: str) -> None: + if not prometheus_available: + raise RuntimeError( + "`prometheus_client` is not installed. " + "Run `pip install 'afancontrol[metrics]'`." + ) + + self._listen_addr, port_str = listen_host.rsplit(":", 1) + self._listen_port = int(port_str) + + self._http_server: Optional[HTTPServer] = None + + self._last_metrics_collect_clock = float("nan") + + # Create a separate registry for this instance instead of using + # the default one (which is global and doesn't allow to instantiate + # this class more than once due to having metrics below being + # registered for a second time): + self.registry = prom.CollectorRegistry(auto_describe=True) + + # Register some default prometheus_client metrics: + prom.ProcessCollector(registry=self.registry) + if hasattr(prom, "PlatformCollector"): + prom.PlatformCollector(registry=self.registry) + if hasattr(prom, "GCCollector"): + prom.GCCollector(registry=self.registry) + + # Temps: + self.temperature_is_failing = prom.Gauge( + "temperature_is_failing", + "The temperature sensor is failing (it isn't returning any data)", + ["temp_name"], + registry=self.registry, + ) + self.temperature_current = prom.Gauge( + "temperature_current", + "The current (filtered) temperature value (in Celsius) " + "from a temperature sensor", + ["temp_name"], + registry=self.registry, + ) + self.temperature_min = prom.Gauge( + "temperature_min", + "The min temperature value (in Celsius) for a temperature sensor", + ["temp_name"], + registry=self.registry, + ) + self.temperature_max = prom.Gauge( + "temperature_max", + "The max temperature value (in Celsius) for a temperature sensor", + ["temp_name"], + registry=self.registry, + ) + self.temperature_panic = prom.Gauge( + "temperature_panic", + "The panic temperature value (in Celsius) for a temperature sensor", + ["temp_name"], + registry=self.registry, + ) + self.temperature_threshold = prom.Gauge( + "temperature_threshold", + "The threshold temperature value (in Celsius) for a temperature sensor", + ["temp_name"], + registry=self.registry, + ) + self.temperature_is_panic = prom.Gauge( + "temperature_is_panic", + "Is panic temperature reached for a temperature sensor", + ["temp_name"], + registry=self.registry, + ) + self.temperature_is_threshold = prom.Gauge( + "temperature_is_threshold", + "Is threshold temperature reached for a temperature sensor", + ["temp_name"], + registry=self.registry, + ) + + self.temperature_current_raw = prom.Gauge( + "temperature_current_raw", + "The current (unfiltered) temperature value (in Celsius) " + "from a temperature sensor", + ["temp_name"], + registry=self.registry, + ) + + # Fans: + self.fan_rpm = prom.Gauge( + "fan_rpm", + "Fan speed (in RPM) as reported by the fan", + ["fan_name"], + registry=self.registry, + ) + self.fan_pwm = prom.Gauge( + "fan_pwm", + "Current fan's PWM value (from 0 to 255)", + ["fan_name"], + registry=self.registry, + ) + self.fan_pwm_normalized = prom.Gauge( + "fan_pwm_normalized", + "Current fan's normalized PWM value (from 0.0 to 1.0, within " + "the `fan_pwm_line_start` and `fan_pwm_line_end` interval)", + ["fan_name"], + registry=self.registry, + ) + self.fan_pwm_line_start = prom.Gauge( + "fan_pwm_line_start", + "PWM value where a linear correlation with RPM starts for the fan", + ["fan_name"], + registry=self.registry, + ) + self.fan_pwm_line_end = prom.Gauge( + "fan_pwm_line_end", + "PWM value where a linear correlation with RPM ends for the fan", + ["fan_name"], + registry=self.registry, + ) + self.fan_is_stopped = prom.Gauge( + "fan_is_stopped", + "Is PWM fan stopped because the corresponding temperatures " + "are already low", + ["fan_name"], + registry=self.registry, + ) + self.fan_is_failing = prom.Gauge( + "fan_is_failing", + "Is PWM fan marked as failing (e.g. because it has jammed)", + ["fan_name"], + registry=self.registry, + ) + + # Arduino boards: + self.arduino_is_connected = prom.Gauge( + "arduino_is_connected", + "Is Arduino board connected via Serial", + ["arduino_name"], + registry=self.registry, + ) + self.arduino_status_age_seconds = prom.Gauge( + "arduino_status_age_seconds", + "Seconds since the last `status` message from " + "the Arduino board (measured at the latest tick)", + ["arduino_name"], + registry=self.registry, + ) + + # Others: + self.is_panic = prom.Gauge( + "is_panic", "Is in panic mode", registry=self.registry + ) + self.is_threshold = prom.Gauge( + "is_threshold", "Is in threshold mode", registry=self.registry + ) + + self.tick_duration = prom.Histogram( + # Summary would have been better there, but prometheus_client + # doesn't yet support quantiles in Summaries. + # See: https://github.com/prometheus/client_python/issues/92 + "tick_duration", + "Duration of a single tick", + buckets=(0.1, 0.25, 0.5, 0.75, 1.0, 2.5, 5.0, 10.0, float("inf")), + registry=self.registry, + ) + last_metrics_tick_seconds_ago = prom.Gauge( + "last_metrics_tick_seconds_ago", + "The time in seconds since the last tick (which also updates these metrics)", + registry=self.registry, + ) + last_metrics_tick_seconds_ago.set_function( + lambda: self.last_metrics_tick_seconds_ago + ) + + @property + def last_metrics_tick_seconds_ago(self): + return self._clock() - self._last_metrics_collect_clock + + def _start(self): + # `prometheus_client.start_http_server` which persists a server reference + # so it could be stopped later. + CustomMetricsHandler = prom.MetricsHandler.factory(self.registry) + httpd = _ThreadingSimpleServer( + (self._listen_addr, self._listen_port), CustomMetricsHandler + ) + t = threading.Thread(target=httpd.serve_forever) + t.daemon = True + t.start() + return httpd + + def __enter__(self): + self._http_server = self._start() + return self + + def __exit__(self, exc_type, exc_value, exc_tb): + assert self._http_server is not None + self._http_server.shutdown() # stop serve_forever() + self._http_server.server_close() + self._http_server = None + return None + + def tick( + self, + temps: Mapping[TempName, ObservedTempStatus], + fans: Fans, + triggers: Triggers, + arduino_connections: Mapping[ArduinoName, ArduinoConnection], + ) -> None: + for temp_name, observed_temp_status in temps.items(): + temp_status = observed_temp_status.filtered + if temp_status is None: + self.temperature_is_failing.labels(temp_name).set(1) + self.temperature_current.labels(temp_name).set(none_to_nan(None)) + self.temperature_min.labels(temp_name).set(none_to_nan(None)) + self.temperature_max.labels(temp_name).set(none_to_nan(None)) + self.temperature_panic.labels(temp_name).set(none_to_nan(None)) + self.temperature_threshold.labels(temp_name).set(none_to_nan(None)) + self.temperature_is_panic.labels(temp_name).set(none_to_nan(None)) + self.temperature_is_threshold.labels(temp_name).set(none_to_nan(None)) + else: + self.temperature_is_failing.labels(temp_name).set(0) + self.temperature_current.labels(temp_name).set(temp_status.temp) + self.temperature_min.labels(temp_name).set(temp_status.min) + self.temperature_max.labels(temp_name).set(temp_status.max) + self.temperature_panic.labels(temp_name).set( + none_to_nan(temp_status.panic) + ) + self.temperature_threshold.labels(temp_name).set( + none_to_nan(temp_status.threshold) + ) + self.temperature_is_panic.labels(temp_name).set(temp_status.is_panic) + self.temperature_is_threshold.labels(temp_name).set( + temp_status.is_threshold + ) + + temp_status = observed_temp_status.raw + if temp_status is None: + self.temperature_current_raw.labels(temp_name).set(none_to_nan(None)) + else: + self.temperature_current_raw.labels(temp_name).set(temp_status.temp) + + for fan_name, pwmfan_norm in fans.fans.items(): + self._collect_fan_metrics(fans, fan_name, pwmfan_norm) + for readonly_fan_name, readonly_pwmfan_norm in fans.readonly_fans.items(): + self._collect_readonly_fan_metrics( + fans, readonly_fan_name, readonly_pwmfan_norm + ) + + for arduino_name, arduino_connection in arduino_connections.items(): + self.arduino_is_connected.labels(arduino_name).set( + arduino_connection.is_connected + ) + self.arduino_status_age_seconds.labels(arduino_name).set( + arduino_connection.status_age_seconds + ) + + self.is_panic.set(triggers.panic_trigger.is_alerting) + self.is_threshold.set(triggers.threshold_trigger.is_alerting) + + self._last_metrics_collect_clock = self._clock() + + def measure_tick(self) -> ContextManager[None]: + return self.tick_duration.time() + + def _collect_fan_metrics( + self, fans: Fans, fan_name: FanName, pwm_fan_norm: PWMFanNorm + ): + self.fan_pwm_line_start.labels(fan_name).set(pwm_fan_norm.pwm_line_start) + self.fan_pwm_line_end.labels(fan_name).set(pwm_fan_norm.pwm_line_end) + self._collect_any_fan_metrics(fans, fan_name, pwm_fan_norm) + + def _collect_readonly_fan_metrics( + self, fans: Fans, fan_name: ReadonlyFanName, pwm_fan_norm: ReadonlyPWMFanNorm + ): + self._collect_any_fan_metrics(fans, fan_name, pwm_fan_norm) + + def _collect_any_fan_metrics( + self, + fans: Fans, + fan_name: AnyFanName, + pwm_fan_norm: Union[PWMFanNorm, ReadonlyPWMFanNorm], + ): + self.fan_is_stopped.labels(fan_name).set(fans.is_fan_stopped(fan_name)) + self.fan_is_failing.labels(fan_name).set(fans.is_fan_failing(fan_name)) + try: + self.fan_rpm.labels(fan_name).set(pwm_fan_norm.get_speed()) + self.fan_pwm.labels(fan_name).set(none_to_nan(pwm_fan_norm.get_raw())) + self.fan_pwm_normalized.labels(fan_name).set( + none_to_nan(pwm_fan_norm.get()) + ) + except Exception: + logger.warning( + "Failed to collect metrics for fan %s", fan_name, exc_info=True + ) + self.fan_rpm.labels(fan_name).set(none_to_nan(None)) + self.fan_pwm.labels(fan_name).set(none_to_nan(None)) + self.fan_pwm_normalized.labels(fan_name).set(none_to_nan(None)) + + def _clock(self): + return default_timer() + + +def none_to_nan(v: Optional[float]) -> float: + if v is None: + return float("nan") + return v + + +class _ThreadingSimpleServer(ThreadingMixIn, HTTPServer): + """Thread per request HTTP server.""" + + # https://github.com/prometheus/client_python/blob/31f5557e2e84ca4ffa9a03abf6e3f4d0c8b8c3eb/prometheus_client/exposition.py#L180-L187 # noqa + # + # Make worker threads "fire and forget". Beginning with Python 3.7 this + # prevents a memory leak because ``ThreadingMixIn`` starts to gather all + # non-daemon threads in a list in order to join on them at server close. + # Enabling daemon threads virtually makes ``_ThreadingSimpleServer`` the + # same as Python 3.7's ``ThreadingHTTPServer``. + daemon_threads = True diff --git a/src/afancontrol/pwmfan/__init__.py b/src/afancontrol/pwmfan/__init__.py new file mode 100644 index 0000000..e3b9a66 --- /dev/null +++ b/src/afancontrol/pwmfan/__init__.py @@ -0,0 +1,131 @@ +from typing import Mapping, NamedTuple, NewType, Optional, Union + +from afancontrol.arduino import ArduinoConnection, ArduinoName +from afancontrol.configparser import ConfigParserSection +from afancontrol.exec import Programs +from afancontrol.pwmfan.arduino import ( + ArduinoFanPWMRead, + ArduinoFanPWMWrite, + ArduinoFanSpeed, +) +from afancontrol.pwmfan.base import ( + BaseFanPWMRead, + BaseFanPWMWrite, + BaseFanSpeed, + FanValue, + PWMValue, +) +from afancontrol.pwmfan.ipmi import FreeIPMIFanSpeed +from afancontrol.pwmfan.linux import ( + FanInputDevice, + LinuxFanPWMRead, + LinuxFanPWMWrite, + LinuxFanSpeed, + PWMDevice, +) + +__all__ = ( + "ArduinoFanPWMRead", + "ArduinoFanPWMWrite", + "ArduinoFanSpeed", + "BaseFanPWMRead", + "BaseFanPWMWrite", + "BaseFanSpeed", + "FanInputDevice", + "FanValue", + "FreeIPMIFanSpeed", + "LinuxFanPWMRead", + "LinuxFanPWMWrite", + "LinuxFanSpeed", + "PWMDevice", + "PWMValue", +) + +DEFAULT_FAN_TYPE = "linux" + +FanName = NewType("FanName", str) +ReadonlyFanName = NewType("ReadonlyFanName", str) +AnyFanName = Union[FanName, ReadonlyFanName] + + +class ReadOnlyFan(NamedTuple): + fan_speed: BaseFanSpeed + pwm_read: Optional[BaseFanPWMRead] + + @classmethod + def from_configparser( + cls, + section: ConfigParserSection[ReadonlyFanName], + arduino_connections: Mapping[ArduinoName, ArduinoConnection], + programs: Programs, + ) -> "ReadOnlyFan": + fan_type = section.get("type", fallback=DEFAULT_FAN_TYPE) + + if fan_type == "linux": + return cls( + fan_speed=LinuxFanSpeed.from_configparser(section), + pwm_read=( + LinuxFanPWMRead.from_configparser(section) + if "pwm" in section + else None + ), + ) + elif fan_type == "arduino": + return cls( + fan_speed=ArduinoFanSpeed.from_configparser( + section, arduino_connections + ), + pwm_read=( + ArduinoFanPWMRead.from_configparser(section, arduino_connections) + if "pwm_pin" in section + else None + ), + ) + elif fan_type == "freeipmi": + return cls( + fan_speed=FreeIPMIFanSpeed.from_configparser(section, programs), + pwm_read=None, + ) + else: + raise ValueError( + "Unsupported FAN type %s. Supported ones are " + "`linux`, `arduino`, `freeipmi`." % fan_type + ) + + +class ReadWriteFan(NamedTuple): + fan_speed: BaseFanSpeed + pwm_read: BaseFanPWMRead + pwm_write: BaseFanPWMWrite + + @classmethod + def from_configparser( + cls, + section: ConfigParserSection[FanName], + arduino_connections: Mapping[ArduinoName, ArduinoConnection], + ) -> "ReadWriteFan": + fan_type = section.get("type", fallback=DEFAULT_FAN_TYPE) + + if fan_type == "linux": + return cls( + fan_speed=LinuxFanSpeed.from_configparser(section), + pwm_read=LinuxFanPWMRead.from_configparser(section), + pwm_write=LinuxFanPWMWrite.from_configparser(section), + ) + elif fan_type == "arduino": + return cls( + fan_speed=ArduinoFanSpeed.from_configparser( + section, arduino_connections + ), + pwm_read=ArduinoFanPWMRead.from_configparser( + section, arduino_connections + ), + pwm_write=ArduinoFanPWMWrite.from_configparser( + section, arduino_connections + ), + ) + else: + raise ValueError( + "Unsupported FAN type %s. Supported ones are " + "`linux` and `arduino`." % fan_type + ) diff --git a/src/afancontrol/pwmfan/arduino.py b/src/afancontrol/pwmfan/arduino.py new file mode 100644 index 0000000..6389606 --- /dev/null +++ b/src/afancontrol/pwmfan/arduino.py @@ -0,0 +1,133 @@ +from typing import Mapping + +from afancontrol.arduino import ArduinoConnection, ArduinoName, ArduinoPin +from afancontrol.configparser import ConfigParserSection +from afancontrol.pwmfan.base import ( + BaseFanPWMRead, + BaseFanPWMWrite, + BaseFanSpeed, + FanValue, + PWMValue, +) + + +def _ensure_arduino_connection( + arduino_name: ArduinoName, + arduino_connections: Mapping[ArduinoName, ArduinoConnection], +) -> None: + if arduino_name not in arduino_connections: + raise ValueError("[arduino:%s] section is missing" % arduino_name) + + +class ArduinoFanSpeed(BaseFanSpeed): + __slots__ = "_conn", "_tacho_pin" + + def __init__( + self, arduino_connection: ArduinoConnection, *, tacho_pin: ArduinoPin + ) -> None: + self._conn = arduino_connection + self._tacho_pin = tacho_pin + + @classmethod + def from_configparser( + cls, + section: ConfigParserSection, + arduino_connections: Mapping[ArduinoName, ArduinoConnection], + ) -> BaseFanSpeed: + arduino_name = ArduinoName(section["arduino_name"]) + _ensure_arduino_connection(arduino_name, arduino_connections) + tacho_pin = ArduinoPin(section.getint("tacho_pin")) + return cls(arduino_connections[arduino_name], tacho_pin=tacho_pin) + + def get_speed(self) -> FanValue: + return FanValue(self._conn.get_rpm(self._tacho_pin)) + + def __enter__(self): # reusable + self._conn.__enter__() + return self + + def __exit__(self, exc_type, exc_value, exc_tb): + self._conn.__exit__(exc_type, exc_value, exc_tb) + + +class ArduinoFanPWMRead(BaseFanPWMRead): + __slots__ = "_conn", "_pwm_pin" + + max_pwm = PWMValue(255) + min_pwm = PWMValue(0) + + def __init__( + self, + arduino_connection: ArduinoConnection, + *, + pwm_pin: ArduinoPin, + ) -> None: + self._conn = arduino_connection + self._pwm_pin = pwm_pin + + @classmethod + def from_configparser( + cls, + section: ConfigParserSection, + arduino_connections: Mapping[ArduinoName, ArduinoConnection], + ) -> BaseFanPWMRead: + arduino_name = ArduinoName(section["arduino_name"]) + _ensure_arduino_connection(arduino_name, arduino_connections) + pwm_pin = ArduinoPin(section.getint("pwm_pin")) + return cls(arduino_connections[arduino_name], pwm_pin=pwm_pin) + + def get(self) -> PWMValue: + return PWMValue(int(self._conn.get_pwm(self._pwm_pin))) + + def __enter__(self): # reusable + self._conn.__enter__() + return self + + def __exit__(self, exc_type, exc_value, exc_tb): + self._conn.__exit__(exc_type, exc_value, exc_tb) + + +class ArduinoFanPWMWrite(BaseFanPWMWrite): + __slots__ = "_conn", "_pwm_pin" + + read_cls = ArduinoFanPWMRead + + def __init__( + self, + arduino_connection: ArduinoConnection, + *, + pwm_pin: ArduinoPin, + ) -> None: + self._conn = arduino_connection + self._pwm_pin = pwm_pin + + @classmethod + def from_configparser( + cls, + section: ConfigParserSection, + arduino_connections: Mapping[ArduinoName, ArduinoConnection], + ) -> BaseFanPWMWrite: + arduino_name = ArduinoName(section["arduino_name"]) + _ensure_arduino_connection(arduino_name, arduino_connections) + pwm_pin = ArduinoPin(section.getint("pwm_pin")) + return cls(arduino_connections[arduino_name], pwm_pin=pwm_pin) + + def _set_raw(self, pwm: PWMValue) -> None: + self._conn.set_pwm(self._pwm_pin, pwm) + + def __enter__(self): # reusable + self._conn.__enter__() + self.set_full_speed() + return self + + def __exit__(self, exc_type, exc_value, exc_tb): + try: + self.set_full_speed() + self._conn.wait_for_status() + + if int(self._conn.get_pwm(self._pwm_pin)) >= self.read_cls.max_pwm: + return + + raise RuntimeError("Couldn't disable PWM on the fan %r" % self) + finally: + self._conn.__exit__(exc_type, exc_value, exc_tb) diff --git a/src/afancontrol/pwmfan/base.py b/src/afancontrol/pwmfan/base.py new file mode 100644 index 0000000..22bc6c1 --- /dev/null +++ b/src/afancontrol/pwmfan/base.py @@ -0,0 +1,86 @@ +import abc +from typing import NewType, Type + +PWMValue = NewType("PWMValue", int) # [0..255] +FanValue = NewType("FanValue", int) + + +class _SlotsReprMixin: + def __eq__(self, other): + if isinstance(other, type(self)): + for attr in self.__slots__: + if getattr(self, attr) != getattr(other, attr): + return False + return True + + return NotImplemented + + def __ne__(self, other): + return not (self == other) + + def __repr__(self): + # repr assumes that the `__slots__` attrs match the `__init__` signature. + + return "%s(%s)" % ( + type(self).__name__, + ", ".join(repr(getattr(self, attr)) for attr in self.__slots__), + ) + + +class BaseFanSpeed(abc.ABC, _SlotsReprMixin): + @abc.abstractmethod + def get_speed(self) -> FanValue: + pass + + def __enter__(self): # reusable + return self + + def __exit__(self, exc_type, exc_value, exc_tb): + pass + + +class BaseFanPWMRead(abc.ABC, _SlotsReprMixin): + max_pwm: PWMValue + min_pwm: PWMValue + + def is_stopped(self) -> bool: + return type(self).is_pwm_stopped(self.get()) + + @staticmethod + def is_pwm_stopped(pwm: PWMValue) -> bool: + return pwm <= 0 + + @abc.abstractmethod + def get(self) -> PWMValue: + pass + + def __enter__(self): # reusable + return self + + def __exit__(self, exc_type, exc_value, exc_tb): + pass + + +class BaseFanPWMWrite(abc.ABC, _SlotsReprMixin): + read_cls: Type[BaseFanPWMRead] + + def set(self, pwm: PWMValue) -> None: + if not (self.read_cls.min_pwm <= pwm <= self.read_cls.max_pwm): + raise ValueError( + "Invalid pwm value %s: it must be within [%s..%s]" + % (pwm, self.read_cls.min_pwm, self.read_cls.max_pwm) + ) + self._set_raw(pwm) + + def set_full_speed(self) -> None: + self._set_raw(self.read_cls.max_pwm) + + @abc.abstractmethod + def _set_raw(self, pwm: PWMValue) -> None: + pass + + def __enter__(self): # reusable + return self + + def __exit__(self, exc_type, exc_value, exc_tb): + pass diff --git a/src/afancontrol/pwmfan/ipmi.py b/src/afancontrol/pwmfan/ipmi.py new file mode 100644 index 0000000..b2f138d --- /dev/null +++ b/src/afancontrol/pwmfan/ipmi.py @@ -0,0 +1,49 @@ +import csv +import io + +from afancontrol.configparser import ConfigParserSection +from afancontrol.exec import Programs, exec_shell_command +from afancontrol.pwmfan.base import BaseFanSpeed, FanValue + +# TODO maybe switch to `python3-pyghmi`? although it looks like the current version +# in Stretch is broken in py3: https://opendev.org/x/pyghmi/commit/2e12f5ce15e11e46a1c11ee3b00b94cb8bd7feb9 # noqa + + +class FreeIPMIFanSpeed(BaseFanSpeed): + __slots__ = ("_name", "_ipmi_sensors_bin", "_ipmi_sensors_extra_args") + + def __init__( + self, name: str, *, ipmi_sensors_bin="ipmi-sensors", ipmi_sensors_extra_args="" + ) -> None: + self._name = name + self._ipmi_sensors_bin = ipmi_sensors_bin + self._ipmi_sensors_extra_args = ipmi_sensors_extra_args + + @classmethod + def from_configparser( + cls, section: ConfigParserSection, programs: Programs + ) -> BaseFanSpeed: + return cls( + section["name"], + ipmi_sensors_bin=programs.ipmi_sensors, + ipmi_sensors_extra_args=section.get("ipmi_sensors_extra_args", fallback=""), + ) + + def get_speed(self) -> FanValue: + out = self._call_ipmi_sensors() + reader = csv.DictReader(io.StringIO(out)) + for row in reader: + if row["Name"] == self._name: + assert row["Units"] == "RPM" + # assert row["Event"] == "'OK'" + return FanValue(int(float(row["Reading"]))) + raise RuntimeError( + "ipmi-sensors output doesn't contain %r fan:\n%s" % (self._name, out) + ) + + def _call_ipmi_sensors(self) -> str: + shell_command = "%s %s --sensor-types Fan --comma-separated-output" % ( + self._ipmi_sensors_bin, + self._ipmi_sensors_extra_args, + ) + return exec_shell_command(shell_command, timeout=2) diff --git a/src/afancontrol/pwmfan/linux.py b/src/afancontrol/pwmfan/linux.py new file mode 100644 index 0000000..9ebf197 --- /dev/null +++ b/src/afancontrol/pwmfan/linux.py @@ -0,0 +1,90 @@ +from pathlib import Path +from typing import NewType + +from afancontrol.configparser import ConfigParserSection +from afancontrol.pwmfan.base import ( + BaseFanPWMRead, + BaseFanPWMWrite, + BaseFanSpeed, + FanValue, + PWMValue, +) + +PWMDevice = NewType("PWMDevice", str) +FanInputDevice = NewType("FanInputDevice", str) + + +class LinuxFanSpeed(BaseFanSpeed): + __slots__ = ("_fan_input",) + + def __init__(self, fan_input: FanInputDevice) -> None: + self._fan_input = Path(fan_input) + + @classmethod + def from_configparser(cls, section: ConfigParserSection) -> BaseFanSpeed: + return cls(FanInputDevice(section["fan_input"])) + + def get_speed(self) -> FanValue: + return FanValue(int(self._fan_input.read_text())) + + +class LinuxFanPWMRead(BaseFanPWMRead): + __slots__ = ("_pwm",) + + max_pwm = PWMValue(255) + min_pwm = PWMValue(0) + + def __init__(self, pwm: PWMDevice) -> None: + self._pwm = Path(pwm) + + @classmethod + def from_configparser(cls, section: ConfigParserSection) -> BaseFanPWMRead: + return cls(PWMDevice(section["pwm"])) + + def get(self) -> PWMValue: + return PWMValue(int(self._pwm.read_text())) + + +class LinuxFanPWMWrite(BaseFanPWMWrite): + __slots__ = "_pwm", "_pwm_enable" + + read_cls = LinuxFanPWMRead + + def __init__(self, pwm: PWMDevice) -> None: + self._pwm = Path(pwm) + self._pwm_enable = Path(pwm + "_enable") + + @classmethod + def from_configparser(cls, section: ConfigParserSection) -> BaseFanPWMWrite: + return cls(PWMDevice(section["pwm"])) + + def _set_raw(self, pwm: PWMValue) -> None: + self._pwm.write_text(str(int(pwm))) + + def __enter__(self): # reusable + # fancontrol way of doing it + if self._pwm_enable.is_file(): + self._pwm_enable.write_text("1") + self.set_full_speed() + return self + + def __exit__(self, exc_type, exc_value, exc_tb): + # fancontrol way of doing it + if not self._pwm_enable.is_file(): + self.set_full_speed() + return + + self._pwm_enable.write_text("0") + if self._pwm_enable.read_text().strip() == "0": + return + + self._pwm_enable.write_text("1") + self.set_full_speed() + + if ( + self._pwm_enable.read_text().strip() == "1" + and int(self._pwm.read_text()) >= self.read_cls.max_pwm + ): + return + + raise RuntimeError("Couldn't disable PWM on the fan %r" % self) diff --git a/src/afancontrol/pwmfannorm.py b/src/afancontrol/pwmfannorm.py new file mode 100644 index 0000000..b5068fc --- /dev/null +++ b/src/afancontrol/pwmfannorm.py @@ -0,0 +1,234 @@ +import math +from contextlib import ExitStack +from typing import Mapping, NewType, Optional + +from afancontrol.arduino import ArduinoConnection, ArduinoName +from afancontrol.configparser import ConfigParserSection +from afancontrol.exec import Programs +from afancontrol.pwmfan import ( + BaseFanPWMRead, + BaseFanPWMWrite, + BaseFanSpeed, + FanName, + FanValue, + PWMValue, + ReadOnlyFan, + ReadonlyFanName, + ReadWriteFan, +) + +PWMValueNorm = NewType("PWMValueNorm", float) # [0..1] + + +class ReadonlyPWMFanNorm: + def __init__( + self, fan_speed: BaseFanSpeed, pwm_read: Optional[BaseFanPWMRead] = None + ) -> None: + self.fan_speed = fan_speed + self.pwm_read = pwm_read + self._stack: Optional[ExitStack] = None + + @classmethod + def from_configparser( + cls, + section: ConfigParserSection[ReadonlyFanName], + arduino_connections: Mapping[ArduinoName, ArduinoConnection], + programs: Programs, + ) -> "ReadonlyPWMFanNorm": + readonly_fan = ReadOnlyFan.from_configparser( + section, arduino_connections, programs + ) + return cls(readonly_fan.fan_speed, readonly_fan.pwm_read) + + def __enter__(self): + self._stack = ExitStack() + try: + self._stack.enter_context(self.fan_speed) + if self.pwm_read is not None: + self._stack.enter_context(self.pwm_read) + except Exception: + self._stack.close() + raise + return self + + def __exit__(self, exc_type, exc_value, exc_tb): + assert self._stack is not None + self._stack.close() + + def get_speed(self) -> FanValue: + return self.fan_speed.get_speed() + + def __eq__(self, other): + if isinstance(other, type(self)): + return self.fan_speed == other.fan_speed and self.pwm_read == other.pwm_read + + return NotImplemented + + def __ne__(self, other): + return not (self == other) + + def __repr__(self): + return "%s(%r, %r)" % (type(self).__name__, self.fan_speed, self.pwm_read) + + def is_pwm_stopped(self, pwm: Optional[PWMValue]) -> Optional[bool]: + if self.pwm_read is None: + return None + if pwm is None: + return None + return type(self.pwm_read).is_pwm_stopped(pwm) + + def get_raw(self) -> Optional[PWMValue]: + if self.pwm_read is None: + return None + return self.pwm_read.get() + + def get(self) -> Optional[PWMValueNorm]: + if self.pwm_read is None: + return None + raw = self.get_raw() + assert raw is not None + return PWMValueNorm(raw / self.pwm_read.max_pwm) + + +class PWMFanNorm: + def __init__( + self, + fan_speed: BaseFanSpeed, + pwm_read: BaseFanPWMRead, + pwm_write: BaseFanPWMWrite, + *, + pwm_line_start: PWMValue, + pwm_line_end: PWMValue, + never_stop: bool = False + ) -> None: + self.fan_speed = fan_speed + self.pwm_read = pwm_read + self.pwm_write = pwm_write + self.pwm_line_start = pwm_line_start + self.pwm_line_end = pwm_line_end + self.never_stop = never_stop + if type(self.pwm_read).min_pwm > self.pwm_line_start: + raise ValueError( + "Invalid pwm_line_start. Expected: min_pwm <= pwm_line_start. " + "Got: %s <= %s" % (type(self.pwm_read).min_pwm, self.pwm_line_start) + ) + if self.pwm_line_end > type(self.pwm_read).max_pwm: + raise ValueError( + "Invalid pwm_line_end. Expected: pwm_line_end <= max_pwm. " + "Got: %s <= %s" % (self.pwm_line_end, type(self.pwm_read).max_pwm) + ) + self._stack: Optional[ExitStack] = None + + @classmethod + def from_configparser( + cls, + section: ConfigParserSection[FanName], + arduino_connections: Mapping[ArduinoName, ArduinoConnection], + ) -> "PWMFanNorm": + readwrite_fan = ReadWriteFan.from_configparser(section, arduino_connections) + never_stop = section.getboolean("never_stop", fallback=True) + pwm_line_start = PWMValue(section.getint("pwm_line_start", fallback=100)) + pwm_line_end = PWMValue(section.getint("pwm_line_end", fallback=240)) + + for pwm_value in (pwm_line_start, pwm_line_end): + if not ( + readwrite_fan.pwm_read.min_pwm + <= pwm_value + <= readwrite_fan.pwm_read.max_pwm + ): + raise RuntimeError( + "Incorrect PWM value '%s' for fan '%s': it must be within [%s;%s]" + % ( + pwm_value, + section.name, + readwrite_fan.pwm_read.min_pwm, + readwrite_fan.pwm_read.max_pwm, + ) + ) + if pwm_line_start >= pwm_line_end: + raise RuntimeError( + "`pwm_line_start` PWM value must be less than `pwm_line_end` for fan '%s'" + % (section.name,) + ) + + return cls( + readwrite_fan.fan_speed, + readwrite_fan.pwm_read, + readwrite_fan.pwm_write, + pwm_line_start=pwm_line_start, + pwm_line_end=pwm_line_end, + never_stop=never_stop, + ) + + def __eq__(self, other): + if isinstance(other, type(self)): + return ( + self.fan_speed == other.fan_speed + and self.pwm_read == other.pwm_read + and self.pwm_write == other.pwm_write + and self.pwm_line_start == other.pwm_line_start + and self.pwm_line_end == other.pwm_line_end + and self.never_stop == other.never_stop + ) + + return NotImplemented + + def __ne__(self, other): + return not (self == other) + + def __repr__(self): + return "%s(%r, %r, %r, pwm_line_start=%r, pwm_line_end=%r, never_stop=%r)" % ( + type(self).__name__, + self.fan_speed, + self.pwm_read, + self.pwm_write, + self.pwm_line_start, + self.pwm_line_end, + self.never_stop, + ) + + def __enter__(self): + self._stack = ExitStack() + try: + self._stack.enter_context(self.fan_speed) + self._stack.enter_context(self.pwm_read) + self._stack.enter_context(self.pwm_write) + except Exception: + self._stack.close() + raise + return self + + def __exit__(self, exc_type, exc_value, exc_tb): + assert self._stack is not None + self._stack.close() + + def get_speed(self) -> FanValue: + return self.fan_speed.get_speed() + + def is_pwm_stopped(self, pwm: PWMValue) -> bool: + return type(self.pwm_read).is_pwm_stopped(pwm) + + def set_full_speed(self) -> None: + self.pwm_write.set_full_speed() + + def get_raw(self) -> PWMValue: + return self.pwm_read.get() + + def get(self) -> PWMValueNorm: + return PWMValueNorm(self.get_raw() / self.pwm_read.max_pwm) + + def set(self, pwm_norm: PWMValueNorm) -> PWMValue: + # TODO validate this formula + pwm_norm = max(pwm_norm, PWMValueNorm(0.0)) + pwm_norm = min(pwm_norm, PWMValueNorm(1.0)) + pwm = pwm_norm * self.pwm_line_end + if 0 < pwm < self.pwm_line_start: + pwm = self.pwm_line_start + if pwm <= 0 and self.never_stop: + pwm = self.pwm_line_start + if pwm_norm >= 1.0: + pwm = self.pwm_read.max_pwm + + pwm = PWMValue(int(math.ceil(pwm))) + self.pwm_write.set(pwm) + return pwm diff --git a/src/afancontrol/report.py b/src/afancontrol/report.py new file mode 100644 index 0000000..4a3bd9e --- /dev/null +++ b/src/afancontrol/report.py @@ -0,0 +1,17 @@ +from afancontrol.exec import exec_shell_command +from afancontrol.logger import logger + + +class Report: + def __init__(self, report_command: str) -> None: + self._report_command = report_command + + def report(self, reason: str, message: str) -> None: + logger.info("[REPORT] Reason: %s. Message: %s", reason, message) + try: + rc = self._report_command + rc = rc.replace("%REASON%", reason) + rc = rc.replace("%MESSAGE%", message) + exec_shell_command(rc) + except Exception as ex: + logger.warning("Report failed: %s", ex, exc_info=True) diff --git a/src/afancontrol/temp/__init__.py b/src/afancontrol/temp/__init__.py new file mode 100644 index 0000000..12e359d --- /dev/null +++ b/src/afancontrol/temp/__init__.py @@ -0,0 +1,55 @@ +from typing import Mapping, NamedTuple, NewType + +from afancontrol.configparser import ConfigParserSection +from afancontrol.exec import Programs +from afancontrol.filters import FilterName, NullFilter, TempFilter +from afancontrol.temp.base import Temp, TempCelsius, TempStatus +from afancontrol.temp.command import CommandTemp +from afancontrol.temp.file import FileTemp +from afancontrol.temp.hdd import HDDTemp + +__all__ = ( + "CommandTemp", + "FileTemp", + "HDDTemp", + "Temp", + "TempCelsius", + "TempStatus", +) + +TempName = NewType("TempName", str) + + +class FilteredTemp(NamedTuple): + temp: Temp + filter: TempFilter + + @classmethod + def from_configparser( + cls, + section: ConfigParserSection[TempName], + filters: Mapping[FilterName, TempFilter], + programs: Programs, + ) -> "FilteredTemp": + + type = section["type"] + + if type == "file": + temp: Temp = FileTemp.from_configparser(section) + elif type == "hdd": + temp = HDDTemp.from_configparser(section, programs) + elif type == "exec": + temp = CommandTemp.from_configparser(section) + else: + raise RuntimeError( + "Unsupported temp type '%s' for temp '%s'" % (type, section.name) + ) + + filter_name = section.get("filter", fallback=None) + + if filter_name is None: + filter: TempFilter = NullFilter() + else: + filter = filters[FilterName(filter_name.strip())].copy() + + return cls(temp=temp, filter=filter) diff --git a/src/afancontrol/temp/base.py b/src/afancontrol/temp/base.py new file mode 100644 index 0000000..097583c --- /dev/null +++ b/src/afancontrol/temp/base.py @@ -0,0 +1,44 @@ +import abc +from typing import NamedTuple, NewType, Optional, Tuple + +TempCelsius = NewType("TempCelsius", float) + + +class TempStatus(NamedTuple): + temp: TempCelsius + min: TempCelsius + max: TempCelsius + panic: Optional[TempCelsius] + threshold: Optional[TempCelsius] + is_panic: bool + is_threshold: bool + + +class Temp(abc.ABC): + def __init__( + self, *, panic: Optional[TempCelsius], threshold: Optional[TempCelsius] + ) -> None: + self._panic = panic + self._threshold = threshold + + def get(self) -> TempStatus: + temp, min_t, max_t = self._get_temp() + + if not (min_t < max_t): + raise RuntimeError( + "Min temperature must be less than max. %s < %s" % (min_t, max_t) + ) + + return TempStatus( + temp=temp, + min=min_t, + max=max_t, + panic=self._panic, + threshold=self._threshold, + is_panic=self._panic is not None and temp >= self._panic, + is_threshold=self._threshold is not None and temp >= self._threshold, + ) + + @abc.abstractmethod + def _get_temp(self) -> Tuple[TempCelsius, TempCelsius, TempCelsius]: + pass diff --git a/src/afancontrol/temp/command.py b/src/afancontrol/temp/command.py new file mode 100644 index 0000000..9fbfb48 --- /dev/null +++ b/src/afancontrol/temp/command.py @@ -0,0 +1,76 @@ +from typing import Optional, Tuple + +from afancontrol.configparser import ConfigParserSection +from afancontrol.exec import exec_shell_command +from afancontrol.temp.base import Temp, TempCelsius + + +class CommandTemp(Temp): + def __init__( + self, + shell_command: str, + *, + min: Optional[TempCelsius], + max: Optional[TempCelsius], + panic: Optional[TempCelsius], + threshold: Optional[TempCelsius] + ) -> None: + super().__init__(panic=panic, threshold=threshold) + self._shell_command = shell_command + self._min = min + self._max = max + + @classmethod + def from_configparser(cls, section: ConfigParserSection) -> Temp: + panic = TempCelsius(section.getfloat("panic", fallback=None)) + threshold = TempCelsius(section.getfloat("threshold", fallback=None)) + min = TempCelsius(section.getfloat("min", fallback=None)) + max = TempCelsius(section.getfloat("max", fallback=None)) + return cls( + section["command"], min=min, max=max, panic=panic, threshold=threshold + ) + + def __eq__(self, other): + if isinstance(other, type(self)): + return ( + self._shell_command == other._shell_command + and self._min == other._min + and self._max == other._max + and self._panic == other._panic + and self._threshold == other._threshold + ) + + return NotImplemented + + def __ne__(self, other): + return not (self == other) + + def __repr__(self): + return "%s(%r, min=%r, max=%r, panic=%r, threshold=%r)" % ( + type(self).__name__, + self._shell_command, + self._min, + self._max, + self._panic, + self._threshold, + ) + + def _get_temp(self) -> Tuple[TempCelsius, TempCelsius, TempCelsius]: + temps = [ + float(line.strip()) + for line in exec_shell_command(self._shell_command).split("\n") + if line.strip() + ] + temp = TempCelsius(temps[0]) + + if self._min is not None: + min_t = self._min + else: + min_t = TempCelsius(temps[1]) + + if self._max is not None: + max_t = self._max + else: + max_t = TempCelsius(temps[2]) + + return temp, min_t, max_t diff --git a/src/afancontrol/temp/file.py b/src/afancontrol/temp/file.py new file mode 100644 index 0000000..0b2d5f2 --- /dev/null +++ b/src/afancontrol/temp/file.py @@ -0,0 +1,109 @@ +import glob +import re +from pathlib import Path +from typing import Optional, Tuple + +from afancontrol.configparser import ConfigParserSection +from afancontrol.temp.base import Temp, TempCelsius + + +def _expand_glob(path: str): + matches = glob.glob(path) + if not matches: + return path # a FileNotFoundError will be raised on a first read attempt + if len(matches) == 1: + return matches[0] + raise ValueError("Expected glob to expand to a single path, got %r" % (matches,)) + + +class FileTemp(Temp): + def __init__( + self, + temp_path: str, # /sys/class/hwmon/hwmon0/temp1 + *, + min: Optional[TempCelsius], + max: Optional[TempCelsius], + panic: Optional[TempCelsius], + threshold: Optional[TempCelsius] + ) -> None: + super().__init__(panic=panic, threshold=threshold) + temp_path = re.sub(r"_input$", "", temp_path) + + # Allow paths looking like this (this one is from an nvme drive): + # /sys/devices/pci0000:00/0000:00:01.3/[...]/hwmon/hwmon*/temp1_input + # The `hwmon*` might change after reboot, but it is always a single + # directory within the device. + temp_path = _expand_glob(temp_path + "_input") + temp_path = re.sub(r"_input$", "", temp_path) + + self._temp_input = Path(temp_path + "_input") + self._temp_min = Path(temp_path + "_min") + self._temp_max = Path(temp_path + "_max") + self._min = min + self._max = max + + @classmethod + def from_configparser(cls, section: ConfigParserSection) -> Temp: + panic = TempCelsius(section.getfloat("panic", fallback=None)) + threshold = TempCelsius(section.getfloat("threshold", fallback=None)) + min = TempCelsius(section.getfloat("min", fallback=None)) + max = TempCelsius(section.getfloat("max", fallback=None)) + return cls(section["path"], min=min, max=max, panic=panic, threshold=threshold) + + def __eq__(self, other): + if isinstance(other, type(self)): + return ( + self._temp_input == other._temp_input + and self._temp_min == other._temp_min + and self._temp_max == other._temp_max + and self._min == other._min + and self._max == other._max + and self._panic == other._panic + and self._threshold == other._threshold + ) + + return NotImplemented + + def __ne__(self, other): + return not (self == other) + + def __repr__(self): + return "%s(%r, min=%r, max=%r, panic=%r, threshold=%r)" % ( + type(self).__name__, + str(self._temp_input), + self._min, + self._max, + self._panic, + self._threshold, + ) + + def _get_temp(self) -> Tuple[TempCelsius, TempCelsius, TempCelsius]: + temp = self._read_temp_from_path(self._temp_input) + return temp, self._get_min(), self._get_max() + + def _get_min(self) -> TempCelsius: + if self._min is not None: + return self._min + try: + min_t = self._read_temp_from_path(self._temp_min) + except FileNotFoundError: + raise RuntimeError( + "Please specify `min` and `max` temperatures for " + "the %s sensor" % self._temp_input + ) + return min_t + + def _get_max(self) -> TempCelsius: + if self._max is not None: + return self._max + try: + max_t = self._read_temp_from_path(self._temp_max) + except FileNotFoundError: + raise RuntimeError( + "Please specify `min` and `max` temperatures for " + "the %s sensor" % self._temp_input + ) + return max_t + + def _read_temp_from_path(self, path: Path) -> TempCelsius: + return TempCelsius(int(path.read_text().strip()) / 1000) diff --git a/src/afancontrol/temp/hdd.py b/src/afancontrol/temp/hdd.py new file mode 100644 index 0000000..b554959 --- /dev/null +++ b/src/afancontrol/temp/hdd.py @@ -0,0 +1,102 @@ +from typing import Optional, Tuple + +from afancontrol.configparser import ConfigParserSection +from afancontrol.exec import Programs, exec_shell_command +from afancontrol.temp.base import Temp, TempCelsius + + +def _is_float(s: str) -> bool: + if not s: + return False + try: + float(s) + except (ValueError, TypeError): + return False + else: + return True + + +class HDDTemp(Temp): + def __init__( + self, + disk_path: str, + *, + min: TempCelsius, + max: TempCelsius, + panic: Optional[TempCelsius], + threshold: Optional[TempCelsius], + hddtemp_bin: str = "hddtemp" + ) -> None: + super().__init__(panic=panic, threshold=threshold) + self._disk_path = disk_path + self._min = min + self._max = max + self._hddtemp_bin = hddtemp_bin + + @classmethod + def from_configparser( + cls, section: ConfigParserSection, programs: Programs + ) -> Temp: + panic = TempCelsius(section.getfloat("panic", fallback=None)) + threshold = TempCelsius(section.getfloat("threshold", fallback=None)) + min = TempCelsius(section.getfloat("min")) + max = TempCelsius(section.getfloat("max")) + return cls( + section["path"], + min=min, + max=max, + panic=panic, + threshold=threshold, + hddtemp_bin=programs.hddtemp, + ) + + def __eq__(self, other): + if isinstance(other, type(self)): + return ( + self._disk_path == other._disk_path + and self._min == other._min + and self._max == other._max + and self._panic == other._panic + and self._threshold == other._threshold + and self._hddtemp_bin == other._hddtemp_bin + ) + + return NotImplemented + + def __ne__(self, other): + return not (self == other) + + def __repr__(self): + return "%s(%r, min=%r, max=%r, panic=%r, threshold=%r, hddtemp_bin=%r)" % ( + type(self).__name__, + self._disk_path, + self._min, + self._max, + self._panic, + self._threshold, + self._hddtemp_bin, + ) + + def _get_temp(self) -> Tuple[TempCelsius, TempCelsius, TempCelsius]: + temps = [ + float(line.strip()) + for line in self._call_hddtemp().split("\n") + if _is_float(line.strip()) + ] + if not temps: + raise RuntimeError( + "hddtemp returned empty list of valid temperature values" + ) + temp = TempCelsius(max(temps)) + return temp, self._get_min(), self._get_max() + + def _get_min(self) -> TempCelsius: + return TempCelsius(self._min) + + def _get_max(self) -> TempCelsius: + return TempCelsius(self._max) + + def _call_hddtemp(self) -> str: + # `disk_path` might be a glob, so it has to be executed with a shell. + shell_command = "%s -n -u C -- %s" % (self._hddtemp_bin, self._disk_path) + return exec_shell_command(shell_command, timeout=10) diff --git a/src/afancontrol/temps.py b/src/afancontrol/temps.py new file mode 100644 index 0000000..079fc2e --- /dev/null +++ b/src/afancontrol/temps.py @@ -0,0 +1,77 @@ +import concurrent.futures +from contextlib import ExitStack +from typing import Mapping, NamedTuple, Optional + +from afancontrol.config import FilteredTemp, TempName +from afancontrol.filters import TempFilter +from afancontrol.logger import logger +from afancontrol.temp import Temp, TempStatus + + +class ObservedTempStatus(NamedTuple): + raw: Optional[TempStatus] + filtered: Optional[TempStatus] + + +def filtered_temps( + temps: Mapping[TempName, ObservedTempStatus] +) -> Mapping[TempName, Optional[TempStatus]]: + return { + temp_name: observed_temp_status.filtered + for temp_name, observed_temp_status in temps.items() + } + + +class Temps: + def __init__(self, temps: Mapping[TempName, FilteredTemp]) -> None: + self.temps = temps + self._stack: Optional[ExitStack] = None + self._executor: Optional[concurrent.futures.Executor] = None + + def __enter__(self): # reusable + self._stack = ExitStack() + try: + for filtered_temp in self.temps.values(): + self._stack.enter_context(filtered_temp.filter) + self._executor = self._stack.enter_context( + concurrent.futures.ThreadPoolExecutor() + ) + except Exception: + self._stack.close() + raise + return self + + def __exit__(self, exc_type, exc_value, exc_tb): + assert self._stack is not None + self._stack.close() + self._executor = None + + def get_temps(self) -> Mapping[TempName, ObservedTempStatus]: + assert self._executor is not None + futures = { + temp_name: self._executor.submit( + _get_temp_status, + temp_name, + temp=filtered_temp.temp, + filter=filtered_temp.filter, + ) + for temp_name, filtered_temp in self.temps.items() + } + return {temp_name: future.result() for temp_name, future in futures.items()} + + +def _get_temp_status( + name: TempName, temp: Temp, filter: TempFilter +) -> ObservedTempStatus: + try: + sensor_value: Optional[TempStatus] = temp.get() + except Exception as e: + sensor_value = None + logger.warning("Temp sensor [%s] has failed: %s", name, e, exc_info=True) + + filtered_value = filter.apply(sensor_value) + logger.debug( + "Temp status [%s]: actual=%s, filtered=%s", name, sensor_value, filtered_value + ) + + return ObservedTempStatus(raw=sensor_value, filtered=filtered_value) diff --git a/src/afancontrol/trigger.py b/src/afancontrol/trigger.py new file mode 100644 index 0000000..3dd363d --- /dev/null +++ b/src/afancontrol/trigger.py @@ -0,0 +1,210 @@ +import abc +from contextlib import ExitStack +from typing import Mapping, Optional, Set + +from afancontrol.config import AlertCommands, TempName, TriggerConfig +from afancontrol.exec import exec_shell_command +from afancontrol.logger import logger +from afancontrol.report import Report +from afancontrol.temp import TempStatus + + +class Trigger(abc.ABC): + def __init__( + self, + *, + global_commands: AlertCommands, + temp_commands: Mapping[TempName, AlertCommands], + report: Report + ) -> None: + self.global_commands = global_commands + self.temp_commands = temp_commands + self.report = report + self._alerting_temps: Set[TempName] = set() + + @property + @abc.abstractmethod + def trigger_name(self): + pass + + def __enter__(self): # reusable + assert not self._alerting_temps + self._alerting_temps.clear() + return self + + def __exit__(self, exc_type, exc_value, exc_tb): + if self.is_alerting: + # Although the exceptional situation is not yet resolved, + # we call the corresponding leave callbacks, because + # if this is a reload, then we might get the enter callbacks + # being executed twice. + # Perhaps we should somehow tell the `leave_cmd` that it's + # being called not because the issue is resolved, + # but because this program is restarting or is shutting down. + self.report.report( + "Leaving %s MODE" % self.trigger_name.upper(), + "Leaving %s MODE because of shutting down or restarting." + % self.trigger_name.upper(), + ) + for name in self._alerting_temps: + self._alert_cmd(self.temp_commands[name].leave_cmd) + self._alert_cmd(self.global_commands.leave_cmd) + + self._alerting_temps.clear() + return None + + @property + def is_alerting(self) -> bool: + return bool(self._alerting_temps) + + def check(self, temps: Mapping[TempName, Optional[TempStatus]]) -> None: + was_alerting = self.is_alerting + self._update_alerting_temps(temps) + self._process_global_alerting_commands(temps, was_alerting, self.is_alerting) + + def _update_alerting_temps( + self, temps: Mapping[TempName, Optional[TempStatus]] + ) -> None: + stopped_alerting_temps = self._alerting_temps.copy() + for name, status in temps.items(): + temp_alerting_reason = self._temp_alerting_reason(status) + if not temp_alerting_reason: + continue + if name in self._alerting_temps: + # Still alerting + stopped_alerting_temps.discard(name) + continue + + # Just started alerting + self._alerting_temps.add(name) + logger.warning( + "%s started on temp. name: %s, status: %s, reason: %s", + self.trigger_name.upper(), + name, + status, + temp_alerting_reason, + ) + self._alert_cmd(self.temp_commands[name].enter_cmd) + + for name in stopped_alerting_temps: + self._alerting_temps.discard(name) + status = temps[name] + + logger.warning( + "%s ended on temp: name: %s, status: %s", + self.trigger_name.upper(), + name, + status, + ) + self._alert_cmd(self.temp_commands[name].leave_cmd) + + def _process_global_alerting_commands( + self, + temps: Mapping[TempName, Optional[TempStatus]], + was_alerting: bool, + is_alerting: bool, + ) -> None: + is_entered = not was_alerting and is_alerting + is_left = was_alerting and not is_alerting + if is_entered or is_left: + temps_debug = "\n".join( + "[%s]: %s" % (name, status) + for name, status in sorted(temps.items(), key=lambda kv: kv[0]) + ) + if is_entered: + self.report.report( + "Entered %s MODE" % self.trigger_name.upper(), + "Entered %s MODE. Take a look as soon as possible!!!\nSensors:\n%s" + % (self.trigger_name.upper(), temps_debug), + ) + self._alert_cmd(self.global_commands.enter_cmd) + if is_left: + self.report.report( + "Leaving %s MODE" % self.trigger_name.upper(), + "Leaving %s MODE.\nSensors:\n%s" + % (self.trigger_name.upper(), temps_debug), + ) + self._alert_cmd(self.global_commands.leave_cmd) + + @abc.abstractmethod + def _temp_alerting_reason(self, temp: Optional[TempStatus]) -> Optional[str]: + pass + + def _alert_cmd(self, shell_cmd): + if not shell_cmd: + return + try: + exec_shell_command(shell_cmd) + except Exception as e: + logger.warning( + "Enable to execute %s trigger command %s:\n%s", + self.trigger_name, + shell_cmd, + e, + ) + + +class PanicTrigger(Trigger): + trigger_name = "panic" + + def _temp_alerting_reason(self, temp: Optional[TempStatus]) -> Optional[str]: + if temp is None: + return "Sensor failed" + if not temp.is_panic: + return None + return "Panic temp reached" + + +class ThresholdTrigger(Trigger): + trigger_name = "threshold" + + def _temp_alerting_reason(self, temp: Optional[TempStatus]) -> Optional[str]: + if temp is None: + return None + if not temp.is_threshold: + return None + return "Threshold temp reached" + + +class Triggers: + def __init__(self, triggers_config: TriggerConfig, report: Report) -> None: + self.panic_trigger = PanicTrigger( + global_commands=triggers_config.global_commands.panic, + temp_commands={ + temp_name: actions.panic + for temp_name, actions in triggers_config.temp_commands.items() + }, + report=report, + ) + self.threshold_trigger = ThresholdTrigger( + global_commands=triggers_config.global_commands.threshold, + temp_commands={ + temp_name: actions.threshold + for temp_name, actions in triggers_config.temp_commands.items() + }, + report=report, + ) + self._stack: Optional[ExitStack] = None + + def __enter__(self): # reusable + self._stack = ExitStack() + try: + self._stack.enter_context(self.panic_trigger) + self._stack.enter_context(self.threshold_trigger) + except Exception: + self._stack.close() + raise + return self + + def __exit__(self, exc_type, exc_value, exc_tb): + assert self._stack is not None + self._stack.close() + return None + + @property + def is_alerting(self) -> bool: + return self.panic_trigger.is_alerting or self.threshold_trigger.is_alerting + + def check(self, temps: Mapping[TempName, Optional[TempStatus]]) -> None: + self.panic_trigger.check(temps) + self.threshold_trigger.check(temps) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..a672fa9 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,38 @@ +import tempfile +from contextlib import contextmanager +from pathlib import Path +from unittest.mock import patch + +import pytest + +from afancontrol.exec import exec_shell_command + + +@pytest.fixture +def temp_path(): + with tempfile.TemporaryDirectory() as tmpdirname: + yield Path(tmpdirname).resolve() + + +@pytest.fixture +def sense_exec_shell_command(): + exec_shell_command_stdout = [] + + def sensed_exec_shell_command(*args, **kwargs): + exec_shell_command_stdout.append(exec_shell_command(*args, **kwargs)) + return exec_shell_command_stdout[-1] + + def get_stdout(): + try: + return exec_shell_command_stdout[:] + finally: + exec_shell_command_stdout.clear() + + @contextmanager + def _sense_exec_shell_command(module): + with patch.object( + module, "exec_shell_command", wraps=sensed_exec_shell_command + ) as mock_exec_shell_command: + yield mock_exec_shell_command, get_stdout + + return _sense_exec_shell_command diff --git a/tests/data/afancontrol-example.conf b/tests/data/afancontrol-example.conf new file mode 100644 index 0000000..b51141f --- /dev/null +++ b/tests/data/afancontrol-example.conf @@ -0,0 +1,53 @@ +[daemon] +pidfile = /run/afancontrol.pid +logfile = /var/log/afancontrol.log +interval = 5 +exporter_listen_host = 127.0.0.1:8083 + +[actions] + +[temp:mobo] +type = file +path = /sys/class/hwmon/hwmon0/device/temp1_input +min = 30 +max = 40 + +[temp: hdds] +type = hdd +path = /dev/sd? +min = 35 +max = 48 +panic = 55 + +[fan: hdd] +pwm = /sys/class/hwmon/hwmon0/device/pwm2 +fan_input = /sys/class/hwmon/hwmon0/device/fan2_input +pwm_line_start = 100 +pwm_line_end = 240 +never_stop = no + +[fan:cpu] +pwm = /sys/class/hwmon/hwmon0/device/pwm1 +fan_input = /sys/class/hwmon/hwmon0/device/fan1_input +pwm_line_start = 100 +pwm_line_end = 240 +never_stop = yes + +[arduino: mymicro] +serial_url = /dev/ttyACM0 +baudrate = 115200 +status_ttl = 5 + +[fan: my_arduino_fan] +type = arduino +arduino_name = mymicro +pwm_pin = 9 +tacho_pin = 3 + +[mapping:1] +fans = cpu, hdd*0.6, my_arduino_fan * 0.222 +temps = mobo, hdds + +[mapping:2] +fans = hdd +temps = hdds diff --git a/tests/pwmfan/__init__.py b/tests/pwmfan/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/pwmfan/test_arduino.py b/tests/pwmfan/test_arduino.py new file mode 100644 index 0000000..199034d --- /dev/null +++ b/tests/pwmfan/test_arduino.py @@ -0,0 +1,177 @@ +import json +import socket +import threading +import traceback +from contextlib import ExitStack +from time import sleep +from typing import Dict + +import pytest + +from afancontrol.arduino import ( + ArduinoConnection, + ArduinoName, + ArduinoPin, + SetPWMCommand, + pyserial_available, +) +from afancontrol.pwmfan import ( + ArduinoFanPWMRead, + ArduinoFanPWMWrite, + ArduinoFanSpeed, + PWMValue, +) + +pytestmark = pytest.mark.skipif( + not pyserial_available, reason="pyserial is not installed" +) + + +class DummyArduino: + """Emulate an Arduino board, i.e. the other side of the pyserial connection. + + Slightly mimics the Arduino program `micro.ino`. + """ + + def __init__(self) -> None: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.bind(("127.0.0.1", 0)) + s.listen(1) + listening_port = s.getsockname()[1] + self.sock = s + self.pyserial_url = "socket://127.0.0.1:%s" % listening_port + self._lock = threading.Lock() + self._loop_iteration_complete = threading.Event() + self._first_loop_iteration_complete = threading.Event() + self._disconnected = threading.Event() + self._thread_error = threading.Event() + self._is_connected = False + self._inner_state_pwms = {"5": 255, "9": 255, "10": 255, "11": 255} + self._inner_state_speeds = {"0": 0, "1": 0, "2": 0, "3": 0, "7": 0} + + def set_inner_state_pwms(self, pwms: Dict[str, int]) -> None: + with self._lock: + self._inner_state_pwms.update(pwms) + if self.is_connected: + self._loop_iteration_complete.clear() + assert self._loop_iteration_complete.wait(5) is True + + def set_speeds(self, speeds: Dict[str, int]) -> None: + with self._lock: + self._inner_state_speeds.update(speeds) + if self.is_connected: + self._loop_iteration_complete.clear() + assert self._loop_iteration_complete.wait(5) is True + + @property + def inner_state_pwms(self): + with self._lock: + copy = self._inner_state_pwms.copy() + return copy + + @property + def is_connected(self): + with self._lock: + if not self._is_connected: + return False + assert self._first_loop_iteration_complete.wait(5) is True + return True + + def wait_for_disconnected(self): + assert self._disconnected.wait(5) is True + + def accept(self): + client, _ = self.sock.accept() + self.sock.close() # Don't accept any more connections + with self._lock: + self._is_connected = True + threading.Thread(target=self._thread_run, args=(client,), daemon=True).start() + + def _thread_run(self, sock): + sock.settimeout(0.001) + command_buffer = bytearray() + try: + while True: + # The code in this loop mimics the `loop` function + # in the `micro.ino` program. + + try: + command_buffer.extend(sock.recv(1024)) + except socket.timeout: + pass + + while len(command_buffer) >= 3: + command_raw = command_buffer[:3] + del command_buffer[:3] + command = SetPWMCommand.parse(command_raw) + with self._lock: + self._inner_state_pwms[str(command.pwm_pin)] = command.pwm + + sock.sendall(self._make_status()) + + self._loop_iteration_complete.set() + self._first_loop_iteration_complete.set() + + sleep(0.050) + except (ConnectionResetError, BrokenPipeError): + pass + except Exception: + traceback.print_exc() + self._thread_error.set() + finally: + with self._lock: + self._is_connected = False + sock.close() + self._disconnected.set() + + def _make_status(self): + with self._lock: + status = { + "fan_inputs": self._inner_state_speeds, + "fan_pwm": self._inner_state_pwms, + } + return (json.dumps(status) + "\n").encode("ascii") + + def ensure_no_errors_in_thread(self): + assert self._thread_error.is_set() is not True + + +@pytest.fixture +def dummy_arduino(): + return DummyArduino() + + +def test_smoke(dummy_arduino): + conn = ArduinoConnection(ArduinoName("test"), dummy_arduino.pyserial_url) + + fan_speed = ArduinoFanSpeed(conn, tacho_pin=ArduinoPin(3)) + pwm_read = ArduinoFanPWMRead(conn, pwm_pin=ArduinoPin(9)) + pwm_write = ArduinoFanPWMWrite(conn, pwm_pin=ArduinoPin(9)) + + dummy_arduino.set_inner_state_pwms({"9": 42}) + + with ExitStack() as stack: + assert not dummy_arduino.is_connected + stack.enter_context(fan_speed) + stack.enter_context(pwm_read) + stack.enter_context(pwm_write) + dummy_arduino.accept() + assert dummy_arduino.is_connected + + dummy_arduino.set_speeds({"3": 1200}) + conn.wait_for_status() # required only for synchronization in the tests + assert fan_speed.get_speed() == 1200 + assert pwm_read.get() == 255 + assert dummy_arduino.inner_state_pwms["9"] == 255 + + pwm_write.set(PWMValue(192)) + dummy_arduino.set_speeds({"3": 998}) + conn.wait_for_status() # required only for synchronization in the tests + assert fan_speed.get_speed() == 998 + assert pwm_read.get() == 192 + assert dummy_arduino.inner_state_pwms["9"] == 192 + + dummy_arduino.wait_for_disconnected() + assert dummy_arduino.inner_state_pwms["9"] == 255 + assert not dummy_arduino.is_connected + dummy_arduino.ensure_no_errors_in_thread() diff --git a/tests/pwmfan/test_ipmi.py b/tests/pwmfan/test_ipmi.py new file mode 100644 index 0000000..f441f6f --- /dev/null +++ b/tests/pwmfan/test_ipmi.py @@ -0,0 +1,41 @@ +from unittest.mock import patch + +import pytest + +from afancontrol.pwmfan import FanValue, FreeIPMIFanSpeed + + +@pytest.fixture +def ipmi_sensors_output(): + return """ +ID,Name,Type,Reading,Units,Event +17,FAN1,Fan,1400.00,RPM,'OK' +18,FAN2,Fan,1800.00,RPM,'OK' +19,FAN3,Fan,N/A,RPM,N/A +20,FAN4,Fan,N/A,RPM,N/A +21,FAN5,Fan,N/A,RPM,N/A +22,FAN6,Fan,N/A,RPM,N/A +""".lstrip() + + +def test_fan_speed(ipmi_sensors_output): + fan_speed = FreeIPMIFanSpeed("FAN2") + with patch.object(FreeIPMIFanSpeed, "_call_ipmi_sensors") as mock_call: + mock_call.return_value = ipmi_sensors_output + assert fan_speed.get_speed() == FanValue(1800) + + +def test_fan_speed_na(ipmi_sensors_output): + fan_speed = FreeIPMIFanSpeed("FAN3") + with patch.object(FreeIPMIFanSpeed, "_call_ipmi_sensors") as mock_call: + mock_call.return_value = ipmi_sensors_output + with pytest.raises(ValueError): + fan_speed.get_speed() + + +def test_fan_speed_unknown(ipmi_sensors_output): + fan_speed = FreeIPMIFanSpeed("FAN30") + with patch.object(FreeIPMIFanSpeed, "_call_ipmi_sensors") as mock_call: + mock_call.return_value = ipmi_sensors_output + with pytest.raises(RuntimeError): + fan_speed.get_speed() diff --git a/tests/pwmfan/test_linux.py b/tests/pwmfan/test_linux.py new file mode 100644 index 0000000..24788f5 --- /dev/null +++ b/tests/pwmfan/test_linux.py @@ -0,0 +1,153 @@ +from contextlib import ExitStack +from unittest.mock import MagicMock + +import pytest + +from afancontrol.pwmfan import ( + FanInputDevice, + LinuxFanPWMRead, + LinuxFanPWMWrite, + LinuxFanSpeed, + PWMDevice, + PWMValue, +) +from afancontrol.pwmfannorm import PWMFanNorm + + +@pytest.fixture +def pwm_path(temp_path): + # pwm = /sys/class/hwmon/hwmon0/pwm2 + pwm_path = temp_path / "pwm2" + pwm_path.write_text("0\n") + return pwm_path + + +@pytest.fixture +def pwm_enable_path(temp_path): + pwm_enable_path = temp_path / "pwm2_enable" + pwm_enable_path.write_text("0\n") + return pwm_enable_path + + +@pytest.fixture +def fan_input_path(temp_path): + # fan_input = /sys/class/hwmon/hwmon0/fan2_input + fan_input_path = temp_path / "fan2_input" + fan_input_path.write_text("1300\n") + return fan_input_path + + +@pytest.fixture +def fan_speed(fan_input_path): + return LinuxFanSpeed(fan_input=FanInputDevice(str(fan_input_path))) + + +@pytest.fixture +def pwm_read(pwm_path): + return LinuxFanPWMRead(pwm=PWMDevice(str(pwm_path))) + + +@pytest.fixture +def pwm_write(pwm_path): + pwm_write = LinuxFanPWMWrite(pwm=PWMDevice(str(pwm_path))) + + # We write to the pwm_enable file values without newlines, + # but when they're read back, they might contain newlines. + # This hack below is to simulate just that: the written values should + # contain newlines. + original_pwm_enable = pwm_write._pwm_enable + pwm_enable = MagicMock(wraps=original_pwm_enable) + pwm_enable.write_text = lambda text: original_pwm_enable.write_text(text + "\n") + pwm_write._pwm_enable = pwm_enable + + return pwm_write + + +@pytest.fixture +def pwmfan_norm(fan_speed, pwm_read, pwm_write): + return PWMFanNorm( + fan_speed, + pwm_read, + pwm_write, + pwm_line_start=PWMValue(100), + pwm_line_end=PWMValue(240), + never_stop=False, + ) + + +@pytest.mark.parametrize("pwmfan_fixture", ["fan_speed", "pwmfan_norm"]) +def test_get_speed(pwmfan_fixture, fan_speed, pwmfan_norm, fan_input_path): + fan = locals()[pwmfan_fixture] + fan_input_path.write_text("721\n") + assert 721 == fan.get_speed() + + +@pytest.mark.parametrize("pwmfan_fixture", ["pwm_write", "pwmfan_norm"]) +@pytest.mark.parametrize("raises", [True, False]) +def test_enter_exit( + raises, pwmfan_fixture, pwm_write, pwmfan_norm, pwm_enable_path, pwm_path +): + fan = locals()[pwmfan_fixture] + + class Exc(Exception): + pass + + with ExitStack() as stack: + if raises: + stack.enter_context(pytest.raises(Exc)) + stack.enter_context(fan) + + assert "1" == pwm_enable_path.read_text().strip() + assert "255" == pwm_path.read_text() + + value = dict(pwm_write=100, pwmfan_norm=0.39)[pwmfan_fixture] # 100/255 ~= 0.39 + fan.set(value) + + assert "1" == pwm_enable_path.read_text().strip() + assert "100" == pwm_path.read_text() + + if raises: + raise Exc() + + assert "0" == pwm_enable_path.read_text().strip() + assert "100" == pwm_path.read_text() # `fancontrol` doesn't reset speed + + +def test_get_set_pwmfan(pwm_read, pwm_write, pwm_path): + pwm_write.set(142) + assert "142" == pwm_path.read_text() + + pwm_path.write_text("132\n") + assert 132 == pwm_read.get() + + pwm_write.set_full_speed() + assert "255" == pwm_path.read_text() + + with pytest.raises(ValueError): + pwm_write.set(256) + + with pytest.raises(ValueError): + pwm_write.set(-1) + + +def test_get_set_pwmfan_norm(pwmfan_norm, pwm_path): + pwmfan_norm.set(0.42) + assert "101" == pwm_path.read_text() + + pwm_path.write_text("132\n") + assert pytest.approx(0.517, 0.01) == pwmfan_norm.get() + + pwmfan_norm.set_full_speed() + assert "255" == pwm_path.read_text() + + assert 238 == pwmfan_norm.set(0.99) + assert "238" == pwm_path.read_text() + + assert 255 == pwmfan_norm.set(1.0) + assert "255" == pwm_path.read_text() + + assert 255 == pwmfan_norm.set(1.1) + assert "255" == pwm_path.read_text() + + assert 0 == pwmfan_norm.set(-0.1) + assert "0" == pwm_path.read_text() diff --git a/tests/temp/__init__.py b/tests/temp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/temp/test_base.py b/tests/temp/test_base.py new file mode 100644 index 0000000..a260778 --- /dev/null +++ b/tests/temp/test_base.py @@ -0,0 +1,46 @@ +from typing import Optional +from unittest.mock import patch + +import pytest + +from afancontrol.temp import Temp, TempCelsius, TempStatus + + +class DummyTemp(Temp): + def _get_temp(self): + pass + + +@pytest.mark.parametrize( + "temp, threshold, panic, is_threshold, is_panic", + [ + (34.0, None, 60.0, False, False), + (42.0, None, 60.0, False, False), + (57.0, 55.0, 60.0, True, False), + (61.0, 55.0, 61.0, True, True), + (61.0, None, 61.0, False, True), + ], +) +def test_temp( + temp: TempCelsius, + threshold: Optional[TempCelsius], + panic: TempCelsius, + is_threshold, + is_panic, +): + min = TempCelsius(40.0) + max = TempCelsius(50.0) + + with patch.object(DummyTemp, "_get_temp") as mock_get_temp: + t = DummyTemp(panic=panic, threshold=threshold) + mock_get_temp.return_value = [temp, min, max] + + assert t.get() == TempStatus( + temp=temp, + min=min, + max=max, + panic=panic, + threshold=threshold, + is_panic=is_panic, + is_threshold=is_threshold, + ) diff --git a/tests/temp/test_command.py b/tests/temp/test_command.py new file mode 100644 index 0000000..000456c --- /dev/null +++ b/tests/temp/test_command.py @@ -0,0 +1,40 @@ +from afancontrol.temp import CommandTemp, TempCelsius, TempStatus + + +def test_command_temp_with_minmax(): + t = CommandTemp( + shell_command=r"printf '%s\n' 35 30 40", + min=TempCelsius(31.0), + max=TempCelsius(39.0), + panic=TempCelsius(50.0), + threshold=None, + ) + assert t.get() == TempStatus( + temp=TempCelsius(35.0), + min=TempCelsius(31.0), + max=TempCelsius(39.0), + panic=TempCelsius(50.0), + threshold=None, + is_panic=False, + is_threshold=False, + ) + print(repr(t)) + + +def test_command_temp_without_minmax(): + t = CommandTemp( + shell_command=r"printf '%s\n' 35 30 40", + min=None, + max=None, + panic=TempCelsius(50.0), + threshold=None, + ) + assert t.get() == TempStatus( + temp=TempCelsius(35.0), + min=TempCelsius(30.0), + max=TempCelsius(40.0), + panic=TempCelsius(50.0), + threshold=None, + is_panic=False, + is_threshold=False, + ) diff --git a/tests/temp/test_file.py b/tests/temp/test_file.py new file mode 100644 index 0000000..c4dd7ff --- /dev/null +++ b/tests/temp/test_file.py @@ -0,0 +1,88 @@ +import pytest + +from afancontrol.temp import FileTemp, TempCelsius, TempStatus + + +@pytest.fixture +def file_temp_path(temp_path): + # /sys/class/hwmon/hwmon0/temp1_input + temp_input_path = temp_path / "temp1_input" + temp_input_path.write_text("34000\n") + + temp_max_path = temp_path / "temp1_max" + temp_max_path.write_text("127000\n") + + temp_min_path = temp_path / "temp1_min" + # My mobo actually returns this as min: + temp_min_path.write_text("127000\n") + + return temp_input_path + + +def test_file_temp_min_max_numbers(file_temp_path): + temp = FileTemp( + temp_path=str(file_temp_path), + min=TempCelsius(40.0), + max=TempCelsius(50.0), + panic=TempCelsius(60.0), + threshold=None, + ) + assert temp.get() == TempStatus( + temp=TempCelsius(34.0), + min=TempCelsius(40.0), + max=TempCelsius(50.0), + panic=TempCelsius(60.0), + threshold=None, + is_panic=False, + is_threshold=False, + ) + print(repr(temp)) + + +def test_file_temp_glob(file_temp_path): + temp = FileTemp( + temp_path=str(file_temp_path).replace("/temp1", "/temp?"), + min=TempCelsius(40.0), + max=None, + panic=None, + threshold=None, + ) + assert temp.get() == TempStatus( + temp=TempCelsius(34.0), + min=TempCelsius(40.0), + max=TempCelsius(127.0), + panic=None, + threshold=None, + is_panic=False, + is_threshold=False, + ) + print(repr(temp)) + + +def test_file_temp_min_max_files(temp_path, file_temp_path): + with pytest.raises(RuntimeError): + # min == max is an error + FileTemp( + temp_path=str(file_temp_path), + min=None, + max=None, + panic=TempCelsius(60.0), + threshold=None, + ).get() + + temp = FileTemp( + temp_path=str(file_temp_path), + min=TempCelsius(50.0), + max=None, + panic=TempCelsius(60.0), + threshold=None, + ) + assert temp.get() == TempStatus( + temp=TempCelsius(34.0), + min=TempCelsius(50.0), + max=TempCelsius(127.0), + panic=TempCelsius(60.0), + threshold=None, + is_panic=False, + is_threshold=False, + ) diff --git a/tests/temp/test_hdd.py b/tests/temp/test_hdd.py new file mode 100644 index 0000000..c2fc0e4 --- /dev/null +++ b/tests/temp/test_hdd.py @@ -0,0 +1,95 @@ +import subprocess +from unittest.mock import patch + +import pytest + +from afancontrol.temp import HDDTemp, TempCelsius, TempStatus + + +@pytest.fixture +def hddtemp_output_many(): + return ( + "/dev/sda: Adaptec XXXXX: drive supported," + " but it doesn't have a temperature sensor.\n" + "/dev/sdb: Adaptec XXXXX: drive supported," + " but it doesn't have a temperature sensor.\n" + "38\n" + "39\n" + "30\n" + "36\n" + ) + + +@pytest.fixture +def hddtemp_output_bad(): + return ( + "/dev/sda: Adaptec XXXXX: drive supported," + " but it doesn't have a temperature sensor.\n" + ) + + +def test_hddtemp_many(hddtemp_output_many): + with patch.object(HDDTemp, "_call_hddtemp") as mock_call_hddtemp: + mock_call_hddtemp.return_value = hddtemp_output_many + t = HDDTemp( + disk_path="/dev/sd?", + min=TempCelsius(38.0), + max=TempCelsius(45.0), + panic=TempCelsius(50.0), + threshold=None, + hddtemp_bin="testbin", + ) + + assert t.get() == TempStatus( + temp=TempCelsius(39.0), + min=TempCelsius(38.0), + max=TempCelsius(45.0), + panic=TempCelsius(50.0), + threshold=None, + is_panic=False, + is_threshold=False, + ) + print(repr(t)) + + +def test_hddtemp_bad(hddtemp_output_bad): + with patch.object(HDDTemp, "_call_hddtemp") as mock_call_hddtemp: + mock_call_hddtemp.return_value = hddtemp_output_bad + t = HDDTemp( + disk_path="/dev/sda", + min=TempCelsius(38.0), + max=TempCelsius(45.0), + panic=TempCelsius(50.0), + threshold=None, + hddtemp_bin="testbin", + ) + with pytest.raises(RuntimeError): + t.get() + + +def test_hddtemp_exec_successful(temp_path): + (temp_path / "sda").write_text("") + (temp_path / "sdz").write_text("") + t = HDDTemp( + disk_path=str(temp_path / "sd") + "?", + min=TempCelsius(38.0), + max=TempCelsius(45.0), + panic=TempCelsius(50.0), + threshold=None, + hddtemp_bin="printf '@%s'", + ) + expected_out = "@-n@-u@C@--@{0}/sda@{0}/sdz".format(temp_path) + assert expected_out == t._call_hddtemp() + + +def test_hddtemp_exec_failed(): + t = HDDTemp( + disk_path="/dev/sd?", + min=TempCelsius(38.0), + max=TempCelsius(45.0), + panic=TempCelsius(50.0), + threshold=None, + hddtemp_bin="false", + ) + with pytest.raises(subprocess.CalledProcessError): + t._call_hddtemp() diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..c663fbd --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,498 @@ +from pathlib import Path +from unittest.mock import Mock + +import pytest + +from afancontrol.arduino import ( + ArduinoConnection, + ArduinoName, + ArduinoPin, + pyserial_available, +) +from afancontrol.config import ( + Actions, + AlertCommands, + DaemonCLIConfig, + DaemonConfig, + FanName, + FanSpeedModifier, + FansTempsRelation, + FilteredTemp, + MappingName, + ParsedConfig, + ReadonlyFanName, + TempName, + TriggerConfig, + parse_config, +) +from afancontrol.filters import MovingMedianFilter, NullFilter +from afancontrol.pwmfan import ( + ArduinoFanPWMRead, + ArduinoFanPWMWrite, + ArduinoFanSpeed, + FanInputDevice, + LinuxFanPWMRead, + LinuxFanPWMWrite, + LinuxFanSpeed, + PWMDevice, + PWMValue, +) +from afancontrol.pwmfannorm import PWMFanNorm, ReadonlyPWMFanNorm +from afancontrol.temp import FileTemp, HDDTemp, TempCelsius + + +@pytest.fixture +def pkg_conf(): + return Path(__file__).parents[1] / "pkg" / "afancontrol.conf" + + +@pytest.fixture +def example_conf(): + return Path(__file__).parents[0] / "data" / "afancontrol-example.conf" + + +def path_from_str(contents: str) -> Path: + p = Mock(spec=Path) + p.read_text.return_value = contents + return p + + +@pytest.mark.skipif(not pyserial_available, reason="pyserial is not installed") +def test_pkg_conf(pkg_conf: Path): + daemon_cli_config = DaemonCLIConfig( + pidfile=None, logfile=None, exporter_listen_host=None + ) + + parsed = parse_config(pkg_conf, daemon_cli_config) + assert parsed == ParsedConfig( + arduino_connections={}, + daemon=DaemonConfig( + pidfile="/run/afancontrol.pid", + logfile="/var/log/afancontrol.log", + interval=5, + exporter_listen_host=None, + ), + report_cmd=( + 'printf "Subject: %s\nTo: %s\n\n%b" ' + '"afancontrol daemon report: %REASON%" root "%MESSAGE%" | sendmail -t' + ), + triggers=TriggerConfig( + global_commands=Actions( + panic=AlertCommands(enter_cmd=None, leave_cmd=None), + threshold=AlertCommands(enter_cmd=None, leave_cmd=None), + ), + temp_commands={ + TempName("mobo"): Actions( + panic=AlertCommands(enter_cmd=None, leave_cmd=None), + threshold=AlertCommands(enter_cmd=None, leave_cmd=None), + ) + }, + ), + fans={ + FanName("hdd"): PWMFanNorm( + fan_speed=LinuxFanSpeed( + FanInputDevice("/sys/class/hwmon/hwmon0/device/fan2_input") + ), + pwm_read=LinuxFanPWMRead( + PWMDevice("/sys/class/hwmon/hwmon0/device/pwm2") + ), + pwm_write=LinuxFanPWMWrite( + PWMDevice("/sys/class/hwmon/hwmon0/device/pwm2") + ), + pwm_line_start=PWMValue(100), + pwm_line_end=PWMValue(240), + never_stop=False, + ) + }, + readonly_fans={ + ReadonlyFanName("cpu"): ReadonlyPWMFanNorm( + fan_speed=LinuxFanSpeed( + FanInputDevice("/sys/class/hwmon/hwmon0/device/fan1_input") + ), + ), + }, + temps={ + TempName("mobo"): FilteredTemp( + temp=FileTemp( + "/sys/class/hwmon/hwmon0/device/temp1_input", + min=TempCelsius(30.0), + max=TempCelsius(40.0), + panic=None, + threshold=None, + ), + filter=MovingMedianFilter(window_size=3), + ) + }, + mappings={ + MappingName("1"): FansTempsRelation( + temps=[TempName("mobo")], + fans=[FanSpeedModifier(fan=FanName("hdd"), modifier=0.6)], + ) + }, + ) + + +@pytest.mark.skipif(not pyserial_available, reason="pyserial is not installed") +def test_example_conf(example_conf: Path): + daemon_cli_config = DaemonCLIConfig( + pidfile=None, logfile=None, exporter_listen_host=None + ) + + parsed = parse_config(example_conf, daemon_cli_config) + assert parsed == ParsedConfig( + arduino_connections={ + ArduinoName("mymicro"): ArduinoConnection( + ArduinoName("mymicro"), "/dev/ttyACM0", baudrate=115200, status_ttl=5 + ) + }, + daemon=DaemonConfig( + pidfile="/run/afancontrol.pid", + logfile="/var/log/afancontrol.log", + exporter_listen_host="127.0.0.1:8083", + interval=5, + ), + report_cmd=( + 'printf "Subject: %s\nTo: %s\n\n%b" ' + '"afancontrol daemon report: %REASON%" root "%MESSAGE%" | sendmail -t' + ), + triggers=TriggerConfig( + global_commands=Actions( + panic=AlertCommands(enter_cmd=None, leave_cmd=None), + threshold=AlertCommands(enter_cmd=None, leave_cmd=None), + ), + temp_commands={ + TempName("hdds"): Actions( + panic=AlertCommands(enter_cmd=None, leave_cmd=None), + threshold=AlertCommands(enter_cmd=None, leave_cmd=None), + ), + TempName("mobo"): Actions( + panic=AlertCommands(enter_cmd=None, leave_cmd=None), + threshold=AlertCommands(enter_cmd=None, leave_cmd=None), + ), + }, + ), + fans={ + FanName("cpu"): PWMFanNorm( + fan_speed=LinuxFanSpeed( + FanInputDevice("/sys/class/hwmon/hwmon0/device/fan1_input") + ), + pwm_read=LinuxFanPWMRead( + PWMDevice("/sys/class/hwmon/hwmon0/device/pwm1") + ), + pwm_write=LinuxFanPWMWrite( + PWMDevice("/sys/class/hwmon/hwmon0/device/pwm1") + ), + pwm_line_start=PWMValue(100), + pwm_line_end=PWMValue(240), + never_stop=True, + ), + FanName("hdd"): PWMFanNorm( + fan_speed=LinuxFanSpeed( + FanInputDevice("/sys/class/hwmon/hwmon0/device/fan2_input") + ), + pwm_read=LinuxFanPWMRead( + PWMDevice("/sys/class/hwmon/hwmon0/device/pwm2") + ), + pwm_write=LinuxFanPWMWrite( + PWMDevice("/sys/class/hwmon/hwmon0/device/pwm2") + ), + pwm_line_start=PWMValue(100), + pwm_line_end=PWMValue(240), + never_stop=False, + ), + FanName("my_arduino_fan"): PWMFanNorm( + fan_speed=ArduinoFanSpeed( + ArduinoConnection( + ArduinoName("mymicro"), + "/dev/ttyACM0", # linux + # "/dev/cu.usbmodem14201", # macos + baudrate=115200, + status_ttl=5, + ), + tacho_pin=ArduinoPin(3), + ), + pwm_read=ArduinoFanPWMRead( + ArduinoConnection( + ArduinoName("mymicro"), + "/dev/ttyACM0", # linux + # "/dev/cu.usbmodem14201", # macos + baudrate=115200, + status_ttl=5, + ), + pwm_pin=ArduinoPin(9), + ), + pwm_write=ArduinoFanPWMWrite( + ArduinoConnection( + ArduinoName("mymicro"), + "/dev/ttyACM0", # linux + # "/dev/cu.usbmodem14201", # macos + baudrate=115200, + status_ttl=5, + ), + pwm_pin=ArduinoPin(9), + ), + pwm_line_start=PWMValue(100), + pwm_line_end=PWMValue(240), + never_stop=True, + ), + }, + readonly_fans={}, + temps={ + TempName("hdds"): FilteredTemp( + temp=HDDTemp( + "/dev/sd?", + min=TempCelsius(35.0), + max=TempCelsius(48.0), + panic=TempCelsius(55.0), + threshold=None, + hddtemp_bin="hddtemp", + ), + filter=NullFilter(), + ), + TempName("mobo"): FilteredTemp( + temp=FileTemp( + "/sys/class/hwmon/hwmon0/device/temp1_input", + min=TempCelsius(30.0), + max=TempCelsius(40.0), + panic=None, + threshold=None, + ), + filter=NullFilter(), + ), + }, + mappings={ + MappingName("1"): FansTempsRelation( + temps=[TempName("mobo"), TempName("hdds")], + fans=[ + FanSpeedModifier(fan=FanName("cpu"), modifier=1.0), + FanSpeedModifier(fan=FanName("hdd"), modifier=0.6), + FanSpeedModifier(fan=FanName("my_arduino_fan"), modifier=0.222), + ], + ), + MappingName("2"): FansTempsRelation( + temps=[TempName("hdds")], + fans=[FanSpeedModifier(fan=FanName("hdd"), modifier=1.0)], + ), + }, + ) + + +def test_minimal_config() -> None: + daemon_cli_config = DaemonCLIConfig( + pidfile=None, logfile=None, exporter_listen_host=None + ) + + config = """ +[daemon] + +[actions] + +[temp:mobo] +type = file +path = /sys/class/hwmon/hwmon0/device/temp1_input + +[fan: case] +pwm = /sys/class/hwmon/hwmon0/device/pwm2 +fan_input = /sys/class/hwmon/hwmon0/device/fan2_input + +[mapping:1] +fans = case*0.6, +temps = mobo +""" + parsed = parse_config(path_from_str(config), daemon_cli_config) + assert parsed == ParsedConfig( + arduino_connections={}, + daemon=DaemonConfig( + pidfile="/run/afancontrol.pid", + logfile=None, + exporter_listen_host=None, + interval=5, + ), + report_cmd=( + 'printf "Subject: %s\nTo: %s\n\n%b" ' + '"afancontrol daemon report: %REASON%" root "%MESSAGE%" | sendmail -t' + ), + triggers=TriggerConfig( + global_commands=Actions( + panic=AlertCommands(enter_cmd=None, leave_cmd=None), + threshold=AlertCommands(enter_cmd=None, leave_cmd=None), + ), + temp_commands={ + TempName("mobo"): Actions( + panic=AlertCommands(enter_cmd=None, leave_cmd=None), + threshold=AlertCommands(enter_cmd=None, leave_cmd=None), + ) + }, + ), + fans={ + FanName("case"): PWMFanNorm( + fan_speed=LinuxFanSpeed( + FanInputDevice("/sys/class/hwmon/hwmon0/device/fan2_input") + ), + pwm_read=LinuxFanPWMRead( + PWMDevice("/sys/class/hwmon/hwmon0/device/pwm2") + ), + pwm_write=LinuxFanPWMWrite( + PWMDevice("/sys/class/hwmon/hwmon0/device/pwm2") + ), + pwm_line_start=PWMValue(100), + pwm_line_end=PWMValue(240), + never_stop=True, + ) + }, + readonly_fans={}, + temps={ + TempName("mobo"): FilteredTemp( + temp=FileTemp( + "/sys/class/hwmon/hwmon0/device/temp1_input", + min=None, + max=None, + panic=None, + threshold=None, + ), + filter=NullFilter(), + ) + }, + mappings={ + MappingName("1"): FansTempsRelation( + temps=[TempName("mobo")], + fans=[FanSpeedModifier(fan=FanName("case"), modifier=0.6)], + ) + }, + ) + + +def test_readonly_config() -> None: + daemon_cli_config = DaemonCLIConfig( + pidfile=None, logfile=None, exporter_listen_host=None + ) + + config = """ +[daemon] + +[actions] + +[temp:mobo] +type = file +path = /sys/class/hwmon/hwmon0/device/temp1_input + +[readonly_fan: cpu] +pwm = /sys/class/hwmon/hwmon0/device/pwm1 +fan_input = /sys/class/hwmon/hwmon0/device/fan1_input +""" + parsed = parse_config(path_from_str(config), daemon_cli_config) + assert parsed == ParsedConfig( + arduino_connections={}, + daemon=DaemonConfig( + pidfile="/run/afancontrol.pid", + logfile=None, + exporter_listen_host=None, + interval=5, + ), + report_cmd=( + 'printf "Subject: %s\nTo: %s\n\n%b" ' + '"afancontrol daemon report: %REASON%" root "%MESSAGE%" | sendmail -t' + ), + triggers=TriggerConfig( + global_commands=Actions( + panic=AlertCommands(enter_cmd=None, leave_cmd=None), + threshold=AlertCommands(enter_cmd=None, leave_cmd=None), + ), + temp_commands={ + TempName("mobo"): Actions( + panic=AlertCommands(enter_cmd=None, leave_cmd=None), + threshold=AlertCommands(enter_cmd=None, leave_cmd=None), + ) + }, + ), + fans={}, + readonly_fans={ + ReadonlyFanName("cpu"): ReadonlyPWMFanNorm( + fan_speed=LinuxFanSpeed( + FanInputDevice("/sys/class/hwmon/hwmon0/device/fan1_input") + ), + pwm_read=LinuxFanPWMRead( + PWMDevice("/sys/class/hwmon/hwmon0/device/pwm1") + ), + ) + }, + temps={ + TempName("mobo"): FilteredTemp( + temp=FileTemp( + "/sys/class/hwmon/hwmon0/device/temp1_input", + min=None, + max=None, + panic=None, + threshold=None, + ), + filter=NullFilter(), + ) + }, + mappings={}, + ) + + +def test_multiline_mapping(): + daemon_cli_config = DaemonCLIConfig( + pidfile=None, logfile=None, exporter_listen_host=None + ) + + config = """ +[daemon] + +[actions] + +[temp:cpu] +type = file +path = /sys/class/hwmon/hwmon0/device/temp1_input + +[temp:mobo] +type = file +path = /sys/class/hwmon/hwmon0/device/temp2_input + +[fan: case] +pwm = /sys/class/hwmon/hwmon0/device/pwm2 +fan_input = /sys/class/hwmon/hwmon0/device/fan2_input + +[fan: hdd] +pwm = /sys/class/hwmon/hwmon0/device/pwm2 +fan_input = /sys/class/hwmon/hwmon0/device/fan2_input + +[mapping:1] +fans = + case*0.6, + hdd, +temps = + mobo, + cpu +""" + parsed = parse_config(path_from_str(config), daemon_cli_config) + assert parsed.mappings == { + MappingName("1"): FansTempsRelation( + temps=[TempName("mobo"), TempName("cpu")], + fans=[ + FanSpeedModifier(fan=FanName("case"), modifier=0.6), + FanSpeedModifier(fan=FanName("hdd"), modifier=1.0), + ], + ) + } + + +def test_extraneous_keys_raises(): + daemon_cli_config = DaemonCLIConfig( + pidfile=None, logfile=None, exporter_listen_host=None + ) + + config = """ +[daemon] + +[actions] + +[temp: mobo] +type = file +path = /sys/class/hwmon/hwmon0/device/temp1_input +aa = 55 +""" + with pytest.raises(RuntimeError) as cm: + parse_config(path_from_str(config), daemon_cli_config) + assert str(cm.value) == "Unknown options in the [temp: mobo] section: {'aa'}" diff --git a/tests/test_daemon.py b/tests/test_daemon.py new file mode 100644 index 0000000..4c14d8a --- /dev/null +++ b/tests/test_daemon.py @@ -0,0 +1,98 @@ +import threading +from contextlib import ExitStack +from unittest.mock import patch + +import pytest +from click.testing import CliRunner + +from afancontrol import daemon +from afancontrol.daemon import PidFile, Signals, daemon as main + + +def test_main_smoke(temp_path): + pwm_path = temp_path / "pwm" / "pwm2" + pwm_enable_path = temp_path / "pwm" / "pwm2_enable" + pwm_faninput_path = temp_path / "pwm" / "fan2_input" + pwm_path.parents[0].mkdir(parents=True) + pwm_path.write_text("100") + pwm_enable_path.write_text("0") + pwm_faninput_path.write_text("999") + + config_path = temp_path / "afancontrol.conf" + config_path.write_text( + """ +[daemon] +hddtemp = true + +[actions] + +[temp:mobo] +type = file +path = /fake/sys/class/hwmon/hwmon0/device/temp1_input + +[fan: case] +pwm = %(pwm_path)s +fan_input = %(pwm_faninput_path)s + +[mapping:1] +fans = case*0.6, +temps = mobo +""" + % dict(pwm_path=pwm_path, pwm_faninput_path=pwm_faninput_path) + ) + + with ExitStack() as stack: + mocked_tick = stack.enter_context(patch.object(daemon.Manager, "tick")) + stack.enter_context(patch.object(daemon, "signal")) + stack.enter_context( + patch.object(daemon.Signals, "wait_for_term_queued", return_value=True) + ) + + runner = CliRunner() + result = runner.invoke( + main, + [ + "--verbose", + "--config", + str(config_path), + "--pidfile", + str(temp_path / "afancontrol.pid"), + "--logfile", + str(temp_path / "afancontrol.log"), + ], + ) + + assert result.exit_code == 0 + assert mocked_tick.call_count == 1 + + +def test_pidfile_not_existing(temp_path): + pidpath = temp_path / "test.pid" + pidfile = PidFile(str(pidpath)) + + with pidfile: + pidfile.save_pid(42) + assert "42" == pidpath.read_text() + + assert not pidpath.exists() + + +def test_pidfile_existing_raises(temp_path): + pidpath = temp_path / "test.pid" + pidfile = PidFile(str(pidpath)) + pidpath.write_text("42") + + with pytest.raises(RuntimeError): + with pidfile: + pytest.fail("Should not be reached") + + assert pidpath.exists() + + +def test_signals(): + s = Signals() + + assert False is s.wait_for_term_queued(0.001) + + threading.Timer(0.01, lambda: s.sigterm(None, None)).start() + assert True is s.wait_for_term_queued(1e6) diff --git a/tests/test_exec.py b/tests/test_exec.py new file mode 100644 index 0000000..fa73e80 --- /dev/null +++ b/tests/test_exec.py @@ -0,0 +1,31 @@ +import subprocess + +import pytest + +from afancontrol.exec import exec_shell_command + + +def test_exec_shell_command_successful(): + assert "42\n" == exec_shell_command("echo 42") + + +def test_exec_shell_command_ignores_stderr(): + assert "42\n" == exec_shell_command("echo 111 >&2; echo 42") + + +def test_exec_shell_command_erroneous(): + with pytest.raises(subprocess.SubprocessError): + exec_shell_command("echo 42 && false") + + +def test_exec_shell_command_raises_for_unicode(): + with pytest.raises(ValueError): + exec_shell_command("echo привет") + + +def test_exec_shell_command_expands_glob(temp_path): + (temp_path / "sda").write_text("") + (temp_path / "sdb").write_text("") + + expected = "{0}/sda {0}/sdb\n".format(temp_path) + assert expected == exec_shell_command('echo "%s/sd"?' % temp_path) diff --git a/tests/test_fans.py b/tests/test_fans.py new file mode 100644 index 0000000..2a573e8 --- /dev/null +++ b/tests/test_fans.py @@ -0,0 +1,69 @@ +from collections import OrderedDict +from unittest.mock import MagicMock + +import pytest + +from afancontrol.config import FanName +from afancontrol.fans import Fans +from afancontrol.pwmfan import BaseFanPWMRead +from afancontrol.pwmfannorm import PWMFanNorm, PWMValueNorm +from afancontrol.report import Report + + +@pytest.fixture +def report(): + return MagicMock(spec=Report) + + +@pytest.mark.parametrize("is_fan_failing", [False, True]) +def test_smoke(report, is_fan_failing): + fan = MagicMock(spec=PWMFanNorm) + fans = Fans(fans={FanName("test"): fan}, readonly_fans={}, report=report) + + fan.set = lambda pwm_norm: int(255 * pwm_norm) + fan.get_speed.return_value = 0 if is_fan_failing else 942 + fan.is_pwm_stopped = BaseFanPWMRead.is_pwm_stopped + + with fans: + assert 1 == fan.__enter__.call_count + fans.check_speeds() + fans.set_all_to_full_speed() + fans.set_fan_speeds({FanName("test"): PWMValueNorm(0.42)}) + assert fan.get_speed.call_count == 1 + if is_fan_failing: + assert fans._failed_fans == {"test"} + assert fans._stopped_fans == set() + else: + assert fans._failed_fans == set() + assert fans._stopped_fans == set() + + assert 1 == fan.__exit__.call_count + + +def test_set_fan_speeds(report): + mocked_fans = OrderedDict( + [ + (FanName("test1"), MagicMock(spec=PWMFanNorm)), + (FanName("test2"), MagicMock(spec=PWMFanNorm)), + (FanName("test3"), MagicMock(spec=PWMFanNorm)), + (FanName("test4"), MagicMock(spec=PWMFanNorm)), + ] + ) + + for fan in mocked_fans.values(): + fan.set.return_value = 240 + fan.get_speed.return_value = 942 + fan.is_pwm_stopped = BaseFanPWMRead.is_pwm_stopped + + fans = Fans(fans=mocked_fans, readonly_fans={}, report=report) + with fans: + fans._ensure_fan_is_failing(FanName("test2"), Exception("test")) + fans.set_fan_speeds( + { + FanName("test1"): PWMValueNorm(0.42), + FanName("test2"): PWMValueNorm(0.42), + FanName("test3"): PWMValueNorm(0.42), + FanName("test4"): PWMValueNorm(0.42), + } + ) + assert [1, 0, 1, 1] == [f.set.call_count for f in mocked_fans.values()] diff --git a/tests/test_fantest.py b/tests/test_fantest.py new file mode 100644 index 0000000..f564e93 --- /dev/null +++ b/tests/test_fantest.py @@ -0,0 +1,105 @@ +from contextlib import ExitStack +from typing import Any, Type +from unittest.mock import MagicMock, patch + +import pytest +from click.testing import CliRunner + +from afancontrol import fantest +from afancontrol.fantest import ( + CSVMeasurementsOutput, + HumanMeasurementsOutput, + MeasurementsOutput, + fantest as main, + run_fantest, +) +from afancontrol.pwmfan import ( + BaseFanPWMRead, + BaseFanPWMWrite, + BaseFanSpeed, + FanInputDevice, + LinuxFanPWMRead, + LinuxFanPWMWrite, + LinuxFanSpeed, + PWMDevice, + PWMValue, + ReadWriteFan, +) + + +def test_main_smoke(temp_path): + pwm_path = temp_path / "pwm2" + pwm_path.write_text("") + fan_input_path = temp_path / "fan2_input" + fan_input_path.write_text("") + + with ExitStack() as stack: + mocked_fantest = stack.enter_context(patch.object(fantest, "run_fantest")) + + runner = CliRunner() + result = runner.invoke( + main, + [ + "--fan-type", + "linux", + "--linux-fan-pwm", + # "/sys/class/hwmon/hwmon0/device/pwm2", + str(pwm_path), # click verifies that this file exists + "--linux-fan-input", + # "/sys/class/hwmon/hwmon0/device/fan2_input", + str(fan_input_path), # click verifies that this file exists + "--output-format", + "human", + "--direction", + "increase", + "--pwm-step-size", + "accurate", + ], + ) + + print(result.output) + assert result.exit_code == 0 + + assert mocked_fantest.call_count == 1 + + args, kwargs = mocked_fantest.call_args + assert not args + assert kwargs.keys() == {"fan", "pwm_step_size", "output"} + assert kwargs["fan"] == ReadWriteFan( + fan_speed=LinuxFanSpeed(FanInputDevice(str(fan_input_path))), + pwm_read=LinuxFanPWMRead(PWMDevice(str(pwm_path))), + pwm_write=LinuxFanPWMWrite(PWMDevice(str(pwm_path))), + ) + assert kwargs["pwm_step_size"] == 5 + assert isinstance(kwargs["output"], HumanMeasurementsOutput) + + +@pytest.mark.parametrize("pwm_step_size", [5, -5]) +@pytest.mark.parametrize("output_cls", [HumanMeasurementsOutput, CSVMeasurementsOutput]) +def test_fantest(output_cls: Type[MeasurementsOutput], pwm_step_size: PWMValue): + fan: Any = ReadWriteFan( + fan_speed=MagicMock(spec=BaseFanSpeed), + pwm_read=MagicMock(spec=BaseFanPWMRead), + pwm_write=MagicMock(spec=BaseFanPWMWrite), + ) + fan.pwm_read.min_pwm = 0 + fan.pwm_read.max_pwm = 255 + output = output_cls() + + with ExitStack() as stack: + mocked_sleep = stack.enter_context(patch.object(fantest, "sleep")) + fan.fan_speed.get_speed.return_value = 999 + + run_fantest(fan=fan, pwm_step_size=pwm_step_size, output=output) + + assert fan.pwm_write.set.call_count == (255 // abs(pwm_step_size)) + 1 + assert fan.fan_speed.get_speed.call_count == (255 // abs(pwm_step_size)) + assert mocked_sleep.call_count == (255 // abs(pwm_step_size)) + 1 + + if pwm_step_size > 0: + # increase + expected_set = [0] + list(range(0, 255, pwm_step_size)) + else: + # decrease + expected_set = [255] + list(range(255, 0, pwm_step_size)) + assert [pwm for (pwm,), _ in fan.pwm_write.set.call_args_list] == expected_set diff --git a/tests/test_filters.py b/tests/test_filters.py new file mode 100644 index 0000000..3ddc971 --- /dev/null +++ b/tests/test_filters.py @@ -0,0 +1,74 @@ +import pytest + +from afancontrol.filters import MovingMedianFilter, MovingQuantileFilter, NullFilter +from afancontrol.temp import TempCelsius, TempStatus + + +def make_temp_status(temp): + return TempStatus( + min=TempCelsius(30), + max=TempCelsius(50), + temp=TempCelsius(temp), + panic=None, + threshold=None, + is_panic=False, + is_threshold=False, + ) + + +@pytest.mark.parametrize( + "filter", + [ + NullFilter(), + MovingMedianFilter(window_size=3), + MovingQuantileFilter(0.5, window_size=3), + ], +) +def test_none(filter): + with filter: + assert filter.apply(None) is None + + +@pytest.mark.parametrize( + "filter", + [ + NullFilter(), + MovingMedianFilter(window_size=3), + MovingQuantileFilter(0.5, window_size=3), + ], +) +def test_single_point(filter): + with filter: + assert filter.apply(make_temp_status(42.0)) == make_temp_status(42.0) + + +def test_moving_quantile(): + f = MovingQuantileFilter(0.8, window_size=10) + with f: + assert f.apply(make_temp_status(42.0)) == make_temp_status(42.0) + assert f.apply(make_temp_status(45.0)) == make_temp_status(45.0) + assert f.apply(make_temp_status(47.0)) == make_temp_status(47.0) + assert f.apply(make_temp_status(123.0)) == make_temp_status(123.0) + assert f.apply(make_temp_status(46.0)) == make_temp_status(123.0) + assert f.apply(make_temp_status(49.0)) == make_temp_status(49.0) + assert f.apply(make_temp_status(51.0)) == make_temp_status(51.0) + assert f.apply(None) == make_temp_status(123.0) + assert f.apply(None) is None + assert f.apply(make_temp_status(51.0)) is None + assert f.apply(make_temp_status(53.0)) is None + + +def test_moving_median(): + f = MovingMedianFilter(window_size=3) + with f: + assert f.apply(make_temp_status(42.0)) == make_temp_status(42.0) + assert f.apply(make_temp_status(45.0)) == make_temp_status(45.0) + assert f.apply(make_temp_status(47.0)) == make_temp_status(45.0) + assert f.apply(make_temp_status(123.0)) == make_temp_status(47.0) + assert f.apply(make_temp_status(46.0)) == make_temp_status(47.0) + assert f.apply(make_temp_status(49.0)) == make_temp_status(49.0) + assert f.apply(make_temp_status(51.0)) == make_temp_status(49.0) + assert f.apply(None) == make_temp_status(51.0) + assert f.apply(None) is None + assert f.apply(make_temp_status(51.0)) is None + assert f.apply(make_temp_status(53.0)) == make_temp_status(53.0) diff --git a/tests/test_manager.py b/tests/test_manager.py new file mode 100644 index 0000000..ff36e03 --- /dev/null +++ b/tests/test_manager.py @@ -0,0 +1,252 @@ +from contextlib import ExitStack +from typing import cast +from unittest.mock import MagicMock, patch, sentinel + +import pytest + +import afancontrol.manager +from afancontrol.config import ( + Actions, + AlertCommands, + FanName, + FanSpeedModifier, + FansTempsRelation, + MappingName, + TempName, + TriggerConfig, +) +from afancontrol.manager import Manager +from afancontrol.metrics import Metrics +from afancontrol.pwmfannorm import PWMFanNorm, PWMValueNorm +from afancontrol.report import Report +from afancontrol.temp import FileTemp, TempCelsius, TempStatus +from afancontrol.trigger import Triggers + + +@pytest.fixture +def report(): + return MagicMock(spec=Report) + + +def test_manager(report): + mocked_case_fan = MagicMock(spec=PWMFanNorm)() + mocked_mobo_temp = MagicMock(spec=FileTemp)() + mocked_metrics = MagicMock(spec=Metrics)() + + with ExitStack() as stack: + stack.enter_context( + patch.object(afancontrol.manager, "Triggers", spec=Triggers) + ) + + manager = Manager( + arduino_connections={}, + fans={FanName("case"): mocked_case_fan}, + readonly_fans={}, + temps={TempName("mobo"): mocked_mobo_temp}, + mappings={ + MappingName("1"): FansTempsRelation( + temps=[TempName("mobo")], + fans=[FanSpeedModifier(fan=FanName("case"), modifier=0.6)], + ) + }, + report=report, + triggers_config=TriggerConfig( + global_commands=Actions( + panic=AlertCommands(enter_cmd=None, leave_cmd=None), + threshold=AlertCommands(enter_cmd=None, leave_cmd=None), + ), + temp_commands={ + TempName("mobo"): Actions( + panic=AlertCommands(enter_cmd=None, leave_cmd=None), + threshold=AlertCommands(enter_cmd=None, leave_cmd=None), + ) + }, + ), + metrics=mocked_metrics, + ) + + stack.enter_context(manager) + + manager.tick() + + mocked_triggers = cast(MagicMock, manager.triggers) + assert mocked_triggers.check.call_count == 1 + assert mocked_case_fan.__enter__.call_count == 1 + assert mocked_metrics.__enter__.call_count == 1 + assert mocked_metrics.tick.call_count == 1 + assert mocked_case_fan.__exit__.call_count == 1 + assert mocked_metrics.__exit__.call_count == 1 + + +@pytest.mark.parametrize( + "temps, mappings, expected_fan_speeds", + [ + ( + { + TempName("cpu"): TempStatus( + min=TempCelsius(30), + max=TempCelsius(50), + temp=TempCelsius((50 - 30) * 0.42 + 30), + panic=None, + threshold=None, + is_panic=False, + is_threshold=False, + ), + TempName("hdd"): None, # a failing sensor + }, + { + MappingName("all"): FansTempsRelation( + temps=[TempName("cpu"), TempName("hdd")], + fans=[FanSpeedModifier(fan=FanName("rear"), modifier=1.0)], + ) + }, + {FanName("rear"): PWMValueNorm(1.0)}, + ), + ( + { + TempName("cpu"): TempStatus( + min=TempCelsius(30), + max=TempCelsius(50), + temp=TempCelsius((50 - 30) * 0.42 + 30), + panic=None, + threshold=None, + is_panic=False, + is_threshold=False, + ) + }, + { + MappingName("all"): FansTempsRelation( + temps=[TempName("cpu")], + fans=[FanSpeedModifier(fan=FanName("rear"), modifier=1.0)], + ) + }, + {FanName("rear"): PWMValueNorm(0.42)}, + ), + ( + { + TempName("cpu"): TempStatus( + min=TempCelsius(30), + max=TempCelsius(50), + temp=TempCelsius((50 - 30) * 0.42 + 30), + panic=None, + threshold=None, + is_panic=False, + is_threshold=False, + ) + }, + { + MappingName("all"): FansTempsRelation( + temps=[TempName("cpu")], + fans=[FanSpeedModifier(fan=FanName("rear"), modifier=0.6)], + ) + }, + {FanName("rear"): PWMValueNorm(0.42 * 0.6)}, + ), + ( + { + TempName("cpu"): TempStatus( + min=TempCelsius(30), + max=TempCelsius(50), + temp=TempCelsius((50 - 30) * 0.42 + 30), + panic=None, + threshold=None, + is_panic=False, + is_threshold=False, + ), + TempName("mobo"): TempStatus( + min=TempCelsius(30), + max=TempCelsius(50), + temp=TempCelsius((50 - 30) * 0.52 + 30), + panic=None, + threshold=None, + is_panic=False, + is_threshold=False, + ), + TempName("hdd"): TempStatus( + min=TempCelsius(30), + max=TempCelsius(50), + temp=TempCelsius((50 - 30) * 0.12 + 30), + panic=None, + threshold=None, + is_panic=False, + is_threshold=False, + ), + }, + { + MappingName("all"): FansTempsRelation( + temps=[TempName("cpu"), TempName("mobo"), TempName("hdd")], + fans=[FanSpeedModifier(fan=FanName("rear"), modifier=1.0)], + ) + }, + {FanName("rear"): PWMValueNorm(0.52)}, + ), + ( + { + TempName("cpu"): TempStatus( + min=TempCelsius(30), + max=TempCelsius(50), + temp=TempCelsius((50 - 30) * 0.42 + 30), + panic=None, + threshold=None, + is_panic=False, + is_threshold=False, + ), + TempName("mobo"): TempStatus( + min=TempCelsius(30), + max=TempCelsius(50), + temp=TempCelsius((50 - 30) * 0.52 + 30), + panic=None, + threshold=None, + is_panic=False, + is_threshold=False, + ), + TempName("hdd"): TempStatus( + min=TempCelsius(30), + max=TempCelsius(50), + temp=TempCelsius((50 - 30) * 0.12 + 30), + panic=None, + threshold=None, + is_panic=False, + is_threshold=False, + ), + }, + { + MappingName("1"): FansTempsRelation( + temps=[TempName("cpu"), TempName("hdd")], + fans=[FanSpeedModifier(fan=FanName("rear"), modifier=1.0)], + ), + MappingName("2"): FansTempsRelation( + temps=[TempName("mobo"), TempName("hdd")], + fans=[FanSpeedModifier(fan=FanName("rear"), modifier=0.6)], + ), + }, + {FanName("rear"): PWMValueNorm(0.42)}, + ), + ], +) +def test_fan_speeds(report, temps, mappings, expected_fan_speeds): + mocked_case_fan = MagicMock(spec=PWMFanNorm)() + mocked_mobo_temp = MagicMock(spec=FileTemp)() + mocked_metrics = MagicMock(spec=Metrics)() + + with ExitStack() as stack: + stack.enter_context( + patch.object(afancontrol.manager, "Triggers", spec=Triggers) + ) + + manager = Manager( + arduino_connections={}, + fans={fan_name: mocked_case_fan for fan_name in expected_fan_speeds.keys()}, + readonly_fans={}, + temps={temp_name: mocked_mobo_temp for temp_name in temps.keys()}, + mappings=mappings, + report=report, + triggers_config=sentinel.some_triggers_config, + metrics=mocked_metrics, + ) + + stack.enter_context(manager) + + assert expected_fan_speeds == pytest.approx( + dict(manager._map_temps_to_fan_speeds(temps)) + ) diff --git a/tests/test_metrics.py b/tests/test_metrics.py new file mode 100644 index 0000000..47ce326 --- /dev/null +++ b/tests/test_metrics.py @@ -0,0 +1,156 @@ +import random +import types +from time import sleep +from unittest.mock import MagicMock + +import pytest +import requests + +from afancontrol.config import FanName, TempName +from afancontrol.fans import Fans +from afancontrol.metrics import PrometheusMetrics, prometheus_available +from afancontrol.pwmfannorm import PWMFanNorm +from afancontrol.report import Report +from afancontrol.temp import TempCelsius, TempStatus +from afancontrol.temps import ObservedTempStatus +from afancontrol.trigger import Triggers + + +@pytest.fixture +def requests_session(): + # Ignore system proxies, see https://stackoverflow.com/a/28521696 + with requests.Session() as session: + session.trust_env = False + yield session + + +@pytest.mark.skipif( + not prometheus_available, reason="prometheus_client is not installed" +) +def test_prometheus_metrics(requests_session): + mocked_fan = MagicMock(spec=PWMFanNorm)() + mocked_triggers = MagicMock(spec=Triggers)() + mocked_report = MagicMock(spec=Report)() + + port = random.randint(20000, 50000) + metrics = PrometheusMetrics("127.0.0.1:%s" % port) + with metrics: + resp = requests_session.get("http://127.0.0.1:%s/metrics" % port) + assert resp.status_code == 200 + assert "is_threshold 0.0" in resp.text + + with metrics.measure_tick(): + sleep(0.01) + + resp = requests_session.get("http://127.0.0.1:%s/metrics" % port) + assert resp.status_code == 200 + assert "tick_duration_count 1.0" in resp.text + assert "tick_duration_sum 0." in resp.text + + mocked_triggers.panic_trigger.is_alerting = True + mocked_triggers.threshold_trigger.is_alerting = False + + mocked_fan.pwm_line_start = 100 + mocked_fan.pwm_line_end = 240 + mocked_fan.get_speed.return_value = 999 + mocked_fan.get_raw.return_value = 142 + mocked_fan.get = types.MethodType(PWMFanNorm.get, mocked_fan) + mocked_fan.pwm_read.max_pwm = 255 + + metrics.tick( + temps={ + TempName("goodtemp"): ObservedTempStatus( + filtered=TempStatus( + temp=TempCelsius(74.0), + min=TempCelsius(40.0), + max=TempCelsius(50.0), + panic=TempCelsius(60.0), + threshold=None, + is_panic=True, + is_threshold=False, + ), + raw=TempStatus( + temp=TempCelsius(72.0), + min=TempCelsius(40.0), + max=TempCelsius(50.0), + panic=TempCelsius(60.0), + threshold=None, + is_panic=True, + is_threshold=False, + ), + ), + TempName("failingtemp"): ObservedTempStatus(filtered=None, raw=None), + }, + fans=Fans( + fans={FanName("test"): mocked_fan}, + readonly_fans={}, + report=mocked_report, + ), + triggers=mocked_triggers, + arduino_connections={}, + ) + + resp = requests_session.get("http://127.0.0.1:%s/metrics" % port) + assert resp.status_code == 200 + print(resp.text) + assert 'temperature_current{temp_name="failingtemp"} NaN' in resp.text + assert 'temperature_current_raw{temp_name="failingtemp"} NaN' in resp.text + assert 'temperature_current{temp_name="goodtemp"} 74.0' in resp.text + assert 'temperature_current_raw{temp_name="goodtemp"} 72.0' in resp.text + assert 'temperature_is_failing{temp_name="failingtemp"} 1.0' in resp.text + assert 'temperature_is_failing{temp_name="goodtemp"} 0.0' in resp.text + assert 'fan_rpm{fan_name="test"} 999.0' in resp.text + assert 'fan_pwm{fan_name="test"} 142.0' in resp.text + assert 'fan_pwm_normalized{fan_name="test"} 0.556' in resp.text + assert 'fan_is_failing{fan_name="test"} 0.0' in resp.text + assert "is_panic 1.0" in resp.text + assert "is_threshold 0.0" in resp.text + assert "last_metrics_tick_seconds_ago 0." in resp.text + + with pytest.raises(IOError): + requests_session.get("http://127.0.0.1:%s/metrics" % port) + + +@pytest.mark.skipif( + not prometheus_available, reason="prometheus_client is not installed" +) +def test_prometheus_faulty_fans_dont_break_metrics_collection(requests_session): + mocked_fan = MagicMock(spec=PWMFanNorm)() + mocked_triggers = MagicMock(spec=Triggers)() + mocked_report = MagicMock(spec=Report)() + + port = random.randint(20000, 50000) + metrics = PrometheusMetrics("127.0.0.1:%s" % port) + with metrics: + mocked_triggers.panic_trigger.is_alerting = False + mocked_triggers.threshold_trigger.is_alerting = False + + mocked_fan.pwm_line_start = 100 + mocked_fan.pwm_line_end = 240 + mocked_fan.get_speed.side_effect = IOError + mocked_fan.get_raw.side_effect = IOError + + # Must not raise despite the PWMFan methods raising above: + metrics.tick( + temps={ + TempName("failingtemp"): ObservedTempStatus(filtered=None, raw=None) + }, + fans=Fans( + fans={FanName("test"): mocked_fan}, + readonly_fans={}, + report=mocked_report, + ), + triggers=mocked_triggers, + arduino_connections={}, + ) + + resp = requests_session.get("http://127.0.0.1:%s/metrics" % port) + assert resp.status_code == 200 + assert 'fan_pwm_line_start{fan_name="test"} 100.0' in resp.text + assert 'fan_pwm_line_end{fan_name="test"} 240.0' in resp.text + assert 'fan_rpm{fan_name="test"} NaN' in resp.text + assert 'fan_pwm{fan_name="test"} NaN' in resp.text + assert 'fan_pwm_normalized{fan_name="test"} NaN' in resp.text + assert 'fan_is_failing{fan_name="test"} 0.0' in resp.text + assert "is_panic 0.0" in resp.text + assert "is_threshold 0.0" in resp.text diff --git a/tests/test_report.py b/tests/test_report.py new file mode 100644 index 0000000..c8466f5 --- /dev/null +++ b/tests/test_report.py @@ -0,0 +1,20 @@ +from unittest.mock import call + +from afancontrol import report +from afancontrol.report import Report + + +def test_report_success(sense_exec_shell_command): + r = Report(r"printf '@%s' '%REASON%' '%MESSAGE%'") + + with sense_exec_shell_command(report) as (mock_exec_shell_command, get_stdout): + r.report("reason here", "message\nthere") + assert mock_exec_shell_command.call_args == call( + "printf '@%s' 'reason here' 'message\nthere'" + ) + assert ["@reason here@message\nthere"] == get_stdout() + + +def test_report_fail_does_not_raise(): + r = Report("false") + r.report("reason here", "message\nthere") diff --git a/tests/test_trigger.py b/tests/test_trigger.py new file mode 100644 index 0000000..ce0cfc2 --- /dev/null +++ b/tests/test_trigger.py @@ -0,0 +1,180 @@ +from unittest.mock import MagicMock, call + +import pytest + +from afancontrol import trigger +from afancontrol.config import Actions, AlertCommands, TempName, TriggerConfig +from afancontrol.report import Report +from afancontrol.temp import TempCelsius, TempStatus +from afancontrol.trigger import PanicTrigger, ThresholdTrigger, Triggers + + +@pytest.fixture +def report(): + return MagicMock(spec=Report) + + +def test_panic_on_empty_temp(report, sense_exec_shell_command): + t = PanicTrigger( + global_commands=AlertCommands( + enter_cmd="printf '@%s' enter", leave_cmd="printf '@%s' leave" + ), + temp_commands={ + TempName("mobo"): AlertCommands( + enter_cmd=None, leave_cmd="printf '@%s' mobo leave" + ) + }, + report=report, + ) + + with sense_exec_shell_command(trigger) as (mock_exec_shell_command, get_stdout): + with t: + assert not t.is_alerting + assert 0 == mock_exec_shell_command.call_count + t.check({TempName("mobo"): None}) + assert t.is_alerting + + assert mock_exec_shell_command.call_args_list == [ + call("printf '@%s' enter") + ] + assert ["@enter"] == get_stdout() + mock_exec_shell_command.reset_mock() + + assert not t.is_alerting + assert mock_exec_shell_command.call_args_list == [ + call("printf '@%s' mobo leave"), + call("printf '@%s' leave"), + ] + assert ["@mobo@leave", "@leave"] == get_stdout() + + +def test_threshold_on_empty_temp(report): + t = ThresholdTrigger( + global_commands=AlertCommands(enter_cmd=None, leave_cmd=None), + temp_commands={TempName("mobo"): AlertCommands(enter_cmd=None, leave_cmd=None)}, + report=report, + ) + with t: + assert not t.is_alerting + t.check({TempName("mobo"): None}) + assert not t.is_alerting + assert not t.is_alerting + + +@pytest.mark.parametrize("cls", [ThresholdTrigger, PanicTrigger]) +def test_good_temp(cls, report): + t = cls( + global_commands=AlertCommands(enter_cmd=None, leave_cmd=None), + temp_commands=dict(mobo=AlertCommands(enter_cmd=None, leave_cmd=None)), + report=report, + ) + with t: + assert not t.is_alerting + t.check( + dict( + mobo=TempStatus( + temp=TempCelsius(34.0), + min=TempCelsius(40.0), + max=TempCelsius(50.0), + panic=TempCelsius(60.0), + threshold=None, + is_panic=False, + is_threshold=False, + ) + ) + ) + assert not t.is_alerting + + +@pytest.mark.parametrize("cls", [ThresholdTrigger, PanicTrigger]) +def test_bad_temp(cls, report, sense_exec_shell_command): + t = cls( + global_commands=AlertCommands( + enter_cmd="printf '@%s' enter", leave_cmd="printf '@%s' leave" + ), + temp_commands=dict( + mobo=AlertCommands( + enter_cmd="printf '@%s' mobo enter", leave_cmd="printf '@%s' mobo leave" + ) + ), + report=report, + ) + with sense_exec_shell_command(trigger) as (mock_exec_shell_command, get_stdout): + with t: + assert not t.is_alerting + t.check( + dict( + mobo=TempStatus( + temp=TempCelsius(70.0), + min=TempCelsius(40.0), + max=TempCelsius(50.0), + panic=TempCelsius(60.0), + threshold=TempCelsius(55.0), + is_panic=True, + is_threshold=True, + ) + ) + ) + assert t.is_alerting + assert mock_exec_shell_command.call_args_list == [ + call("printf '@%s' mobo enter"), + call("printf '@%s' enter"), + ] + assert ["@mobo@enter", "@enter"] == get_stdout() + mock_exec_shell_command.reset_mock() + + t.check( + dict( + mobo=TempStatus( + temp=TempCelsius(34.0), + min=TempCelsius(40.0), + max=TempCelsius(50.0), + panic=TempCelsius(60.0), + threshold=None, + is_panic=False, + is_threshold=False, + ) + ) + ) + assert not t.is_alerting + assert mock_exec_shell_command.call_args_list == [ + call("printf '@%s' mobo leave"), + call("printf '@%s' leave"), + ] + assert ["@mobo@leave", "@leave"] == get_stdout() + mock_exec_shell_command.reset_mock() + assert 0 == mock_exec_shell_command.call_count + + +def test_triggers_good_temp(report): + t = Triggers( + TriggerConfig( + global_commands=Actions( + panic=AlertCommands(enter_cmd=None, leave_cmd=None), + threshold=AlertCommands(enter_cmd=None, leave_cmd=None), + ), + temp_commands={ + TempName("mobo"): Actions( + panic=AlertCommands(enter_cmd=None, leave_cmd=None), + threshold=AlertCommands(enter_cmd=None, leave_cmd=None), + ) + }, + ), + report=report, + ) + with t: + assert not t.is_alerting + t.check( + { + TempName("mobo"): TempStatus( + temp=TempCelsius(34.0), + min=TempCelsius(40.0), + max=TempCelsius(50.0), + panic=TempCelsius(60.0), + threshold=None, + is_panic=False, + is_threshold=False, + ) + } + ) + assert not t.is_alerting diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..8df76e6 --- /dev/null +++ b/tox.ini @@ -0,0 +1,28 @@ +[tox] +envlist=py{36,37,38,39,310}{,-arduino,-metrics},lint,check-docs + +[testenv] +extras = + arduino: arduino + dev + metrics: metrics +whitelist_externals = make +commands = make test +; Fix coverage not working because tox doesn't install +; sources to the working dir by default. +usedevelop = True + +[testenv:lint] +extras = + arduino + dev + metrics +basepython = python3 +; Use `pip install -e .` so isort would treat imports from this package +; as first party imports instead of third party: +usedevelop = True +commands = make lint + +[testenv:check-docs] +basepython = python3 +commands = make check-docs