2026-08-13 16:23:18 +04:00

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 });
},
};
},
};