Add custom web UI with visual curve editor and live temperature display
- Interactive SVG curve editor with draggable points - Live GPU temperature display with indicator on graph - Real-time fan speed monitoring - Mode toggle between Fixed and Curve - Point table for precise value entry - Touch support for mobile devices
This commit is contained in:
parent
a3cd6bac16
commit
f0873c1c6e
773
usermods/GPU_Fan_Controller/gpu_fan_page.h
Normal file
773
usermods/GPU_Fan_Controller/gpu_fan_page.h
Normal file
@ -0,0 +1,773 @@
|
||||
/*
|
||||
* 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);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user