Imported Upstream version 3.0.0

This commit is contained in:
Mario Fetka 2021-10-26 12:58:36 +02:00
commit 27b4629279
87 changed files with 7722 additions and 0 deletions

1
.dockerignore Normal file
View File

@ -0,0 +1 @@
.tox

16
.gitignore vendored Normal file
View File

@ -0,0 +1,16 @@
*.egg-info
dist
build
.py[co]
__pycache__/
.coverage
.pytest_cache
.python-version
.tox
docs/_build

37
.travis.yml Normal file
View File

@ -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

43
CONTRIBUTING.md Normal file
View File

@ -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

27
Dockerfile.debian Normal file
View File

@ -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 .

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2019 Kostya Esmukov <kostya@esmukov.ru>
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.

5
MANIFEST.in Normal file
View File

@ -0,0 +1,5 @@
graft pkg
graft tests
recursive-exclude * *.py[co]
recursive-exclude * .DS_Store
recursive-exclude * __pycache__

81
Makefile Normal file
View File

@ -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/; \
'

23
README.rst Normal file
View File

@ -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 <https://github.com/lm-sensors/lm-sensors/blob/master/prog/pwm/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 `<https://afancontrol.readthedocs.io/>`_.

3
arduino/Readme.md Normal file
View File

@ -0,0 +1,3 @@
![Arduino Micro schematics](./micro_schematics.svg)
See the docs at https://afancontrol.readthedocs.io/#pwm-fans-via-arduino

237
arduino/micro.ino Normal file
View File

@ -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 <TimerOne.h>
#include <TimerThree.h>
// 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");
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 130 KiB

3
debian/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
afancontrol/
debhelper-build-stamp
files

78
debian/changelog vendored Normal file
View File

