Imported Upstream version 3.0.0

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

0
tests/__init__.py Normal file
View File

38
tests/conftest.py Normal file
View File

@@ -0,0 +1,38 @@
import tempfile
from contextlib import contextmanager
from pathlib import Path
from unittest.mock import patch
import pytest
from afancontrol.exec import exec_shell_command
@pytest.fixture
def temp_path():
with tempfile.TemporaryDirectory() as tmpdirname:
yield Path(tmpdirname).resolve()
@pytest.fixture
def sense_exec_shell_command():
exec_shell_command_stdout = []
def sensed_exec_shell_command(*args, **kwargs):
exec_shell_command_stdout.append(exec_shell_command(*args, **kwargs))
return exec_shell_command_stdout[-1]
def get_stdout():
try:
return exec_shell_command_stdout[:]
finally:
exec_shell_command_stdout.clear()
@contextmanager
def _sense_exec_shell_command(module):
with patch.object(
module, "exec_shell_command", wraps=sensed_exec_shell_command
) as mock_exec_shell_command:
yield mock_exec_shell_command, get_stdout
return _sense_exec_shell_command

View File

@@ -0,0 +1,53 @@
[daemon]
pidfile = /run/afancontrol.pid
logfile = /var/log/afancontrol.log
interval = 5
exporter_listen_host = 127.0.0.1:8083
[actions]
[temp:mobo]
type = file
path = /sys/class/hwmon/hwmon0/device/temp1_input
min = 30
max = 40
[temp: hdds]
type = hdd
path = /dev/sd?
min = 35
max = 48
panic = 55
[fan: hdd]
pwm = /sys/class/hwmon/hwmon0/device/pwm2
fan_input = /sys/class/hwmon/hwmon0/device/fan2_input
pwm_line_start = 100
pwm_line_end = 240
never_stop = no
[fan:cpu]
pwm = /sys/class/hwmon/hwmon0/device/pwm1
fan_input = /sys/class/hwmon/hwmon0/device/fan1_input
pwm_line_start = 100
pwm_line_end = 240
never_stop = yes
[arduino: mymicro]
serial_url = /dev/ttyACM0
baudrate = 115200
status_ttl = 5
[fan: my_arduino_fan]
type = arduino
arduino_name = mymicro
pwm_pin = 9
tacho_pin = 3
[mapping:1]
fans = cpu, hdd*0.6, my_arduino_fan * 0.222
temps = mobo, hdds
[mapping:2]
fans = hdd
temps = hdds

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

View File

@@ -0,0 +1,177 @@
import json
import socket
import threading
import traceback
from contextlib import ExitStack
from time import sleep
from typing import Dict
import pytest
from afancontrol.arduino import (
ArduinoConnection,
ArduinoName,
ArduinoPin,
SetPWMCommand,
pyserial_available,
)
from afancontrol.pwmfan import (
ArduinoFanPWMRead,
ArduinoFanPWMWrite,
ArduinoFanSpeed,
PWMValue,
)
pytestmark = pytest.mark.skipif(
not pyserial_available, reason="pyserial is not installed"
)
class DummyArduino:
"""Emulate an Arduino board, i.e. the other side of the pyserial connection.
Slightly mimics the Arduino program `micro.ino`.
"""
def __init__(self) -> None:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind(("127.0.0.1", 0))
s.listen(1)
listening_port = s.getsockname()[1]
self.sock = s
self.pyserial_url = "socket://127.0.0.1:%s" % listening_port
self._lock = threading.Lock()
self._loop_iteration_complete = threading.Event()
self._first_loop_iteration_complete = threading.Event()
self._disconnected = threading.Event()
self._thread_error = threading.Event()
self._is_connected = False
self._inner_state_pwms = {"5": 255, "9": 255, "10": 255, "11": 255}
self._inner_state_speeds = {"0": 0, "1": 0, "2": 0, "3": 0, "7": 0}
def set_inner_state_pwms(self, pwms: Dict[str, int]) -> None:
with self._lock:
self._inner_state_pwms.update(pwms)
if self.is_connected:
self._loop_iteration_complete.clear()
assert self._loop_iteration_complete.wait(5) is True
def set_speeds(self, speeds: Dict[str, int]) -> None:
with self._lock:
self._inner_state_speeds.update(speeds)
if self.is_connected:
self._loop_iteration_complete.clear()
assert self._loop_iteration_complete.wait(5) is True
@property
def inner_state_pwms(self):
with self._lock:
copy = self._inner_state_pwms.copy()
return copy
@property
def is_connected(self):
with self._lock:
if not self._is_connected:
return False
assert self._first_loop_iteration_complete.wait(5) is True
return True
def wait_for_disconnected(self):
assert self._disconnected.wait(5) is True
def accept(self):
client, _ = self.sock.accept()
self.sock.close() # Don't accept any more connections
with self._lock:
self._is_connected = True
threading.Thread(target=self._thread_run, args=(client,), daemon=True).start()
def _thread_run(self, sock):
sock.settimeout(0.001)
command_buffer = bytearray()
try:
while True:
# The code in this loop mimics the `loop` function
# in the `micro.ino` program.
try:
command_buffer.extend(sock.recv(1024))
except socket.timeout:
pass
while len(command_buffer) >= 3:
command_raw = command_buffer[:3]
del command_buffer[:3]
command = SetPWMCommand.parse(command_raw)
with self._lock:
self._inner_state_pwms[str(command.pwm_pin)] = command.pwm
sock.sendall(self._make_status())
self._loop_iteration_complete.set()
self._first_loop_iteration_complete.set()
sleep(0.050)
except (ConnectionResetError, BrokenPipeError):
pass
except Exception:
traceback.print_exc()
self._thread_error.set()
finally:
with self._lock:
self._is_connected = False
sock.close()
self._disconnected.set()
def _make_status(self):
with self._lock:
status = {
"fan_inputs": self._inner_state_speeds,
"fan_pwm": self._inner_state_pwms,
}
return (json.dumps(status) + "\n").encode("ascii")
def ensure_no_errors_in_thread(self):
assert self._thread_error.is_set() is not True
@pytest.fixture
def dummy_arduino():
return DummyArduino()
def test_smoke(dummy_arduino):
conn = ArduinoConnection(ArduinoName("test"), dummy_arduino.pyserial_url)
fan_speed = ArduinoFanSpeed(conn, tacho_pin=ArduinoPin(3))
pwm_read = ArduinoFanPWMRead(conn, pwm_pin=ArduinoPin(9))
pwm_write = ArduinoFanPWMWrite(conn, pwm_pin=ArduinoPin(9))
dummy_arduino.set_inner_state_pwms({"9": 42})
with ExitStack() as stack:
assert not dummy_arduino.is_connected
stack.enter_context(fan_speed)
stack.enter_context(pwm_read)
stack.enter_context(pwm_write)
dummy_arduino.accept()
assert dummy_arduino.is_connected
dummy_arduino.set_speeds({"3": 1200})
conn.wait_for_status() # required only for synchronization in the tests
assert fan_speed.get_speed() == 1200
assert pwm_read.get() == 255
assert dummy_arduino.inner_state_pwms["9"] == 255
pwm_write.set(PWMValue(192))
dummy_arduino.set_speeds({"3": 998})
conn.wait_for_status() # required only for synchronization in the tests
assert fan_speed.get_speed() == 998
assert pwm_read.get() == 192
assert dummy_arduino.inner_state_pwms["9"] == 192
dummy_arduino.wait_for_disconnected()
assert dummy_arduino.inner_state_pwms["9"] == 255
assert not dummy_arduino.is_connected
dummy_arduino.ensure_no_errors_in_thread()

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

