Files
2026-09-20 18:55:16 +02:00

218 lines
8.8 KiB
C

// The control page, served from flash. Kept deliberately small and
// dependency-free: no frameworks, no CDN, nothing to fetch. It has to work
// when the phone is joined to the machine's own network with no internet.
#pragma once
static const char PAGE_HTML[] PROGMEM = R"HTML(<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,user-scalable=no,viewport-fit=cover">
<title>TranspOtter</title>
<style>
:root{
--bg:#0f1214; --panel:#181d21; --line:#2a3238; --text:#e6edf3;
--dim:#8b979f; --accent:#4cc2ff; --ok:#3fb950; --warn:#d29922; --bad:#f85149;
}
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
html,body{margin:0;height:100%}
body{
background:var(--bg);color:var(--text);
font:14px/1.4 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
display:flex;flex-direction:column;gap:10px;padding:10px;
padding-bottom:calc(10px + env(safe-area-inset-bottom));
overscroll-behavior:none;touch-action:none;
}
header{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
h1{font-size:15px;margin:0;font-weight:600;letter-spacing:.02em}
.pill{
display:inline-flex;align-items:center;gap:6px;background:var(--panel);
border:1px solid var(--line);border-radius:999px;padding:4px 10px;
font-variant-numeric:tabular-nums;font-size:12px;
}
.dot{width:8px;height:8px;border-radius:50%;background:var(--bad)}
.dot.on{background:var(--ok)}
.pad{
flex:1;min-height:210px;background:var(--panel);border:1px solid var(--line);
border-radius:16px;position:relative;overflow:hidden;touch-action:none;
}
.pad canvas{width:100%;height:100%;display:block}
.row{display:flex;gap:8px}
.row>*{flex:1}
button{
background:var(--panel);color:var(--text);border:1px solid var(--line);
border-radius:12px;padding:13px 8px;font-size:14px;font-weight:600;
font-family:inherit;cursor:pointer;
}
button:active{background:#232a30}
button.stop{border-color:#5a4a1f;color:#f0c674}
button.estop{background:#3d1519;border-color:#7d2b31;color:#ff9c96}
.grid{
display:grid;grid-template-columns:repeat(auto-fit,minmax(88px,1fr));gap:6px;
background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:10px;
}
.cell{display:flex;flex-direction:column;gap:2px}
.k{color:var(--dim);font-size:10px;text-transform:uppercase;letter-spacing:.06em}
.val{font-variant-numeric:tabular-nums;font-size:15px;font-weight:600}
.limit{display:flex;align-items:center;gap:10px;background:var(--panel);
border:1px solid var(--line);border-radius:12px;padding:8px 12px}
input[type=range]{flex:1;accent-color:var(--accent)}
@media (min-width:720px){ body{max-width:760px;margin:0 auto} }
</style>
</head>
<body>
<header>
<h1>TranspOtter</h1>
<span class="pill"><span id="dot" class="dot"></span><span id="conn">offline</span></span>
<span class="pill" id="mode">idle</span>
<span class="pill" id="batt">— V</span>
<span class="pill" id="temp">— &deg;C</span>
</header>
<div class="pad" id="pad"><canvas id="cv"></canvas></div>
<div class="limit">
<span class="k">Speed</span>
<input type="range" id="lim" min="5" max="100" value="40">
<span class="val" id="limv" style="min-width:3.2em;text-align:right">40%</span>
</div>
<div class="limit">
<span class="k">Response</span>
<input type="range" id="rsp" min="0" max="100" value="0">
<span class="val" id="rspv" style="min-width:3.2em;text-align:right">soft</span>
</div>
<div class="row">
<button id="p0">HAUL</button>
<button id="p1">NORMAL</button>
<button id="p2">INDOOR</button>
</div>
<div class="row">
<button class="stop" id="bstop">STOP</button>
<button class="estop" id="bestop">E-STOP</button>
</div>
<div class="row">
<button id="barm">ARM</button>
<button id="bzero">ZERO ODO</button>
</div>
<div class="grid">
<div class="cell"><span class="k">speed</span><span class="val" id="v">0.00</span></div>
<div class="cell"><span class="k">turn</span><span class="val" id="w">0.00</span></div>
<div class="cell"><span class="k">steer gain</span><span class="val" id="g">1.00</span></div>
<div class="cell"><span class="k">cmd L/R</span><span class="val" id="c">0/0</span></div>
<div class="cell"><span class="k">distance</span><span class="val" id="d">0.00</span></div>
<div class="cell"><span class="k">heading</span><span class="val" id="h">0.0</span></div>
<div class="cell"><span class="k">frames</span><span class="val" id="f">0</span></div>
<div class="cell"><span class="k">link</span><span class="val" id="lk">down</span></div>
</div>
<script>
const $=id=>document.getElementById(id);
let ws=null, wsOpen=false;
let jx=0, jy=0, limit=0.4, active=false;
// ---- joystick -------------------------------------------------------------
const pad=$('pad'), cv=$('cv'), ctx=cv.getContext('2d');
let W=0,H=0,R=0,cx=0,cy=0,dpr=1;
function size(){
dpr=window.devicePixelRatio||1;
W=pad.clientWidth; H=pad.clientHeight;
cv.width=W*dpr; cv.height=H*dpr; ctx.setTransform(dpr,0,0,dpr,0,0);
cx=W/2; cy=H/2; R=Math.min(W,H)/2-16;
draw();
}
function draw(){
ctx.clearRect(0,0,W,H);
ctx.strokeStyle='#2a3238'; ctx.lineWidth=1;
ctx.beginPath(); ctx.arc(cx,cy,R,0,6.2832); ctx.stroke();
ctx.beginPath(); ctx.moveTo(cx-R,cy); ctx.lineTo(cx+R,cy);
ctx.moveTo(cx,cy-R); ctx.lineTo(cx,cy+R); ctx.stroke();
ctx.strokeStyle='#212930';
ctx.beginPath(); ctx.arc(cx,cy,R*0.5,0,6.2832); ctx.stroke();
const kx=cx+jx*R, ky=cy-jy*R;
ctx.strokeStyle='#4cc2ff'; ctx.lineWidth=2;
ctx.beginPath(); ctx.moveTo(cx,cy); ctx.lineTo(kx,ky); ctx.stroke();
ctx.fillStyle=active?'#4cc2ff':'#5a666e';
ctx.beginPath(); ctx.arc(kx,ky,20,0,6.2832); ctx.fill();
}
function at(e){
const r=pad.getBoundingClientRect();
let x=(e.clientX-r.left-cx)/R, y=-(e.clientY-r.top-cy)/R;
const m=Math.hypot(x,y); if(m>1){x/=m;y/=m;}
jx=x; jy=y; draw();
}
pad.addEventListener('pointerdown',e=>{active=true;pad.setPointerCapture(e.pointerId);at(e);});
pad.addEventListener('pointermove',e=>{if(active)at(e);});
function release(){active=false;jx=0;jy=0;draw();}
pad.addEventListener('pointerup',release);
pad.addEventListener('pointercancel',release);
window.addEventListener('resize',size);
// Speed scales top end; response scales how hard it accelerates to get there.
// Both live on the device and are saved there, so a fresh phone picks up the
// settings you last used rather than imposing its own.
let response=0, synced=false;
function rspLabel(v){ return v<15?'soft':v<40?'easy':v<70?'brisk':v<90?'sharp':'max'; }
function pushSettings(){ send('s,'+limit.toFixed(2)+','+response.toFixed(2)); }
$('lim').addEventListener('input',e=>{
limit=e.target.value/100; $('limv').textContent=e.target.value+'%'; pushSettings();
});
$('rsp').addEventListener('input',e=>{
response=e.target.value/100; $('rspv').textContent=rspLabel(+e.target.value); pushSettings();
});
function preset(v){
response=v/100; $('rsp').value=v; $('rspv').textContent=rspLabel(v); pushSettings();
}
$('p0').onclick=()=>preset(0);
$('p1').onclick=()=>preset(45);
$('p2').onclick=()=>preset(100);
// ---- link -----------------------------------------------------------------
function connect(){
ws=new WebSocket('ws://'+location.host+'/ws');
ws.onopen =()=>{wsOpen=true;synced=false;$('dot').classList.add('on');$('conn').textContent='online';};
ws.onclose=()=>{wsOpen=false;$('dot').classList.remove('on');$('conn').textContent='offline';
setTimeout(connect,700);};
ws.onerror=()=>ws.close();
ws.onmessage=e=>{
let t; try{t=JSON.parse(e.data);}catch(_){return;}
$('mode').textContent=t.m;
$('batt').textContent=t.b.toFixed(1)+' V';
$('temp').textContent=t.t.toFixed(0)+' °C';
$('v').textContent=t.v.toFixed(2);
$('w').textContent=t.w.toFixed(2);
$('g').textContent=t.g.toFixed(2);
$('c').textContent=t.cl+'/'+t.cr;
$('d').textContent=t.d.toFixed(2);
$('h').textContent=t.h.toFixed(1);
$('f').textContent=t.fo+(t.fb?(' / '+t.fb+' bad'):'');
$('lk').textContent=t.k?'up':'down';
$('lk').style.color=t.k?'var(--ok)':'var(--bad)';
if(!synced && t.lim!==undefined){ // adopt the device's saved values once
synced=true;
limit=t.lim; $('lim').value=Math.round(t.lim*100); $('limv').textContent=Math.round(t.lim*100)+'%';
response=t.rsp; $('rsp').value=Math.round(t.rsp*100); $('rspv').textContent=rspLabel(Math.round(t.rsp*100));
}
};
}
function send(s){ if(wsOpen) try{ws.send(s);}catch(_){ } }
function cmd(w){ send('c,'+w); }
$('bstop').onclick =()=>{release();cmd('stop');};
$('bestop').onclick=()=>{release();cmd('estop');};
$('barm').onclick =()=>cmd('arm');
$('bzero').onclick =()=>cmd('zero');
// The drive frame is also the heartbeat: it goes out at 10 Hz whether or not
// the stick moved. Stop sending — background the tab, walk out of range — and
// the firmware's deadman ramps the machine down.
setInterval(()=>send('d,'+jy.toFixed(3)+','+jx.toFixed(3)),100);
size(); connect();
</script>
</body>
</html>)HTML";