Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Interface with udev database for various fixes (but future work needed) #42

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ LABEL org.opencontainers.image.url="https://github.com/petersulyok/smfc"

RUN <<EOT
set -xe
apk add --no-cache ipmitool python3 smartmontools
apk add --no-cache ipmitool python3 smartmontools py3-udev
ln -s /usr/sbin/ipmitool /usr/bin/ipmitool
apk add --no-cache --virtual .depends git build-base linux-headers automake autoconf gettext-dev
mkdir /tmp/build/
Expand Down
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,7 @@ Here are some sample HWMON file locations for these kernel modules:
- `drivetemp`: `/sys/class/scsi_disk/0:0:0:0/device/hwmon/hwmon*/temp1_input`

Notes:
- `smfc` is able to find the proper HWMON file automatically for Intel(R) CPUs and SATA hard drives, but users of the AMD(R) CPU should specify manually (see `hwmon_path=` parameter in the config file)
- `smfc` is able to find the proper HWMON file automatically for Intel(R) CPUs, AMD(R) CPUs, SATA drives, or NVMe drives, but users may also specify the files manually (see `hwmon_path=` parameter in the config file)
- Reading `drivetemp` module is the fastest way to get the temperature of the hard disks, and it can read temperature of the SATA hard disks even in standby mode, too.

### 10. Installation
Expand Down Expand Up @@ -327,6 +327,7 @@ Edit `/opt/smfc/smfc.conf` and specify your configuration parameters here:
# Fan controller enabled (bool, default=0)
enabled=1
# Number of CPUs (int, default=1)
# If hwmon_path is not specified (i.e., if CPU detection is automatic), then the value of count is overridden by the detected number of sockets.
count=1
# Calculation method for CPU temperatures (int, [0-minimum, 1-average, 2-maximum], default=1)
temp_calc=1
Expand All @@ -344,8 +345,8 @@ Edit `/opt/smfc/smfc.conf` and specify your configuration parameters here:
min_level=35
# Maximum CPU fan level (int, %, default=100)
max_level=100
# Optional parameter, it will be generated automatically for Intel CPUs and must be specified manually for AMD CPUs.
# Path for CPU sys/hwmon file(s) (str multi-line list, default=/sys/devices/platform/coretemp.0/hwmon/hwmon*/temp1_input)
# Optional parameter, it will be generated automatically if not specified
# Path for CPU sys/hwmon file(s) (str multi-line list, default="")
# hwmon_path=/sys/devices/platform/coretemp.0/hwmon/hwmon*/temp1_input
# /sys/devices/platform/coretemp.1/hwmon/hwmon*/temp1_input
# or
Expand Down Expand Up @@ -417,7 +418,7 @@ Important notes:

