From c22db5f54582265cf953c08ceb940e1e3ce9bf57 Mon Sep 17 00:00:00 2001 From: Yuanzhe Liu Date: Thu, 9 Jul 2026 15:04:35 +0800 Subject: [PATCH] fix fan speed tolerance from hardcode to hw-mgmt sysfs reading Signed-off-by: Yuanzhe Liu --- .../mlnx-platform-api/sonic_platform/fan.py | 85 ++++++++++-- .../mlnx-platform-api/tests/test_fan_api.py | 122 +++++++++++++++++- 2 files changed, 194 insertions(+), 13 deletions(-) diff --git a/platform/mellanox/mlnx-platform-api/sonic_platform/fan.py b/platform/mellanox/mlnx-platform-api/sonic_platform/fan.py index a1c8691f3ed..d2ad82648f5 100644 --- a/platform/mellanox/mlnx-platform-api/sonic_platform/fan.py +++ b/platform/mellanox/mlnx-platform-api/sonic_platform/fan.py @@ -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. @@ -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'] @@ -211,6 +212,56 @@ 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 @@ -218,16 +269,32 @@ def get_target_speed(self): 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 diff --git a/platform/mellanox/mlnx-platform-api/tests/test_fan_api.py b/platform/mellanox/mlnx-platform-api/tests/test_fan_api.py index 52ab2aa73a1..4ef7befd9e3 100644 --- a/platform/mellanox/mlnx-platform-api/tests/test_fan_api.py +++ b/platform/mellanox/mlnx-platform-api/tests/test_fan_api.py @@ -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. @@ -126,8 +126,6 @@ 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 @@ -135,6 +133,122 @@ def test_psu_fan_basic(self, mock_path_exists, mock_powergood, mock_presence, mo 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)