Compare commits

..

No commits in common. "feature/gpu-fan-controller" and "main" have entirely different histories.

9 changed files with 0 additions and 2436 deletions

View File

@ -1,343 +0,0 @@
#include "wled.h"
#include "gpu_fan_html.h"
#ifndef PWM_FAN_PIN
#define PWM_FAN_PIN 13
#endif
#define GPU_FAN_PWM_FREQ 25000
#define GPU_FAN_PWM_RESOLUTION 8
class GPUFanControllerUsermod : public Usermod {
private:
bool enabled = true;
bool initDone = false;
bool webHandlerRegistered = false;
int8_t pwmPin = PWM_FAN_PIN;
#ifdef ARDUINO_ARCH_ESP32
uint8_t pwmChannel = 255;
#endif
enum ControlMode {
MODE_FIXED = 0,
MODE_CURVE = 1
};
ControlMode controlMode = MODE_CURVE;
uint8_t fixedSpeedPct = 50;
static const uint8_t MAX_CURVE_POINTS = 5;
uint8_t curveCount = 4;
int16_t curveTemp1 = 30;
int16_t curveTemp2 = 50;
int16_t curveTemp3 = 70;
int16_t curveTemp4 = 85;
int16_t curveTemp5 = 95;
uint8_t curveSpeed1 = 30;
uint8_t curveSpeed2 = 50;
uint8_t curveSpeed3 = 75;
uint8_t curveSpeed4 = 100;
uint8_t curveSpeed5 = 100;
float currentTemp = 25.0f;
uint8_t currentPWM = 0;
unsigned long lastTempUpdate = 0;
unsigned long tempTimeoutMs = 10000;
unsigned long lastLoopTime = 0;
unsigned long loopIntervalMs = 2000;
static const char _name[];
static const char _enabled[];
void initPWM() {
if (pwmPin < 0 || !PinManager::allocatePin(pwmPin, true, PinOwner::UM_Unspecified)) {
enabled = false;
pwmPin = -1;
return;
}
#ifdef ESP8266
analogWriteRange(255);
analogWriteFreq(GPU_FAN_PWM_FREQ);
#else
pwmChannel = PinManager::allocateLedc(1);
if (pwmChannel == 255) {
deinitPWM();
return;
}
ledcSetup(pwmChannel, GPU_FAN_PWM_FREQ, GPU_FAN_PWM_RESOLUTION);
ledcAttachPin(pwmPin, pwmChannel);
#endif
}
void deinitPWM() {
if (pwmPin < 0) return;
PinManager::deallocatePin(pwmPin, PinOwner::UM_Unspecified);
#ifdef ARDUINO_ARCH_ESP32
if (pwmChannel != 255) {
PinManager::deallocateLedc(pwmChannel, 1);
pwmChannel = 255;
}
#endif
pwmPin = -1;
}
void setFanPWM(uint8_t pwmValue) {
if (!enabled || pwmPin < 0) return;
currentPWM = pwmValue;
#ifdef ESP8266
analogWrite(pwmPin, pwmValue);
#else
ledcWrite(pwmChannel, pwmValue);
#endif
}
int16_t getCurveTemp(uint8_t idx) {
switch(idx) {
case 0: return curveTemp1;
case 1: return curveTemp2;
case 2: return curveTemp3;
case 3: return curveTemp4;
case 4: return curveTemp5;
default: return 50;
}
}
uint8_t getCurveSpeed(uint8_t idx) {
switch(idx) {
case 0: return curveSpeed1;
case 1: return curveSpeed2;
case 2: return curveSpeed3;
case 3: return curveSpeed4;
case 4: return curveSpeed5;
default: return 50;
}
}
uint8_t calculateCurveSpeed(float temp) {
if (curveCount < 2) return 50;
if (temp <= getCurveTemp(0)) return getCurveSpeed(0);
if (temp >= getCurveTemp(curveCount - 1)) return getCurveSpeed(curveCount - 1);
for (uint8_t i = 0; i < curveCount - 1; i++) {
int16_t t1 = getCurveTemp(i);
int16_t t2 = getCurveTemp(i + 1);
uint8_t s1 = getCurveSpeed(i);
uint8_t s2 = getCurveSpeed(i + 1);
if (temp >= t1 && temp <= t2) {
float ratio = (temp - t1) / (float)(t2 - t1);
return s1 + (uint8_t)(ratio * (s2 - s1));
}
}
return 50;
}
void updateFanSpeed() {
uint8_t targetSpeedPct = (controlMode == MODE_FIXED) ? fixedSpeedPct : calculateCurveSpeed(currentTemp);
uint8_t targetPWM = map(constrain(targetSpeedPct, 0, 100), 0, 100, 0, 255);
if (targetPWM != currentPWM) setFanPWM(targetPWM);
}
void registerWebHandler() {
if (webHandlerRegistered) return;
server.on("/gpu-fan", HTTP_GET, [](AsyncWebServerRequest *request) {
request->send_P(200, "text/html", GPU_FAN_HTML);
});
// Custom save endpoint
server.on("/gpu-fan/save", HTTP_POST,
[](AsyncWebServerRequest *request) {
request->send(200, "application/json", "{\"success\":true}");
},
NULL,
[this](AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) {
DynamicJsonDocument doc(1024);
if (deserializeJson(doc, (const char*)data, len)) return;
if (doc.containsKey("mode")) controlMode = (ControlMode)doc["mode"].as<int>();
if (doc.containsKey("fixed-speed")) fixedSpeedPct = doc["fixed-speed"].as<int>();
if (doc.containsKey("curve-points")) curveCount = constrain(doc["curve-points"].as<int>(), 2, 5);
if (doc.containsKey("curve-t1")) curveTemp1 = doc["curve-t1"].as<int>();
if (doc.containsKey("curve-t2")) curveTemp2 = doc["curve-t2"].as<int>();
if (doc.containsKey("curve-t3")) curveTemp3 = doc["curve-t3"].as<int>();
if (doc.containsKey("curve-t4")) curveTemp4 = doc["curve-t4"].as<int>();
if (doc.containsKey("curve-t5")) curveTemp5 = doc["curve-t5"].as<int>();
if (doc.containsKey("curve-s1")) curveSpeed1 = constrain(doc["curve-s1"].as<int>(), 0, 100);
if (doc.containsKey("curve-s2")) curveSpeed2 = constrain(doc["curve-s2"].as<int>(), 0, 100);
if (doc.containsKey("curve-s3")) curveSpeed3 = constrain(doc["curve-s3"].as<int>(), 0, 100);
if (doc.containsKey("curve-s4")) curveSpeed4 = constrain(doc["curve-s4"].as<int>(), 0, 100);
if (doc.containsKey("curve-s5")) curveSpeed5 = constrain(doc["curve-s5"].as<int>(), 0, 100);
// Trigger WLED config save using the global flag
doSerializeConfig = true;
updateFanSpeed();
}
);
webHandlerRegistered = true;
}
public:
void setup() override {
initPWM();
setFanPWM((fixedSpeedPct * 255) / 100);
lastTempUpdate = millis();
initDone = true;
registerWebHandler();
}
void connected() override {
registerWebHandler();
}
void loop() override {
if (!enabled || strip.isUpdating()) return;
unsigned long now = millis();
if (controlMode == MODE_CURVE && (now - lastTempUpdate > tempTimeoutMs)) {
if (currentPWM != 255) setFanPWM(255);
return;
}
if (now - lastLoopTime > loopIntervalMs) {
updateFanSpeed();
lastLoopTime = now;
}
}
bool handleButton(uint8_t b) override { return false; }
void addToJsonInfo(JsonObject& root) override {
JsonObject user = root["u"];
if (user.isNull()) user = root.createNestedObject("u");
JsonArray infoArr = user.createNestedArray(FPSTR(_name));
String uiDomString = F("<button class=\"btn btn-xs\" onclick=\"requestJson({'");
uiDomString += FPSTR(_name);
uiDomString += F("':{'enabled':");
uiDomString += enabled ? "false" : "true";
uiDomString += F("}});\"><i class=\"icons ");
uiDomString += enabled ? "on" : "off";
uiDomString += F("\">&#xe08f;</i></button> <a href=\"/gpu-fan\" target=\"_blank\" style=\"color:#e94560\">[Editor]</a>");
infoArr.add(uiDomString);
if (enabled) {
JsonArray tempArr = user.createNestedArray(F("GPU Temp"));
tempArr.add(currentTemp);
tempArr.add(F("°C"));
JsonArray speedArr = user.createNestedArray(F("Fan Speed"));
speedArr.add((currentPWM * 100) / 255);
speedArr.add(F("%"));
JsonArray modeArr = user.createNestedArray(F("Fan Mode"));
modeArr.add(controlMode == MODE_FIXED ? F("Fixed") : F("Curve"));
}
}
void readFromJsonState(JsonObject& root) override {
if (!initDone) return;
JsonObject usermod = root[FPSTR(_name)];
if (usermod.isNull()) return;
if (usermod["enabled"].is<bool>()) {
enabled = usermod["enabled"].as<bool>();
if (!enabled) setFanPWM(0);
}
if (usermod["speed"].is<int>()) {
fixedSpeedPct = usermod["speed"].as<int>();
controlMode = MODE_FIXED;
updateFanSpeed();
}
if (usermod["mode"].is<int>()) {
controlMode = (ControlMode)usermod["mode"].as<int>();
}
if (!usermod["temperature"].isNull()) {
currentTemp = usermod["temperature"].as<float>();
lastTempUpdate = millis();
if (controlMode == MODE_CURVE) updateFanSpeed();
}
}
void addToConfig(JsonObject& root) override {
JsonObject top = root.createNestedObject(FPSTR(_name));
top[FPSTR(_enabled)] = enabled;
top["pwm-pin"] = pwmPin;
top["mode"] = (int)controlMode;
top["fixed-speed"] = fixedSpeedPct;
top["timeout-ms"] = (int)tempTimeoutMs;
top["curve-points"] = curveCount;
top["curve-t1"] = curveTemp1;
top["curve-t2"] = curveTemp2;
top["curve-t3"] = curveTemp3;
top["curve-t4"] = curveTemp4;
top["curve-t5"] = curveTemp5;
top["curve-s1"] = curveSpeed1;
top["curve-s2"] = curveSpeed2;
top["curve-s3"] = curveSpeed3;
top["curve-s4"] = curveSpeed4;
top["curve-s5"] = curveSpeed5;
}
void appendConfigData() override {
oappend(SET_F("addInfo('GPU-Fan:pwm-pin',1,'GPIO');"));
oappend(SET_F("addInfo('GPU-Fan:mode',1,'0=Fixed, 1=Curve');"));
oappend(SET_F("addInfo('GPU-Fan:fixed-speed',1,'%');"));
}
bool readFromConfig(JsonObject& root) override {
int8_t newPwmPin = pwmPin;
JsonObject top = root[FPSTR(_name)];
if (top.isNull()) return false;
enabled = top[FPSTR(_enabled)] | enabled;
newPwmPin = top["pwm-pin"] | newPwmPin;
controlMode = (ControlMode)(top["mode"] | (int)controlMode);
fixedSpeedPct = top["fixed-speed"] | fixedSpeedPct;
tempTimeoutMs = top["timeout-ms"] | (int)tempTimeoutMs;
curveCount = constrain(top["curve-points"] | curveCount, 2, MAX_CURVE_POINTS);
curveTemp1 = top["curve-t1"] | curveTemp1;
curveTemp2 = top["curve-t2"] | curveTemp2;
curveTemp3 = top["curve-t3"] | curveTemp3;
curveTemp4 = top["curve-t4"] | curveTemp4;
curveTemp5 = top["curve-t5"] | curveTemp5;
curveSpeed1 = constrain(top["curve-s1"] | curveSpeed1, 0, 100);
curveSpeed2 = constrain(top["curve-s2"] | curveSpeed2, 0, 100);
curveSpeed3 = constrain(top["curve-s3"] | curveSpeed3, 0, 100);
curveSpeed4 = constrain(top["curve-s4"] | curveSpeed4, 0, 100);
curveSpeed5 = constrain(top["curve-s5"] | curveSpeed5, 0, 100);
if (!initDone) {
pwmPin = newPwmPin;
} else if (pwmPin != newPwmPin) {
deinitPWM();
pwmPin = newPwmPin;
setup();
}
return true;
}
uint16_t getId() override { return USERMOD_ID_UNSPECIFIED; }
};
const char GPUFanControllerUsermod::_name[] PROGMEM = "GPU-Fan";
const char GPUFanControllerUsermod::_enabled[] PROGMEM = "enabled";
static GPUFanControllerUsermod gpuFanController;
REGISTER_USERMOD(gpuFanController);