My experience is that Noctua fans in my box are running stable in the 35-100% fan level interval. An additional user experience is (see [issue #12](https://github.com/petersulyok/smfc/issues/12)) when Noctua fans are paired with Ultra Low Noise Adapter the minimum stable fan level could go up to 45% (i.e. 35% is not stable).

3. `[CPU zone] / [HD zone] hwmon_path=`: This parameter is optional for Intel(R) CPUs and SATA drives (i.e. `smfc` can identify automatically the proper file locations), but must be specified manually for AMD(R) CPUs. In case of SAS/SCSI hard disks (where `drivetemp` cannot be loaded) you can specify `hddtemp` value. You can use wild characters (`?,*`) in this parameter and `smfc` will do the path resolution automatically.
3. `[CPU zone] / [HD zone] hwmon_path=`: This parameter is optional for Intel(R) CPUs, AMD(R) CPUs, SATA drives, and NVME drives (i.e., `smfc` can automatically identify the proper file locations), but may also be specified manually for special use cases. In case of SAS/SCSI hard disks (where `drivetemp` cannot be loaded) you can specify `hddtemp` value. You can use wild characters (`?,*`) in this parameter and `smfc` will do the path resolution automatically.
4. Several sample configuration files are provided for different scenarios in folder `./src/samples`. Please take a look on them, it could be a good starting point in the creation of your own configuration.

### 12. Automatic execution of the service
Expand Down
1 change: 1 addition & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ coverage
pylint
pytest
pytest-cov
pyudev
1 change: 1 addition & 0 deletions smfc.spec
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ Requires: systemd
Requires: python3 >= 3.7
Requires: bash
Requires: ipmitool
Requires: python3-pyudev
Recommends: smartmontools
Recommends: hddtemp

Expand Down
7 changes: 4 additions & 3 deletions src/smfc.conf
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ swapped_zones=0
# Fan controller enabled (bool, default=0)
enabled=1
# Number of CPUs (int, default=1)
# If hwmon_path is not specified (i.e., if CPU detection is automatic), then the value of count is overridden by the detected number of sockets.
count=1
# Calculation method for CPU temperatures (int, [0-minimum, 1-average, 2-maximum], default=1)
temp_calc=1
Expand All @@ -39,11 +40,11 @@ max_temp=60.0
min_level=35
# Maximum CPU fan level (int, %, default=100)
max_level=100
# Path for CPU sys/hwmon file(s) (str multi-line list, default=/sys/devices/platform/coretemp.0/hwmon/hwmon*/temp1_input)
# It will be automatically generated for Intel CPUs:
# Optional parameter, it will be generated automatically if not specified
# Path for CPU sys/hwmon file(s) (str multi-line list, default="")
# hwmon_path=/sys/devices/platform/coretemp.0/hwmon/hwmon*/temp1_input
# /sys/devices/platform/coretemp.1/hwmon/hwmon*/temp1_input
# and must be specified for AMD CPUs:
# or
# hwmon_path=/sys/bus/pci/drivers/k10temp/0000*/hwmon/hwmon*/temp1_input


Expand Down
81 changes: 43 additions & 38 deletions src/smfc.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import time
from typing import List, Callable

from pyudev import Context, Devices, Device

# Program version string
version_str: str = '3.5.1'
Expand Down Expand Up @@ -303,6 +304,7 @@ class FanController:

# Configuration parameters
log: Log # Reference to a Log class instance
udev_context: Context # Reference to a udev database connection (instance of Context from pyudev)
ipmi: Ipmi # Reference to an Ipmi class instance
ipmi_zone: int # IPMI zone identifier
name: str # Name of the controller
Expand All @@ -328,13 +330,14 @@ class FanController:
# Function variable for selected temperature calculation method
get_temp_func: Callable[[], float]

def __init__(self, log: Log, ipmi: Ipmi, ipmi_zone: int, name: str, count: int, temp_calc: int, steps: int,
def __init__(self, log: Log, udev_context: Context, ipmi: Ipmi, ipmi_zone: int, name: str, count: int, temp_calc: int, steps: int,
sensitivity: float, polling: float, min_temp: float, max_temp: float, min_level: int,
max_level: int, hwmon_path: str, hwmon_reserved: set) -> None:
"""Initialize the FanController class. Will raise an exception in case of invalid parameters.

Args:
log (Log): reference to a Log class instance
udev_context (Context): reference to a udev database connection (instance of Context from pyudev)
ipmi (Ipmi): reference to an Ipmi class instance
ipmi_zone (int): IPMI zone identifier
name (str): name of the controller
Expand All @@ -352,6 +355,7 @@ def __init__(self, log: Log, ipmi: Ipmi, ipmi_zone: int, name: str, count: int,
"""
# Save and validate configuration parameters.
self.log = log
self.udev_context = udev_context
self.ipmi = ipmi
self.ipmi_zone = ipmi_zone
if self.ipmi_zone not in {Ipmi.CPU_ZONE, Ipmi.HD_ZONE}:
Expand Down Expand Up @@ -418,6 +422,15 @@ def __init__(self, log: Log, ipmi: Ipmi, ipmi_zone: int, name: str, count: int,
self.log.msg(self.log.LOG_CONFIG, f' hwmon_path = {self.hwmon_path}')
self.print_temp_level_mapping()

def get_tempinput(self, parent_dev: Device) -> None:
"""A helper function to get the temp1_input attribute path of a given parent device's associated hwmon

Raises:
ValueError: if parent_dev does not have exactly one hwmon device in its subtree
"""
[hwmon_dev] = self.udev_context.list_devices(subsystem='hwmon', parent=parent_dev)
return os.path.join(hwmon_dev.sys_path, 'temp1_input')

def build_hwmon_path(self, hwmon_str: str) -> None:
"""Build hwmon_path[] list for the specific zone."""

Expand Down Expand Up @@ -586,18 +599,19 @@ class CpuZone(FanController):
CV_CPU_ZONE_MAX_LEVEL: str = 'max_level'
CV_CPU_ZONE_HWMON_PATH: str = 'hwmon_path'

def __init__(self, log: Log, ipmi: Ipmi, config: configparser.ConfigParser) -> None:
def __init__(self, log: Log, udev_context: Context, ipmi: Ipmi, config: configparser.ConfigParser) -> None:
"""Initialize the CpuZone class and raise exception in case of invalid configuration.

Args:
log (Log): reference to a Log class instance
udev_context (Context): reference to a udev database connection (instance of Context from pyudev)
ipmi (Ipmi): reference to an Ipmi class instance
config (configparser.ConfigParser): reference to the configuration (default=None)
"""

# Initialize FanController class.
super().__init__(
log, ipmi, Ipmi.CPU_ZONE, self.CS_CPU_ZONE,
log, udev_context, ipmi, Ipmi.CPU_ZONE, self.CS_CPU_ZONE,
config[self.CS_CPU_ZONE].getint(self.CV_CPU_ZONE_COUNT, fallback=1),
config[self.CS_CPU_ZONE].getint(self.CV_CPU_ZONE_TEMP_CALC, fallback=FanController.CALC_AVG),
config[self.CS_CPU_ZONE].getint(self.CV_CPU_ZONE_STEPS, fallback=6),
Expand All @@ -620,16 +634,17 @@ def build_hwmon_path(self, hwmon_str: str) -> None:
if hwmon_str:
# Convert the string into a list of path.
super().build_hwmon_path(hwmon_str)
# If the hwmon_path string was not specified it will be created automatically.
# If the hwmon_path string was not specified it will be created automatically. We assume either Intel (using coretemp) or AMD (using k10temp) CPUs
else:
# Construct hwmon_path with the resolution of wildcard characters.
self.hwmon_path = []
for i in range(self.count):
path = '/sys/devices/platform/coretemp.' + str(i) + '/hwmon/hwmon*/temp1_input'
file_names = glob.glob(path)
if not file_names:
raise ValueError(self.ERROR_MSG_FILE_IO.format(path))
self.hwmon_path.append(file_names[0])
for module in ['coretemp', 'k10temp']:
hwmon_path = [self.get_tempinput(dev) for dev in self.udev_context.list_devices(DRIVER=module)]
if hwmon_path:
break
if not hwmon_path:
self.log.msg(Log.LOG_ERROR, 'No explicit hwmon_path was configured, and automatic detection failed to find any devices using the coretemp or k10temp modules')
sys.exit(6)
self.hwmon_path = hwmon_path
self.count = len(hwmon_path)

def _get_nth_temp(self, index: int) -> float:
"""Get the temperature of the 'nth' element in the hwmon list.
Expand Down Expand Up @@ -695,11 +710,12 @@ class HdZone(FanController):
# Constant for using 'hddtemp'
STR_HDD_TEMP: str = 'hddtemp'

def __init__(self, log: Log, ipmi: Ipmi, config: configparser.ConfigParser) -> None:
def __init__(self, log: Log, udev_context: Context, ipmi: Ipmi, config: configparser.ConfigParser) -> None:
"""Initialize the HdZone class. Abort in case of configuration errors.

Args:
log (Log): reference to a Log class instance
udev_context (Context): reference to a udev database connection (instance of Context from pyudev)
ipmi (Ipmi): reference to an Ipmi class instance
config (configparser.ConfigParser): reference to the configuration (default=None)
"""
Expand All @@ -724,7 +740,7 @@ def __init__(self, log: Log, ipmi: Ipmi, config: configparser.ConfigParser) -> N
self.hddtemp_path = config[self.CS_HD_ZONE].get(self.CV_HD_ZONE_HDDTEMP_PATH, '/usr/sbin/hddtemp')
# Initialize FanController class.
super().__init__(
log, ipmi, Ipmi.HD_ZONE, self.CS_HD_ZONE, count,
log, udev_context, ipmi, Ipmi.HD_ZONE, self.CS_HD_ZONE, count,
config[self.CS_HD_ZONE].getint(self.CV_HD_ZONE_TEMP_CALC, fallback=FanController.CALC_AVG),
config[self.CS_HD_ZONE].getint(self.CV_HD_ZONE_STEPS, fallback=4),
config[self.CS_HD_ZONE].getfloat(self.CV_HD_ZONE_SENSITIVITY, fallback=2),
Expand Down Expand Up @@ -787,28 +803,9 @@ def build_hwmon_path(self, hwmon_str: str) -> None:

# If the current one is an NVME SSD disk.
# NOTE: kernel provides this, no extra modules required
if "nvme-" in self.hd_device_names[i]:
disk_name = os.path.basename(os.readlink(self.hd_device_names[i]))
search_str = os.path.join('/sys/class/nvme', disk_name, disk_name + "n1", 'hwmon*/temp1_input')
file_names = glob.glob(search_str)
if not file_names:
raise ValueError(self.ERROR_MSG_FILE_IO.format(search_str))
self.hwmon_path.append(file_names[0])

# If the current one is a SATA disk.
# NOTE: 'drivetemp' kernel module must be loaded otherwise this path does not exist!
elif "ata-" in self.hd_device_names[i] or "-SATA" in self.hd_device_names[i]:
disk_name = os.path.basename(os.readlink(self.hd_device_names[i]))
search_str = os.path.join('/sys/class/scsi_disk/*', 'device/block', disk_name)
file_names = glob.glob(search_str)
if not file_names:
raise ValueError(self.ERROR_MSG_FILE_IO.format(search_str))
file_names[0] = file_names[0].replace("/device/block/" + disk_name, "")
search_str = os.path.join(file_names[0], 'device/hwmon/hwmon*/temp1_input')
file_names = glob.glob(search_str)
if not file_names:
raise ValueError(self.ERROR_MSG_FILE_IO.format(search_str))
self.hwmon_path.append(file_names[0])
if "nvme-" in self.hd_device_names[i] or "ata-" in self.hd_device_names[i] or "-SATA" in self.hd_device_names[i]:
block_dev = Devices.from_device_file(self.udev_context, self.hd_device_names[i])
self.hwmon_path.append(self.get_tempinput(block_dev.parent))

# Otherwise we assume it is a SAS/SCSI disk.
# 'hddtemp' command will be used to read HD temperature.
Expand Down Expand Up @@ -955,6 +952,7 @@ class Service:

config: configparser.ConfigParser # Instance for a parsed configuration
log: Log # Instance for a Log class
udev_context: Context # Reference to a udev database connection (instance of Context from pyudev)
ipmi: Ipmi # Instance for an Ipmi class
cpu_zone: CpuZone # Instance for a CPU Zone fan controller class
hd_zone: HdZone # Instance for an HD Zone fan controller class
Expand Down Expand Up @@ -1111,17 +1109,24 @@ def run(self) -> None:
self.log.msg(Log.LOG_DEBUG,
f'New IPMI fan mode = {self.ipmi.get_fan_mode_name(Ipmi.FULL_MODE)}')

# Initialize connection to udev database
try:
self.udev_context = Context()
except ImportError as e:
self.log.msg(Log.LOG_ERROR, f'Could not interface with libudev. Check your installation: {e}.')
sys.exit(7)

# Create an instance for CPU zone fan controller if enabled.
# self.cpu_zone = None
if self.cpu_zone_enabled:
self.log.msg(Log.LOG_DEBUG, 'CPU zone fan controller enabled')
self.cpu_zone = CpuZone(self.log, self.ipmi, self.config)
self.cpu_zone = CpuZone(self.log, self.udev_context, self.ipmi, self.config)

# Create an instance for HD zone fan controller if enabled.
# self.hd_zone = None
if self.hd_zone_enabled:
self.log.msg(Log.LOG_DEBUG, 'HD zone fan controller enabled')
self.hd_zone = HdZone(self.log, self.ipmi, self.config)
self.hd_zone = HdZone(self.log, self.udev_context, self.ipmi, self.config)

# Calculate the default sleep time for the main loop.
if self.cpu_zone_enabled and self.hd_zone_enabled:
Expand Down
Loading