smfc 4.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of smfc might be problematic. Click here for more details.
- smfc/__init__.py +17 -0
- smfc/cmd.py +18 -0
- smfc/constzone.py +95 -0
- smfc/cpuzone.py +90 -0
- smfc/fancontroller.py +278 -0
- smfc/gpuzone.py +130 -0
- smfc/hdzone.py +312 -0
- smfc/ipmi.py +259 -0
- smfc/log.py +119 -0
- smfc/service.py +280 -0
- smfc-4.0.0.dist-info/METADATA +631 -0
- smfc-4.0.0.dist-info/RECORD +16 -0
- smfc-4.0.0.dist-info/WHEEL +5 -0
- smfc-4.0.0.dist-info/entry_points.txt +2 -0
- smfc-4.0.0.dist-info/licenses/LICENSE +674 -0
- smfc-4.0.0.dist-info/top_level.txt +1 -0
smfc/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#
|
|
2
|
+
# __init__.py (C) 2020-2025, Peter Sulyok
|
|
3
|
+
# smfc package: Super Micro fan control for Linux (home) servers.
|
|
4
|
+
#
|
|
5
|
+
from smfc.log import Log
|
|
6
|
+
from smfc.ipmi import Ipmi
|
|
7
|
+
from smfc.fancontroller import FanController
|
|
8
|
+
from smfc.cpuzone import CpuZone
|
|
9
|
+
from smfc.hdzone import HdZone
|
|
10
|
+
from smfc.gpuzone import GpuZone
|
|
11
|
+
from smfc.constzone import ConstZone
|
|
12
|
+
from smfc.service import Service
|
|
13
|
+
from smfc.cmd import main
|
|
14
|
+
|
|
15
|
+
__all__ = [ "Log", "Ipmi", "FanController", "CpuZone", "HdZone", "GpuZone", "ConstZone", "Service", "main"]
|
|
16
|
+
|
|
17
|
+
# End.
|
smfc/cmd.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#
|
|
2
|
+
# cmd.py (C) 2020-2025, Peter Sulyok
|
|
3
|
+
# smfc package: Super Micro fan control for Linux (home) servers.
|
|
4
|
+
# smfc.main() function implementation, command-line interface.
|
|
5
|
+
#
|
|
6
|
+
from smfc.service import Service
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def main() -> None:
|
|
10
|
+
"""Entry point of the `smfc` program."""
|
|
11
|
+
service = Service()
|
|
12
|
+
service.run()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
if __name__ == '__main__':
|
|
16
|
+
main()
|
|
17
|
+
|
|
18
|
+
# End.
|
smfc/constzone.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
#
|
|
2
|
+
# constzone.py (C) 2020-2025, Peter Sulyok
|
|
3
|
+
# smfc package: Super Micro fan control for Linux (home) servers.
|
|
4
|
+
# smfc.ConstZone() class implementation.
|
|
5
|
+
#
|
|
6
|
+
import re
|
|
7
|
+
import time
|
|
8
|
+
from configparser import ConfigParser
|
|
9
|
+
from smfc.fancontroller import FanController
|
|
10
|
+
from smfc.ipmi import Ipmi
|
|
11
|
+
from smfc.log import Log
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ConstZone(FanController):
|
|
15
|
+
"""Constant zone fan control."""
|
|
16
|
+
|
|
17
|
+
# Constant values for the configuration parameters.
|
|
18
|
+
CS_CONST_ZONE: str = 'CONST zone'
|
|
19
|
+
CV_CONST_ZONE_ENABLED: str = 'enabled'
|
|
20
|
+
CV_CONST_IPMI_ZONE: str = 'ipmi_zone'
|
|
21
|
+
CV_CONST_ZONE_POLLING: str = 'polling'
|
|
22
|
+
CV_CONST_ZONE_LEVEL: str = 'level'
|
|
23
|
+
|
|
24
|
+
# Constant level for the zone.
|
|
25
|
+
level: int
|
|
26
|
+
|
|
27
|
+
#pylint: disable=super-init-not-called
|
|
28
|
+
def __init__(self, log: Log, ipmi: Ipmi, config:ConfigParser) -> None:
|
|
29
|
+
"""Initialize the ConstZone class and raise exception in case invalid configuration items.
|
|
30
|
+
Args:
|
|
31
|
+
log (Log): reference to a Log class instance
|
|
32
|
+
ipmi (Ipmi): reference to an Ipmi class instance
|
|
33
|
+
config (ConfigParser): reference to the configuration (default=None)
|
|
34
|
+
Raises:
|
|
35
|
+
ValueError: invalid configuration parameters
|
|
36
|
+
"""
|
|
37
|
+
# Initialize ConstZone class.
|
|
38
|
+
self.log = log
|
|
39
|
+
self.ipmi = ipmi
|
|
40
|
+
|
|
41
|
+
# Read the list of IPMI zones from a string (trim and remove multiple spaces, convert strings to integers)
|
|
42
|
+
ipmi_zone_str = config[ConstZone.CS_CONST_ZONE].get(ConstZone.CV_CONST_IPMI_ZONE, fallback=f'{Ipmi.HD_ZONE}')
|
|
43
|
+
ipmi_zone_str = re.sub(' +', ' ', ipmi_zone_str.strip())
|
|
44
|
+
try:
|
|
45
|
+
self.ipmi_zone = [int(s) for s in ipmi_zone_str.split(',' if ',' in ipmi_zone_str else ' ')]
|
|
46
|
+
except ValueError as e:
|
|
47
|
+
raise e
|
|
48
|
+
for zone in self.ipmi_zone:
|
|
49
|
+
if zone not in range(0, 101):
|
|
50
|
+
raise ValueError(f'invalid value: ipmi_zone={ipmi_zone_str}.')
|
|
51
|
+
|
|
52
|
+
self.name = ConstZone.CS_CONST_ZONE
|
|
53
|
+
self.polling = config[ConstZone.CS_CONST_ZONE].getfloat(ConstZone.CV_CONST_ZONE_POLLING, fallback=30.0)
|
|
54
|
+
if self.polling < 0:
|
|
55
|
+
raise ValueError('polling < 0')
|
|
56
|
+
self.level = config[ConstZone.CS_CONST_ZONE].getint(ConstZone.CV_CONST_ZONE_LEVEL, fallback=50)
|
|
57
|
+
if self.level not in range(0, 101):
|
|
58
|
+
raise ValueError('invalid level')
|
|
59
|
+
self.last_time = 0
|
|
60
|
+
|
|
61
|
+
# Print configuration at DEBUG log level.
|
|
62
|
+
if self.log.log_level >= Log.LOG_CONFIG:
|
|
63
|
+
self.log.msg(Log.LOG_CONFIG, f'{self.name} fan controller was initialized with:')
|
|
64
|
+
self.log.msg(Log.LOG_CONFIG, f' ipmi zone = {self.ipmi_zone}')
|
|
65
|
+
self.log.msg(Log.LOG_CONFIG, f' polling = {self.polling}')
|
|
66
|
+
self.log.msg(Log.LOG_CONFIG, f' level = {self.level}')
|
|
67
|
+
# pylint: enable=super-init-not-called
|
|
68
|
+
|
|
69
|
+
def run(self) -> None:
|
|
70
|
+
"""Run IPMI zone controller function with the following steps:
|
|
71
|
+
|
|
72
|
+
* Step 1: Read current time. If the elapsed time is bigger than the polling time period,
|
|
73
|
+
then go to step 2, otherwise return.
|
|
74
|
+
* Step 2: Loop through IPMI zones: read current fan level in the zone, if the level is different from the
|
|
75
|
+
expected one then we set fan level in the zone again, otherwise return.
|
|
76
|
+
* Step 3: Log the fan level.
|
|
77
|
+
"""
|
|
78
|
+
current_time: float # Current system timestamp (measured)
|
|
79
|
+
|
|
80
|
+
# Step 1: check the elapsed time.
|
|
81
|
+
current_time = time.monotonic()
|
|
82
|
+
if (time.monotonic() - self.last_time) >= self.polling:
|
|
83
|
+
self.last_time = current_time
|
|
84
|
+
|
|
85
|
+
# Check in all IPMI zones if the current fan level is the expected one,
|
|
86
|
+
# otherwise set the fan level again.
|
|
87
|
+
for zone in self.ipmi_zone:
|
|
88
|
+
level = self.ipmi.get_fan_level(zone)
|
|
89
|
+
if level != self.level:
|
|
90
|
+
self.ipmi.set_fan_level(zone, self.level)
|
|
91
|
+
self.log.msg(Log.LOG_INFO, f'{self.name}: set fan level > {self.level}% '
|
|
92
|
+
f'@ IPMI {self.ipmi_zone} zone(s).')
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# End.
|
smfc/cpuzone.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
#
|
|
2
|
+
# cpuzone.py (C) 2020-2025, Peter Sulyok
|
|
3
|
+
# smfc package: Super Micro fan control for Linux (home) servers.
|
|
4
|
+
# smfc.CpuZone() class implementation.
|
|
5
|
+
#
|
|
6
|
+
from configparser import ConfigParser
|
|
7
|
+
from pyudev import Context
|
|
8
|
+
from smfc.fancontroller import FanController
|
|
9
|
+
from smfc.ipmi import Ipmi
|
|
10
|
+
from smfc.log import Log
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CpuZone(FanController):
|
|
14
|
+
"""CPU zone fan control."""
|
|
15
|
+
|
|
16
|
+
# Constant values for the configuration parameters.
|
|
17
|
+
CS_CPU_ZONE: str = 'CPU zone'
|
|
18
|
+
CV_CPU_ZONE_ENABLED: str = 'enabled'
|
|
19
|
+
CV_CPU_IPMI_ZONE: str = 'ipmi_zone'
|
|
20
|
+
CV_CPU_ZONE_TEMP_CALC: str = 'temp_calc'
|
|
21
|
+
CV_CPU_ZONE_STEPS: str = 'steps'
|
|
22
|
+
CV_CPU_ZONE_SENSITIVITY: str = 'sensitivity'
|
|
23
|
+
CV_CPU_ZONE_POLLING: str = 'polling'
|
|
24
|
+
CV_CPU_ZONE_MIN_TEMP: str = 'min_temp'
|
|
25
|
+
CV_CPU_ZONE_MAX_TEMP: str = 'max_temp'
|
|
26
|
+
CV_CPU_ZONE_MIN_LEVEL: str = 'min_level'
|
|
27
|
+
CV_CPU_ZONE_MAX_LEVEL: str = 'max_level'
|
|
28
|
+
|
|
29
|
+
def __init__(self, log: Log, udevc: Context, ipmi: Ipmi, config:ConfigParser) -> None:
|
|
30
|
+
"""Initialize the CpuZone class and raise exception in case of invalid configuration.
|
|
31
|
+
Args:
|
|
32
|
+
log (Log): reference to a Log class instance
|
|
33
|
+
udevc (Context): reference to an udev database connection (instance of Context from pyudev)
|
|
34
|
+
ipmi (Ipmi): reference to an Ipmi class instance
|
|
35
|
+
config (ConfigParser): reference to the configuration (default=None)
|
|
36
|
+
Raises:
|
|
37
|
+
ValueError: multiple hwmon devices reported, one expected
|
|
38
|
+
RuntimeError: No HWMON device found for CPU(s)
|
|
39
|
+
"""
|
|
40
|
+
count: int # CPU count.
|
|
41
|
+
|
|
42
|
+
# Build the list of paths for hwmon devices.
|
|
43
|
+
self.hwmon_path = []
|
|
44
|
+
# We are looking for either Intel (coretemp) or AMD (k10temp) CPUs.
|
|
45
|
+
for dev_filter in [{'MODALIAS':'platform:coretemp'}, {'DRIVER':'k10temp'}]:
|
|
46
|
+
self.hwmon_path = [self.get_hwmon_path(udevc, dev) for dev in udevc.list_devices(**dev_filter)]
|
|
47
|
+
# If we found results.
|
|
48
|
+
if self.hwmon_path:
|
|
49
|
+
break
|
|
50
|
+
if not self.hwmon_path:
|
|
51
|
+
raise RuntimeError('pyudev: No HWMON device(s) can be found for the CPU.')
|
|
52
|
+
# Calculate count.
|
|
53
|
+
count = len(self.hwmon_path)
|
|
54
|
+
|
|
55
|
+
# Initialize FanController class.
|
|
56
|
+
super().__init__(log, ipmi,
|
|
57
|
+
config[CpuZone.CS_CPU_ZONE].get(CpuZone.CV_CPU_IPMI_ZONE, fallback=f'{Ipmi.CPU_ZONE}'),
|
|
58
|
+
CpuZone.CS_CPU_ZONE, count,
|
|
59
|
+
config[CpuZone.CS_CPU_ZONE].getint(CpuZone.CV_CPU_ZONE_TEMP_CALC, fallback=FanController.CALC_AVG),
|
|
60
|
+
config[CpuZone.CS_CPU_ZONE].getint(CpuZone.CV_CPU_ZONE_STEPS, fallback=6),
|
|
61
|
+
config[CpuZone.CS_CPU_ZONE].getfloat(CpuZone.CV_CPU_ZONE_SENSITIVITY, fallback=3.0),
|
|
62
|
+
config[CpuZone.CS_CPU_ZONE].getfloat(CpuZone.CV_CPU_ZONE_POLLING, fallback=2),
|
|
63
|
+
config[CpuZone.CS_CPU_ZONE].getfloat(CpuZone.CV_CPU_ZONE_MIN_TEMP, fallback=30.0),
|
|
64
|
+
config[CpuZone.CS_CPU_ZONE].getfloat(CpuZone.CV_CPU_ZONE_MAX_TEMP, fallback=60.0),
|
|
65
|
+
config[CpuZone.CS_CPU_ZONE].getint(CpuZone.CV_CPU_ZONE_MIN_LEVEL, fallback=35),
|
|
66
|
+
config[CpuZone.CS_CPU_ZONE].getint(CpuZone.CV_CPU_ZONE_MAX_LEVEL, fallback=100)
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def _get_nth_temp(self, index: int) -> float:
|
|
70
|
+
"""Get the temperature of the 'nth' element in the hwmon list.
|
|
71
|
+
Args:
|
|
72
|
+
index (int): index in hwmon list
|
|
73
|
+
Returns:
|
|
74
|
+
float: temperature value
|
|
75
|
+
Raises:
|
|
76
|
+
FileNotFoundError: file not found
|
|
77
|
+
IOError: file cannot be opened
|
|
78
|
+
ValueError: invalid value read from file
|
|
79
|
+
IndexError: invalid index
|
|
80
|
+
"""
|
|
81
|
+
value: float # Temperature value
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
with open(self.hwmon_path[index], "r", encoding="UTF-8") as f:
|
|
85
|
+
value = float(f.read()) / 1000
|
|
86
|
+
except (IOError, FileNotFoundError, ValueError) as e:
|
|
87
|
+
raise e
|
|
88
|
+
return value
|
|
89
|
+
|
|
90
|
+
# End.
|
smfc/fancontroller.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
#
|
|
2
|
+
# fancontroller.py (C) 2020-2025, Peter Sulyok
|
|
3
|
+
# smfc package: Super Micro fan control for Linux (home) servers.
|
|
4
|
+
# smfc.FanController() class implementation.
|
|
5
|
+
#
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
import re
|
|
9
|
+
from typing import List, Callable
|
|
10
|
+
from pyudev import Context, Device
|
|
11
|
+
from smfc.ipmi import Ipmi
|
|
12
|
+
from smfc.log import Log
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class FanController:
|
|
16
|
+
"""Generic fan controller class for an IPMI zone."""
|
|
17
|
+
|
|
18
|
+
# Constant values for temperature calculation
|
|
19
|
+
CALC_MIN: int = 0
|
|
20
|
+
CALC_AVG: int = 1
|
|
21
|
+
CALC_MAX: int = 2
|
|
22
|
+
|
|
23
|
+
# Configuration parameters
|
|
24
|
+
log: Log # Reference to a Log class instance
|
|
25
|
+
ipmi: Ipmi # Reference to an Ipmi class instance
|
|
26
|
+
ipmi_zone: List[int] # List of IPMI zones assigned to this fan controller
|
|
27
|
+
name: str # Name of the controller
|
|
28
|
+
count: int # Number of controlled entities
|
|
29
|
+
temp_calc: int # Calculate of the temperature (0-min, 1-avg, 2-max)
|
|
30
|
+
steps: int # Discrete steps in temperatures and fan levels
|
|
31
|
+
sensitivity: float # Temperature change to activate fan controller (C)
|
|
32
|
+
polling: float # Polling interval to read temperature (sec)
|
|
33
|
+
min_temp: float # Minimum temperature value (C)
|
|
34
|
+
max_temp: float # Maximum temperature value (C)
|
|
35
|
+
min_level: int # Minimum fan level (0..100%)
|
|
36
|
+
max_level: int # Maximum fan level (0..100%)
|
|
37
|
+
hwmon_path: List[str] # List of paths for HWMON devices
|
|
38
|
+
|
|
39
|
+
# Measured or calculated attributes
|
|
40
|
+
temp_step: float # A temperature steps value (C)
|
|
41
|
+
level_step: float # A fan level step value (0..100%)
|
|
42
|
+
last_time: float # Last system time we polled temperature (timestamp)
|
|
43
|
+
last_temp: float # Last measured temperature value (C)
|
|
44
|
+
last_level: int # Last configured fan level (0..100%)
|
|
45
|
+
|
|
46
|
+
# Function variable for selected temperature calculation method
|
|
47
|
+
get_temp_func: Callable[[], float]
|
|
48
|
+
|
|
49
|
+
def __init__(self, log: Log, ipmi: Ipmi, ipmi_zone: str, name: str, count: int, temp_calc: int,
|
|
50
|
+
steps: int, sensitivity: float, polling: float, min_temp: float, max_temp: float, min_level: int,
|
|
51
|
+
max_level: int) -> None:
|
|
52
|
+
"""Initialize the FanController class. Will raise an exception in case of invalid parameters.
|
|
53
|
+
Args:
|
|
54
|
+
log (Log): reference to a Log class instance
|
|
55
|
+
ipmi (Ipmi): reference to an Ipmi class instance
|
|
56
|
+
ipmi_zone (str): IPMI zone(s) assigned to the controller
|
|
57
|
+
name (str): name of the controller
|
|
58
|
+
count (int): number of devices
|
|
59
|
+
temp_calc (int): calculation of temperature
|
|
60
|
+
steps (int): discrete steps in temperatures and fan levels
|
|
61
|
+
sensitivity (float): temperature change to activate fan controller (C)
|
|
62
|
+
polling (float): polling time interval for reading temperature (sec)
|
|
63
|
+
min_temp (float): minimum temperature value (C)
|
|
64
|
+
max_temp (float): maximum temperature value (C)
|
|
65
|
+
min_level (int): minimum fan level value [0..100%]
|
|
66
|
+
max_level (int): maximum fan level value [0..100%]
|
|
67
|
+
Raises:
|
|
68
|
+
ValueError: invalid input parameter
|
|
69
|
+
"""
|
|
70
|
+
# Save and validate configuration parameters.
|
|
71
|
+
self.log = log
|
|
72
|
+
self.ipmi = ipmi
|
|
73
|
+
# Read the list of IPMI zones from a string (trim and remove multiple spaces, convert strings to integers)
|
|
74
|
+
zone_str = re.sub(' +', ' ', ipmi_zone.strip())
|
|
75
|
+
try:
|
|
76
|
+
self.ipmi_zone = [int(s) for s in zone_str.split(',' if ',' in ipmi_zone else ' ')]
|
|
77
|
+
except ValueError as e:
|
|
78
|
+
raise e
|
|
79
|
+
for zone in self.ipmi_zone:
|
|
80
|
+
if zone not in range(0, 101):
|
|
81
|
+
raise ValueError(f'invalid value: ipmi_zone={ipmi_zone}.')
|
|
82
|
+
self.name = name
|
|
83
|
+
self.count = count
|
|
84
|
+
if self.count <= 0:
|
|
85
|
+
raise ValueError('invalid value: count <= 0')
|
|
86
|
+
self.temp_calc = temp_calc
|
|
87
|
+
if self.temp_calc not in {self.CALC_MIN, self.CALC_AVG, self.CALC_MAX}:
|
|
88
|
+
raise ValueError(f'invalid value: temp_calc ({temp_calc}).')
|
|
89
|
+
self.steps = steps
|
|
90
|
+
if self.steps <= 0:
|
|
91
|
+
raise ValueError('invalid value: steps <= 0')
|
|
92
|
+
self.sensitivity = sensitivity
|
|
93
|
+
if self.sensitivity <= 0:
|
|
94
|
+
raise ValueError('invalid value: sensitivity <= 0')
|
|
95
|
+
self.polling = polling
|
|
96
|
+
if self.polling < 0:
|
|
97
|
+
raise ValueError('polling < 0')
|
|
98
|
+
if max_temp < min_temp:
|
|
99
|
+
raise ValueError('invalid value: max_temp < min_temp')
|
|
100
|
+
self.min_temp = min_temp
|
|
101
|
+
self.max_temp = max_temp
|
|
102
|
+
if max_level < min_level:
|
|
103
|
+
raise ValueError('invalid value: max_level < min_level')
|
|
104
|
+
self.min_level = min_level
|
|
105
|
+
self.max_level = max_level
|
|
106
|
+
|
|
107
|
+
# Set the proper temperature function.
|
|
108
|
+
if self.count == 1:
|
|
109
|
+
self.get_temp_func = self.get_1_temp
|
|
110
|
+
else:
|
|
111
|
+
self.get_temp_func = self.get_avg_temp
|
|
112
|
+
if self.temp_calc == self.CALC_MIN:
|
|
113
|
+
self.get_temp_func = self.get_min_temp
|
|
114
|
+
elif self.temp_calc == self.CALC_MAX:
|
|
115
|
+
self.get_temp_func = self.get_max_temp
|
|
116
|
+
|
|
117
|
+
# Try to read device temperature (the hwmon_path[] list has already been created by a child class).
|
|
118
|
+
# If there is any problem with reading temperature, the program will stop here with an exception.
|
|
119
|
+
self.get_temp_func()
|
|
120
|
+
|
|
121
|
+
# Initialize calculated values.
|
|
122
|
+
self.temp_step = (max_temp - min_temp) / steps
|
|
123
|
+
self.level_step = (max_level - min_level) / steps
|
|
124
|
+
self.last_temp = 0
|
|
125
|
+
self.last_level = 0
|
|
126
|
+
self.last_time = time.monotonic() - (polling + 1)
|
|
127
|
+
# Print configuration at DEBUG log level.
|
|
128
|
+
if self.log.log_level >= Log.LOG_CONFIG:
|
|
129
|
+
self.log.msg(Log.LOG_CONFIG, f'{self.name} fan controller was initialized with:')
|
|
130
|
+
self.log.msg(Log.LOG_CONFIG, f' ipmi zone = {self.ipmi_zone}')
|
|
131
|
+
self.log.msg(Log.LOG_CONFIG, f' count = {self.count}')
|
|
132
|
+
self.log.msg(Log.LOG_CONFIG, f' temp_calc = {self.temp_calc}')
|
|
133
|
+
self.log.msg(Log.LOG_CONFIG, f' steps = {self.steps}')
|
|
134
|
+
self.log.msg(Log.LOG_CONFIG, f' sensitivity = {self.sensitivity}')
|
|
135
|
+
self.log.msg(Log.LOG_CONFIG, f' polling = {self.polling}')
|
|
136
|
+
self.log.msg(Log.LOG_CONFIG, f' min_temp = {self.min_temp}')
|
|
137
|
+
self.log.msg(Log.LOG_CONFIG, f' max_temp = {self.max_temp}')
|
|
138
|
+
self.log.msg(Log.LOG_CONFIG, f' min_level = {self.min_level}')
|
|
139
|
+
self.log.msg(Log.LOG_CONFIG, f' max_level = {self.max_level}')
|
|
140
|
+
if hasattr(self, 'hwmon_path'):
|
|
141
|
+
self.log.msg(Log.LOG_CONFIG, f' hwmon_path = {[p if p else "smartctl" for p in self.hwmon_path]}')
|
|
142
|
+
self.print_temp_level_mapping()
|
|
143
|
+
|
|
144
|
+
@staticmethod
|
|
145
|
+
def get_hwmon_path(udevc: Context, parent_dev: Device) -> str:
|
|
146
|
+
"""A helper function to get HWMON path of a given parent device's associated hwmon
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
udevc (Context): pyudev Context
|
|
150
|
+
parent_dev (Device): parent device
|
|
151
|
+
Returns:
|
|
152
|
+
str: path for a HWMON device
|
|
153
|
+
"""
|
|
154
|
+
try:
|
|
155
|
+
[hwmon_device] = udevc.list_devices(subsystem='hwmon', parent=parent_dev)
|
|
156
|
+
except ValueError:
|
|
157
|
+
# If parent_dev has zero (or more?) hwmon device in its subtree
|
|
158
|
+
hwmon_device = None
|
|
159
|
+
return os.path.join(hwmon_device.sys_path, 'temp1_input') if hwmon_device is not None else ''
|
|
160
|
+
|
|
161
|
+
def _get_nth_temp(self, index: int) -> float:
|
|
162
|
+
"""Get the temperature of the 'nth' element in the hwmon list. This is an empty implementation."""
|
|
163
|
+
|
|
164
|
+
def get_1_temp(self) -> float:
|
|
165
|
+
"""Get a single temperature of a controlled entity in the IPMI zone.
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
float: single temperature of a controlled entity (C)
|
|
169
|
+
"""
|
|
170
|
+
return self._get_nth_temp(0)
|
|
171
|
+
|
|
172
|
+
def get_min_temp(self) -> float:
|
|
173
|
+
"""Get the minimum temperature of multiple controlled entities.
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
float: minimum temperature of the controlled entities (C)
|
|
177
|
+
"""
|
|
178
|
+
minimum: float # Minimum temperature value
|
|
179
|
+
|
|
180
|
+
# Calculate minimum temperature.
|
|
181
|
+
minimum = 1000.0
|
|
182
|
+
for i in range(self.count):
|
|
183
|
+
minimum = min(self._get_nth_temp(i), minimum)
|
|
184
|
+
return minimum
|
|
185
|
+
|
|
186
|
+
def get_avg_temp(self):
|
|
187
|
+
"""Get the average temperature of the controlled entities in the IPMI zone.
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
float: average temperature of the controlled entities (C)
|
|
191
|
+
"""
|
|
192
|
+
average: float # Average temperature
|
|
193
|
+
counter: int # Value counter
|
|
194
|
+
|
|
195
|
+
# Calculate average temperature.
|
|
196
|
+
average = 0.0
|
|
197
|
+
counter = 0
|
|
198
|
+
for i in range(self.count):
|
|
199
|
+
average += self._get_nth_temp(i)
|
|
200
|
+
counter += 1
|
|
201
|
+
return average / counter
|
|
202
|
+
|
|
203
|
+
def get_max_temp(self) -> float:
|
|
204
|
+
"""Get the maximum temperature of the controlled entities in the IPMI zone.
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
float: maximum temperature of the controlled entities (C)
|
|
208
|
+
"""
|
|
209
|
+
maximum: float # Maximum temperature value
|
|
210
|
+
|
|
211
|
+
# Calculate minimum temperature.
|
|
212
|
+
maximum = -1.0
|
|
213
|
+
for i in range(self.count):
|
|
214
|
+
maximum = max(self._get_nth_temp(i), maximum)
|
|
215
|
+
return maximum
|
|
216
|
+
|
|
217
|
+
def set_fan_level(self, level: int) -> None:
|
|
218
|
+
"""Set the new fan level in all IPMI zones of the controller.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
level (int): new fan level [0..100]
|
|
222
|
+
"""
|
|
223
|
+
self.ipmi.set_multiple_fan_levels(self.ipmi_zone, level)
|
|
224
|
+
|
|
225
|
+
def callback_func(self) -> None:
|
|
226
|
+
"""Call-back function for a child class."""
|
|
227
|
+
|
|
228
|
+
def run(self) -> None:
|
|
229
|
+
"""Run IPMI zone controller function with the following steps:
|
|
230
|
+
|
|
231
|
+
* Step 1: Read current time. If the elapsed time is bigger than the polling time period
|
|
232
|
+
then go to step 2, otherwise return.
|
|
233
|
+
* Step 2: Read the current temperature. If the change of the temperature goes beyond
|
|
234
|
+
the sensitivity limit then go to step 3, otherwise return
|
|
235
|
+
* Step 3: Calculate the current gain and fan level based on the measured temperature
|
|
236
|
+
* Step 4: If the new fan level is different it will be set and logged
|
|
237
|
+
"""
|
|
238
|
+
current_time: float # Current system timestamp (measured)
|
|
239
|
+
current_temp: float # Current temperature (measured)
|
|
240
|
+
current_level: int # Current fan level (calculated)
|
|
241
|
+
current_gain: int # Current gain (calculated)
|
|
242
|
+
|
|
243
|
+
# Step 1: check the elapsed time.
|
|
244
|
+
current_time = time.monotonic()
|
|
245
|
+
if (current_time - self.last_time) >= self.polling:
|
|
246
|
+
self.last_time = current_time
|
|
247
|
+
|
|
248
|
+
# Step 2: read the temperature and check the sensitivity gap.
|
|
249
|
+
self.callback_func()
|
|
250
|
+
current_temp = self.get_temp_func()
|
|
251
|
+
self.log.msg(Log.LOG_DEBUG, f'{self.name}: new temperature > {current_temp:.1f}C')
|
|
252
|
+
if abs(current_temp - self.last_temp) >= self.sensitivity:
|
|
253
|
+
self.last_temp = current_temp
|
|
254
|
+
|
|
255
|
+
# Step 3: calculate gain and fan level.
|
|
256
|
+
if current_temp <= self.min_temp:
|
|
257
|
+
current_level = self.min_level
|
|
258
|
+
elif current_temp >= self.max_temp:
|
|
259
|
+
current_level = self.max_level
|
|
260
|
+
else:
|
|
261
|
+
current_gain = int(round((current_temp - self.min_temp) / self.temp_step))
|
|
262
|
+
current_level = int(round(float(current_gain) * self.level_step)) + self.min_level
|
|
263
|
+
|
|
264
|
+
# Step 4: the new fan level will be set and logged.
|
|
265
|
+
if current_level != self.last_level:
|
|
266
|
+
self.last_level = current_level
|
|
267
|
+
self.set_fan_level(current_level)
|
|
268
|
+
self.log.msg(Log.LOG_INFO, f'{self.name}: new fan level > {current_level}%/{current_temp:.1f}C'
|
|
269
|
+
f' @ IPMI {self.ipmi_zone} zone(s).')
|
|
270
|
+
|
|
271
|
+
def print_temp_level_mapping(self) -> None:
|
|
272
|
+
"""Print out the user-defined temperature to level mapping value in log DEBUG level."""
|
|
273
|
+
self.log.msg(Log.LOG_CONFIG, ' User-defined control function:')
|
|
274
|
+
for i in range(self.steps + 1):
|
|
275
|
+
self.log.msg(Log.LOG_CONFIG, f' {i}. [T:{self.min_temp+(i*self.temp_step):.1f}C - '
|
|
276
|
+
f'L:{int(self.min_level + (i * self.level_step))}%]')
|
|
277
|
+
|
|
278
|
+
# End.
|
smfc/gpuzone.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
#
|
|
2
|
+
# gpuzone.py (C) 2020-2025, Peter Sulyok
|
|
3
|
+
# smfc package: Super Micro fan control for Linux (home) servers.
|
|
4
|
+
# smfc.GpuZone() class implementation.
|
|
5
|
+
#
|
|
6
|
+
import subprocess
|
|
7
|
+
import time
|
|
8
|
+
import re
|
|
9
|
+
from configparser import ConfigParser
|
|
10
|
+
from typing import List
|
|
11
|
+
from smfc.fancontroller import FanController
|
|
12
|
+
from smfc.ipmi import Ipmi
|
|
13
|
+
from smfc.log import Log
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class GpuZone(FanController):
|
|
17
|
+
"""Class for GPU zone fan control."""
|
|
18
|
+
|
|
19
|
+
# GpuZone specific parameters.
|
|
20
|
+
gpu_device_ids: List[int] # GPU device IDs (indexes)
|
|
21
|
+
nvidia_smi_path: str # Path for `nvidia-smi` command
|
|
22
|
+
nvidia_smi_called: float # Timestamp when `nvidia-smi` command executed
|
|
23
|
+
gpu_temperature: List[float] # List of GPU temperatures
|
|
24
|
+
|
|
25
|
+
# Constant values for the configuration parameters.
|
|
26
|
+
CS_GPU_ZONE: str = 'GPU zone'
|
|
27
|
+
CV_GPU_ZONE_ENABLED: str = 'enabled'
|
|
28
|
+
CV_GPU_IPMI_ZONE: str = 'ipmi_zone'
|
|
29
|
+
CV_GPU_ZONE_TEMP_CALC: str = 'temp_calc'
|
|
30
|
+
CV_GPU_ZONE_STEPS: str = 'steps'
|
|
31
|
+
CV_GPU_ZONE_SENSITIVITY: str = 'sensitivity'
|
|
32
|
+
CV_GPU_ZONE_POLLING: str = 'polling'
|
|
33
|
+
CV_GPU_ZONE_MIN_TEMP: str = 'min_temp'
|
|
34
|
+
CV_GPU_ZONE_MAX_TEMP: str = 'max_temp'
|
|
35
|
+
CV_GPU_ZONE_MIN_LEVEL: str = 'min_level'
|
|
36
|
+
CV_GPU_ZONE_MAX_LEVEL: str = 'max_level'
|
|
37
|
+
CV_GPU_ZONE_GPU_IDS: str = 'gpu_device_ids'
|
|
38
|
+
CV_GPU_ZONE_NVIDIA_SMI_PATH: str = 'nvidia_smi_path'
|
|
39
|
+
|
|
40
|
+
def __init__(self, log: Log, ipmi: Ipmi, config: ConfigParser) -> None:
|
|
41
|
+
"""Initialize the GpuZone class. Abort in case of configuration errors.
|
|
42
|
+
Args:
|
|
43
|
+
log (Log): reference to a Log class instance
|
|
44
|
+
ipmi (Ipmi): reference to an Ipmi class instance
|
|
45
|
+
config (configparser.ConfigParser): reference to the configuration (default=None)
|
|
46
|
+
Raises:
|
|
47
|
+
ValueError: invalid parameters
|
|
48
|
+
"""
|
|
49
|
+
gpu_id_list: str # String for gpu_device_ids=
|
|
50
|
+
count: int # GPU count.
|
|
51
|
+
|
|
52
|
+
# Save and validate GpuZone class-specific parameters.
|
|
53
|
+
gpu_id_list = config[self.CS_GPU_ZONE].get(self.CV_GPU_ZONE_GPU_IDS, '0')
|
|
54
|
+
gpu_id_list = re.sub(' +', ' ', gpu_id_list.strip())
|
|
55
|
+
try:
|
|
56
|
+
self.gpu_device_ids = [int(s) for s in gpu_id_list.split(',' if ',' in gpu_id_list else ' ')]
|
|
57
|
+
except ValueError as e:
|
|
58
|
+
raise e
|
|
59
|
+
for gid in self.gpu_device_ids:
|
|
60
|
+
if gid not in range(0, 101):
|
|
61
|
+
raise ValueError(f'invalid value: {self.CV_GPU_ZONE_GPU_IDS}={gpu_id_list}.')
|
|
62
|
+
count = len(self.gpu_device_ids)
|
|
63
|
+
self.nvidia_smi_path = config[GpuZone.CS_GPU_ZONE].get(GpuZone.CV_GPU_ZONE_NVIDIA_SMI_PATH,
|
|
64
|
+
'/usr/bin/nvidia-smi')
|
|
65
|
+
self.nvidia_smi_called = 0
|
|
66
|
+
|
|
67
|
+
# Initialize FanController class.
|
|
68
|
+
super().__init__(log, ipmi,
|
|
69
|
+
config[GpuZone.CS_GPU_ZONE].get(GpuZone.CV_GPU_IPMI_ZONE, fallback=f'{Ipmi.HD_ZONE}'),
|
|
70
|
+
GpuZone.CS_GPU_ZONE, count,
|
|
71
|
+
config[GpuZone.CS_GPU_ZONE].getint(GpuZone.CV_GPU_ZONE_TEMP_CALC, fallback=FanController.CALC_AVG),
|
|
72
|
+
config[GpuZone.CS_GPU_ZONE].getint(GpuZone.CV_GPU_ZONE_STEPS, fallback=5),
|
|
73
|
+
config[GpuZone.CS_GPU_ZONE].getfloat(GpuZone.CV_GPU_ZONE_SENSITIVITY, fallback=2),
|
|
74
|
+
config[GpuZone.CS_GPU_ZONE].getfloat(GpuZone.CV_GPU_ZONE_POLLING, fallback=2),
|
|
75
|
+
config[GpuZone.CS_GPU_ZONE].getfloat(GpuZone.CV_GPU_ZONE_MIN_TEMP, fallback=40),
|
|
76
|
+
config[GpuZone.CS_GPU_ZONE].getfloat(GpuZone.CV_GPU_ZONE_MAX_TEMP, fallback=70),
|
|
77
|
+
config[GpuZone.CS_GPU_ZONE].getint(GpuZone.CV_GPU_ZONE_MIN_LEVEL, fallback=35),
|
|
78
|
+
config[GpuZone.CS_GPU_ZONE].getint(GpuZone.CV_GPU_ZONE_MAX_LEVEL, fallback=100)
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# Print configuration in CONFIG log level (or higher).
|
|
82
|
+
if self.log.log_level >= Log.LOG_CONFIG:
|
|
83
|
+
self.log.msg(Log.LOG_CONFIG, f' {self.CV_GPU_ZONE_GPU_IDS} = {self.gpu_device_ids}')
|
|
84
|
+
self.log.msg(Log.LOG_CONFIG, f' {self.CV_GPU_ZONE_NVIDIA_SMI_PATH} = {self.nvidia_smi_path}')
|
|
85
|
+
|
|
86
|
+
def _exec_nvidia_smi(self, arguments: List[str]) -> subprocess.CompletedProcess:
|
|
87
|
+
"""Execution of the `nvidia-smi` command.
|
|
88
|
+
Args:
|
|
89
|
+
arguments (List[str]): list of argument of `nvidia-smi` command
|
|
90
|
+
Raises:
|
|
91
|
+
FileNotFoundError: command not found
|
|
92
|
+
"""
|
|
93
|
+
r: subprocess.CompletedProcess # Result of the executed process
|
|
94
|
+
args: List[str] = [] # List of arguments
|
|
95
|
+
|
|
96
|
+
# Execute `nvidia-smi` command.
|
|
97
|
+
try:
|
|
98
|
+
args.append(self.nvidia_smi_path)
|
|
99
|
+
args.extend(arguments)
|
|
100
|
+
r = subprocess.run(args, check=False, capture_output=True, text=True)
|
|
101
|
+
except FileNotFoundError as e:
|
|
102
|
+
raise e
|
|
103
|
+
return r
|
|
104
|
+
|
|
105
|
+
def _get_nth_temp(self, index: int) -> float:
|
|
106
|
+
"""Get the temperature of the nth element in the GPU device list.
|
|
107
|
+
Args:
|
|
108
|
+
index (int): index in GPU device list
|
|
109
|
+
Returns:
|
|
110
|
+
float: temperature value
|
|
111
|
+
Raises:
|
|
112
|
+
FileNotFoundError: file or command cannot be found
|
|
113
|
+
ValueError: invalid temperature value
|
|
114
|
+
IndexError: invalid index
|
|
115
|
+
"""
|
|
116
|
+
current_time = time.monotonic()
|
|
117
|
+
if (current_time - self.nvidia_smi_called) >= self.polling:
|
|
118
|
+
r: subprocess.CompletedProcess # result of the executed process
|
|
119
|
+
|
|
120
|
+
r = self._exec_nvidia_smi(['--query-gpu=temperature.gpu', '--format=csv,noheader,nounits'])
|
|
121
|
+
self.nvidia_smi_called = current_time
|
|
122
|
+
temp_list = r.stdout.splitlines()
|
|
123
|
+
self.gpu_temperature = []
|
|
124
|
+
for gid in self.gpu_device_ids:
|
|
125
|
+
self.gpu_temperature.append(int(temp_list[gid]))
|
|
126
|
+
|
|
127
|
+
return self.gpu_temperature[index]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# End.
|