@@ -0,0 +1,41 @@
from unittest.mock import patch
import pytest
from afancontrol.pwmfan import FanValue, FreeIPMIFanSpeed
@pytest.fixture
def ipmi_sensors_output():
return """
ID,Name,Type,Reading,Units,Event
17,FAN1,Fan,1400.00,RPM,'OK'
18,FAN2,Fan,1800.00,RPM,'OK'
19,FAN3,Fan,N/A,RPM,N/A
20,FAN4,Fan,N/A,RPM,N/A
21,FAN5,Fan,N/A,RPM,N/A
22,FAN6,Fan,N/A,RPM,N/A
""".lstrip()
def test_fan_speed(ipmi_sensors_output):
fan_speed = FreeIPMIFanSpeed("FAN2")
with patch.object(FreeIPMIFanSpeed, "_call_ipmi_sensors") as mock_call:
mock_call.return_value = ipmi_sensors_output
assert fan_speed.get_speed() == FanValue(1800)
def test_fan_speed_na(ipmi_sensors_output):
fan_speed = FreeIPMIFanSpeed("FAN3")
with patch.object(FreeIPMIFanSpeed, "_call_ipmi_sensors") as mock_call:
mock_call.return_value = ipmi_sensors_output
with pytest.raises(ValueError):
fan_speed.get_speed()
def test_fan_speed_unknown(ipmi_sensors_output):
fan_speed = FreeIPMIFanSpeed("FAN30")
with patch.object(FreeIPMIFanSpeed, "_call_ipmi_sensors") as mock_call:
mock_call.return_value = ipmi_sensors_output
with pytest.raises(RuntimeError):
fan_speed.get_speed()

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

@@ -0,0 +1,153 @@
from contextlib import ExitStack
from unittest.mock import MagicMock
import pytest
from afancontrol.pwmfan import (
FanInputDevice,
LinuxFanPWMRead,
LinuxFanPWMWrite,
LinuxFanSpeed,
PWMDevice,
PWMValue,
)
from afancontrol.pwmfannorm import PWMFanNorm
@pytest.fixture
def pwm_path(temp_path):
# pwm = /sys/class/hwmon/hwmon0/pwm2
pwm_path = temp_path / "pwm2"
pwm_path.write_text("0\n")
return pwm_path
@pytest.fixture
def pwm_enable_path(temp_path):
pwm_enable_path = temp_path / "pwm2_enable"
pwm_enable_path.write_text("0\n")
return pwm_enable_path
@pytest.fixture
def fan_input_path(temp_path):
# fan_input = /sys/class/hwmon/hwmon0/fan2_input
fan_input_path = temp_path / "fan2_input"
fan_input_path.write_text("1300\n")
return fan_input_path
@pytest.fixture
def fan_speed(fan_input_path):
return LinuxFanSpeed(fan_input=FanInputDevice(str(fan_input_path)))
@pytest.fixture
def pwm_read(pwm_path):
return LinuxFanPWMRead(pwm=PWMDevice(str(pwm_path)))
@pytest.fixture
def pwm_write(pwm_path):
pwm_write = LinuxFanPWMWrite(pwm=PWMDevice(str(pwm_path)))
# We write to the pwm_enable file values without newlines,
# but when they're read back, they might contain newlines.
# This hack below is to simulate just that: the written values should
# contain newlines.
original_pwm_enable = pwm_write._pwm_enable
pwm_enable = MagicMock(wraps=original_pwm_enable)
pwm_enable.write_text = lambda text: original_pwm_enable.write_text(text + "\n")
pwm_write._pwm_enable = pwm_enable
return pwm_write
@pytest.fixture
def pwmfan_norm(fan_speed, pwm_read, pwm_write):
return PWMFanNorm(
fan_speed,
pwm_read,
pwm_write,
pwm_line_start=PWMValue(100),
pwm_line_end=PWMValue(240),
never_stop=False,
)
@pytest.mark.parametrize("pwmfan_fixture", ["fan_speed", "pwmfan_norm"])
def test_get_speed(pwmfan_fixture, fan_speed, pwmfan_norm, fan_input_path):
fan = locals()[pwmfan_fixture]
fan_input_path.write_text("721\n")
assert 721 == fan.get_speed()
@pytest.mark.parametrize("pwmfan_fixture", ["pwm_write", "pwmfan_norm"])
@pytest.mark.parametrize("raises", [True, False])
def test_enter_exit(
raises, pwmfan_fixture, pwm_write, pwmfan_norm, pwm_enable_path, pwm_path
):
fan = locals()[pwmfan_fixture]
class Exc(Exception):
pass
with ExitStack() as stack:
if raises:
stack.enter_context(pytest.raises(Exc))
stack.enter_context(fan)
assert "1" == pwm_enable_path.read_text().strip()
assert "255" == pwm_path.read_text()
value = dict(pwm_write=100, pwmfan_norm=0.39)[pwmfan_fixture] # 100/255 ~= 0.39
fan.set(value)
assert "1" == pwm_enable_path.read_text().strip()
assert "100" == pwm_path.read_text()
if raises:
raise Exc()
assert "0" == pwm_enable_path.read_text().strip()
assert "100" == pwm_path.read_text() # `fancontrol` doesn't reset speed
def test_get_set_pwmfan(pwm_read, pwm_write, pwm_path):
pwm_write.set(142)
assert "142" == pwm_path.read_text()
pwm_path.write_text("132\n")
assert 132 == pwm_read.get()
pwm_write.set_full_speed()
assert "255" == pwm_path.read_text()
with pytest.raises(ValueError):
pwm_write.set(256)
with pytest.raises(ValueError):
pwm_write.set(-1)
def test_get_set_pwmfan_norm(pwmfan_norm, pwm_path):
pwmfan_norm.set(0.42)
assert "101" == pwm_path.read_text()
pwm_path.write_text("132\n")
assert pytest.approx(0.517, 0.01) == pwmfan_norm.get()
pwmfan_norm.set_full_speed()
assert "255" == pwm_path.read_text()
assert 238 == pwmfan_norm.set(0.99)
assert "238" == pwm_path.read_text()
assert 255 == pwmfan_norm.set(1.0)
assert "255" == pwm_path.read_text()
assert 255 == pwmfan_norm.set(1.1)
assert "255" == pwm_path.read_text()
assert 0 == pwmfan_norm.set(-0.1)
assert "0" == pwm_path.read_text()

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

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

@@ -0,0 +1,46 @@
from typing import Optional
from unittest.mock import patch
import pytest
from afancontrol.temp import Temp, TempCelsius, TempStatus
class DummyTemp(Temp):
def _get_temp(self):
pass
@pytest.mark.parametrize(
"temp, threshold, panic, is_threshold, is_panic",
[
(34.0, None, 60.0, False, False),
(42.0, None, 60.0, False, False),
(57.0, 55.0, 60.0, True, False),
(61.0, 55.0, 61.0, True, True),
(61.0, None, 61.0, False, True),
],
)
def test_temp(
temp: TempCelsius,
threshold: Optional[TempCelsius],
panic: TempCelsius,
is_threshold,
is_panic,
):
min = TempCelsius(40.0)
max = TempCelsius(50.0)
with patch.object(DummyTemp, "_get_temp") as mock_get_temp:
t = DummyTemp(panic=panic, threshold=threshold)
mock_get_temp.return_value = [temp, min, max]
assert t.get() == TempStatus(
temp=temp,
min=min,
max=max,
panic=panic,
threshold=threshold,
is_panic=is_panic,
is_threshold=is_threshold,
)

View File

@@ -0,0 +1,40 @@
from afancontrol.temp import CommandTemp, TempCelsius, TempStatus
def test_command_temp_with_minmax():
t = CommandTemp(
shell_command=r"printf '%s\n' 35 30 40",
min=TempCelsius(31.0),
max=TempCelsius(39.0),
panic=TempCelsius(50.0),
threshold=None,
)
assert t.get() == TempStatus(
temp=TempCelsius(35.0),
min=TempCelsius(31.0),
max=TempCelsius(39.0),
panic=TempCelsius(50.0),
threshold=None,
is_panic=False,
is_threshold=False,
)
print(repr(t))
def test_command_temp_without_minmax():
t = CommandTemp(
shell_command=r"printf '%s\n' 35 30 40",
min=None,
max=None,
panic=TempCelsius(50.0),
threshold=None,
)
assert t.get() == TempStatus(
temp=TempCelsius(35.0),
min=TempCelsius(30.0),
max=TempCelsius(40.0),
panic=TempCelsius(50.0),
threshold=None,
is_panic=False,
is_threshold=False,
)

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

