commit 27b4629279e7a559cea19f8f123f3310724dec9a Author: Mario Fetka Date: Tue Oct 26 12:58:36 2021 +0200 Imported Upstream version 3.0.0 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