Skip to content
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
85 changes: 76 additions & 9 deletions platform/mellanox/mlnx-platform-api/sonic_platform/fan.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#
# SPDX-FileCopyrightText: NVIDIA CORPORATION & AFFILIATES
# Copyright (c) 2019-2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
# Copyright (c) 2019-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -148,6 +148,7 @@ def get_fan_direction(cls, dir_path):


class PsuFan(MlnxFan):

# PSU fan speed vector
PSU_FAN_SPEED = ['0x3c', '0x3c', '0x3c', '0x3c', '0x3c',
'0x3c', '0x3c', '0x46', '0x50', '0x5a', '0x64']
Expand Down Expand Up @@ -211,23 +212,89 @@ def get_presence(self):
"""
return self.psu.get_presence() and self.psu.get_powergood_status() and os.path.exists(self.fan_presence_path)

def get_speed_tolerance(self):
"""
Retrieves the speed tolerance of the fan

Returns:
An integer, the percentage of variance from min/max speed which is
considered tolerable
"""
# The tolerance value is fixed as 30% for PSU fans
return 30

def is_under_speed(self):
"""
Checks if PSU fan speed is below the hardware minimum RPM threshold.

Returns:
bool: True if fan speed is under the minimum threshold, False if not
"""
if not self.get_presence():
return False

try:
speed_in_rpm = utils.read_int_from_file(self.fan_speed_get_path, raise_exception=True)
min_speed_in_rpm = utils.read_int_from_file(self.fan_min_speed_path, raise_exception=True)
except (ValueError, IOError):
return False

return speed_in_rpm < min_speed_in_rpm * (1 - self.get_speed_tolerance() / 100.0)

def is_over_speed(self):
"""
Checks if PSU fan speed is above the hardware maximum RPM threshold.

Returns:
bool: True if fan speed is over the maximum threshold, False if not
"""
if not self.get_presence():
return False

try:
speed_in_rpm = utils.read_int_from_file(self.fan_speed_get_path, raise_exception=True)
max_speed_in_rpm = utils.read_int_from_file(self.fan_max_speed_path, raise_exception=True)
except (ValueError, IOError):
return False

if max_speed_in_rpm == 0:
return False

return speed_in_rpm > max_speed_in_rpm * (1 + self.get_speed_tolerance() / 100.0)

def get_target_speed(self):
"""
Retrieves the expected speed of fan

Returns:
int: percentage of the max fan speed
"""
if not self.get_presence():
return 0

try:
# Get PSU fan target speed according to current system cooling level
pwm = utils.read_int_from_file('/run/hw-management/thermal/pwm1', log_func=None)
if pwm >= PWM_MAX:
pwm = PWM_MAX - 1
cooling_level = int(pwm / PWM_MAX * 10)
return int(self.PSU_FAN_SPEED[cooling_level], 16)
except Exception:
speed_in_rpm = utils.read_int_from_file(self.fan_speed_get_path, raise_exception=True)
max_speed_in_rpm = utils.read_int_from_file(self.fan_max_speed_path, raise_exception=True)
min_speed_in_rpm = utils.read_int_from_file(self.fan_min_speed_path, raise_exception=True)
except (ValueError, IOError):
return self.get_speed()

if max_speed_in_rpm == 0:
return self.get_speed()

if speed_in_rpm < min_speed_in_rpm:
target_rpm = min_speed_in_rpm
elif speed_in_rpm > max_speed_in_rpm:
target_rpm = max_speed_in_rpm
else:
target_rpm = speed_in_rpm

speed = 100 * target_rpm // max_speed_in_rpm
if speed > 100:
speed = 100

return speed

def set_speed(self, speed):
"""
Set fan speed to expected value
Expand Down
122 changes: 118 additions & 4 deletions platform/mellanox/mlnx-platform-api/tests/test_fan_api.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#
# SPDX-FileCopyrightText: NVIDIA CORPORATION & AFFILIATES
# Copyright (c) 2020-2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
# Copyright (c) 2020-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -126,15 +126,129 @@ def test_psu_fan_basic(self, mock_path_exists, mock_powergood, mock_presence, mo
assert fan.get_presence() is False
mock_path_exists.return_value = True
assert fan.get_presence() is True
mock_read_int.return_value = int(255 / 10 * 7)
assert fan.get_target_speed() == 60
mock_read_int.return_value = FAN_DIR_VALUE_INTAKE
assert fan.get_direction() == Fan.FAN_DIRECTION_INTAKE
mock_read_int.return_value = FAN_DIR_VALUE_EXHAUST
assert fan.get_direction() == Fan.FAN_DIRECTION_EXHAUST
mock_read_int.return_value = -1 # invalid value
assert fan.get_direction() == Fan.FAN_DIRECTION_NOT_APPLICABLE

