mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 17:40:46 -04:00
uiuiui
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
// Desktop Accessories Expansion
|
||||
// Purpose: Places generic rover controls on a collapsible surface centered along the video's left wall.
|
||||
// Scope: Owns desktop positioning and persisted visibility while reusing the layout-independent control renderer.
|
||||
import RoverAccessoryControls from '../../../RoverAccessoryControls/index.jsx';
|
||||
import AccessoriesToggle from '../../../RoverAccessoryControls/AccessoriesToggle.jsx';
|
||||
import useRoverAccessories from '../../../RoverAccessoryControls/useRoverAccessories.js';
|
||||
import usePodVisibility from './usePodVisibility.js';
|
||||
|
||||
export default function AccessoriesExpansion({ roverId }) {
|
||||
const { hasAccessories } = useRoverAccessories(roverId);
|
||||
const [open, setOpen] = usePodVisibility('accessories', false);
|
||||
|
||||
// Do not leave an invisible anchor or reserved HUD area on rovers whose
|
||||
// peripherals provide only standardized controls or no controls at all.
|
||||
if (!hasAccessories) return null;
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 z-20 flex items-center">
|
||||
<AccessoriesToggle
|
||||
label="Accessories"
|
||||
ariaLabel={open ? 'Hide accessory controls' : 'Show accessory controls'}
|
||||
onClick={() => setOpen(!open)}
|
||||
className={`pointer-events-auto !h-28 rounded-l-none ${open ? 'rounded-r-none' : ''}`}
|
||||
/>
|
||||
{open ? (
|
||||
<div className="pointer-events-auto h-[70%] min-h-48 max-h-[32rem] w-72 overflow-hidden rounded-r-xl border-2 border-l-0 border-cyan-300/70 bg-slate-950/95 shadow-2xl">
|
||||
{/* This is exactly the renderer mounted by AuxColumn. The desktop
|
||||
wrapper changes available dimensions, never control behavior. */}
|
||||
<RoverAccessoryControls roverId={roverId} className="h-full" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import TopLeftPod from './TopLeftPod.jsx';
|
||||
import TopRightPod from './TopRightPod.jsx';
|
||||
import BottomLeftPod from './BottomLeftPod.jsx';
|
||||
import BottomRightPod from './BottomRightPod.jsx';
|
||||
import AccessoriesExpansion from './AccessoriesExpansion.jsx';
|
||||
import { useDriverLayout } from '../../../../layouts/driver/DriverLayoutContext.jsx';
|
||||
|
||||
export default function CornerPods({ roverId }) {
|
||||
@@ -17,6 +18,9 @@ export default function CornerPods({ roverId }) {
|
||||
{/* The mobile layouts already provide large touch controls around the video.
|
||||
Omitting this pod avoids presenting duplicate horn, light, and laser actions. */}
|
||||
{showPhysicalControlPods ? <BottomLeftPod roverId={roverId} /> : null}
|
||||
{/* Generic accessory controls stay on the same left side at every
|
||||
breakpoint, but mobile owns their placement inside AuxColumn. */}
|
||||
{showPhysicalControlPods ? <AccessoriesExpansion roverId={roverId} /> : null}
|
||||
{/* BottomRightPod also owns the independent chat expansion, so it remains mounted
|
||||
on mobile and determines its own camera-control visibility from layout context. */}
|
||||
<BottomRightPod roverId={roverId} />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// 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, useEffect, useRef } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { FaBullhorn, FaCrosshairs, FaLightbulb } from 'react-icons/fa';
|
||||
import './mobileControls.css';
|
||||
import { useControlActions, useControlSelector } from '../../controls/index.js';
|
||||
@@ -15,11 +15,14 @@ import { AUX_ZERO } from './constants.js';
|
||||
import VacuumControls from './VacuumControls.jsx';
|
||||
import VerticalCameraTilt from './VerticalCameraTilt.jsx';
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import RoverAccessoryControls from '../RoverAccessoryControls/index.jsx';
|
||||
import AccessoriesToggle from '../RoverAccessoryControls/AccessoriesToggle.jsx';
|
||||
import useRoverAccessories from '../RoverAccessoryControls/useRoverAccessories.js';
|
||||
|
||||
const CAMERA_TILT_STEP_DEGREES = 0.5;
|
||||
const CAMERA_TILT_PRECISION_STEP_DEGREES = 0.1;
|
||||
|
||||
function AuxColumnContent() {
|
||||
function AuxColumnContent({ accessoriesAvailable, onShowAccessories }) {
|
||||
const roverId = useControlSelector((control) => control.state.roverId);
|
||||
const roomLightsLockedOn = useSessionSelector((state) => Boolean(state.session?.homeAssistant?.lightPolicy?.lockedOn));
|
||||
const camera = useControlSelector((control) => control.state.camera);
|
||||
@@ -119,11 +122,20 @@ function AuxColumnContent() {
|
||||
|
||||
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={vacuumDisabled}
|
||||
onPress={handleAuxPress}
|
||||
onRelease={handleAuxRelease}
|
||||
/>
|
||||
<div className={`mobile-touch-control grid min-h-0 gap-0.5 ${accessoriesAvailable ? 'grid-cols-[minmax(0,1fr)_2rem]' : 'grid-cols-1'}`}>
|
||||
<VacuumControls
|
||||
disabled={vacuumDisabled}
|
||||
onPress={handleAuxPress}
|
||||
onRelease={handleAuxRelease}
|
||||
/>
|
||||
{accessoriesAvailable ? (
|
||||
<AccessoriesToggle
|
||||
label="Accessories"
|
||||
ariaLabel="Show accessory controls"
|
||||
onClick={onShowAccessories}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mobile-touch-control flex min-h-0 items-stretch gap-0.5">
|
||||
{cameraEnabled ? (
|
||||
<VerticalCameraTilt
|
||||
@@ -180,10 +192,47 @@ function AuxColumnContent() {
|
||||
);
|
||||
}
|
||||
|
||||
export default function AuxColumn({ layout, className = '' }) {
|
||||
function RoverAuxColumn({ roverId, layout, className }) {
|
||||
const [showAccessories, setShowAccessories] = useState(false);
|
||||
const { hasAccessories } = useRoverAccessories(roverId);
|
||||
|
||||
return (
|
||||
<div className={`mobile-touch-control flex flex-col gap-0.5 ${className}`.trim()} data-mobile-layout={layout}>
|
||||
<AuxColumnContent />
|
||||
{showAccessories && hasAccessories ? (
|
||||
<div className="mobile-touch-control relative h-full min-h-0 overflow-hidden rounded-xl border-2 border-cyan-300/70 bg-slate-950/95">
|
||||
{/* The compact return tab overlays only the first heading corner. It
|
||||
does not reserve an otherwise empty rail down the full column. */}
|
||||
<AccessoriesToggle
|
||||
label="Aux"
|
||||
ariaLabel="Return to auxiliary controls"
|
||||
compact
|
||||
onClick={() => setShowAccessories(false)}
|
||||
className="absolute right-1 top-1 z-10"
|
||||
/>
|
||||
<RoverAccessoryControls roverId={roverId} className="h-full pr-1" />
|
||||
</div>
|
||||
) : (
|
||||
<AuxColumnContent
|
||||
accessoriesAvailable={hasAccessories}
|
||||
onShowAccessories={() => setShowAccessories(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AuxColumn({ layout, className = '' }) {
|
||||
const roverId = useControlSelector((control) => control.state.roverId);
|
||||
|
||||
// Keying the stateful view by assignment makes every newly selected rover
|
||||
// start in the familiar Aux view. It also guarantees that an Accessories
|
||||
// view cannot remain open after changing to a rover without accessories.
|
||||
return (
|
||||
<RoverAuxColumn
|
||||
key={roverId || 'no-rover'}
|
||||
roverId={roverId}
|
||||
layout={layout}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// Accessories Vertical Toggle
|
||||
// Purpose: Gives mobile and desktop the same edge-mounted control for changing accessory visibility.
|
||||
// Scope: Owns only visual treatment and activation; each parent decides its position and destination.
|
||||
import { FaPuzzlePiece } from 'react-icons/fa';
|
||||
import { triggerTouchHaptic } from '../../lib/touchHaptics.js';
|
||||
|
||||
export default function AccessoriesToggle({
|
||||
label,
|
||||
ariaLabel,
|
||||
onClick,
|
||||
compact = false,
|
||||
className = '',
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={ariaLabel || label}
|
||||
onClick={() => {
|
||||
triggerTouchHaptic('button');
|
||||
onClick();
|
||||
}}
|
||||
className={`mobile-touch-control flex shrink-0 items-center justify-center rounded-xl border-2 border-cyan-300/70 bg-cyan-900 text-sm font-semibold text-cyan-50 shadow-md transition hover:brightness-110 active:scale-[0.98] active:brightness-125 ${compact ? 'h-14 w-7' : 'h-full w-8'} ${className}`.trim()}
|
||||
>
|
||||
{/* Vertical writing keeps the launcher readable in the narrow wall space
|
||||
without rotating the glyph itself away from its natural orientation. */}
|
||||
<span className="flex items-center gap-1 [writing-mode:vertical-rl] rotate-180">
|
||||
{!compact ? <FaPuzzlePiece className="shrink-0 text-sm" aria-hidden="true" /> : null}
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
// Accessory Control Field
|
||||
// Purpose: Maps one firmware-advertised generic control to a large rover-control surface.
|
||||
// Scope: Owns browser-local values and input semantics; transport and device-specific behavior stay outside this file.
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { triggerTouchHaptic } from '../../lib/touchHaptics.js';
|
||||
|
||||
function integerBound(value, fallback) {
|
||||
return Number.isInteger(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function clampInteger(value, minimum, maximum) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) return minimum;
|
||||
return Math.min(maximum, Math.max(minimum, Math.round(numeric)));
|
||||
}
|
||||
|
||||
function trimUnicode(value, maximumLength) {
|
||||
// Array.from counts Unicode code points instead of UTF-16 code units. That
|
||||
// mirrors Go's rune-count validation for emoji and other non-BMP characters.
|
||||
return Array.from(String(value ?? '')).slice(0, maximumLength).join('');
|
||||
}
|
||||
|
||||
const CARD_CLASS = 'mobile-touch-control rounded-xl border-2 px-2.5 py-2 text-slate-50 shadow-md';
|
||||
const DISABLED_CLASS = 'disabled:cursor-not-allowed disabled:opacity-40';
|
||||
|
||||
function SliderControl({ peripheralId, control, disabled, send, value: storedValue }) {
|
||||
const minimum = integerBound(control.min, 0);
|
||||
const maximum = integerBound(control.max, minimum);
|
||||
const value = Number.isInteger(storedValue)
|
||||
? clampInteger(storedValue, minimum, maximum)
|
||||
: minimum;
|
||||
|
||||
const updateValue = (event) => {
|
||||
const next = clampInteger(event.target.value, minimum, maximum);
|
||||
send(peripheralId, control.id, next);
|
||||
};
|
||||
|
||||
return (
|
||||
<label className={`${CARD_CLASS} block border-emerald-300/70 bg-emerald-900`}>
|
||||
<span className="flex items-center justify-between gap-2 text-sm font-semibold">
|
||||
<span>{control.name}</span>
|
||||
<span className="font-mono text-emerald-100">{value}</span>
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min={minimum}
|
||||
max={maximum}
|
||||
step="1"
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={updateValue}
|
||||
className={`mobile-touch-control mt-2 h-8 w-full cursor-pointer accent-emerald-300 ${DISABLED_CLASS}`}
|
||||
/>
|
||||
<span className="flex justify-between text-xs text-emerald-100/80" aria-hidden="true">
|
||||
<span>{minimum}</span>
|
||||
<span>{maximum}</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleControl({ peripheralId, control, disabled, send, value }) {
|
||||
const enabled = value === true;
|
||||
|
||||
const toggle = () => {
|
||||
if (disabled) return;
|
||||
const next = !enabled;
|
||||
send(peripheralId, control.id, next);
|
||||
triggerTouchHaptic('button');
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={enabled}
|
||||
disabled={disabled}
|
||||
onClick={toggle}
|
||||
className={`${CARD_CLASS} ${DISABLED_CLASS} flex min-h-[4.5rem] w-full items-center justify-between gap-2 font-semibold transition active:scale-[0.99] ${enabled ? 'border-emerald-300/70 bg-emerald-800 text-emerald-50' : 'border-amber-300/70 bg-amber-900 text-amber-50'}`}
|
||||
>
|
||||
<span>{control.name}</span>
|
||||
<span className="text-sm">{enabled ? 'On' : 'Off'}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function MomentaryControl({ peripheralId, control, disabled, send, value }) {
|
||||
const pressed = value === true;
|
||||
const pressedRef = useRef(false);
|
||||
const pointerIdRef = useRef(null);
|
||||
|
||||
const release = useCallback(() => {
|
||||
if (!pressedRef.current) return;
|
||||
pressedRef.current = false;
|
||||
pointerIdRef.current = null;
|
||||
// Always pair a successful press with false. In particular, this cleanup
|
||||
// path runs when a touch is cancelled or the Accessories view is replaced.
|
||||
send(peripheralId, control.id, false);
|
||||
}, [control.id, peripheralId, send]);
|
||||
|
||||
const press = useCallback(() => {
|
||||
if (disabled || pressedRef.current) return;
|
||||
pressedRef.current = true;
|
||||
send(peripheralId, control.id, true);
|
||||
}, [control.id, disabled, peripheralId, send]);
|
||||
|
||||
useEffect(() => release, [release]);
|
||||
useEffect(() => {
|
||||
// Browsers do not guarantee pointer-up after a held button becomes
|
||||
// disabled. Proactively emit the neutral edge at the permission boundary.
|
||||
if (disabled) release();
|
||||
}, [disabled, release]);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={pressed}
|
||||
disabled={disabled}
|
||||
onPointerDown={(event) => {
|
||||
if (disabled || pointerIdRef.current != null) return;
|
||||
event.preventDefault();
|
||||
pointerIdRef.current = event.pointerId;
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||
press();
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
if (pointerIdRef.current !== event.pointerId) return;
|
||||
release();
|
||||
triggerTouchHaptic('button');
|
||||
}}
|
||||
onPointerCancel={release}
|
||||
onLostPointerCapture={release}
|
||||
onKeyDown={(event) => {
|
||||
if ((event.key === ' ' || event.key === 'Enter') && !event.repeat) {
|
||||
event.preventDefault();
|
||||
press();
|
||||
}
|
||||
}}
|
||||
onKeyUp={(event) => {
|
||||
if (event.key === ' ' || event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
release();
|
||||
triggerTouchHaptic('button');
|
||||
}
|
||||
}}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
className={`${CARD_CLASS} ${DISABLED_CLASS} flex min-h-[4.5rem] w-full items-center justify-center text-center font-semibold transition active:scale-[0.99] ${pressed ? 'border-fuchsia-200 bg-fuchsia-600 text-white' : 'border-fuchsia-300/70 bg-fuchsia-900 text-fuchsia-50'}`}
|
||||
>
|
||||
{control.name}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function NumberControl({ peripheralId, control, disabled, send, value: storedValue }) {
|
||||
const minimum = integerBound(control.min, 0);
|
||||
const maximum = integerBound(control.max, minimum);
|
||||
const initialValue = Number.isInteger(storedValue)
|
||||
? clampInteger(storedValue, minimum, maximum)
|
||||
: minimum;
|
||||
const [value, setValue] = useState(String(initialValue));
|
||||
const lastSentRef = useRef(initialValue);
|
||||
|
||||
const commit = () => {
|
||||
const next = clampInteger(value, minimum, maximum);
|
||||
setValue(String(next));
|
||||
if (disabled || next === lastSentRef.current) return;
|
||||
lastSentRef.current = next;
|
||||
send(peripheralId, control.id, next);
|
||||
triggerTouchHaptic('button');
|
||||
};
|
||||
|
||||
return (
|
||||
<label className={`${CARD_CLASS} block border-indigo-300/70 bg-indigo-900`}>
|
||||
<span className="flex items-center justify-between gap-2 text-sm font-semibold">
|
||||
<span>{control.name}</span>
|
||||
<span className="text-xs text-indigo-100/80">{minimum}–{maximum}</span>
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={minimum}
|
||||
max={maximum}
|
||||
step="1"
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
commit();
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
className={`mobile-touch-control mt-2 min-h-11 w-full rounded-lg border border-indigo-200/70 bg-indigo-950 px-3 text-base text-white outline-none focus-visible:ring-2 focus-visible:ring-indigo-200 ${DISABLED_CLASS}`}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function TextControl({ peripheralId, control, disabled, send, value: storedValue }) {
|
||||
const maximumLength = Math.max(1, integerBound(control.maxLength, 1));
|
||||
const initialValue = typeof storedValue === 'string'
|
||||
? trimUnicode(storedValue, maximumLength)
|
||||
: '';
|
||||
const [value, setValue] = useState(initialValue);
|
||||
const lastSentRef = useRef(initialValue);
|
||||
|
||||
const commit = () => {
|
||||
const next = trimUnicode(value, maximumLength);
|
||||
setValue(next);
|
||||
if (disabled || next === lastSentRef.current) return;
|
||||
lastSentRef.current = next;
|
||||
send(peripheralId, control.id, next);
|
||||
triggerTouchHaptic('button');
|
||||
};
|
||||
|
||||
return (
|
||||
<label className={`${CARD_CLASS} block border-sky-300/70 bg-sky-900`}>
|
||||
<span className="flex items-center justify-between gap-2 text-sm font-semibold">
|
||||
<span>{control.name}</span>
|
||||
<span className="text-xs text-sky-100/80">{Array.from(value).length}/{maximumLength}</span>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={(event) => setValue(trimUnicode(event.target.value, maximumLength))}
|
||||
onBlur={commit}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
commit();
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
className={`mobile-touch-control mt-2 min-h-11 w-full rounded-lg border border-sky-200/70 bg-sky-950 px-3 text-base text-white outline-none focus-visible:ring-2 focus-visible:ring-sky-200 ${DISABLED_CLASS}`}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AccessoryControlField(props) {
|
||||
switch (props.control?.type) {
|
||||
case 'slider':
|
||||
return <SliderControl {...props} />;
|
||||
case 'button':
|
||||
return props.control.mode === 'momentary'
|
||||
? <MomentaryControl {...props} />
|
||||
: <ToggleControl {...props} />;
|
||||
case 'number':
|
||||
return <NumberControl {...props} />;
|
||||
case 'text':
|
||||
return <TextControl {...props} />;
|
||||
default:
|
||||
// roverd validates the four-type contract before publishing metadata.
|
||||
// Returning nothing remains a defensive boundary for stale servers.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// Rover Accessory Controls
|
||||
// Purpose: Renders every generic control advertised by a selected rover as one ordered control surface.
|
||||
// Scope: Reusable content only; mobile and desktop parents own placement, expansion, and visibility.
|
||||
import { useCallback } from 'react';
|
||||
import { useControlActions, useControlSelector } from '../../controls/index.js';
|
||||
import useCanControlRover from '../../hooks/useCanControlRover.js';
|
||||
import AccessoryControlField from './AccessoryControlField.jsx';
|
||||
import useRoverAccessories from './useRoverAccessories.js';
|
||||
|
||||
const EMPTY_ACCESSORY_VALUES = Object.freeze({});
|
||||
|
||||
export default function RoverAccessoryControls({ roverId, className = '' }) {
|
||||
const { peripherals } = useRoverAccessories(roverId);
|
||||
const canControl = useCanControlRover(roverId);
|
||||
const { setPeripheralControl } = useControlActions();
|
||||
const values = useControlSelector(
|
||||
(control) => control.state.peripheralValues?.[String(roverId)] || EMPTY_ACCESSORY_VALUES,
|
||||
);
|
||||
const send = useCallback(
|
||||
(peripheralId, controlId, value) => setPeripheralControl(peripheralId, controlId, value),
|
||||
[setPeripheralControl],
|
||||
);
|
||||
|
||||
if (peripherals.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`mobile-touch-control min-h-0 overflow-y-auto overscroll-contain p-1.5 text-slate-100 ${className}`.trim()}
|
||||
aria-label="Rover accessories"
|
||||
>
|
||||
{peripherals.map((peripheral) => (
|
||||
<section key={peripheral.id} className="mb-2 last:mb-0">
|
||||
{/* The firmware's array order is authoritative. Mapping directly over
|
||||
it keeps physical authoring order intact across every UI host. */}
|
||||
<h3 className="mb-1.5 border-b border-cyan-300/40 px-1 pr-8 pb-1 text-sm font-semibold text-cyan-100">
|
||||
{peripheral.name}
|
||||
</h3>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{peripheral.controls.map((control) => (
|
||||
<AccessoryControlField
|
||||
key={control.id}
|
||||
peripheralId={peripheral.id}
|
||||
control={control}
|
||||
value={values[peripheral.id]?.[control.id]}
|
||||
disabled={!roverId || !canControl}
|
||||
send={send}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Rover Accessories Selector
|
||||
// Purpose: Provides the ordered generic-control inventory advertised by one rover.
|
||||
// Scope: Selects public roster metadata only; placement and visibility remain parent-UI decisions.
|
||||
import { useMemo } from 'react';
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
|
||||
export default function useRoverAccessories(roverId) {
|
||||
const rosterEntry = useSessionSelector((state) => {
|
||||
if (!roverId) return null;
|
||||
const roster = Array.isArray(state.session?.roster) ? state.session.roster : [];
|
||||
return roster.find((entry) => String(entry.id) === String(roverId)) || null;
|
||||
});
|
||||
const advertisedPeripherals = rosterEntry?.peripherals;
|
||||
|
||||
const peripherals = useMemo(() => {
|
||||
if (!Array.isArray(advertisedPeripherals)) return [];
|
||||
// A peripheral with no generic controls may still provide a standardized
|
||||
// camera, headlight, or laser backend. Those roles use their established
|
||||
// HUD controls and must not create an empty Accessories surface.
|
||||
return advertisedPeripherals.filter(
|
||||
(peripheral) => Array.isArray(peripheral?.controls) && peripheral.controls.length > 0,
|
||||
);
|
||||
}, [advertisedPeripherals]);
|
||||
|
||||
return {
|
||||
peripherals,
|
||||
hasAccessories: peripherals.length > 0,
|
||||
};
|
||||
}
|
||||
@@ -66,6 +66,7 @@ const CONTROL_ACTION_NAMES = [
|
||||
'sendSong',
|
||||
'startHorn',
|
||||
'stopHorn',
|
||||
'setPeripheralControl',
|
||||
'setMicPttActive',
|
||||
];
|
||||
|
||||
@@ -724,6 +725,29 @@ export function ControlSystemProvider({ children }) {
|
||||
dispatch({ type: 'control/set-mic-ptt', payload: Boolean(active) });
|
||||
}, []);
|
||||
|
||||
const setPeripheralControl = useCallback(
|
||||
(peripheralId, controlId, value) => {
|
||||
const sent = pipeline.sendPeripheralControl(peripheralId, controlId, value);
|
||||
if (sent) {
|
||||
// The generic protocol is currently command-only. Recording the value
|
||||
// here lets every renderer instance share the browser's latest intent
|
||||
// without pretending that it is device-reported telemetry.
|
||||
dispatch({
|
||||
type: 'control/set-peripheral-value',
|
||||
payload: {
|
||||
roverId: pipeline.roverId,
|
||||
peripheralId,
|
||||
controlId,
|
||||
value,
|
||||
},
|
||||
});
|
||||
recordControlIntent();
|
||||
}
|
||||
return sent;
|
||||
},
|
||||
[pipeline, recordControlIntent],
|
||||
);
|
||||
|
||||
const actionImplementations = useMemo(
|
||||
() => ({
|
||||
setMode,
|
||||
@@ -751,6 +775,7 @@ export function ControlSystemProvider({ children }) {
|
||||
sendSong,
|
||||
startHorn,
|
||||
stopHorn,
|
||||
setPeripheralControl,
|
||||
setMicPttActive,
|
||||
}),
|
||||
[
|
||||
@@ -779,6 +804,7 @@ export function ControlSystemProvider({ children }) {
|
||||
sendSong,
|
||||
startHorn,
|
||||
stopHorn,
|
||||
setPeripheralControl,
|
||||
setMicPttActive,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -44,6 +44,11 @@ export function useCommandPipeline(options = {}) {
|
||||
return rosterEntry.horn;
|
||||
}, [rosterEntry]);
|
||||
|
||||
const peripherals = useMemo(
|
||||
() => (Array.isArray(rosterEntry?.peripherals) ? rosterEntry.peripherals : []),
|
||||
[rosterEntry],
|
||||
);
|
||||
|
||||
const headlightState = useMemo(() => rosterEntry?.headlight?.state ?? null, [rosterEntry]);
|
||||
const laserState = useMemo(() => rosterEntry?.laser?.state ?? null, [rosterEntry]);
|
||||
const emitCommand = useCallback(
|
||||
@@ -208,6 +213,22 @@ export function useCommandPipeline(options = {}) {
|
||||
[emitCommand, roverId],
|
||||
);
|
||||
|
||||
const sendPeripheralControl = useCallback(
|
||||
(peripheralId, controlId, value) => {
|
||||
if (!roverId || !peripheralId || !controlId) return null;
|
||||
const peripheral = { id: peripheralId, control: controlId, value };
|
||||
// Peripheral commands deliberately use the same command envelope as all
|
||||
// other rover actuation. This keeps turn authorization, acknowledgements,
|
||||
// and rover WebSocket routing in the server's existing command boundary.
|
||||
emitCommand({
|
||||
type: 'peripheral',
|
||||
data: { peripheral },
|
||||
});
|
||||
return peripheral;
|
||||
},
|
||||
[emitCommand, roverId],
|
||||
);
|
||||
|
||||
const sendSong = useCallback(
|
||||
(notes = [], options = {}) => {
|
||||
if (!roverId) return null;
|
||||
@@ -244,6 +265,7 @@ export function useCommandPipeline(options = {}) {
|
||||
laser,
|
||||
laserState,
|
||||
horn,
|
||||
peripherals,
|
||||
emitCommand,
|
||||
enableSensorStream,
|
||||
sendDriveDirect,
|
||||
@@ -253,6 +275,7 @@ export function useCommandPipeline(options = {}) {
|
||||
sendHeadlight,
|
||||
sendLaser,
|
||||
sendHorn,
|
||||
sendPeripheralControl,
|
||||
sendSong,
|
||||
runMacroSteps,
|
||||
}),
|
||||
@@ -265,6 +288,7 @@ export function useCommandPipeline(options = {}) {
|
||||
laser,
|
||||
laserState,
|
||||
horn,
|
||||
peripherals,
|
||||
emitCommand,
|
||||
enableSensorStream,
|
||||
sendDriveDirect,
|
||||
@@ -274,6 +298,8 @@ export function useCommandPipeline(options = {}) {
|
||||
sendHeadlight,
|
||||
sendLaser,
|
||||
sendHorn,
|
||||
sendPeripheralControl,
|
||||
sendSong,
|
||||
runMacroSteps,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -69,6 +69,10 @@ export const initialControlState = {
|
||||
macros: DEFAULT_MACROS,
|
||||
keymap: DEFAULT_KEYMAP,
|
||||
inputs: {},
|
||||
// Generic accessory controls do not currently report state back from roverd.
|
||||
// Keep the last browser-issued value per rover/device/control so closing a
|
||||
// drawer or changing responsive layouts does not make the UI lie about it.
|
||||
peripheralValues: {},
|
||||
};
|
||||
|
||||
export function controlReducer(state, action) {
|
||||
@@ -226,6 +230,27 @@ export function controlReducer(state, action) {
|
||||
active: Boolean(action.payload),
|
||||
},
|
||||
};
|
||||
case 'control/set-peripheral-value': {
|
||||
const roverId = String(action.payload?.roverId || '');
|
||||
const peripheralId = String(action.payload?.peripheralId || '');
|
||||
const controlId = String(action.payload?.controlId || '');
|
||||
if (!roverId || !peripheralId || !controlId) return state;
|
||||
const roverValues = state.peripheralValues?.[roverId] || {};
|
||||
const peripheralValues = roverValues[peripheralId] || {};
|
||||
return {
|
||||
...state,
|
||||
peripheralValues: {
|
||||
...(state.peripheralValues || {}),
|
||||
[roverId]: {
|
||||
...roverValues,
|
||||
[peripheralId]: {
|
||||
...peripheralValues,
|
||||
[controlId]: action.payload.value,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user