From eabc8d8bd270a894e9537a2a4c86e7efafc43afc Mon Sep 17 00:00:00 2001 From: lkddi Date: Wed, 6 May 2026 10:04:15 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81=E9=80=9A=E8=BF=87=E7=8E=AF?= =?UTF-8?q?=E5=A2=83=E5=8F=98=E9=87=8F=E9=85=8D=E7=BD=AE=E6=B8=A9=E6=8E=A7?= =?UTF-8?q?=E6=A1=A3=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 1 + CHANGES.md | 3 +- README.md | 32 +++++++++++++++- controller/client.py | 72 ++++++++++++++++++++++++++++++------ start.py | 11 +++++- tests/test_fan_controller.py | 54 +++++++++++++++++++++++++++ 6 files changed, 158 insertions(+), 15 deletions(-) create mode 100644 tests/test_fan_controller.py diff --git a/.env.example b/.env.example index 0806a22..12419db 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,4 @@ HOST=192.168.1.100 USERNAME=root PASSWORD=your_idrac_password +FAN_SPEED_STEPS=50:20,55:25,60:30,65:40 diff --git a/CHANGES.md b/CHANGES.md index 8ed414a..c195999 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -3,12 +3,13 @@ ## Unreleased - 准备公开开源发布流程,Docker Hub 镜像统一为 `lkddi/dell-fans-controller`。 -- GitHub Actions 改为 PR/master 构建验证,`v*` tag 才发布 Docker 镜像。 +- GitHub Actions 改为 PR 构建验证,`v*` tag 才发布 Docker 镜像。 - 新增 Docker、Docker Compose 和本机 Python 三种运行说明。 - 移除代码中的默认 iDRAC 地址、账号和密码,改为必须通过环境变量配置。 - 增加 `.env.example`、`.dockerignore` 和 MIT License。 - 清理 Python 缓存文件,避免将运行产物提交到仓库。 - Docker 运行镜像切换到 Debian slim,只安装 `python3`、`ipmitool` 和时区数据,在降低体积的同时保留更好的IPMI兼容性。 +- 新增 `FAN_SPEED_STEPS` 环境变量,允许用户通过 `.env` 自定义温度阈值和风扇转速档位。 ## Previous Improvements diff --git a/README.md b/README.md index 64b279d..a1759f3 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ docker run -d --name dell-fans-controller \ -e HOST=192.168.1.100 \ -e USERNAME=root \ -e PASSWORD=your_idrac_password \ + -e FAN_SPEED_STEPS=50:20,55:25,60:30,65:40 \ lkddi/dell-fans-controller:latest ``` @@ -61,6 +62,7 @@ cp .env.example .env HOST=192.168.1.100 USERNAME=root PASSWORD=your_idrac_password +FAN_SPEED_STEPS=50:20,55:25,60:30,65:40 ``` Start the service / 启动服务: @@ -88,6 +90,7 @@ Run / 运行: export HOST=192.168.1.100 export USERNAME=root export PASSWORD=your_idrac_password +export FAN_SPEED_STEPS=50:20,55:25,60:30,65:40 python3 start.py ``` @@ -98,6 +101,7 @@ python3 start.py | `HOST` | Yes | iDRAC management IP address. / iDRAC 管理 IP。 | | `USERNAME` | Yes | iDRAC username with IPMI permission. / 有 IPMI 权限的 iDRAC 用户名。 | | `PASSWORD` | Yes | iDRAC password. / iDRAC 密码。 | +| `FAN_SPEED_STEPS` | No | Temperature-to-speed rules. Default: `50:20,55:25,60:30,65:40`. / 温度和风扇转速规则,默认值:`50:20,55:25,60:30,65:40`。 | The application does not include default credentials. Missing variables will stop startup with a clear error. @@ -117,6 +121,30 @@ The controller reads all temperature sensors and uses the highest value. | `60-65 C` | `40%` | | `>65 C` | iDRAC automatic mode / iDRAC 自动模式 | +Customize the policy with `FAN_SPEED_STEPS`. Each item uses `temperature:speed`, separated by commas. The controller sorts thresholds from low to high. When the current highest temperature is greater than the last threshold, it switches to iDRAC automatic mode. + +可以通过 `FAN_SPEED_STEPS` 自定义温控策略。每一项格式为 `温度:转速`,多项用英文逗号分隔。控制器会按温度阈值从低到高排序;当最高温度超过最后一个阈值时,自动切回 iDRAC 自动模式。 + +Invalid values stop startup with a clear error. Fan speed must be an integer from `10` to `100`, and temperature thresholds cannot be duplicated. + +配置错误会在启动时直接报错退出。风扇转速必须是 `10` 到 `100` 的整数,温度阈值不能重复。 + +Example / 示例: + +```env +FAN_SPEED_STEPS=45:20,55:30,62:45,70:60 +``` + +This means / 含义: + +| Temperature / 温度 | Fan Speed / 风扇转速 | +| --- | --- | +| `0-45 C` | `20%` | +| `45-55 C` | `30%` | +| `55-62 C` | `45%` | +| `62-70 C` | `60%` | +| `>70 C` | iDRAC automatic mode / iDRAC 自动模式 | + ## Troubleshooting / 故障排查 Test IPMI manually first / 先手动测试 IPMI: @@ -135,9 +163,9 @@ Common issues / 常见问题: ## Release / 发布新版本 -Docker images are published only from Git tags matching `v*`. Pull requests and `master` pushes only build and verify the image. +Docker images are published only from Git tags matching `v*`. Pull requests build and verify the image, while direct `master` pushes do not trigger Docker builds. -Docker 镜像只在推送 `v*` tag 时发布。PR 和 `master` 推送只做构建验证,不覆盖 `latest`。 +Docker 镜像只在推送 `v*` tag 时发布。PR 会做构建验证,直接推送 `master` 不触发 Docker 构建。 ```bash git tag v1.0.0 diff --git a/controller/client.py b/controller/client.py index b2f4def..595c85e 100644 --- a/controller/client.py +++ b/controller/client.py @@ -3,19 +3,74 @@ from controller.logger import logger from controller.ipmi import IpmiTool +DEFAULT_FAN_SPEED_STEPS = ( + (50, 20), + (55, 25), + (60, 30), + (65, 40), +) + + # 风扇控制器:根据iDRAC温度传感器结果自动切换风扇模式和转速 class FanController: # 初始化控制器并记录iDRAC连接信息 - def __init__(self, host: str, username: str, password: str): + def __init__(self, host: str, username: str, password: str, fan_speed_steps: str = None): self.host = host self.username = username self.password = password self.ipmi = IpmiTool(self.host, self.username, self.password) + self.fan_speed_steps = self.parse_fan_speed_steps(fan_speed_steps) self.last_set_speed = None # 记录最后设置的风扇速度 self.is_auto_mode = False # 记录当前是否为自动模式 + # 解析温控规则配置,格式为 "50:20,55:25,60:30,65:40" + def parse_fan_speed_steps(self, steps: str) -> tuple: + """ + 解析环境变量中的温控规则 + :param steps: 温度阈值和风扇转速配置 + :return: 按温度升序排列的规则元组 + """ + if steps is None: + return DEFAULT_FAN_SPEED_STEPS + + if not steps.strip(): + raise ValueError('FAN_SPEED_STEPS 至少需要包含一条温控规则') + + parsed_rules = [] + for item in steps.split(','): + item = item.strip() + if not item: + continue + + try: + temperature_text, speed_text = item.split(':', 1) + temperature = int(temperature_text.strip()) + speed = int(speed_text.strip()) + except ValueError as exc: + raise ValueError( + f'FAN_SPEED_STEPS 格式错误: {steps},正确示例: 50:20,55:25,60:30,65:40' + ) from exc + + if temperature <= 0: + raise ValueError('FAN_SPEED_STEPS 温度阈值必须大于0') + + if speed < 10 or speed > 100: + raise ValueError('FAN_SPEED_STEPS 风扇转速必须在10到100之间') + + parsed_rules.append((temperature, speed)) + + if not parsed_rules: + raise ValueError('FAN_SPEED_STEPS 至少需要包含一条温控规则') + + parsed_rules.sort(key=lambda rule: rule[0]) + temperatures = [rule[0] for rule in parsed_rules] + if len(temperatures) != len(set(temperatures)): + raise ValueError('FAN_SPEED_STEPS 不能包含重复的温度阈值') + + return tuple(parsed_rules) + # 设置手动风扇速度 def set_fan_speed(self, speed: int): logger.info(f'设置风扇速度: {speed}%') @@ -28,16 +83,11 @@ class FanController: :param temperature: 当前最高温度 :return: 对应的风扇转速百分比,如果应该切换到自动模式则返回-1 """ - if 0 < temperature <= 50: - return 20 - elif 50 < temperature <= 55: - return 25 - elif 55 < temperature <= 60: - return 30 - elif 60 < temperature <= 65: - return 40 - else: - return -1 # 表示应切换到自动模式 + for threshold, speed in self.fan_speed_steps: + if 0 < temperature <= threshold: + return speed + + return -1 # 表示应切换到自动模式 # 执行一次完整的温度读取和风扇控制周期 def run(self): diff --git a/start.py b/start.py index ea87f69..a4f4a36 100644 --- a/start.py +++ b/start.py @@ -11,6 +11,10 @@ if __name__ == '__main__': host = os.getenv('HOST') username = os.getenv('USERNAME') password = os.getenv('PASSWORD') + # 优先读取新的温控档位变量,并兼容旧别名 + fan_speed_steps = os.getenv('FAN_SPEED_STEPS') + if fan_speed_steps is None: + fan_speed_steps = os.getenv('FAN_SPEED_RULES') if not host: raise RuntimeError('未设置 HOST 环境变量') @@ -21,7 +25,12 @@ if __name__ == '__main__': raise RuntimeError('未设置 PASSWORD 环境变量') # 复用控制器实例,避免每轮循环丢失上次设置状态 - client = FanController(host=host, username=username, password=password) + client = FanController( + host=host, + username=username, + password=password, + fan_speed_steps=fan_speed_steps, + ) while True: try: diff --git a/tests/test_fan_controller.py b/tests/test_fan_controller.py new file mode 100644 index 0000000..c758222 --- /dev/null +++ b/tests/test_fan_controller.py @@ -0,0 +1,54 @@ +import unittest + +from controller.client import FanController + + +class FanControllerConfigTest(unittest.TestCase): + def make_controller(self, fan_speed_steps=None): + return FanController( + host='127.0.0.1', + username='root', + password='password', + fan_speed_steps=fan_speed_steps, + ) + + def test_default_fan_speed_steps_keep_existing_policy(self): + controller = self.make_controller() + + self.assertEqual(controller.get_required_fan_speed(50), 20) + self.assertEqual(controller.get_required_fan_speed(55), 25) + self.assertEqual(controller.get_required_fan_speed(60), 30) + self.assertEqual(controller.get_required_fan_speed(65), 40) + self.assertEqual(controller.get_required_fan_speed(66), -1) + self.assertEqual(controller.get_required_fan_speed(0), -1) + + def test_custom_fan_speed_steps_are_sorted_by_temperature(self): + controller = self.make_controller('70:50,45:15,55:25') + + self.assertEqual(controller.get_required_fan_speed(45), 15) + self.assertEqual(controller.get_required_fan_speed(46), 25) + self.assertEqual(controller.get_required_fan_speed(55), 25) + self.assertEqual(controller.get_required_fan_speed(56), 50) + self.assertEqual(controller.get_required_fan_speed(70), 50) + self.assertEqual(controller.get_required_fan_speed(71), -1) + + def test_invalid_fan_speed_steps_raise_clear_error(self): + invalid_values = [ + '', + '50', + 'abc:20', + '50:abc', + '-1:20', + '50:9', + '50:101', + '50:20,50:30', + ] + + for invalid_value in invalid_values: + with self.subTest(invalid_value=invalid_value): + with self.assertRaises(ValueError): + self.make_controller(invalid_value) + + +if __name__ == '__main__': + unittest.main()