@@ -0,0 +1,88 @@
import pytest
from afancontrol.temp import FileTemp, TempCelsius, TempStatus
@pytest.fixture
def file_temp_path(temp_path):
# /sys/class/hwmon/hwmon0/temp1_input
temp_input_path = temp_path / "temp1_input"
temp_input_path.write_text("34000\n")
temp_max_path = temp_path / "temp1_max"
temp_max_path.write_text("127000\n")
temp_min_path = temp_path / "temp1_min"
# My mobo actually returns this as min:
temp_min_path.write_text("127000\n")
return temp_input_path
def test_file_temp_min_max_numbers(file_temp_path):
temp = FileTemp(
temp_path=str(file_temp_path),
min=TempCelsius(40.0),
max=TempCelsius(50.0),
panic=TempCelsius(60.0),
threshold=None,
)
assert temp.get() == TempStatus(
temp=TempCelsius(34.0),
min=TempCelsius(40.0),
max=TempCelsius(50.0),
panic=TempCelsius(60.0),
threshold=None,
is_panic=False,
is_threshold=False,
)
print(repr(temp))
def test_file_temp_glob(file_temp_path):
temp = FileTemp(
temp_path=str(file_temp_path).replace("/temp1", "/temp?"),
min=TempCelsius(40.0),
max=None,
panic=None,
threshold=None,
)
assert temp.get() == TempStatus(
temp=TempCelsius(34.0),
min=TempCelsius(40.0),
max=TempCelsius(127.0),
panic=None,
threshold=None,
is_panic=False,
is_threshold=False,
)
print(repr(temp))
def test_file_temp_min_max_files(temp_path, file_temp_path):
with pytest.raises(RuntimeError):
# min == max is an error
FileTemp(
temp_path=str(file_temp_path),
min=None,
max=None,
panic=TempCelsius(60.0),
threshold=None,
).get()
temp = FileTemp(
temp_path=str(file_temp_path),
min=TempCelsius(50.0),
max=None,
panic=TempCelsius(60.0),
threshold=None,
)
assert temp.get() == TempStatus(
temp=TempCelsius(34.0),
min=TempCelsius(50.0),
max=TempCelsius(127.0),
panic=TempCelsius(60.0),
threshold=None,
is_panic=False,
is_threshold=False,
)

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

@@ -0,0 +1,95 @@
import subprocess
from unittest.mock import patch
import pytest
from afancontrol.temp import HDDTemp, TempCelsius, TempStatus
@pytest.fixture
def hddtemp_output_many():
return (
"/dev/sda: Adaptec XXXXX: drive supported,"
" but it doesn't have a temperature sensor.\n"
"/dev/sdb: Adaptec XXXXX: drive supported,"
" but it doesn't have a temperature sensor.\n"
"38\n"
"39\n"
"30\n"
"36\n"
)
@pytest.fixture
def hddtemp_output_bad():
return (
"/dev/sda: Adaptec XXXXX: drive supported,"
" but it doesn't have a temperature sensor.\n"
)
def test_hddtemp_many(hddtemp_output_many):
with patch.object(HDDTemp, "_call_hddtemp") as mock_call_hddtemp:
mock_call_hddtemp.return_value = hddtemp_output_many
t = HDDTemp(
disk_path="/dev/sd?",
min=TempCelsius(38.0),
max=TempCelsius(45.0),
panic=TempCelsius(50.0),
threshold=None,
hddtemp_bin="testbin",
)
assert t.get() == TempStatus(
temp=TempCelsius(39.0),
min=TempCelsius(38.0),
max=TempCelsius(45.0),
panic=TempCelsius(50.0),
threshold=None,
is_panic=False,
is_threshold=False,
)
print(repr(t))
def test_hddtemp_bad(hddtemp_output_bad):
with patch.object(HDDTemp, "_call_hddtemp") as mock_call_hddtemp:
mock_call_hddtemp.return_value = hddtemp_output_bad
t = HDDTemp(
disk_path="/dev/sda",
min=TempCelsius(38.0),
max=TempCelsius(45.0),
panic=TempCelsius(50.0),
threshold=None,
hddtemp_bin="testbin",
)
with pytest.raises(RuntimeError):
t.get()
def test_hddtemp_exec_successful(temp_path):
(temp_path / "sda").write_text("")
(temp_path / "sdz").write_text("")
t = HDDTemp(
disk_path=str(temp_path / "sd") + "?",
min=TempCelsius(38.0),
max=TempCelsius(45.0),
panic=TempCelsius(50.0),
threshold=None,
hddtemp_bin="printf '@%s'",
)
expected_out = "@-n@-u@C@--@{0}/sda@{0}/sdz".format(temp_path)
assert expected_out == t._call_hddtemp()
def test_hddtemp_exec_failed():
t = HDDTemp(
disk_path="/dev/sd?",
min=TempCelsius(38.0),
max=TempCelsius(45.0),
panic=TempCelsius(50.0),
threshold=None,
hddtemp_bin="false",
)
with pytest.raises(subprocess.CalledProcessError):
t._call_hddtemp()

498
tests/test_config.py Normal file
View File

