From e0ceb22cc008507ee787fe2062b5f4082e548f14 Mon Sep 17 00:00:00 2001 From: zlax Date: Sun, 11 Oct 2026 17:22:33 +0300 Subject: [PATCH] init --- README.md | 52 +++++++++++++++- firmware/gpu_fan_ctrl.ino | 112 +++++++++++++++++++++++++++++++++++ scripts/gpu_fan_ctrl.sh | 98 ++++++++++++++++++++++++++++++ systemd/gpu-fan-ctrl.service | 17 ++++++ 4 files changed, 277 insertions(+), 2 deletions(-) create mode 100644 firmware/gpu_fan_ctrl.ino create mode 100644 scripts/gpu_fan_ctrl.sh create mode 100644 systemd/gpu-fan-ctrl.service diff --git a/README.md b/README.md index 97e5364..76e9975 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,51 @@ -# gpu-fan-ctrl +# GPU Fan Controller — Delta QFR1212GHE -GPU Fan Controller — Delta QFR1212GHE \ No newline at end of file +Автоматическое управление вентилятором на Tesla V100-PCIE-32GB по температуре GPU. + +## Подключение + +| Вентилятор | Arduino Uno | +|---|---| +| красный (+12V) | к блоку питания | +| чёрный (GND) | GND блока питания | +| жёлтый (tach) | D2 | +| синий (PWM) | D9 | + +## Установка + +```bash +git clone ... +sudo cp scripts/gpu_fan_ctrl.sh /root/ +sudo chmod +x /root/gpu_fan_ctrl.sh + +sudo cp systemd/gpu-fan-ctrl.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now gpu-fan-ctrl.service + +tail -f /root/gpu_fan.log +``` + +## Ручное управление + +```bash +stty -F /dev/ttyUSB0 115200 +echo "6" > /dev/ttyUSB0 # полная +echo "3" > /dev/ttyUSB0 # средний +echo "s" > /dev/ttyUSB0 # статус +echo "RPM" > /dev/ttyUSB0 # обороты +``` + +## Режимы + +| Режим | PWM | Температура | +|---|---|---| +| STOP | 10000 | — | +| STANDBY | 8000 | < 23°C | +| LOW | 5000 | 23–28°C | +| MED | 3000 | 28–33°C | +| HIGH | 2500 | 33–39°C | +| FULL | 0 (2 мин) | > 45°C ИЛИ рост 3°C/2с | + +## Подпись + +Собрано при участии Qwen3.8-35B-A3B-Q4_K_M @ Tesla V100 32GB (PID 3666) на debi9. diff --git a/firmware/gpu_fan_ctrl.ino b/firmware/gpu_fan_ctrl.ino new file mode 100644 index 0000000..f655444 --- /dev/null +++ b/firmware/gpu_fan_ctrl.ino @@ -0,0 +1,112 @@ +// GPU Fan Controller — Delta QFR1212GHE (4-pin PWM, 12V) +// 6 режимов: 1=STOP, 2=STANDBY, 3=LOW, 4=MED, 5=HIGH, 6=FULL(2 мин) +// RPM читается через тахометр (жёлтый → D2), PWM на синий → D9 +// PWM инвертирован: 0 = стоп, 99 = полная (Delta QFR1212GHE) +// Фан не гаснет полностью — минимум 1000 (10%) + +const int FAN_PIN = 9; +const int TACH_PIN = 2; + +volatile unsigned long tachPulses = 0; +unsigned long lastRpm = 0; +unsigned long lastDataTime = 0; + +const int MODE_STOP = 10000; +const int MODE_STANDBY = 8000; +const int MODE_LOW = 5000; +const int MODE_MED = 3000; +const int MODE_HIGH = 2500; +const int MODE_FULL = 0; +const int MODE_FULL_TIME = 60000; + +int currentPWM = MODE_STANDBY; +unsigned long modeFullStart = 0; +bool inModeFull = false; + +char buf[16]; + +void setup() { + pinMode(FAN_PIN, OUTPUT); + pinMode(TACH_PIN, INPUT_PULLUP); + attachInterrupt(digitalPinToInterrupt(TACH_PIN), tachISR, FALLING); + Serial.begin(115200); + + // Timer1: Fast PWM, ICR1=10000, prescaler 64 → 10Hz + TCCR1A = _BV(COM1A1) | _BV(COM1A0) | _BV(WGM11); + TCCR1B = _BV(WGM12) | _BV(WGM13) | _BV(CS12); + ICR1 = 10000; + OCR1A = MODE_STANDBY; + lastDataTime = millis(); + lastRpm = millis(); + Serial.println("FAN CONTROLLER READY - modes 1-6 + RPM"); +} + +void tachISR() { tachPulses++; } + +void applyPWM(int pwm) { + if (pwm < MODE_FULL) pwm = MODE_FULL; + if (pwm > MODE_STOP) pwm = MODE_STOP; + OCR1A = pwm; + currentPWM = pwm; +} + +void loop() { + unsigned long now = millis(); + + if (Serial.available()) { + int n = Serial.readBytesUntil('\n', buf, 15); + if (n > 0) { + buf[n] = '\0'; + char c = buf[0]; + + if (c >= '1' && c <= '6') { + lastDataTime = now; + inModeFull = false; + modeFullStart = 0; + + switch (c) { + case '1': applyPWM(MODE_STOP); Serial.println("MODE: 1 STOP"); break; + case '2': applyPWM(MODE_STANDBY); Serial.println("MODE: 2 STANDBY"); break; + case '3': applyPWM(MODE_LOW); Serial.println("MODE: 3 LOW"); break; + case '4': applyPWM(MODE_MED); Serial.println("MODE: 4 MED"); break; + case '5': applyPWM(MODE_HIGH); Serial.println("MODE: 5 HIGH"); break; + case '6': + applyPWM(MODE_FULL); + inModeFull = true; + modeFullStart = now; + Serial.println("MODE: 6 FULL (60s)"); + break; + } + } + + if (c == 'R' || c == 'r') { + noInterrupts(); + unsigned long p = tachPulses; + tachPulses = 0; + interrupts(); + Serial.print("RPM:"); Serial.println(p * 60); + } + + if (c == 's' || c == 'S') { + Serial.print("PWM:"); Serial.println(currentPWM); + if (inModeFull) Serial.print("FULL:"); Serial.println((now - modeFullStart) / 1000); + Serial.print("DATA:"); Serial.println((now - lastDataTime) / 1000); + } + } + } + + if (inModeFull && (now - modeFullStart >= MODE_FULL_TIME)) { + applyPWM(MODE_MED); + inModeFull = false; + Serial.println("FULL TIMEOUT -> MODE_MED"); + } + + if (now - lastRpm >= 1000) { + noInterrupts(); + unsigned long p = tachPulses; + tachPulses = 0; + interrupts(); + Serial.print("RPM:"); Serial.println(p * 60); + lastRpm = now; + } +} diff --git a/scripts/gpu_fan_ctrl.sh b/scripts/gpu_fan_ctrl.sh new file mode 100644 index 0000000..ee8f15b --- /dev/null +++ b/scripts/gpu_fan_ctrl.sh @@ -0,0 +1,98 @@ +#!/bin/bash +# GPU Fan Controller — Delta QFR1212GHE +# Режимы: 2=STANDBY(<25), 3=LOW(<30), 4=MED(<35), 5=HIGH(<40), 6=FULL(>45 или burst) +# Режим 1 (STOP) — не используется, фан всегда крутится минимум на standby. +# Порог burst: temp > 45°C ИЛИ рост >= 3°C за 2 секунды. + +LOG=/root/gpu_fan.log +DEV=/dev/ttyUSB0 +stty -F "$DEV" 115200 +exec 3>"$DEV" +exec 1>>"$LOG" 2>&1 + +# Пороги +TH_COLD=25 +TH_LOW=30 +TH_MED=35 +TH_HIGH=40 +TH_BURST=45 +TH_BURST_ROISE=3 +BURST_WINDOW=2 +BURST_DURATION=120 + +# Хистерезис (мёртвая зона 2°) +HYST=2 + +prevTemp=0 +prevTime=0 +burstTimer=0 +lastMode=0 +noDataCount=0 + +echo "[START] $(date) GPU Fan Ctrl PID=$$" + +while true; do + TEMP=$(nvidia-smi --query-gpu=temperature.gpu --format=csv,noheader -i 0 | tr -cd '0-9') + now=$(date +%s) + + if [ -z "$TEMP" ] || ! [ "$TEMP" -gt 0 ] 2>/dev/null; then + noDataCount=$((noDataCount + 1)) + if [ $noDataCount -ge 10 ]; then + echo "[ERROR] nvidia-smi failed 10+ times, aborting" + exit 1 + fi + sleep 1; continue + fi + noDataCount=0 + + # Burst: temp rose TH_BURST_ROISE in BURST_WINDOW + if [ $prevTemp -gt 0 ] && [ $((prevTime != 0 ? now - prevTime : 99999)) -le $BURST_WINDOW ]; then + rise=$((TEMP - prevTemp)) + if [ $rise -ge $TH_BURST_ROISE ]; then + burstTimer=$BURST_DURATION + echo "[BURST-rise] rise=$rise°C in $((now-prevTime))s → mode 6" + fi + fi + + # Если в burst-режиме — держим режим 6 + if [ $burstTimer -gt 0 ]; then + burstTimer=$((burstTimer - 1)) + if [ $lastMode -ne 6 ]; then + echo "[MODE 6] full speed (burst, ${burstTimer}s remain)" + lastMode=6 + echo "6" >&3 + fi + prevTemp=$TEMP; prevTime=$now + sleep 1; continue + fi + + # Если burst истёк И temp >= TH_BURST — новый burst + if [ $TEMP -ge $TH_BURST ]; then + burstTimer=$BURST_DURATION + echo "[MODE 6] full speed (re-burst: $TEMP°C)" + lastMode=6; prevTemp=$TEMP; prevTime=$now + echo "6" >&3 + sleep 1; continue + fi + + # Обычная логика с хистерезисом + newMode=5 # default: HIGH (35-39) + + if [ $TEMP -lt $((TH_COLD - HYST)) ]; then + newMode=2 # STANDBY (< 23) + elif [ $TEMP -lt $((TH_LOW - HYST)) ]; then + newMode=3 # LOW (< 28) + elif [ $TEMP -lt $((TH_MED - HYST)) ]; then + newMode=4 # MED (< 33) + fi + + # Отправляем только если режим изменился + if [ $newMode -ne $lastMode ]; then + echo "[MODE $newMode] temp=$TEMP°C (hysteresis: no-change zone)" + lastMode=$newMode + echo "$newMode" >&3 + fi + + prevTemp=$TEMP; prevTime=$now + sleep 1 +done diff --git a/systemd/gpu-fan-ctrl.service b/systemd/gpu-fan-ctrl.service new file mode 100644 index 0000000..aed3f26 --- /dev/null +++ b/systemd/gpu-fan-ctrl.service @@ -0,0 +1,17 @@ +[Unit] +Description=GPU Fan Controller (Delta QFR1212GHE, Tesla V100 32GB) +After=network-online.target nvidia.service +Wants=network-online.target + +[Service] +Type=simple +ExecStart=/root/gpu_fan_ctrl.sh +Restart=on-failure +RestartSec=5 +StandardOutput=journal +StandardError=journal +User=root +Group=root + +[Install] +WantedBy=multi-user.target