Created
May 4, 2026 07:38
-
-
Save fxprime/53679499059d52127351f03724ee13a5 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #pragma once | |
| // html_page.h | |
| // Place this file in the SAME folder as ESP32_PCA9685_ServoWS.ino | |
| // Arduino's prototype-generator only scans .ino files, so JavaScript | |
| // "function" keywords inside this string won't cause compile errors. | |
| const char INDEX_HTML[] PROGMEM = R"HTMLEOF( | |
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width,initial-scale=1"> | |
| <title>Servo Controller</title> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Rajdhani:wght@400;600;700&display=swap" rel="stylesheet"> | |
| <style> | |
| :root { | |
| --bg: #0a0c0f; | |
| --surface: #111418; | |
| --border: #1e2530; | |
| --border2: #2a3444; | |
| --amber: #f0a500; | |
| --amber2: #ffcc44; | |
| --green: #39d353; | |
| --red: #ff4444; | |
| --blue: #4da6ff; | |
| --text: #c8d4e0; | |
| --muted: #556070; | |
| --mono: 'Share Tech Mono', monospace; | |
| --sans: 'Rajdhani', sans-serif; | |
| } | |
| *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } | |
| body { | |
| background: var(--bg); | |
| color: var(--text); | |
| font-family: var(--sans); | |
| min-height: 100vh; | |
| padding: 20px; | |
| } | |
| body::before { | |
| content: ''; | |
| position: fixed; inset: 0; | |
| background: repeating-linear-gradient(0deg,transparent,transparent 2px,rgba(0,0,0,.08) 2px,rgba(0,0,0,.08) 4px); | |
| pointer-events: none; | |
| z-index: 999; | |
| } | |
| header { | |
| display: flex; align-items: center; gap: 16px; | |
| margin-bottom: 28px; padding-bottom: 16px; | |
| border-bottom: 1px solid var(--border2); | |
| } | |
| .logo { display: flex; align-items: center; gap: 10px; } | |
| .logo-icon { | |
| width: 36px; height: 36px; | |
| border: 2px solid var(--amber); border-radius: 6px; | |
| display: flex; align-items: center; justify-content: center; | |
| font-family: var(--mono); font-size: 14px; color: var(--amber); flex-shrink: 0; | |
| } | |
| h1 { | |
| font-family: var(--sans); font-size: 22px; font-weight: 700; | |
| letter-spacing: 2px; text-transform: uppercase; color: var(--amber2); | |
| } | |
| .status-bar { | |
| margin-left: auto; display: flex; align-items: center; gap: 8px; | |
| font-family: var(--mono); font-size: 12px; color: var(--muted); | |
| } | |
| .ws-dot { | |
| width: 8px; height: 8px; border-radius: 50%; | |
| background: var(--red); transition: background .3s; | |
| } | |
| .ws-dot.connected { background: var(--green); box-shadow: 0 0 6px var(--green); } | |
| .grid { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); | |
| gap: 16px; | |
| } | |
| .card { | |
| background: var(--surface); border: 1px solid var(--border2); | |
| border-radius: 10px; overflow: hidden; transition: border-color .2s; | |
| } | |
| .card:hover { border-color: var(--amber); } | |
| .card-head { | |
| display: flex; align-items: center; gap: 12px; | |
| padding: 14px 18px; | |
| background: rgba(240,165,0,.05); | |
| border-bottom: 1px solid var(--border); | |
| } | |
| .ch-badge { | |
| width: 32px; height: 32px; background: var(--amber); color: #000; | |
| border-radius: 6px; display: flex; align-items: center; justify-content: center; | |
| font-family: var(--mono); font-size: 16px; font-weight: bold; flex-shrink: 0; | |
| } | |
| .ch-label { font-size: 16px; font-weight: 700; letter-spacing: 1px; color: var(--text); flex: 1; } | |
| .center-btn { | |
| padding: 5px 12px; background: transparent; | |
| border: 1px solid var(--border2); border-radius: 4px; | |
| color: var(--muted); font-family: var(--mono); font-size: 11px; | |
| cursor: pointer; transition: all .2s; | |
| } | |
| .center-btn:hover { border-color: var(--blue); color: var(--blue); } | |
| .card-body { padding: 18px; } | |
| .readouts { display: flex; gap: 12px; margin-bottom: 18px; } | |
| .readout { | |
| flex: 1; background: #0a0c0f; | |
| border: 1px solid var(--border); border-radius: 6px; padding: 12px 14px; | |
| } | |
| .readout-label { | |
| font-family: var(--mono); font-size: 10px; color: var(--muted); | |
| letter-spacing: 1px; margin-bottom: 4px; text-transform: uppercase; | |
| } | |
| .readout-value { | |
| font-family: var(--mono); font-size: 26px; | |
| color: var(--amber2); line-height: 1; letter-spacing: -1px; | |
| } | |
| .readout-value.deg-val { color: var(--green); font-size: 22px; } | |
| .readout-unit { font-size: 11px; color: var(--muted); margin-left: 2px; } | |
| .slider-row { display: flex; align-items: center; gap: 10px; margin-bottom: 16px; } | |
| .range-label { | |
| font-family: var(--mono); font-size: 10px; color: var(--muted); | |
| width: 36px; text-align: center; flex-shrink: 0; | |
| } | |
| input[type=range] { | |
| flex: 1; -webkit-appearance: none; appearance: none; | |
| height: 4px; background: var(--border2); border-radius: 2px; outline: none; cursor: pointer; | |
| } | |
| input[type=range]::-webkit-slider-thumb { | |
| -webkit-appearance: none; width: 18px; height: 18px; border-radius: 50%; | |
| background: var(--amber); border: 2px solid var(--bg); | |
| box-shadow: 0 0 6px rgba(240,165,0,.5); cursor: pointer; transition: transform .1s; | |
| } | |
| input[type=range]::-webkit-slider-thumb:hover { transform: scale(1.2); } | |
| .us-input-row { display: flex; align-items: center; gap: 8px; margin-bottom: 20px; } | |
| .us-input-row label { font-family: var(--mono); font-size: 11px; color: var(--muted); white-space: nowrap; } | |
| input[type=number] { | |
| background: #0a0c0f; border: 1px solid var(--border2); | |
| color: var(--amber2); font-family: var(--mono); font-size: 14px; | |
| padding: 6px 10px; border-radius: 5px; width: 90px; outline: none; transition: border-color .2s; | |
| } | |
| input[type=number]:focus { border-color: var(--amber); } | |
| .apply-btn { | |
| padding: 6px 14px; background: var(--amber); color: #000; | |
| font-family: var(--sans); font-size: 13px; font-weight: 700; | |
| border: none; border-radius: 5px; cursor: pointer; | |
| letter-spacing: 1px; transition: opacity .2s, transform .1s; | |
| } | |
| .apply-btn:hover { opacity: .85; } | |
| .apply-btn:active { transform: scale(.97); } | |
| .config-toggle { | |
| display: flex; align-items: center; gap: 8px; cursor: pointer; | |
| color: var(--muted); font-size: 13px; font-weight: 600; | |
| letter-spacing: 1px; text-transform: uppercase; user-select: none; | |
| padding-bottom: 2px; border-bottom: 1px solid var(--border); | |
| margin-bottom: 0; transition: color .2s; | |
| } | |
| .config-toggle:hover { color: var(--blue); } | |
| .config-toggle .arrow { font-size: 10px; transition: transform .25s; } | |
| .config-toggle.open .arrow { transform: rotate(90deg); } | |
| .config-panel { display: none; flex-direction: column; gap: 12px; padding-top: 14px; } | |
| .config-panel.open { display: flex; } | |
| .config-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } | |
| .config-row label { font-family: var(--mono); font-size: 11px; color: var(--muted); width: 60px; flex-shrink: 0; } | |
| select { | |
| background: #0a0c0f; border: 1px solid var(--border2); | |
| color: var(--text); font-family: var(--mono); font-size: 13px; | |
| padding: 6px 10px; border-radius: 5px; outline: none; cursor: pointer; | |
| } | |
| select:focus { border-color: var(--blue); } | |
| .save-btn { | |
| padding: 7px 18px; background: transparent; color: var(--green); | |
| border: 1px solid var(--green); font-family: var(--sans); font-size: 13px; | |
| font-weight: 700; border-radius: 5px; cursor: pointer; | |
| letter-spacing: 1px; transition: all .2s; | |
| } | |
| .save-btn:hover { background: var(--green); color: #000; } | |
| .saved-flash { font-family: var(--mono); font-size: 11px; color: var(--green); opacity: 0; transition: opacity .3s; } | |
| .saved-flash.show { opacity: 1; } | |
| .gauge-wrap { display: flex; justify-content: center; margin-bottom: 14px; } | |
| svg.gauge { overflow: visible; } | |
| footer { | |
| margin-top: 28px; padding-top: 14px; border-top: 1px solid var(--border); | |
| display: flex; justify-content: space-between; align-items: center; | |
| font-family: var(--mono); font-size: 11px; color: var(--muted); | |
| } | |
| @media (max-width: 480px) { | |
| .readout-value { font-size: 20px; } | |
| .grid { grid-template-columns: 1fr; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <header> | |
| <div class="logo"> | |
| <div class="logo-icon">SV</div> | |
| <h1>Servo Controller</h1> | |
| </div> | |
| <div class="status-bar"> | |
| <div class="ws-dot" id="wsDot"></div> | |
| <span id="wsLabel">OFFLINE</span> | |
| </div> | |
| </header> | |
| <div class="grid" id="grid"></div> | |
| <footer> | |
| <span>PCA9685 · CH 0-3 · 50 Hz</span> | |
| <span id="footerIp">ESP32</span> | |
| </footer> | |
| <script> | |
| (function() { | |
| var NUM_CH = 4; | |
| var state = []; | |
| for (var i = 0; i < NUM_CH; i++) { | |
| state.push({ ch: i, us: 1500, min: 500, max: 2500, deg: 180 }); | |
| } | |
| // ── WebSocket ──────────────────────────────────────────────────────────── | |
| var ws; | |
| var wsReconnectTimer = null; | |
| function connectWs() { | |
| ws = new WebSocket('ws://' + location.hostname + '/ws'); | |
| ws.onopen = function() { | |
| document.getElementById('wsDot').classList.add('connected'); | |
| document.getElementById('wsLabel').textContent = 'CONNECTED'; | |
| }; | |
| ws.onclose = function() { | |
| document.getElementById('wsDot').classList.remove('connected'); | |
| document.getElementById('wsLabel').textContent = 'RECONNECTING'; | |
| wsReconnectTimer = setTimeout(connectWs, 2000); | |
| }; | |
| ws.onmessage = function(e) { | |
| try { | |
| var msg = JSON.parse(e.data); | |
| if (msg.servos) { | |
| msg.servos.forEach(function(s) { | |
| state[s.ch] = s; | |
| updateCard(s.ch); | |
| }); | |
| } | |
| } catch(err) { console.error('WS parse error', err); } | |
| }; | |
| } | |
| function wsSend(obj) { | |
| if (ws && ws.readyState === 1) ws.send(JSON.stringify(obj)); | |
| } | |
| // ── Degree calc ────────────────────────────────────────────────────────── | |
| function usToDeg(s) { | |
| var range = s.max - s.min; | |
| if (range === 0) return '0'; | |
| return ((s.us - s.min) / range * s.deg).toFixed(1); | |
| } | |
| // ── Arc gauge ──────────────────────────────────────────────────────────── | |
| function toRad(d) { return (d - 90) * Math.PI / 180; } | |
| function describeArc(cx, cy, r, startDeg, endDeg) { | |
| var x1 = cx + r * Math.cos(toRad(startDeg)); | |
| var y1 = cy + r * Math.sin(toRad(startDeg)); | |
| var x2 = cx + r * Math.cos(toRad(endDeg)); | |
| var y2 = cy + r * Math.sin(toRad(endDeg)); | |
| var large = (endDeg - startDeg) > 180 ? 1 : 0; | |
| return 'M ' + x1 + ' ' + y1 + ' A ' + r + ' ' + r + ' 0 ' + large + ' 1 ' + x2 + ' ' + y2; | |
| } | |
| function updateGauge(ch) { | |
| var s = state[ch]; | |
| var svg = document.getElementById('gauge-' + ch); | |
| if (!svg) return; | |
| var cx = 60, cy = 60, r = 48; | |
| var arcStart = -140, arcSpan = 280; | |
| var ratio = (s.us - s.min) / Math.max(1, s.max - s.min); | |
| var arcEnd = arcStart + arcSpan * ratio; | |
| svg.querySelector('.arc-bg').setAttribute('d', describeArc(cx, cy, r, arcStart, arcStart + arcSpan)); | |
| svg.querySelector('.arc-fill').setAttribute('d', describeArc(cx, cy, r, arcStart, arcEnd)); | |
| var needleAngle = toRad(arcStart + arcSpan * ratio); | |
| var nx = cx + r * 0.78 * Math.cos(needleAngle); | |
| var ny = cy + r * 0.78 * Math.sin(needleAngle); | |
| svg.querySelector('.needle').setAttribute('d', 'M ' + cx + ' ' + cy + ' L ' + nx + ' ' + ny); | |
| } | |
| // ── Build card ─────────────────────────────────────────────────────────── | |
| function buildCard(s) { | |
| var div = document.createElement('div'); | |
| div.className = 'card'; | |
| div.id = 'card-' + s.ch; | |
| div.innerHTML = | |
| '<div class="card-head">' + | |
| '<div class="ch-badge">' + s.ch + '</div>' + | |
| '<div class="ch-label">SERVO ' + s.ch + '</div>' + | |
| '<button class="center-btn" onclick="APP.centerServo(' + s.ch + ')">CENTER</button>' + | |
| '</div>' + | |
| '<div class="card-body">' + | |
| '<div class="gauge-wrap">' + | |
| '<svg id="gauge-' + s.ch + '" class="gauge" width="120" height="90" viewBox="0 0 120 90">' + | |
| '<path class="arc-bg" fill="none" stroke="#1e2530" stroke-width="6" stroke-linecap="round"/>' + | |
| '<path class="arc-fill" fill="none" stroke="#f0a500" stroke-width="6" stroke-linecap="round"/>' + | |
| '<path class="needle" fill="none" stroke="#ffcc44" stroke-width="2" stroke-linecap="round"/>' + | |
| '<circle cx="60" cy="60" r="4" fill="#f0a500"/>' + | |
| '</svg>' + | |
| '</div>' + | |
| '<div class="readouts">' + | |
| '<div class="readout">' + | |
| '<div class="readout-label">PULSE WIDTH</div>' + | |
| '<div class="readout-value" id="us-' + s.ch + '">' + s.us + '<span class="readout-unit">us</span></div>' + | |
| '</div>' + | |
| '<div class="readout">' + | |
| '<div class="readout-label">POSITION</div>' + | |
| '<div class="readout-value deg-val" id="deg-' + s.ch + '">--<span class="readout-unit">deg</span></div>' + | |
| '</div>' + | |
| '</div>' + | |
| '<div class="slider-row">' + | |
| '<span class="range-label" id="smin-' + s.ch + '">' + s.min + '</span>' + | |
| '<input type="range" id="slider-' + s.ch + '" min="' + s.min + '" max="' + s.max + '" value="' + s.us + '"' + | |
| ' oninput="APP.onSlider(' + s.ch + ', this.value)">' + | |
| '<span class="range-label" id="smax-' + s.ch + '">' + s.max + '</span>' + | |
| '</div>' + | |
| '<div class="us-input-row">' + | |
| '<label>SET us</label>' + | |
| '<input type="number" id="usinput-' + s.ch + '" min="' + s.min + '" max="' + s.max + '" value="' + s.us + '"' + | |
| ' onkeydown="if(event.key===\'Enter\') APP.applyUs(' + s.ch + ')">' + | |
| '<button class="apply-btn" onclick="APP.applyUs(' + s.ch + ')">GO</button>' + | |
| '</div>' + | |
| '<div class="config-toggle" id="cfgtoggle-' + s.ch + '" onclick="APP.toggleConfig(' + s.ch + ')">' + | |
| '<span class="arrow">►</span> CONFIGURE' + | |
| '</div>' + | |
| '<div class="config-panel" id="cfgpanel-' + s.ch + '">' + | |
| '<div class="config-row"><label>MIN us</label>' + | |
| '<input type="number" id="cfgmin-' + s.ch + '" value="' + s.min + '" min="100" max="3000"></div>' + | |
| '<div class="config-row"><label>MAX us</label>' + | |
| '<input type="number" id="cfgmax-' + s.ch + '" value="' + s.max + '" min="100" max="3000"></div>' + | |
| '<div class="config-row"><label>RANGE</label>' + | |
| '<select id="cfgdeg-' + s.ch + '">' + | |
| '<option value="90">90 deg</option>' + | |
| '<option value="180">180 deg</option>' + | |
| '<option value="270">270 deg</option>' + | |
| '<option value="360">360 deg</option>' + | |
| '</select></div>' + | |
| '<div class="config-row">' + | |
| '<button class="save-btn" onclick="APP.saveConfig(' + s.ch + ')">SAVE CONFIG</button>' + | |
| '<span class="saved-flash" id="saved-' + s.ch + '">Saved</span>' + | |
| '</div>' + | |
| '</div>' + | |
| '</div>'; | |
| return div; | |
| } | |
| // ── Update card ────────────────────────────────────────────────────────── | |
| function updateCard(ch) { | |
| var s = state[ch]; | |
| var usEl = document.getElementById('us-' + ch); | |
| if (usEl) usEl.innerHTML = s.us + '<span class="readout-unit">us</span>'; | |
| var degEl = document.getElementById('deg-' + ch); | |
| if (degEl) degEl.innerHTML = usToDeg(s) + '<span class="readout-unit">deg</span>'; | |
| var slider = document.getElementById('slider-' + ch); | |
| if (slider) { slider.min = s.min; slider.max = s.max; slider.value = s.us; } | |
| var sminEl = document.getElementById('smin-' + ch); | |
| if (sminEl) sminEl.textContent = s.min; | |
| var smaxEl = document.getElementById('smax-' + ch); | |
| if (smaxEl) smaxEl.textContent = s.max; | |
| var usIn = document.getElementById('usinput-' + ch); | |
| if (usIn) { usIn.min = s.min; usIn.max = s.max; usIn.value = s.us; } | |
| var cfgmin = document.getElementById('cfgmin-' + ch); | |
| if (cfgmin) cfgmin.value = s.min; | |
| var cfgmax = document.getElementById('cfgmax-' + ch); | |
| if (cfgmax) cfgmax.value = s.max; | |
| var cfgdeg = document.getElementById('cfgdeg-' + ch); | |
| if (cfgdeg) cfgdeg.value = s.deg; | |
| updateGauge(ch); | |
| } | |
| // ── Handlers (exposed as APP.* so onclick attrs can call them) ─────────── | |
| window.APP = { | |
| onSlider: function(ch, val) { | |
| val = parseInt(val); | |
| state[ch].us = val; | |
| var usEl = document.getElementById('us-' + ch); | |
| if (usEl) usEl.innerHTML = val + '<span class="readout-unit">us</span>'; | |
| var degEl = document.getElementById('deg-' + ch); | |
| if (degEl) degEl.innerHTML = usToDeg(state[ch]) + '<span class="readout-unit">deg</span>'; | |
| var usIn = document.getElementById('usinput-' + ch); | |
| if (usIn) usIn.value = val; | |
| updateGauge(ch); | |
| wsSend({ cmd: 'set', ch: ch, us: val }); | |
| }, | |
| applyUs: function(ch) { | |
| var val = parseInt(document.getElementById('usinput-' + ch).value); | |
| if (isNaN(val)) return; | |
| APP.onSlider(ch, val); | |
| }, | |
| centerServo: function(ch) { | |
| wsSend({ cmd: 'center', ch: ch }); | |
| }, | |
| toggleConfig: function(ch) { | |
| document.getElementById('cfgtoggle-' + ch).classList.toggle('open'); | |
| document.getElementById('cfgpanel-' + ch).classList.toggle('open'); | |
| }, | |
| saveConfig: function(ch) { | |
| var min = parseInt(document.getElementById('cfgmin-' + ch).value); | |
| var max = parseInt(document.getElementById('cfgmax-' + ch).value); | |
| var deg = parseInt(document.getElementById('cfgdeg-' + ch).value); | |
| if (isNaN(min) || isNaN(max) || min >= max) { alert('Invalid min/max'); return; } | |
| wsSend({ cmd: 'config', ch: ch, min: min, max: max, deg: deg }); | |
| var flash = document.getElementById('saved-' + ch); | |
| flash.classList.add('show'); | |
| setTimeout(function() { flash.classList.remove('show'); }, 1800); | |
| } | |
| }; | |
| // ── Init ───────────────────────────────────────────────────────────────── | |
| function init() { | |
| var grid = document.getElementById('grid'); | |
| state.forEach(function(s) { grid.appendChild(buildCard(s)); }); | |
| state.forEach(function(s) { updateCard(s.ch); updateGauge(s.ch); }); | |
| document.getElementById('footerIp').textContent = location.hostname; | |
| connectWs(); | |
| } | |
| window.addEventListener('DOMContentLoaded', init); | |
| })(); | |
| </script> | |
| </body> | |
| </html> | |
| )HTMLEOF"; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment