Imported Upstream version 3.0.0
This commit is contained in:
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
38
tests/conftest.py
Normal file
38
tests/conftest.py
Normal file
@@ -0,0 +1,38 @@
|
||||
import tempfile
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from afancontrol.exec import exec_shell_command
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_path():
|
||||
with tempfile.TemporaryDirectory() as tmpdirname:
|
||||
yield Path(tmpdirname).resolve()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sense_exec_shell_command():
|
||||
exec_shell_command_stdout = []
|
||||
|
||||
def sensed_exec_shell_command(*args, **kwargs):
|
||||
exec_shell_command_stdout.append(exec_shell_command(*args, **kwargs))
|
||||
return exec_shell_command_stdout[-1]
|
||||
|
||||
def get_stdout():
|
||||
try:
|
||||
return exec_shell_command_stdout[:]
|
||||
finally:
|
||||
exec_shell_command_stdout.clear()
|
||||
|
||||
@contextmanager
|
||||
def _sense_exec_shell_command(module):
|
||||
with patch.object(
|
||||
module, "exec_shell_command", wraps=sensed_exec_shell_command
|
||||
) as mock_exec_shell_command:
|
||||
yield mock_exec_shell_command, get_stdout
|
||||
|
||||
return _sense_exec_shell_command
|
||||
53
tests/data/afancontrol-example.conf
Normal file
53
tests/data/afancontrol-example.conf
Normal file
@@ -0,0 +1,53 @@
|
||||
[daemon]
|
||||
pidfile = /run/afancontrol.pid
|
||||
logfile = /var/log/afancontrol.log
|
||||
interval = 5
|
||||
exporter_listen_host = 127.0.0.1:8083
|
||||
|
||||
[actions]
|
||||
|
||||
[temp:mobo]
|
||||
type = file
|
||||
path = /sys/class/hwmon/hwmon0/device/temp1_input
|
||||
min = 30
|
||||
max = 40
|
||||
|
||||
[temp: hdds]
|
||||
type = hdd
|
||||
path = /dev/sd?
|
||||
min = 35
|
||||
max = 48
|
||||
panic = 55
|
||||
|
||||
[fan: hdd]
|
||||
pwm = /sys/class/hwmon/hwmon0/device/pwm2
|
||||
fan_input = /sys/class/hwmon/hwmon0/device/fan2_input
|
||||
pwm_line_start = 100
|
||||
pwm_line_end = 240
|
||||
never_stop = no
|
||||
|
||||
[fan:cpu]
|
||||
pwm = /sys/class/hwmon/hwmon0/device/pwm1
|
||||
fan_input = /sys/class/hwmon/hwmon0/device/fan1_input
|
||||
pwm_line_start = 100
|
||||
pwm_line_end = 240
|
||||
never_stop = yes
|
||||
|
||||
[arduino: mymicro]
|
||||
serial_url = /dev/ttyACM0
|
||||
baudrate = 115200
|
||||
status_ttl = 5
|
||||
|
||||
[fan: my_arduino_fan]
|
||||
type = arduino
|
||||
arduino_name = mymicro
|
||||
pwm_pin = 9
|
||||
tacho_pin = 3
|
||||
|
||||
[mapping:1]
|
||||
fans = cpu, hdd*0.6, my_arduino_fan * 0.222
|
||||
temps = mobo, hdds
|
||||
|
||||
[mapping:2]
|
||||
fans = hdd
|
||||
temps = hdds
|
||||
0
tests/pwmfan/__init__.py
Normal file
0
tests/pwmfan/__init__.py
Normal file
177
tests/pwmfan/test_arduino.py
Normal file
177
tests/pwmfan/test_arduino.py
Normal file
@@ -0,0 +1,177 @@
|
||||
import json
|
||||
import socket
|
||||
import threading
|
||||
import traceback
|
||||
from contextlib import ExitStack
|
||||
from time import sleep
|
||||
from typing import Dict
|
||||
|
||||
import pytest
|
||||
|
||||
from afancontrol.arduino import (
|
||||
ArduinoConnection,
|
||||
ArduinoName,
|
||||
ArduinoPin,
|
||||
SetPWMCommand,
|
||||
pyserial_available,
|
||||
)
|
||||
from afancontrol.pwmfan import (
|
||||
ArduinoFanPWMRead,
|
||||
ArduinoFanPWMWrite,
|
||||
ArduinoFanSpeed,
|
||||
PWMValue,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not pyserial_available, reason="pyserial is not installed"
|
||||
)
|
||||
|
||||
|
||||
class DummyArduino:
|
||||
"""Emulate an Arduino board, i.e. the other side of the pyserial connection.
|
||||
|
||||
Slightly mimics the Arduino program `micro.ino`.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.bind(("127.0.0.1", 0))
|
||||
s.listen(1)
|
||||
listening_port = s.getsockname()[1]
|
||||
self.sock = s
|
||||
self.pyserial_url = "socket://127.0.0.1:%s" % listening_port
|
||||
self._lock = threading.Lock()
|
||||
self._loop_iteration_complete = threading.Event()
|
||||
self._first_loop_iteration_complete = threading.Event()
|
||||
self._disconnected = threading.Event()
|
||||
self._thread_error = threading.Event()
|
||||
self._is_connected = False
|
||||
self._inner_state_pwms = {"5": 255, "9": 255, "10": 255, "11": 255}
|
||||
self._inner_state_speeds = {"0": 0, "1": 0, "2": 0, "3": 0, "7": 0}
|
||||
|
||||
def set_inner_state_pwms(self, pwms: Dict[str, int]) -> None:
|
||||
with self._lock:
|
||||
self._inner_state_pwms.update(pwms)
|
||||
if self.is_connected:
|
||||
self._loop_iteration_complete.clear()
|
||||
assert self._loop_iteration_complete.wait(5) is True
|
||||
|
||||
def set_speeds(self, speeds: Dict[str, int]) -> None:
|
||||
with self._lock:
|
||||
self._inner_state_speeds.update(speeds)
|
||||
if self.is_connected:
|
||||
self._loop_iteration_complete.clear()
|
||||
assert self._loop_iteration_complete.wait(5) is True
|
||||
|
||||
@property
|
||||
def inner_state_pwms(self):
|
||||
with self._lock:
|
||||
copy = self._inner_state_pwms.copy()
|
||||
return copy
|
||||
|
||||
@property
|
||||
def is_connected(self):
|
||||
with self._lock:
|
||||
if not self._is_connected:
|
||||
return False
|
||||
assert self._first_loop_iteration_complete.wait(5) is True
|
||||
return True
|
||||
|
||||
def wait_for_disconnected(self):
|
||||
assert self._disconnected.wait(5) is True
|
||||
|
||||
def accept(self):
|
||||
client, _ = self.sock.accept()
|
||||
self.sock.close() # Don't accept any more connections
|
||||
with self._lock:
|
||||
self._is_connected = True
|
||||
threading.Thread(target=self._thread_run, args=(client,), daemon=True).start()
|
||||
|
||||
def _thread_run(self, sock):
|
||||
sock.settimeout(0.001)
|
||||
command_buffer = bytearray()
|
||||
try:
|
||||
while True:
|
||||
# The code in this loop mimics the `loop` function
|
||||
# in the `micro.ino` program.
|
||||
|
||||
try:
|
||||
command_buffer.extend(sock.recv(1024))
|
||||
except socket.timeout:
|
||||
pass
|
||||
|
||||
while len(command_buffer) >= 3:
|
||||
command_raw = command_buffer[:3]
|
||||
del command_buffer[:3]
|
||||
command = SetPWMCommand.parse(command_raw)
|
||||
with self._lock:
|
||||
self._inner_state_pwms[str(command.pwm_pin)] = command.pwm
|
||||
|
||||
sock.sendall(self._make_status())
|
||||
|
||||
self._loop_iteration_complete.set()
|
||||
self._first_loop_iteration_complete.set()
|
||||
|
||||
sleep(0.050)
|
||||
except (ConnectionResetError, BrokenPipeError):
|
||||
pass
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
self._thread_error.set()
|
||||
finally:
|
||||
with self._lock:
|
||||
self._is_connected = False
|
||||
sock.close()
|
||||
self._disconnected.set()
|
||||
|
||||
def _make_status(self):
|
||||
with self._lock:
|
||||
status = {
|
||||
"fan_inputs": self._inner_state_speeds,
|
||||
"fan_pwm": self._inner_state_pwms,
|
||||
}
|
||||
return (json.dumps(status) + "\n").encode("ascii")
|
||||
|
||||
def ensure_no_errors_in_thread(self):
|
||||
assert self._thread_error.is_set() is not True
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dummy_arduino():
|
||||
return DummyArduino()
|
||||
|
||||
|
||||
def test_smoke(dummy_arduino):
|
||||
conn = ArduinoConnection(ArduinoName("test"), dummy_arduino.pyserial_url)
|
||||
|
||||
fan_speed = ArduinoFanSpeed(conn, tacho_pin=ArduinoPin(3))
|
||||
pwm_read = ArduinoFanPWMRead(conn, pwm_pin=ArduinoPin(9))
|
||||
pwm_write = ArduinoFanPWMWrite(conn, pwm_pin=ArduinoPin(9))
|
||||
|
||||
dummy_arduino.set_inner_state_pwms({"9": 42})
|
||||
|
||||
with ExitStack() as stack:
|
||||
assert not dummy_arduino.is_connected
|
||||
stack.enter_context(fan_speed)
|
||||
stack.enter_context(pwm_read)
|
||||
stack.enter_context(pwm_write)
|
||||
dummy_arduino.accept()
|
||||
assert dummy_arduino.is_connected
|
||||
|
||||
dummy_arduino.set_speeds({"3": 1200})
|
||||
conn.wait_for_status() # required only for synchronization in the tests
|
||||
assert fan_speed.get_speed() == 1200
|
||||
assert pwm_read.get() == 255
|
||||
assert dummy_arduino.inner_state_pwms["9"] == 255
|
||||
|
||||
pwm_write.set(PWMValue(192))
|
||||
dummy_arduino.set_speeds({"3": 998})
|
||||
conn.wait_for_status() # required only for synchronization in the tests
|
||||
assert fan_speed.get_speed() == 998
|
||||
assert pwm_read.get() == 192
|
||||
assert dummy_arduino.inner_state_pwms["9"] == 192
|
||||
|
||||
dummy_arduino.wait_for_disconnected()
|
||||
assert dummy_arduino.inner_state_pwms["9"] == 255
|
||||
assert not dummy_arduino.is_connected
|
||||
dummy_arduino.ensure_no_errors_in_thread()
|
||||
41
tests/pwmfan/test_ipmi.py
Normal file
41
tests/pwmfan/test_ipmi.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from afancontrol.pwmfan import FanValue, FreeIPMIFanSpeed
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ipmi_sensors_output():
|
||||
return """
|
||||
ID,Name,Type,Reading,Units,Event
|
||||
17,FAN1,Fan,1400.00,RPM,'OK'
|
||||
18,FAN2,Fan,1800.00,RPM,'OK'
|
||||
19,FAN3,Fan,N/A,RPM,N/A
|
||||
20,FAN4,Fan,N/A,RPM,N/A
|
||||
21,FAN5,Fan,N/A,RPM,N/A
|
||||
22,FAN6,Fan,N/A,RPM,N/A
|
||||
""".lstrip()
|
||||
|
||||
|
||||
def test_fan_speed(ipmi_sensors_output):
|
||||
fan_speed = FreeIPMIFanSpeed("FAN2")
|
||||
with patch.object(FreeIPMIFanSpeed, "_call_ipmi_sensors") as mock_call:
|
||||
mock_call.return_value = ipmi_sensors_output
|
||||
assert fan_speed.get_speed() == FanValue(1800)
|
||||
|
||||
|
||||
def test_fan_speed_na(ipmi_sensors_output):
|
||||
fan_speed = FreeIPMIFanSpeed("FAN3")
|
||||
with patch.object(FreeIPMIFanSpeed, "_call_ipmi_sensors") as mock_call:
|
||||
mock_call.return_value = ipmi_sensors_output
|
||||
with pytest.raises(ValueError):
|
||||
fan_speed.get_speed()
|
||||
|
||||
|
||||
def test_fan_speed_unknown(ipmi_sensors_output):
|
||||
fan_speed = FreeIPMIFanSpeed("FAN30")
|
||||
with patch.object(FreeIPMIFanSpeed, "_call_ipmi_sensors") as mock_call:
|
||||
mock_call.return_value = ipmi_sensors_output
|
||||
with pytest.raises(RuntimeError):
|
||||
fan_speed.get_speed()
|
||||
153
tests/pwmfan/test_linux.py
Normal file
153
tests/pwmfan/test_linux.py
Normal file
@@ -0,0 +1,153 @@
|
||||
from contextlib import ExitStack
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from afancontrol.pwmfan import (
|
||||
FanInputDevice,
|
||||
LinuxFanPWMRead,
|
||||
LinuxFanPWMWrite,
|
||||
LinuxFanSpeed,
|
||||
PWMDevice,
|
||||
PWMValue,
|
||||
)
|
||||
from afancontrol.pwmfannorm import PWMFanNorm
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pwm_path(temp_path):
|
||||
# pwm = /sys/class/hwmon/hwmon0/pwm2
|
||||
pwm_path = temp_path / "pwm2"
|
||||
pwm_path.write_text("0\n")
|
||||
return pwm_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pwm_enable_path(temp_path):
|
||||
pwm_enable_path = temp_path / "pwm2_enable"
|
||||
pwm_enable_path.write_text("0\n")
|
||||
return pwm_enable_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fan_input_path(temp_path):
|
||||
# fan_input = /sys/class/hwmon/hwmon0/fan2_input
|
||||
fan_input_path = temp_path / "fan2_input"
|
||||
fan_input_path.write_text("1300\n")
|
||||
return fan_input_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fan_speed(fan_input_path):
|
||||
return LinuxFanSpeed(fan_input=FanInputDevice(str(fan_input_path)))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pwm_read(pwm_path):
|
||||
return LinuxFanPWMRead(pwm=PWMDevice(str(pwm_path)))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pwm_write(pwm_path):
|
||||
pwm_write = LinuxFanPWMWrite(pwm=PWMDevice(str(pwm_path)))
|
||||
|
||||
# We write to the pwm_enable file values without newlines,
|
||||
# but when they're read back, they might contain newlines.
|
||||
# This hack below is to simulate just that: the written values should
|
||||
# contain newlines.
|
||||
original_pwm_enable = pwm_write._pwm_enable
|
||||
pwm_enable = MagicMock(wraps=original_pwm_enable)
|
||||
pwm_enable.write_text = lambda text: original_pwm_enable.write_text(text + "\n")
|
||||
pwm_write._pwm_enable = pwm_enable
|
||||
|
||||
return pwm_write
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pwmfan_norm(fan_speed, pwm_read, pwm_write):
|
||||
return PWMFanNorm(
|
||||
fan_speed,
|
||||
pwm_read,
|
||||
pwm_write,
|
||||
pwm_line_start=PWMValue(100),
|
||||
pwm_line_end=PWMValue(240),
|
||||
never_stop=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("pwmfan_fixture", ["fan_speed", "pwmfan_norm"])
|
||||
def test_get_speed(pwmfan_fixture, fan_speed, pwmfan_norm, fan_input_path):
|
||||
fan = locals()[pwmfan_fixture]
|
||||
fan_input_path.write_text("721\n")
|
||||
assert 721 == fan.get_speed()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("pwmfan_fixture", ["pwm_write", "pwmfan_norm"])
|
||||
@pytest.mark.parametrize("raises", [True, False])
|
||||
def test_enter_exit(
|
||||
raises, pwmfan_fixture, pwm_write, pwmfan_norm, pwm_enable_path, pwm_path
|
||||
):
|
||||
fan = locals()[pwmfan_fixture]
|
||||
|
||||
class Exc(Exception):
|
||||
pass
|
||||
|
||||
with ExitStack() as stack:
|
||||
if raises:
|
||||
stack.enter_context(pytest.raises(Exc))
|
||||
stack.enter_context(fan)
|
||||
|
||||
assert "1" == pwm_enable_path.read_text().strip()
|
||||
assert "255" == pwm_path.read_text()
|
||||
|
||||
value = dict(pwm_write=100, pwmfan_norm=0.39)[pwmfan_fixture] # 100/255 ~= 0.39
|
||||
fan.set(value)
|
||||
|
||||
assert "1" == pwm_enable_path.read_text().strip()
|
||||
assert "100" == pwm_path.read_text()
|
||||
|
||||
if raises:
|
||||
raise Exc()
|
||||
|
||||
assert "0" == pwm_enable_path.read_text().strip()
|
||||
assert "100" == pwm_path.read_text() # `fancontrol` doesn't reset speed
|
||||
|
||||
|
||||
def test_get_set_pwmfan(pwm_read, pwm_write, pwm_path):
|
||||
pwm_write.set(142)
|
||||
assert "142" == pwm_path.read_text()
|
||||
|
||||
pwm_path.write_text("132\n")
|
||||
assert 132 == pwm_read.get()
|
||||
|
||||
pwm_write.set_full_speed()
|
||||
assert "255" == pwm_path.read_text()
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
pwm_write.set(256)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
pwm_write.set(-1)
|
||||
|
||||
|
||||
def test_get_set_pwmfan_norm(pwmfan_norm, pwm_path):
|
||||
pwmfan_norm.set(0.42)
|
||||
assert "101" == pwm_path.read_text()
|
||||
|
||||
pwm_path.write_text("132\n")
|
||||
assert pytest.approx(0.517, 0.01) == pwmfan_norm.get()
|
||||
|
||||
pwmfan_norm.set_full_speed()
|
||||
assert "255" == pwm_path.read_text()
|
||||
|
||||
assert 238 == pwmfan_norm.set(0.99)
|
||||
assert "238" == pwm_path.read_text()
|
||||
|
||||
assert 255 == pwmfan_norm.set(1.0)
|
||||
assert "255" == pwm_path.read_text()
|
||||
|
||||
assert 255 == pwmfan_norm.set(1.1)
|
||||
assert "255" == pwm_path.read_text()
|
||||
|
||||
assert 0 == pwmfan_norm.set(-0.1)
|
||||
assert "0" == pwm_path.read_text()
|
||||
0
tests/temp/__init__.py
Normal file
0
tests/temp/__init__.py
Normal file
46
tests/temp/test_base.py
Normal file
46
tests/temp/test_base.py
Normal file
@@ -0,0 +1,46 @@
|
||||
from typing import Optional
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from afancontrol.temp import Temp, TempCelsius, TempStatus
|
||||
|
||||
|
||||
class DummyTemp(Temp):
|
||||
def _get_temp(self):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"temp, threshold, panic, is_threshold, is_panic",
|
||||
[
|
||||
(34.0, None, 60.0, False, False),
|
||||
(42.0, None, 60.0, False, False),
|
||||
(57.0, 55.0, 60.0, True, False),
|
||||
(61.0, 55.0, 61.0, True, True),
|
||||
(61.0, None, 61.0, False, True),
|
||||
],
|
||||
)
|
||||
def test_temp(
|
||||
temp: TempCelsius,
|
||||
threshold: Optional[TempCelsius],
|
||||
panic: TempCelsius,
|
||||
is_threshold,
|
||||
is_panic,
|
||||
):
|
||||
min = TempCelsius(40.0)
|
||||
max = TempCelsius(50.0)
|
||||
|
||||
with patch.object(DummyTemp, "_get_temp") as mock_get_temp:
|
||||
t = DummyTemp(panic=panic, threshold=threshold)
|
||||
mock_get_temp.return_value = [temp, min, max]
|
||||
|
||||
assert t.get() == TempStatus(
|
||||
temp=temp,
|
||||
min=min,
|
||||
max=max,
|
||||
panic=panic,
|
||||
threshold=threshold,
|
||||
is_panic=is_panic,
|
||||
is_threshold=is_threshold,
|
||||
)
|
||||
40
tests/temp/test_command.py
Normal file
40
tests/temp/test_command.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from afancontrol.temp import CommandTemp, TempCelsius, TempStatus
|
||||
|
||||
|
||||
def test_command_temp_with_minmax():
|
||||
t = CommandTemp(
|
||||
shell_command=r"printf '%s\n' 35 30 40",
|
||||
min=TempCelsius(31.0),
|
||||
max=TempCelsius(39.0),
|
||||
panic=TempCelsius(50.0),
|
||||
threshold=None,
|
||||
)
|
||||
assert t.get() == TempStatus(
|
||||
temp=TempCelsius(35.0),
|
||||
min=TempCelsius(31.0),
|
||||
max=TempCelsius(39.0),
|
||||
panic=TempCelsius(50.0),
|
||||
threshold=None,
|
||||
is_panic=False,
|
||||
is_threshold=False,
|
||||
)
|
||||
print(repr(t))
|
||||
|
||||
|
||||
def test_command_temp_without_minmax():
|
||||
t = CommandTemp(
|
||||
shell_command=r"printf '%s\n' 35 30 40",
|
||||
min=None,
|
||||
max=None,
|
||||
panic=TempCelsius(50.0),
|
||||
threshold=None,
|
||||
)
|
||||
assert t.get() == TempStatus(
|
||||
temp=TempCelsius(35.0),
|
||||
min=TempCelsius(30.0),
|
||||
max=TempCelsius(40.0),
|
||||
panic=TempCelsius(50.0),
|
||||
threshold=None,
|
||||
is_panic=False,
|
||||
is_threshold=False,
|
||||
)
|
||||
88
tests/temp/test_file.py
Normal file
88
tests/temp/test_file.py
Normal file
@@ -0,0 +1,88 @@
|
||||
import pytest
|
||||
|
||||
from afancontrol.temp import FileTemp, TempCelsius, TempStatus
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def file_temp_path(temp_path):
|
||||
# /sys/class/hwmon/hwmon0/temp1_input
|
||||
temp_input_path = temp_path / "temp1_input"
|
||||
temp_input_path.write_text("34000\n")
|
||||
|
||||
temp_max_path = temp_path / "temp1_max"
|
||||
temp_max_path.write_text("127000\n")
|
||||
|
||||
temp_min_path = temp_path / "temp1_min"
|
||||
# My mobo actually returns this as min:
|
||||
temp_min_path.write_text("127000\n")
|
||||
|
||||
return temp_input_path
|
||||
|
||||
|
||||
def test_file_temp_min_max_numbers(file_temp_path):
|
||||
temp = FileTemp(
|
||||
temp_path=str(file_temp_path),
|
||||
min=TempCelsius(40.0),
|
||||
max=TempCelsius(50.0),
|
||||
panic=TempCelsius(60.0),
|
||||
threshold=None,
|
||||
)
|
||||
assert temp.get() == TempStatus(
|
||||
temp=TempCelsius(34.0),
|
||||
min=TempCelsius(40.0),
|
||||
max=TempCelsius(50.0),
|
||||
panic=TempCelsius(60.0),
|
||||
threshold=None,
|
||||
is_panic=False,
|
||||
is_threshold=False,
|
||||
)
|
||||
print(repr(temp))
|
||||
|
||||
|
||||
def test_file_temp_glob(file_temp_path):
|
||||
temp = FileTemp(
|
||||
temp_path=str(file_temp_path).replace("/temp1", "/temp?"),
|
||||
min=TempCelsius(40.0),
|
||||
max=None,
|
||||
panic=None,
|
||||
threshold=None,
|
||||
)
|
||||
assert temp.get() == TempStatus(
|
||||
temp=TempCelsius(34.0),
|
||||
min=TempCelsius(40.0),
|
||||
max=TempCelsius(127.0),
|
||||
panic=None,
|
||||
threshold=None,
|
||||
is_panic=False,
|
||||
is_threshold=False,
|
||||
)
|
||||
print(repr(temp))
|
||||
|
||||
|
||||
def test_file_temp_min_max_files(temp_path, file_temp_path):
|
||||
with pytest.raises(RuntimeError):
|
||||
# min == max is an error
|
||||
FileTemp(
|
||||
temp_path=str(file_temp_path),
|
||||
min=None,
|
||||
max=None,
|
||||
panic=TempCelsius(60.0),
|
||||
threshold=None,
|
||||
).get()
|
||||
|
||||
temp = FileTemp(
|
||||
temp_path=str(file_temp_path),
|
||||
min=TempCelsius(50.0),
|
||||
max=None,
|
||||
panic=TempCelsius(60.0),
|
||||
threshold=None,
|
||||
)
|
||||
assert temp.get() == TempStatus(
|
||||
temp=TempCelsius(34.0),
|
||||
min=TempCelsius(50.0),
|
||||
max=TempCelsius(127.0),
|
||||
panic=TempCelsius(60.0),
|
||||
threshold=None,
|
||||
is_panic=False,
|
||||
is_threshold=False,
|
||||
)
|
||||
95
tests/temp/test_hdd.py
Normal file
95
tests/temp/test_hdd.py
Normal file
@@ -0,0 +1,95 @@
|
||||
import subprocess
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from afancontrol.temp import HDDTemp, TempCelsius, TempStatus
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def hddtemp_output_many():
|
||||
return (
|
||||
"/dev/sda: Adaptec XXXXX: drive supported,"
|
||||
" but it doesn't have a temperature sensor.\n"
|
||||
"/dev/sdb: Adaptec XXXXX: drive supported,"
|
||||
" but it doesn't have a temperature sensor.\n"
|
||||
"38\n"
|
||||
"39\n"
|
||||
"30\n"
|
||||
"36\n"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def hddtemp_output_bad():
|
||||
return (
|
||||
"/dev/sda: Adaptec XXXXX: drive supported,"
|
||||
" but it doesn't have a temperature sensor.\n"
|
||||
)
|
||||
|
||||
|
||||
def test_hddtemp_many(hddtemp_output_many):
|
||||
with patch.object(HDDTemp, "_call_hddtemp") as mock_call_hddtemp:
|
||||
mock_call_hddtemp.return_value = hddtemp_output_many
|
||||
t = HDDTemp(
|
||||
disk_path="/dev/sd?",
|
||||
min=TempCelsius(38.0),
|
||||
max=TempCelsius(45.0),
|
||||
panic=TempCelsius(50.0),
|
||||
threshold=None,
|
||||
hddtemp_bin="testbin",
|
||||
)
|
||||
|
||||
assert t.get() == TempStatus(
|
||||
temp=TempCelsius(39.0),
|
||||
min=TempCelsius(38.0),
|
||||
max=TempCelsius(45.0),
|
||||
panic=TempCelsius(50.0),
|
||||
threshold=None,
|
||||
is_panic=False,
|
||||
is_threshold=False,
|
||||
)
|
||||
print(repr(t))
|
||||
|
||||
|
||||
def test_hddtemp_bad(hddtemp_output_bad):
|
||||
with patch.object(HDDTemp, "_call_hddtemp") as mock_call_hddtemp:
|
||||
mock_call_hddtemp.return_value = hddtemp_output_bad
|
||||
t = HDDTemp(
|
||||
disk_path="/dev/sda",
|
||||
min=TempCelsius(38.0),
|
||||
max=TempCelsius(45.0),
|
||||
panic=TempCelsius(50.0),
|
||||
threshold=None,
|
||||
hddtemp_bin="testbin",
|
||||
)
|
||||
with pytest.raises(RuntimeError):
|
||||
t.get()
|
||||
|
||||
|
||||
def test_hddtemp_exec_successful(temp_path):
|
||||
(temp_path / "sda").write_text("")
|
||||
(temp_path / "sdz").write_text("")
|
||||
t = HDDTemp(
|
||||
disk_path=str(temp_path / "sd") + "?",
|
||||
min=TempCelsius(38.0),
|
||||
max=TempCelsius(45.0),
|
||||
panic=TempCelsius(50.0),
|
||||
threshold=None,
|
||||
hddtemp_bin="printf '@%s'",
|
||||
)
|
||||
expected_out = "@-n@-u@C@--@{0}/sda@{0}/sdz".format(temp_path)
|
||||
assert expected_out == t._call_hddtemp()
|
||||
|
||||
|
||||
def test_hddtemp_exec_failed():
|
||||
t = HDDTemp(
|
||||
disk_path="/dev/sd?",
|
||||
min=TempCelsius(38.0),
|
||||
max=TempCelsius(45.0),
|
||||
panic=TempCelsius(50.0),
|
||||
threshold=None,
|
||||
hddtemp_bin="false",
|
||||
)
|
||||
with pytest.raises(subprocess.CalledProcessError):
|
||||
t._call_hddtemp()
|
||||
498
tests/test_config.py
Normal file
498
tests/test_config.py
Normal file
@@ -0,0 +1,498 @@
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from afancontrol.arduino import (
|
||||
ArduinoConnection,
|
||||
ArduinoName,
|
||||
ArduinoPin,
|
||||
pyserial_available,
|
||||
)
|
||||
from afancontrol.config import (
|
||||
Actions,
|
||||
AlertCommands,
|
||||
DaemonCLIConfig,
|
||||
DaemonConfig,
|
||||
FanName,
|
||||
FanSpeedModifier,
|
||||
FansTempsRelation,
|
||||
FilteredTemp,
|
||||
MappingName,
|
||||
ParsedConfig,
|
||||
ReadonlyFanName,
|
||||
TempName,
|
||||
TriggerConfig,
|
||||
parse_config,
|
||||
)
|
||||
from afancontrol.filters import MovingMedianFilter, NullFilter
|
||||
from afancontrol.pwmfan import (
|
||||
ArduinoFanPWMRead,
|
||||
ArduinoFanPWMWrite,
|
||||
ArduinoFanSpeed,
|
||||
FanInputDevice,
|
||||
LinuxFanPWMRead,
|
||||
LinuxFanPWMWrite,
|
||||
LinuxFanSpeed,
|
||||
PWMDevice,
|
||||
PWMValue,
|
||||
)
|
||||
from afancontrol.pwmfannorm import PWMFanNorm, ReadonlyPWMFanNorm
|
||||
from afancontrol.temp import FileTemp, HDDTemp, TempCelsius
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pkg_conf():
|
||||
return Path(__file__).parents[1] / "pkg" / "afancontrol.conf"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def example_conf():
|
||||
return Path(__file__).parents[0] / "data" / "afancontrol-example.conf"
|
||||
|
||||
|
||||
def path_from_str(contents: str) -> Path:
|
||||
p = Mock(spec=Path)
|
||||
p.read_text.return_value = contents
|
||||
return p
|
||||
|
||||
|
||||
@pytest.mark.skipif(not pyserial_available, reason="pyserial is not installed")
|
||||
def test_pkg_conf(pkg_conf: Path):
|
||||
daemon_cli_config = DaemonCLIConfig(
|
||||
pidfile=None, logfile=None, exporter_listen_host=None
|
||||
)
|
||||
|
||||
parsed = parse_config(pkg_conf, daemon_cli_config)
|
||||
assert parsed == ParsedConfig(
|
||||
arduino_connections={},
|
||||
daemon=DaemonConfig(
|
||||
pidfile="/run/afancontrol.pid",
|
||||
logfile="/var/log/afancontrol.log",
|
||||
interval=5,
|
||||
exporter_listen_host=None,
|
||||
),
|
||||
report_cmd=(
|
||||
'printf "Subject: %s\nTo: %s\n\n%b" '
|
||||
'"afancontrol daemon report: %REASON%" root "%MESSAGE%" | sendmail -t'
|
||||
),
|
||||
triggers=TriggerConfig(
|
||||
global_commands=Actions(
|
||||
panic=AlertCommands(enter_cmd=None, leave_cmd=None),
|
||||
threshold=AlertCommands(enter_cmd=None, leave_cmd=None),
|
||||
),
|
||||
temp_commands={
|
||||
TempName("mobo"): Actions(
|
||||
panic=AlertCommands(enter_cmd=None, leave_cmd=None),
|
||||
threshold=AlertCommands(enter_cmd=None, leave_cmd=None),
|
||||
)
|
||||
},
|
||||
),
|
||||
fans={
|
||||
FanName("hdd"): PWMFanNorm(
|
||||
fan_speed=LinuxFanSpeed(
|
||||
FanInputDevice("/sys/class/hwmon/hwmon0/device/fan2_input")
|
||||
),
|
||||
pwm_read=LinuxFanPWMRead(
|
||||
PWMDevice("/sys/class/hwmon/hwmon0/device/pwm2")
|
||||
),
|
||||
pwm_write=LinuxFanPWMWrite(
|
||||
PWMDevice("/sys/class/hwmon/hwmon0/device/pwm2")
|
||||
),
|
||||
pwm_line_start=PWMValue(100),
|
||||
pwm_line_end=PWMValue(240),
|
||||
never_stop=False,
|
||||
)
|
||||
},
|
||||
readonly_fans={
|
||||
ReadonlyFanName("cpu"): ReadonlyPWMFanNorm(
|
||||
fan_speed=LinuxFanSpeed(
|
||||
FanInputDevice("/sys/class/hwmon/hwmon0/device/fan1_input")
|
||||
),
|
||||
),
|
||||
},
|
||||
temps={
|
||||
TempName("mobo"): FilteredTemp(
|
||||
temp=FileTemp(
|
||||
"/sys/class/hwmon/hwmon0/device/temp1_input",
|
||||
min=TempCelsius(30.0),
|
||||
max=TempCelsius(40.0),
|
||||
panic=None,
|
||||
threshold=None,
|
||||
),
|
||||
filter=MovingMedianFilter(window_size=3),
|
||||
)
|
||||
},
|
||||
mappings={
|
||||
MappingName("1"): FansTempsRelation(
|
||||
temps=[TempName("mobo")],
|
||||
fans=[FanSpeedModifier(fan=FanName("hdd"), modifier=0.6)],
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not pyserial_available, reason="pyserial is not installed")
|
||||
def test_example_conf(example_conf: Path):
|
||||
daemon_cli_config = DaemonCLIConfig(
|
||||
pidfile=None, logfile=None, exporter_listen_host=None
|
||||
)
|
||||
|
||||
parsed = parse_config(example_conf, daemon_cli_config)
|
||||
assert parsed == ParsedConfig(
|
||||
arduino_connections={
|
||||
ArduinoName("mymicro"): ArduinoConnection(
|
||||
ArduinoName("mymicro"), "/dev/ttyACM0", baudrate=115200, status_ttl=5
|
||||
)
|
||||
},
|
||||
daemon=DaemonConfig(
|
||||
pidfile="/run/afancontrol.pid",
|
||||
logfile="/var/log/afancontrol.log",
|
||||
exporter_listen_host="127.0.0.1:8083",
|
||||
interval=5,
|
||||
),
|
||||
report_cmd=(
|
||||
'printf "Subject: %s\nTo: %s\n\n%b" '
|
||||
'"afancontrol daemon report: %REASON%" root "%MESSAGE%" | sendmail -t'
|
||||
),
|
||||
triggers=TriggerConfig(
|
||||
global_commands=Actions(
|
||||
panic=AlertCommands(enter_cmd=None, leave_cmd=None),
|
||||
threshold=AlertCommands(enter_cmd=None, leave_cmd=None),
|
||||
),
|
||||
temp_commands={
|
||||
TempName("hdds"): Actions(
|
||||
panic=AlertCommands(enter_cmd=None, leave_cmd=None),
|
||||
threshold=AlertCommands(enter_cmd=None, leave_cmd=None),
|
||||
),
|
||||
TempName("mobo"): Actions(
|
||||
panic=AlertCommands(enter_cmd=None, leave_cmd=None),
|
||||
threshold=AlertCommands(enter_cmd=None, leave_cmd=None),
|
||||
),
|
||||
},
|
||||
),
|
||||
fans={
|
||||
FanName("cpu"): PWMFanNorm(
|
||||
fan_speed=LinuxFanSpeed(
|
||||
FanInputDevice("/sys/class/hwmon/hwmon0/device/fan1_input")
|
||||
),
|
||||
pwm_read=LinuxFanPWMRead(
|
||||
PWMDevice("/sys/class/hwmon/hwmon0/device/pwm1")
|
||||
),
|
||||
pwm_write=LinuxFanPWMWrite(
|
||||
PWMDevice("/sys/class/hwmon/hwmon0/device/pwm1")
|
||||
),
|
||||
pwm_line_start=PWMValue(100),
|
||||
pwm_line_end=PWMValue(240),
|
||||
never_stop=True,
|
||||
),
|
||||
FanName("hdd"): PWMFanNorm(
|
||||
fan_speed=LinuxFanSpeed(
|
||||
FanInputDevice("/sys/class/hwmon/hwmon0/device/fan2_input")
|
||||
),
|
||||
pwm_read=LinuxFanPWMRead(
|
||||
PWMDevice("/sys/class/hwmon/hwmon0/device/pwm2")
|
||||
),
|
||||
pwm_write=LinuxFanPWMWrite(
|
||||
PWMDevice("/sys/class/hwmon/hwmon0/device/pwm2")
|
||||
),
|
||||
pwm_line_start=PWMValue(100),
|
||||
pwm_line_end=PWMValue(240),
|
||||
never_stop=False,
|
||||
),
|
||||
FanName("my_arduino_fan"): PWMFanNorm(
|
||||
fan_speed=ArduinoFanSpeed(
|
||||
ArduinoConnection(
|
||||
ArduinoName("mymicro"),
|
||||
"/dev/ttyACM0", # linux
|
||||
# "/dev/cu.usbmodem14201", # macos
|
||||
baudrate=115200,
|
||||
status_ttl=5,
|
||||
),
|
||||
tacho_pin=ArduinoPin(3),
|
||||
),
|
||||
pwm_read=ArduinoFanPWMRead(
|
||||
ArduinoConnection(
|
||||
ArduinoName("mymicro"),
|
||||
"/dev/ttyACM0", # linux
|
||||
# "/dev/cu.usbmodem14201", # macos
|
||||
baudrate=115200,
|
||||
status_ttl=5,
|
||||
),
|
||||
pwm_pin=ArduinoPin(9),
|
||||
),
|
||||
pwm_write=ArduinoFanPWMWrite(
|
||||
ArduinoConnection(
|
||||
ArduinoName("mymicro"),
|
||||
"/dev/ttyACM0", # linux
|
||||
# "/dev/cu.usbmodem14201", # macos
|
||||
baudrate=115200,
|
||||
status_ttl=5,
|
||||
),
|
||||
pwm_pin=ArduinoPin(9),
|
||||
),
|
||||
pwm_line_start=PWMValue(100),
|
||||
pwm_line_end=PWMValue(240),
|
||||
never_stop=True,
|
||||
),
|
||||
},
|
||||
readonly_fans={},
|
||||
temps={
|
||||
TempName("hdds"): FilteredTemp(
|
||||
temp=HDDTemp(
|
||||
"/dev/sd?",
|
||||
min=TempCelsius(35.0),
|
||||
max=TempCelsius(48.0),
|
||||
panic=TempCelsius(55.0),
|
||||
threshold=None,
|
||||
hddtemp_bin="hddtemp",
|
||||
),
|
||||
filter=NullFilter(),
|
||||
),
|
||||
TempName("mobo"): FilteredTemp(
|
||||
temp=FileTemp(
|
||||
"/sys/class/hwmon/hwmon0/device/temp1_input",
|
||||
min=TempCelsius(30.0),
|
||||
max=TempCelsius(40.0),
|
||||
panic=None,
|
||||
threshold=None,
|
||||
),
|
||||
filter=NullFilter(),
|
||||
),
|
||||
},
|
||||
mappings={
|
||||
MappingName("1"): FansTempsRelation(
|
||||
temps=[TempName("mobo"), TempName("hdds")],
|
||||
fans=[
|
||||
FanSpeedModifier(fan=FanName("cpu"), modifier=1.0),
|
||||
FanSpeedModifier(fan=FanName("hdd"), modifier=0.6),
|
||||
FanSpeedModifier(fan=FanName("my_arduino_fan"), modifier=0.222),
|
||||
],
|
||||
),
|
||||
MappingName("2"): FansTempsRelation(
|
||||
temps=[TempName("hdds")],
|
||||
fans=[FanSpeedModifier(fan=FanName("hdd"), modifier=1.0)],
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_minimal_config() -> None:
|
||||
daemon_cli_config = DaemonCLIConfig(
|
||||
pidfile=None, logfile=None, exporter_listen_host=None
|
||||
)
|
||||
|
||||
config = """
|
||||
[daemon]
|
||||
|
||||
[actions]
|
||||
|
||||
[temp:mobo]
|
||||
type = file
|
||||
path = /sys/class/hwmon/hwmon0/device/temp1_input
|
||||
|
||||
[fan: case]
|
||||
pwm = /sys/class/hwmon/hwmon0/device/pwm2
|
||||
fan_input = /sys/class/hwmon/hwmon0/device/fan2_input
|
||||
|
||||
[mapping:1]
|
||||
fans = case*0.6,
|
||||
temps = mobo
|
||||
"""
|
||||
parsed = parse_config(path_from_str(config), daemon_cli_config)
|
||||
assert parsed == ParsedConfig(
|
||||
arduino_connections={},
|
||||
daemon=DaemonConfig(
|
||||
pidfile="/run/afancontrol.pid",
|
||||
logfile=None,
|
||||
exporter_listen_host=None,
|
||||
interval=5,
|
||||
),
|
||||
report_cmd=(
|
||||
'printf "Subject: %s\nTo: %s\n\n%b" '
|
||||
'"afancontrol daemon report: %REASON%" root "%MESSAGE%" | sendmail -t'
|
||||
),
|
||||
triggers=TriggerConfig(
|
||||
global_commands=Actions(
|
||||
panic=AlertCommands(enter_cmd=None, leave_cmd=None),
|
||||
threshold=AlertCommands(enter_cmd=None, leave_cmd=None),
|
||||
),
|
||||
temp_commands={
|
||||
TempName("mobo"): Actions(
|
||||
panic=AlertCommands(enter_cmd=None, leave_cmd=None),
|
||||
threshold=AlertCommands(enter_cmd=None, leave_cmd=None),
|
||||
)
|
||||
},
|
||||
),
|
||||
fans={
|
||||
FanName("case"): PWMFanNorm(
|
||||
fan_speed=LinuxFanSpeed(
|
||||
FanInputDevice("/sys/class/hwmon/hwmon0/device/fan2_input")
|
||||
),
|
||||
pwm_read=LinuxFanPWMRead(
|
||||
PWMDevice("/sys/class/hwmon/hwmon0/device/pwm2")
|
||||
),
|
||||
pwm_write=LinuxFanPWMWrite(
|
||||
PWMDevice("/sys/class/hwmon/hwmon0/device/pwm2")
|
||||
),
|
||||
pwm_line_start=PWMValue(100),
|
||||
pwm_line_end=PWMValue(240),
|
||||
never_stop=True,
|
||||
)
|
||||
},
|
||||
readonly_fans={},
|
||||
temps={
|
||||
TempName("mobo"): FilteredTemp(
|
||||
temp=FileTemp(
|
||||
"/sys/class/hwmon/hwmon0/device/temp1_input",
|
||||
min=None,
|
||||
max=None,
|
||||
panic=None,
|
||||
threshold=None,
|
||||
),
|
||||
filter=NullFilter(),
|
||||
)
|
||||
},
|
||||
mappings={
|
||||
MappingName("1"): FansTempsRelation(
|
||||
temps=[TempName("mobo")],
|
||||
fans=[FanSpeedModifier(fan=FanName("case"), modifier=0.6)],
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_readonly_config() -> None:
|
||||
daemon_cli_config = DaemonCLIConfig(
|
||||
pidfile=None, logfile=None, exporter_listen_host=None
|
||||
)
|
||||
|
||||
config = """
|
||||
[daemon]
|
||||
|
||||
[actions]
|
||||
|
||||
[temp:mobo]
|
||||
type = file
|
||||
path = /sys/class/hwmon/hwmon0/device/temp1_input
|
||||
|
||||
[readonly_fan: cpu]
|
||||
pwm = /sys/class/hwmon/hwmon0/device/pwm1
|
||||
fan_input = /sys/class/hwmon/hwmon0/device/fan1_input
|
||||
"""
|
||||
parsed = parse_config(path_from_str(config), daemon_cli_config)
|
||||
assert parsed == ParsedConfig(
|
||||
arduino_connections={},
|
||||
daemon=DaemonConfig(
|
||||
pidfile="/run/afancontrol.pid",
|
||||
logfile=None,
|
||||
exporter_listen_host=None,
|
||||
interval=5,
|
||||
),
|
||||
report_cmd=(
|
||||
'printf "Subject: %s\nTo: %s\n\n%b" '
|
||||
'"afancontrol daemon report: %REASON%" root "%MESSAGE%" | sendmail -t'
|
||||
),
|
||||
triggers=TriggerConfig(
|
||||
global_commands=Actions(
|
||||
panic=AlertCommands(enter_cmd=None, leave_cmd=None),
|
||||
threshold=AlertCommands(enter_cmd=None, leave_cmd=None),
|
||||
),
|
||||
temp_commands={
|
||||
TempName("mobo"): Actions(
|
||||
panic=AlertCommands(enter_cmd=None, leave_cmd=None),
|
||||
threshold=AlertCommands(enter_cmd=None, leave_cmd=None),
|
||||
)
|
||||
},
|
||||
),
|
||||
fans={},
|
||||
readonly_fans={
|
||||
ReadonlyFanName("cpu"): ReadonlyPWMFanNorm(
|
||||
fan_speed=LinuxFanSpeed(
|
||||
FanInputDevice("/sys/class/hwmon/hwmon0/device/fan1_input")
|
||||
),
|
||||
pwm_read=LinuxFanPWMRead(
|
||||
PWMDevice("/sys/class/hwmon/hwmon0/device/pwm1")
|
||||
),
|
||||
)
|
||||
},
|
||||
temps={
|
||||
TempName("mobo"): FilteredTemp(
|
||||
temp=FileTemp(
|
||||
"/sys/class/hwmon/hwmon0/device/temp1_input",
|
||||
min=None,
|
||||
max=None,
|
||||
panic=None,
|
||||
threshold=None,
|
||||
),
|
||||
filter=NullFilter(),
|
||||
)
|
||||
},
|
||||
mappings={},
|
||||
)
|
||||
|
||||
|
||||
def test_multiline_mapping():
|
||||
daemon_cli_config = DaemonCLIConfig(
|
||||
pidfile=None, logfile=None, exporter_listen_host=None
|
||||
)
|
||||
|
||||
config = """
|
||||
[daemon]
|
||||
|
||||
[actions]
|
||||
|
||||
[temp:cpu]
|
||||
type = file
|
||||
path = /sys/class/hwmon/hwmon0/device/temp1_input
|
||||
|
||||
[temp:mobo]
|
||||
type = file
|
||||
path = /sys/class/hwmon/hwmon0/device/temp2_input
|
||||
|
||||
[fan: case]
|
||||
pwm = /sys/class/hwmon/hwmon0/device/pwm2
|
||||
fan_input = /sys/class/hwmon/hwmon0/device/fan2_input
|
||||
|
||||
[fan: hdd]
|
||||
pwm = /sys/class/hwmon/hwmon0/device/pwm2
|
||||
fan_input = /sys/class/hwmon/hwmon0/device/fan2_input
|
||||
|
||||
[mapping:1]
|
||||
fans =
|
||||
case*0.6,
|
||||
hdd,
|
||||
temps =
|
||||
mobo,
|
||||
cpu
|
||||
"""
|
||||
parsed = parse_config(path_from_str(config), daemon_cli_config)
|
||||
assert parsed.mappings == {
|
||||
MappingName("1"): FansTempsRelation(
|
||||
temps=[TempName("mobo"), TempName("cpu")],
|
||||
fans=[
|
||||
FanSpeedModifier(fan=FanName("case"), modifier=0.6),
|
||||
FanSpeedModifier(fan=FanName("hdd"), modifier=1.0),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def test_extraneous_keys_raises():
|
||||
daemon_cli_config = DaemonCLIConfig(
|
||||
pidfile=None, logfile=None, exporter_listen_host=None
|
||||
)
|
||||
|
||||
config = """
|
||||
[daemon]
|
||||
|
||||
[actions]
|
||||
|
||||
[temp: mobo]
|
||||
type = file
|
||||
path = /sys/class/hwmon/hwmon0/device/temp1_input
|
||||
aa = 55
|
||||
"""
|
||||
with pytest.raises(RuntimeError) as cm:
|
||||
parse_config(path_from_str(config), daemon_cli_config)
|
||||
assert str(cm.value) == "Unknown options in the [temp: mobo] section: {'aa'}"
|
||||
98
tests/test_daemon.py
Normal file
98
tests/test_daemon.py
Normal file
@@ -0,0 +1,98 @@
|
||||
import threading
|
||||
from contextlib import ExitStack
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from afancontrol import daemon
|
||||
from afancontrol.daemon import PidFile, Signals, daemon as main
|
||||
|
||||
|
||||
def test_main_smoke(temp_path):
|
||||
pwm_path = temp_path / "pwm" / "pwm2"
|
||||
pwm_enable_path = temp_path / "pwm" / "pwm2_enable"
|
||||
pwm_faninput_path = temp_path / "pwm" / "fan2_input"
|
||||
pwm_path.parents[0].mkdir(parents=True)
|
||||
pwm_path.write_text("100")
|
||||
pwm_enable_path.write_text("0")
|
||||
pwm_faninput_path.write_text("999")
|
||||
|
||||
config_path = temp_path / "afancontrol.conf"
|
||||
config_path.write_text(
|
||||
"""
|
||||
[daemon]
|
||||
hddtemp = true
|
||||
|
||||
[actions]
|
||||
|
||||
[temp:mobo]
|
||||
type = file
|
||||
path = /fake/sys/class/hwmon/hwmon0/device/temp1_input
|
||||
|
||||
[fan: case]
|
||||
pwm = %(pwm_path)s
|
||||
fan_input = %(pwm_faninput_path)s
|
||||
|
||||
[mapping:1]
|
||||
fans = case*0.6,
|
||||
temps = mobo
|
||||
"""
|
||||
% dict(pwm_path=pwm_path, pwm_faninput_path=pwm_faninput_path)
|
||||
)
|
||||
|
||||
with ExitStack() as stack:
|
||||
mocked_tick = stack.enter_context(patch.object(daemon.Manager, "tick"))
|
||||
stack.enter_context(patch.object(daemon, "signal"))
|
||||
stack.enter_context(
|
||||
patch.object(daemon.Signals, "wait_for_term_queued", return_value=True)
|
||||
)
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
[
|
||||
"--verbose",
|
||||
"--config",
|
||||
str(config_path),
|
||||
"--pidfile",
|
||||
str(temp_path / "afancontrol.pid"),
|
||||
"--logfile",
|
||||
str(temp_path / "afancontrol.log"),
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert mocked_tick.call_count == 1
|
||||
|
||||
|
||||
def test_pidfile_not_existing(temp_path):
|
||||
pidpath = temp_path / "test.pid"
|
||||
pidfile = PidFile(str(pidpath))
|
||||
|
||||
with pidfile:
|
||||
pidfile.save_pid(42)
|
||||
assert "42" == pidpath.read_text()
|
||||
|
||||
assert not pidpath.exists()
|
||||
|
||||
|
||||
def test_pidfile_existing_raises(temp_path):
|
||||
pidpath = temp_path / "test.pid"
|
||||
pidfile = PidFile(str(pidpath))
|
||||
pidpath.write_text("42")
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
with pidfile:
|
||||
pytest.fail("Should not be reached")
|
||||
|
||||
assert pidpath.exists()
|
||||
|
||||
|
||||
def test_signals():
|
||||
s = Signals()
|
||||
|
||||
assert False is s.wait_for_term_queued(0.001)
|
||||
|
||||
threading.Timer(0.01, lambda: s.sigterm(None, None)).start()
|
||||
assert True is s.wait_for_term_queued(1e6)
|
||||
31
tests/test_exec.py
Normal file
31
tests/test_exec.py
Normal file
@@ -0,0 +1,31 @@
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
from afancontrol.exec import exec_shell_command
|
||||
|
||||
|
||||
def test_exec_shell_command_successful():
|
||||
assert "42\n" == exec_shell_command("echo 42")
|
||||
|
||||
|
||||
def test_exec_shell_command_ignores_stderr():
|
||||
assert "42\n" == exec_shell_command("echo 111 >&2; echo 42")
|
||||
|
||||
|
||||
def test_exec_shell_command_erroneous():
|
||||
with pytest.raises(subprocess.SubprocessError):
|
||||
exec_shell_command("echo 42 && false")
|
||||
|
||||
|
||||
def test_exec_shell_command_raises_for_unicode():
|
||||
with pytest.raises(ValueError):
|
||||
exec_shell_command("echo привет")
|
||||
|
||||
|
||||
def test_exec_shell_command_expands_glob(temp_path):
|
||||
(temp_path / "sda").write_text("")
|
||||
(temp_path / "sdb").write_text("")
|
||||
|
||||
expected = "{0}/sda {0}/sdb\n".format(temp_path)
|
||||
assert expected == exec_shell_command('echo "%s/sd"?' % temp_path)
|
||||
69
tests/test_fans.py
Normal file
69
tests/test_fans.py
Normal file
@@ -0,0 +1,69 @@
|
||||
from collections import OrderedDict
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from afancontrol.config import FanName
|
||||
from afancontrol.fans import Fans
|
||||
from afancontrol.pwmfan import BaseFanPWMRead
|
||||
from afancontrol.pwmfannorm import PWMFanNorm, PWMValueNorm
|
||||
from afancontrol.report import Report
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def report():
|
||||
return MagicMock(spec=Report)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("is_fan_failing", [False, True])
|
||||
def test_smoke(report, is_fan_failing):
|
||||
fan = MagicMock(spec=PWMFanNorm)
|
||||
fans = Fans(fans={FanName("test"): fan}, readonly_fans={}, report=report)
|
||||
|
||||
fan.set = lambda pwm_norm: int(255 * pwm_norm)
|
||||
fan.get_speed.return_value = 0 if is_fan_failing else 942
|
||||
fan.is_pwm_stopped = BaseFanPWMRead.is_pwm_stopped
|
||||
|
||||
with fans:
|
||||
assert 1 == fan.__enter__.call_count
|
||||
fans.check_speeds()
|
||||
fans.set_all_to_full_speed()
|
||||
fans.set_fan_speeds({FanName("test"): PWMValueNorm(0.42)})
|
||||
assert fan.get_speed.call_count == 1
|
||||
if is_fan_failing:
|
||||
assert fans._failed_fans == {"test"}
|
||||
assert fans._stopped_fans == set()
|
||||
else:
|
||||
assert fans._failed_fans == set()
|
||||
assert fans._stopped_fans == set()
|
||||
|
||||
assert 1 == fan.__exit__.call_count
|
||||
|
||||
|
||||
def test_set_fan_speeds(report):
|
||||
mocked_fans = OrderedDict(
|
||||
[
|
||||
(FanName("test1"), MagicMock(spec=PWMFanNorm)),
|
||||
(FanName("test2"), MagicMock(spec=PWMFanNorm)),
|
||||
(FanName("test3"), MagicMock(spec=PWMFanNorm)),
|
||||
(FanName("test4"), MagicMock(spec=PWMFanNorm)),
|
||||
]
|
||||
)
|
||||
|
||||
for fan in mocked_fans.values():
|
||||
fan.set.return_value = 240
|
||||
fan.get_speed.return_value = 942
|
||||
fan.is_pwm_stopped = BaseFanPWMRead.is_pwm_stopped
|
||||
|
||||
fans = Fans(fans=mocked_fans, readonly_fans={}, report=report)
|
||||
with fans:
|
||||
fans._ensure_fan_is_failing(FanName("test2"), Exception("test"))
|
||||
fans.set_fan_speeds(
|
||||
{
|
||||
FanName("test1"): PWMValueNorm(0.42),
|
||||
FanName("test2"): PWMValueNorm(0.42),
|
||||
FanName("test3"): PWMValueNorm(0.42),
|
||||
FanName("test4"): PWMValueNorm(0.42),
|
||||
}
|
||||
)
|
||||
assert [1, 0, 1, 1] == [f.set.call_count for f in mocked_fans.values()]
|
||||
105
tests/test_fantest.py
Normal file
105
tests/test_fantest.py
Normal file
@@ -0,0 +1,105 @@
|
||||
from contextlib import ExitStack
|
||||
from typing import Any, Type
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from afancontrol import fantest
|
||||
from afancontrol.fantest import (
|
||||
CSVMeasurementsOutput,
|
||||
HumanMeasurementsOutput,
|
||||
MeasurementsOutput,
|
||||
fantest as main,
|
||||
run_fantest,
|
||||
)
|
||||
from afancontrol.pwmfan import (
|
||||
BaseFanPWMRead,
|
||||
BaseFanPWMWrite,
|
||||
BaseFanSpeed,
|
||||
FanInputDevice,
|
||||
LinuxFanPWMRead,
|
||||
LinuxFanPWMWrite,
|
||||
LinuxFanSpeed,
|
||||
PWMDevice,
|
||||
PWMValue,
|
||||
ReadWriteFan,
|
||||
)
|
||||
|
||||
|
||||
def test_main_smoke(temp_path):
|
||||
pwm_path = temp_path / "pwm2"
|
||||
pwm_path.write_text("")
|
||||
fan_input_path = temp_path / "fan2_input"
|
||||
fan_input_path.write_text("")
|
||||
|
||||
with ExitStack() as stack:
|
||||
mocked_fantest = stack.enter_context(patch.object(fantest, "run_fantest"))
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
[
|
||||
"--fan-type",
|
||||
"linux",
|
||||
"--linux-fan-pwm",
|
||||
# "/sys/class/hwmon/hwmon0/device/pwm2",
|
||||
str(pwm_path), # click verifies that this file exists
|
||||
"--linux-fan-input",
|
||||
# "/sys/class/hwmon/hwmon0/device/fan2_input",
|
||||
str(fan_input_path), # click verifies that this file exists
|
||||
"--output-format",
|
||||
"human",
|
||||
"--direction",
|
||||
"increase",
|
||||
"--pwm-step-size",
|
||||
"accurate",
|
||||
],
|
||||
)
|
||||
|
||||
print(result.output)
|
||||
assert result.exit_code == 0
|
||||
|
||||
assert mocked_fantest.call_count == 1
|
||||
|
||||
args, kwargs = mocked_fantest.call_args
|
||||
assert not args
|
||||
assert kwargs.keys() == {"fan", "pwm_step_size", "output"}
|
||||
assert kwargs["fan"] == ReadWriteFan(
|
||||
fan_speed=LinuxFanSpeed(FanInputDevice(str(fan_input_path))),
|
||||
pwm_read=LinuxFanPWMRead(PWMDevice(str(pwm_path))),
|
||||
pwm_write=LinuxFanPWMWrite(PWMDevice(str(pwm_path))),
|
||||
)
|
||||
assert kwargs["pwm_step_size"] == 5
|
||||
assert isinstance(kwargs["output"], HumanMeasurementsOutput)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("pwm_step_size", [5, -5])
|
||||
@pytest.mark.parametrize("output_cls", [HumanMeasurementsOutput, CSVMeasurementsOutput])
|
||||
def test_fantest(output_cls: Type[MeasurementsOutput], pwm_step_size: PWMValue):
|
||||
fan: Any = ReadWriteFan(
|
||||
fan_speed=MagicMock(spec=BaseFanSpeed),
|
||||
pwm_read=MagicMock(spec=BaseFanPWMRead),
|
||||
pwm_write=MagicMock(spec=BaseFanPWMWrite),
|
||||
)
|
||||
fan.pwm_read.min_pwm = 0
|
||||
fan.pwm_read.max_pwm = 255
|
||||
output = output_cls()
|
||||
|
||||
with ExitStack() as stack:
|
||||
mocked_sleep = stack.enter_context(patch.object(fantest, "sleep"))
|
||||
fan.fan_speed.get_speed.return_value = 999
|
||||
|
||||
run_fantest(fan=fan, pwm_step_size=pwm_step_size, output=output)
|
||||
|
||||
assert fan.pwm_write.set.call_count == (255 // abs(pwm_step_size)) + 1
|
||||
assert fan.fan_speed.get_speed.call_count == (255 // abs(pwm_step_size))
|
||||
assert mocked_sleep.call_count == (255 // abs(pwm_step_size)) + 1
|
||||
|
||||
if pwm_step_size > 0:
|
||||
# increase
|
||||
expected_set = [0] + list(range(0, 255, pwm_step_size))
|
||||
else:
|
||||
# decrease
|
||||
expected_set = [255] + list(range(255, 0, pwm_step_size))
|
||||
assert [pwm for (pwm,), _ in fan.pwm_write.set.call_args_list] == expected_set
|
||||
74
tests/test_filters.py
Normal file
74
tests/test_filters.py
Normal file
@@ -0,0 +1,74 @@
|
||||
import pytest
|
||||
|
||||
from afancontrol.filters import MovingMedianFilter, MovingQuantileFilter, NullFilter
|
||||
from afancontrol.temp import TempCelsius, TempStatus
|
||||
|
||||
|
||||
def make_temp_status(temp):
|
||||
return TempStatus(
|
||||
min=TempCelsius(30),
|
||||
max=TempCelsius(50),
|
||||
temp=TempCelsius(temp),
|
||||
panic=None,
|
||||
threshold=None,
|
||||
is_panic=False,
|
||||
is_threshold=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"filter",
|
||||
[
|
||||
NullFilter(),
|
||||
MovingMedianFilter(window_size=3),
|
||||
MovingQuantileFilter(0.5, window_size=3),
|
||||
],
|
||||
)
|
||||
def test_none(filter):
|
||||
with filter:
|
||||
assert filter.apply(None) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"filter",
|
||||
[
|
||||
NullFilter(),
|
||||
MovingMedianFilter(window_size=3),
|
||||
MovingQuantileFilter(0.5, window_size=3),
|
||||
],
|
||||
)
|
||||
def test_single_point(filter):
|
||||
with filter:
|
||||
assert filter.apply(make_temp_status(42.0)) == make_temp_status(42.0)
|
||||
|
||||
|
||||
def test_moving_quantile():
|
||||
f = MovingQuantileFilter(0.8, window_size=10)
|
||||
with f:
|
||||
assert f.apply(make_temp_status(42.0)) == make_temp_status(42.0)
|
||||
assert f.apply(make_temp_status(45.0)) == make_temp_status(45.0)
|
||||
assert f.apply(make_temp_status(47.0)) == make_temp_status(47.0)
|
||||
assert f.apply(make_temp_status(123.0)) == make_temp_status(123.0)
|
||||
assert f.apply(make_temp_status(46.0)) == make_temp_status(123.0)
|
||||
assert f.apply(make_temp_status(49.0)) == make_temp_status(49.0)
|
||||
assert f.apply(make_temp_status(51.0)) == make_temp_status(51.0)
|
||||
assert f.apply(None) == make_temp_status(123.0)
|
||||
assert f.apply(None) is None
|
||||
assert f.apply(make_temp_status(51.0)) is None
|
||||
assert f.apply(make_temp_status(53.0)) is None
|
||||
|
||||
|
||||
def test_moving_median():
|
||||
f = MovingMedianFilter(window_size=3)
|
||||
with f:
|
||||
assert f.apply(make_temp_status(42.0)) == make_temp_status(42.0)
|
||||
assert f.apply(make_temp_status(45.0)) == make_temp_status(45.0)
|
||||
assert f.apply(make_temp_status(47.0)) == make_temp_status(45.0)
|
||||
assert f.apply(make_temp_status(123.0)) == make_temp_status(47.0)
|
||||
assert f.apply(make_temp_status(46.0)) == make_temp_status(47.0)
|
||||
assert f.apply(make_temp_status(49.0)) == make_temp_status(49.0)
|
||||
assert f.apply(make_temp_status(51.0)) == make_temp_status(49.0)
|
||||
assert f.apply(None) == make_temp_status(51.0)
|
||||
assert f.apply(None) is None
|
||||
assert f.apply(make_temp_status(51.0)) is None
|
||||
assert f.apply(make_temp_status(53.0)) == make_temp_status(53.0)
|
||||
252
tests/test_manager.py
Normal file
252
tests/test_manager.py
Normal file
@@ -0,0 +1,252 @@
|
||||
from contextlib import ExitStack
|
||||
from typing import cast
|
||||
from unittest.mock import MagicMock, patch, sentinel
|
||||
|
||||
import pytest
|
||||
|
||||
import afancontrol.manager
|
||||
from afancontrol.config import (
|
||||
Actions,
|
||||
AlertCommands,
|
||||
FanName,
|
||||
FanSpeedModifier,
|
||||
FansTempsRelation,
|
||||
MappingName,
|
||||
TempName,
|
||||
TriggerConfig,
|
||||
)
|
||||
from afancontrol.manager import Manager
|
||||
from afancontrol.metrics import Metrics
|
||||
from afancontrol.pwmfannorm import PWMFanNorm, PWMValueNorm
|
||||
from afancontrol.report import Report
|
||||
from afancontrol.temp import FileTemp, TempCelsius, TempStatus
|
||||
from afancontrol.trigger import Triggers
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def report():
|
||||
return MagicMock(spec=Report)
|
||||
|
||||
|
||||
def test_manager(report):
|
||||
mocked_case_fan = MagicMock(spec=PWMFanNorm)()
|
||||
mocked_mobo_temp = MagicMock(spec=FileTemp)()
|
||||
mocked_metrics = MagicMock(spec=Metrics)()
|
||||
|
||||
with ExitStack() as stack:
|
||||
stack.enter_context(
|
||||
patch.object(afancontrol.manager, "Triggers", spec=Triggers)
|
||||
)
|
||||
|
||||
manager = Manager(
|
||||
arduino_connections={},
|
||||
fans={FanName("case"): mocked_case_fan},
|
||||
readonly_fans={},
|
||||
temps={TempName("mobo"): mocked_mobo_temp},
|
||||
mappings={
|
||||
MappingName("1"): FansTempsRelation(
|
||||
temps=[TempName("mobo")],
|
||||
fans=[FanSpeedModifier(fan=FanName("case"), modifier=0.6)],
|
||||
)
|
||||
},
|
||||
report=report,
|
||||
triggers_config=TriggerConfig(
|
||||
global_commands=Actions(
|
||||
panic=AlertCommands(enter_cmd=None, leave_cmd=None),
|
||||
threshold=AlertCommands(enter_cmd=None, leave_cmd=None),
|
||||
),
|
||||
temp_commands={
|
||||
TempName("mobo"): Actions(
|
||||
panic=AlertCommands(enter_cmd=None, leave_cmd=None),
|
||||
threshold=AlertCommands(enter_cmd=None, leave_cmd=None),
|
||||
)
|
||||
},
|
||||
),
|
||||
metrics=mocked_metrics,
|
||||
)
|
||||
|
||||
stack.enter_context(manager)
|
||||
|
||||
manager.tick()
|
||||
|
||||
mocked_triggers = cast(MagicMock, manager.triggers)
|
||||
assert mocked_triggers.check.call_count == 1
|
||||
assert mocked_case_fan.__enter__.call_count == 1
|
||||
assert mocked_metrics.__enter__.call_count == 1
|
||||
assert mocked_metrics.tick.call_count == 1
|
||||
assert mocked_case_fan.__exit__.call_count == 1
|
||||
assert mocked_metrics.__exit__.call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"temps, mappings, expected_fan_speeds",
|
||||
[
|
||||
(
|
||||
{
|
||||
TempName("cpu"): TempStatus(
|
||||
min=TempCelsius(30),
|
||||
max=TempCelsius(50),
|
||||
temp=TempCelsius((50 - 30) * 0.42 + 30),
|
||||
panic=None,
|
||||
threshold=None,
|
||||
is_panic=False,
|
||||
is_threshold=False,
|
||||
),
|
||||
TempName("hdd"): None, # a failing sensor
|
||||
},
|
||||
{
|
||||
MappingName("all"): FansTempsRelation(
|
||||
temps=[TempName("cpu"), TempName("hdd")],
|
||||
fans=[FanSpeedModifier(fan=FanName("rear"), modifier=1.0)],
|
||||
)
|
||||
},
|
||||
{FanName("rear"): PWMValueNorm(1.0)},
|
||||
),
|
||||
(
|
||||
{
|
||||
TempName("cpu"): TempStatus(
|
||||
min=TempCelsius(30),
|
||||
max=TempCelsius(50),
|
||||
temp=TempCelsius((50 - 30) * 0.42 + 30),
|
||||
panic=None,
|
||||
threshold=None,
|
||||
is_panic=False,
|
||||
is_threshold=False,
|
||||
)
|
||||
},
|
||||
{
|
||||
MappingName("all"): FansTempsRelation(
|
||||
temps=[TempName("cpu")],
|
||||
fans=[FanSpeedModifier(fan=FanName("rear"), modifier=1.0)],
|
||||
)
|
||||
},
|
||||
{FanName("rear"): PWMValueNorm(0.42)},
|
||||
),
|
||||
(
|
||||
{
|
||||
TempName("cpu"): TempStatus(
|
||||
min=TempCelsius(30),
|
||||
max=TempCelsius(50),
|
||||
temp=TempCelsius((50 - 30) * 0.42 + 30),
|
||||
panic=None,
|
||||
threshold=None,
|
||||
is_panic=False,
|
||||
is_threshold=False,
|
||||
)
|
||||
},
|
||||
{
|
||||
MappingName("all"): FansTempsRelation(
|
||||
temps=[TempName("cpu")],
|
||||
fans=[FanSpeedModifier(fan=FanName("rear"), modifier=0.6)],
|
||||
)
|
||||
},
|
||||
{FanName("rear"): PWMValueNorm(0.42 * 0.6)},
|
||||
),
|
||||
(
|
||||
{
|
||||
TempName("cpu"): TempStatus(
|
||||
min=TempCelsius(30),
|
||||
max=TempCelsius(50),
|
||||
temp=TempCelsius((50 - 30) * 0.42 + 30),
|
||||
panic=None,
|
||||
threshold=None,
|
||||
is_panic=False,
|
||||
is_threshold=False,
|
||||
),
|
||||
TempName("mobo"): TempStatus(
|
||||
min=TempCelsius(30),
|
||||
max=TempCelsius(50),
|
||||
temp=TempCelsius((50 - 30) * 0.52 + 30),
|
||||
panic=None,
|
||||
threshold=None,
|
||||
is_panic=False,
|
||||
is_threshold=False,
|
||||
),
|
||||
TempName("hdd"): TempStatus(
|
||||
min=TempCelsius(30),
|
||||
max=TempCelsius(50),
|
||||
temp=TempCelsius((50 - 30) * 0.12 + 30),
|
||||
panic=None,
|
||||
threshold=None,
|
||||
is_panic=False,
|
||||
is_threshold=False,
|
||||
),
|
||||
},
|
||||
{
|
||||
MappingName("all"): FansTempsRelation(
|
||||
temps=[TempName("cpu"), TempName("mobo"), TempName("hdd")],
|
||||
fans=[FanSpeedModifier(fan=FanName("rear"), modifier=1.0)],
|
||||
)
|
||||
},
|
||||
{FanName("rear"): PWMValueNorm(0.52)},
|
||||
),
|
||||
(
|
||||
{
|
||||
TempName("cpu"): TempStatus(
|
||||
min=TempCelsius(30),
|
||||
max=TempCelsius(50),
|
||||
temp=TempCelsius((50 - 30) * 0.42 + 30),
|
||||
panic=None,
|
||||
threshold=None,
|
||||
is_panic=False,
|
||||
is_threshold=False,
|
||||
),
|
||||
TempName("mobo"): TempStatus(
|
||||
min=TempCelsius(30),
|
||||
max=TempCelsius(50),
|
||||
temp=TempCelsius((50 - 30) * 0.52 + 30),
|
||||
panic=None,
|
||||
threshold=None,
|
||||
is_panic=False,
|
||||
is_threshold=False,
|
||||
),
|
||||
TempName("hdd"): TempStatus(
|
||||
min=TempCelsius(30),
|
||||
max=TempCelsius(50),
|
||||
temp=TempCelsius((50 - 30) * 0.12 + 30),
|
||||
panic=None,
|
||||
threshold=None,
|
||||
is_panic=False,
|
||||
is_threshold=False,
|
||||
),
|
||||
},
|
||||
{
|
||||
MappingName("1"): FansTempsRelation(
|
||||
temps=[TempName("cpu"), TempName("hdd")],
|
||||
fans=[FanSpeedModifier(fan=FanName("rear"), modifier=1.0)],
|
||||
),
|
||||
MappingName("2"): FansTempsRelation(
|
||||
temps=[TempName("mobo"), TempName("hdd")],
|
||||
fans=[FanSpeedModifier(fan=FanName("rear"), modifier=0.6)],
|
||||
),
|
||||
},
|
||||
{FanName("rear"): PWMValueNorm(0.42)},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_fan_speeds(report, temps, mappings, expected_fan_speeds):
|
||||
mocked_case_fan = MagicMock(spec=PWMFanNorm)()
|
||||
mocked_mobo_temp = MagicMock(spec=FileTemp)()
|
||||
mocked_metrics = MagicMock(spec=Metrics)()
|
||||
|
||||
with ExitStack() as stack:
|
||||
stack.enter_context(
|
||||
patch.object(afancontrol.manager, "Triggers", spec=Triggers)
|
||||
)
|
||||
|
||||
manager = Manager(
|
||||
arduino_connections={},
|
||||
fans={fan_name: mocked_case_fan for fan_name in expected_fan_speeds.keys()},
|
||||
readonly_fans={},
|
||||
temps={temp_name: mocked_mobo_temp for temp_name in temps.keys()},
|
||||
mappings=mappings,
|
||||
report=report,
|
||||
triggers_config=sentinel.some_triggers_config,
|
||||
metrics=mocked_metrics,
|
||||
)
|
||||
|
||||
stack.enter_context(manager)
|
||||
|
||||
assert expected_fan_speeds == pytest.approx(
|
||||
dict(manager._map_temps_to_fan_speeds(temps))
|
||||
)
|
||||
156
tests/test_metrics.py
Normal file
156
tests/test_metrics.py
Normal file
@@ -0,0 +1,156 @@
|
||||
import random
|
||||
import types
|
||||
from time import sleep
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from afancontrol.config import FanName, TempName
|
||||
from afancontrol.fans import Fans
|
||||
from afancontrol.metrics import PrometheusMetrics, prometheus_available
|
||||
from afancontrol.pwmfannorm import PWMFanNorm
|
||||
from afancontrol.report import Report
|
||||
from afancontrol.temp import TempCelsius, TempStatus
|
||||
from afancontrol.temps import ObservedTempStatus
|
||||
from afancontrol.trigger import Triggers
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def requests_session():
|
||||
# Ignore system proxies, see https://stackoverflow.com/a/28521696
|
||||
with requests.Session() as session:
|
||||
session.trust_env = False
|
||||
yield session
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not prometheus_available, reason="prometheus_client is not installed"
|
||||
)
|
||||
def test_prometheus_metrics(requests_session):
|
||||
mocked_fan = MagicMock(spec=PWMFanNorm)()
|
||||
mocked_triggers = MagicMock(spec=Triggers)()
|
||||
mocked_report = MagicMock(spec=Report)()
|
||||
|
||||
port = random.randint(20000, 50000)
|
||||
metrics = PrometheusMetrics("127.0.0.1:%s" % port)
|
||||
with metrics:
|
||||
resp = requests_session.get("http://127.0.0.1:%s/metrics" % port)
|
||||
assert resp.status_code == 200
|
||||
assert "is_threshold 0.0" in resp.text
|
||||
|
||||
with metrics.measure_tick():
|
||||
sleep(0.01)
|
||||
|
||||
resp = requests_session.get("http://127.0.0.1:%s/metrics" % port)
|
||||
assert resp.status_code == 200
|
||||
assert "tick_duration_count 1.0" in resp.text
|
||||
assert "tick_duration_sum 0." in resp.text
|
||||
|
||||
mocked_triggers.panic_trigger.is_alerting = True
|
||||
mocked_triggers.threshold_trigger.is_alerting = False
|
||||
|
||||
mocked_fan.pwm_line_start = 100
|
||||
mocked_fan.pwm_line_end = 240
|
||||
mocked_fan.get_speed.return_value = 999
|
||||
mocked_fan.get_raw.return_value = 142
|
||||
mocked_fan.get = types.MethodType(PWMFanNorm.get, mocked_fan)
|
||||
mocked_fan.pwm_read.max_pwm = 255
|
||||
|
||||
metrics.tick(
|
||||
temps={
|
||||
TempName("goodtemp"): ObservedTempStatus(
|
||||
filtered=TempStatus(
|
||||
temp=TempCelsius(74.0),
|
||||
min=TempCelsius(40.0),
|
||||
max=TempCelsius(50.0),
|
||||
panic=TempCelsius(60.0),
|
||||
threshold=None,
|
||||
is_panic=True,
|
||||
is_threshold=False,
|
||||
),
|
||||
raw=TempStatus(
|
||||
temp=TempCelsius(72.0),
|
||||
min=TempCelsius(40.0),
|
||||
max=TempCelsius(50.0),
|
||||
panic=TempCelsius(60.0),
|
||||
threshold=None,
|
||||
is_panic=True,
|
||||
is_threshold=False,
|
||||
),
|
||||
),
|
||||
TempName("failingtemp"): ObservedTempStatus(filtered=None, raw=None),
|
||||
},
|
||||
fans=Fans(
|
||||
fans={FanName("test"): mocked_fan},
|
||||
readonly_fans={},
|
||||
report=mocked_report,
|
||||
),
|
||||
triggers=mocked_triggers,
|
||||
arduino_connections={},
|
||||
)
|
||||
|
||||
resp = requests_session.get("http://127.0.0.1:%s/metrics" % port)
|
||||
assert resp.status_code == 200
|
||||
print(resp.text)
|
||||
assert 'temperature_current{temp_name="failingtemp"} NaN' in resp.text
|
||||
assert 'temperature_current_raw{temp_name="failingtemp"} NaN' in resp.text
|
||||
assert 'temperature_current{temp_name="goodtemp"} 74.0' in resp.text
|
||||
assert 'temperature_current_raw{temp_name="goodtemp"} 72.0' in resp.text
|
||||
assert 'temperature_is_failing{temp_name="failingtemp"} 1.0' in resp.text
|
||||
assert 'temperature_is_failing{temp_name="goodtemp"} 0.0' in resp.text
|
||||
assert 'fan_rpm{fan_name="test"} 999.0' in resp.text
|
||||
assert 'fan_pwm{fan_name="test"} 142.0' in resp.text
|
||||
assert 'fan_pwm_normalized{fan_name="test"} 0.556' in resp.text
|
||||
assert 'fan_is_failing{fan_name="test"} 0.0' in resp.text
|
||||
assert "is_panic 1.0" in resp.text
|
||||
assert "is_threshold 0.0" in resp.text
|
||||
assert "last_metrics_tick_seconds_ago 0." in resp.text
|
||||
|
||||
with pytest.raises(IOError):
|
||||
requests_session.get("http://127.0.0.1:%s/metrics" % port)
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not prometheus_available, reason="prometheus_client is not installed"
|
||||
)
|
||||
def test_prometheus_faulty_fans_dont_break_metrics_collection(requests_session):
|
||||
mocked_fan = MagicMock(spec=PWMFanNorm)()
|
||||
mocked_triggers = MagicMock(spec=Triggers)()
|
||||
mocked_report = MagicMock(spec=Report)()
|
||||
|
||||
port = random.randint(20000, 50000)
|
||||
metrics = PrometheusMetrics("127.0.0.1:%s" % port)
|
||||
with metrics:
|
||||
mocked_triggers.panic_trigger.is_alerting = False
|
||||
mocked_triggers.threshold_trigger.is_alerting = False
|
||||
|
||||
mocked_fan.pwm_line_start = 100
|
||||
mocked_fan.pwm_line_end = 240
|
||||
mocked_fan.get_speed.side_effect = IOError
|
||||
mocked_fan.get_raw.side_effect = IOError
|
||||
|
||||
# Must not raise despite the PWMFan methods raising above:
|
||||
metrics.tick(
|
||||
temps={
|
||||
TempName("failingtemp"): ObservedTempStatus(filtered=None, raw=None)
|
||||
},
|
||||
fans=Fans(
|
||||
fans={FanName("test"): mocked_fan},
|
||||
readonly_fans={},
|
||||
report=mocked_report,
|
||||
),
|
||||
triggers=mocked_triggers,
|
||||
arduino_connections={},
|
||||
)
|
||||
|
||||
resp = requests_session.get("http://127.0.0.1:%s/metrics" % port)
|
||||
assert resp.status_code == 200
|
||||
assert 'fan_pwm_line_start{fan_name="test"} 100.0' in resp.text
|
||||
assert 'fan_pwm_line_end{fan_name="test"} 240.0' in resp.text
|
||||
assert 'fan_rpm{fan_name="test"} NaN' in resp.text
|
||||
assert 'fan_pwm{fan_name="test"} NaN' in resp.text
|
||||
assert 'fan_pwm_normalized{fan_name="test"} NaN' in resp.text
|
||||
assert 'fan_is_failing{fan_name="test"} 0.0' in resp.text
|
||||
assert "is_panic 0.0" in resp.text
|
||||
assert "is_threshold 0.0" in resp.text
|
||||
20
tests/test_report.py
Normal file
20
tests/test_report.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from unittest.mock import call
|
||||
|
||||
from afancontrol import report
|
||||
from afancontrol.report import Report
|
||||
|
||||
|
||||
def test_report_success(sense_exec_shell_command):
|
||||
r = Report(r"printf '@%s' '%REASON%' '%MESSAGE%'")
|
||||
|
||||
with sense_exec_shell_command(report) as (mock_exec_shell_command, get_stdout):
|
||||
r.report("reason here", "message\nthere")
|
||||
assert mock_exec_shell_command.call_args == call(
|
||||
"printf '@%s' 'reason here' 'message\nthere'"
|
||||
)
|
||||
assert ["@reason here@message\nthere"] == get_stdout()
|
||||
|
||||
|
||||
def test_report_fail_does_not_raise():
|
||||
r = Report("false")
|
||||
r.report("reason here", "message\nthere")
|
||||
180
tests/test_trigger.py
Normal file
180
tests/test_trigger.py
Normal file
@@ -0,0 +1,180 @@
|
||||
from unittest.mock import MagicMock, call
|
||||
|
||||
import pytest
|
||||
|
||||
from afancontrol import trigger
|
||||
from afancontrol.config import Actions, AlertCommands, TempName, TriggerConfig
|
||||
from afancontrol.report import Report
|
||||
from afancontrol.temp import TempCelsius, TempStatus
|
||||
from afancontrol.trigger import PanicTrigger, ThresholdTrigger, Triggers
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def report():
|
||||
return MagicMock(spec=Report)
|
||||
|
||||
|
||||
def test_panic_on_empty_temp(report, sense_exec_shell_command):
|
||||
t = PanicTrigger(
|
||||
global_commands=AlertCommands(
|
||||
enter_cmd="printf '@%s' enter", leave_cmd="printf '@%s' leave"
|
||||
),
|
||||
temp_commands={
|
||||
TempName("mobo"): AlertCommands(
|
||||
enter_cmd=None, leave_cmd="printf '@%s' mobo leave"
|
||||
)
|
||||
},
|
||||
report=report,
|
||||
)
|
||||
|
||||
with sense_exec_shell_command(trigger) as (mock_exec_shell_command, get_stdout):
|
||||
with t:
|
||||
assert not t.is_alerting
|
||||
assert 0 == mock_exec_shell_command.call_count
|
||||
t.check({TempName("mobo"): None})
|
||||
assert t.is_alerting
|
||||
|
||||
assert mock_exec_shell_command.call_args_list == [
|
||||
call("printf '@%s' enter")
|
||||
]
|
||||
assert ["@enter"] == get_stdout()
|
||||
mock_exec_shell_command.reset_mock()
|
||||
|
||||
assert not t.is_alerting
|
||||
assert mock_exec_shell_command.call_args_list == [
|
||||
call("printf '@%s' mobo leave"),
|
||||
call("printf '@%s' leave"),
|
||||
]
|
||||
assert ["@mobo@leave", "@leave"] == get_stdout()
|
||||
|
||||
|
||||
def test_threshold_on_empty_temp(report):
|
||||
t = ThresholdTrigger(
|
||||
global_commands=AlertCommands(enter_cmd=None, leave_cmd=None),
|
||||
temp_commands={TempName("mobo"): AlertCommands(enter_cmd=None, leave_cmd=None)},
|
||||
report=report,
|
||||
)
|
||||
with t:
|
||||
assert not t.is_alerting
|
||||
t.check({TempName("mobo"): None})
|
||||
assert not t.is_alerting
|
||||
assert not t.is_alerting
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cls", [ThresholdTrigger, PanicTrigger])
|
||||
def test_good_temp(cls, report):
|
||||
t = cls(
|
||||
global_commands=AlertCommands(enter_cmd=None, leave_cmd=None),
|
||||
temp_commands=dict(mobo=AlertCommands(enter_cmd=None, leave_cmd=None)),
|
||||
report=report,
|
||||
)
|
||||
with t:
|
||||
assert not t.is_alerting
|
||||
t.check(
|
||||
dict(
|
||||
mobo=TempStatus(
|
||||
temp=TempCelsius(34.0),
|
||||
min=TempCelsius(40.0),
|
||||
max=TempCelsius(50.0),
|
||||
panic=TempCelsius(60.0),
|
||||
threshold=None,
|
||||
is_panic=False,
|
||||
is_threshold=False,
|
||||
)
|
||||
)
|
||||
)
|
||||
assert not t.is_alerting
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cls", [ThresholdTrigger, PanicTrigger])
|
||||
def test_bad_temp(cls, report, sense_exec_shell_command):
|
||||
t = cls(
|
||||
global_commands=AlertCommands(
|
||||
enter_cmd="printf '@%s' enter", leave_cmd="printf '@%s' leave"
|
||||
),
|
||||
temp_commands=dict(
|
||||
mobo=AlertCommands(
|
||||
enter_cmd="printf '@%s' mobo enter", leave_cmd="printf '@%s' mobo leave"
|
||||
)
|
||||
),
|
||||
report=report,
|
||||
)
|
||||
with sense_exec_shell_command(trigger) as (mock_exec_shell_command, get_stdout):
|
||||
with t:
|
||||
assert not t.is_alerting
|
||||
t.check(
|
||||
dict(
|
||||
mobo=TempStatus(
|
||||
temp=TempCelsius(70.0),
|
||||
min=TempCelsius(40.0),
|
||||
max=TempCelsius(50.0),
|
||||
panic=TempCelsius(60.0),
|
||||
threshold=TempCelsius(55.0),
|
||||
is_panic=True,
|
||||
is_threshold=True,
|
||||
)
|
||||
)
|
||||
)
|
||||
assert t.is_alerting
|
||||
assert mock_exec_shell_command.call_args_list == [
|
||||
call("printf '@%s' mobo enter"),
|
||||
call("printf '@%s' enter"),
|
||||
]
|
||||
assert ["@mobo@enter", "@enter"] == get_stdout()
|
||||
mock_exec_shell_command.reset_mock()
|
||||
|
||||
t.check(
|
||||
dict(
|
||||
mobo=TempStatus(
|
||||
temp=TempCelsius(34.0),
|
||||
min=TempCelsius(40.0),
|
||||
max=TempCelsius(50.0),
|
||||
panic=TempCelsius(60.0),
|
||||
threshold=None,
|
||||
is_panic=False,
|
||||
is_threshold=False,
|
||||
)
|
||||
)
|
||||
)
|
||||
assert not t.is_alerting
|
||||
assert mock_exec_shell_command.call_args_list == [
|
||||
call("printf '@%s' mobo leave"),
|
||||
call("printf '@%s' leave"),
|
||||
]
|
||||
assert ["@mobo@leave", "@leave"] == get_stdout()
|
||||
mock_exec_shell_command.reset_mock()
|
||||
assert 0 == mock_exec_shell_command.call_count
|
||||
|
||||
|
||||
def test_triggers_good_temp(report):
|
||||
t = Triggers(
|
||||
TriggerConfig(
|
||||
global_commands=Actions(
|
||||
panic=AlertCommands(enter_cmd=None, leave_cmd=None),
|
||||
threshold=AlertCommands(enter_cmd=None, leave_cmd=None),
|
||||
),
|
||||
temp_commands={
|
||||
TempName("mobo"): Actions(
|
||||
panic=AlertCommands(enter_cmd=None, leave_cmd=None),
|
||||
threshold=AlertCommands(enter_cmd=None, leave_cmd=None),
|
||||
)
|
||||
},
|
||||
),
|
||||
report=report,
|
||||
)
|
||||
with t:
|
||||
assert not t.is_alerting
|
||||
t.check(
|
||||
{
|
||||
TempName("mobo"): TempStatus(
|
||||
temp=TempCelsius(34.0),
|
||||
min=TempCelsius(40.0),
|
||||
max=TempCelsius(50.0),
|
||||
panic=TempCelsius(60.0),
|
||||
threshold=None,
|
||||
is_panic=False,
|
||||
is_threshold=False,
|
||||
)
|
||||
}
|
||||
)
|
||||
assert not t.is_alerting
|
||||
Reference in New Issue
Block a user