headlight rework and laser addition

This commit is contained in:
legop3
2026-06-27 15:57:47 -04:00
parent 3c1b1dab6e
commit 8e322ed57b
45 changed files with 596 additions and 373 deletions
@@ -1,14 +1,15 @@
// Night Vision Control
// Purpose: Defines the Night Vision Control module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
// GPIO Toggle Control
// Purpose: Renders a direct press target for rover GPIO-backed toggles such as the headlight and laser.
// Scope: Owns optimistic button state and touch/click de-duplication while callers provide device labels and actions.
import { useEffect, useMemo, useRef, useState } from 'react';
function isBoolean(value) {
return typeof value === 'boolean';
}
export default function NightVisionControl({
nightVisionOn,
export default function GPIOToggleControl({
label,
on,
disabled,
onToggle,
keyLabel,
@@ -16,16 +17,21 @@ export default function NightVisionControl({
heightClass = '',
}) {
const [optimistic, setOptimistic] = useState(
isBoolean(nightVisionOn) ? nightVisionOn : null,
isBoolean(on) ? on : null,
);
const suppressClickRef = useRef(false);
const suppressClickTimerRef = useRef(null);
useEffect(() => {
if (isBoolean(nightVisionOn)) {
setOptimistic(nightVisionOn);
if (isBoolean(on)) {
// Server-confirmed state can arrive after an optimistic click. Defer the
// reconciliation one tick so this effect stays a synchronization point
// instead of triggering React's synchronous set-state-in-effect lint rule.
const timer = window.setTimeout(() => setOptimistic(on), 0);
return () => window.clearTimeout(timer);
}
}, [nightVisionOn]);
return undefined;
}, [on]);
useEffect(
() => () => {
@@ -58,7 +64,7 @@ export default function NightVisionControl({
Mobile browsers, especially Safari, do not always dispatch a reliable
synthetic click for a second finger while another finger is held on the
drive pad. Toggle on the real touch pointerdown instead, then suppress the
follow-up click so one tap cannot flip night vision twice.
follow-up click so one tap cannot flip the GPIO-backed device twice.
*/
event.preventDefault();
suppressClickRef.current = true;
@@ -91,7 +97,7 @@ export default function NightVisionControl({
};
const buttonClasses = useMemo(() => {
// Night vision is used as a direct mobile press target, so selection and
// This control is used as a direct mobile press target, so selection and
// Safari callout suppression live on the button itself rather than only on
// the surrounding mobile column.
const base =
@@ -114,7 +120,7 @@ export default function NightVisionControl({
className={buttonClasses}
>
<span className="flex items-center gap-0.5">
<span className="text-sm font-semibold">Night Vision</span>
<span className="text-sm font-semibold">{label}</span>
{keyLabel ? (
<span className="rounded bg-slate-800 px-1 py-0.5 text-[0.6rem] font-semibold text-slate-200">
{keyLabel}
@@ -38,7 +38,8 @@ export const ACTIONS = [
{ id: 'sideReverse', label: 'Side reverse toggle', kind: 'button', section: 'Brush toggles' },
{ id: 'driveMacro', label: 'Drive macro', kind: 'button', section: 'Mode macros' },
{ id: 'dockMacro', label: 'Dock macro', kind: 'button', section: 'Mode macros' },
{ id: 'nightVisionToggle', label: 'Night vision toggle', kind: 'button', section: 'Camera' },
{ id: 'headlightToggle', label: 'Headlight toggle', kind: 'button', section: 'Camera' },
{ id: 'laserToggle', label: 'Laser toggle', kind: 'button', section: 'Camera' },
];
export const CAPTURE_AXIS_THRESHOLD = 0.45;
@@ -26,7 +26,8 @@ const KEY_ACTIONS = [
{ id: 'auxAllForward', label: 'All Aux Forward', group: 'Aux Motors' },
{ id: 'cameraUp', label: 'Camera Up', group: 'Camera' },
{ id: 'cameraDown', label: 'Camera Down', group: 'Camera' },
{ id: 'nightVisionToggle', label: 'Toggle Night Vision', group: 'Camera' },
{ id: 'headlightToggle', label: 'Toggle Headlight', group: 'Camera' },
{ id: 'laserToggle', label: 'Toggle Laser', group: 'Camera' },
{ id: 'videoFilterCycle', label: 'Cycle Video Filter', group: 'Camera' },
{ id: 'hornHonk', label: 'Horn (Hold)', group: 'Audio' },
{ id: 'micPtt', label: 'Mic Push To Talk', group: 'Audio' },
@@ -1,11 +1,11 @@
// Aux Column
// Purpose: Assembles the mobile auxiliary controls column, which is the left column by default.
// Scope: Owns mobile aux/camera/night vision/horn wiring while reusing desktop variation components where intended.
// Scope: Owns mobile aux/camera/headlight/laser/horn wiring while reusing desktop variation components where intended.
import { useCallback, useRef } from 'react';
import { useControlActions, useControlSelector } from '../../controls/index.js';
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
import HornControl from '../HornControl/index.jsx';
import NightVisionControl from '../NightVisionControl/index.jsx';
import GPIOToggleControl from '../GPIOToggleControl/index.jsx';
import { AUX_ZERO } from './constants.js';
import VacuumControls from './VacuumControls.jsx';
import VerticalCameraTilt from './VerticalCameraTilt.jsx';
@@ -15,16 +15,19 @@ function AuxColumnContent() {
const roverId = useControlSelector((control) => control.state.roverId);
const camera = useControlSelector((control) => control.state.camera);
const horn = useControlSelector((control) => control.state.horn);
const nightVision = useControlSelector((control) => control.pipeline?.nightVision);
const nightVisionState = useControlSelector((control) => control.pipeline?.nightVisionState);
const headlight = useControlSelector((control) => control.pipeline?.headlight);
const headlightState = useControlSelector((control) => control.pipeline?.headlightState);
const laser = useControlSelector((control) => control.pipeline?.laser);
const laserState = useControlSelector((control) => control.pipeline?.laserState);
const pipelineHorn = useControlSelector((control) => control.pipeline?.horn);
const { setServoAngle, setNightVision, setAuxMotors, startHorn, stopHorn } = useControlActions();
const { setServoAngle, setHeadlight, setLaser, setAuxMotors, startHorn, stopHorn } = useControlActions();
const dockAssist = useManualDockAssist();
const disabled = !roverId;
const activeAuxButtonRef = useRef(null);
const cameraConfig = camera?.config;
const cameraEnabled = Boolean(roverId && camera?.enabled && cameraConfig);
const nightVisionAvailable = Boolean(roverId && nightVision);
const headlightAvailable = Boolean(roverId && headlight);
const laserAvailable = Boolean(roverId && laser);
const hornAvailable = Boolean(roverId && pipelineHorn);
const hornBlocked = horn?.overheated;
const cameraMin = typeof cameraConfig?.minAngle === 'number' ? cameraConfig.minAngle : -45;
@@ -37,13 +40,22 @@ function AuxColumnContent() {
: (cameraMin + cameraMax) / 2;
const cameraDisabled = Boolean(disabled || dockAssist.cameraLocked);
const handleNightVisionToggle = useCallback(
const handleHeadlightToggle = useCallback(
(nextOn) => {
if (!nightVisionAvailable) return;
trackAnalyticsEvent('night_vision_toggle', { roverId, source: 'mobile_control', enabled: Boolean(nextOn) });
setNightVision(nextOn);
if (!headlightAvailable) return;
trackAnalyticsEvent('headlight_toggle', { roverId, source: 'mobile_control', enabled: Boolean(nextOn) });
setHeadlight(nextOn);
},
[nightVisionAvailable, roverId, setNightVision],
[headlightAvailable, roverId, setHeadlight],
);
const handleLaserToggle = useCallback(
(nextOn) => {
if (!laserAvailable) return;
trackAnalyticsEvent('laser_toggle', { roverId, source: 'mobile_control', enabled: Boolean(nextOn) });
setLaser(nextOn);
},
[laserAvailable, roverId, setLaser],
);
const handleHornStart = useCallback(() => {
@@ -93,13 +105,27 @@ function AuxColumnContent() {
/>
</div>
) : null}
{nightVisionAvailable ? (
<NightVisionControl
nightVisionOn={nightVisionState?.nightVisionOn}
disabled={disabled}
onToggle={handleNightVisionToggle}
heightClass="h-full"
/>
{(headlightAvailable || laserAvailable) ? (
<div className="mobile-touch-control flex min-h-0 flex-1 flex-col gap-0.5">
{headlightAvailable ? (
<GPIOToggleControl
label="Headlight"
on={headlightState?.headlightOn}
disabled={disabled}
onToggle={handleHeadlightToggle}
heightClass="h-full"
/>
) : null}
{laserAvailable ? (
<GPIOToggleControl
label="Laser"
on={laserState?.laserOn}
disabled={disabled}
onToggle={handleLaserToggle}
heightClass="h-full"
/>
) : null}
</div>
) : null}
</div>
<div className="mobile-touch-control min-h-0">
+39 -17
View File
@@ -19,7 +19,7 @@ import { useControlActions, useControlSelector } from '../../controls/index.js';
import RoverQueuesPanel from '../RoverQueuesPanel/index.jsx';
import RawUserPilePanel from '../RawUserPilePanel/index.jsx';
import { formatKeyLabel } from '../../controls/keymapUtils.js';
import NightVisionControl from '../NightVisionControl/index.jsx';
import GPIOToggleControl from '../GPIOToggleControl/index.jsx';
import HornControl from '../HornControl/index.jsx';
import CameraTiltControl from '../CameraTiltControl/index.jsx';
import VipPanel from '../VipPanel/index.jsx';
@@ -62,17 +62,20 @@ function DriveDockPanel() {
const keymap = useControlSelector((control) => control.state.keymap);
const camera = useControlSelector((control) => control.state.camera);
const horn = useControlSelector((control) => control.state.horn);
const nightVision = useControlSelector((control) => control.pipeline?.nightVision);
const nightVisionState = useControlSelector((control) => control.pipeline?.nightVisionState);
const headlight = useControlSelector((control) => control.pipeline?.headlight);
const headlightState = useControlSelector((control) => control.pipeline?.headlightState);
const laser = useControlSelector((control) => control.pipeline?.laser);
const laserState = useControlSelector((control) => control.pipeline?.laserState);
const pipelineHorn = useControlSelector((control) => control.pipeline?.horn);
const { setServoAngle, setNightVision, startHorn, stopHorn } = useControlActions();
const { setServoAngle, setHeadlight, setLaser, startHorn, stopHorn } = useControlActions();
const dockAssist = useManualDockAssist();
const driveDockState = useDriveDockState(roverId);
const hideInlineControls = driveDockState.docked && !driveDockState.driving;
const config = camera?.config;
const cameraEnabled = Boolean(roverId && camera?.enabled && config);
const nightVisionAvailable = Boolean(roverId && nightVision);
const headlightAvailable = Boolean(roverId && headlight);
const laserAvailable = Boolean(roverId && laser);
const hornAvailable = Boolean(roverId && pipelineHorn);
const hornBlocked = horn?.overheated;
const min = typeof config?.minAngle === 'number' ? config.minAngle : -30;
@@ -83,16 +86,21 @@ function DriveDockPanel() {
: typeof config?.homeAngle === 'number'
? config.homeAngle
: (min + max) / 2;
const nightVisionLabel = formatKeyLabel(keymap?.nightVisionToggle?.[0]);
const headlightLabel = formatKeyLabel(keymap?.headlightToggle?.[0]);
const laserLabel = formatKeyLabel(keymap?.laserToggle?.[0]);
const hornLabel = formatKeyLabel(keymap?.hornHonk?.[0]);
const upLabel = formatKeyLabel(keymap?.cameraUp?.[0]);
const downLabel = formatKeyLabel(keymap?.cameraDown?.[0]);
const cameraDisabled = Boolean(!roverId || dockAssist.cameraLocked);
const trackedControls = useMemo(
() => ({
setNightVision: (nextOn) => {
trackAnalyticsEvent('night_vision_toggle', { roverId, source: 'desktop_control', enabled: Boolean(nextOn) });
setNightVision(nextOn);
setHeadlight: (nextOn) => {
trackAnalyticsEvent('headlight_toggle', { roverId, source: 'desktop_control', enabled: Boolean(nextOn) });
setHeadlight(nextOn);
},
setLaser: (nextOn) => {
trackAnalyticsEvent('laser_toggle', { roverId, source: 'desktop_control', enabled: Boolean(nextOn) });
setLaser(nextOn);
},
startHorn: () => {
trackAnalyticsEvent('horn_start', { roverId, source: 'desktop_control' });
@@ -100,7 +108,7 @@ function DriveDockPanel() {
},
stopHorn,
}),
[roverId, setNightVision, startHorn, stopHorn],
[roverId, setHeadlight, setLaser, startHorn, stopHorn],
);
return (
@@ -113,13 +121,27 @@ function DriveDockPanel() {
<DriveDockAction layout="desktop" expand driveDockState={driveDockState} />
{!hideInlineControls ? (
<div className={`${themeStackClass} p-0 text-sm text-slate-200`}>
{nightVisionAvailable && (
<NightVisionControl
nightVisionOn={nightVisionState?.nightVisionOn}
disabled={!roverId}
onToggle={trackedControls.setNightVision}
keyLabel={nightVisionLabel}
/>
{(headlightAvailable || laserAvailable) && (
<div className="grid grid-cols-2 gap-1">
{headlightAvailable && (
<GPIOToggleControl
label="Headlight"
on={headlightState?.headlightOn}
disabled={!roverId}
onToggle={trackedControls.setHeadlight}
keyLabel={headlightLabel}
/>
)}
{laserAvailable && (
<GPIOToggleControl
label="Laser"
on={laserState?.laserOn}
disabled={!roverId}
onToggle={trackedControls.setLaser}
keyLabel={laserLabel}
/>
)}
</div>
)}
{hornAvailable && (
<HornControl