- ESP32-CAM: servidor web, stream MJPEG, control motores via Serial - ESP8266/Wemos D1: control motores DC, servos pan/tilt, buzzer - OTA automatico en arranque via Gitea (HTTPS, WiFiClientSecure) - Interfaz web rediseñada: full-screen, tema gaming, reconexion de stream - partitions.csv con esquema dual OTA (2x1.75MB) para ESP32-CAM - firmware/ con version.txt inicial para cada placa Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
462 lines
16 KiB
HTML
462 lines
16 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="es">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
|
<title>Coche RC</title>
|
|
<style>
|
|
:root {
|
|
--red: #e63946;
|
|
--blue: #4361ee;
|
|
--orange: #f4a261;
|
|
--bg: #08080f;
|
|
--panel: rgba(10, 10, 20, 0.82);
|
|
--btn: rgba(30, 30, 50, 0.9);
|
|
--text: #e0e0e0;
|
|
--dim: #666;
|
|
--border: rgba(255,255,255,0.07);
|
|
}
|
|
|
|
*, *::before, *::after {
|
|
margin: 0; padding: 0;
|
|
box-sizing: border-box;
|
|
-webkit-tap-highlight-color: transparent;
|
|
user-select: none;
|
|
}
|
|
|
|
html, body {
|
|
width: 100%; height: 100%;
|
|
overflow: hidden;
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
font-family: 'Segoe UI', system-ui, sans-serif;
|
|
}
|
|
|
|
/* ── STATUS BAR ─────────────────────────────────── */
|
|
#bar {
|
|
position: fixed;
|
|
top: 0; left: 0; right: 0;
|
|
height: 38px;
|
|
background: rgba(5,5,15,0.85);
|
|
backdrop-filter: blur(12px);
|
|
border-bottom: 1px solid var(--border);
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 0 14px;
|
|
gap: 10px;
|
|
z-index: 200;
|
|
}
|
|
|
|
.bar-title {
|
|
font-size: 13px;
|
|
font-weight: 700;
|
|
letter-spacing: 2px;
|
|
text-transform: uppercase;
|
|
color: var(--red);
|
|
}
|
|
|
|
.dot {
|
|
width: 8px; height: 8px;
|
|
border-radius: 50%;
|
|
background: #333;
|
|
flex-shrink: 0;
|
|
transition: background .4s, box-shadow .4s;
|
|
}
|
|
.dot.ok { background: #2ecc71; box-shadow: 0 0 7px #2ecc71; }
|
|
.dot.err { background: var(--red); box-shadow: 0 0 7px var(--red); animation: blink 1s infinite; }
|
|
@keyframes blink { 50% { opacity: .3; } }
|
|
|
|
#bar-status { font-size: 11px; color: var(--dim); }
|
|
#bar-speed { margin-left: auto; font-size: 12px; font-weight: 600; color: var(--orange); letter-spacing: 1px; }
|
|
|
|
/* ── VIDEO ──────────────────────────────────────── */
|
|
#screen {
|
|
position: fixed;
|
|
top: 38px; left: 0; right: 0; bottom: 0;
|
|
background: #000;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
#stream {
|
|
max-width: 100%;
|
|
max-height: 100%;
|
|
object-fit: contain;
|
|
display: block;
|
|
}
|
|
|
|
/* ── CONTROLS OVERLAY ───────────────────────────── */
|
|
#controls {
|
|
position: fixed;
|
|
bottom: 0; left: 0; right: 0;
|
|
padding: 0 12px 14px;
|
|
background: linear-gradient(transparent 0%, rgba(5,5,15,0.88) 45%);
|
|
display: flex;
|
|
align-items: flex-end;
|
|
justify-content: space-between;
|
|
gap: 8px;
|
|
z-index: 100;
|
|
pointer-events: none; /* let video clicks through the gradient */
|
|
}
|
|
#controls > * { pointer-events: all; }
|
|
|
|
/* ── D-PAD ──────────────────────────────────────── */
|
|
.dpad {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, 54px);
|
|
grid-template-rows: repeat(3, 54px);
|
|
gap: 5px;
|
|
}
|
|
.dpad.small {
|
|
grid-template-columns: repeat(3, 44px);
|
|
grid-template-rows: repeat(3, 44px);
|
|
gap: 4px;
|
|
}
|
|
|
|
.btn {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
border-radius: 10px;
|
|
background: var(--btn);
|
|
border: 1px solid var(--border);
|
|
color: var(--text);
|
|
font-size: 22px;
|
|
cursor: pointer;
|
|
transition: transform .08s, background .08s, box-shadow .08s;
|
|
touch-action: none;
|
|
backdrop-filter: blur(6px);
|
|
}
|
|
|
|
/* car buttons — red accent */
|
|
.btn.car.pressed, .btn.car:active {
|
|
background: var(--red);
|
|
transform: scale(0.9);
|
|
box-shadow: 0 0 16px rgba(230,57,70,.55);
|
|
}
|
|
|
|
/* camera buttons — blue accent */
|
|
.btn.cam { font-size: 18px; border-radius: 8px; }
|
|
.btn.cam.pressed, .btn.cam:active {
|
|
background: var(--blue);
|
|
transform: scale(0.9);
|
|
box-shadow: 0 0 14px rgba(67,97,238,.55);
|
|
}
|
|
.btn.cam.center { font-size: 14px; }
|
|
|
|
/* ── CENTER PANEL ───────────────────────────────── */
|
|
.center {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 10px;
|
|
max-width: 180px;
|
|
}
|
|
|
|
/* Speed slider */
|
|
.speed-wrap { width: 100%; display: flex; flex-direction: column; align-items: center; gap: 5px; }
|
|
.speed-label { font-size: 10px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--dim); }
|
|
|
|
#speed {
|
|
-webkit-appearance: none;
|
|
appearance: none;
|
|
width: 100%;
|
|
height: 5px;
|
|
border-radius: 3px;
|
|
background: linear-gradient(to right, var(--red) 0%, var(--orange) var(--pct,40%), #222 var(--pct,40%));
|
|
outline: none;
|
|
cursor: pointer;
|
|
}
|
|
#speed::-webkit-slider-thumb {
|
|
-webkit-appearance: none;
|
|
width: 20px; height: 20px;
|
|
border-radius: 50%;
|
|
background: #fff;
|
|
box-shadow: 0 0 8px rgba(230,57,70,.6);
|
|
cursor: pointer;
|
|
}
|
|
|
|
/* Extras */
|
|
.extras { display: flex; gap: 8px; }
|
|
|
|
.btn-extra {
|
|
padding: 8px 14px;
|
|
border-radius: 8px;
|
|
background: var(--btn);
|
|
border: 1px solid var(--border);
|
|
color: var(--text);
|
|
font-size: 18px;
|
|
cursor: pointer;
|
|
backdrop-filter: blur(6px);
|
|
transition: background .1s, box-shadow .1s, transform .08s;
|
|
}
|
|
.btn-extra:active { transform: scale(0.92); }
|
|
.btn-extra.active {
|
|
background: var(--orange);
|
|
box-shadow: 0 0 12px rgba(244,162,97,.5);
|
|
}
|
|
|
|
/* Keys hint */
|
|
.hint { font-size: 9px; color: rgba(255,255,255,.2); text-align: center; line-height: 1.8; }
|
|
|
|
/* ── LABELS ─────────────────────────────────────── */
|
|
.dpad-label {
|
|
font-size: 9px;
|
|
letter-spacing: 1px;
|
|
text-transform: uppercase;
|
|
color: var(--dim);
|
|
text-align: center;
|
|
margin-bottom: 2px;
|
|
}
|
|
.dpad-wrap { display: flex; flex-direction: column; align-items: center; }
|
|
|
|
/* ── LANDSCAPE / SMALL SCREEN ───────────────────── */
|
|
@media (max-height: 480px) {
|
|
.dpad { grid-template-columns: repeat(3, 44px); grid-template-rows: repeat(3, 44px); gap: 4px; }
|
|
.dpad.small { grid-template-columns: repeat(3, 36px); grid-template-rows: repeat(3, 36px); }
|
|
.btn { font-size: 18px; }
|
|
.btn.cam { font-size: 14px; }
|
|
#controls { padding: 0 10px 10px; }
|
|
.center { gap: 7px; max-width: 150px; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<!-- STATUS BAR -->
|
|
<div id="bar">
|
|
<span class="bar-title">RC</span>
|
|
<div class="dot" id="dot"></div>
|
|
<span id="bar-status">Conectando…</span>
|
|
<span id="bar-speed">VEL 3/6</span>
|
|
</div>
|
|
|
|
<!-- STREAM -->
|
|
<div id="screen">
|
|
<img id="stream" alt="Cámara ESP32">
|
|
</div>
|
|
|
|
<!-- CONTROLS -->
|
|
<div id="controls">
|
|
|
|
<!-- Coche -->
|
|
<div class="dpad-wrap">
|
|
<div class="dpad-label">Coche</div>
|
|
<div class="dpad" id="dpad-car">
|
|
<div></div>
|
|
<div class="btn car" data-cmd="F">▲</div>
|
|
<div></div>
|
|
<div class="btn car" data-cmd="L">◄</div>
|
|
<div></div>
|
|
<div class="btn car" data-cmd="R">►</div>
|
|
<div></div>
|
|
<div class="btn car" data-cmd="B">▼</div>
|
|
<div></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Centro -->
|
|
<div class="center">
|
|
<div class="speed-wrap">
|
|
<span class="speed-label">Velocidad</span>
|
|
<input type="range" id="speed" min="1" max="6" step="1" value="3">
|
|
</div>
|
|
<div class="extras">
|
|
<button class="btn-extra" id="btn-horn" title="Manten pulsado">📯</button>
|
|
<button class="btn-extra" id="btn-led" title="Luz">💡</button>
|
|
</div>
|
|
<div class="hint">WASD · ←→↑↓ · E · F · R</div>
|
|
</div>
|
|
|
|
<!-- Cámara -->
|
|
<div class="dpad-wrap">
|
|
<div class="dpad-label">Cámara</div>
|
|
<div class="dpad small" id="dpad-cam">
|
|
<div></div>
|
|
<div class="btn cam" data-cam="YU">▲</div>
|
|
<div></div>
|
|
<div class="btn cam" data-cam="XL">◄</div>
|
|
<div class="btn cam center" data-cam="C">⊙</div>
|
|
<div class="btn cam" data-cam="XR">►</div>
|
|
<div></div>
|
|
<div class="btn cam" data-cam="YD">▼</div>
|
|
<div></div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<script>
|
|
// ── URLs ────────────────────────────────────────────────────────
|
|
const baseHost = document.location.origin;
|
|
const hostname = window.location.hostname;
|
|
const isHttps = baseHost.startsWith('https');
|
|
const streamSrc = isHttps
|
|
? `https://stream${hostname}/stream`
|
|
: `${baseHost.replace(/:\d+$/, '')}:81/stream`;
|
|
|
|
// ── STREAM con reconexión automática ────────────────────────────
|
|
const view = document.getElementById('stream');
|
|
const dot = document.getElementById('dot');
|
|
const barStatus = document.getElementById('bar-status');
|
|
|
|
function startStream() {
|
|
view.src = streamSrc + '?t=' + Date.now();
|
|
}
|
|
view.onload = () => {
|
|
dot.className = 'dot ok';
|
|
barStatus.textContent = 'En vivo';
|
|
};
|
|
view.onerror = () => {
|
|
dot.className = 'dot err';
|
|
barStatus.textContent = 'Reconectando…';
|
|
setTimeout(startStream, 2500);
|
|
};
|
|
startStream();
|
|
|
|
// ── COMANDOS con cancelación de movimiento ───────────────────────
|
|
let moveCtrl = null; // AbortController para F/B/L/R/S
|
|
let ledOn = false;
|
|
let hornOn = false;
|
|
|
|
function sendCommand(cmd) {
|
|
let query;
|
|
switch (cmd) {
|
|
case 'K':
|
|
ledOn = !ledOn;
|
|
document.getElementById('btn-led').classList.toggle('active', ledOn);
|
|
query = `${baseHost}/control?var=led_intensity&val=${ledOn ? 255 : 0}`;
|
|
fetch(query).catch(() => {});
|
|
return;
|
|
default:
|
|
query = `${baseHost}/control?var=car&val=${cmd}`;
|
|
}
|
|
|
|
// Cancelar comando de movimiento anterior para no acumular cola
|
|
if (['F','B','L','R','S'].includes(cmd)) {
|
|
if (moveCtrl) moveCtrl.abort();
|
|
moveCtrl = new AbortController();
|
|
fetch(query, { signal: moveCtrl.signal }).catch(() => {});
|
|
} else {
|
|
fetch(query).catch(() => {});
|
|
}
|
|
}
|
|
|
|
// ── VELOCIDAD ───────────────────────────────────────────────────
|
|
const speedEl = document.getElementById('speed');
|
|
const barSpeed = document.getElementById('bar-speed');
|
|
|
|
function applySpeed() {
|
|
const level = parseInt(speedEl.value);
|
|
const pct = ((level - 1) / 5 * 100).toFixed(0);
|
|
speedEl.style.setProperty('--pct', pct + '%');
|
|
barSpeed.textContent = `VEL ${level}/6`;
|
|
sendCommand('V' + Math.round(150 + (level - 1) * 21));
|
|
}
|
|
speedEl.addEventListener('input', applySpeed);
|
|
applySpeed();
|
|
|
|
// ── SERVOS ──────────────────────────────────────────────────────
|
|
let posX = 90, posY = 90;
|
|
const STEP = 10;
|
|
|
|
function moveServo(axis, dir) {
|
|
if (axis === 'X') {
|
|
posX = Math.min(180, Math.max(0, posX + dir * STEP));
|
|
sendCommand('X' + posX);
|
|
} else {
|
|
posY = Math.min(180, Math.max(0, posY + dir * STEP));
|
|
sendCommand('Y' + posY);
|
|
}
|
|
}
|
|
function centerServos() {
|
|
posX = 90; posY = 90;
|
|
sendCommand('X90'); sendCommand('Y90');
|
|
}
|
|
|
|
// ── BOTONES COCHE ───────────────────────────────────────────────
|
|
document.querySelectorAll('#dpad-car .btn').forEach(btn => {
|
|
const cmd = btn.dataset.cmd;
|
|
const isMove = ['F','B','L','R'].includes(cmd);
|
|
|
|
const press = () => { btn.classList.add('pressed'); sendCommand(cmd); };
|
|
const release = () => {
|
|
btn.classList.remove('pressed');
|
|
if (isMove) sendCommand('S');
|
|
};
|
|
|
|
btn.addEventListener('mousedown', press);
|
|
btn.addEventListener('mouseup', release);
|
|
btn.addEventListener('mouseleave', release);
|
|
btn.addEventListener('touchstart', e => { e.preventDefault(); press(); }, { passive: false });
|
|
btn.addEventListener('touchend', e => { e.preventDefault(); release(); }, { passive: false });
|
|
btn.addEventListener('touchcancel',e => { e.preventDefault(); release(); }, { passive: false });
|
|
});
|
|
|
|
// ── BOTONES CÁMARA ──────────────────────────────────────────────
|
|
document.querySelectorAll('#dpad-cam .btn').forEach(btn => {
|
|
const act = btn.dataset.cam;
|
|
const actions = {
|
|
YU: () => moveServo('Y', -1),
|
|
YD: () => moveServo('Y', 1),
|
|
XL: () => moveServo('X', 1),
|
|
XR: () => moveServo('X', -1),
|
|
C: () => centerServos(),
|
|
};
|
|
const press = () => { btn.classList.add('pressed'); actions[act]?.(); };
|
|
const release = () => btn.classList.remove('pressed');
|
|
|
|
btn.addEventListener('mousedown', press);
|
|
btn.addEventListener('mouseup', release);
|
|
btn.addEventListener('mouseleave', release);
|
|
btn.addEventListener('touchstart', e => { e.preventDefault(); press(); }, { passive: false });
|
|
btn.addEventListener('touchend', e => { e.preventDefault(); release(); }, { passive: false });
|
|
btn.addEventListener('touchcancel',e => { e.preventDefault(); release(); }, { passive: false });
|
|
});
|
|
|
|
// ── BOCINA (hold to honk) ───────────────────────────────────────
|
|
const btnHorn = document.getElementById('btn-horn');
|
|
const startHorn = () => { if (hornOn) return; hornOn = true; btnHorn.classList.add('active'); sendCommand('H1'); };
|
|
const stopHorn = () => { if (!hornOn) return; hornOn = false; btnHorn.classList.remove('active'); sendCommand('H0'); };
|
|
btnHorn.addEventListener('mousedown', startHorn);
|
|
btnHorn.addEventListener('mouseup', stopHorn);
|
|
btnHorn.addEventListener('mouseleave', stopHorn);
|
|
btnHorn.addEventListener('touchstart', e => { e.preventDefault(); startHorn(); }, { passive: false });
|
|
btnHorn.addEventListener('touchend', e => { e.preventDefault(); stopHorn(); }, { passive: false });
|
|
btnHorn.addEventListener('touchcancel',e => { e.preventDefault(); stopHorn(); }, { passive: false });
|
|
|
|
// ── LUZ ─────────────────────────────────────────────────────────
|
|
document.getElementById('btn-led').addEventListener('click', () => sendCommand('K'));
|
|
|
|
// ── TECLADO ─────────────────────────────────────────────────────
|
|
const held = new Set();
|
|
|
|
document.addEventListener('keydown', e => {
|
|
if (e.repeat || held.has(e.key)) return;
|
|
held.add(e.key);
|
|
switch (e.key) {
|
|
case 'w': case 'W': sendCommand('F'); break;
|
|
case 's': case 'S': sendCommand('B'); break;
|
|
case 'a': case 'A': sendCommand('L'); break;
|
|
case 'd': case 'D': sendCommand('R'); break;
|
|
case 'ArrowUp': moveServo('Y', -1); break;
|
|
case 'ArrowDown': moveServo('Y', 1); break;
|
|
case 'ArrowLeft': moveServo('X', 1); break;
|
|
case 'ArrowRight': moveServo('X', -1); break;
|
|
case 'e': case 'E': startHorn(); break;
|
|
case 'f': case 'F': sendCommand('K'); break;
|
|
case 'r': case 'R': centerServos(); break;
|
|
}
|
|
});
|
|
|
|
document.addEventListener('keyup', e => {
|
|
held.delete(e.key);
|
|
if (['w','W','s','S','a','A','d','D'].includes(e.key)) sendCommand('S');
|
|
if (['e','E'].includes(e.key)) stopHorn();
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|