439 lines
16 KiB
JavaScript
439 lines
16 KiB
JavaScript
/* 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 });
|
|
},
|
|
};
|
|
},
|
|
};
|