Imported Upstream version 3.0.0
This commit is contained in:
commit
27b4629279
1
.dockerignore
Normal file
1
.dockerignore
Normal file
@ -0,0 +1 @@
|
||||
.tox
|
16
.gitignore
vendored
Normal file
16
.gitignore
vendored
Normal 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
37
.travis.yml
Normal 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
43
CONTRIBUTING.md
Normal 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
27
Dockerfile.debian
Normal 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
21
LICENSE
Normal 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
5
MANIFEST.in
Normal file
@ -0,0 +1,5 @@
|
||||
graft pkg
|
||||
graft tests
|
||||
recursive-exclude * *.py[co]
|
||||
recursive-exclude * .DS_Store
|
||||
recursive-exclude * __pycache__
|
81
Makefile
Normal file
81
Makefile
Normal 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
23
README.rst
Normal 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
3
arduino/Readme.md
Normal 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
237
arduino/micro.ino
Normal 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");
|
||||
}
|
||||
}
|
1
arduino/micro_schematics.svg
Normal file
1
arduino/micro_schematics.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 130 KiB |
3
debian/.gitignore
vendored
Normal file
3
debian/.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
afancontrol/
|
||||
debhelper-build-stamp
|
||||
files
|
78
debian/changelog
vendored
Normal file
78
debian/changelog
vendored
Normal 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
1
debian/compat
vendored
Normal file
@ -0,0 +1 @@
|
||||
9
|
38
debian/control
vendored
Normal file
38
debian/control
vendored
Normal 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
33
debian/copyright
vendored
Normal 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
2
debian/install
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
pkg/afancontrol.conf /etc/afancontrol
|
||||
pkg/afancontrol.service /lib/systemd/system
|
15
debian/patches/remove-setup-py-data-files.patch
vendored
Normal file
15
debian/patches/remove-setup-py-data-files.patch
vendored
Normal 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
1
debian/patches/series
vendored
Normal file
@ -0,0 +1 @@
|
||||
remove-setup-py-data-files.patch
|
12
debian/rules
vendored
Normal file
12
debian/rules
vendored
Normal 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
1
debian/source/format
vendored
Normal file
@ -0,0 +1 @@
|
||||
3.0 (quilt)
|
1
debian/source/options
vendored
Normal file
1
debian/source/options
vendored
Normal file
@ -0,0 +1 @@
|
||||
extend-diff-ignore = "^[^/]*[.]egg-info/"
|
3
debian/watch
vendored
Normal file
3
debian/watch
vendored
Normal 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
20
docs/Makefile
Normal 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
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
10
docs/_static/custom.css
vendored
Normal 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
1
docs/_static/micro_schematics.svg
vendored
Symbolic link
@ -0,0 +1 @@
|
||||
../../arduino/micro_schematics.svg
|
1
docs/_static/noctua_arduino.svg
vendored
Normal file
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
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
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
79
docs/conf.py
Normal 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
511
docs/index.rst
Normal 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
269
pkg/afancontrol.conf
Normal 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
12
pkg/afancontrol.service
Normal 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
112
setup.cfg
Normal 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
16
setup.py
Normal 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"]),
|
||||
],
|
||||
)
|
1
src/afancontrol/__init__.py
Normal file
1
src/afancontrol/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
__version__ = "3.0.0"
|
21
src/afancontrol/__main__.py
Normal file
21
src/afancontrol/__main__.py
Normal 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
308
src/afancontrol/arduino.py
Normal 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
389
src/afancontrol/config.py
Normal 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
|
129
src/afancontrol/configparser.py
Normal file
129
src/afancontrol/configparser.py
Normal 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
162
src/afancontrol/daemon.py
Normal 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
50
src/afancontrol/exec.py
Normal 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
150
src/afancontrol/fans.py
Normal 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
333
src/afancontrol/fantest.py
Normal 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
127
src/afancontrol/filters.py
Normal 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
|
3
src/afancontrol/logger.py
Normal file
3
src/afancontrol/logger.py
Normal file
@ -0,0 +1,3 @@
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("afancontrol")
|
116
src/afancontrol/manager.py
Normal file
116
src/afancontrol/manager.py
Normal 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
392
src/afancontrol/metrics.py
Normal 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
|
131
src/afancontrol/pwmfan/__init__.py
Normal file
131
src/afancontrol/pwmfan/__init__.py
Normal 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
|
||||
)
|
133
src/afancontrol/pwmfan/arduino.py
Normal file
133
src/afancontrol/pwmfan/arduino.py
Normal 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)
|
86
src/afancontrol/pwmfan/base.py
Normal file
86
src/afancontrol/pwmfan/base.py
Normal 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
|
49
src/afancontrol/pwmfan/ipmi.py
Normal file
49
src/afancontrol/pwmfan/ipmi.py
Normal 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)
|
90
src/afancontrol/pwmfan/linux.py
Normal file
90
src/afancontrol/pwmfan/linux.py
Normal 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)
|
234
src/afancontrol/pwmfannorm.py
Normal file
234
src/afancontrol/pwmfannorm.py
Normal 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
17
src/afancontrol/report.py
Normal 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)
|
55
src/afancontrol/temp/__init__.py
Normal file
55
src/afancontrol/temp/__init__.py
Normal 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)
|
44
src/afancontrol/temp/base.py
Normal file
44
src/afancontrol/temp/base.py
Normal 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
|
76
src/afancontrol/temp/command.py
Normal file
76
src/afancontrol/temp/command.py
Normal 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
|
109
src/afancontrol/temp/file.py
Normal file
109
src/afancontrol/temp/file.py
Normal 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
102
src/afancontrol/temp/hdd.py
Normal 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
77
src/afancontrol/temps.py
Normal 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
210
src/afancontrol/trigger.py
Normal 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
0
tests/__init__.py
Normal file
38
tests/conftest.py
Normal file
38
tests/conftest.py
Normal 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
|
53
tests/data/afancontrol-example.conf
Normal file
53
tests/data/afancontrol-example.conf
Normal 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
0
tests/pwmfan/__init__.py
Normal file
177
tests/pwmfan/test_arduino.py
Normal file
177
tests/pwmfan/test_arduino.py
Normal 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
41
tests/pwmfan/test_ipmi.py
Normal 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
153
tests/pwmfan/test_linux.py
Normal 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
0
tests/temp/__init__.py
Normal file
46
tests/temp/test_base.py
Normal file
46
tests/temp/test_base.py
Normal 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,
|
||||
)
|
40
tests/temp/test_command.py
Normal file
40
tests/temp/test_command.py
Normal 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
88
tests/temp/test_file.py
Normal 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
95
tests/temp/test_hdd.py
Normal 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
498
tests/test_config.py
Normal 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
98
tests/test_daemon.py
Normal 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
31
tests/test_exec.py
Normal 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
69
tests/test_fans.py
Normal 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
105
tests/test_fantest.py
Normal 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
74
tests/test_filters.py
Normal 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
252
tests/test_manager.py
Normal 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
156
tests/test_metrics.py
Normal 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
20
tests/test_report.py
Normal 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
180
tests/test_trigger.py
Normal 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
28
tox.ini
Normal 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
|
Loading…
Reference in New Issue
Block a user