View File

@ -1,345 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>GPU Fan Controller</title>
<style>
:root{--bg:#1a1a2e;--card:#16213e;--acc:#0f3460;--hl:#e94560;--txt:#eee;--dim:#888;--ok:#4ade80;--warn:#fbbf24;--err:#ef4444}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,sans-serif;background:var(--bg);color:var(--txt);padding:20px}
.c{max-width:800px;margin:0 auto}
h1{text-align:center;margin-bottom:20px;color:var(--hl)}
.card{background:var(--card);border-radius:12px;padding:20px;margin-bottom:20px}
.card h2{font-size:1em;margin-bottom:15px;color:var(--dim);text-transform:uppercase}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:15px}
.stat{text-align:center;padding:15px;background:var(--acc);border-radius:8px}
.val{font-size:2em;font-weight:bold}
.lbl{font-size:.8em;color:var(--dim);margin-top:5px}
.t-val{color:var(--warn)}.s-val{color:var(--ok)}.m-val{color:var(--hl)}
.graph{position:relative;width:100%;padding-top:60%;background:var(--acc);border-radius:8px;overflow:hidden}
svg{position:absolute;top:0;left:0;width:100%;height:100%}
.gline{stroke:#fff3;stroke-width:1}
.albl{fill:var(--dim);font-size:10px}
.curve{fill:none;stroke:var(--hl);stroke-width:3}
.area{fill:url(#grad);opacity:.3}
.point{fill:var(--hl);stroke:#fff;stroke-width:2;cursor:grab}
.point:hover{r:10;fill:#fff}
.tline{stroke:var(--warn);stroke-width:2;stroke-dasharray:5,5}
.tdot{fill:var(--warn);stroke:#fff;stroke-width:2}
.btns{display:flex;flex-wrap:wrap;gap:10px;margin-top:15px}
.btn{padding:10px 20px;border:none;border-radius:6px;cursor:pointer;font-weight:500}
.btn-p{background:var(--hl);color:#fff}
.btn-s{background:var(--acc);color:var(--txt)}
.btn-g{background:var(--ok);color:#000}
.mode{display:flex;background:var(--acc);border-radius:8px;padding:4px;margin-bottom:15px}
.mode button{flex:1;padding:10px;border:none;background:0;color:var(--dim);cursor:pointer;border-radius:6px}
.mode button.on{background:var(--hl);color:#fff}
.slider{padding:15px 0}
.slider.hide{display:none}
.slider input{width:100%;height:8px;-webkit-appearance:none;background:var(--acc);border-radius:4px}
.slider input::-webkit-slider-thumb{-webkit-appearance:none;width:24px;height:24px;background:var(--hl);border-radius:50%;cursor:pointer}
.slider .v{text-align:center;font-size:1.5em;margin-top:10px}
table{width:100%;margin-top:15px;border-collapse:collapse}
th,td{padding:8px;text-align:center;border-bottom:1px solid var(--acc)}
table input{width:60px;padding:5px;border:1px solid var(--acc);border-radius:4px;background:var(--bg);color:var(--txt);text-align:center}
.toast{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);padding:12px 24px;background:var(--ok);color:#000;border-radius:8px;opacity:0;transition:opacity .3s}
.toast.show{opacity:1}
.toast.err{background:var(--err);color:#fff}
.back{display:inline-block;margin-bottom:15px;color:var(--dim);text-decoration:none}
</style>
</head>
<body>
<div class="c">
<a href="/" class="back">← Back to WLED</a>
<h1>🌀 GPU Fan Controller</h1>
<div class="card">
<h2>Live Status</h2>
<div class="grid">
<div class="stat"><div class="val t-val" id="temp">--</div><div class="lbl">GPU Temp (°C)</div></div>
<div class="stat"><div class="val s-val" id="speed">--</div><div class="lbl">Fan Speed (%)</div></div>
<div class="stat"><div class="val m-val" id="mode">--</div><div class="lbl">Mode</div></div>
<div class="stat"><div class="val" id="status" style="color:var(--ok)"></div><div class="lbl" id="statTxt">Connected</div></div>
</div>
</div>
<div class="card">
<h2>Control Mode</h2>
<div class="mode">
<button id="btnF" onclick="setMode(0)">Fixed Speed</button>
<button id="btnC" class="on" onclick="setMode(1)">Temperature Curve</button>
</div>
<div class="slider hide" id="fixedCtrl">
<input type="range" id="fixedSlider" min="0" max="100" value="50" oninput="setFixed(this.value)">
<div class="v"><span id="fixedVal">50</span>%</div>
</div>
<div id="curveCtrl">
<div class="graph">
<svg id="svg" viewBox="0 0 400 240">
<defs><linearGradient id="grad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#e94560;stop-opacity:.5"/>
<stop offset="100%" style="stop-color:#e94560;stop-opacity:0"/>
</linearGradient></defs>
<g id="grid"></g>
<g id="labels"></g>
<line id="tempLine" class="tline" x1="0" y1="0" x2="0" y2="240"/>
<path id="area" class="area"/>
<path id="curve" class="curve"/>
<circle id="tempDot" class="tdot" r="6" cx="0" cy="0"/>
<g id="points"></g>
</svg>
</div>
<table>
<thead><tr><th>Point</th><th>Temp (°C)</th><th>Speed (%)</th></tr></thead>
<tbody id="table"></tbody>
</table>
<div class="btns">
<button class="btn btn-s" onclick="addPt()">+ Add</button>
<button class="btn btn-s" onclick="remPt()">- Remove</button>
<button class="btn btn-s" onclick="reset()">Reset</button>
</div>
</div>
</div>
<div class="card">
<div class="btns" style="justify-content:center">
<button class="btn btn-g" onclick="save()" style="padding:15px 40px;font-size:1.1em">💾 Save</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
const PAD={l:45,r:20,t:20,b:30},W=400,H=240;
const GW=W-PAD.l-PAD.r,GH=H-PAD.t-PAD.b;
const TMIN=20,TMAX=100,SMIN=0,SMAX=100;
let pts=[{t:30,s:30},{t:50,s:50},{t:70,s:75},{t:85,s:100}];
let curMode=1,fixSpd=50,curTemp=25,drag=null;
document.addEventListener('DOMContentLoaded',()=>{
drawGrid();loadCfg();draw();
setInterval(poll,2000);
});
function tX(t){return PAD.l+((t-TMIN)/(TMAX-TMIN))*GW}
function sY(s){return PAD.t+(1-(s-SMIN)/(SMAX-SMIN))*GH}
function xT(x){return TMIN+((x-PAD.l)/GW)*(TMAX-TMIN)}
function yS(y){return SMAX-((y-PAD.t)/GH)*(SMAX-SMIN)}
function drawGrid(){
let g=document.getElementById('grid'),l=document.getElementById('labels');
g.innerHTML='';l.innerHTML='';
for(let t=20;t<=100;t+=20){
let x=tX(t);
g.innerHTML+=`<line class="gline" x1="${x}" y1="${PAD.t}" x2="${x}" y2="${H-PAD.b}"/>`;
l.innerHTML+=`<text class="albl" x="${x}" y="${H-10}" text-anchor="middle">${t}°</text>`;
}
for(let s=0;s<=100;s+=25){
let y=sY(s);
g.innerHTML+=`<line class="gline" x1="${PAD.l}" y1="${y}" x2="${W-PAD.r}" y2="${y}"/>`;
l.innerHTML+=`<text class="albl" x="${PAD.l-8}" y="${y+4}" text-anchor="end">${s}%</text>`;
}
}
function draw(){
pts.sort((a,b)=>a.t-b.t);
let path='',area='';
pts.forEach((p,i)=>{
let x=tX(p.t),y=sY(p.s);
path+=(i?'L':'M')+x+','+y+' ';
});
if(pts.length){
area=path+`L${tX(pts[pts.length-1].t)},${sY(0)} L${tX(pts[0].t)},${sY(0)} Z`;
}
document.getElementById('curve').setAttribute('d',path);
document.getElementById('area').setAttribute('d',area);
let pg=document.getElementById('points');pg.innerHTML='';
pts.forEach((p,i)=>{
let c=document.createElementNS('http://www.w3.org/2000/svg','circle');
c.setAttribute('class','point');
c.setAttribute('cx',tX(p.t));
c.setAttribute('cy',sY(p.s));
c.setAttribute('r',8);
c.setAttribute('data-i',i);
c.onmousedown=startDrag;
c.ontouchstart=startDrag;
pg.appendChild(c);
});
drawTable();
drawTemp();
}
function drawTable(){
let tb=document.getElementById('table');tb.innerHTML='';
pts.forEach((p,i)=>{
tb.innerHTML+=`<tr><td>${i+1}</td>
<td><input type="number" value="${p.t}" min="0" max="100" onchange="setPt(${i},'t',this.value)"></td>
<td><input type="number" value="${p.s}" min="0" max="100" onchange="setPt(${i},'s',this.value)"></td></tr>`;
});
}
function setPt(i,k,v){pts[i][k]=parseInt(v);draw()}
function drawTemp(){
let x=tX(curTemp);
document.getElementById('tempLine').setAttribute('x1',x);
document.getElementById('tempLine').setAttribute('x2',x);
let s=calcSpeed(curTemp);
document.getElementById('tempDot').setAttribute('cx',x);
document.getElementById('tempDot').setAttribute('cy',sY(s));
}
function calcSpeed(t){
if(pts.length<2)return 50;
if(t<=pts[0].t)return pts[0].s;
if(t>=pts[pts.length-1].t)return pts[pts.length-1].s;
for(let i=0;i<pts.length-1;i++){
if(t>=pts[i].t&&t<=pts[i+1].t){
let r=(t-pts[i].t)/(pts[i+1].t-pts[i].t);
return pts[i].s+r*(pts[i+1].s-pts[i].s);
}
}
return 50;
}
function startDrag(e){
e.preventDefault();
drag=parseInt(e.target.getAttribute('data-i'));
document.onmousemove=doDrag;
document.onmouseup=endDrag;
document.ontouchmove=doDrag;
document.ontouchend=endDrag;
}
function doDrag(e){
if(drag===null)return;
e.preventDefault();
let svg=document.getElementById('svg');
let rect=svg.getBoundingClientRect();
let cx=e.touches?e.touches[0].clientX:e.clientX;
let cy=e.touches?e.touches[0].clientY:e.clientY;
let x=(cx-rect.left)*(W/rect.width);
let y=(cy-rect.top)*(H/rect.height);
let t=Math.round(xT(x)),s=Math.round(yS(y));
pts[drag].t=Math.max(TMIN,Math.min(TMAX,t));
pts[drag].s=Math.max(SMIN,Math.min(SMAX,s));
draw();
}
function endDrag(){
drag=null;
document.onmousemove=null;
document.onmouseup=null;
document.ontouchmove=null;
document.ontouchend=null;
}
function addPt(){
if(pts.length>=5){toast('Max 5 points',1);return}
let mt=(pts[0].t+pts[pts.length-1].t)/2;
pts.push({t:Math.round(mt),s:Math.round(calcSpeed(mt))});
draw();
}
function remPt(){
if(pts.length<=2){toast('Min 2 points',1);return}
pts.pop();draw();
}
function reset(){
pts=[{t:30,s:30},{t:50,s:50},{t:70,s:75},{t:85,s:100}];
draw();
}
function setMode(m){
curMode=m;
document.getElementById('btnF').classList.toggle('on',m===0);
document.getElementById('btnC').classList.toggle('on',m===1);
document.getElementById('fixedCtrl').classList.toggle('hide',m!==0);
document.getElementById('curveCtrl').style.display=m===0?'none':'block';
}
function setFixed(v){
fixSpd=parseInt(v);
document.getElementById('fixedVal').textContent=fixSpd;
fetch('/json/state',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({'GPU-Fan':{speed:fixSpd}})});
}
async function loadCfg(){
try{
let r=await fetch('/cfg.json');
let cfg=await r.json();
let f=cfg.um?.['GPU-Fan'];
if(f){
curMode=f.mode||1;
fixSpd=f['fixed-speed']||50;
pts=[];
let n=f['curve-points']||4;
for(let i=1;i<=n&&i<=5;i++){
let t=f['curve-t'+i],s=f['curve-s'+i];
if(t!==undefined&&s!==undefined)pts.push({t,s});
}
if(pts.length<2)reset();
setMode(curMode);
document.getElementById('fixedSlider').value=fixSpd;
document.getElementById('fixedVal').textContent=fixSpd;
draw();
}
}catch(e){console.error(e)}
}
async function poll(){
try{
let r=await fetch('/json/info');
let info=await r.json();
let u=info.u;
if(u){
if(u['GPU Temp']){curTemp=parseFloat(u['GPU Temp'][0])||25;document.getElementById('temp').textContent=curTemp.toFixed(1)}
if(u['Fan Speed'])document.getElementById('speed').textContent=parseInt(u['Fan Speed'][0])||0;
if(u['Fan Mode'])document.getElementById('mode').textContent=u['Fan Mode'][0];
}
drawTemp();
document.getElementById('status').style.color='var(--ok)';
document.getElementById('statTxt').textContent='Connected';
}catch(e){
document.getElementById('status').style.color='var(--err)';
document.getElementById('statTxt').textContent='Disconnected';
}
}
async function save(){
pts.sort((a,b)=>a.t-b.t);
let cfg={'GPU-Fan':{enabled:true,mode:curMode,'fixed-speed':fixSpd,'curve-points':pts.length}};
for(let i=0;i<5;i++){
cfg['GPU-Fan']['curve-t'+(i+1)]=i<pts.length?pts[i].t:50;
cfg['GPU-Fan']['curve-s'+(i+1)]=i<pts.length?pts[i].s:50;
}
try{
let r=await fetch('/cfg.json',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({um:cfg})});
if(r.ok){
toast('Saved!');
fetch('/json/state',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({'GPU-Fan':{mode:curMode}})});
}else toast('Failed',1);
}catch(e){toast('Error: '+e.message,1)}
}
function toast(msg,err){
let t=document.getElementById('toast');
t.textContent=msg;
t.classList.toggle('err',!!err);
t.classList.add('show');
setTimeout(()=>t.classList.remove('show'),3000);
}
</script>
</body>
</html>

View File

@ -1,340 +0,0 @@
#pragma once
const char GPU_FAN_HTML[] PROGMEM = R"=====(
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>GPU Fan</title>
<style>
:root{--bg:#1a1a2e;--card:#16213e;--acc:#0f3460;--hl:#e94560;--txt:#eee;--dim:#888;--ok:#4ade80;--warn:#fbbf24;--err:#ef4444}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,sans-serif;background:var(--bg);color:var(--txt);padding:15px}
.c{max-width:700px;margin:0 auto}
h1{text-align:center;margin-bottom:15px;color:var(--hl);font-size:1.5em}
.card{background:var(--card);border-radius:10px;padding:15px;margin-bottom:15px}
.card h2{font-size:.9em;margin-bottom:12px;color:var(--dim);text-transform:uppercase}
.grid{display:grid;grid-template-columns:repeat(4,1fr);gap:10px}
.stat{text-align:center;padding:10px;background:var(--acc);border-radius:6px}
.val{font-size:1.5em;font-weight:bold}
.lbl{font-size:.7em;color:var(--dim);margin-top:3px}
.t-val{color:var(--warn)}.s-val{color:var(--ok)}.m-val{color:var(--hl)}
.graph{position:relative;width:100%;height:200px;background:var(--acc);border-radius:6px;overflow:hidden}
svg{width:100%;height:100%}
.gline{stroke:#fff3;stroke-width:1}
.albl{fill:var(--dim);font-size:9px}
.curve{fill:none;stroke:var(--hl);stroke-width:2.5}
.area{fill:url(#grad);opacity:.3}
.point{fill:var(--hl);stroke:#fff;stroke-width:2;cursor:grab}
.point:hover{r:9;fill:#fff}
.tline{stroke:var(--warn);stroke-width:2;stroke-dasharray:4,4}
.tdot{fill:var(--warn);stroke:#fff;stroke-width:2}
.btns{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px}
.btn{padding:8px 16px;border:none;border-radius:5px;cursor:pointer;font-size:.85em}
.btn-s{background:var(--acc);color:var(--txt)}
.btn-g{background:var(--ok);color:#000}
.mode{display:flex;background:var(--acc);border-radius:6px;padding:3px;margin-bottom:12px}
.mode button{flex:1;padding:8px;border:none;background:0;color:var(--dim);cursor:pointer;border-radius:4px;font-size:.85em}
.mode button.on{background:var(--hl);color:#fff}
.slider{padding:12px 0}
.slider.hide{display:none}
.slider input{width:100%;height:6px;-webkit-appearance:none;background:var(--acc);border-radius:3px}
.slider input::-webkit-slider-thumb{-webkit-appearance:none;width:20px;height:20px;background:var(--hl);border-radius:50%;cursor:pointer}
.slider .v{text-align:center;font-size:1.3em;margin-top:8px}
table{width:100%;margin-top:12px;border-collapse:collapse;font-size:.85em}
th,td{padding:6px;text-align:center;border-bottom:1px solid var(--acc)}
table input{width:50px;padding:4px;border:1px solid var(--acc);border-radius:3px;background:var(--bg);color:var(--txt);text-align:center}
.toast{position:fixed;bottom:15px;left:50%;transform:translateX(-50%);padding:10px 20px;background:var(--ok);color:#000;border-radius:6px;opacity:0;transition:opacity .3s;font-size:.9em}
.toast.show{opacity:1}
.toast.err{background:var(--err);color:#fff}
.back{display:inline-block;margin-bottom:10px;color:var(--dim);text-decoration:none;font-size:.9em}
</style>
</head>
<body>
<div class="c">
<a href="/" class="back">&#8592; WLED</a>
<h1>&#127744; GPU Fan Controller</h1>
<div class="card">
<h2>Status</h2>
<div class="grid">
<div class="stat"><div class="val t-val" id="temp">--</div><div class="lbl">Temp C</div></div>
<div class="stat"><div class="val s-val" id="speed">--</div><div class="lbl">Fan %</div></div>
<div class="stat"><div class="val m-val" id="mode">--</div><div class="lbl">Mode</div></div>
<div class="stat"><div class="val" id="status" style="color:var(--ok)">&#9679;</div><div class="lbl" id="stxt">OK</div></div>
</div>
</div>
<div class="card">
<h2>Mode</h2>
<div class="mode">
<button id="btnF" onclick="setMode(0)">Fixed</button>
<button id="btnC" class="on" onclick="setMode(1)">Curve</button>
</div>
<div class="slider hide" id="fCtrl">
<input type="range" id="fSlider" min="0" max="100" value="50" oninput="setFixed(this.value)">
<div class="v"><span id="fVal">50</span>%</div>
</div>
<div id="cCtrl">
<div class="graph">
<svg id="svg" viewBox="0 0 400 200">
<defs><linearGradient id="grad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#e94560;stop-opacity:.5"/>
<stop offset="100%" style="stop-color:#e94560;stop-opacity:0"/>
</linearGradient></defs>
<g id="grid"></g>
<g id="labels"></g>
<line id="tLine" class="tline" x1="0" y1="0" x2="0" y2="200"/>
<path id="area" class="area"/>
<path id="curve" class="curve"/>
<circle id="tDot" class="tdot" r="5" cx="0" cy="0"/>
<g id="points"></g>
</svg>
</div>
<table>
<thead><tr><th>#</th><th>Temp</th><th>Speed</th></tr></thead>
<tbody id="tbl"></tbody>
</table>
<div class="btns">
<button class="btn btn-s" onclick="addPt()">+ Add</button>
<button class="btn btn-s" onclick="remPt()">- Remove</button>
<button class="btn btn-s" onclick="reset()">Reset</button>
<button class="btn btn-g" onclick="save()">Save</button>
</div>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
const P={l:40,r:15,t:15,b:25},W=400,H=200;
const GW=W-P.l-P.r,GH=H-P.t-P.b;
const TN=20,TX=100,SN=0,SX=100;
let pts=[{t:30,s:30},{t:50,s:50},{t:70,s:75},{t:85,s:100}];
let cMode=1,fSpd=50,cTemp=25,drag=null;
document.addEventListener('DOMContentLoaded',()=>{
drawGrid();loadCfg();draw();
setInterval(poll,2000);
});
function tX(t){return P.l+((t-TN)/(TX-TN))*GW}
function sY(s){return P.t+(1-(s-SN)/(SX-SN))*GH}
function xT(x){return TN+((x-P.l)/GW)*(TX-TN)}
function yS(y){return SX-((y-P.t)/GH)*(SX-SN)}
function drawGrid(){
let g=document.getElementById('grid'),l=document.getElementById('labels');
for(let t=20;t<=100;t+=20){
let x=tX(t);
g.innerHTML+=`<line class="gline" x1="${x}" y1="${P.t}" x2="${x}" y2="${H-P.b}"/>`;
l.innerHTML+=`<text class="albl" x="${x}" y="${H-8}" text-anchor="middle">${t}</text>`;
}
for(let s=0;s<=100;s+=25){
let y=sY(s);
g.innerHTML+=`<line class="gline" x1="${P.l}" y1="${y}" x2="${W-P.r}" y2="${y}"/>`;
l.innerHTML+=`<text class="albl" x="${P.l-5}" y="${y+3}" text-anchor="end">${s}</text>`;
}
}
function draw(){
pts.sort((a,b)=>a.t-b.t);
let path='',area='';
pts.forEach((p,i)=>{
let x=tX(p.t),y=sY(p.s);
path+=(i?'L':'M')+x+','+y+' ';
});
if(pts.length){
area=path+`L${tX(pts[pts.length-1].t)},${sY(0)} L${tX(pts[0].t)},${sY(0)} Z`;
}
document.getElementById('curve').setAttribute('d',path);
document.getElementById('area').setAttribute('d',area);
let pg=document.getElementById('points');pg.innerHTML='';
pts.forEach((p,i)=>{
let c=document.createElementNS('http://www.w3.org/2000/svg','circle');
c.setAttribute('class','point');
c.setAttribute('cx',tX(p.t));
c.setAttribute('cy',sY(p.s));
c.setAttribute('r',7);
c.setAttribute('data-i',i);
c.onmousedown=c.ontouchstart=startDrag;
pg.appendChild(c);
});
drawTbl();drawTemp();
}
function drawTbl(){
let tb=document.getElementById('tbl');tb.innerHTML='';
pts.forEach((p,i)=>{
tb.innerHTML+=`<tr><td>${i+1}</td>
<td><input type="number" value="${p.t}" min="0" max="100" onchange="setPt(${i},'t',this.value)"></td>
<td><input type="number" value="${p.s}" min="0" max="100" onchange="setPt(${i},'s',this.value)"></td></tr>`;
});
}
function setPt(i,k,v){pts[i][k]=parseInt(v);draw()}
function drawTemp(){
let x=tX(cTemp);
document.getElementById('tLine').setAttribute('x1',x);
document.getElementById('tLine').setAttribute('x2',x);
let s=calcSpd(cTemp);
document.getElementById('tDot').setAttribute('cx',x);
document.getElementById('tDot').setAttribute('cy',sY(s));
}
function calcSpd(t){
if(pts.length<2)return 50;
if(t<=pts[0].t)return pts[0].s;
if(t>=pts[pts.length-1].t)return pts[pts.length-1].s;
for(let i=0;i<pts.length-1;i++){
if(t>=pts[i].t&&t<=pts[i+1].t){
return pts[i].s+(t-pts[i].t)/(pts[i+1].t-pts[i].t)*(pts[i+1].s-pts[i].s);
}
}
return 50;
}
function startDrag(e){
e.preventDefault();
drag=parseInt(e.target.getAttribute('data-i'));
document.onmousemove=document.ontouchmove=doDrag;
document.onmouseup=document.ontouchend=endDrag;
}
function doDrag(e){
if(drag===null)return;
e.preventDefault();
let svg=document.getElementById('svg');
let rect=svg.getBoundingClientRect();
let cx=e.touches?e.touches[0].clientX:e.clientX;
let cy=e.touches?e.touches[0].clientY:e.clientY;
let x=(cx-rect.left)*(W/rect.width);
let y=(cy-rect.top)*(H/rect.height);
pts[drag].t=Math.max(TN,Math.min(TX,Math.round(xT(x))));
pts[drag].s=Math.max(SN,Math.min(SX,Math.round(yS(y))));
draw();
}
function endDrag(){
drag=null;
document.onmousemove=document.ontouchmove=null;
document.onmouseup=document.ontouchend=null;
}
function addPt(){
if(pts.length>=5){toast('Max 5',1);return}
let mt=(pts[0].t+pts[pts.length-1].t)/2;
pts.push({t:Math.round(mt),s:Math.round(calcSpd(mt))});
draw();
}
function remPt(){
if(pts.length<=2){toast('Min 2',1);return}
pts.pop();draw();
}
function reset(){
pts=[{t:30,s:30},{t:50,s:50},{t:70,s:75},{t:85,s:100}];
draw();
}
function setMode(m){
cMode=m;
document.getElementById('btnF').classList.toggle('on',m===0);
document.getElementById('btnC').classList.toggle('on',m===1);
document.getElementById('fCtrl').classList.toggle('hide',m!==0);
document.getElementById('cCtrl').style.display=m===0?'none':'block';
}
function setFixed(v){
fSpd=parseInt(v);
document.getElementById('fVal').textContent=fSpd;
fetch('/json/state',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({'GPU-Fan':{speed:fSpd}})});
}
async function loadCfg(){
try{
let r=await fetch('/cfg.json');
let cfg=await r.json();
let f=cfg.um?.['GPU-Fan'];
if(f){
cMode=f.mode||1;
fSpd=f['fixed-speed']||50;
pts=[];
let n=f['curve-points']||4;
for(let i=1;i<=n&&i<=5;i++){
let t=f['curve-t'+i],s=f['curve-s'+i];
if(t!==undefined&&s!==undefined)pts.push({t,s});
}
if(pts.length<2)reset();
setMode(cMode);
document.getElementById('fSlider').value=fSpd;
document.getElementById('fVal').textContent=fSpd;
draw();
}
}catch(e){console.error(e)}
}
async function poll(){
try{
let r=await fetch('/json/info');
let info=await r.json();
let u=info.u;
if(u){
if(u['GPU Temp']){cTemp=parseFloat(u['GPU Temp'][0])||25;document.getElementById('temp').textContent=cTemp.toFixed(1)}
if(u['Fan Speed'])document.getElementById('speed').textContent=parseInt(u['Fan Speed'][0])||0;
if(u['Fan Mode'])document.getElementById('mode').textContent=u['Fan Mode'][0];
}
drawTemp();
document.getElementById('status').style.color='var(--ok)';
document.getElementById('stxt').textContent='OK';
}catch(e){
document.getElementById('status').style.color='var(--err)';
document.getElementById('stxt').textContent='Err';
}
}
async function save(){
pts.sort((a,b)=>a.t-b.t);
let cfg={
'mode':cMode,
'fixed-speed':fSpd,
'curve-points':pts.length,
'curve-t1':pts[0]?.t||30,'curve-s1':pts[0]?.s||30,
'curve-t2':pts[1]?.t||50,'curve-s2':pts[1]?.s||50,
'curve-t3':pts[2]?.t||70,'curve-s3':pts[2]?.s||75,
'curve-t4':pts[3]?.t||85,'curve-s4':pts[3]?.s||100,
'curve-t5':pts[4]?.t||95,'curve-s5':pts[4]?.s||100
};
try{
let r=await fetch('/gpu-fan/save',{
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify(cfg)
});
if(r.ok){toast('Saved!');}
else{toast('Failed: '+r.status,1);}
}catch(e){toast('Error!',1);console.error(e);}
}
function toast(m,e){
let t=document.getElementById('toast');
t.textContent=m;
t.classList.toggle('err',!!e);
t.classList.add('show');
setTimeout(()=>t.classList.remove('show'),2000);
}
</script>
</body>
</html>
)=====";

View File

@ -1,773 +0,0 @@
/*
* GPU Fan Controller - Custom Web UI
*
* This file adds a custom settings page with:
* - Interactive curve editor (drag points on a graph)
* - Live GPU temperature display
* - Real-time fan speed indicator
*/
// Add this to the usermod class - replaces the simple appendConfigData
void addSettingsPage() {
// This injects a custom page into WLED's web interface
}
// Custom HTML page served at /gpu-fan
void serveGpuFanPage(AsyncWebServerRequest *request) {
String html = F(R"rawliteral(
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GPU Fan Controller</title>
<style>
:root {
--bg-color: #1a1a2e;
--card-bg: #16213e;
--accent: #0f3460;
--highlight: #e94560;
--text: #eee;
--text-dim: #888;
--success: #4ade80;
--warning: #fbbf24;
--danger: #ef4444;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg-color);
color: var(--text);
min-height: 100vh;
padding: 20px;
}
.container { max-width: 800px; margin: 0 auto; }
h1 { text-align: center; margin-bottom: 20px; color: var(--highlight); }
.card {
background: var(--card-bg);
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 4px 6px rgba(0,0,0,0.3);
}
.card h2 {
font-size: 1.1em;
margin-bottom: 15px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 1px;
}
.status-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 15px;
}
.status-item {
text-align: center;
padding: 15px;
background: var(--accent);
border-radius: 8px;
}
.status-value {
font-size: 2em;
font-weight: bold;
line-height: 1.2;
}
.status-label {
font-size: 0.85em;
color: var(--text-dim);
margin-top: 5px;
}
.temp-value { color: var(--warning); }
.speed-value { color: var(--success); }
.mode-value { color: var(--highlight); }
/* Curve Editor */
.curve-container {
position: relative;
width: 100%;
padding-top: 60%;
background: var(--accent);
border-radius: 8px;
overflow: hidden;
}
.curve-svg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.grid-line { stroke: #ffffff20; stroke-width: 1; }
.axis-label { fill: var(--text-dim); font-size: 10px; }
.curve-line {
fill: none;
stroke: var(--highlight);
stroke-width: 3;
stroke-linecap: round;
stroke-linejoin: round;
}
.curve-area {
fill: url(#curveGradient);
opacity: 0.3;
}
.curve-point {
fill: var(--highlight);
stroke: white;
stroke-width: 2;
cursor: grab;
transition: r 0.15s;
}
.curve-point:hover, .curve-point.dragging {
r: 10;
fill: white;
}
.current-temp-line {
stroke: var(--warning);
stroke-width: 2;
stroke-dasharray: 5,5;
}
.current-temp-dot {
fill: var(--warning);
stroke: white;
stroke-width: 2;
}
/* Controls */
.controls {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 15px;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.9em;
font-weight: 500;
transition: all 0.2s;
}
.btn-primary {
background: var(--highlight);
color: white;
}
.btn-primary:hover { background: #d63d56; }
.btn-secondary {
background: var(--accent);
color: var(--text);
}
.btn-secondary:hover { background: #1a4a7a; }
.btn-success {
background: var(--success);
color: #000;
}
.btn-success:hover { background: #22c55e; }
/* Mode Toggle */
.mode-toggle {
display: flex;
background: var(--accent);
border-radius: 8px;
padding: 4px;
margin-bottom: 15px;
}
.mode-btn {
flex: 1;
padding: 10px;
border: none;
background: transparent;
color: var(--text-dim);
cursor: pointer;
border-radius: 6px;
transition: all 0.2s;
}
.mode-btn.active {
background: var(--highlight);
color: white;
}
/* Fixed Speed Slider */
.slider-container {
padding: 15px 0;
}
.slider-container.hidden { display: none; }
.slider {
width: 100%;
height: 8px;
-webkit-appearance: none;
background: var(--accent);
border-radius: 4px;
outline: none;
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 24px;
height: 24px;
background: var(--highlight);
border-radius: 50%;
cursor: pointer;
}
.slider-value {
text-align: center;
font-size: 1.5em;
margin-top: 10px;
}
/* Point Editor Table */
.point-table {
width: 100%;
margin-top: 15px;
border-collapse: collapse;
}
.point-table th, .point-table td {
padding: 8px;
text-align: center;
border-bottom: 1px solid var(--accent);
}
.point-table input {
width: 60px;
padding: 5px;
border: 1px solid var(--accent);
border-radius: 4px;
background: var(--bg-color);
color: var(--text);
text-align: center;
}
.point-table input:focus {
outline: none;
border-color: var(--highlight);
}
/* Toast notification */
.toast {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
padding: 12px 24px;
background: var(--success);
color: #000;
border-radius: 8px;
opacity: 0;
transition: opacity 0.3s;
z-index: 1000;
}
.toast.show { opacity: 1; }
.toast.error { background: var(--danger); color: white; }
/* Back link */
.back-link {
display: inline-block;
margin-bottom: 15px;
color: var(--text-dim);
text-decoration: none;
}
.back-link:hover { color: var(--text); }
</style>
</head>
<body>
<div class="container">
<a href="/" class="back-link"> Back to WLED</a>
<h1>🌀 GPU Fan Controller</h1>
<!-- Live Status -->
<div class="card">
<h2>Live Status</h2>
<div class="status-grid">
<div class="status-item">
<div class="status-value temp-value" id="currentTemp">--</div>
<div class="status-label">GPU Temperature (°C)</div>
</div>
<div class="status-item">
<div class="status-value speed-value" id="currentSpeed">--</div>
<div class="status-label">Fan Speed (%)</div>
</div>
<div class="status-item">
<div class="status-value mode-value" id="currentMode">--</div>
<div class="status-label">Mode</div>
</div>
<div class="status-item">
<div class="status-value" id="statusIndicator" style="color: var(--success)"></div>
<div class="status-label" id="statusText">Connected</div>
</div>
</div>
</div>
<!-- Mode Selection -->
<div class="card">
<h2>Fan Control Mode</h2>
<div class="mode-toggle">
<button class="mode-btn" id="modeFixed" onclick="setMode(0)">Fixed Speed</button>
<button class="mode-btn active" id="modeCurve" onclick="setMode(1)">Temperature Curve</button>
</div>
<!-- Fixed Speed Slider -->
<div class="slider-container hidden" id="fixedSpeedControl">
<input type="range" class="slider" id="fixedSpeedSlider" min="0" max="100" value="50" oninput="updateFixedSpeed(this.value)">
<div class="slider-value"><span id="fixedSpeedValue">50</span>%</div>
</div>
<!-- Curve Editor -->
<div id="curveControl">
<div class="curve-container">
<svg class="curve-svg" id="curveSvg" viewBox="0 0 400 240">
<defs>
<linearGradient id="curveGradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#e94560;stop-opacity:0.5" />
<stop offset="100%" style="stop-color:#e94560;stop-opacity:0" />
</linearGradient>
</defs>
<!-- Grid -->
<g id="grid"></g>
<!-- Axis labels -->
<g id="axisLabels"></g>
<!-- Current temp indicator -->
<line id="tempLine" class="current-temp-line" x1="0" y1="0" x2="0" y2="240" />
<!-- Curve area fill -->
<path id="curveArea" class="curve-area" />
<!-- Curve line -->
<path id="curveLine" class="curve-line" />
<!-- Current operating point -->
<circle id="tempDot" class="current-temp-dot" r="6" cx="0" cy="0" />
<!-- Draggable points -->
<g id="curvePoints"></g>
</svg>
</div>
<!-- Point Editor -->
<table class="point-table">
<thead>
<tr>
<th>Point</th>
<th>Temperature (°C)</th>
<th>Fan Speed (%)</th>
</tr>
</thead>
<tbody id="pointTableBody"></tbody>
</table>
<div class="controls">
<button class="btn btn-secondary" onclick="addPoint()">+ Add Point</button>
<button class="btn btn-secondary" onclick="removePoint()">- Remove Point</button>
<button class="btn btn-secondary" onclick="resetCurve()">Reset Default</button>
</div>
</div>
</div>
<!-- Save Button -->
<div class="card">
<div class="controls" style="justify-content: center;">
<button class="btn btn-success" onclick="saveConfig()" style="padding: 15px 40px; font-size: 1.1em;">
💾 Save Configuration
</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
// Configuration
const PADDING = { left: 45, right: 20, top: 20, bottom: 30 };
const SVG_WIDTH = 400;
const SVG_HEIGHT = 240;
const GRAPH_WIDTH = SVG_WIDTH - PADDING.left - PADDING.right;
const GRAPH_HEIGHT = SVG_HEIGHT - PADDING.top - PADDING.bottom;
const TEMP_MIN = 20;
const TEMP_MAX = 100;
const SPEED_MIN = 0;
const SPEED_MAX = 100;
// State
let curvePoints = [
{ temp: 30, speed: 30 },
{ temp: 50, speed: 50 },
{ temp: 70, speed: 75 },
{ temp: 85, speed: 100 }
];
let currentMode = 1;
let fixedSpeed = 50;
let currentTemp = 25;
let currentFanSpeed = 0;
let draggingPoint = null;
// Initialize
document.addEventListener('DOMContentLoaded', () => {
drawGrid();
loadConfig();
updateDisplay();
setInterval(fetchStatus, 2000);
});
// Coordinate conversion
function tempToX(temp) {
return PADDING.left + ((temp - TEMP_MIN) / (TEMP_MAX - TEMP_MIN)) * GRAPH_WIDTH;
}
function speedToY(speed) {
return PADDING.top + (1 - (speed - SPEED_MIN) / (SPEED_MAX - SPEED_MIN)) * GRAPH_HEIGHT;
}
function xToTemp(x) {
return TEMP_MIN + ((x - PADDING.left) / GRAPH_WIDTH) * (TEMP_MAX - TEMP_MIN);
}
function yToSpeed(y) {
return SPEED_MAX - ((y - PADDING.top) / GRAPH_HEIGHT) * (SPEED_MAX - SPEED_MIN);
}
// Draw grid
function drawGrid() {
const grid = document.getElementById('grid');
const labels = document.getElementById('axisLabels');
grid.innerHTML = '';
labels.innerHTML = '';
// Vertical lines (temperature)
for (let t = 20; t <= 100; t += 20) {
const x = tempToX(t);
grid.innerHTML += `<line class="grid-line" x1="${x}" y1="${PADDING.top}" x2="${x}" y2="${SVG_HEIGHT - PADDING.bottom}" />`;
labels.innerHTML += `<text class="axis-label" x="${x}" y="${SVG_HEIGHT - 10}" text-anchor="middle">${t}°</text>`;
}
// Horizontal lines (speed)
for (let s = 0; s <= 100; s += 25) {
const y = speedToY(s);
grid.innerHTML += `<line class="grid-line" x1="${PADDING.left}" y1="${y}" x2="${SVG_WIDTH - PADDING.right}" y2="${y}" />`;
labels.innerHTML += `<text class="axis-label" x="${PADDING.left - 8}" y="${y + 4}" text-anchor="end">${s}%</text>`;
}
}
// Update curve display
function updateDisplay() {
// Sort points by temperature
curvePoints.sort((a, b) => a.temp - b.temp);
// Build path
let linePath = '';
let areaPath = '';
curvePoints.forEach((p, i) => {
const x = tempToX(p.temp);
const y = speedToY(p.speed);
linePath += (i === 0 ? 'M' : 'L') + `${x},${y} `;
});
// Area fill
if (curvePoints.length > 0) {
areaPath = linePath;
areaPath += `L${tempToX(curvePoints[curvePoints.length-1].temp)},${speedToY(0)} `;
areaPath += `L${tempToX(curvePoints[0].temp)},${speedToY(0)} Z`;
}
document.getElementById('curveLine').setAttribute('d', linePath);
document.getElementById('curveArea').setAttribute('d', areaPath);
// Draw points
const pointsGroup = document.getElementById('curvePoints');
pointsGroup.innerHTML = '';
curvePoints.forEach((p, i) => {
const x = tempToX(p.temp);
const y = speedToY(p.speed);
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
circle.setAttribute('class', 'curve-point');
circle.setAttribute('cx', x);
circle.setAttribute('cy', y);
circle.setAttribute('r', 8);
circle.setAttribute('data-index', i);
circle.addEventListener('mousedown', startDrag);
circle.addEventListener('touchstart', startDrag, { passive: false });
pointsGroup.appendChild(circle);
});
// Update table
updateTable();
// Update current temp indicator
updateTempIndicator();
}
// Update point table
function updateTable() {
const tbody = document.getElementById('pointTableBody');
tbody.innerHTML = '';
curvePoints.forEach((p, i) => {
tbody.innerHTML += `
<tr>
<td>${i + 1}</td>
<td><input type="number" value="${p.temp}" min="0" max="100" onchange="updatePointFromTable(${i}, 'temp', this.value)"></td>
<td><input type="number" value="${p.speed}" min="0" max="100" onchange="updatePointFromTable(${i}, 'speed', this.value)"></td>
</tr>
`;
});
}
// Update point from table input
function updatePointFromTable(index, field, value) {
curvePoints[index][field] = parseInt(value);
updateDisplay();
}
// Current temperature indicator
function updateTempIndicator() {
const x = tempToX(currentTemp);
document.getElementById('tempLine').setAttribute('x1', x);
document.getElementById('tempLine').setAttribute('x2', x);
// Calculate current speed from curve
const speed = calculateSpeedFromCurve(currentTemp);
const y = speedToY(speed);
document.getElementById('tempDot').setAttribute('cx', x);
document.getElementById('tempDot').setAttribute('cy', y);
}
// Calculate speed from curve
function calculateSpeedFromCurve(temp) {
if (curvePoints.length < 2) return 50;
if (temp <= curvePoints[0].temp) return curvePoints[0].speed;
if (temp >= curvePoints[curvePoints.length - 1].temp) return curvePoints[curvePoints.length - 1].speed;
for (let i = 0; i < curvePoints.length - 1; i++) {
if (temp >= curvePoints[i].temp && temp <= curvePoints[i + 1].temp) {
const t1 = curvePoints[i].temp, t2 = curvePoints[i + 1].temp;
const s1 = curvePoints[i].speed, s2 = curvePoints[i + 1].speed;
return s1 + (temp - t1) * (s2 - s1) / (t2 - t1);
}
}
return 50;
}
// Drag handling
function startDrag(e) {
e.preventDefault();
draggingPoint = parseInt(e.target.getAttribute('data-index'));
e.target.classList.add('dragging');
document.addEventListener('mousemove', doDrag);
document.addEventListener('mouseup', endDrag);
document.addEventListener('touchmove', doDrag, { passive: false });
document.addEventListener('touchend', endDrag);
}
function doDrag(e) {
if (draggingPoint === null) return;
e.preventDefault();
const svg = document.getElementById('curveSvg');
const rect = svg.getBoundingClientRect();
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
const x = (clientX - rect.left) * (SVG_WIDTH / rect.width);
const y = (clientY - rect.top) * (SVG_HEIGHT / rect.height);
let temp = Math.round(xToTemp(x));
let speed = Math.round(yToSpeed(y));
temp = Math.max(TEMP_MIN, Math.min(TEMP_MAX, temp));
speed = Math.max(SPEED_MIN, Math.min(SPEED_MAX, speed));
curvePoints[draggingPoint].temp = temp;
curvePoints[draggingPoint].speed = speed;
updateDisplay();
}
function endDrag(e) {
if (draggingPoint !== null) {
const circle = document.querySelector(`.curve-point[data-index="${draggingPoint}"]`);
if (circle) circle.classList.remove('dragging');
}
draggingPoint = null;
document.removeEventListener('mousemove', doDrag);
document.removeEventListener('mouseup', endDrag);
document.removeEventListener('touchmove', doDrag);
document.removeEventListener('touchend', endDrag);
}
// Add/remove points
function addPoint() {
if (curvePoints.length >= 5) {
showToast('Maximum 5 points allowed', true);
return;
}
// Add point in the middle of the curve
const midTemp = (curvePoints[0].temp + curvePoints[curvePoints.length - 1].temp) / 2;
const midSpeed = calculateSpeedFromCurve(midTemp);
curvePoints.push({ temp: Math.round(midTemp), speed: Math.round(midSpeed) });
updateDisplay();
}
function removePoint() {
if (curvePoints.length <= 2) {
showToast('Minimum 2 points required', true);
return;
}
curvePoints.pop();
updateDisplay();
}
function resetCurve() {
curvePoints = [
{ temp: 30, speed: 30 },
{ temp: 50, speed: 50 },
{ temp: 70, speed: 75 },
{ temp: 85, speed: 100 }
];
updateDisplay();
}
// Mode control
function setMode(mode) {
currentMode = mode;
document.getElementById('modeFixed').classList.toggle('active', mode === 0);
document.getElementById('modeCurve').classList.toggle('active', mode === 1);
document.getElementById('fixedSpeedControl').classList.toggle('hidden', mode !== 0);
document.getElementById('curveControl').style.display = mode === 0 ? 'none' : 'block';
}
function updateFixedSpeed(value) {
fixedSpeed = parseInt(value);
document.getElementById('fixedSpeedValue').textContent = fixedSpeed;
// Send immediately
fetch('/json/state', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ 'GPU-Fan': { speed: fixedSpeed } })
});
}
// Load config from device
async function loadConfig() {
try {
const response = await fetch('/cfg.json');
const cfg = await response.json();
const fanCfg = cfg.um?.['GPU-Fan'];
if (fanCfg) {
currentMode = fanCfg.mode || 1;
fixedSpeed = fanCfg['fixed-speed'] || 50;
// Load curve points
curvePoints = [];
const count = fanCfg['curve-points'] || 4;
for (let i = 1; i <= count && i <= 5; i++) {
const temp = fanCfg[`curve-t${i}`];
const speed = fanCfg[`curve-s${i}`];
if (temp !== undefined && speed !== undefined) {
curvePoints.push({ temp, speed });
}
}
if (curvePoints.length < 2) {
resetCurve();
}
setMode(currentMode);
document.getElementById('fixedSpeedSlider').value = fixedSpeed;
document.getElementById('fixedSpeedValue').textContent = fixedSpeed;
updateDisplay();
}
} catch (e) {
console.error('Failed to load config:', e);
}
}
// Fetch live status
async function fetchStatus() {
try {
const response = await fetch('/json/info');
const info = await response.json();
const userInfo = info.u;
if (userInfo) {
// Parse GPU temp
if (userInfo['GPU Temp']) {
currentTemp = parseFloat(userInfo['GPU Temp'][0]) || 25;
document.getElementById('currentTemp').textContent = currentTemp.toFixed(1);
}
// Parse fan speed
if (userInfo['Fan Speed']) {
currentFanSpeed = parseInt(userInfo['Fan Speed'][0]) || 0;
document.getElementById('currentSpeed').textContent = currentFanSpeed;
}
// Parse mode
if (userInfo['Fan Mode']) {
document.getElementById('currentMode').textContent = userInfo['Fan Mode'][0];
}
}
updateTempIndicator();
document.getElementById('statusIndicator').style.color = 'var(--success)';
document.getElementById('statusText').textContent = 'Connected';
} catch (e) {
document.getElementById('statusIndicator').style.color = 'var(--danger)';
document.getElementById('statusText').textContent = 'Disconnected';
}
}
// Save configuration
async function saveConfig() {
// Sort points
curvePoints.sort((a, b) => a.temp - b.temp);
// Build config object
const cfg = {
'GPU-Fan': {
enabled: true,
mode: currentMode,
'fixed-speed': fixedSpeed,
'curve-points': curvePoints.length
}
};
// Add curve points
for (let i = 0; i < 5; i++) {
if (i < curvePoints.length) {
cfg['GPU-Fan'][`curve-t${i + 1}`] = curvePoints[i].temp;
cfg['GPU-Fan'][`curve-s${i + 1}`] = curvePoints[i].speed;
} else {
cfg['GPU-Fan'][`curve-t${i + 1}`] = 50;
cfg['GPU-Fan'][`curve-s${i + 1}`] = 50;
}
}
try {
// Save to WLED config
const response = await fetch('/cfg.json', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ um: cfg })
});
if (response.ok) {
showToast('Configuration saved!');
// Also set mode immediately
await fetch('/json/state', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ 'GPU-Fan': { mode: currentMode } })
});
} else {
showToast('Failed to save', true);
}
} catch (e) {
showToast('Error: ' + e.message, true);
}
}
// Toast notification
function showToast(message, isError = false) {
const toast = document.getElementById('toast');
toast.textContent = message;
toast.classList.toggle('error', isError);
toast.classList.add('show');
setTimeout(() => toast.classList.remove('show'), 3000);
}
</script>
</body>
</html>
)rawliteral");
request->send(200, "text/html", html);
}

View File

@ -1,310 +0,0 @@
#!/usr/bin/env python3
"""
GPU Temperature Monitor for WLED GPU Fan Controller Usermod
This script monitors GPU temperature and sends it to the WLED
GPU Fan Controller usermod via the JSON API.
Supports:
- NVIDIA GPUs (via nvidia-ml-py / pynvml)
- AMD/Intel/NVIDIA GPUs (via gpustat)
Usage:
python gpu_temp_monitor.py --wled-ip 192.168.1.100
python gpu_temp_monitor.py --wled-ip 192.168.1.100 --gpu-type nvidia --interval 2
python gpu_temp_monitor.py --test
"""
import argparse
import sys
import time
import signal
import json
from typing import Optional, Callable
# Try to import requests
try:
import requests
except ImportError:
print("Error: 'requests' module not found. Install with: pip install requests")
sys.exit(1)
class GPUMonitor:
"""Base class for GPU monitoring"""
def get_temperature(self, gpu_index: int = 0) -> Optional[float]:
raise NotImplementedError
def get_name(self) -> str:
raise NotImplementedError
def cleanup(self):
pass
class NVMLMonitor(GPUMonitor):
"""NVIDIA GPU monitor using NVML (nvidia-ml-py)"""
def __init__(self):
try:
import pynvml
pynvml.nvmlInit()
self.pynvml = pynvml
self._initialized = True
except ImportError:
raise ImportError("nvidia-ml-py not installed. Install with: pip install nvidia-ml-py")
except Exception as e:
raise RuntimeError(f"Failed to initialize NVML: {e}")
def get_temperature(self, gpu_index: int = 0) -> Optional[float]:
try:
handle = self.pynvml.nvmlDeviceGetHandleByIndex(gpu_index)
temp = self.pynvml.nvmlDeviceGetTemperature(handle, self.pynvml.NVML_TEMPERATURE_GPU)
return float(temp)
except Exception as e:
print(f"Error reading NVIDIA GPU temperature: {e}")
return None
def get_name(self) -> str:
return "NVIDIA (NVML)"
def cleanup(self):
if hasattr(self, '_initialized') and self._initialized:
try:
self.pynvml.nvmlShutdown()
except:
pass
class GPUStatMonitor(GPUMonitor):
"""Multi-vendor GPU monitor using gpustat"""
def __init__(self):
try:
import gpustat
self.gpustat = gpustat
except ImportError:
raise ImportError("gpustat not installed. Install with: pip install gpustat")
def get_temperature(self, gpu_index: int = 0) -> Optional[float]:
try:
stats = self.gpustat.GPUStatCollection.new_query()
if gpu_index < len(stats.gpus):
return float(stats.gpus[gpu_index].temperature)
else:
print(f"GPU index {gpu_index} not found. Available GPUs: {len(stats.gpus)}")
return None
except Exception as e:
print(f"Error reading GPU temperature via gpustat: {e}")
return None
def get_name(self) -> str:
return "gpustat (multi-vendor)"
def create_monitor(gpu_type: str = "auto") -> GPUMonitor:
"""Create appropriate GPU monitor based on type or auto-detection"""
if gpu_type == "nvidia":
return NVMLMonitor()
elif gpu_type == "gpustat":
return GPUStatMonitor()
elif gpu_type == "auto":
# Try NVML first, then gpustat
try:
monitor = NVMLMonitor()
# Test if it works
if monitor.get_temperature(0) is not None:
return monitor
except:
pass
try:
monitor = GPUStatMonitor()
if monitor.get_temperature(0) is not None:
return monitor
except:
pass
raise RuntimeError("No GPU monitoring method available. Install nvidia-ml-py or gpustat.")
else:
raise ValueError(f"Unknown GPU type: {gpu_type}")
def send_temperature_to_wled(wled_ip: str, temperature: float, timeout: float = 5.0) -> bool:
"""Send temperature to WLED via JSON API"""
url = f"http://{wled_ip}/json/state"
payload = {
"GPU-Fan": {
"temperature": temperature
}
}
try:
response = requests.post(url, json=payload, timeout=timeout)
return response.status_code == 200
except requests.exceptions.RequestException as e:
print(f"Error sending temperature to WLED: {e}")
return False
def get_wled_fan_status(wled_ip: str, timeout: float = 5.0) -> Optional[dict]:
"""Get current fan status from WLED"""
url = f"http://{wled_ip}/json/info"
try:
response = requests.get(url, timeout=timeout)
if response.status_code == 200:
data = response.json()
return data.get("u", {})
return None
except requests.exceptions.RequestException as e:
print(f"Error getting WLED status: {e}")
return None
def test_gpu_monitoring():
"""Test GPU temperature monitoring"""
print("Testing GPU temperature monitoring...\n")
# Test NVML
print("Testing NVIDIA (NVML)...")
try:
monitor = NVMLMonitor()
temp = monitor.get_temperature(0)
if temp is not None:
print(f" ✓ NVML working - Temperature: {temp}°C")
else:
print(" ✗ NVML initialized but couldn't read temperature")
monitor.cleanup()
except ImportError as e:
print(f" ✗ Not available: {e}")
except Exception as e:
print(f" ✗ Error: {e}")
print()
# Test gpustat
print("Testing gpustat...")
try:
monitor = GPUStatMonitor()
temp = monitor.get_temperature(0)
if temp is not None:
print(f" ✓ gpustat working - Temperature: {temp}°C")
else:
print(" ✗ gpustat initialized but couldn't read temperature")
except ImportError as e:
print(f" ✗ Not available: {e}")
except Exception as e:
print(f" ✗ Error: {e}")
print()
# Test auto-detection
print("Testing auto-detection...")
try:
monitor = create_monitor("auto")
temp = monitor.get_temperature(0)
print(f" ✓ Auto-detection successful using {monitor.get_name()}")
print(f" Current temperature: {temp}°C")
monitor.cleanup()
except Exception as e:
print(f" ✗ Auto-detection failed: {e}")
def main():
parser = argparse.ArgumentParser(
description="GPU Temperature Monitor for WLED GPU Fan Controller",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s --wled-ip 192.168.1.100
%(prog)s --wled-ip 192.168.1.100 --gpu-type nvidia --interval 2
%(prog)s --test
"""
)
parser.add_argument("--wled-ip", help="IP address of WLED device")
parser.add_argument("--gpu-type", choices=["auto", "nvidia", "gpustat"],
default="auto", help="GPU monitoring method (default: auto)")
parser.add_argument("--gpu-index", type=int, default=0,
help="GPU index for multi-GPU systems (default: 0)")
parser.add_argument("--interval", type=float, default=2.0,
help="Update interval in seconds (default: 2.0)")
parser.add_argument("--test", action="store_true",
help="Test GPU monitoring capabilities")
parser.add_argument("--quiet", action="store_true",
help="Suppress normal output (only show errors)")
args = parser.parse_args()
if args.test:
test_gpu_monitoring()
return
if not args.wled_ip:
parser.error("--wled-ip is required (unless using --test)")
# Setup signal handlers for graceful shutdown
running = True
def signal_handler(sig, frame):
nonlocal running
print("\nShutting down...")
running = False
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
# Create monitor
try:
monitor = create_monitor(args.gpu_type)
if not args.quiet:
print(f"GPU Monitor started using {monitor.get_name()}")
print(f"Sending to WLED at {args.wled_ip}")
print(f"Update interval: {args.interval}s")
print("-" * 40)
except Exception as e:
print(f"Error: {e}")
sys.exit(1)
# Main loop
error_count = 0
max_errors = 5
try:
while running:
temp = monitor.get_temperature(args.gpu_index)
if temp is not None:
success = send_temperature_to_wled(args.wled_ip, temp)
if success:
error_count = 0
if not args.quiet:
print(f"Temperature: {temp:.1f}°C - Sent OK")
else:
error_count += 1
print(f"Failed to send temperature (error {error_count}/{max_errors})")
else:
error_count += 1
print(f"Failed to read GPU temperature (error {error_count}/{max_errors})")
if error_count >= max_errors:
print(f"Too many consecutive errors ({max_errors}). Exiting.")
break
time.sleep(args.interval)
finally:
monitor.cleanup()
if not args.quiet:
print("GPU Monitor stopped")
if __name__ == "__main__":
main()

View File

@ -1,6 +0,0 @@
{
"name": "GPU_Fan_Controller",
"build": {
"libArchive": false
}
}

View File

@ -1,27 +0,0 @@
; PlatformIO override file for GPU Fan Controller Usermod
;
; Copy this file to the WLED root directory and rename to platformio_override.ini
; Then build with: pio run -e esp32dev_gpu_fan -t upload
[env:esp32dev_gpu_fan]
extends = env:esp32dev
board = esp32dev
platform = ${esp32.platform}
platform_packages = ${esp32.platform_packages}
build_unflags = ${common.build_unflags}
build_flags = ${common.build_flags} ${esp32.build_flags}
-D USERMOD_GPU_FAN_CONTROLLER
-D PWM_FAN_PIN=13
; Add these lib_deps if they're not already included
lib_deps = ${esp32.lib_deps}
custom_usermods = GPU_Fan_Controller
; Alternative: ESP32-S3 build
[env:esp32s3dev_gpu_fan]
extends = env:esp32s3dev_8MB
board = esp32-s3-devkitc-1
build_flags = ${common.build_flags} ${esp32s3.build_flags}
-D USERMOD_GPU_FAN_CONTROLLER
-D PWM_FAN_PIN=13
lib_deps = ${esp32s3.lib_deps}
custom_usermods = GPU_Fan_Controller

View File

@ -1,282 +0,0 @@
# GPU Fan Controller Usermod
This usermod allows WLED to control PWM PC fans based on GPU temperature data received from an external source (typically a Python script running on your PC).
## Features
- **Web API Integration**: Receives GPU temperature via HTTP API
- **Fixed Speed Mode**: Set a constant fan speed (0-100%)
- **Temperature Curve Mode**: Automatically adjusts fan speed based on temperature
- **Visual Curve Editor**: Interactive web-based graph editor at `/gpu-fan.htm`
- **Up to 5 Curve Points**: Define custom temperature-to-speed mappings
- **Safety Fallback**: Automatically runs at 100% if temperature data is lost
- **WLED UI Integration**: Control and monitor from the WLED web interface
- **Live Temperature Display**: See current GPU temp and fan speed in real-time
## Hardware Requirements
- ESP32 development board (ESP32 Dev Module recommended)
- 4-pin PWM PC fan (12V)
- 12V power supply for the fan
- Common ground connection between ESP32 and power supply
## Wiring
| Fan Pin | Connection |
|---------|------------|
| GND (Black) | ESP32 GND + 12V PSU GND |
| +12V (Yellow) | 12V Power Supply + |
| PWM (Blue) | ESP32 GPIO 13 (configurable) |
| TACH (Green) | Not used |
**Important:**
- Connect 12V power supply GND to ESP32 GND (common ground)
- DO NOT connect +12V to any ESP32 pins
## Installation
### Option 1: Using platformio_override.ini (Recommended)
1. Copy the `GPU_Fan_Controller` folder to `wled00/usermods/`
2. Create or edit `platformio_override.ini` in the WLED root directory:
```ini
[env:esp32dev_gpu_fan]
extends = env:esp32dev
build_flags = ${env:esp32dev.build_flags}
-D USERMOD_GPU_FAN_CONTROLLER
-D PWM_FAN_PIN=13
custom_usermods = GPU_Fan_Controller
```
3. Build and upload:
```bash
pio run -e esp32dev_gpu_fan -t upload
```
4. Upload the custom page (optional but recommended):
```bash
pio run -e esp32dev_gpu_fan -t uploadfs
```
### Option 2: Manual Integration
1. Copy `GPU_Fan_Controller.cpp` to `wled00/usermods/GPU_Fan_Controller/`
2. Add to your build flags:
```
-D USERMOD_GPU_FAN_CONTROLLER
```
## Visual Curve Editor
Access the visual curve editor at: `http://YOUR_WLED_IP/gpu-fan.htm`
![Curve Editor Screenshot](curve-editor.png)
Features:
- **Drag points** on the graph to adjust temperature/speed mappings
- **Live temperature indicator** shows current GPU temp on the graph
- **Real-time updates** - see fan speed change as you adjust
- **Table view** for precise value entry
- **Add/remove points** - use 2-5 curve points
- **Mode toggle** - switch between Fixed and Curve modes
## Configuration
### Via WLED Web Interface
Navigate to Config → Usermods to configure:
- **enabled**: Enable/disable the fan controller
- **pwm-pin**: GPIO pin for PWM output (default: 13)
- **mode**: 0 = Fixed, 1 = Curve
- **fixed-speed**: Speed percentage when in fixed mode
- **timeout-ms**: Temperature timeout in milliseconds (default: 10000)
- **curve-t1 to curve-t5**: Temperature points (°C)
- **curve-s1 to curve-s5**: Speed points (%)
- **curve-points**: Number of curve points to use (2-5)
### Via JSON API
```json
{
"GPU-Fan": {
"enabled": true,
"mode": 1,
"fixed-speed": 50,
"curve-points": 4,
"curve-t1": 30, "curve-s1": 30,
"curve-t2": 50, "curve-s2": 50,
"curve-t3": 70, "curve-s3": 75,
"curve-t4": 85, "curve-s4": 100
}
}
```
## API Endpoints
### Update Temperature
Send GPU temperature to the controller:
```http
POST /json/state
Content-Type: application/json
{
"GPU-Fan": {
"temperature": 65.5
}
}
```
### Set Manual Speed
```http
POST /json/state
Content-Type: application/json
{
"GPU-Fan": {
"speed": 75
}
}
```
### Set Mode
```http
POST /json/state
Content-Type: application/json
{
"GPU-Fan": {
"mode": 1
}
}
```
Where mode: 0 = Fixed, 1 = Curve
### Get Status
```http
GET /json/info
```
Response includes GPU temperature, fan speed, and mode in the "u" (user) object.
## Python GPU Monitor Script
Use the included `gpu_temp_monitor.py` script to send GPU temperatures to the ESP32:
### Installation
```bash
pip install requests nvidia-ml-py
# OR for multi-vendor support:
pip install requests gpustat
```
### Usage
```bash
# Basic usage
python gpu_temp_monitor.py --wled-ip 192.168.1.100
# Specify GPU type
python gpu_temp_monitor.py --wled-ip 192.168.1.100 --gpu-type nvidia
# Custom update interval
python gpu_temp_monitor.py --wled-ip 192.168.1.100 --interval 3
```
### Running as a Service
#### Windows (Task Scheduler)
Create a batch file and add to startup:
```batch
@echo off
python C:\path\to\gpu_temp_monitor.py --wled-ip 192.168.1.100
```
#### Linux (systemd)
Create `/etc/systemd/system/gpu-fan-monitor.service`:
```ini
[Unit]
Description=GPU Fan Controller Monitor
After=network.target
[Service]
Type=simple
User=yourusername
ExecStart=/usr/bin/python3 /path/to/gpu_temp_monitor.py --wled-ip 192.168.1.100
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
```
Enable and start:
```bash
sudo systemctl enable gpu-fan-monitor
sudo systemctl start gpu-fan-monitor
```
## Temperature Curve
The default curve provides quiet operation at low temperatures and aggressive cooling at high temperatures:
| Temperature | Fan Speed |
|-------------|-----------|
| ≤30°C | 30% |
| 50°C | 50% |
| 70°C | 75% |
| ≥85°C | 100% |
Speeds between points are linearly interpolated.
## Troubleshooting
### Fan not spinning
1. Check wiring connections
2. Verify the fan is PWM-compatible (4-pin)
3. Check that 12V power supply is adequate
4. Try a different GPIO pin
### Temperature not updating
1. Check the Python script is running
2. Verify ESP32 IP address is correct
3. Check firewall settings
4. Test with: `curl -X POST http://ESP32_IP/json/state -H "Content-Type: application/json" -d '{"GPU-Fan":{"temperature":50}}'`
### Curve not saving
1. Make sure to click "Save" in the curve editor
2. Check the browser console for errors
3. Try refreshing the page and re-saving
### WLED doesn't show the usermod
1. Ensure the build flag `-D USERMOD_GPU_FAN_CONTROLLER` is set
2. Rebuild and re-upload the firmware
3. Check serial output for initialization messages
## Technical Details
- **PWM Frequency**: 25kHz (Intel 4-pin fan specification)
- **PWM Resolution**: 8-bit (0-255)
- **Default GPIO**: 13 (configurable)
- **Update Interval**: 2 seconds
- **Safety Timeout**: 10 seconds (runs at 100% if no temperature data)
- **Max Curve Points**: 5
## License
This usermod is released under the same license as WLED (MIT).
## Credits
- WLED project: https://github.com/wled/WLED
- PWM fan control techniques adapted from various ESP32 fan controller projects

View File

@ -1,10 +0,0 @@
# GPU Temperature Monitor for WLED GPU Fan Controller
#
# Required:
requests>=2.25.0
# For NVIDIA GPUs (recommended for NVIDIA users):
nvidia-ml-py>=11.0.0
# Alternative for any GPU (NVIDIA, AMD, Intel):
# gpustat>=1.0.0