Compare commits
No commits in common. "master" and "pristine-tar" have entirely different histories.
master
...
pristine-t
@ -1 +0,0 @@
|
|||||||
.tox
|
|
57
.github/workflows/ci.yml
vendored
57
.github/workflows/ci.yml
vendored
@ -1,57 +0,0 @@
|
|||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request: {}
|
|
||||||
push: {}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
lint:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v2
|
|
||||||
- name: Set up Python 3
|
|
||||||
uses: actions/setup-python@v2
|
|
||||||
with:
|
|
||||||
python-version: '3.x'
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
python3 -m pip install --upgrade pip
|
|
||||||
python3 -m pip install tox tox-gh-actions
|
|
||||||
- run: tox -e lint
|
|
||||||
|
|
||||||
check-docs:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v2
|
|
||||||
- name: Set up Python 3
|
|
||||||
uses: actions/setup-python@v2
|
|
||||||
with:
|
|
||||||
python-version: '3.x'
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
python3 -m pip install --upgrade pip
|
|
||||||
python3 -m pip install tox tox-gh-actions
|
|
||||||
- run: tox -e check-docs
|
|
||||||
|
|
||||||
test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
continue-on-error: ${{ matrix.experimental }}
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"]
|
|
||||||
experimental: [false]
|
|
||||||
include:
|
|
||||||
- python-version: "3.12-dev"
|
|
||||||
experimental: true
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v2
|
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
|
||||||
uses: actions/setup-python@v2
|
|
||||||
with:
|
|
||||||
python-version: ${{ matrix.python-version }}
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
python3 -m pip install --upgrade pip setuptools
|
|
||||||
python3 -m pip install tox tox-gh-actions
|
|
||||||
- run: tox
|
|
16
.gitignore
vendored
16
.gitignore
vendored
@ -1,16 +0,0 @@
|
|||||||
*.egg-info
|
|
||||||
dist
|
|
||||||
build
|
|
||||||
|
|
||||||
.py[co]
|
|
||||||
__pycache__/
|
|
||||||
|
|
||||||
.coverage
|
|
||||||
.pytest_cache
|
|
||||||
|
|
||||||
.python-version
|
|
||||||
|
|
||||||
.tox
|
|
||||||
|
|
||||||
docs/_build
|
|
||||||
|
|
37
.travis.yml
37
.travis.yml
@ -1,37 +0,0 @@
|
|||||||
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
|
|
@ -1,43 +0,0 @@
|
|||||||
# 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
|
|
@ -1,27 +0,0 @@
|
|||||||
# 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
21
LICENSE
@ -1,21 +0,0 @@
|
|||||||
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.
|
|
@ -1,5 +0,0 @@
|
|||||||
graft pkg
|
|
||||||
graft tests
|
|
||||||
recursive-exclude * *.py[co]
|
|
||||||
recursive-exclude * .DS_Store
|
|
||||||
recursive-exclude * __pycache__
|
|
77
Makefile
77
Makefile
@ -1,77 +0,0 @@
|
|||||||
|
|
||||||
.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 pytest
|
|
||||||
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 \
|
|
||||||
-v `pwd`/dist:/afancontrol/dist \
|
|
||||||
-v `pwd`/debian:/afancontrol/debian \
|
|
||||||
afancontrol-debuild sh -ex -c '\
|
|
||||||
tar xaf /afancontrol/dist/afancontrol-*.tar.gz --strip 1; \
|
|
||||||
debuild -us -uc -b; \
|
|
||||||
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
23
README.rst
@ -1,23 +0,0 @@
|
|||||||
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/github/workflow/status/KostyaEsmukov/afancontrol/CI?style=flat-square
|
|
||||||
:target: https://github.com/KostyaEsmukov/afancontrol/actions
|
|
||||||
: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/>`_.
|
|
BIN
afancontrol_2.2.1.orig.tar.gz.delta
Normal file
BIN
afancontrol_2.2.1.orig.tar.gz.delta
Normal file
Binary file not shown.
1
afancontrol_2.2.1.orig.tar.gz.id
Normal file
1
afancontrol_2.2.1.orig.tar.gz.id
Normal file
@ -0,0 +1 @@
|
|||||||
|
77d172814f4f4b5994aa4378a40946acdcd4e768
|
BIN
afancontrol_3.0.0.orig.tar.gz.delta
Normal file
BIN
afancontrol_3.0.0.orig.tar.gz.delta
Normal file
Binary file not shown.
1
afancontrol_3.0.0.orig.tar.gz.id
Normal file
1
afancontrol_3.0.0.orig.tar.gz.id
Normal file
@ -0,0 +1 @@
|
|||||||
|
9b7b0355f99d65d6a6fd7821862b69e5e30bfb4b
|
BIN
afancontrol_3.1.0.orig.tar.gz.delta
Normal file
BIN
afancontrol_3.1.0.orig.tar.gz.delta
Normal file
Binary file not shown.
1
afancontrol_3.1.0.orig.tar.gz.id
Normal file
1
afancontrol_3.1.0.orig.tar.gz.id
Normal file
@ -0,0 +1 @@
|
|||||||
|
5bf41b9cf371e6b3b4b3962edb21c4a4adec24b0
|
@ -1,3 +0,0 @@
|
|||||||
![Arduino Micro schematics](./micro_schematics.svg)
|
|
||||||
|
|
||||||
See the docs at https://afancontrol.readthedocs.io/#pwm-fans-via-arduino
|
|
@ -1,237 +0,0 @@
|
|||||||
// The pins defined in this program are for Arduino Micro.
|
|
||||||
|
|
||||||
/////////////////////////
|
|
||||||
// FAN PWM outputs:
|
|
||||||
|
|
||||||
// https://www.pjrc.com/teensy/td_libs_TimerOne.html
|
|
||||||
// https://github.com/PaulStoffregen/TimerOne
|
|
||||||
// https://github.com/PaulStoffregen/TimerThree
|
|
||||||
#include <TimerOne.h>
|
|
||||||
#include <TimerThree.h>
|
|
||||||
|
|
||||||
// TimerOne/Three accepts PWM duty in range from 0 to 1023.
|
|
||||||
// The standard range for PWM fans on Linux is, however, from 0 to 255.
|
|
||||||
// This macros below does the conversion from 255 to 1023.
|
|
||||||
#define PWM_255_TO_1023(PWM) ((PWM == 0) ? 0 : (1L * PWM + 1) * 4 - 1)
|
|
||||||
|
|
||||||
// These are the pins connected to the Timers 1 and 3 on Arduino Micro.
|
|
||||||
// See https://www.pjrc.com/teensy/td_libs_TimerOne.html
|
|
||||||
byte currentPWM5;
|
|
||||||
byte currentPWM9;
|
|
||||||
byte currentPWM10;
|
|
||||||
byte currentPWM11;
|
|
||||||
|
|
||||||
#define SET_PWM(PIN, PWM) currentPWM##PIN = (PWM);
|
|
||||||
#define SET_PWM_HIGH(PIN) SET_PWM(PIN, 255)
|
|
||||||
|
|
||||||
#define PRINT_PWM_JSON(PIN) \
|
|
||||||
Serial.print("\""); \
|
|
||||||
Serial.print(PIN, DEC); \
|
|
||||||
Serial.print("\":"); \
|
|
||||||
Serial.print(currentPWM##PIN, DEC);
|
|
||||||
|
|
||||||
/////////////////////////
|
|
||||||
// FAN tachometer (RPM) inputs:
|
|
||||||
|
|
||||||
// FAN speed from tachometer is measured by counting the number
|
|
||||||
// of interrupts (pulses) for a small period of time `MEASUREMENT_TIME_MS`.
|
|
||||||
//
|
|
||||||
// Between the periods the current status (a JSON) is reported and
|
|
||||||
// an incoming command is read (if any) from the Serial port.
|
|
||||||
//
|
|
||||||
// The number of pulses for each time period is written to a ring buffer,
|
|
||||||
// which allows to compute the RPM on a larger time interval than a single
|
|
||||||
// small period of `MEASUREMENT_TIME_MS`, which yields a smoother RPM.
|
|
||||||
//
|
|
||||||
// The number of periods of the ring buffer is `PULSES_BUFFER_LEN`,
|
|
||||||
// so the total amount of time the pulses are measured for would be
|
|
||||||
// `PULSES_BUFFER_LEN` * `MEASUREMENT_TIME_MS`.
|
|
||||||
//
|
|
||||||
#define MEASUREMENT_TIME_MS 250
|
|
||||||
#define PULSES_BUFFER_LEN 6
|
|
||||||
int pulsesBufferPosition = 0;
|
|
||||||
|
|
||||||
// * 60 seconds in 1 minute (we count revolutions per *minute*);
|
|
||||||
// * 1000 to go from seconds to milliseconds;
|
|
||||||
// * Divided by the amount of time the pulses are measured.
|
|
||||||
//
|
|
||||||
// Be sure to select the dividers which yield an integer result after each division.
|
|
||||||
#define PULSES_MULTIPLIER (1L * 60 * 1000 / MEASUREMENT_TIME_MS / PULSES_BUFFER_LEN)
|
|
||||||
|
|
||||||
// When a PWM wire goes near the Tachometer wire, the Tachometer one might
|
|
||||||
// receive interference, which would be sensed by the interruptions,
|
|
||||||
// spoiling the RPM measurements.
|
|
||||||
//
|
|
||||||
// PWM works at 25kHz, Tachometer is in the range ~4hz - ~200hz (120 RPM - 6000 RPM),
|
|
||||||
// so the extraneous PWM pulses would have delay ~0.04 - 1ms, while
|
|
||||||
// the genuine Tachometer pulses would have delay 5ms-250ms.
|
|
||||||
//
|
|
||||||
// This problem is similar to the common one occurring with the switches
|
|
||||||
// (http://www.gammon.com.au/switches), when a click on a switch produces
|
|
||||||
// many short pulses instead of a single long one.
|
|
||||||
//
|
|
||||||
// This var defines the minimum delay (in ms) between the two RISING
|
|
||||||
// interrupts, which should be treated as a valid Tachometer pulse.
|
|
||||||
#define PULSES_ACCEPT_MIN_DURATION_MS 3
|
|
||||||
|
|
||||||
#define TACHO_PULSES_INT_FUNCTION(PIN) \
|
|
||||||
volatile int tachoPulses##PIN [PULSES_BUFFER_LEN]; \
|
|
||||||
volatile unsigned long lastPulse##PIN; \
|
|
||||||
void incTachoPulses##PIN () { \
|
|
||||||
unsigned long now = millis(); \
|
|
||||||
if (now - lastPulse##PIN < PULSES_ACCEPT_MIN_DURATION_MS) { lastPulse##PIN = now; return; } \
|
|
||||||
lastPulse##PIN = now; \
|
|
||||||
tachoPulses##PIN [pulsesBufferPosition] ++; \
|
|
||||||
}
|
|
||||||
|
|
||||||
#define TACHO_PULSES_ATTACH_INT(PIN) \
|
|
||||||
pinMode(PIN, INPUT); \
|
|
||||||
attachInterrupt(digitalPinToInterrupt(PIN), incTachoPulses##PIN, RISING); \
|
|
||||||
{ \
|
|
||||||
for (int i = 0; i < PULSES_BUFFER_LEN; i++) tachoPulses##PIN[i] = 0; \
|
|
||||||
lastPulse##PIN = 0; \
|
|
||||||
}
|
|
||||||
|
|
||||||
#define TACHO_PULSES_NEXT_BUCKET \
|
|
||||||
pulsesBufferPosition = (pulsesBufferPosition + 1) % PULSES_BUFFER_LEN;
|
|
||||||
|
|
||||||
#define TACHO_PULSES_RESET_CURRENT_BUCKET(PIN) \
|
|
||||||
tachoPulses##PIN[pulsesBufferPosition] = 0;
|
|
||||||
|
|
||||||
#define PRINT_RPM_JSON(PIN) \
|
|
||||||
Serial.print("\""); \
|
|
||||||
Serial.print(PIN, DEC); \
|
|
||||||
Serial.print("\":"); \
|
|
||||||
Serial.print(PULSES_MULTIPLIER * sumPulses(tachoPulses##PIN) / 2, DEC);
|
|
||||||
// ^^^ Regarding division by 2: PC fans do 2 pulses per each revolution,
|
|
||||||
// see https://electronics.stackexchange.com/q/8295
|
|
||||||
|
|
||||||
int sumPulses(volatile int tachoPulses [PULSES_BUFFER_LEN]) {
|
|
||||||
int sum = 0;
|
|
||||||
for (int i = 0; i < PULSES_BUFFER_LEN; i++) {
|
|
||||||
sum += tachoPulses[i];
|
|
||||||
}
|
|
||||||
return sum;
|
|
||||||
}
|
|
||||||
|
|
||||||
// These are the pins on Arduino Micro which support interrupts.
|
|
||||||
// See https://www.arduino.cc/reference/en/language/functions/external-interrupts/attachinterrupt/
|
|
||||||
TACHO_PULSES_INT_FUNCTION(0);
|
|
||||||
TACHO_PULSES_INT_FUNCTION(1);
|
|
||||||
TACHO_PULSES_INT_FUNCTION(2);
|
|
||||||
TACHO_PULSES_INT_FUNCTION(3);
|
|
||||||
TACHO_PULSES_INT_FUNCTION(7);
|
|
||||||
|
|
||||||
|
|
||||||
/////////////////////////
|
|
||||||
// Serial commands:
|
|
||||||
|
|
||||||
char setSpeedCommand = '\xf1'; // The only supported command currently.
|
|
||||||
char commandBuffer[3]; // Buffer for the incoming command: [command; pin; speed].
|
|
||||||
int commandPosition = 0; // The current position in the `commandBuffer`
|
|
||||||
|
|
||||||
/////////////////////////
|
|
||||||
|
|
||||||
void setup() {
|
|
||||||
// https://github.com/PaulStoffregen/TimerOne/blob/master/examples/FanSpeed/FanSpeed.pde
|
|
||||||
Timer1.initialize(40); // 40us == 25kHz
|
|
||||||
Timer3.initialize(40);
|
|
||||||
|
|
||||||
SET_PWM_HIGH(5);
|
|
||||||
SET_PWM_HIGH(9);
|
|
||||||
SET_PWM_HIGH(10);
|
|
||||||
SET_PWM_HIGH(11);
|
|
||||||
|
|
||||||
TACHO_PULSES_ATTACH_INT(0);
|
|
||||||
TACHO_PULSES_ATTACH_INT(1);
|
|
||||||
TACHO_PULSES_ATTACH_INT(2);
|
|
||||||
TACHO_PULSES_ATTACH_INT(3);
|
|
||||||
TACHO_PULSES_ATTACH_INT(7);
|
|
||||||
|
|
||||||
Serial.begin(115200);
|
|
||||||
}
|
|
||||||
|
|
||||||
void loop () {
|
|
||||||
|
|
||||||
Timer3.pwm(5, PWM_255_TO_1023(currentPWM5));
|
|
||||||
Timer1.pwm(9, PWM_255_TO_1023(currentPWM9));
|
|
||||||
Timer1.pwm(10, PWM_255_TO_1023(currentPWM10));
|
|
||||||
Timer1.pwm(11, PWM_255_TO_1023(currentPWM11));
|
|
||||||
|
|
||||||
// Measure RPM from tachometers:
|
|
||||||
TACHO_PULSES_RESET_CURRENT_BUCKET(0);
|
|
||||||
TACHO_PULSES_RESET_CURRENT_BUCKET(1);
|
|
||||||
TACHO_PULSES_RESET_CURRENT_BUCKET(2);
|
|
||||||
TACHO_PULSES_RESET_CURRENT_BUCKET(3);
|
|
||||||
TACHO_PULSES_RESET_CURRENT_BUCKET(7);
|
|
||||||
interrupts();
|
|
||||||
delay (MEASUREMENT_TIME_MS);
|
|
||||||
noInterrupts();
|
|
||||||
TACHO_PULSES_NEXT_BUCKET;
|
|
||||||
|
|
||||||
readSerialCommand();
|
|
||||||
|
|
||||||
// Print the status (in JSON):
|
|
||||||
|
|
||||||
Serial.print("{");
|
|
||||||
|
|
||||||
Serial.print("\"fan_inputs\": {");
|
|
||||||
PRINT_RPM_JSON(0);
|
|
||||||
Serial.print(",");
|
|
||||||
PRINT_RPM_JSON(1);
|
|
||||||
Serial.print(",");
|
|
||||||
PRINT_RPM_JSON(2);
|
|
||||||
Serial.print(",");
|
|
||||||
PRINT_RPM_JSON(3);
|
|
||||||
Serial.print(",");
|
|
||||||
PRINT_RPM_JSON(7);
|
|
||||||
Serial.print("}, ");
|
|
||||||
|
|
||||||
Serial.print("\"fan_pwm\": {");
|
|
||||||
PRINT_PWM_JSON(5);
|
|
||||||
Serial.print(",");
|
|
||||||
PRINT_PWM_JSON(9);
|
|
||||||
Serial.print(",");
|
|
||||||
PRINT_PWM_JSON(10);
|
|
||||||
Serial.print(",");
|
|
||||||
PRINT_PWM_JSON(11);
|
|
||||||
Serial.print("}");
|
|
||||||
|
|
||||||
Serial.print("}\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
void readSerialCommand() {
|
|
||||||
while (Serial.available()) {
|
|
||||||
char c = Serial.read();
|
|
||||||
if (commandPosition == 0 && c != setSpeedCommand) {
|
|
||||||
Serial.print("{\"error\": \"Unknown command ");
|
|
||||||
Serial.print(c, HEX);
|
|
||||||
Serial.print("\"}\n");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
commandBuffer[commandPosition] = c;
|
|
||||||
commandPosition++;
|
|
||||||
if (commandPosition >= 3) {
|
|
||||||
// The command buffer is now complete, process it:
|
|
||||||
processSerialCommand();
|
|
||||||
|
|
||||||
commandPosition = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void processSerialCommand() {
|
|
||||||
// assert (commandBuffer[0] == setSpeedCommand);
|
|
||||||
|
|
||||||
byte pwm = (byte)commandBuffer[2];
|
|
||||||
switch (commandBuffer[1]) {
|
|
||||||
case 5: SET_PWM(5, pwm); break;
|
|
||||||
case 9: SET_PWM(9, pwm); break;
|
|
||||||
case 10: SET_PWM(10, pwm); break;
|
|
||||||
case 11: SET_PWM(11, pwm); break;
|
|
||||||
default:
|
|
||||||
Serial.print("{\"error\": \"Unknown pin ");
|
|
||||||
Serial.print((int)commandBuffer[1], DEC);
|
|
||||||
Serial.print(" for the set speed command\"}\n");
|
|
||||||
}
|
|
||||||
}
|
|
File diff suppressed because one or more lines are too long
Before Width: | Height: | Size: 130 KiB |
3
debian/.gitignore
vendored
3
debian/.gitignore
vendored
@ -1,3 +0,0 @@
|
|||||||
afancontrol/
|
|
||||||
debhelper-build-stamp
|
|
||||||
files
|
|
94
debian/changelog
vendored
94
debian/changelog
vendored
@ -1,94 +0,0 @@
|
|||||||
afancontrol (3.1.0-1) unstable; urgency=medium
|
|
||||||
|
|
||||||
[ Juha Yrjölä ]
|
|
||||||
* Support glob expansion with fans (#9)
|
|
||||||
|
|
||||||
[ Kostya Esmukov ]
|
|
||||||
* Drop Python 3.6 support, add 3.11
|
|
||||||
|
|
||||||
-- Kostya Esmukov <kostya@esmukov.ru> Mon, 28 Nov 2022 00:11:43 +0200
|
|
||||||
|
|
||||||
afancontrol (3.0.0-2) unstable; urgency=medium
|
|
||||||
|
|
||||||
* Bump debhelper compat to 13
|
|
||||||
|
|
||||||
-- Kostya Esmukov <kostya@esmukov.ru> Mon, 02 Aug 2021 19:19:34 +0000
|
|
||||||
|
|
||||||
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
|
|
30
debian/control
vendored
30
debian/control
vendored
@ -1,30 +0,0 @@
|
|||||||
Source: afancontrol
|
|
||||||
Section: utils
|
|
||||||
Priority: optional
|
|
||||||
Maintainer: Kostya Esmukov <kostya@esmukov.ru>
|
|
||||||
Build-Depends: debhelper-compat (= 13),
|
|
||||||
dh-python,
|
|
||||||
python3-all,
|
|
||||||
python3-click,
|
|
||||||
python3-prometheus-client (>= 0.1.0),
|
|
||||||
python3-pytest,
|
|
||||||
python3-requests,
|
|
||||||
python3-serial,
|
|
||||||
python3-setuptools
|
|
||||||
Standards-Version: 3.9.8
|
|
||||||
Homepage: https://github.com/KostyaEsmukov/afancontrol
|
|
||||||
|
|
||||||
Package: afancontrol
|
|
||||||
Architecture: all
|
|
||||||
Depends: hddtemp,
|
|
||||||
lm-sensors,
|
|
||||||
python3-click,
|
|
||||||
python3-pkg-resources,
|
|
||||||
python3-prometheus-client (>= 0.1.0),
|
|
||||||
python3-serial,
|
|
||||||
${misc:Depends},
|
|
||||||
${python3:Depends}
|
|
||||||
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.
|
|
33
debian/copyright
vendored
33
debian/copyright
vendored
@ -1,33 +0,0 @@
|
|||||||
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
2
debian/install
vendored
@ -1,2 +0,0 @@
|
|||||||
pkg/afancontrol.conf /etc/afancontrol
|
|
||||||
pkg/afancontrol.service /lib/systemd/system
|
|
15
debian/patches/remove-setup-py-data-files.patch
vendored
15
debian/patches/remove-setup-py-data-files.patch
vendored
@ -1,15 +0,0 @@
|
|||||||
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
1
debian/patches/series
vendored
@ -1 +0,0 @@
|
|||||||
remove-setup-py-data-files.patch
|
|
12
debian/rules
vendored
12
debian/rules
vendored
@ -1,12 +0,0 @@
|
|||||||
#!/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 python3 --buildsystem=pybuild
|
|
1
debian/source/format
vendored
1
debian/source/format
vendored
@ -1 +0,0 @@
|
|||||||
3.0 (quilt)
|
|
1
debian/source/options
vendored
1
debian/source/options
vendored
@ -1 +0,0 @@
|
|||||||
extend-diff-ignore = "^[^/]*[.]egg-info/"
|
|
3
debian/watch
vendored
3
debian/watch
vendored
@ -1,3 +0,0 @@
|
|||||||
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)))
|
|
@ -1,20 +0,0 @@
|
|||||||
# 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
1
docs/_static/arctic_motherboard.svg
vendored
File diff suppressed because one or more lines are too long
Before Width: | Height: | Size: 57 KiB |
10
docs/_static/custom.css
vendored
10
docs/_static/custom.css
vendored
@ -1,10 +0,0 @@
|
|||||||
|
|
||||||
/* 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
1
docs/_static/micro_schematics.svg
vendored
@ -1 +0,0 @@
|
|||||||
../../arduino/micro_schematics.svg
|
|
1
docs/_static/noctua_arduino.svg
vendored
1
docs/_static/noctua_arduino.svg
vendored
File diff suppressed because one or more lines are too long
Before Width: | Height: | Size: 55 KiB |
1
docs/_static/noctua_motherboard.svg
vendored
1
docs/_static/noctua_motherboard.svg
vendored
File diff suppressed because one or more lines are too long
Before Width: | Height: | Size: 58 KiB |
170
docs/_static/pc_case_example.svg
vendored
170
docs/_static/pc_case_example.svg
vendored
File diff suppressed because one or more lines are too long
Before Width: | Height: | Size: 150 KiB |
79
docs/conf.py
79
docs/conf.py
@ -1,79 +0,0 @@
|
|||||||
# 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
511
docs/index.rst
@ -1,511 +0,0 @@
|
|||||||
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
|
|
@ -1,269 +0,0 @@
|
|||||||
[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
|
|
@ -1,12 +0,0 @@
|
|||||||
[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
|
|
104
setup.cfg
104
setup.cfg
@ -1,104 +0,0 @@
|
|||||||
[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]
|
|
||||||
multi_line_output = 3
|
|
||||||
profile = black
|
|
||||||
|
|
||||||
[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
|
|
||||||
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.7
|
|
||||||
|
|
||||||
[options.entry_points]
|
|
||||||
console_scripts =
|
|
||||||
afancontrol = afancontrol.__main__:main
|
|
||||||
|
|
||||||
[options.extras_require]
|
|
||||||
arduino =
|
|
||||||
pyserial>=3.0
|
|
||||||
metrics =
|
|
||||||
prometheus-client>=0.1.0
|
|
||||||
dev =
|
|
||||||
black==22.10.0
|
|
||||||
coverage==6.5.0
|
|
||||||
flake8==5.0.4
|
|
||||||
isort==5.10.1
|
|
||||||
mypy==0.990
|
|
||||||
pytest==7.2.0
|
|
||||||
sphinx==4.3.2
|
|
||||||
vcrpy==4.2.1
|
|
||||||
requests
|
|
||||||
types-requests
|
|
||||||
|
|
||||||
[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
16
setup.py
@ -1,16 +0,0 @@
|
|||||||
#!/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 +0,0 @@
|
|||||||
__version__ = "3.1.0"
|
|
@ -1,21 +0,0 @@
|
|||||||
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")
|
|
@ -1,308 +0,0 @@
|
|||||||
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
|
|
@ -1,389 +0,0 @@
|
|||||||
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
|
|
@ -1,139 +0,0 @@
|
|||||||
import configparser
|
|
||||||
import glob
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
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,))
|
|
@ -1,162 +0,0 @@
|
|||||||
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
|
|
@ -1,50 +0,0 @@
|
|||||||
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
|
|
@ -1,150 +0,0 @@
|
|||||||
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)
|
|
@ -1,336 +0,0 @@
|
|||||||
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, # type: ignore
|
|
||||||
)
|
|
||||||
|
|
||||||
# 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, # type: ignore
|
|
||||||
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, # type: ignore
|
|
||||||
)
|
|
||||||
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, # type: ignore
|
|
||||||
)
|
|
||||||
|
|
||||||
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 "")
|
|
@ -1,127 +0,0 @@
|
|||||||
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
|
|
@ -1,3 +0,0 @@
|
|||||||
import logging
|
|
||||||
|
|
||||||
logger = logging.getLogger("afancontrol")
|
|
@ -1,116 +0,0 @@
|
|||||||
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
|
|
@ -1,392 +0,0 @@
|
|||||||
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
|
|
@ -1,131 +0,0 @@
|
|||||||
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
|
|
||||||
)
|
|
@ -1,133 +0,0 @@
|
|||||||
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)
|
|
@ -1,88 +0,0 @@
|
|||||||
import abc
|
|
||||||
from typing import NewType, Tuple, Type
|
|
||||||
|
|
||||||
PWMValue = NewType("PWMValue", int) # [0..255]
|
|
||||||
FanValue = NewType("FanValue", int)
|
|
||||||
|
|
||||||
|
|
||||||
class _SlotsReprMixin:
|
|
||||||
__slots__: Tuple[str, ...]
|
|
||||||
|
|
||||||
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
|
|
@ -1,49 +0,0 @@
|
|||||||
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)
|
|
@ -1,91 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
from typing import NewType
|
|
||||||
|
|
||||||
from afancontrol.configparser import ConfigParserSection, expand_glob
|
|
||||||
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(expand_glob(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(expand_glob(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:
|
|
||||||
base = expand_glob(pwm)
|
|
||||||
self._pwm = Path(base)
|
|
||||||
self._pwm_enable = Path(base + "_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)
|
|
@ -1,234 +0,0 @@
|
|||||||
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
|
|
@ -1,17 +0,0 @@
|
|||||||
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)
|
|
@ -1,55 +0,0 @@
|
|||||||
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)
|
|
@ -1,44 +0,0 @@
|
|||||||
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
|
|
@ -1,76 +0,0 @@
|
|||||||
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
|
|
@ -1,99 +0,0 @@
|
|||||||
import re
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Optional, Tuple
|
|
||||||
|
|
||||||
from afancontrol.configparser import ConfigParserSection, expand_glob
|
|
||||||
from afancontrol.temp.base import Temp, TempCelsius
|
|
||||||
|
|
||||||
|
|
||||||
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)
|
|
@ -1,102 +0,0 @@
|
|||||||
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)
|
|
@ -1,77 +0,0 @@
|
|||||||
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)
|
|
@ -1,210 +0,0 @@
|
|||||||
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)
|
|
@ -1,38 +0,0 @@
|
|||||||
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
|
|
@ -1,53 +0,0 @@
|
|||||||
[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
|
|
@ -1,177 +0,0 @@
|
|||||||
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()
|
|
@ -1,41 +0,0 @@
|
|||||||
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()
|
|
@ -1,153 +0,0 @@
|
|||||||
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()
|
|
@ -1,46 +0,0 @@
|
|||||||
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,
|
|
||||||
)
|
|
@ -1,40 +0,0 @@
|
|||||||
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,
|
|
||||||
)
|
|
@ -1,88 +0,0 @@
|
|||||||
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,
|
|
||||||
)
|
|
@ -1,95 +0,0 @@
|
|||||||
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()
|
|
@ -1,498 +0,0 @@
|
|||||||
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'}"
|
|
@ -1,99 +0,0 @@
|
|||||||
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
|
|
||||||
from afancontrol.daemon import 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)
|
|
@ -1,31 +0,0 @@
|
|||||||
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)
|
|
@ -1,69 +0,0 @@
|
|||||||
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()]
|
|
@ -1,105 +0,0 @@
|
|||||||
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,
|
|
||||||
)
|
|
||||||
from afancontrol.fantest import fantest as main
|
|
||||||
from afancontrol.fantest import 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
|
|
@ -1,74 +0,0 @@
|
|||||||
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)
|
|
@ -1,252 +0,0 @@
|
|||||||
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))
|
|
||||||
)
|
|
@ -1,156 +0,0 @@
|
|||||||
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
|
|
@ -1,20 +0,0 @@
|
|||||||
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")
|
|
@ -1,180 +0,0 @@
|
|||||||
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
|
|
37
tox.ini
37
tox.ini
@ -1,37 +0,0 @@
|
|||||||
[tox]
|
|
||||||
envlist=py{37,38,39,310,311,312}{,-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
|
|
||||||
|
|
||||||
[gh-actions]
|
|
||||||
python =
|
|
||||||
3.7: py37
|
|
||||||
3.8: py38
|
|
||||||
3.9: py39
|
|
||||||
3.10: py310
|
|
||||||
3.11: py311
|
|
||||||
3.12: py312
|
|
||||||
|
|
||||||
[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