/* Control - mode switching, arbitration, driving, preset motions. */ import { store, socket, post, command, toast, num, confirmDialog } from '../core.js'; import { el, card, pageHead, button, note, badge, table, range, keyHint, kv, setChildren, } from '../ui.js'; export default { id: 'control', label: 'Control', icon: 'control', async render() { const spec = store.spec; const root = el('div.stack'); root.appendChild(pageHead( 'Control', 'Motion mode, drive input and preset motions. Keep the physical remote controller within reach — it outranks this dashboard.', )); /* ================================================================== Motion mode ================================================================== */ const modeTiles = el('div.stack'); const modeButtons = new Map(); for (const group of spec?.mode_groups || []) { const inGroup = (spec?.modes || []).filter((m) => m.group === group.key); if (!inGroup.length) continue; const grid = el('div.tile-grid'); for (const mode of inGroup) { const tile = el('button.tile', { type: 'button', 'aria-pressed': 'false', title: mode.desc, onclick: () => setMode(mode), }, el('div.row.between', {}, el('span.tile-name', { text: mode.label }), mode.danger ? badge('risk', 'critical') : null, ), el('span.tile-meta', { text: `${mode.id} · ${mode.value}` }), el('span', { text: mode.desc, style: { fontSize: '11px', color: 'var(--text-3)', lineHeight: '1.4' }, }), ); modeButtons.set(mode.id, tile); grid.appendChild(tile); } modeTiles.appendChild(el('div', {}, el('div', { text: group.label, style: { fontSize: '11px', textTransform: 'uppercase', letterSpacing: '.07em', color: 'var(--text-3)', fontWeight: '600', margin: '2px 0 8px' }, }), grid, )); } async function setMode(mode) { if (mode.danger) { const releases = ['PASSIVE_DEFAULT', 'ZERO_TORQUE_DEFAULT'].includes(mode.id); const ok = await confirmDialog( `Switch to ${mode.label}?`, releases ? 'This removes all joint holding force. A free-standing robot will collapse. ' + 'Only continue if the robot is supported or already seated.' : `${mode.desc} Make sure the area around the robot is clear before continuing.`, { confirmLabel: `Yes, switch to ${mode.label}` }, ); if (!ok) return; } await command('/api/mode', { mode: mode.id, confirmed: true }, { successTitle: `Mode: ${mode.label}` }); } const modeCard = card('Motion mode', { sub: `SetMcAction · ${(spec?.modes || []).length} modes`, }, modeTiles, el('div', { style: { marginTop: '12px' } }, note('Stable stand is the prerequisite for preset motions and for walking. ' + 'Joint control mode is required before direct joint commands take effect.', 'info'), ), ); /* ================================================================== Input source arbitration ================================================================== */ const dash = spec?.dashboard_input_source || { name: 'x2_dashboard', priority: 30, timeout: 1000 }; const sourceStatus = el('div'); const registerBtn = button('Announce the dashboard again', async () => { await command('/api/input-source', dash, { successTitle: 'Dashboard announced' }); }, { size: 'sm' }); const arbitrationRows = [ ...(spec?.builtin_input_sources || []), { name: dash.name, priority: dash.priority, timeout: dash.timeout, desc: 'This dashboard' }, ].sort((a, b) => b.priority - a.priority); const sourceCard = card('Who is allowed to drive', { sub: 'Control arbitration' }, el('div.stack', {}, sourceStatus, el('p', { style: { margin: '0', fontSize: '13px', color: 'var(--text-2)', lineHeight: '1.6' }, text: 'Several things can send movement commands to this robot at once — the handheld ' + 'remote, the mobile app, VR teleoperation, and this dashboard. The robot listens ' + 'to whichever one is actively sending and has the highest priority.', }), el('p', { style: { margin: '0', fontSize: '13px', color: 'var(--text-2)', lineHeight: '1.6' }, text: 'The robot ignores commands from a sender it has never heard of, so the dashboard ' + 'introduces itself automatically as soon as it connects. That is all "announcing" ' + 'means — it does not take control away from anyone. You only need the button below ' + 'if the robot restarts and forgets.', }), table( [ { key: 'name', label: 'Sender' }, { key: 'priority', label: 'Priority', align: 'right' }, { key: 'desc', label: 'What it is' }, ], arbitrationRows, ), note(`This dashboard sits at priority ${dash.priority} on purpose — below the handheld ` + 'remote (80) and the mobile app (60). If someone picks up the remote while you are ' + 'driving, the remote wins immediately.', 'info'), el('div.row', {}, registerBtn), ), ); root.appendChild(el('div.grid.cols-2', {}, modeCard, sourceCard)); /* ================================================================== Drive ================================================================== */ const limits = spec?.velocity_limits || { forward: { max: 0.8 }, lateral: { max: 0.7 }, angular: { max: 0.8 }, }; const thresholds = spec?.velocity_thresholds || {}; let scale = 0.5; let vector = { forward: 0, lateral: 0, angular: 0 }; let strafeMode = false; const knob = el('div.joystick-knob'); const pad = el('div.joystick', { dataset: { active: 'false' } }, el('div.joystick-ring'), el('span.joystick-label.n', { text: 'fwd' }), el('span.joystick-label.s', { text: 'back' }), el('span.joystick-label.w', { text: 'left' }), el('span.joystick-label.e', { text: 'right' }), knob, ); const readoutHost = el('div'); const strafeToggle = el('div.segmented'); for (const [label, value] of [['Turn', false], ['Strafe', true]]) { const btn = el('button', { type: 'button', text: label, 'aria-pressed': String(value === strafeMode) }); btn.addEventListener('click', () => { strafeMode = value; for (const b of strafeToggle.children) b.setAttribute('aria-pressed', String(b === btn)); setVector(0, 0); }); strafeToggle.appendChild(btn); } const scaleRange = range({ min: 0.1, max: 1, step: 0.05, value: scale, precision: 2, onInput: (v) => { scale = v; }, }); function setVector(nx, ny) { // nx / ny are -1..1 in pad space; y is inverted so up is forward. const forward = -ny * (limits.forward.max ?? 0.8) * scale; const secondary = nx * ((strafeMode ? limits.lateral.max : limits.angular.max) ?? 0.7) * scale; vector = { forward: Number(forward.toFixed(3)), lateral: strafeMode ? Number(secondary.toFixed(3)) : 0, // A joystick pushed right should turn right, i.e. clockwise, which is a // negative yaw rate under the documented counter-clockwise-positive sign. angular: strafeMode ? 0 : Number((-secondary).toFixed(3)), }; knob.style.transform = `translate(${nx * 62}px, ${ny * 62}px)`; socket.sendThrottled('velocity', vector); paintReadout(); } function paintReadout() { const s = store.state; const measured = s?.velocity || {}; const belowThreshold = (axis, value) => { const limit = thresholds[axis]; return limit !== undefined && Math.abs(value) > 0 && Math.abs(value) < limit; }; readoutHost.replaceChildren( kv([ ['Forward cmd', `${num(vector.forward, 2)} m/s`], ['Lateral cmd', `${num(vector.lateral, 2)} m/s`], ['Yaw cmd', `${num(vector.angular, 2)} rad/s`], ['Forward now', `${num(measured.forward, 2)} m/s`], ['Yaw now', `${num(measured.angular, 2)} rad/s`], ]), ); const warnings = []; if (belowThreshold('forward', vector.forward)) { warnings.push(`Forward command is below the ${thresholds.forward} m/s activation threshold — the robot will not start moving.`); } if (belowThreshold('angular', vector.angular)) { warnings.push(`Yaw command is below the ${thresholds.angular} rad/s activation threshold.`); } if (belowThreshold('lateral', vector.lateral)) { warnings.push(`Lateral command is below the ${thresholds.lateral} m/s activation threshold.`); } for (const text of warnings) readoutHost.appendChild(note(text, 'warning', '⚠')); } /* Pointer drive */ let pointerId = null; function padPoint(event) { const rect = pad.getBoundingClientRect(); const cx = rect.left + rect.width / 2; const cy = rect.top + rect.height / 2; const radius = rect.width / 2 - 28; let dx = (event.clientX - cx) / radius; let dy = (event.clientY - cy) / radius; const magnitude = Math.hypot(dx, dy); if (magnitude > 1) { dx /= magnitude; dy /= magnitude; } return [dx, dy]; } pad.addEventListener('pointerdown', (event) => { pointerId = event.pointerId; pad.setPointerCapture(pointerId); pad.dataset.active = 'true'; setVector(...padPoint(event)); }); pad.addEventListener('pointermove', (event) => { if (event.pointerId !== pointerId) return; setVector(...padPoint(event)); }); const releasePad = (event) => { if (event.pointerId !== pointerId) return; pointerId = null; pad.dataset.active = 'false'; setVector(0, 0); }; pad.addEventListener('pointerup', releasePad); pad.addEventListener('pointercancel', releasePad); /* Keyboard drive */ const held = new Set(); const KEYS = { KeyW: 'up', ArrowUp: 'up', KeyS: 'down', ArrowDown: 'down', KeyA: 'left', ArrowLeft: 'left', KeyD: 'right', ArrowRight: 'right', }; function applyKeys() { const nx = (held.has('right') ? 1 : 0) - (held.has('left') ? 1 : 0); const ny = (held.has('down') ? 1 : 0) - (held.has('up') ? 1 : 0); setVector(nx, ny); } const onKeyDown = (event) => { if (['INPUT', 'TEXTAREA', 'SELECT'].includes(event.target.tagName)) return; const dir = KEYS[event.code]; if (!dir) return; event.preventDefault(); if (held.has(dir)) return; held.add(dir); applyKeys(); }; const onKeyUp = (event) => { const dir = KEYS[event.code]; if (!dir) return; held.delete(dir); applyKeys(); }; // A lost window must not leave the robot walking. const onBlur = () => { held.clear(); setVector(0, 0); }; document.addEventListener('keydown', onKeyDown); document.addEventListener('keyup', onKeyUp); window.addEventListener('blur', onBlur); const driveCard = card('Drive', { sub: 'McLocomotionVelocity · /aima/mc/locomotion/velocity' }, el('div.joystick-wrap', {}, pad, el('div.joystick-readout', {}, el('div.row.between', {}, el('span', { text: 'Horizontal axis', style: { fontSize: '12px', color: 'var(--text-2)' } }), strafeToggle, ), el('div', {}, el('div', { text: 'Speed scale', style: { fontSize: '12px', color: 'var(--text-2)', marginBottom: '4px' } }), scaleRange, ), readoutHost, ), ), el('div.stack', { style: { marginTop: '14px', gap: '8px' } }, keyHint(['W', 'A', 'S', 'D'], 'or arrow keys to drive'), keyHint(['Space'], 'emergency stop — zeroes velocity immediately'), note('Velocity is republished continuously while you hold the stick. If this browser stops ' + 'sending for half a second the server zeroes the command automatically.', 'info'), ), ); root.appendChild(driveCard); /* ================================================================== Preset motions ================================================================== */ const presetHost = el('div.stack'); const groups = spec?.preset_groups || []; const presets = spec?.presets || []; for (const group of groups) { const inGroup = presets.filter((p) => p.group === group.key); if (!inGroup.length) continue; const grid = el('div.tile-grid'); for (const preset of inGroup) { grid.appendChild(el('button.tile', { type: 'button', onclick: async (event) => { const node = event.currentTarget; node.disabled = true; await command('/api/preset', { key: preset.key }, { successTitle: preset.label }); setTimeout(() => { node.disabled = false; }, 900); }, }, el('span.tile-name', { text: preset.label }), el('span.tile-meta', { text: `${preset.side} · ${preset.enum}` }), el('span.tile-meta', { text: `motion ${preset.motion} · area ${preset.area}`, style: { opacity: '.7' }, }), )); } presetHost.appendChild(el('div', {}, el('div', { text: group.label, style: { fontSize: '11px', textTransform: 'uppercase', letterSpacing: '.07em', color: 'var(--text-3)', fontWeight: '600', margin: '2px 0 8px' }, }), grid, )); } root.appendChild(card('Preset motions', { sub: `SetMcPresetMotion · ${presets.length} actions` }, note('All preset motions require Stable stand mode and clear space around the robot.', 'warning', '⚠'), el('div', { style: { marginTop: '12px' } }, presetHost), )); /* ================================================================== Live binding ================================================================== */ function paintState() { const s = store.state; if (!s) return; for (const [id, tile] of modeButtons) { tile.setAttribute('aria-pressed', String(id === s.mode)); } const registered = s.source_registered; const winner = s.input_source; setChildren(sourceStatus, el('div.row', {}, badge(registered ? 'Dashboard can drive' : 'Not announced yet', registered ? 'good' : 'warning'), winner && winner !== dash.name ? badge(`${winner} is driving`, 'accent') : null, ), registered ? null : el('div', { style: { marginTop: '8px' } }, note('The robot has not acknowledged the dashboard yet. This normally clears within a ' + 'second or two of connecting; if it does not, use the button below.', 'warning', '⚠'), ), ); const driveable = (spec?.driveable_modes || []).includes(s.mode); const canDrive = driveable && registered; pad.style.opacity = canDrive ? '1' : '.45'; pad.style.pointerEvents = canDrive ? 'auto' : 'none'; pad.title = canDrive ? '' : !driveable ? `Mode ${s.mode} does not accept velocity — switch to Stable stand or Walk` : 'Register an input source to drive'; } paintState(); paintReadout(); let last = 0; const unsubscribe = store.on('state', () => { const now = Date.now(); if (now - last < 400) return; last = now; paintState(); paintReadout(); }); return { node: root, dispose: () => { unsubscribe(); document.removeEventListener('keydown', onKeyDown); document.removeEventListener('keyup', onKeyUp); window.removeEventListener('blur', onBlur); // Leaving the tab must not leave a velocity command standing. socket.send('velocity', { forward: 0, lateral: 0, angular: 0 }); }, }; }, };