@@ -0,0 +1,498 @@
from pathlib import Path
from unittest.mock import Mock
import pytest
from afancontrol.arduino import (
ArduinoConnection,
ArduinoName,
ArduinoPin,
pyserial_available,
)
from afancontrol.config import (
Actions,
AlertCommands,
DaemonCLIConfig,
DaemonConfig,
FanName,
FanSpeedModifier,
FansTempsRelation,
FilteredTemp,
MappingName,
ParsedConfig,
ReadonlyFanName,
TempName,
TriggerConfig,
parse_config,
)
from afancontrol.filters import MovingMedianFilter, NullFilter
from afancontrol.pwmfan import (
ArduinoFanPWMRead,
ArduinoFanPWMWrite,
ArduinoFanSpeed,
FanInputDevice,
LinuxFanPWMRead,
LinuxFanPWMWrite,
LinuxFanSpeed,
PWMDevice,
PWMValue,
)
from afancontrol.pwmfannorm import PWMFanNorm, ReadonlyPWMFanNorm
from afancontrol.temp import FileTemp, HDDTemp, TempCelsius
@pytest.fixture
def pkg_conf():
return Path(__file__).parents[1] / "pkg" / "afancontrol.conf"
@pytest.fixture
def example_conf():
return Path(__file__).parents[0] / "data" / "afancontrol-example.conf"
def path_from_str(contents: str) -> Path:
p = Mock(spec=Path)
p.read_text.return_value = contents
return p
@pytest.mark.skipif(not pyserial_available, reason="pyserial is not installed")
def test_pkg_conf(pkg_conf: Path):
daemon_cli_config = DaemonCLIConfig(
pidfile=None, logfile=None, exporter_listen_host=None
)
parsed = parse_config(pkg_conf, daemon_cli_config)
assert parsed == ParsedConfig(
arduino_connections={},
daemon=DaemonConfig(
pidfile="/run/afancontrol.pid",
logfile="/var/log/afancontrol.log",
interval=5,
exporter_listen_host=None,
),
report_cmd=(
'printf "Subject: %s\nTo: %s\n\n%b" '
'"afancontrol daemon report: %REASON%" root "%MESSAGE%" | sendmail -t'
),
triggers=TriggerConfig(
global_commands=Actions(
panic=AlertCommands(enter_cmd=None, leave_cmd=None),
threshold=AlertCommands(enter_cmd=None, leave_cmd=None),
),
temp_commands={
TempName("mobo"): Actions(
panic=AlertCommands(enter_cmd=None, leave_cmd=None),
threshold=AlertCommands(enter_cmd=None, leave_cmd=None),
)
},
),
fans={
FanName("hdd"): PWMFanNorm(
fan_speed=LinuxFanSpeed(
FanInputDevice("/sys/class/hwmon/hwmon0/device/fan2_input")
),
pwm_read=LinuxFanPWMRead(
PWMDevice("/sys/class/hwmon/hwmon0/device/pwm2")
),
pwm_write=LinuxFanPWMWrite(
PWMDevice("/sys/class/hwmon/hwmon0/device/pwm2")
),
pwm_line_start=PWMValue(100),
pwm_line_end=PWMValue(240),
never_stop=False,
)
},
readonly_fans={
ReadonlyFanName("cpu"): ReadonlyPWMFanNorm(
fan_speed=LinuxFanSpeed(
FanInputDevice("/sys/class/hwmon/hwmon0/device/fan1_input")
),
),
},
temps={
TempName("mobo"): FilteredTemp(
temp=FileTemp(
"/sys/class/hwmon/hwmon0/device/temp1_input",
min=TempCelsius(30.0),
max=TempCelsius(40.0),
panic=None,
threshold=None,
),
filter=MovingMedianFilter(window_size=3),
)
},
mappings={
MappingName("1"): FansTempsRelation(
temps=[TempName("mobo")],
fans=[FanSpeedModifier(fan=FanName("hdd"), modifier=0.6)],
)
},
)
@pytest.mark.skipif(not pyserial_available, reason="pyserial is not installed")
def test_example_conf(example_conf: Path):
daemon_cli_config = DaemonCLIConfig(
pidfile=None, logfile=None, exporter_listen_host=None
)
parsed = parse_config(example_conf, daemon_cli_config)
assert parsed == ParsedConfig(
arduino_connections={
ArduinoName("mymicro"): ArduinoConnection(
ArduinoName("mymicro"), "/dev/ttyACM0", baudrate=115200, status_ttl=5
)
},
daemon=DaemonConfig(
pidfile="/run/afancontrol.pid",
logfile="/var/log/afancontrol.log",
exporter_listen_host="127.0.0.1:8083",
interval=5,
),
report_cmd=(
'printf "Subject: %s\nTo: %s\n\n%b" '
'"afancontrol daemon report: %REASON%" root "%MESSAGE%" | sendmail -t'
),
triggers=TriggerConfig(
global_commands=Actions(
panic=AlertCommands(enter_cmd=None, leave_cmd=None),
threshold=AlertCommands(enter_cmd=None, leave_cmd=None),
),
temp_commands={
TempName("hdds"): Actions(
panic=AlertCommands(enter_cmd=None, leave_cmd=None),
threshold=AlertCommands(enter_cmd=None, leave_cmd=None),
),
TempName("mobo"): Actions(
panic=AlertCommands(enter_cmd=None, leave_cmd=None),
threshold=AlertCommands(enter_cmd=None, leave_cmd=None),
),
},
),
fans={
FanName("cpu"): PWMFanNorm(
fan_speed=LinuxFanSpeed(
FanInputDevice("/sys/class/hwmon/hwmon0/device/fan1_input")
),
pwm_read=LinuxFanPWMRead(
PWMDevice("/sys/class/hwmon/hwmon0/device/pwm1")
),
pwm_write=LinuxFanPWMWrite(
PWMDevice("/sys/class/hwmon/hwmon0/device/pwm1")
),
pwm_line_start=PWMValue(100),
pwm_line_end=PWMValue(240),
never_stop=True,
),
FanName("hdd"): PWMFanNorm(
fan_speed=LinuxFanSpeed(
FanInputDevice("/sys/class/hwmon/hwmon0/device/fan2_input")
),
pwm_read=LinuxFanPWMRead(
PWMDevice("/sys/class/hwmon/hwmon0/device/pwm2")
),
pwm_write=LinuxFanPWMWrite(
PWMDevice("/sys/class/hwmon/hwmon0/device/pwm2")
),
pwm_line_start=PWMValue(100),
pwm_line_end=PWMValue(240),
never_stop=False,
),
FanName("my_arduino_fan"): PWMFanNorm(
fan_speed=ArduinoFanSpeed(
ArduinoConnection(
ArduinoName("mymicro"),
"/dev/ttyACM0", # linux
# "/dev/cu.usbmodem14201", # macos
baudrate=115200,
status_ttl=5,
),
tacho_pin=ArduinoPin(3),
),
pwm_read=ArduinoFanPWMRead(
ArduinoConnection(
ArduinoName("mymicro"),
"/dev/ttyACM0", # linux
# "/dev/cu.usbmodem14201", # macos
baudrate=115200,
status_ttl=5,
),
pwm_pin=ArduinoPin(9),
),
pwm_write=ArduinoFanPWMWrite(
ArduinoConnection(
ArduinoName("mymicro"),
"/dev/ttyACM0", # linux
# "/dev/cu.usbmodem14201", # macos
baudrate=115200,
status_ttl=5,
),
pwm_pin=ArduinoPin(9),
),
pwm_line_start=PWMValue(100),
pwm_line_end=PWMValue(240),
never_stop=True,
),
},
readonly_fans={},
temps={
TempName("hdds"): FilteredTemp(
temp=HDDTemp(
"/dev/sd?",
min=TempCelsius(35.0),
max=TempCelsius(48.0),
panic=TempCelsius(55.0),
threshold=None,
hddtemp_bin="hddtemp",
),
filter=NullFilter(),
),
TempName("mobo"): FilteredTemp(
temp=FileTemp(
"/sys/class/hwmon/hwmon0/device/temp1_input",
min=TempCelsius(30.0),
max=TempCelsius(40.0),
panic=None,
threshold=None,
),
filter=NullFilter(),
),
},
mappings={
MappingName("1"): FansTempsRelation(
temps=[TempName("mobo"), TempName("hdds")],
fans=[
FanSpeedModifier(fan=FanName("cpu"), modifier=1.0),
FanSpeedModifier(fan=FanName("hdd"), modifier=0.6),
FanSpeedModifier(fan=FanName("my_arduino_fan"), modifier=0.222),
],
),
MappingName("2"): FansTempsRelation(
temps=[TempName("hdds")],
fans=[FanSpeedModifier(fan=FanName("hdd"), modifier=1.0)],
),
},
)
def test_minimal_config() -> None:
daemon_cli_config = DaemonCLIConfig(
pidfile=None, logfile=None, exporter_listen_host=None
)
config = """
[daemon]
[actions]
[temp:mobo]
type = file
path = /sys/class/hwmon/hwmon0/device/temp1_input
[fan: case]
pwm = /sys/class/hwmon/hwmon0/device/pwm2
fan_input = /sys/class/hwmon/hwmon0/device/fan2_input
[mapping:1]
fans = case*0.6,
temps = mobo
"""
parsed = parse_config(path_from_str(config), daemon_cli_config)
assert parsed == ParsedConfig(
arduino_connections={},
daemon=DaemonConfig(
pidfile="/run/afancontrol.pid",
logfile=None,
exporter_listen_host=None,
interval=5,
),
report_cmd=(
'printf "Subject: %s\nTo: %s\n\n%b" '
'"afancontrol daemon report: %REASON%" root "%MESSAGE%" | sendmail -t'
),
triggers=TriggerConfig(
global_commands=Actions(
panic=AlertCommands(enter_cmd=None, leave_cmd=None),
threshold=AlertCommands(enter_cmd=None, leave_cmd=None),
),
temp_commands={
TempName("mobo"): Actions(
panic=AlertCommands(enter_cmd=None, leave_cmd=None),
threshold=AlertCommands(enter_cmd=None, leave_cmd=None),
)
},
),
fans={
FanName("case"): PWMFanNorm(
fan_speed=LinuxFanSpeed(
FanInputDevice("/sys/class/hwmon/hwmon0/device/fan2_input")
),
pwm_read=LinuxFanPWMRead(
PWMDevice("/sys/class/hwmon/hwmon0/device/pwm2")
),
pwm_write=LinuxFanPWMWrite(
PWMDevice("/sys/class/hwmon/hwmon0/device/pwm2")
),
pwm_line_start=PWMValue(100),
pwm_line_end=PWMValue(240),
never_stop=True,
)
},
readonly_fans={},
temps={
TempName("mobo"): FilteredTemp(
temp=FileTemp(
"/sys/class/hwmon/hwmon0/device/temp1_input",
min=None,
max=None,
panic=None,
threshold=None,
),
filter=NullFilter(),
)
},
mappings={
MappingName("1"): FansTempsRelation(
temps=[TempName("mobo")],
fans=[FanSpeedModifier(fan=FanName("case"), modifier=0.6)],
)
},
)
def test_readonly_config() -> None:
daemon_cli_config = DaemonCLIConfig(
pidfile=None, logfile=None, exporter_listen_host=None
)
config = """
[daemon]
[actions]
[temp:mobo]
type = file
path = /sys/class/hwmon/hwmon0/device/temp1_input
[readonly_fan: cpu]
pwm = /sys/class/hwmon/hwmon0/device/pwm1
fan_input = /sys/class/hwmon/hwmon0/device/fan1_input
"""
parsed = parse_config(path_from_str(config), daemon_cli_config)
assert parsed == ParsedConfig(
arduino_connections={},
daemon=DaemonConfig(
pidfile="/run/afancontrol.pid",
logfile=None,
exporter_listen_host=None,
interval=5,
),
report_cmd=(
'printf "Subject: %s\nTo: %s\n\n%b" '
'"afancontrol daemon report: %REASON%" root "%MESSAGE%" | sendmail -t'
),
triggers=TriggerConfig(
global_commands=Actions(
panic=AlertCommands(enter_cmd=None, leave_cmd=None),
threshold=AlertCommands(enter_cmd=None, leave_cmd=None),
),
temp_commands={
TempName("mobo"): Actions(
panic=AlertCommands(enter_cmd=None, leave_cmd=None),
threshold=AlertCommands(enter_cmd=None, leave_cmd=None),
)
},
),
fans={},
readonly_fans={
ReadonlyFanName("cpu"): ReadonlyPWMFanNorm(
fan_speed=LinuxFanSpeed(
FanInputDevice("/sys/class/hwmon/hwmon0/device/fan1_input")
),
pwm_read=LinuxFanPWMRead(
PWMDevice("/sys/class/hwmon/hwmon0/device/pwm1")
),
)
},
temps={
TempName("mobo"): FilteredTemp(
temp=FileTemp(
"/sys/class/hwmon/hwmon0/device/temp1_input",
min=None,
max=None,
panic=None,
threshold=None,
),
filter=NullFilter(),
)
},
mappings={},
)
def test_multiline_mapping():
daemon_cli_config = DaemonCLIConfig(
pidfile=None, logfile=None, exporter_listen_host=None
)
config = """
[daemon]
[actions]
[temp:cpu]
type = file
path = /sys/class/hwmon/hwmon0/device/temp1_input
[temp:mobo]
type = file
path = /sys/class/hwmon/hwmon0/device/temp2_input
[fan: case]
pwm = /sys/class/hwmon/hwmon0/device/pwm2
fan_input = /sys/class/hwmon/hwmon0/device/fan2_input
[fan: hdd]
pwm = /sys/class/hwmon/hwmon0/device/pwm2
fan_input = /sys/class/hwmon/hwmon0/device/fan2_input
[mapping:1]
fans =
case*0.6,
hdd,
temps =
mobo,
cpu
"""
parsed = parse_config(path_from_str(config), daemon_cli_config)
assert parsed.mappings == {
MappingName("1"): FansTempsRelation(
temps=[TempName("mobo"), TempName("cpu")],
fans=[
FanSpeedModifier(fan=FanName("case"), modifier=0.6),
FanSpeedModifier(fan=FanName("hdd"), modifier=1.0),
],
)
}
def test_extraneous_keys_raises():
daemon_cli_config = DaemonCLIConfig(
pidfile=None, logfile=None, exporter_listen_host=None
)
config = """
[daemon]
[actions]
[temp: mobo]
type = file
path = /sys/class/hwmon/hwmon0/device/temp1_input
aa = 55
"""
with pytest.raises(RuntimeError) as cm:
parse_config(path_from_str(config), daemon_cli_config)
assert str(cm.value) == "Unknown options in the [temp: mobo] section: {'aa'}"

