mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-19 02:50:46 -04:00
big boy webui new new new new new 100 files changed 80 years
This commit is contained in:
@@ -1,10 +1,14 @@
|
||||
// Aux Column
|
||||
// Purpose: Assembles the mobile auxiliary controls column, which is the left column by default.
|
||||
// Scope: Owns mobile aux/camera/headlight/laser/horn wiring while reusing desktop variation components where intended.
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { FaBullhorn, FaCrosshairs, FaLightbulb } from 'react-icons/fa';
|
||||
import './mobileControls.css';
|
||||
import { useControlActions, useControlSelector } from '../../controls/index.js';
|
||||
import { useTelemetrySelector } from '../../context/TelemetryContext.jsx';
|
||||
import { dockTelemetryEqual, selectDockTelemetry } from '../../context/telemetryViews.js';
|
||||
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
|
||||
import useCanControlRover from '../../hooks/useCanControlRover.js';
|
||||
import HornControl from '../HornControl/index.jsx';
|
||||
import GPIOToggleControl from '../GPIOToggleControl/index.jsx';
|
||||
import { AUX_ZERO } from './constants.js';
|
||||
@@ -27,7 +31,17 @@ function AuxColumnContent() {
|
||||
const pipelineHorn = useControlSelector((control) => control.pipeline?.horn);
|
||||
const { setServoAngle, setHeadlight, setLaser, setAuxMotors, startHorn, stopHorn } = useControlActions();
|
||||
const dockAssist = useManualDockAssist();
|
||||
const disabled = !roverId;
|
||||
const dockTelemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
|
||||
const canControl = useCanControlRover(roverId);
|
||||
// Turn ownership is the common mutation boundary for every control in this
|
||||
// column. Dock and OI state are applied separately only where the hardware
|
||||
// command itself depends on the Roomba being able to drive.
|
||||
const controlsDisabled = !roverId || !canControl;
|
||||
const docked = Boolean(dockTelemetry?.homeBase);
|
||||
const drivingMode = String(dockTelemetry?.oiModeLabel || '').toLowerCase() === 'full';
|
||||
const vacuumDisabled = controlsDisabled
|
||||
|| docked
|
||||
|| (!drivingMode && !dockAssist.active);
|
||||
const activeAuxButtonRef = useRef(null);
|
||||
const cameraConfig = camera?.config;
|
||||
const cameraEnabled = Boolean(roverId && camera?.enabled && cameraConfig);
|
||||
@@ -43,7 +57,7 @@ function AuxColumnContent() {
|
||||
: typeof cameraConfig?.homeAngle === 'number'
|
||||
? cameraConfig.homeAngle
|
||||
: (cameraMin + cameraMax) / 2;
|
||||
const cameraDisabled = Boolean(disabled || dockAssist.cameraLocked);
|
||||
const cameraDisabled = Boolean(controlsDisabled || dockAssist.cameraLocked);
|
||||
/*
|
||||
The mobile tilt track shares the same precision flag as desktop tilt. This
|
||||
keeps the servo fine-step behavior tied to the selected movement mode rather
|
||||
@@ -55,73 +69,80 @@ function AuxColumnContent() {
|
||||
|
||||
const handleHeadlightToggle = useCallback(
|
||||
(nextOn) => {
|
||||
if (!headlightAvailable) return;
|
||||
if (!headlightAvailable || controlsDisabled) return;
|
||||
setHeadlight(nextOn);
|
||||
},
|
||||
[headlightAvailable, setHeadlight],
|
||||
[controlsDisabled, headlightAvailable, setHeadlight],
|
||||
);
|
||||
|
||||
const handleLaserToggle = useCallback(
|
||||
(nextOn) => {
|
||||
if (!laserAvailable) return;
|
||||
if (!laserAvailable || controlsDisabled) return;
|
||||
setLaser(nextOn);
|
||||
},
|
||||
[laserAvailable, setLaser],
|
||||
[controlsDisabled, laserAvailable, setLaser],
|
||||
);
|
||||
|
||||
const handleHornStart = useCallback(() => {
|
||||
if (controlsDisabled) return false;
|
||||
return startHorn();
|
||||
}, [startHorn]);
|
||||
}, [controlsDisabled, startHorn]);
|
||||
|
||||
const handleAuxPress = useCallback(
|
||||
(id, values) => {
|
||||
if (disabled) return;
|
||||
if (vacuumDisabled) return;
|
||||
activeAuxButtonRef.current = id;
|
||||
setAuxMotors(values);
|
||||
},
|
||||
[disabled, setAuxMotors],
|
||||
[setAuxMotors, vacuumDisabled],
|
||||
);
|
||||
|
||||
const handleAuxRelease = useCallback(
|
||||
(id) => {
|
||||
if (disabled) return;
|
||||
if (activeAuxButtonRef.current === id) {
|
||||
activeAuxButtonRef.current = null;
|
||||
// Neutral commands must remain available after ownership or OI state is
|
||||
// lost; otherwise disabling a held control could preserve its last output.
|
||||
setAuxMotors(AUX_ZERO);
|
||||
}
|
||||
},
|
||||
[disabled, setAuxMotors],
|
||||
[setAuxMotors],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!vacuumDisabled || activeAuxButtonRef.current === null) return;
|
||||
// Pointer cancellation is browser-dependent when a held button becomes
|
||||
// disabled. Explicitly neutralize the Roomba motors at the state boundary.
|
||||
activeAuxButtonRef.current = null;
|
||||
setAuxMotors(AUX_ZERO);
|
||||
}, [setAuxMotors, vacuumDisabled]);
|
||||
|
||||
return (
|
||||
<div className="mobile-touch-control grid h-full min-h-0 w-full grid-rows-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)] gap-0.5 text-slate-100">
|
||||
<VacuumControls
|
||||
disabled={disabled}
|
||||
disabled={vacuumDisabled}
|
||||
onPress={handleAuxPress}
|
||||
onRelease={handleAuxRelease}
|
||||
/>
|
||||
<div className="mobile-touch-control flex min-h-0 items-stretch gap-0.5">
|
||||
{cameraEnabled ? (
|
||||
// Match the desktop camera tilt card's emerald styling so the vertical
|
||||
// mobile control reads as the same feature in a phone-sized layout.
|
||||
<div className="mobile-touch-control flex-1 min-h-0 rounded-xl border-2 border-emerald-300/70 bg-emerald-900 px-1 py-1 text-emerald-50">
|
||||
<VerticalCameraTilt
|
||||
value={cameraValue}
|
||||
min={cameraMin}
|
||||
max={cameraMax}
|
||||
step={cameraTiltStep}
|
||||
disabled={cameraDisabled}
|
||||
onChange={setServoAngle}
|
||||
/>
|
||||
</div>
|
||||
<VerticalCameraTilt
|
||||
value={cameraValue}
|
||||
min={cameraMin}
|
||||
max={cameraMax}
|
||||
step={cameraTiltStep}
|
||||
disabled={cameraDisabled}
|
||||
onChange={setServoAngle}
|
||||
/>
|
||||
) : null}
|
||||
{(headlightAvailable || laserAvailable) ? (
|
||||
<div className="mobile-touch-control flex min-h-0 flex-1 flex-col gap-0.5">
|
||||
{headlightAvailable ? (
|
||||
<GPIOToggleControl
|
||||
label="Headlight"
|
||||
icon={FaLightbulb}
|
||||
on={headlightState?.headlightOn}
|
||||
disabled={disabled}
|
||||
disabled={controlsDisabled}
|
||||
onToggle={handleHeadlightToggle}
|
||||
heightClass="h-full"
|
||||
/>
|
||||
@@ -129,8 +150,9 @@ function AuxColumnContent() {
|
||||
{laserAvailable ? (
|
||||
<GPIOToggleControl
|
||||
label="Laser"
|
||||
icon={FaCrosshairs}
|
||||
on={laserState?.laserOn}
|
||||
disabled={disabled || roomLightsLockedOn}
|
||||
disabled={controlsDisabled || roomLightsLockedOn}
|
||||
onToggle={handleLaserToggle}
|
||||
heightClass="h-full"
|
||||
/>
|
||||
@@ -141,7 +163,8 @@ function AuxColumnContent() {
|
||||
<div className="mobile-touch-control min-h-0">
|
||||
{hornAvailable ? (
|
||||
<HornControl
|
||||
disabled={disabled || hornBlocked}
|
||||
icon={FaBullhorn}
|
||||
disabled={controlsDisabled || hornBlocked}
|
||||
onStart={handleHornStart}
|
||||
onStop={stopHorn}
|
||||
active={horn?.active}
|
||||
|
||||
@@ -181,7 +181,10 @@ export default function ControlPadPanel({ compact = false, disabled = false }) {
|
||||
}, [disabled, setCameraPrecisionMode, stopDrivePad]);
|
||||
|
||||
return (
|
||||
<div className={`mobile-touch-control flex flex-1 min-h-0 flex-col overflow-hidden rounded-xl border-2 border-slate-700 bg-slate-900 text-slate-100 shadow-md ${compact ? 'h-full' : ''}`}>
|
||||
<div
|
||||
aria-disabled={disabled}
|
||||
className={`mobile-touch-control flex flex-1 min-h-0 flex-col overflow-hidden rounded-xl border-2 border-slate-700 bg-slate-900 text-slate-100 shadow-md transition-opacity ${compact ? 'h-full' : ''} ${disabled ? 'opacity-50' : 'opacity-100'}`}
|
||||
>
|
||||
<div className={`mobile-touch-control grid grid-cols-3 gap-0.5 border-b border-slate-700 bg-slate-950 ${compact ? 'p-0.25' : 'p-0.5'}`}>
|
||||
{DRIVE_PAD_SPEED_MODES.map((mode) => {
|
||||
const active = speedMode === mode.id;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// Scope: Owns pointer tracking, fixed overlay positioning, and active 3x3 drive-zone feedback.
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { FaArrowsAlt } from 'react-icons/fa';
|
||||
import { triggerTouchHaptic } from '../../lib/touchHaptics.js';
|
||||
|
||||
const PAD_MARGIN = 12;
|
||||
@@ -244,7 +245,10 @@ export default function FloatingJoystick({
|
||||
) : null}
|
||||
{!compact ? (
|
||||
<div className="pointer-events-none flex flex-col items-center gap-0.5 px-2 pt-5 text-center">
|
||||
<span className="text-sm font-semibold text-slate-100">drive pad</span>
|
||||
<span className="flex items-center gap-1 text-sm font-semibold text-slate-100">
|
||||
<FaArrowsAlt aria-hidden="true" />
|
||||
<span>drive pad</span>
|
||||
</span>
|
||||
<span className="text-xs leading-tight text-slate-300">hold and drag</span>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
import { triggerTouchHaptic } from '../../lib/touchHaptics.js';
|
||||
|
||||
export default function MobileAuxButton({ id, label, values, color, disabled, onPress, onRelease }) {
|
||||
export default function MobileAuxButton({ id, label, icon: Icon, values, color, disabled, onPress, onRelease }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@@ -23,9 +23,12 @@ export default function MobileAuxButton({ id, label, values, color, disabled, on
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
// The mobile-touch-control class is applied directly to this button because
|
||||
// long-press callouts and text selection are triggered at the pressed node.
|
||||
className={`mobile-touch-control flex h-full w-full items-center justify-center rounded-xl border-2 px-1 py-0.75 text-center text-sm font-semibold text-white transition select-none no-touch-select ${color} hover:brightness-110 active:brightness-125 active:scale-[0.99] disabled:opacity-30`}
|
||||
className={`mobile-touch-control flex h-full w-full items-center justify-center gap-1 rounded-xl border-2 px-1 py-0.75 text-center text-sm font-semibold text-white transition select-none no-touch-select ${color} hover:brightness-110 active:brightness-125 active:scale-[0.99] disabled:opacity-30`}
|
||||
>
|
||||
{label}
|
||||
{/* The icon is optional because this low-level held-action control is also
|
||||
useful for labels that do not have a clear visual symbol. */}
|
||||
{Icon ? <Icon className="shrink-0 text-base" aria-hidden="true" /> : null}
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,36 +1,85 @@
|
||||
// Movement Column
|
||||
// Purpose: Assembles the mobile movement column, which is the right column by default.
|
||||
// Scope: Integrates drive/dock actions with the mobile control pad without hiding that dependency inside the pad.
|
||||
// Scope: Owns movement availability while the in-video DockingHud owns every dock/undock action.
|
||||
import { FaChargingStation } from 'react-icons/fa';
|
||||
import { useControlSelector } from '../../controls/index.js';
|
||||
import DriveDockAction from '../DriveDockAction/index.jsx';
|
||||
import { useDriveDockState } from '../DriveDockAction/driveDockState.js';
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { useTelemetrySelector } from '../../context/TelemetryContext.jsx';
|
||||
import { dockTelemetryEqual, selectDockTelemetry } from '../../context/telemetryViews.js';
|
||||
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
|
||||
import useCanControlRover from '../../hooks/useCanControlRover.js';
|
||||
import { triggerTouchHaptic } from '../../lib/touchHaptics.js';
|
||||
import ControlPadPanel from './ControlPadPanel.jsx';
|
||||
|
||||
function MovementColumnContent({ layout }) {
|
||||
const roverId = useControlSelector((control) => control.state.roverId);
|
||||
const driveDockState = useDriveDockState(roverId);
|
||||
const dockedNotDriving = driveDockState.docked && !driveDockState.driving;
|
||||
const expandAction = dockedNotDriving || driveDockState.dockingInProgress;
|
||||
const disabled = !roverId;
|
||||
const dockTelemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
|
||||
const dockAssist = useManualDockAssist();
|
||||
const canControl = useCanControlRover(roverId);
|
||||
const batteryState = useSessionSelector((state) => {
|
||||
const rover = (state.session?.roster || []).find((entry) => String(entry.id) === String(roverId));
|
||||
return rover?.batteryState || null;
|
||||
});
|
||||
const batteryUrgent = Boolean(batteryState?.urgentActive);
|
||||
const batteryLow = Boolean(batteryState?.warnActive || batteryUrgent);
|
||||
const docked = Boolean(dockTelemetry?.homeBase);
|
||||
const drivingMode = String(dockTelemetry?.oiModeLabel || '').toLowerCase() === 'full';
|
||||
|
||||
/*
|
||||
DriveDockAction owns whether the rover is ready to accept movement, while
|
||||
ControlPadPanel owns only movement intent. Keeping the integration here makes
|
||||
the column layout explicit and prevents the pad component from knowing about
|
||||
docking state.
|
||||
DockingHud is now the only place where a user starts driving or enters docking
|
||||
assist. This column only decides whether touch movement is safe. Requiring both
|
||||
an undocked rover and full OI mode keeps the pad inert throughout the undock
|
||||
macro, including the interval where dock contact and OI mode change separately.
|
||||
|
||||
Manual assist is the deliberate exception to the normal OI-mode requirement:
|
||||
its entire purpose is to let the user drive onto the dock under the assist speed
|
||||
cap. Actual dock contact still disables movement immediately.
|
||||
*/
|
||||
const fillClass = dockedNotDriving ? 'max-h-screen self-start' : '';
|
||||
const containerClass = `mobile-touch-control flex h-full flex-col gap-0.5 text-slate-100 ${fillClass}`;
|
||||
const movementDisabled = !roverId
|
||||
|| !canControl
|
||||
|| docked
|
||||
|| (!drivingMode && !dockAssist.active);
|
||||
// Both controls share the same hardware-availability boundary. Dock assist
|
||||
// changes their actions, not who is allowed to command the rover.
|
||||
const dockActionDisabled = movementDisabled;
|
||||
|
||||
const handleDockAction = () => {
|
||||
if (dockActionDisabled) return;
|
||||
triggerTouchHaptic('button');
|
||||
// Mobile deliberately enters assist on the first tap. The centered video HUD
|
||||
// provides the resulting instruction, so a confirmation modal would only add
|
||||
// friction between intent and the camera-guided docking task.
|
||||
if (dockAssist.active) {
|
||||
dockAssist.exitAssist();
|
||||
} else {
|
||||
dockAssist.enterAssist();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={containerClass} data-mobile-layout={layout}>
|
||||
<DriveDockAction
|
||||
layout="mobile"
|
||||
expand={expandAction}
|
||||
driveDockState={driveDockState}
|
||||
compactHeightClass="min-h-[5rem]"
|
||||
/>
|
||||
{!expandAction ? <ControlPadPanel disabled={disabled} /> : null}
|
||||
<div className="mobile-touch-control flex h-full flex-col gap-0.5 text-slate-100" data-mobile-layout={layout}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={dockActionDisabled}
|
||||
onClick={handleDockAction}
|
||||
className={`mobile-touch-control flex min-h-[4.5rem] shrink-0 items-center justify-center gap-1.5 rounded-xl border-2 px-2 text-base font-semibold shadow-md transition disabled:cursor-not-allowed disabled:opacity-50 ${
|
||||
dockAssist.active
|
||||
? 'border-cyan-300/70 bg-cyan-900 text-cyan-50'
|
||||
: batteryUrgent
|
||||
? 'border-red-300/80 bg-red-950 text-red-50'
|
||||
: batteryLow
|
||||
? 'border-amber-300/80 bg-amber-950 text-amber-50'
|
||||
: 'border-indigo-300/70 bg-indigo-900 text-indigo-50'
|
||||
}`}
|
||||
>
|
||||
<FaChargingStation className="text-lg" aria-hidden="true" />
|
||||
{/* The mobile column remains the only mobile entry point into dock assist. Battery
|
||||
severity makes that existing action more obvious without adding another control. */}
|
||||
<span>{dockAssist.active ? 'Exit dock assist' : batteryUrgent ? 'Dock now' : batteryLow ? 'Dock and charge soon' : 'Dock and charge'}</span>
|
||||
</button>
|
||||
{/* Keeping the pad mounted prevents the mobile columns from changing size while
|
||||
the centered video HUD explains and performs dock-related transitions. */}
|
||||
<ControlPadPanel disabled={movementDisabled} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Vacuum Controls
|
||||
// Purpose: Renders the mobile-only vacuum forward/backward auxiliary motor controls.
|
||||
// Scope: Owns only the two vacuum buttons; the parent column owns rover state and aux motor commands.
|
||||
import { FaRedo, FaUndo } from 'react-icons/fa';
|
||||
import MobileAuxButton from './MobileAuxButton.jsx';
|
||||
import { AUX_ALL_BACKWARD, AUX_ALL_FORWARD } from './constants.js';
|
||||
|
||||
@@ -14,6 +15,7 @@ export default function VacuumControls({
|
||||
<MobileAuxButton
|
||||
id="aux-vac-forward"
|
||||
label="Vacuum Forward"
|
||||
icon={FaRedo}
|
||||
values={AUX_ALL_FORWARD}
|
||||
color="bg-fuchsia-600"
|
||||
disabled={disabled}
|
||||
@@ -23,6 +25,7 @@ export default function VacuumControls({
|
||||
<MobileAuxButton
|
||||
id="aux-vac-backward"
|
||||
label="Vacuum Backward"
|
||||
icon={FaUndo}
|
||||
values={AUX_ALL_BACKWARD}
|
||||
color="bg-fuchsia-800"
|
||||
disabled={disabled}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Purpose: Provides the mobile-only camera tilt slider that supports simultaneous two-finger mobile driving.
|
||||
// Scope: Owns pointer tracking and visual slider state for the compact vertical mobile camera control.
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import { FaVideo } from 'react-icons/fa';
|
||||
import { triggerTouchHaptic } from '../../lib/touchHaptics.js';
|
||||
|
||||
const HAPTIC_MIN_ANGLE_DELTA_DEGREES = 2;
|
||||
@@ -32,7 +33,7 @@ export default function VerticalCameraTilt({
|
||||
if (max === min) return 50;
|
||||
return ((clampCameraAngle(value, min, max) - min) / (max - min)) * 100;
|
||||
}, [max, min, value]);
|
||||
const disabledClass = disabled ? 'opacity-50' : '';
|
||||
const disabledClass = disabled ? 'cursor-not-allowed opacity-50' : '';
|
||||
|
||||
const valueFromPointer = useCallback(
|
||||
(event) => {
|
||||
@@ -106,9 +107,16 @@ export default function VerticalCameraTilt({
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="mobile-touch-control flex h-full items-center justify-center gap-0.5">
|
||||
<span className="mobile-touch-control text-sm font-semibold text-emerald-50 [writing-mode:vertical-rl] rotate-180">
|
||||
Camera tilt
|
||||
<div
|
||||
className={`mobile-touch-control flex h-full min-h-0 flex-1 items-center justify-center gap-0.5 rounded-xl border-2 border-emerald-300/70 bg-emerald-900 px-1 py-1 text-emerald-50 transition-opacity ${disabledClass}`}
|
||||
>
|
||||
{/* This component owns its entire visible card so interaction state, border,
|
||||
background, label, and slider always dim as one coherent control. */}
|
||||
<span className="mobile-touch-control flex items-center gap-1 text-sm font-semibold text-emerald-50 [writing-mode:vertical-rl] rotate-180">
|
||||
{/* Rotate the icon with the established vertical label so both read as one
|
||||
control identity without consuming additional horizontal space. */}
|
||||
<FaVideo className="shrink-0" aria-hidden="true" />
|
||||
<span>Camera tilt</span>
|
||||
</span>
|
||||
<div
|
||||
ref={trackRef}
|
||||
@@ -134,7 +142,7 @@ export default function VerticalCameraTilt({
|
||||
camera gesture.
|
||||
*/
|
||||
style={{ touchAction: 'none' }}
|
||||
className={`mobile-touch-control mobile-drag-control relative h-full w-6 rounded-full border border-emerald-100/80 bg-emerald-950 shadow-inner focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-200 ${disabledClass}`.trim()}
|
||||
className="mobile-touch-control mobile-drag-control relative h-full w-6 rounded-full border border-emerald-100/80 bg-emerald-950 shadow-inner focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-200"
|
||||
>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-1 bottom-1 rounded-full bg-emerald-400"
|
||||
|
||||
Reference in New Issue
Block a user