smfc 4.0.0b1__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,15 @@
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.service import Service
11
+ from smfc.cmd import main
12
+
13
+ __all__ = [ "Log", "Ipmi", "FanController", "CpuZone", "HdZone", "Service", "main"]
14
+
15
+ # 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/cpuzone.py ADDED
@@ -0,0 +1,87 @@
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_ZONE_TEMP_CALC: str = 'temp_calc'
20
+ CV_CPU_ZONE_STEPS: str = 'steps'
21
+ CV_CPU_ZONE_SENSITIVITY: str = 'sensitivity'
22
+ CV_CPU_ZONE_POLLING: str = 'polling'
23
+ CV_CPU_ZONE_MIN_TEMP: str = 'min_temp'
24
+ CV_CPU_ZONE_MAX_TEMP: str = 'max_temp'
25
+ CV_CPU_ZONE_MIN_LEVEL: str = 'min_level'
26
+ CV_CPU_ZONE_MAX_LEVEL: str = 'max_level'
27
+
28
+ def __init__(self, log: Log, udevc: Context, ipmi: Ipmi, config:ConfigParser) -> None:
29
+ """Initialize the CpuZone class and raise exception in case of invalid configuration.
30
+ Args:
31
+ log (Log): reference to a Log class instance
32
+ udevc (Context): reference to an udev database connection (instance of Context from pyudev)
33
+ ipmi (Ipmi): reference to an Ipmi class instance
34
+ config (ConfigParser): reference to the configuration (default=None)
35
+ Raises:
36
+ ValueError: multiple hwmon devices reported, one expected
37
+ RuntimeError: No HWMON device found for CPU(s)
38
+ """
39
+ count: int # CPU count.
40
+
41
+ # Build the list of paths for hwmon devices.
42
+ self.hwmon_path = []
43
+ # We are looking for either Intel (coretemp) or AMD (k10temp) CPUs.
44
+ for dev_filter in [{'MODALIAS':'platform:coretemp'}, {'DRIVER':'k10temp'}]:
45
+ self.hwmon_path = [self.get_hwmon_path(udevc, dev) for dev in udevc.list_devices(**dev_filter)]
46
+ # If we found results.
47
+ if self.hwmon_path:
48
+ break
49
+ if not self.hwmon_path:
50
+ raise RuntimeError('pyudev: No HWMON device(s) can be found for the CPU.')
51
+ # Calculate count.
52
+ count = len(self.hwmon_path)
53
+
54
+ # Initialize FanController class.
55
+ super().__init__(log, ipmi, Ipmi.CPU_ZONE, CpuZone.CS_CPU_ZONE, count,
56
+ config[CpuZone.CS_CPU_ZONE].getint(CpuZone.CV_CPU_ZONE_TEMP_CALC, fallback=FanController.CALC_AVG),
57
+ config[CpuZone.CS_CPU_ZONE].getint(CpuZone.CV_CPU_ZONE_STEPS, fallback=6),
58
+ config[CpuZone.CS_CPU_ZONE].getfloat(CpuZone.CV_CPU_ZONE_SENSITIVITY, fallback=3.0),
59
+ config[CpuZone.CS_CPU_ZONE].getfloat(CpuZone.CV_CPU_ZONE_POLLING, fallback=2),
60
+ config[CpuZone.CS_CPU_ZONE].getfloat(CpuZone.CV_CPU_ZONE_MIN_TEMP, fallback=30.0),
61
+ config[CpuZone.CS_CPU_ZONE].getfloat(CpuZone.CV_CPU_ZONE_MAX_TEMP, fallback=60.0),
62
+ config[CpuZone.CS_CPU_ZONE].getint(CpuZone.CV_CPU_ZONE_MIN_LEVEL, fallback=35),
63
+ config[CpuZone.CS_CPU_ZONE].getint(CpuZone.CV_CPU_ZONE_MAX_LEVEL, fallback=100)
64
+ )
65
+
66
+ def _get_nth_temp(self, index: int) -> float:
67
+ """Get the temperature of the 'nth' element in the hwmon list.
68
+ Args:
69
+ index (int): index in hwmon list
70
+ Returns:
71
+ float: temperature value
72
+ Raises:
73
+ FileNotFoundError: file not found
74
+ IOError: file cannot be opened
75
+ ValueError: invalid value read from file
76
+ IndexError: invalid index
77
+ """
78
+ value: float # Temperature value
79
+
80
+ try:
81
+ with open(self.hwmon_path[index], "r", encoding="UTF-8") as f:
82
+ value = float(f.read()) / 1000
83
+ except (IOError, FileNotFoundError, ValueError) as e:
84
+ raise e
85
+ return value
86
+
87
+ # End.
smfc/fancontroller.py ADDED
@@ -0,0 +1,273 @@
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
+ from typing import List, Callable
9
+ from pyudev import Context, Device
10
+ from smfc.ipmi import Ipmi
11
+ from smfc.log import Log
12
+
13
+
14
+ class FanController:
15
+ """Generic fan controller class for an IPMI zone."""
16
+
17
+ # Constant values for temperature calculation
18
+ CALC_MIN: int = 0
19
+ CALC_AVG: int = 1
20
+ CALC_MAX: int = 2
21
+
22
+ # Configuration parameters
23
+ log: Log # Reference to a Log class instance
24
+ ipmi: Ipmi # Reference to an Ipmi class instance
25
+ ipmi_zone: int # IPMI zone identifier
26
+ name: str # Name of the controller
27
+ count: int # Number of controlled entities
28
+ temp_calc: int # Calculate of the temperature (0-min, 1-avg, 2-max)
29
+ steps: int # Discrete steps in temperatures and fan levels
30
+ sensitivity: float # Temperature change to activate fan controller (C)
31
+ polling: float # Polling interval to read temperature (sec)
32
+ min_temp: float # Minimum temperature value (C)
33
+ max_temp: float # Maximum temperature value (C)
34
+ min_level: int # Minimum fan level (0..100%)
35
+ max_level: int # Maximum fan level (0..100%)
36
+ hwmon_path: List[str] # List of paths for HWMON devices
37
+
38
+ # Measured or calculated attributes
39
+ temp_step: float # A temperature steps value (C)
40
+ level_step: float # A fan level step value (0..100%)
41
+ last_time: float # Last system time we polled temperature (timestamp)
42
+ last_temp: float # Last measured temperature value (C)
43
+ last_level: int # Last configured fan level (0..100%)
44
+
45
+ # Function variable for selected temperature calculation method
46
+ get_temp_func: Callable[[], float]
47
+
48
+ def __init__(self, log: Log, ipmi: Ipmi, ipmi_zone: int, name: str, count: int, temp_calc: int,
49
+ steps: int, sensitivity: float, polling: float, min_temp: float, max_temp: float, min_level: int,
50
+ max_level: int) -> None:
51
+ """Initialize the FanController class. Will raise an exception in case of invalid parameters.
52
+ Args:
53
+ log (Log): reference to a Log class instance
54
+ ipmi (Ipmi): reference to an Ipmi class instance
55
+ ipmi_zone (int): IPMI zone identifier
56
+ name (str): name of the controller
57
+ count (int): number of devices
58
+ temp_calc (int): calculation of temperature
59
+ steps (int): discrete steps in temperatures and fan levels
60
+ sensitivity (float): temperature change to activate fan controller (C)
61
+ polling (float): polling time interval for reading temperature (sec)
62
+ min_temp (float): minimum temperature value (C)
63
+ max_temp (float): maximum temperature value (C)
64
+ min_level (int): minimum fan level value [0..100%]
65
+ max_level (int): maximum fan level value [0..100%]
66
+ Raises:
67
+ ValueError: invalid input parameter
68
+ """
69
+ # Save and validate configuration parameters.
70
+ self.log = log
71
+ self.ipmi = ipmi
72
+ self.ipmi_zone = ipmi_zone
73
+ if self.ipmi_zone not in {Ipmi.CPU_ZONE, Ipmi.HD_ZONE}:
74
+ raise ValueError('invalid value: ipmi_zone')
75
+ self.name = name
76
+ self.count = count
77
+ if self.count <= 0:
78
+ raise ValueError('count <= 0')
79
+ self.temp_calc = temp_calc
80
+ if self.temp_calc not in {self.CALC_MIN, self.CALC_AVG, self.CALC_MAX}:
81
+ raise ValueError('invalid value: temp_calc')
82
+ self.steps = steps
83
+ if self.steps <= 0:
84
+ raise ValueError('steps <= 0')
85
+ self.sensitivity = sensitivity
86
+ if self.sensitivity <= 0:
87
+ raise ValueError('sensitivity <= 0')
88
+ self.polling = polling
89
+ if self.polling < 0:
90
+ raise ValueError('polling < 0')
91
+ if max_temp < min_temp:
92
+ raise ValueError('max_temp < min_temp')
93
+ self.min_temp = min_temp
94
+ self.max_temp = max_temp
95
+ if max_level < min_level:
96
+ raise ValueError('max_level < min_level')
97
+ self.min_level = min_level
98
+ self.max_level = max_level
99
+
100
+ # Set the proper temperature function.
101
+ if self.count == 1:
102
+ self.get_temp_func = self.get_1_temp
103
+ else:
104
+ self.get_temp_func = self.get_avg_temp
105
+ if self.temp_calc == self.CALC_MIN:
106
+ self.get_temp_func = self.get_min_temp
107
+ elif self.temp_calc == self.CALC_MAX:
108
+ self.get_temp_func = self.get_max_temp
109
+
110
+ # Try to read device temperature (at this point hwmon_path[] list must be prepared by a child class).
111
+ # If there is any problem with reading temperature, the program will stop here with an exception.
112
+ self.get_temp_func()
113
+
114
+ # Initialize calculated values.
115
+ self.temp_step = (max_temp - min_temp) / steps
116
+ self.level_step = (max_level - min_level) / steps
117
+ self.last_temp = 0
118
+ self.last_level = 0
119
+ self.last_time = time.monotonic() - (polling + 1)
120
+ # Print configuration at DEBUG log level.
121
+ if self.log.log_level >= Log.LOG_CONFIG:
122
+ self.log.msg(Log.LOG_CONFIG, f'{self.name} fan controller was initialized with:')
123
+ self.log.msg(Log.LOG_CONFIG, f' ipmi zone = {self.ipmi_zone}')
124
+ self.log.msg(Log.LOG_CONFIG, f' count = {self.count}')
125
+ self.log.msg(Log.LOG_CONFIG, f' temp_calc = {self.temp_calc}')
126
+ self.log.msg(Log.LOG_CONFIG, f' steps = {self.steps}')
127
+ self.log.msg(Log.LOG_CONFIG, f' sensitivity = {self.sensitivity}')
128
+ self.log.msg(Log.LOG_CONFIG, f' polling = {self.polling}')
129
+ self.log.msg(Log.LOG_CONFIG, f' min_temp = {self.min_temp}')
130
+ self.log.msg(Log.LOG_CONFIG, f' max_temp = {self.max_temp}')
131
+ self.log.msg(Log.LOG_CONFIG, f' min_level = {self.min_level}')
132
+ self.log.msg(Log.LOG_CONFIG, f' max_level = {self.max_level}')
133
+ result=[]
134
+ if hasattr(self, 'hwmon_path'):
135
+ for d in self.hwmon_path:
136
+ result.append(d if d else 'smartctl')
137
+ self.log.msg(Log.LOG_CONFIG, f' hwmon_path = {result}')
138
+ self.print_temp_level_mapping()
139
+
140
+ @staticmethod
141
+ def get_hwmon_path(udevc: Context, parent_dev: Device) -> str:
142
+ """A helper function to get HWMON path of a given parent device's associated hwmon
143
+
144
+ Args:
145
+ udevc (Context): pyudev Context
146
+ parent_dev (Device): parent device
147
+ Returns:
148
+ str: path for a HWMON device
149
+ """
150
+ try:
151
+ [hwmon_device] = udevc.list_devices(subsystem='hwmon', parent=parent_dev)
152
+ except ValueError:
153
+ # If parent_dev has zero (or more?) hwmon device in its subtree
154
+ hwmon_device = None
155
+ return os.path.join(hwmon_device.sys_path, 'temp1_input') if hwmon_device is not None else ''
156
+
157
+ def _get_nth_temp(self, index: int) -> float:
158
+ """Get the temperature of the 'nth' element in the hwmon list. This is an empty implementation."""
159
+
160
+ def get_1_temp(self) -> float:
161
+ """Get a single temperature of a controlled entity in the IPMI zone.
162
+
163
+ Returns:
164
+ float: single temperature of a controlled entity (C)
165
+ """
166
+ return self._get_nth_temp(0)
167
+
168
+ def get_min_temp(self) -> float:
169
+ """Get the minimum temperature of multiple controlled entities.
170
+
171
+ Returns:
172
+ float: minimum temperature of the controlled entities (C)
173
+ """
174
+ minimum: float # Minimum temperature value
175
+
176
+ # Calculate minimum temperature.
177
+ minimum = 1000.0
178
+ for i in range(self.count):
179
+ minimum = min(self._get_nth_temp(i), minimum)
180
+ return minimum
181
+
182
+ def get_avg_temp(self):
183
+ """Get the average temperature of the controlled entities in the IPMI zone.
184
+
185
+ Returns:
186
+ float: average temperature of the controlled entities (C)
187
+ """
188
+ average: float # Average temperature
189
+ counter: int # Value counter
190
+
191
+ # Calculate average temperature.
192
+ average = 0.0
193
+ counter = 0
194
+ for i in range(self.count):
195
+ average += self._get_nth_temp(i)
196
+ counter += 1
197
+ return average / counter
198
+
199
+ def get_max_temp(self) -> float:
200
+ """Get the maximum temperature of the controlled entities in the IPMI zone.
201
+
202
+ Returns:
203
+ float: maximum temperature of the controlled entities (C)
204
+ """
205
+ maximum: float # Maximum temperature value
206
+
207
+ # Calculate minimum temperature.
208
+ maximum = -1.0
209
+ for i in range(self.count):
210
+ maximum = max(self._get_nth_temp(i), maximum)
211
+ return maximum
212
+
213
+ def set_fan_level(self, level: int) -> None:
214
+ """Set the new fan level in the IPMI zone of the controller.
215
+
216
+ Args:
217
+ level (int): new fan level [0..100]
218
+ """
219
+ self.ipmi.set_fan_level(self.ipmi_zone, level)
220
+
221
+ def callback_func(self) -> None:
222
+ """Call-back function for a child class."""
223
+
224
+ def run(self) -> None:
225
+ """Run IPMI zone controller function with the following steps:
226
+
227
+ * Step 1: Read current time. If the elapsed time is bigger than the polling time period
228
+ then go to step 2, otherwise return.
229
+ * Step 2: Read the current temperature. If the change of the temperature goes beyond
230
+ the sensitivity limit then go to step 3, otherwise return
231
+ * Step 3: Calculate the current gain and fan level based on the measured temperature
232
+ * Step 4: If the new fan level is different it will be set and logged
233
+ """
234
+ current_time: float # Current system timestamp (measured)
235
+ current_temp: float # Current temperature (measured)
236
+ current_level: int # Current fan level (calculated)
237
+ current_gain: int # Current gain (calculated)
238
+
239
+ # Step 1: check the elapsed time.
240
+ current_time = time.monotonic()
241
+ if (current_time - self.last_time) >= self.polling:
242
+ self.last_time = current_time
243
+
244
+ # Step 2: read temperature and sensitivity gap.
245
+ self.callback_func()
246
+ current_temp = self.get_temp_func()
247
+ self.log.msg(Log.LOG_DEBUG, f'{self.name}: new temperature > {current_temp:.1f}C')
248
+ if abs(current_temp - self.last_temp) >= self.sensitivity:
249
+ self.last_temp = current_temp
250
+
251
+ # Step 3: calculate gain and fan level.
252
+ if current_temp <= self.min_temp:
253
+ current_level = self.min_level
254
+ elif current_temp >= self.max_temp:
255
+ current_level = self.max_level
256
+ else:
257
+ current_gain = int(round((current_temp - self.min_temp) / self.temp_step))
258
+ current_level = int(round(float(current_gain) * self.level_step)) + self.min_level
259
+
260
+ # Step 4: the new fan level will be set and logged.
261
+ if current_level != self.last_level:
262
+ self.last_level = current_level
263
+ self.set_fan_level(current_level)
264
+ self.log.msg(Log.LOG_INFO, f'{self.name}: new fan level > {current_level}%/{current_temp:.1f}C')
265
+
266
+ def print_temp_level_mapping(self) -> None:
267
+ """Print out the user-defined temperature to level mapping value in log DEBUG level."""
268
+ self.log.msg(Log.LOG_CONFIG, ' User-defined control function:')
269
+ for i in range(self.steps + 1):
270
+ self.log.msg(Log.LOG_CONFIG, f' {i}. [T:{self.min_temp+(i*self.temp_step):.1f}C - '
271
+ f'L:{int(self.min_level + (i * self.level_step))}%]')
272
+
273
+ # End.