98
tests/test_daemon.py Normal file
View File

@@ -0,0 +1,98 @@
import threading
from contextlib import ExitStack
from unittest.mock import patch
import pytest
from click.testing import CliRunner
from afancontrol import daemon
from afancontrol.daemon import PidFile, Signals, daemon as main
def test_main_smoke(temp_path):
pwm_path = temp_path / "pwm" / "pwm2"
pwm_enable_path = temp_path / "pwm" / "pwm2_enable"
pwm_faninput_path = temp_path / "pwm" / "fan2_input"
pwm_path.parents[0].mkdir(parents=True)
pwm_path.write_text("100")
pwm_enable_path.write_text("0")
pwm_faninput_path.write_text("999")
config_path = temp_path / "afancontrol.conf"
config_path.write_text(
"""
[daemon]
hddtemp = true
[actions]
[temp:mobo]
type = file
path = /fake/sys/class/hwmon/hwmon0/device/temp1_input
[fan: case]
pwm = %(pwm_path)s
fan_input = %(pwm_faninput_path)s
[mapping:1]
fans = case*0.6,
temps = mobo
"""
% dict(pwm_path=pwm_path, pwm_faninput_path=pwm_faninput_path)
)
with ExitStack() as stack:
mocked_tick = stack.enter_context(patch.object(daemon.Manager, "tick"))
stack.enter_context(patch.object(daemon, "signal"))
stack.enter_context(
patch.object(daemon.Signals, "wait_for_term_queued", return_value=True)
)
runner = CliRunner()
result = runner.invoke(
main,
[
"--verbose",
"--config",
str(config_path),
"--pidfile",
str(temp_path / "afancontrol.pid"),
"--logfile",
str(temp_path / "afancontrol.log"),
],
)
assert result.exit_code == 0
assert mocked_tick.call_count == 1
def test_pidfile_not_existing(temp_path):
pidpath = temp_path / "test.pid"
pidfile = PidFile(str(pidpath))
with pidfile:
pidfile.save_pid(42)
assert "42" == pidpath.read_text()
assert not pidpath.exists()
def test_pidfile_existing_raises(temp_path):
pidpath = temp_path / "test.pid"
pidfile = PidFile(str(pidpath))
pidpath.write_text("42")
with pytest.raises(RuntimeError):
with pidfile:
pytest.fail("Should not be reached")
assert pidpath.exists()
def test_signals():
s = Signals()
assert False is s.wait_for_term_queued(0.001)
threading.Timer(0.01, lambda: s.sigterm(None, None)).start()
assert True is s.wait_for_term_queued(1e6)

31
tests/test_exec.py Normal file
View File

@@ -0,0 +1,31 @@
import subprocess
import pytest
from afancontrol.exec import exec_shell_command
def test_exec_shell_command_successful():
assert "42\n" == exec_shell_command("echo 42")
def test_exec_shell_command_ignores_stderr():
assert "42\n" == exec_shell_command("echo 111 >&2; echo 42")
def test_exec_shell_command_erroneous():
with pytest.raises(subprocess.SubprocessError):
exec_shell_command("echo 42 && false")
def test_exec_shell_command_raises_for_unicode():
with pytest.raises(ValueError):
exec_shell_command("echo привет")
def test_exec_shell_command_expands_glob(temp_path):
(temp_path / "sda").write_text("")
(temp_path / "sdb").write_text("")
expected = "{0}/sda {0}/sdb\n".format(temp_path)
assert expected == exec_shell_command('echo "%s/sd"?' % temp_path)

69
tests/test_fans.py Normal file
View File

@@ -0,0 +1,69 @@
from collections import OrderedDict
from unittest.mock import MagicMock
import pytest
from afancontrol.config import FanName
from afancontrol.fans import Fans
from afancontrol.pwmfan import BaseFanPWMRead
from afancontrol.pwmfannorm import PWMFanNorm, PWMValueNorm
from afancontrol.report import Report
@pytest.fixture
def report():
return MagicMock(spec=Report)
@pytest.mark.parametrize("is_fan_failing", [False, True])
def test_smoke(report, is_fan_failing):
fan = MagicMock(spec=PWMFanNorm)
fans = Fans(fans={FanName("test"): fan}, readonly_fans={}, report=report)
fan.set = lambda pwm_norm: int(255 * pwm_norm)
fan.get_speed.return_value = 0 if is_fan_failing else 942
fan.is_pwm_stopped = BaseFanPWMRead.is_pwm_stopped
with fans:
assert 1 == fan.__enter__.call_count
fans.check_speeds()
fans.set_all_to_full_speed()
fans.set_fan_speeds({FanName("test"): PWMValueNorm(0.42)})
assert fan.get_speed.call_count == 1
if is_fan_failing:
assert fans._failed_fans == {"test"}
assert fans._stopped_fans == set()
else:
assert fans._failed_fans == set()
assert fans._stopped_fans == set()
assert 1 == fan.__exit__.call_count
def test_set_fan_speeds(report):
mocked_fans = OrderedDict(
[
(FanName("test1"), MagicMock(spec=PWMFanNorm)),
(FanName("test2"), MagicMock(spec=PWMFanNorm)),
(FanName("test3"), MagicMock(spec=PWMFanNorm)),
(FanName("test4"), MagicMock(spec=PWMFanNorm)),
]
)
for fan in mocked_fans.values():
fan.set.return_value = 240
fan.get_speed.return_value = 942
fan.is_pwm_stopped = BaseFanPWMRead.is_pwm_stopped
fans = Fans(fans=mocked_fans, readonly_fans={}, report=report)
with fans:
fans._ensure_fan_is_failing(FanName("test2"), Exception("test"))
fans.set_fan_speeds(
{
FanName("test1"): PWMValueNorm(0.42),
FanName("test2"): PWMValueNorm(0.42),
FanName("test3"): PWMValueNorm(0.42),
FanName("test4"): PWMValueNorm(0.42),
}
)
assert [1, 0, 1, 1] == [f.set.call_count for f in mocked_fans.values()]

