218 lines
8.8 KiB
C
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">— °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";
|