This commit is contained in:
legop3
2025-11-17 21:18:43 -05:00
parent 2303fd3c7b
commit dc6a4b3c3a
22 changed files with 1318 additions and 493 deletions
+6 -9
View File
@@ -1,6 +1,5 @@
import { useMemo } from 'react';
import { useDriveControl } from '../context/DriveControlContext.jsx';
import { useSocket } from '../context/SocketContext.jsx';
import { useControlSystem } from '../controls/index.js';
import AuthPanel from './AuthPanel.jsx';
import AdminPanel from './AdminPanel.jsx';
@@ -13,8 +12,10 @@ const manualTabs = [
];
export default function AdvancedSettings() {
const { roverId, sendOiCommand } = useDriveControl();
const socket = useSocket();
const {
state: { roverId },
actions: { sendOiCommand, setSensorStream },
} = useControlSystem();
const canControl = Boolean(roverId);
const sensorButtons = useMemo(
@@ -27,11 +28,7 @@ export default function AdvancedSettings() {
const handleSensorToggle = (enable) => {
if (!roverId) return;
socket.emit('command', {
roverId,
type: 'sensorStream',
data: { sensorStream: { enable } },
});
setSensorStream(enable);
};
return (
+13 -10
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react';
import { useDriveControl } from '../context/DriveControlContext.jsx';
import { useControlSystem } from '../controls/index.js';
const SLIDER_THROTTLE_MS = 150;
@@ -9,14 +9,17 @@ function formatDegrees(value) {
}
export default function CameraServoPanel() {
const { roverId, servo } = useDriveControl();
const config = servo?.config;
const enabled = Boolean(roverId && servo?.enabled && config);
const {
state: { roverId, camera },
actions: { setServoAngle, nudgeServo, goServoHome },
} = useControlSystem();
const config = camera?.config;
const enabled = Boolean(roverId && camera?.enabled && config);
const min = typeof config?.minAngle === 'number' ? config.minAngle : -30;
const max = typeof config?.maxAngle === 'number' ? config.maxAngle : 30;
const value =
typeof servo?.angle === 'number'
? servo.angle
typeof camera?.angle === 'number'
? camera.angle
: typeof config?.homeAngle === 'number'
? config.homeAngle
: (min + max) / 2;
@@ -52,7 +55,7 @@ export default function CameraServoPanel() {
clearTimeout(throttleRef.current);
}
throttleRef.current = setTimeout(() => {
servo.setAngle(next);
setServoAngle(next);
}, SLIDER_THROTTLE_MS);
};
@@ -69,12 +72,12 @@ export default function CameraServoPanel() {
if (throttleRef.current) {
clearTimeout(throttleRef.current);
}
servo.setAngle(pendingAngle);
setServoAngle(pendingAngle);
};
const handleNudge = (direction) => {
const delta = step * direction;
servo.nudge(delta);
nudgeServo(delta);
};
return (
@@ -112,7 +115,7 @@ export default function CameraServoPanel() {
<button
type="button"
className="flex-1 rounded bg-slate-700 px-1 py-0.5 text-slate-100 hover:bg-slate-600"
onClick={() => servo.goHome()}
onClick={() => goServoHome()}
>
Center
</button>
+19 -4
View File
@@ -1,8 +1,11 @@
import { useDriveControl } from '../context/DriveControlContext.jsx';
import { useTelemetryFrame } from '../context/TelemetryContext.jsx';
import { useControlSystem } from '../controls/index.js';
export default function DrivePanel() {
const { roverId, runStartDockFull, seekDock } = useDriveControl();
const {
state: { roverId },
actions,
} = useControlSystem();
const frame = useTelemetryFrame(roverId);
const sensors = frame?.sensors || {};
const drivingMode = (sensors.oiMode?.label || '').toLowerCase() === 'full';
@@ -11,6 +14,18 @@ export default function DrivePanel() {
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');
};
return (
<section className="rounded-sm bg-[#242a32] p-1 text-base text-slate-100">
<div className="flex items-center justify-between text-sm text-slate-300">
@@ -23,7 +38,7 @@ export default function DrivePanel() {
description="Press to enable driving mode, then start moving. The headlamps should illuminate."
statuses={[{ label: drivingMode ? 'Ready!' : 'Not Ready!', active: drivingMode }]}
tone="emerald"
onClick={runStartDockFull}
onClick={handleStartDrive}
disabled={!roverId}
/>
<ActionCard
@@ -34,7 +49,7 @@ export default function DrivePanel() {
{ label: charging ? 'Charging!' : 'Not Charging!', active: charging },
]}
tone="indigo"
onClick={seekDock}
onClick={handleDock}
disabled={!roverId}
/>
</div>
+117 -87
View File
@@ -1,114 +1,132 @@
import { useCallback } from 'react';
import { useCallback, useRef } from 'react';
import { Joystick } from 'react-joystick-component';
import { useDriveControl } from '../context/DriveControlContext.jsx';
import { useControlSystem } from '../controls/index.js';
import { clampUnit } from '../controls/controlMath.js';
import DriveModeToggle from './controls/DriveModeToggle.jsx';
function clampUnit(value = 0) {
if (typeof value !== 'number' || Number.isNaN(value)) return 0;
return Math.max(-1, Math.min(1, value));
}
const SOURCE = 'mobile-joystick';
const AUX_BUTTONS = [
{ id: 'main-forward', label: 'Main +', values: { main: 127 }, hold: true },
{ id: 'main-reverse', label: 'Main -', values: { main: -127 }, hold: true },
{ id: 'side-forward', label: 'Side +', values: { side: 127 }, hold: true },
{ id: 'side-reverse', label: 'Side -', values: { side: -70 }, hold: true },
{ id: 'vacuum-fast', label: 'Vacuum Max', values: { vacuum: 127 }, hold: true },
{ id: 'vacuum-slow', label: 'Vacuum Low', values: { vacuum: 50 }, hold: true },
{ id: 'all-forward', label: 'All +', values: { main: 127, side: 127, vacuum: 127 }, hold: true },
{ id: 'stop-all', label: 'Stop', values: { main: 0, side: 0, vacuum: 0 }, hold: false },
];
function MobileJoystick() {
const { roverId, driveWithVector, stopMotors } = useDriveControl();
function MobileJoystickPanel({ layout }) {
const {
state: { roverId },
actions: { setDriveVector, registerInputState, stopAllMotion },
} = useControlSystem();
const disabled = !roverId;
const handleMove = useCallback(
(event = {}) => {
if (disabled) return;
const x = clampUnit(event.x ?? 0);
const y = clampUnit(event.y ?? 0);
driveWithVector({ x, y });
const vector = {
x: clampUnit(event.x ?? 0),
y: clampUnit(event.y ?? 0),
boost: Boolean(event.shiftKey),
};
setDriveVector(vector, { source: SOURCE });
registerInputState(SOURCE, { vector, lastEvent: 'move' });
},
[disabled, driveWithVector],
[disabled, registerInputState, setDriveVector],
);
const handleStop = useCallback(() => {
if (disabled) return;
driveWithVector({ x: 0, y: 0 });
}, [disabled, driveWithVector]);
const zero = { x: 0, y: 0, boost: false };
setDriveVector(zero, { source: SOURCE });
registerInputState(SOURCE, { vector: zero, lastEvent: 'stop' });
}, [disabled, registerInputState, setDriveVector]);
return (
<section className="rounded-sm bg-[#242a32] p-1 text-sm text-slate-100">
<div className="text-xs text-slate-400">Joystick</div>
<div className="mt-1 flex items-center justify-center">
<Joystick
size={120}
baseColor="#0f172a"
stickColor="#38bdf8"
throttle={75}
move={handleMove}
stop={handleStop}
<DriveModeToggle size="compact" />
<div className="mt-1 flex flex-col gap-1">
<div className="flex items-center justify-center">
<Joystick
size={layout === 'landscape' ? 140 : 120}
baseColor="#0f172a"
stickColor="#38bdf8"
throttle={60}
move={handleMove}
stop={handleStop}
disabled={disabled}
/>
</div>
<p className="text-[0.7rem] text-slate-400">Drag to steer · Release to stop sending drive commands.</p>
<button
type="button"
onClick={stopAllMotion}
disabled={disabled}
/>
className="rounded-sm bg-red-600 px-1 py-1 text-xs font-semibold uppercase tracking-wide text-red-100 disabled:opacity-40"
>
Panic Stop
</button>
</div>
<p className="mt-1 text-xs text-slate-400">
Drag to drive. Release to stop sending drive commands.
</p>
<button
type="button"
onClick={stopMotors}
disabled={disabled}
className="mt-1 w-full rounded-sm bg-black/40 px-1 py-1 text-xs text-slate-200 disabled:opacity-40"
>
Panic Stop
</button>
</section>
);
}
function AuxMotorControls() {
const { roverId, setAuxMotors } = useDriveControl();
function AuxMotorPanel({ orientation }) {
const {
state: { roverId },
actions: { setAuxMotors },
} = useControlSystem();
const activeRef = useRef(null);
const disabled = !roverId;
const runAllForward = () => {
if (disabled) return;
setAuxMotors({ main: 127, side: 127, vacuum: 127 });
};
const handlePress = useCallback(
(button) => {
if (disabled) return;
if (button.hold === false) {
setAuxMotors(button.values);
activeRef.current = null;
return;
}
activeRef.current = button.id;
setAuxMotors(button.values);
},
[disabled, setAuxMotors],
);
const stopAll = () => {
if (disabled) return;
setAuxMotors({ main: 0, side: 0, vacuum: 0 });
};
const handleRelease = useCallback(
(button) => {
if (disabled || button.hold === false) return;
if (activeRef.current === button.id) {
activeRef.current = null;
setAuxMotors({ main: 0, side: 0, vacuum: 0 });
}
},
[disabled, setAuxMotors],
);
const auxButtons = [
{ label: 'Main +', values: { main: 127 } },
{ label: 'Main -', values: { main: -127 } },
{ label: 'Side +', values: { side: 127 } },
{ label: 'Side -', values: { side: -127 } },
{ label: 'Vacuum Max', values: { vacuum: 127 } },
{ label: 'Vacuum Off', values: { vacuum: 0 } },
];
const buttonClasses = orientation === 'landscape' ? 'text-xs' : 'text-[0.75rem]';
return (
<section className="rounded-sm bg-[#242a32] p-1 text-sm text-slate-100">
<div className="text-xs text-slate-400">Aux motors</div>
<div className="mt-1 flex flex-wrap gap-1">
<button
type="button"
onClick={runAllForward}
disabled={disabled}
className="flex-1 rounded-sm bg-black/40 px-1 py-1 text-xs text-slate-200 disabled:opacity-40"
>
All forward
</button>
<button
type="button"
onClick={stopAll}
disabled={disabled}
className="flex-1 rounded-sm bg-black/40 px-1 py-1 text-xs text-slate-200 disabled:opacity-40"
>
Stop all
</button>
</div>
<div className="mt-1 grid grid-cols-2 gap-1 text-[0.75rem]">
{auxButtons.map((btn) => (
<p className="text-xs text-slate-400">Aux motors · hold to run</p>
<div className="mt-1 grid grid-cols-2 gap-1">
{AUX_BUTTONS.map((button) => (
<button
key={btn.label}
key={button.id}
type="button"
onClick={() => !disabled && setAuxMotors(btn.values)}
disabled={disabled}
className="rounded-sm bg-black/30 px-1 py-1 text-slate-200 disabled:opacity-30"
onPointerDown={(event) => {
event.preventDefault();
handlePress(button);
}}
onPointerUp={() => handleRelease(button)}
onPointerLeave={() => handleRelease(button)}
onPointerCancel={() => handleRelease(button)}
className={`rounded-sm bg-black/40 px-1 py-1 text-left text-slate-100 disabled:opacity-30 ${buttonClasses}`}
>
{btn.label}
{button.label}
</button>
))}
</div>
@@ -116,17 +134,29 @@ function AuxMotorControls() {
);
}
export default function MobileControlsStack() {
export function MobileLandscapeAuxColumn() {
return (
<div className="flex flex-col gap-1 sm:flex-row">
<div className="flex-1">
<MobileJoystick />
</div>
<div className="flex-1">
<AuxMotorControls />
</div>
<div className="flex flex-col gap-1">
<AuxMotorPanel orientation="landscape" />
</div>
);
}
export { MobileJoystick, AuxMotorControls };
export function MobileLandscapeControlColumn() {
return (
<div className="flex flex-col gap-1">
<MobileJoystickPanel layout="landscape" />
</div>
);
}
export default function MobilePortraitControls() {
return (
<section className="rounded-sm bg-transparent">
<div className="grid grid-cols-1 gap-1 sm:grid-cols-2">
<AuxMotorPanel orientation="portrait" />
<MobileJoystickPanel layout="portrait" />
</div>
</section>
);
}
@@ -0,0 +1,66 @@
import { useState } from 'react';
import { useControlSystem } from '../../controls/index.js';
export default function DriveModeToggle({ size = 'default' }) {
const {
state: { roverId, mode },
actions,
} = useControlSystem();
const [pending, setPending] = useState(null);
const disabled = !roverId || pending !== null;
const handleDrive = async () => {
if (!roverId) return;
setPending('drive');
try {
actions.setMode('drive');
await actions.runMacro('drive-sequence');
} finally {
setPending(null);
}
};
const handleDock = async () => {
if (!roverId) return;
setPending('dock');
try {
actions.setMode('dock');
await actions.runMacro('seek-dock');
} finally {
setPending(null);
}
};
const pillClass =
size === 'compact'
? 'text-xs px-1 py-0.5'
: 'text-sm px-1.5 py-0.5';
const currentLabel = mode === 'dock' ? 'Dock mode' : 'Drive mode';
return (
<div className="rounded-sm bg-black/30 p-1 text-slate-100">
<div className="flex items-center justify-between">
<span className={`rounded-full bg-slate-800 ${pillClass}`}>{currentLabel}</span>
<span className="text-[0.65rem] text-slate-400">Rover {roverId ?? '—'}</span>
</div>
<div className="mt-1 grid grid-cols-2 gap-1 text-xs">
<button
type="button"
onClick={handleDrive}
disabled={disabled}
className={`rounded-sm bg-emerald-600 px-1 py-1 font-semibold uppercase tracking-wide text-emerald-50 disabled:opacity-40 ${size === 'compact' ? 'text-xs' : 'text-sm'}`}
>
Drive
</button>
<button
type="button"
onClick={handleDock}
disabled={disabled}
className={`rounded-sm bg-indigo-600 px-1 py-1 font-semibold uppercase tracking-wide text-indigo-50 disabled:opacity-40 ${size === 'compact' ? 'text-xs' : 'text-sm'}`}
>
Dock
</button>
</div>
</div>
);
}