105
tests/test_fantest.py Normal file
View File

@@ -0,0 +1,105 @@
from contextlib import ExitStack
from typing import Any, Type
from unittest.mock import MagicMock, patch
import pytest
from click.testing import CliRunner
from afancontrol import fantest
from afancontrol.fantest import (
CSVMeasurementsOutput,
HumanMeasurementsOutput,
MeasurementsOutput,
fantest as main,
run_fantest,
)
from afancontrol.pwmfan import (
BaseFanPWMRead,
BaseFanPWMWrite,
BaseFanSpeed,
FanInputDevice,
LinuxFanPWMRead,
LinuxFanPWMWrite,
LinuxFanSpeed,
PWMDevice,
PWMValue,
ReadWriteFan,
)
def test_main_smoke(temp_path):
pwm_path = temp_path / "pwm2"
pwm_path.write_text("")
fan_input_path = temp_path / "fan2_input"
fan_input_path.write_text("")
with ExitStack() as stack:
mocked_fantest = stack.enter_context(patch.object(fantest, "run_fantest"))
runner = CliRunner()
result = runner.invoke(
main,
[
"--fan-type",
"linux",
"--linux-fan-pwm",
# "/sys/class/hwmon/hwmon0/device/pwm2",
str(pwm_path), # click verifies that this file exists
"--linux-fan-input",
# "/sys/class/hwmon/hwmon0/device/fan2_input",
str(fan_input_path), # click verifies that this file exists
"--output-format",
"human",
"--direction",
"increase",
"--pwm-step-size",
"accurate",
],
)
print(result.output)
assert result.exit_code == 0
assert mocked_fantest.call_count == 1
args, kwargs = mocked_fantest.call_args
assert not args
assert kwargs.keys() == {"fan", "pwm_step_size", "output"}
assert kwargs["fan"] == ReadWriteFan(
fan_speed=LinuxFanSpeed(FanInputDevice(str(fan_input_path))),
pwm_read=LinuxFanPWMRead(PWMDevice(str(pwm_path))),
pwm_write=LinuxFanPWMWrite(PWMDevice(str(pwm_path))),
)
assert kwargs["pwm_step_size"] == 5
assert isinstance(kwargs["output"], HumanMeasurementsOutput)
@pytest.mark.parametrize("pwm_step_size", [5, -5])
@pytest.mark.parametrize("output_cls", [HumanMeasurementsOutput, CSVMeasurementsOutput])
def test_fantest(output_cls: Type[MeasurementsOutput], pwm_step_size: PWMValue):
fan: Any = ReadWriteFan(
fan_speed=MagicMock(spec=BaseFanSpeed),
pwm_read=MagicMock(spec=BaseFanPWMRead),
pwm_write=MagicMock(spec=BaseFanPWMWrite),
)
fan.pwm_read.min_pwm = 0
fan.pwm_read.max_pwm = 255
output = output_cls()
with ExitStack() as stack:
mocked_sleep = stack.enter_context(patch.object(fantest, "sleep"))
fan.fan_speed.get_speed.return_value = 999
run_fantest(fan=fan, pwm_step_size=pwm_step_size, output=output)
assert fan.pwm_write.set.call_count == (255 // abs(pwm_step_size)) + 1
assert fan.fan_speed.get_speed.call_count == (255 // abs(pwm_step_size))
assert mocked_sleep.call_count == (255 // abs(pwm_step_size)) + 1
if pwm_step_size > 0:
# increase
expected_set = [0] + list(range(0, 255, pwm_step_size))
else:
# decrease
expected_set = [255] + list(range(255, 0, pwm_step_size))
assert [pwm for (pwm,), _ in fan.pwm_write.set.call_args_list] == expected_set

74
tests/test_filters.py Normal file
View File

@@ -0,0 +1,74 @@
import pytest
from afancontrol.filters import MovingMedianFilter, MovingQuantileFilter, NullFilter
from afancontrol.temp import TempCelsius, TempStatus
def make_temp_status(temp):
return TempStatus(
min=TempCelsius(30),
max=TempCelsius(50),
temp=TempCelsius(temp),
panic=None,
threshold=None,
is_panic=False,
is_threshold=False,
)
@pytest.mark.parametrize(
"filter",
[
NullFilter(),
MovingMedianFilter(window_size=3),
MovingQuantileFilter(0.5, window_size=3),
],
)
def test_none(filter):
with filter:
assert filter.apply(None) is None
@pytest.mark.parametrize(
"filter",
[
NullFilter(),
MovingMedianFilter(window_size=3),
MovingQuantileFilter(0.5, window_size=3),
],
)
def test_single_point(filter):
with filter:
assert filter.apply(make_temp_status(42.0)) == make_temp_status(42.0)
def test_moving_quantile():
f = MovingQuantileFilter(0.8, window_size=10)
with f:
assert f.apply(make_temp_status(42.0)) == make_temp_status(42.0)
assert f.apply(make_temp_status(45.0)) == make_temp_status(45.0)
assert f.apply(make_temp_status(47.0)) == make_temp_status(47.0)
assert f.apply(make_temp_status(123.0)) == make_temp_status(123.0)
assert f.apply(make_temp_status(46.0)) == make_temp_status(123.0)
assert f.apply(make_temp_status(49.0)) == make_temp_status(49.0)
assert f.apply(make_temp_status(51.0)) == make_temp_status(51.0)
assert f.apply(None) == make_temp_status(123.0)
assert f.apply(None) is None
assert f.apply(make_temp_status(51.0)) is None
assert f.apply(make_temp_status(53.0)) is None
def test_moving_median():
f = MovingMedianFilter(window_size=3)
with f:
assert f.apply(make_temp_status(42.0)) == make_temp_status(42.0)
assert f.apply(make_temp_status(45.0)) == make_temp_status(45.0)
assert f.apply(make_temp_status(47.0)) == make_temp_status(45.0)
assert f.apply(make_temp_status(123.0)) == make_temp_status(47.0)
assert f.apply(make_temp_status(46.0)) == make_temp_status(47.0)
assert f.apply(make_temp_status(49.0)) == make_temp_status(49.0)
assert f.apply(make_temp_status(51.0)) == make_temp_status(49.0)
assert f.apply(None) == make_temp_status(51.0)
assert f.apply(None) is None
assert f.apply(make_temp_status(51.0)) is None
assert f.apply(make_temp_status(53.0)) == make_temp_status(53.0)

252
tests/test_manager.py Normal file
View File

@@ -0,0 +1,252 @@
from contextlib import ExitStack
from typing import cast
from unittest.mock import MagicMock, patch, sentinel
import pytest
import afancontrol.manager
from afancontrol.config import (
Actions,
AlertCommands,
FanName,
FanSpeedModifier,
FansTempsRelation,
MappingName,
TempName,
TriggerConfig,
)
from afancontrol.manager import Manager
from afancontrol.metrics import Metrics
from afancontrol.pwmfannorm import PWMFanNorm, PWMValueNorm
from afancontrol.report import Report
from afancontrol.temp import FileTemp, TempCelsius, TempStatus
from afancontrol.trigger import Triggers
@pytest.fixture
def report():
return MagicMock(spec=Report)
def test_manager(report):
mocked_case_fan = MagicMock(spec=PWMFanNorm)()
mocked_mobo_temp = MagicMock(spec=FileTemp)()
mocked_metrics = MagicMock(spec=Metrics)()
with ExitStack() as stack:
stack.enter_context(
patch.object(afancontrol.manager, "Triggers", spec=Triggers)
)
manager = Manager(
arduino_connections={},
fans={FanName("case"): mocked_case_fan},
readonly_fans={},
temps={TempName("mobo"): mocked_mobo_temp},
mappings={
MappingName("1"): FansTempsRelation(
temps=[TempName("mobo")],
fans=[FanSpeedModifier(fan=FanName("case"), modifier=0.6)],
)
},
report=report,
triggers_config=TriggerConfig(
global_commands=Actions(
panic=AlertCommands(enter_cmd=None, leave_cmd=None),
threshold=AlertCommands(enter_cmd=None, leave_cmd=None),
),
temp_commands={
TempName("mobo"): Actions(
panic=AlertCommands(enter_cmd=None, leave_cmd=None),
threshold=AlertCommands(enter_cmd=None, leave_cmd=None),
)
},
),
metrics=mocked_metrics,
)
stack.enter_context(manager)
manager.tick()
mocked_triggers = cast(MagicMock, manager.triggers)
assert mocked_triggers.check.call_count == 1
assert mocked_case_fan.__enter__.call_count == 1
assert mocked_metrics.__enter__.call_count == 1
assert mocked_metrics.tick.call_count == 1
assert mocked_case_fan.__exit__.call_count == 1
assert mocked_metrics.__exit__.call_count == 1
@pytest.mark.parametrize(
"temps, mappings, expected_fan_speeds",
[
(
{
TempName("cpu"): TempStatus(
min=TempCelsius(30),
max=TempCelsius(50),
temp=TempCelsius((50 - 30) * 0.42 + 30),
panic=None,
threshold=None,
is_panic=False,
is_threshold=False,
),
TempName("hdd"): None, # a failing sensor
},
{
MappingName("all"): FansTempsRelation(
temps=[TempName("cpu"), TempName("hdd")],
fans=[FanSpeedModifier(fan=FanName("rear"), modifier=1.0)],
)
},
{FanName("rear"): PWMValueNorm(1.0)},
),
(
{
TempName("cpu"): TempStatus(
min=TempCelsius(30),
max=TempCelsius(50),
temp=TempCelsius((50 - 30) * 0.42 + 30),
panic=None,
threshold=None,
is_panic=False,
is_threshold=False,
)
},
{
MappingName("all"): FansTempsRelation(
temps=[TempName("cpu")],
fans=[FanSpeedModifier(fan=FanName("rear"), modifier=1.0)],
)
},
{FanName("rear"): PWMValueNorm(0.42)},
),
(
{
TempName("cpu"): TempStatus(
min=TempCelsius(30),
max=TempCelsius(50),
temp=TempCelsius((50 - 30) * 0.42 + 30),
panic=None,
threshold=None,
is_panic=False,
is_threshold=False,
)
},
{
MappingName("all"): FansTempsRelation(
temps=[TempName("cpu")],
fans=[FanSpeedModifier(fan=FanName("rear"), modifier=0.6)],
)
},
{FanName("rear"): PWMValueNorm(0.42 * 0.6)},
),
(
{
TempName("cpu"): TempStatus(
min=TempCelsius(30),
max=TempCelsius(50),
temp=TempCelsius((50 - 30) * 0.42 + 30),
panic=None,
threshold=None,
is_panic=False,
is_threshold=False,
),
TempName("mobo"): TempStatus(
min=TempCelsius(30),
max=TempCelsius(50),
temp=TempCelsius((50 - 30) * 0.52 + 30),
panic=None,
threshold=None,
is_panic=False,
is_threshold=False,
),
TempName("hdd"): TempStatus(
min=TempCelsius(30),
max=TempCelsius(50),
temp=TempCelsius((50 - 30) * 0.12 + 30),
panic=None,
threshold=None,
is_panic=False,
is_threshold=False,
),
},
{
MappingName("all"): FansTempsRelation(
temps=[TempName("cpu"), TempName("mobo"), TempName("hdd")],
fans=[FanSpeedModifier(fan=FanName("rear"), modifier=1.0)],
)
},
{FanName("rear"): PWMValueNorm(0.52)},
),
(
{
TempName("cpu"): TempStatus(
min=TempCelsius(30),
max=TempCelsius(50),
temp=TempCelsius((50 - 30) * 0.42 + 30),
panic=None,
threshold=None,
is_panic=False,
is_threshold=False,
),
TempName("mobo"): TempStatus(
min=TempCelsius(30),
max=TempCelsius(50),
temp=TempCelsius((50 - 30) * 0.52 + 30),
panic=None,
threshold=None,
is_panic=False,
is_threshold=False,
),
TempName("hdd"): TempStatus(
min=TempCelsius(30),
max=TempCelsius(50),
temp=TempCelsius((50 - 30) * 0.12 + 30),
panic=None,
threshold=None,
is_panic=False,
is_threshold=False,
),
},
{
MappingName("1"): FansTempsRelation(
temps=[TempName("cpu"), TempName("hdd")],
fans=[FanSpeedModifier(fan=FanName("rear"), modifier=1.0)],
),
MappingName("2"): FansTempsRelation(
temps=[TempName("mobo"), TempName("hdd")],
fans=[FanSpeedModifier(fan=FanName("rear"), modifier=0.6)],
),
},
{FanName("rear"): PWMValueNorm(0.42)},
),
],
)
def test_fan_speeds(report, temps, mappings, expected_fan_speeds):
mocked_case_fan = MagicMock(spec=PWMFanNorm)()
mocked_mobo_temp = MagicMock(spec=FileTemp)()
mocked_metrics = MagicMock(spec=Metrics)()
with ExitStack() as stack:
stack.enter_context(
patch.object(afancontrol.manager, "Triggers", spec=Triggers)
)
manager = Manager(
arduino_connections={},
fans={fan_name: mocked_case_fan for fan_name in expected_fan_speeds.keys()},
readonly_fans={},
temps={temp_name: mocked_mobo_temp for temp_name in temps.keys()},
mappings=mappings,
report=report,
triggers_config=sentinel.some_triggers_config,
metrics=mocked_metrics,
)
stack.enter_context(manager)
assert expected_fan_speeds == pytest.approx(
dict(manager._map_temps_to_fan_speeds(temps))
)

156
tests/test_metrics.py Normal file
View File

@@ -0,0 +1,156 @@
import random
import types
from time import sleep
from unittest.mock import MagicMock
import pytest
import requests
from afancontrol.config import FanName, TempName
from afancontrol.fans import Fans
from afancontrol.metrics import PrometheusMetrics, prometheus_available
from afancontrol.pwmfannorm import PWMFanNorm
from afancontrol.report import Report
from afancontrol.temp import TempCelsius, TempStatus
from afancontrol.temps import ObservedTempStatus
from afancontrol.trigger import Triggers
@pytest.fixture
def requests_session():
# Ignore system proxies, see https://stackoverflow.com/a/28521696
with requests.Session() as session:
session.trust_env = False
yield session
@pytest.mark.skipif(
not prometheus_available, reason="prometheus_client is not installed"
)
def test_prometheus_metrics(requests_session):
mocked_fan = MagicMock(spec=PWMFanNorm)()
mocked_triggers = MagicMock(spec=Triggers)()
mocked_report = MagicMock(spec=Report)()
port = random.randint(20000, 50000)
metrics = PrometheusMetrics("127.0.0.1:%s" % port)
with metrics:
resp = requests_session.get("http://127.0.0.1:%s/metrics" % port)
assert resp.status_code == 200
assert "is_threshold 0.0" in resp.text
with metrics.measure_tick():
sleep(0.01)
resp = requests_session.get("http://127.0.0.1:%s/metrics" % port)
assert resp.status_code == 200
assert "tick_duration_count 1.0" in resp.text
assert "tick_duration_sum 0." in resp.text
mocked_triggers.panic_trigger.is_alerting = True
mocked_triggers.threshold_trigger.is_alerting = False
mocked_fan.pwm_line_start = 100
mocked_fan.pwm_line_end = 240
mocked_fan.get_speed.return_value = 999
mocked_fan.get_raw.return_value = 142
mocked_fan.get = types.MethodType(PWMFanNorm.get, mocked_fan)
mocked_fan.pwm_read.max_pwm = 255
metrics.tick(
temps={
TempName("goodtemp"): ObservedTempStatus(
filtered=TempStatus(
temp=TempCelsius(74.0),
min=TempCelsius(40.0),
max=TempCelsius(50.0),
panic=TempCelsius(60.0),
threshold=None,
is_panic=True,
is_threshold=False,
),
raw=TempStatus(
temp=TempCelsius(72.0),
min=TempCelsius(40.0),
max=TempCelsius(50.0),
panic=TempCelsius(60.0),
threshold=None,
is_panic=True,
is_threshold=False,
),
),
TempName("failingtemp"): ObservedTempStatus(filtered=None, raw=None),
},
fans=Fans(
fans={FanName("test"): mocked_fan},
readonly_fans={},
report=mocked_report,
),
triggers=mocked_triggers,
arduino_connections={},
)
resp = requests_session.get("http://127.0.0.1:%s/metrics" % port)
assert resp.status_code == 200
print(resp.text)
assert 'temperature_current{temp_name="failingtemp"} NaN' in resp.text
assert 'temperature_current_raw{temp_name="failingtemp"} NaN' in resp.text
assert 'temperature_current{temp_name="goodtemp"} 74.0' in resp.text
assert 'temperature_current_raw{temp_name="goodtemp"} 72.0' in resp.text
assert 'temperature_is_failing{temp_name="failingtemp"} 1.0' in resp.text
assert 'temperature_is_failing{temp_name="goodtemp"} 0.0' in resp.text
assert 'fan_rpm{fan_name="test"} 999.0' in resp.text
assert 'fan_pwm{fan_name="test"} 142.0' in resp.text
assert 'fan_pwm_normalized{fan_name="test"} 0.556' in resp.text
assert 'fan_is_failing{fan_name="test"} 0.0' in resp.text
assert "is_panic 1.0" in resp.text
assert "is_threshold 0.0" in resp.text
assert "last_metrics_tick_seconds_ago 0." in resp.text
with pytest.raises(IOError):
requests_session.get("http://127.0.0.1:%s/metrics" % port)
@pytest.mark.skipif(
not prometheus_available, reason="prometheus_client is not installed"
)
def test_prometheus_faulty_fans_dont_break_metrics_collection(requests_session):
mocked_fan = MagicMock(spec=PWMFanNorm)()
mocked_triggers = MagicMock(spec=Triggers)()
mocked_report = MagicMock(spec=Report)()
port = random.randint(20000, 50000)
metrics = PrometheusMetrics("127.0.0.1:%s" % port)
with metrics:
mocked_triggers.panic_trigger.is_alerting = False
mocked_triggers.threshold_trigger.is_alerting = False
mocked_fan.pwm_line_start = 100
mocked_fan.pwm_line_end = 240
mocked_fan.get_speed.side_effect = IOError
mocked_fan.get_raw.side_effect = IOError
# Must not raise despite the PWMFan methods raising above:
metrics.tick(
temps={
TempName("failingtemp"): ObservedTempStatus(filtered=None, raw=None)
},
fans=Fans(
fans={FanName("test"): mocked_fan},
readonly_fans={},
report=mocked_report,
),
triggers=mocked_triggers,
arduino_connections={},
)
resp = requests_session.get("http://127.0.0.1:%s/metrics" % port)
assert resp.status_code == 200
assert 'fan_pwm_line_start{fan_name="test"} 100.0' in resp.text
assert 'fan_pwm_line_end{fan_name="test"} 240.0' in resp.text
assert 'fan_rpm{fan_name="test"} NaN' in resp.text
assert 'fan_pwm{fan_name="test"} NaN' in resp.text
assert 'fan_pwm_normalized{fan_name="test"} NaN' in resp.text
assert 'fan_is_failing{fan_name="test"} 0.0' in resp.text
assert "is_panic 0.0" in resp.text
assert "is_threshold 0.0" in resp.text

20
tests/test_report.py Normal file
View File

@@ -0,0 +1,20 @@
from unittest.mock import call
from afancontrol import report
from afancontrol.report import Report
def test_report_success(sense_exec_shell_command):
r = Report(r"printf '@%s' '%REASON%' '%MESSAGE%'")
with sense_exec_shell_command(report) as (mock_exec_shell_command, get_stdout):
r.report("reason here", "message\nthere")
assert mock_exec_shell_command.call_args == call(
"printf '@%s' 'reason here' 'message\nthere'"
)
assert ["@reason here@message\nthere"] == get_stdout()
def test_report_fail_does_not_raise():
r = Report("false")
r.report("reason here", "message\nthere")

180
tests/test_trigger.py Normal file
View File

@@ -0,0 +1,180 @@
from unittest.mock import MagicMock, call
import pytest
from afancontrol import trigger
from afancontrol.config import Actions, AlertCommands, TempName, TriggerConfig
from afancontrol.report import Report
from afancontrol.temp import TempCelsius, TempStatus
from afancontrol.trigger import PanicTrigger, ThresholdTrigger, Triggers
@pytest.fixture
def report():
return MagicMock(spec=Report)
def test_panic_on_empty_temp(report, sense_exec_shell_command):
t = PanicTrigger(
global_commands=AlertCommands(
enter_cmd="printf '@%s' enter", leave_cmd="printf '@%s' leave"
),
temp_commands={
TempName("mobo"): AlertCommands(
enter_cmd=None, leave_cmd="printf '@%s' mobo leave"
)
},
report=report,
)
with sense_exec_shell_command(trigger) as (mock_exec_shell_command, get_stdout):
with t:
assert not t.is_alerting
assert 0 == mock_exec_shell_command.call_count
t.check({TempName("mobo"): None})
assert t.is_alerting
assert mock_exec_shell_command.call_args_list == [
call("printf '@%s' enter")
]
assert ["@enter"] == get_stdout()
mock_exec_shell_command.reset_mock()
assert not t.is_alerting
assert mock_exec_shell_command.call_args_list == [
call("printf '@%s' mobo leave"),
call("printf '@%s' leave"),
]
assert ["@mobo@leave", "@leave"] == get_stdout()
def test_threshold_on_empty_temp(report):
t = ThresholdTrigger(
global_commands=AlertCommands(enter_cmd=None, leave_cmd=None),
temp_commands={TempName("mobo"): AlertCommands(enter_cmd=None, leave_cmd=None)},
report=report,
)
with t:
assert not t.is_alerting
t.check({TempName("mobo"): None})
assert not t.is_alerting
assert not t.is_alerting
@pytest.mark.parametrize("cls", [ThresholdTrigger, PanicTrigger])
def test_good_temp(cls, report):
t = cls(
global_commands=AlertCommands(enter_cmd=None, leave_cmd=None),
temp_commands=dict(mobo=AlertCommands(enter_cmd=None, leave_cmd=None)),
report=report,
)
with t:
assert not t.is_alerting
t.check(
dict(
mobo=TempStatus(
temp=TempCelsius(34.0),
min=TempCelsius(40.0),
max=TempCelsius(50.0),
panic=TempCelsius(60.0),
threshold=None,
is_panic=False,
is_threshold=False,
)
)
)
assert not t.is_alerting
@pytest.mark.parametrize("cls", [ThresholdTrigger, PanicTrigger])
def test_bad_temp(cls, report, sense_exec_shell_command):
t = cls(
global_commands=AlertCommands(
enter_cmd="printf '@%s' enter", leave_cmd="printf '@%s' leave"
),
temp_commands=dict(
mobo=AlertCommands(
enter_cmd="printf '@%s' mobo enter", leave_cmd="printf '@%s' mobo leave"
)
),
report=report,
)
with sense_exec_shell_command(trigger) as (mock_exec_shell_command, get_stdout):
with t:
assert not t.is_alerting
t.check(
dict(
mobo=TempStatus(
temp=TempCelsius(70.0),
min=TempCelsius(40.0),
max=TempCelsius(50.0),
panic=TempCelsius(60.0),
threshold=TempCelsius(55.0),
is_panic=True,
is_threshold=True,
)
)
)
assert t.is_alerting
assert mock_exec_shell_command.call_args_list == [
call("printf '@%s' mobo enter"),
call("printf '@%s' enter"),
]
assert ["@mobo@enter", "@enter"] == get_stdout()
mock_exec_shell_command.reset_mock()
t.check(
dict(
mobo=TempStatus(
temp=TempCelsius(34.0),
min=TempCelsius(40.0),
max=TempCelsius(50.0),
panic=TempCelsius(60.0),
threshold=None,
is_panic=False,
is_threshold=False,
)
)
)
assert not t.is_alerting
assert mock_exec_shell_command.call_args_list == [
call("printf '@%s' mobo leave"),
call("printf '@%s' leave"),
]
assert ["@mobo@leave", "@leave"] == get_stdout()
mock_exec_shell_command.reset_mock()
assert 0 == mock_exec_shell_command.call_count
def test_triggers_good_temp(report):
t = Triggers(
TriggerConfig(
global_commands=Actions(
panic=AlertCommands(enter_cmd=None, leave_cmd=None),
threshold=AlertCommands(enter_cmd=None, leave_cmd=None),
),
temp_commands={
TempName("mobo"): Actions(
panic=AlertCommands(enter_cmd=None, leave_cmd=None),
threshold=AlertCommands(enter_cmd=None, leave_cmd=None),
)
},
),
report=report,
)
with t:
assert not t.is_alerting
t.check(
{
TempName("mobo"): TempStatus(
temp=TempCelsius(34.0),
min=TempCelsius(40.0),
max=TempCelsius(50.0),
panic=TempCelsius(60.0),
threshold=None,
is_panic=False,
is_threshold=False,
)
}
)
assert not t.is_alerting