teleop / src /embed.ts
ebubar's picture
fix: expose controller selection and live joystick input
828a888
Raw History Blame Contribute Delete
17.6 kB
import { connectToHost } from '@pollen-robotics/reachy-mini-sdk/host/embed';
import { safelyReturnToPose } from '@pollen-robotics/reachy-mini-sdk/animation';
import { Gate, Motion, idle, readPad, type Input } from './control';
import './style.css';
async function main() {
const handle = await connectToHost();
const { reachy } = handle;
// External configs are deliberately unused: they cannot override motion limits.
document.documentElement.dataset.theme = handle.theme;
const offTheme = handle.onThemeChange(theme => { document.documentElement.dataset.theme = theme; });
document.querySelector<HTMLDivElement>('#root')!.innerHTML = `
<main>
<header><div class="eyebrow">REACHY MINI</div><h1>Joystick control</h1>
<p>1. Choose your controller. 2. Check the input indicators. 3. Center the sticks and start.</p></header>
<section class="session" aria-label="Session controls">
<div class="status"><span id="signal" class="dot"></span><span id="phase" role="status">Waiting for telemetry</span></div>
<div class="session-actions"><button id="start" class="primary">Start controls</button><button id="stop">Stop & center</button></div>
<div class="options"><label>Input<select id="source"><option value="gamepad">Gamepad</option><option value="touch">Touch / mouse</option></select></label>
<label class="mode"><input id="turbo" type="checkbox"> Turbo <small>2ร— speed</small></label></div>
<p id="pad-name" class="hint">Pair your controller with this device, then press any controller button.</p>
</section>
<section class="controller-check" aria-label="Controller input test">
<label>Controller<select id="controller"><option value="">Press a controller button to detect it</option></select></label>
<p id="input-status" role="status">Move a stick to test detection. Testing while paused does not move the robot.</p>
<div class="input-values"><span>Left stick <output id="left-input">0.00 / 0.00</output></span><span>Right stick <output id="right-input">0.00 / 0.00</output></span><span>Triggers L / R <output id="trigger-input">0% / 0%</output></span></div>
</section>
<div class="controls-grid">
<section class="card"><div class="section-title"><span class="index">01</span><h2>Look</h2><span class="chip">LEFT STICK</span></div>
<p>Head yaw & pitch, relative to the body.</p><div class="stick" id="look" role="group" aria-label="Head touch joystick"><span class="cross horizontal"></span><span class="cross vertical"></span><span class="puck"></span></div><div class="stick-labels"><span>โ† Look โ†’</span><span>โ†‘ Tilt โ†“</span></div>
</section>
<section class="card"><div class="section-title"><span class="index">02</span><h2>Turn & stretch</h2><span class="chip">RIGHT STICK</span></div>
<p>Body rotation & head height.</p><div class="stick" id="body" role="group" aria-label="Body and height touch joystick"><span class="cross horizontal"></span><span class="cross vertical"></span><span class="puck"></span></div><div class="stick-labels"><span>โ† Turn โ†’</span><span>โ†‘ Raise โ†“</span></div>
</section>
<section class="card antennas"><div class="section-title"><span class="index">03</span><h2>Antennas</h2><span class="chip">TRIGGERS / SHOULDERS</span></div>
<p>Hold to move. Release to keep the expression.</p>
<div class="antenna-grid"><div><span class="antenna-label">LEFT</span><button data-hold="left-out">โ†– Outward <small>LT / L2</small></button><button data-hold="left-in">โ†˜ Inward <small>LB / L1</small></button></div>
<div><span class="antenna-label">RIGHT</span><button data-hold="right-out">Outward โ†— <small>RT / R2</small></button><button data-hold="right-in">Inward โ†™ <small>RB / R1</small></button></div></div>
<div class="readouts"><div><small>HEIGHT</small><strong id="height">โ€”</strong></div><div><small>LEFT ANTENNA</small><strong id="left-angle">โ€”</strong></div><div><small>RIGHT ANTENNA</small><strong id="right-angle">โ€”</strong></div></div>
</section>
</div>
<footer><p><strong>Make a little room.</strong> Start with centered sticks and released triggers. D-pad โ†‘ / โ†“ switches Turbo / Precision.</p>
<p>Stop centers the robot; use the hostโ€™s Leave button to end the session. Changing tabs or losing the controller pauses controls. No camera or microphone is requested.</p><p class="hint">Readouts show commanded positions. Software limits are not collision sensors.</p></footer>
</main>`;
const $ = <T extends HTMLElement = HTMLElement>(id: string) => document.getElementById(id) as T;
const source = $<HTMLSelectElement>('source'), turbo = $<HTMLInputElement>('turbo'), start = $<HTMLButtonElement>('start');
const controllerSelect = $<HTMLSelectElement>('controller');
if (matchMedia('(pointer: coarse)').matches) {
source.value = 'touch';
$('pad-name').textContent = 'Drag the circles; hold antenna buttons. Controls stay on this device.';
}
const gate = new Gate();
let motion: Motion | null = null, lastState = -Infinity, lastTick = performance.now(), restUntil = 0;
let leaving = false, selectedPad = -1, worker: Worker | null = null;
let sourceChosen = false, padSignature = '', inputMessage = '', preview = idle();
let touch = idle();
const held = new Set<string>();
const pointerIds = new Map<string, number>();
const abort = new AbortController();
const signal = abort.signal;
function clearInput() {
touch = idle(); held.clear(); pointerIds.clear();
document.querySelectorAll<HTMLElement>('.puck').forEach(p => { p.style.transform = 'translate(0,0)'; });
document.querySelectorAll('[data-hold]').forEach(b => b.classList.remove('held'));
}
const fresh = () => performance.now() - lastState < 2500;
function stop(reason: string, returnHome = true, forceHome = false) {
const wasActive = gate.active;
gate.pause(reason); clearInput(); motion = null;
if (returnHome && (wasActive || forceHome) && performance.now() >= restUntil && reachy.state === 'streaming' && fresh()) {
try {
const plan = safelyReturnToPose(reachy);
restUntil = performance.now() + plan.duration * 1000 + 400;
} catch (error) { gate.reason = `Paused; centering failed: ${String(error)}`; }
}
}
function pads() {
try { return Array.from(navigator.getGamepads?.() ?? []).filter((pad): pad is Gamepad => Boolean(pad?.connected)); }
catch { throw new Error('Gamepad access is blocked here. Open the direct Space URL or choose Touch.'); }
}
function scanControllers() {
const all = pads();
const signature = all.map(p => `${p.index}:${p.id}:${p.mapping}`).join('|');
if (signature !== padSignature) {
padSignature = signature;
controllerSelect.replaceChildren();
const placeholder = new Option(all.length ? 'Choose your controller' : 'Press a controller button to detect it', '');
controllerSelect.add(placeholder);
for (const pad of all) {
const option = new Option(`${pad.id}${pad.mapping === 'standard' ? '' : ' (unsupported mapping)'}`, String(pad.index));
option.disabled = pad.mapping !== 'standard'; controllerSelect.add(option);
}
const supported = all.filter(p => p.mapping === 'standard');
if (!gate.active && !all.some(p => p.index === selectedPad)) selectedPad = supported.length === 1 ? supported[0].index : -1;
controllerSelect.value = selectedPad < 0 ? '' : String(selectedPad);
// A paired controller takes priority over a touch-capable screen until the
// user explicitly chooses an input. Otherwise a touchscreen laptop can
// silently ignore a working physical gamepad in Touch mode.
if (!sourceChosen && supported.length) source.value = 'gamepad';
}
}
function getInput(): Input {
if (source.value === 'gamepad') {
const pad = pads().find(p => p.index === selectedPad);
if (!pad) throw new Error(gate.active ? 'Controller disconnected โ€” reconnect and press Start.' : 'No mapped gamepad. Press a controller button or choose Touch / mouse.');
$('pad-name').textContent = pad.id;
return readPad(pad);
}
return { ...touch, triggers: [Number(held.has('left-out')), Number(held.has('right-out'))],
shoulders: [held.has('left-in'), held.has('right-in')] };
}
start.addEventListener('click', () => {
try {
if (!worker) throw new Error('Control clock unavailable โ€” reload the app in a browser that supports Web Workers.');
if (leaving || document.hidden || performance.now() < restUntil || reachy.robotState.is_move_running) {
throw new Error('Wait for centering to finish, then press Start.');
}
const input = getInput();
const next = new Motion(reachy.robotState);
gate.arm(input, performance.now(), lastState, reachy.state === 'streaming');
if (!reachy.setMotorMode('enabled')) throw new Error('Robot connection is unavailable.');
motion = next; lastTick = performance.now();
} catch (error) { gate.pause(String(error instanceof Error ? error.message : error)); }
}, {signal});
$('stop').addEventListener('click', () => stop('Paused โ€” press Start after centering to resume.', true, true), {signal});
source.addEventListener('change', () => {
sourceChosen = true;
stop('Input changed โ€” press Start to resume.');
$('pad-name').textContent = source.value === 'touch' ? 'Drag the circles; hold antenna buttons. Controls stay on this device.' : 'Press a gamepad button, then Start.';
}, {signal});
controllerSelect.addEventListener('change', () => {
stop('Controller changed โ€” test the sticks, then press Start.');
selectedPad = controllerSelect.value === '' ? -1 : Number(controllerSelect.value);
sourceChosen = true; source.value = 'gamepad';
}, {signal});
function makeStick(id: string, offset: number) {
const el = $(id), puck = el.querySelector<HTMLElement>('.puck')!;
const reset = () => { touch.axes[offset] = touch.axes[offset+1] = 0; puck.style.transform = 'translate(0,0)'; };
const move = (event: PointerEvent) => {
if (pointerIds.get(id) !== event.pointerId) return;
const box = el.getBoundingClientRect(), radius = box.width * .34;
let x = (event.clientX-box.left-box.width/2)/radius, y = (event.clientY-box.top-box.height/2)/radius;
const magnitude = Math.max(1, Math.hypot(x,y)); x /= magnitude; y /= magnitude;
touch.axes[offset] = x; touch.axes[offset+1] = y;
puck.style.transform = `translate(${x*radius}px,${y*radius}px)`;
};
el.addEventListener('pointerdown', event => {
if (source.value !== 'touch' || !gate.active || pointerIds.has(id)) return;
event.preventDefault(); pointerIds.set(id, event.pointerId); el.setPointerCapture(event.pointerId); move(event);
}, {signal});
el.addEventListener('pointermove', move, {signal});
el.addEventListener('pointerup', event => { if (pointerIds.get(id) === event.pointerId) { pointerIds.delete(id); reset(); } }, {signal});
for (const name of ['pointercancel', 'lostpointercapture'] as const) el.addEventListener(name, () => {
if (pointerIds.has(id)) { reset(); stop('Touch interrupted โ€” press Start to resume.'); }
}, {signal});
}
makeStick('look', 0); makeStick('body', 2);
document.querySelectorAll<HTMLButtonElement>('[data-hold]').forEach(button => {
const name = button.dataset.hold!;
button.addEventListener('pointerdown', event => {
if (source.value !== 'touch' || !gate.active) return;
event.preventDefault(); held.add(name); button.classList.add('held'); button.setPointerCapture(event.pointerId);
}, {signal});
const release = () => { held.delete(name); button.classList.remove('held'); };
button.addEventListener('pointerup', release, {signal});
button.addEventListener('pointercancel', () => { release(); stop('Touch interrupted โ€” press Start to resume.'); }, {signal});
button.addEventListener('lostpointercapture', release, {signal});
button.addEventListener('keydown', event => {
if ((event.key === ' ' || event.key === 'Enter') && source.value === 'touch' && gate.active) {
event.preventDefault(); held.add(name); button.classList.add('held');
}
}, {signal});
button.addEventListener('keyup', release, {signal});
button.addEventListener('blur', release, {signal});
});
const onState = () => { lastState = performance.now(); };
reachy.addEventListener('state', onState);
reachy.requestState();
const lost = () => stop('Robot session ended โ€” reconnect through the host.', false);
for (const event of ['disconnected','sessionStopped','error'] as const) reachy.addEventListener(event, lost);
document.addEventListener('visibilitychange', () => {
if (document.hidden) stop('Tab hidden โ€” press Start to resume.');
else reachy.requestState();
}, {signal});
window.addEventListener('blur', () => stop('Window lost focus โ€” press Start to resume.'), {signal});
window.addEventListener('gamepaddisconnected', event => {
if (source.value === 'gamepad' && event.gamepad.index === selectedPad) stop('Controller disconnected โ€” reconnect and press Start.');
}, {signal});
try { worker = new Worker(new URL('./heartbeat.worker.ts', import.meta.url), {type: 'module'}); }
catch { gate.pause('Control clock unavailable โ€” this browser cannot start Web Workers.'); }
if (worker) worker.onmessage = () => {
const now = performance.now(), dt = (now-lastTick)/1000; lastTick = now;
try {
scanControllers();
preview = getInput(); inputMessage = '';
if (!gate.active || !motion) return;
if (document.hidden || !fresh() || reachy.state !== 'streaming') { stop('Connection stale or tab hidden โ€” press Start after reconnecting.', false); return; }
if (dt > .25) { stop('Controls stalled โ€” press Start to resume.'); return; }
const input = preview;
if (input.turbo !== undefined) turbo.checked = input.turbo;
if (!reachy.setTarget(motion.step(input, dt, turbo.checked))) stop('Robot connection interrupted.', false);
} catch (error) {
preview = idle(); inputMessage = String(error instanceof Error ? error.message : error);
if (gate.active) stop(inputMessage);
}
};
if (worker) worker.onerror = () => {
stop('Control clock failed โ€” reload this app.'); worker?.terminate(); worker = null;
};
let frame = 0;
function render() {
$('phase').textContent = gate.reason;
$('signal').classList.toggle('live', gate.active);
start.disabled = gate.active || leaving || !worker || performance.now() < restUntil;
start.textContent = performance.now() < restUntil ? 'Centeringโ€ฆ' : 'Start controls';
document.body.dataset.input = source.value;
controllerSelect.disabled = gate.active;
$('left-input').textContent = `${preview.axes[0].toFixed(2)} / ${preview.axes[1].toFixed(2)}`;
$('right-input').textContent = `${preview.axes[2].toFixed(2)} / ${preview.axes[3].toFixed(2)}`;
$('trigger-input').textContent = `${Math.round(preview.triggers[0]*100)}% / ${Math.round(preview.triggers[1]*100)}%`;
const moving = preview.axes.some(n => Math.abs(n) > .15) || preview.triggers.some(n => n > .15) || preview.shoulders.some(Boolean);
$('input-status').textContent = inputMessage || (source.value === 'touch'
? 'Touch mode: the physical gamepad is ignored. Choose Gamepad to use your controller.'
: moving ? (gate.active ? 'Controller input received โ€” sending movement commands.' : 'Controller input received. Release controls, then press Start.')
: 'Controller connected. Move a stick or trigger to check the indicators below.');
if (source.value === 'gamepad') {
for (const [id, offset] of [['look',0],['body',2]] as const) {
const el = $(id), puck = el.querySelector<HTMLElement>('.puck')!;
const radius = el.clientWidth * .34;
puck.style.transform = `translate(${preview.axes[offset]*radius}px,${preview.axes[offset+1]*radius}px)`;
}
for (const [name, active] of [['left-out',preview.triggers[0]>.15],['right-out',preview.triggers[1]>.15],['left-in',preview.shoulders[0]],['right-in',preview.shoulders[1]]] as const) {
document.querySelector(`[data-hold="${name}"]`)?.classList.toggle('held',active);
}
}
const values = motion?.current;
$('height').textContent = values ? `${(values[2]*1000).toFixed(0)} mm` : 'โ€”';
$('left-angle').textContent = values ? `${(values[5]*180/Math.PI).toFixed(0)}ยฐ` : 'โ€”';
$('right-angle').textContent = values ? `${(values[4]*180/Math.PI).toFixed(0)}ยฐ` : 'โ€”';
frame = requestAnimationFrame(render);
}
frame = requestAnimationFrame(render);
handle.onLeave(() => {
leaving = true; gate.pause('Leaving'); clearInput();
worker?.terminate(); worker = null; cancelAnimationFrame(frame); abort.abort(); offTheme();
reachy.removeEventListener('state', onState);
for (const event of ['disconnected','sessionStopped','error'] as const) reachy.removeEventListener(event, lost);
safelyReturnToPose(reachy);
});
}
void main().catch(error => {
console.error('[teleop] startup failed', error);
const root = document.querySelector('#root');
if (root) root.textContent = `Unable to open Teleop: ${String(error)}`;
window.parent.postMessage({source:'reachy-mini',type:'embed:error',version:1,message:String(error),fatal:true}, location.origin);
});