new dock / drive / docking buttons and dialog

This commit is contained in:
legop3
2025-12-26 20:04:21 -05:00
parent 6301058f87
commit b9d7a62b0e
8 changed files with 355 additions and 144 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -11,8 +11,8 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" /> <meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
<title>Multi Roomba Rover</title> <title>Multi Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-Bfcf31QB.js"></script> <script type="module" crossorigin src="/assets/index-CL1kOB9m.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B88Ix9u6.css"> <link rel="stylesheet" crossorigin href="/assets/index-D2GpvMU5.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+11 -88
View File
@@ -5,40 +5,22 @@ import { useControlSystem } from '../controls/index.js';
import { formatKeyLabel } from '../controls/keymapUtils.js'; import { formatKeyLabel } from '../controls/keymapUtils.js';
import TopDownMap from './TopDownMap.jsx'; import TopDownMap from './TopDownMap.jsx';
import RoverRoster from './RoverRoster.jsx'; import RoverRoster from './RoverRoster.jsx';
import DriveDockAction, { useDriveDockState } from './DriveDockAction.jsx';
export default function ControlSummary() { export default function ControlSummary() {
const { session, requestControl } = useSession(); const { session, requestControl } = useSession();
const { const {
state: { roverId, keymap }, state: { roverId, keymap },
actions,
} = useControlSystem(); } = useControlSystem();
const frame = useTelemetryFrame(roverId); const frame = useTelemetryFrame(roverId);
const sensors = frame?.sensors || {}; const sensors = frame?.sensors || {};
const driveDockState = useDriveDockState(roverId);
const roster = session?.roster ?? []; const roster = session?.roster ?? [];
const [pending, setPending] = useState({}); const [pending, setPending] = useState({});
const canRequest = useMemo(() => session?.role && session.role !== 'spectator', [session?.role]); const canRequest = useMemo(() => session?.role && session.role !== 'spectator', [session?.role]);
const driving = useMemo( const hideInlineControls = driveDockState.docked && !driveDockState.driving;
() => (sensors.oiMode?.label || '').toLowerCase() === 'full',
[sensors.oiMode?.label],
);
const docked = Boolean(sensors.chargingSources?.homeBase);
const charging = Boolean(
sensors.chargingState?.label && sensors.chargingState.label.toLowerCase() !== 'not charging',
);
const handleStartDrive = () => {
if (!roverId) return;
actions.setMode('drive');
actions.runMacro('drive-sequence');
};
const handleDock = () => {
if (!roverId) return;
actions.setMode('dock');
actions.runMacro('seek-dock');
};
async function handleRequest(targetRoverId) { async function handleRequest(targetRoverId) {
if (!targetRoverId) return; if (!targetRoverId) return;
@@ -61,30 +43,10 @@ export default function ControlSummary() {
</div> </div>
</div> </div>
<div className="grid h-full grid-rows-[1fr_1fr_auto] gap-0.75"> <div className="grid h-full grid-rows-[1fr_1fr_auto] gap-0.75">
<ActionButton <div className="row-span-2">
title="Start Driving" <DriveDockAction layout="desktop" expand driveDockState={driveDockState} />
description="Enable driving mode, then begin driving." </div>
statuses={[{ label: driving ? 'Ready' : 'Press to enter driving mode', active: driving }]} {!hideInlineControls ? <InlineCameraTilt keymap={keymap} /> : null}
tone="emerald"
onClick={handleStartDrive}
disabled={!roverId}
keyAction="driveMacro"
keymap={keymap}
/>
<ActionButton
title="Dock and Charge"
description="Line up about a foot from the dock, then press to attempt auto-dock."
statuses={[
{ label: docked ? 'Docked' : 'Not docked', active: docked },
{ label: charging ? 'Charging' : 'Not charging', active: charging },
]}
tone="indigo"
onClick={handleDock}
disabled={!roverId}
keyAction="dockMacro"
keymap={keymap}
/>
<InlineCameraTilt keymap={keymap} />
</div> </div>
</div> </div>
<RoverRoster <RoverRoster
@@ -107,49 +69,6 @@ export default function ControlSummary() {
); );
} }
function ActionButton({ title, description, statuses, onClick, disabled, tone, keyAction, keymap }) {
const colors =
tone === 'indigo'
? 'bg-indigo-700 hover:bg-indigo-600 focus-visible:ring-indigo-400'
: 'bg-emerald-700 hover:bg-emerald-600 focus-visible:ring-emerald-400';
const keyLabel = keyAction ? formatKeyLabel(keymap?.[keyAction]?.[0]) : '';
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={`flex h-full w-full flex-col items-center justify-center gap-0.5 border border-slate-700 px-1 py-1 text-center text-white transition focus-visible:outline-none focus-visible:ring-1 ${colors} disabled:cursor-not-allowed disabled:opacity-50`}
>
<div className="flex items-center justify-center gap-1">
<span className="text-base font-semibold text-slate-100">{title}</span>
{keyLabel ? <KeyPill label={keyLabel} /> : null}
</div>
<p className="text-sm text-slate-300 text-center">{description}</p>
<div className="flex flex-wrap items-center justify-center gap-0.5">
{statuses.map((status) => (
<StatusPill key={status.label} active={status.active} label={status.label} />
))}
</div>
</button>
);
}
function KeyPill({ label }) {
return <span className="rounded border border-white/40 px-1 text-[0.7rem] text-white">{label}</span>;
}
function StatusPill({ label, active }) {
return (
<span
className={`rounded px-1.5 py-0.5 text-[0.7rem] font-semibold ${
active ? 'bg-emerald-600 text-white' : 'bg-red-500 text-white'
}`}
>
{label}
</span>
);
}
function InlineCameraTilt({ keymap }) { function InlineCameraTilt({ keymap }) {
const { const {
state: { roverId, camera }, state: { roverId, camera },
@@ -226,6 +145,10 @@ function InlineCameraTilt({ keymap }) {
); );
} }
function KeyPill({ label }) {
return <span className="rounded border border-white/40 px-1 text-[0.7rem] text-white">{label}</span>;
}
function formatDegrees(value) { function formatDegrees(value) {
if (typeof value !== 'number' || Number.isNaN(value)) return '—'; if (typeof value !== 'number' || Number.isNaN(value)) return '—';
return `${value > 0 ? '+' : ''}${value.toFixed(1)}°`; return `${value > 0 ? '+' : ''}${value.toFixed(1)}°`;
+281
View File
@@ -0,0 +1,281 @@
import { useMemo, useState } from 'react';
import { useControlSystem } from '../controls/index.js';
import { useTelemetryFrame } from '../context/TelemetryContext.jsx';
import { formatKeyLabel } from '../controls/keymapUtils.js';
export function deriveDriveDockState(frame) {
const sensors = frame?.sensors || {};
const oiLabel = sensors.oiMode?.label || 'Unknown';
const oiNormalized = oiLabel.toLowerCase();
const chargingLabel = sensors.chargingState?.label || '';
const docked = Boolean(sensors.chargingSources?.homeBase);
const charging = docked && chargingLabel.toLowerCase() !== 'not charging' && chargingLabel !== '';
const driving = oiNormalized === 'full';
const dockedNotCharging = docked && !charging;
const dockingInProgress = !docked && !charging && oiNormalized === 'passive';
return { driving, docked, charging, dockedNotCharging, dockingInProgress, oiLabel, chargingLabel };
}
export function useDriveDockState(roverId) {
const frame = useTelemetryFrame(roverId);
return useMemo(() => deriveDriveDockState(frame), [frame]);
}
function StatusPill({ label, active }) {
return (
<span
className={`rounded px-1.5 py-0.5 text-[0.75rem] font-semibold ${
active ? 'bg-emerald-600 text-white' : 'bg-red-600 text-white'
}`}
>
{label}
</span>
);
}
function KeyPill({ label }) {
if (!label) return null;
return <span className="rounded border border-white/40 px-1 text-[0.7rem] text-white">{label}</span>;
}
function DockModal({ instructions, onConfirm, onCancel, pending }) {
return (
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/70 p-1">
<div className="surface w-full max-w-md space-y-0.5 border border-indigo-700 bg-indigo-950/90 p-1 text-slate-100 shadow-2xl">
<div className="space-y-0.25">
<p className="text-lg font-semibold text-indigo-50">Dock Rover</p>
<p className="text-sm text-slate-200">{instructions.summary}</p>
<StepList steps={instructions.steps} tone="indigo" />
</div>
<div className="grid grid-cols-2 gap-0.5 text-sm">
<button
type="button"
onClick={onCancel}
className="bg-slate-700 px-0.5 py-1 font-semibold text-slate-100 transition hover:bg-slate-600"
>
Cancel
</button>
<button
type="button"
disabled={pending}
onClick={onConfirm}
className="bg-indigo-600 px-0.5 py-1 font-semibold text-indigo-50 transition hover:bg-indigo-500 disabled:opacity-50"
>
{pending ? 'Docking…' : 'Confirm Dock'}
</button>
</div>
</div>
</div>
);
}
export default function DriveDockAction({ layout = 'desktop', expand = false, driveDockState }) {
const isMobile = layout === 'mobile';
const {
state: { roverId, keymap },
actions,
} = useControlSystem();
const frame = useTelemetryFrame(roverId);
const state = driveDockState ?? deriveDriveDockState(frame);
const { driving, docked, charging, dockedNotCharging, dockingInProgress, oiLabel } = state;
const [pending, setPending] = useState(null);
const [confirmOpen, setConfirmOpen] = useState(false);
const [showModal, setShowModal] = useState(false);
const driveDisabled = !roverId || pending !== null;
const dockDisabled = !roverId || pending !== null;
const driveKeyLabel = formatKeyLabel(keymap?.driveMacro?.[0]);
const dockKeyLabel = formatKeyLabel(keymap?.dockMacro?.[0]);
const dockInstructions = {
summary: 'You are about to trigger an automatic docking attempt! To have a successful dock:',
steps: ['Line up the rover straight in front of the dock', 'Make sure the rover is about one foot or half a meter away from the dock', 'Press Confirm Dock to begin the docking process'],
};
const dockButtonCaption = 'Click this button to initate auto-dock.';
const startDriveInstructions = {
summary: 'You must enable driving mode before you can move the rover.',
steps: ['Press the keybind or tap this button to enable driving mode', 'Once ready (should be instant), this dialog will change, and you will be able to move'],
};
const handleReturnToDrive = async () => {
if (!roverId || pending) return;
setPending('drive');
try {
actions.setMode('drive');
await actions.runMacro('drive-sequence');
} catch (err) {
alert(err.message);
} finally {
setPending(null);
}
};
const handleStartDrive = async () => {
if (!roverId || pending) return;
setConfirmOpen(false);
setShowModal(false);
setPending('drive');
try {
actions.setMode('drive');
await actions.runMacro('drive-sequence');
} catch (err) {
alert(err.message);
} finally {
setPending(null);
}
};
const handleConfirmDock = async () => {
if (!roverId || pending) return;
setPending('dock');
try {
actions.setMode('dock');
await actions.runMacro('seek-dock');
} catch (err) {
alert(err.message);
} finally {
setPending(null);
setConfirmOpen(false);
setShowModal(false);
}
};
const handleOpenDock = () => {
if (dockDisabled) return;
setShowModal(true);
setConfirmOpen(true);
};
const baseCardClasses =
'flex w-full flex-col gap-0.5 border border-slate-700 px-0.75 py-0.75 text-slate-100 shadow';
const filledHeight = expand ? 'h-full flex-1' : '';
if (!driving && !dockingInProgress) {
return (
<button
type="button"
onClick={handleStartDrive}
disabled={driveDisabled}
className={`${baseCardClasses} ${filledHeight} items-center justify-between bg-emerald-800 hover:bg-emerald-700 text-center transition disabled:cursor-not-allowed disabled:opacity-60`}
>
<div className="space-y-0.25 w-full">
<div className="flex items-center justify-center gap-0.5">
<span className="text-base font-semibold text-emerald-50">Start Driving</span>
{!isMobile && driveKeyLabel ? <KeyPill label={driveKeyLabel} /> : null}
</div>
<p className="text-sm text-emerald-50/90">{startDriveInstructions.summary}</p>
<StepList steps={startDriveInstructions.steps} tone="emerald" />
</div>
<div className="flex w-full flex-col gap-0.5">
<StatusPill label={`OI: ${oiLabel}`} active={oiLabel.toLowerCase() === 'full'} />
<StatusPill label={docked ? 'Docked!' : 'Not docked'} active={docked} />
<StatusPill label={charging ? 'Charging!' : 'Not charging'} active={charging} />
</div>
</button>
);
}
if (dockingInProgress) {
const inProgressCopy = {
summary: 'Docking attempt in progress.',
steps: ['The rover should be slowly wiggling towards the dock', 'If it is obviously not working, press this button to enter driving mode and try again'],
};
return (
<button
type="button"
disabled={driveDisabled}
onClick={handleReturnToDrive}
className={`${baseCardClasses} ${filledHeight} items-center bg-amber-900 text-center transition hover:bg-amber-800 disabled:cursor-not-allowed disabled:opacity-50`}
>
<div className="space-y-0.25 w-full">
<div className="flex items-center justify-center gap-0.5">
<span className="text-base font-semibold text-amber-50">Docking in Progress</span>
</div>
<p className="text-sm text-amber-50/90">{inProgressCopy.summary}</p>
</div>
<div className="flex w-full flex-col gap-0.5">
<StatusPill label={docked ? 'Docked' : 'Not docked'} active={docked} />
<StatusPill label={charging ? 'Charging' : 'Not charging'} active={charging} />
</div>
<StepList steps={inProgressCopy.steps} tone="amber" />
</button>
);
}
return (
<>
<button
type="button"
disabled={dockDisabled}
onClick={handleOpenDock}
className={
isMobile
? 'flex w-full items-center justify-center border border-slate-700 bg-indigo-900 px-0.75 py-0.75 text-sm font-semibold text-indigo-50 shadow transition hover:bg-indigo-800 disabled:cursor-not-allowed disabled:opacity-50'
: `${baseCardClasses} ${filledHeight} items-center bg-indigo-900 text-center transition hover:bg-indigo-800 disabled:cursor-not-allowed disabled:opacity-50`
}
>
<div className="flex items-center justify-center gap-0.5 w-full">
<span className="text-base font-semibold text-indigo-50">Dock and Charge</span>
{!isMobile && dockKeyLabel ? <KeyPill label={dockKeyLabel} /> : null}
</div>
{!isMobile && (
<>
<p className="text-sm text-indigo-50/90">{dockButtonCaption}</p>
<div className="flex w-full flex-col gap-0.5">
<StatusPill label={docked ? 'Docked' : 'Not docked'} active={docked} />
<StatusPill label={charging ? 'Charging' : 'Not charging'} active={charging} />
{dockedNotCharging ? (
<StatusPill label="Not charging yet" active={false} />
) : null}
</div>
<div className="text-xs text-indigo-100/80">
Line up straight, about 1 foot away, before docking.
</div>
</>
)}
</button>
{showModal || (!isMobile && confirmOpen) ? (
<DockModal
instructions={dockInstructions}
pending={pending === 'dock'}
onCancel={() => {
setShowModal(false);
setConfirmOpen(false);
}}
onConfirm={handleConfirmDock}
/>
) : null}
</>
);
}
function StepList({ steps, tone = 'emerald' }) {
const container =
tone === 'emerald'
? 'bg-emerald-900/60 text-emerald-50/90 border-emerald-700/60'
: tone === 'amber'
? 'bg-amber-900/60 text-amber-50/90 border-amber-700/60'
: 'bg-indigo-900/60 text-indigo-50/90 border-indigo-700/60';
const numberColor =
tone === 'emerald'
? 'text-emerald-200'
: tone === 'amber'
? 'text-amber-200'
: 'text-indigo-200';
return (
<div className={`space-y-0.5 rounded border px-0.5 py-0.35 text-left ${container}`}>
{steps.map((step, idx) => (
<div key={step} className="text-[0.85rem] leading-snug break-words">
<div className="flex items-start">
<span className={`mr-0.35 align-top text-[0.75rem] font-semibold ${numberColor}`}>{idx + 1}.</span>
<span className="align-top">{step}</span>
</div>
{idx < steps.length - 1 ? <div className="mt-0.35 h-px bg-white/10" /> : null}
</div>
))}
</div>
);
}
+48 -41
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { useControlSystem } from '../controls/index.js'; import { useControlSystem } from '../controls/index.js';
import { clampUnit } from '../controls/controlMath.js'; import { clampUnit } from '../controls/controlMath.js';
import DriveModeToggle from './controls/DriveModeToggle.jsx'; import DriveDockAction, { useDriveDockState } from './DriveDockAction.jsx';
const SOURCE = 'mobile-joystick'; const SOURCE = 'mobile-joystick';
const JOYSTICK_RADIUS = 80; const JOYSTICK_RADIUS = 80;
@@ -134,8 +134,10 @@ function MobileJoystickPanel({ layout }) {
const { const {
state: { roverId, camera }, state: { roverId, camera },
pipeline, pipeline,
actions: { setDriveVector, registerInputState, stopAllMotion, setServoAngle, toggleNightVision }, actions: { setDriveVector, registerInputState, setServoAngle, toggleNightVision },
} = useControlSystem(); } = useControlSystem();
const driveDockState = useDriveDockState(roverId);
const dockedNotDriving = driveDockState.docked && !driveDockState.driving;
const disabled = !roverId; const disabled = !roverId;
const cameraConfig = camera?.config; const cameraConfig = camera?.config;
const cameraEnabled = Boolean(roverId && camera?.enabled && cameraConfig); const cameraEnabled = Boolean(roverId && camera?.enabled && cameraConfig);
@@ -196,47 +198,52 @@ function MobileJoystickPanel({ layout }) {
setServoAngle(next); setServoAngle(next);
}; };
const containerClass = `flex flex-col gap-0.5 text-slate-100 ${
dockedNotDriving ? 'h-screen max-h-screen' : ''
}`;
return ( return (
<div className="flex flex-col gap-0.5 text-slate-100"> <div className={containerClass}>
<DriveDockAction layout="mobile" expand={dockedNotDriving} driveDockState={driveDockState} />
<DriveModeToggle size="compact" /> {!dockedNotDriving ? (
{nightVisionAvailable && ( <>
<button {nightVisionAvailable && (
type="button" <button
onClick={() => toggleNightVision()} type="button"
disabled={disabled} onClick={() => toggleNightVision()}
className="bg-amber-600 px-0.5 py-1 text-sm font-semibold text-amber-50 transition hover:bg-amber-500 disabled:opacity-40" disabled={disabled}
> className="bg-amber-600 px-0.5 py-1 text-sm font-semibold text-amber-50 transition hover:bg-amber-500 disabled:opacity-40"
Toggle Night Vision >
</button> Toggle Night Vision
)} </button>
{cameraEnabled && ( )}
<div className="bg-zinc-950 p-0.5 text-xs"> {cameraEnabled && (
<div className="flex items-center justify-between text-[0.75rem] text-slate-400"> <div className="bg-zinc-950 p-0.5 text-xs">
<span>Camera Tilt</span> <div className="flex items-center justify-between text-[0.75rem] text-slate-400">
<span className="font-mono text-slate-200">{cameraValue.toFixed(1)}°</span> <span>Camera Tilt</span>
</div> <span className="font-mono text-slate-200">{cameraValue.toFixed(1)}°</span>
<input </div>
type="range" <input
min={cameraMin} type="range"
max={cameraMax} min={cameraMin}
step={0.5} max={cameraMax}
value={cameraValue} step={0.5}
onChange={handleCameraSlider} value={cameraValue}
className="mt-0.5 w-full accent-cyan-400" onChange={handleCameraSlider}
className="mt-0.5 w-full accent-cyan-400"
/>
</div>
)}
<FloatingJoystick
disabled={disabled}
layout={layout}
radius={joystickRadius}
onMove={handleMove}
onStop={handleStop}
/> />
</div> </>
)} ) : null}
<FloatingJoystick {/* Panic stop button can be re-enabled here if needed */}
disabled={disabled}
layout={layout}
radius={joystickRadius}
onMove={handleMove}
onStop={handleStop}
/>
{/* <button type="button" onClick={stopAllMotion} disabled={disabled} className="button-danger w-full disabled:opacity-40">
Panic Stop
</button> */}
</div> </div>
); );