def test_psu_fan_get_speed_tolerance(self):
psu = Psu(0)
fan = PsuFan(0, 1, psu)
assert fan.get_speed_tolerance() == 30

def test_psu_fan_is_under_speed(self):
psu = Psu(0)
fan = PsuFan(0, 1, psu)
fan.get_presence = MagicMock(return_value=False)
assert fan.is_under_speed() is False

fan.get_presence = MagicMock(return_value=True)
mock_sysfs_content = {
fan.fan_speed_get_path: 3000,
fan.fan_min_speed_path: 5000,
}

def mock_read_int_from_file(file_path, default=0, raise_exception=False, log_func=None):
return mock_sysfs_content[file_path]

utils.read_int_from_file = mock_read_int_from_file
# threshold = 5000 * (1 - 0.3) = 3500, speed 3000 < 3500 -> under speed
assert fan.is_under_speed() is True

mock_sysfs_content[fan.fan_speed_get_path] = 3500
assert fan.is_under_speed() is False

# genuine 0 RPM stall should still trigger under-speed
mock_sysfs_content[fan.fan_speed_get_path] = 0
assert fan.is_under_speed() is True

# read failure should not trigger under-speed
def mock_read_int_raise(file_path, default=0, raise_exception=False, log_func=None):
if raise_exception and file_path == fan.fan_speed_get_path:
raise IOError('read failed')
return mock_sysfs_content[file_path]

utils.read_int_from_file = mock_read_int_raise
assert fan.is_under_speed() is False

def test_psu_fan_is_over_speed(self):
psu = Psu(0)
fan = PsuFan(0, 1, psu)
fan.get_presence = MagicMock(return_value=False)
assert fan.is_over_speed() is False

fan.get_presence = MagicMock(return_value=True)
mock_sysfs_content = {
fan.fan_speed_get_path: 27000,
fan.fan_max_speed_path: 20000,
}

def mock_read_int_from_file(file_path, default=0, raise_exception=False, log_func=None):
return mock_sysfs_content[file_path]

utils.read_int_from_file = mock_read_int_from_file
# threshold = 20000 * (1 + 0.3) = 26000, speed 27000 > 26000 -> over speed
assert fan.is_over_speed() is True

mock_sysfs_content[fan.fan_speed_get_path] = 26000
assert fan.is_over_speed() is False

# max_rpm == 0 is an unavailable threshold -> not over-speed
mock_sysfs_content[fan.fan_max_speed_path] = 0
assert fan.is_over_speed() is False

# read failure of max speed should not trigger over-speed
mock_sysfs_content[fan.fan_max_speed_path] = 20000
def mock_read_int_raise(file_path, default=0, raise_exception=False, log_func=None):
if raise_exception and file_path == fan.fan_max_speed_path:
raise IOError('read failed')
return mock_sysfs_content[file_path]

utils.read_int_from_file = mock_read_int_raise
assert fan.is_over_speed() is False

def test_psu_fan_get_target_speed(self):
psu = Psu(0)
fan = PsuFan(0, 1, psu)
fan.get_presence = MagicMock(return_value=True)

mock_sysfs_content = {
fan.fan_speed_get_path: 15000,
fan.fan_min_speed_path: 10000,
fan.fan_max_speed_path: 20000,
}

def mock_read_int_from_file(file_path, default=0, raise_exception=False, log_func=None):
return mock_sysfs_content[file_path]

utils.read_int_from_file = mock_read_int_from_file
# speed in [min, max] -> return current speed as percentage
assert fan.get_target_speed() == 75

mock_sysfs_content[fan.fan_speed_get_path] = 5000
# below min -> clamp to min as percentage: 100 * 10000 // 20000 = 50
assert fan.get_target_speed() == 50

mock_sysfs_content[fan.fan_speed_get_path] = 30000
# above max -> clamp to max as percentage: 100
assert fan.get_target_speed() == 100

mock_sysfs_content[fan.fan_max_speed_path] = 0
mock_sysfs_content[fan.fan_speed_get_path] = 8000
fan.get_speed = MagicMock(return_value=30)
# max_rpm == 0 -> fall back to get_speed()
assert fan.get_target_speed() == 30

fan.get_speed = MagicMock(return_value=42)
utils.read_int_from_file = MagicMock(side_effect=IOError('read failed'))
# read failure -> fall back to get_speed()
assert fan.get_target_speed() == 42

fan.get_presence = MagicMock(return_value=False)
assert fan.get_target_speed() == 0

def test_psu_fan_set_speed(self):
psu = Psu(0)
fan = PsuFan(0, 1, psu)
Expand Down
Loading