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 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.