Skip to content

Instantly share code, notes, and snippets.

@fxprime
Created May 4, 2026 07:38
Show Gist options
  • Select an option

  • Save fxprime/53679499059d52127351f03724ee13a5 to your computer and use it in GitHub Desktop.

Select an option

Save fxprime/53679499059d52127351f03724ee13a5 to your computer and use it in GitHub Desktop.
#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 &middot; CH 0-3 &middot; 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">&#9658;</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