@ -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 <kostya@esmukov.ru> Sat, 10 Oct 2020 14:43:01 +0000
afancontrol (2.2.1-1) unstable; urgency=medium
* Fix compatibility with py3.5
-- Kostya Esmukov <kostya@esmukov.ru> 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 <kostya@esmukov.ru> Mon, 28 Sep 2020 22:12:04 +0000
afancontrol (2.1.0-1) unstable; urgency=medium
* Move PID file under /run (#3)
-- Kostya Esmukov <kostya@esmukov.ru> 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 <kostya@esmukov.ru> 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 <kostya@esmukov.ru> 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 <kostya@esmukov.ru> 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 <kostya@esmukov.ru> Wed, 01 May 2019 12:40:13 +0000
afancontrol (2.0.0~b2-1) unstable; urgency=medium
* Fix hddtemp not expanding glob
-- Kostya Esmukov <kostya@esmukov.ru> Mon, 29 Apr 2019 19:04:42 +0000
afancontrol (2.0.0~b1-1) unstable; urgency=medium
* Initial release
-- Kostya Esmukov <kostya@esmukov.ru> Sun, 28 Apr 2019 11:58:16 +0000

1
debian/compat vendored Normal file
View File

@ -0,0 +1 @@
9

38
debian/control vendored Normal file
View File

@ -0,0 +1,38 @@
Source: afancontrol
Section: utils
Priority: optional
Maintainer: Kostya Esmukov <kostya@esmukov.ru>
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.

33
debian/copyright vendored Normal file
View File

@ -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 <kostya@esmukov.ru>
License: Expat
Files: debian/*
Copyright: 2019 Kostya Esmukov <kostya@esmukov.ru>
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.

2
debian/install vendored Normal file
View File

@ -0,0 +1,2 @@
pkg/afancontrol.conf /etc/afancontrol
pkg/afancontrol.service /lib/systemd/system

View File

@ -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"]),
- ],
)

1
debian/patches/series vendored Normal file
View File

@ -0,0 +1 @@
remove-setup-py-data-files.patch

12
debian/rules vendored Normal file
View File

@ -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

1
debian/source/format vendored Normal file
View File

@ -0,0 +1 @@
3.0 (quilt)

1
debian/source/options vendored Normal file
View File

@ -0,0 +1 @@
extend-diff-ignore = "^[^/]*[.]egg-info/"

3
debian/watch vendored Normal file
View File

@ -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)))

20
docs/Makefile Normal file
View File

@ -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)

1
docs/_static/arctic_motherboard.svg vendored Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 57 KiB

10
docs/_static/custom.css vendored Normal file
View File

@ -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;
}

1
docs/_static/micro_schematics.svg vendored Symbolic link
View File

@ -0,0 +1 @@
../../arduino/micro_schematics.svg

1
docs/_static/noctua_arduino.svg vendored Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 55 KiB

1
docs/_static/noctua_motherboard.svg vendored Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 58 KiB

170
docs/_static/pc_case_example.svg vendored Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 150 KiB

79
docs/conf.py Normal file
View File

@ -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'

511
docs/index.rst Normal file
View File

@ -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 <https://github.com/lm-sensors/lm-sensors/blob/master/prog/pwm/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 <index.html#pwm-fans-via-arduino>`_);
- 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 <index.html#pwm-fans-via-arduino>`_, if
extra PWM fan connectors are needed;
- Prepare and connect the PWM fans and temperature sensors;
- `Set up lm-sensors <index.html#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

269
pkg/afancontrol.conf Normal file
View File

@ -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

12
pkg/afancontrol.service Normal file
View File

@ -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

112
setup.cfg Normal file
View File

@ -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

16
setup.py Normal file
View File

@ -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"]),
],
)

View File

@ -0,0 +1 @@
__version__ = "3.0.0"

View File

@ -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")

308
src/afancontrol/arduino.py Normal file
View File

@ -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

389
src/afancontrol/config.py Normal file
View File

@ -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

View File

@ -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

162
src/afancontrol/daemon.py Normal file
View File

@ -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

50
src/afancontrol/exec.py Normal file
View File

@ -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

150
src/afancontrol/fans.py Normal file
View File

@ -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)

333
src/afancontrol/fantest.py Normal file
View File

@ -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 "")

127
src/afancontrol/filters.py Normal file
View File

@ -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

View File

@ -0,0 +1,3 @@
import logging
logger = logging.getLogger("afancontrol")

116
src/afancontrol/manager.py Normal file
View File

@ -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

392
src/afancontrol/metrics.py Normal file
View File

@ -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

View File

@ -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
)

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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

17
src/afancontrol/report.py Normal file
View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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)

102
src/afancontrol/temp/hdd.py Normal file
View File

@ -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)

77
src/afancontrol/temps.py Normal file
View File

@ -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)

210
src/afancontrol/trigger.py Normal file
View File

@ -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)

0
tests/__init__.py Normal file
View File

38
tests/conftest.py Normal file
View File

@ -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

View File

@ -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

0
tests/pwmfan/__init__.py Normal file
View File

View File

@ -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()

41
tests/pwmfan/test_ipmi.py Normal file
View File

@ -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()

153
tests/pwmfan/test_linux.py Normal file
View File

@ -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()

0
tests/temp/__init__.py Normal file
View File

46
tests/temp/test_base.py Normal file
View File

@ -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,
)

View File

@ -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,
)

88
tests/temp/test_file.py Normal file
View File

@ -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,
)

95
tests/temp/test_hdd.py Normal file
View File

@ -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()

498
tests/test_config.py Normal file
View File

@ -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'}"

98
tests/test_daemon.py Normal file
View File

@ -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)

31
tests/test_exec.py Normal file
View File

@ -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)

69
tests/test_fans.py Normal file
View File

@ -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()]

105
tests/test_fantest.py Normal file
View File

@ -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

74
tests/test_filters.py Normal file
View File

@ -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)

252
tests/test_manager.py Normal file
View File

@ -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))
)

156
tests/test_metrics.py Normal file
View File

@ -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

20
tests/test_report.py Normal file
View File

@ -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")

180
tests/test_trigger.py Normal file
View File

@ -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

28
tox.ini Normal file
View File

@ -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