-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathvr.py
More file actions
484 lines (456 loc) · 22.7 KB
/
Copy pathvr.py
File metadata and controls
484 lines (456 loc) · 22.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
"""The /vr page: stereoscopic view of the two cameras.
Two delivery paths from one renderer:
* Phone in a Cardboard-style headset -- the page draws the feeds itself as a
side-by-side pair with a per-eye barrel pre-warp. Headset lenses add
pincushion distortion; pre-warping the image cancels it, which is what the
plain side-by-side FPV page can't do (straight lines bow outward there).
* Standalone headset (Quest etc.) -- an "Enter VR" button starts a real WebXR
immersive-vr session and lets the runtime's compositor handle the lenses.
WebXR needs a secure context, so this button only appears over HTTPS (see
tlscert.py); over plain http:// the page quietly falls back to Cardboard mode.
The view is fixed: both cameras are bolted to the chassis and both servos drive
wheels, so there is nothing for head movement to actuate. You look around by
turning the bot -- with the gamepad plugged into the Pi (see gamepad.py), which
is also where the emergency stop lives, since you can't reach the phone.
Every knob (eye separation, distortion, zoom, eye swap, fit) is adjustable
because it depends on your headset's lenses, and is remembered in localStorage.
"""
VR_PAGE = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<title>FPV Bot — VR</title>
<style>
* { box-sizing:border-box; -webkit-user-select:none; user-select:none; -webkit-touch-callout:none; }
html,body { margin:0; height:100%; background:#000; overflow:hidden; color:#e8eaed;
font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif; }
#gl { position:fixed; inset:0; width:100%; height:100%; display:block; touch-action:none; }
/* Fallback when WebGL is missing: plain side-by-side, no lens correction. */
#plain { position:fixed; inset:0; display:none; }
body.plain #gl { display:none; }
body.plain #plain { display:flex; }
#plain img { width:50%; height:100%; object-fit:cover; }
.cams { position:absolute; width:0; height:0; overflow:hidden; opacity:0; }
.panel { position:fixed; left:50%; top:50%; transform:translate(-50%,-50%); z-index:10;
width:min(92vw,430px); max-height:92vh; overflow-y:auto; padding:16px 18px;
background:rgba(14,17,20,.94); border:1px solid #2a2f35; border-radius:14px;
box-shadow:0 12px 40px rgba(0,0,0,.6); }
body.hidepanel .panel { display:none; }
h1 { margin:0 0 2px; font-size:17px; letter-spacing:.02em; }
.sub { margin:0 0 12px; font-size:12px; color:#9aa0a6; line-height:1.45; }
.row { display:flex; align-items:center; gap:10px; margin:9px 0; font-size:13px; }
.row label { flex:0 0 108px; color:#c3c7cb; }
.row input[type=range] { flex:1; accent-color:#3bd16f; min-width:0; }
.row .val { flex:0 0 46px; text-align:right; font-variant-numeric:tabular-nums; color:#9aa0a6; }
.btns { display:flex; flex-wrap:wrap; gap:8px; margin-top:14px; }
button { flex:1 1 auto; min-height:44px; padding:0 14px; font-size:14px; font-weight:600;
color:#e8eaed; background:#1e2429; border:1px solid #2f363d; border-radius:10px;
touch-action:manipulation; }
button.primary { background:#3bd16f; border-color:#3bd16f; color:#08130c; }
button.on { border-color:#3bd16f; color:#3bd16f; }
button:disabled { opacity:.4; }
.status { margin-top:12px; padding-top:10px; border-top:1px solid #23282d;
font-size:12px; color:#9aa0a6; line-height:1.6; }
.dot { display:inline-block; width:8px; height:8px; border-radius:50%; background:#777;
margin-right:6px; vertical-align:middle; }
.dot.ok { background:#3bd16f; } .dot.bad { background:#d23b3b; }
/* Warnings are drawn once per eye so they're readable through the lenses. */
.warn { position:fixed; bottom:8%; z-index:9; width:50%; text-align:center; font-size:13px;
color:#ffb4b4; text-shadow:0 2px 6px #000; pointer-events:none; display:none; }
body.warned .warn { display:block; }
.warn.l { left:0; } .warn.r { right:0; }
#show { position:fixed; top:10px; left:50%; transform:translateX(-50%); z-index:8;
min-height:34px; padding:0 14px; font-size:12px; opacity:.35; display:none; }
body.hidepanel #show { display:block; }
</style>
</head>
<body>
<canvas id="gl"></canvas>
<div id="plain"><img id="pl" alt=""><img id="pr" alt=""></div>
<div class="cams"><img id="camA" src="/video1" alt=""><img id="camB" src="/video0" alt=""></div>
<div class="warn l" id="warnL"></div>
<div class="warn r" id="warnR"></div>
<button id="show">settings</button>
<div class="panel" id="panel">
<h1>VR view</h1>
<p class="sub">Set this up <b>before</b> you put the phone in the headset — drive with the
gamepad plugged into the Pi. Its <b>STOP</b> button is your emergency stop.</p>
<div class="row"><label>Eye separation</label>
<input type="range" id="ipd" min="-0.15" max="0.15" step="0.005"><span class="val" id="ipdv"></span></div>
<div class="row"><label>Lens warp</label>
<input type="range" id="k1" min="0" max="0.6" step="0.01"><span class="val" id="k1v"></span></div>
<div class="row"><label>Warp (outer)</label>
<input type="range" id="k2" min="-0.3" max="0.6" step="0.01"><span class="val" id="k2v"></span></div>
<div class="row"><label>Zoom</label>
<input type="range" id="zoom" min="0.5" max="2.5" step="0.02"><span class="val" id="zoomv"></span></div>
<div class="row"><label>Headset size</label>
<input type="range" id="screen" min="0.2" max="1" step="0.02"><span class="val" id="screenv"></span></div>
<div class="btns">
<button id="fit">fit: cover</button>
<button id="swap">swap eyes</button>
<button id="reset">reset</button>
</div>
<div class="row" style="margin-top:12px"><label>Video</label>
<span class="val" id="qnow" style="flex:1; text-align:left">—</span></div>
<div class="btns" id="qbtns"></div>
<div class="btns">
<button id="enter" class="primary">Enter VR (phone)</button>
<button id="xr" disabled>Enter VR (headset)</button>
</div>
<div class="btns"><button id="tohttps" style="display:none">🔒 Switch to HTTPS (needed for headsets)</button></div>
<div class="btns"><button id="back" style="flex:0 0 auto">← driving page</button></div>
<div class="status">
<div><span class="dot" id="dLink"></span><span id="sLink">checking link…</span></div>
<div><span class="dot" id="dPad"></span><span id="sPad">checking gamepad…</span></div>
<div id="sXr" style="color:#7f858a"></div>
</div>
</div>
<script>
(function(){
"use strict";
// ---- settings (persisted; every headset's lenses differ) ----
const DEFAULTS = {ipd:0.0, k1:0.22, k2:0.10, zoom:1.0, swap:false, cover:true, screen:0.6};
let cfg = Object.assign({}, DEFAULTS);
try { Object.assign(cfg, JSON.parse(localStorage.getItem('fpvvr') || '{}')); } catch(e){}
const save = () => { try { localStorage.setItem('fpvvr', JSON.stringify(cfg)); } catch(e){} };
const body = document.body;
const canvas = document.getElementById('gl');
const camA = document.getElementById('camA'); // /video1 (left on the main page)
const camB = document.getElementById('camB'); // /video0
// ---- WebGL ----
let gl = canvas.getContext('webgl', {alpha:false, antialias:false, xrCompatible:true})
|| canvas.getContext('experimental-webgl', {alpha:false, antialias:false});
// No WebGL (or it blows up below) -> plain side-by-side, no lens correction.
// Worse in a headset, but a usable picture beats a blank screen.
function fallbackPlain(why){
gl = null;
body.classList.add('plain');
document.getElementById('pl').src = '/video1';
document.getElementById('pr').src = '/video0';
if (why) console.warn('VR: falling back to flat side-by-side —', why);
}
if (!gl) fallbackPlain('no WebGL context');
const VERT = `
attribute vec2 a_pos;
varying vec2 v_uv;
void main(){ v_uv = a_pos * 0.5 + 0.5; gl_Position = vec4(a_pos, 0.0, 1.0); }`;
// Barrel pre-warp: for an output pixel at radius r from the lens centre we
// sample the source at r*(1 + k1*r^2 + k2*r^4). That squeezes the edges of the
// image inward, cancelling the pincushion the headset lens adds on the way to
// your eye. k1=k2=0 gives a straight, undistorted copy (WebXR / lens-less).
const FRAG = `
precision mediump float;
varying vec2 v_uv;
uniform sampler2D u_tex;
uniform vec2 u_center; // lens centre in viewport space
uniform vec2 u_aspect; // (viewportW/viewportH, 1) -> circles stay circular
uniform vec2 u_texScale; // lens space -> texture space (fit + zoom + y flip)
uniform float u_k1, u_k2;
void main(){
vec2 c = (v_uv - u_center) * u_aspect;
float r2 = dot(c, c);
vec2 d = c * (1.0 + u_k1 * r2 + u_k2 * r2 * r2);
vec2 uv = vec2(0.5) + d * u_texScale;
if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0){
gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0); // outside the frame: black
return;
}
gl_FragColor = texture2D(u_tex, uv);
}`;
let prog, loc, texA, texB;
function compile(type, src){
const s = gl.createShader(type);
gl.shaderSource(s, src); gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s));
return s;
}
function makeTexture(){
const t = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, t);
// The feeds aren't power-of-two, so mipmaps and REPEAT are both off-limits.
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, 1, 1, 0, gl.RGB, gl.UNSIGNED_BYTE,
new Uint8Array([0,0,0]));
return t;
}
if (gl) try {
prog = gl.createProgram();
gl.attachShader(prog, compile(gl.VERTEX_SHADER, VERT));
gl.attachShader(prog, compile(gl.FRAGMENT_SHADER, FRAG));
gl.bindAttribLocation(prog, 0, 'a_pos');
gl.linkProgram(prog);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(prog));
gl.useProgram(prog);
loc = {center:gl.getUniformLocation(prog,'u_center'), aspect:gl.getUniformLocation(prog,'u_aspect'),
texScale:gl.getUniformLocation(prog,'u_texScale'), k1:gl.getUniformLocation(prog,'u_k1'),
k2:gl.getUniformLocation(prog,'u_k2'), tex:gl.getUniformLocation(prog,'u_tex')};
const buf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 1,-1, -1,1, 1,1]), gl.STATIC_DRAW);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
gl.uniform1i(loc.tex, 0);
gl.activeTexture(gl.TEXTURE0);
texA = makeTexture(); texB = makeTexture();
gl.clearColor(0,0,0,1);
} catch(e){
fallbackPlain(e.message);
}
// ---- feed -> texture upload, throttled ----
// Just above the 60 fps the cameras target, so no frame is skipped, but still
// throttled -- a headset runs rAF at 72-90 Hz and re-uploading the same frame
// twice is pure heat.
const UPLOAD_MS = 15;
let lastUpload = 0;
const size = {w:1280, h:720};
function upload(now){
if (now - lastUpload < UPLOAD_MS) return;
lastUpload = now;
for (const [img, tex] of [[camA, texA], [camB, texB]]){
// MJPEG streams never fire a final 'load', so gate on decoded pixels instead.
if (!img.naturalWidth) continue;
size.w = img.naturalWidth; size.h = img.naturalHeight;
gl.bindTexture(gl.TEXTURE_2D, tex);
try { gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, img); }
catch(e){ /* frame mid-decode; next tick */ }
}
}
// ---- one eye ----
// eye: -1 left, +1 right. (x,y,w,h) is the viewport in framebuffer pixels.
function drawEye(eye, x, y, w, h, warp){
const tex = ((eye < 0) !== cfg.swap) ? texA : texB;
const vpAspect = w / h, frameAspect = size.w / size.h;
// Height the frame occupies in lens space (viewport height == 1.0).
let fh = cfg.cover ? Math.max(1.0, vpAspect / frameAspect)
: Math.min(1.0, vpAspect / frameAspect);
fh /= cfg.zoom;
gl.viewport(x, y, w, h);
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.uniform2f(loc.center, 0.5 + eye * cfg.ipd, 0.5);
gl.uniform2f(loc.aspect, vpAspect, 1.0);
// y is negated: clip space counts up, image rows count down.
gl.uniform2f(loc.texScale, 1.0 / (fh * frameAspect), -1.0 / fh);
gl.uniform1f(loc.k1, warp ? cfg.k1 : 0.0);
gl.uniform1f(loc.k2, warp ? cfg.k2 : 0.0);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
}
// ---- Cardboard render loop ----
function resize(){
const dpr = Math.min(window.devicePixelRatio || 1, 2); // cap: 3x DPR is wasted here
const w = Math.round(innerWidth * dpr), h = Math.round(innerHeight * dpr);
if (canvas.width !== w || canvas.height !== h){ canvas.width = w; canvas.height = h; }
}
let rafId = 0;
function frame(now){
rafId = requestAnimationFrame(frame);
resize(); upload(now);
const w = canvas.width, h = canvas.height, half = Math.floor(w / 2);
gl.clear(gl.COLOR_BUFFER_BIT);
drawEye(-1, 0, 0, half, h, true);
drawEye(+1, half, 0, w - half, h, true);
}
if (gl) rafId = requestAnimationFrame(frame);
// ---- WebXR: let the headset's own compositor do the lens work ----
// Fixed view on purpose -- the cameras don't move, so we ignore head pose and
// paint each eye's viewport with its camera. k1/k2 are forced off here; the XR
// runtime already pre-warps for its lenses and doubling up would bow the image.
const xrBtn = document.getElementById('xr'), sXr = document.getElementById('sXr');
let xrSession = null;
function xrFrame(t, xframe){
const session = xframe.session;
session.requestAnimationFrame(xrFrame);
const layer = session.renderState.baseLayer;
const pose = xframe.getViewerPose(xrRefSpace);
gl.bindFramebuffer(gl.FRAMEBUFFER, layer.framebuffer);
gl.clear(gl.COLOR_BUFFER_BIT);
if (!pose) return;
upload(t);
for (const view of pose.views){
const vp = layer.getViewport(view);
if (!vp) continue;
// Draw into a centred rectangle that carries the camera's own aspect
// ratio, rather than stretching across the whole eye. Filling the eye
// spreads a ~70° camera image over the headset's ~100° field of view,
// which reads as heavy zoom, and an almost-square eye viewport crops a
// 16:9 frame to about half its width on top of that. cfg.screen sets how
// much of your view the picture occupies -- turn it down until motion
// looks natural, i.e. until it matches your lens.
const aspect = size.w / size.h;
let rw = vp.width * cfg.screen;
let rh = rw / aspect;
if (rh > vp.height * cfg.screen){ rh = vp.height * cfg.screen; rw = rh * aspect; }
drawEye(view.eye === 'right' ? +1 : -1,
vp.x + (vp.width - rw) / 2, vp.y + (vp.height - rh) / 2, rw, rh, false);
}
}
let xrRefSpace = null;
async function enterXr(){
try {
const session = await navigator.xr.requestSession('immersive-vr');
xrSession = session;
await gl.makeXRCompatible();
session.updateRenderState({baseLayer: new XRWebGLLayer(session, gl)});
xrRefSpace = await session.requestReferenceSpace('local');
cancelAnimationFrame(rafId);
xrBtn.textContent = 'in VR…';
session.addEventListener('end', ()=>{
xrSession = null; xrBtn.textContent = 'Enter VR (headset)';
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
rafId = requestAnimationFrame(frame);
});
session.requestAnimationFrame(xrFrame);
} catch(e){
sXr.textContent = 'WebXR failed: ' + e.message;
}
}
// Work out *why* headset mode is or isn't available and say so plainly. A
// hidden button with no explanation is indistinguishable from a broken page,
// and the three causes (insecure origin / no WebXR / not a headset) need
// completely different fixes.
const httpsBtn = document.getElementById('tohttps');
const HTTPS_PORT = '__HTTPS_PORT__';
const httpsUrl = HTTPS_PORT ? 'https://' + location.hostname + ':' + HTTPS_PORT + '/vr' : '';
function report(state, detail){
const ua = navigator.userAgent.length > 90
? navigator.userAgent.slice(0, 90) + '…' : navigator.userAgent;
sXr.innerHTML = state + '<br><span style="color:#666">' + detail
+ '<br>' + location.protocol + '//' + location.host
+ ' · secure: ' + window.isSecureContext
+ ' · WebXR: ' + (navigator.xr ? 'yes' : 'no')
+ '<br>' + ua + '</span>';
}
if (!window.isSecureContext){
// navigator.xr doesn't merely fail on an insecure origin -- it doesn't
// exist at all, so there is nothing to feature-detect. Offer the fix.
if (httpsUrl){
report('Headset mode needs HTTPS.',
'Tap the button above, then accept the certificate warning once ' +
'("Advanced" → "Proceed"). Phone/Cardboard mode works fine right here.');
httpsBtn.style.display = '';
httpsBtn.addEventListener('click', ()=>{ location.href = httpsUrl; });
} else {
report('Headset mode needs HTTPS, but the HTTPS listener is off.',
'Restart without FPV_HTTPS=0.');
}
} else if (!navigator.xr || !navigator.xr.isSessionSupported){
report('This browser has no WebXR.',
'Headset mode needs the Meta Quest Browser (or another WebXR browser). ' +
'Phone/Cardboard mode works here.');
} else {
navigator.xr.isSessionSupported('immersive-vr').then(ok=>{
if (ok){
xrBtn.disabled = false;
xrBtn.classList.add('on');
xrBtn.addEventListener('click', enterXr);
report('Headset mode ready — tap "Enter VR (headset)".', 'immersive-vr supported.');
} else {
report('No VR headset detected on this device.',
'WebXR is present but reports no immersive-vr device — that is what ' +
'a phone or laptop says. Open this page in the headset itself.');
}
}).catch(e => report('WebXR check failed.', e.message));
}
// ---- fullscreen + landscape (phone / Cardboard mode) ----
function enterPhone(){
body.classList.add('hidepanel');
const el = document.documentElement;
if (el.requestFullscreen) el.requestFullscreen().catch(()=>{});
if (screen.orientation && screen.orientation.lock) screen.orientation.lock('landscape').catch(()=>{});
}
document.getElementById('enter').addEventListener('click', enterPhone);
document.getElementById('show').addEventListener('click', ()=> body.classList.remove('hidepanel'));
document.getElementById('back').addEventListener('click', ()=>{ location.href = '/'; });
// ---- panel wiring ----
const els = {};
['ipd','k1','k2','zoom','screen'].forEach(k=>{
els[k] = document.getElementById(k);
els[k].value = cfg[k];
els[k].addEventListener('input', ()=>{ cfg[k] = parseFloat(els[k].value); paint(); save(); });
});
const fitBtn = document.getElementById('fit'), swapBtn = document.getElementById('swap');
fitBtn.addEventListener('click', ()=>{ cfg.cover = !cfg.cover; paint(); save(); });
swapBtn.addEventListener('click', ()=>{ cfg.swap = !cfg.swap; paint(); save(); });
document.getElementById('reset').addEventListener('click', ()=>{
cfg = Object.assign({}, DEFAULTS);
['ipd','k1','k2','zoom','screen'].forEach(k=> els[k].value = cfg[k]);
paint(); save();
});
function paint(){
document.getElementById('ipdv').textContent = cfg.ipd.toFixed(3);
document.getElementById('k1v').textContent = cfg.k1.toFixed(2);
document.getElementById('k2v').textContent = cfg.k2.toFixed(2);
document.getElementById('zoomv').textContent = cfg.zoom.toFixed(2) + '×';
document.getElementById('screenv').textContent = Math.round(cfg.screen * 100) + '%';
fitBtn.textContent = 'fit: ' + (cfg.cover ? 'cover' : 'contain');
swapBtn.classList.toggle('on', cfg.swap);
}
paint();
// ---- link / gamepad status ----
const dLink=document.getElementById('dLink'), sLink=document.getElementById('sLink'),
dPad=document.getElementById('dPad'), sPad=document.getElementById('sPad'),
warnL=document.getElementById('warnL'), warnR=document.getElementById('warnR');
function warn(msg){
warnL.textContent = warnR.textContent = msg || '';
body.classList.toggle('warned', !!msg);
}
// ---- video quality: Auto, or pin a tier ----
// One camera has one mode, so a tier applies to the whole bot rather than to
// this viewer -- and both eyes must match anyway, or the stereo falls apart.
const qnow = document.getElementById('qnow'), qbtns = document.getElementById('qbtns');
let qbuilt = false;
async function setQuality(body){
try {
const r = await fetch('/quality', {method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify(body)});
showQuality(await r.json());
} catch(e){}
}
function buildQuality(q){
qbtns.innerHTML = '';
const mk = (text, body) => {
const b = document.createElement('button');
b.textContent = text; b.style.flex = '1 1 60px'; b.style.minHeight = '38px';
b.style.fontSize = '12px';
b.addEventListener('click', ()=> setQuality(body));
qbtns.appendChild(b); return b;
};
mk('auto', {auto:true});
q.ladder.forEach((name, i)=> mk(name, {tier:i}));
qbuilt = true;
}
function showQuality(q){
if (!q) return;
if (!qbuilt) buildQuality(q);
qnow.textContent = q.mode + ' · ' + q.fps.toFixed(0) + ' fps' + (q.auto ? ' · auto' : ' · pinned');
const labels = ['auto'].concat(q.ladder);
const active = q.auto ? 'auto' : q.ladder[q.tier];
[...qbtns.children].forEach((b, i)=> b.classList.toggle('on', labels[i] === active));
}
async function poll(){
try {
const r = await fetch('/status', {cache:'no-store'});
const j = await r.json();
showQuality(j.quality);
dLink.className = 'dot ' + (j.simulated ? 'bad' : 'ok');
sLink.textContent = j.simulated ? 'SIMULATION — motors off' : 'link up, motors armed';
dPad.className = 'dot ' + (j.gamepad.connected ? 'ok' : 'bad');
sPad.textContent = j.gamepad.connected
? 'gamepad: ' + j.gamepad.name
: 'no gamepad on the Pi — plug in the 2.4G dongle';
warn(j.gamepad.connected ? '' : 'no gamepad — check the dongle');
} catch(e){
dLink.className = 'dot bad'; sLink.textContent = 'no connection to the bot';
warn('lost connection to the bot');
}
}
poll(); setInterval(poll, 2000);
})();
</script>
</body>
</html>"""