Files
Arduino-Car/Esp32Cam/data/index.html
Natxo1000 dacc913c5a Coche RC con OTA via Gitea
- 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>
2026-06-02 09:52:51 +02:00

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>