mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
home assistant lights and other tweaks
This commit is contained in:
@@ -16,6 +16,7 @@ import DriverVideoPanel from './components/DriverVideoPanel.jsx';
|
||||
import RightPaneTabs from './components/RightPaneTabs.jsx';
|
||||
import ModeGateOverlay from './components/ModeGateOverlay.jsx';
|
||||
import SessionSnapshot from './components/SessionSnapshot.jsx';
|
||||
import HomeAssistantControls from './components/HomeAssistantControls.jsx';
|
||||
|
||||
function useLayoutMode() {
|
||||
const [mode, setMode] = useState(() => {
|
||||
@@ -72,6 +73,7 @@ function MobilePortraitLayout() {
|
||||
<AuthPanel />
|
||||
<AdminPanel />
|
||||
<RoomCameraPanel />
|
||||
<HomeAssistantControls />
|
||||
<LogPanel />
|
||||
</div>
|
||||
);
|
||||
@@ -87,6 +89,7 @@ function MobileLandscapeLayout() {
|
||||
</section>
|
||||
<div className="flex flex-col gap-0.5 pb-0.5">
|
||||
<RoomCameraPanel />
|
||||
<HomeAssistantControls />
|
||||
<DrivePanel />
|
||||
<section className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-0.5">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
|
||||
@@ -35,7 +35,7 @@ export default function DrivePanel() {
|
||||
<div className="space-y-0.5">
|
||||
<ActionCard
|
||||
title="Start Driving"
|
||||
description="Press to enable driving mode, then start moving. The headlamps should illuminate."
|
||||
description="Press to enable driving mode, then start moving."
|
||||
statuses={[{ label: drivingMode ? 'Ready!' : 'Not Ready!', active: drivingMode }]}
|
||||
tone="emerald"
|
||||
onClick={handleStartDrive}
|
||||
@@ -43,7 +43,7 @@ export default function DrivePanel() {
|
||||
/>
|
||||
<ActionCard
|
||||
title="Dock and Charge"
|
||||
description="Line the rover up about a foot from the dock, then trigger an automatic approach."
|
||||
description="Line the rover up about a foot from the dock, then trigger an automatic docking attempt."
|
||||
statuses={[
|
||||
{ label: docked ? 'Docked!' : 'Not Docked!', active: docked },
|
||||
{ label: charging ? 'Charging!' : 'Not Charging!', active: charging },
|
||||
@@ -71,12 +71,13 @@ function ActionCard({ title, description, statuses, tone, onClick, disabled, foo
|
||||
>
|
||||
<p className="text-base font-semibold">{title}</p>
|
||||
<p className="text-sm text-white/90">{description}</p>
|
||||
<div className="mt-0.5 flex flex-wrap gap-0.5">
|
||||
{/* center statuses in button */}
|
||||
<div className="mt-0.5 flex flex-wrap gap-0.5 items-center w-full justify-center">
|
||||
{statuses.map((status) => (
|
||||
<span
|
||||
key={status.label}
|
||||
className={`px-0.5 py-0.5 text-xs font-semibold ${
|
||||
status.active ? 'bg-lime-300 text-emerald-900' : 'bg-emerald-900 text-emerald-100'
|
||||
className={`p-1 text-xs font-semibold ${
|
||||
status.active ? 'bg-green-600 text-white' : 'bg-red-500 text-white'
|
||||
}`}
|
||||
>
|
||||
{status.label}
|
||||
|
||||
@@ -151,9 +151,13 @@ export default function GamepadMappingSettings() {
|
||||
}, {});
|
||||
}, []);
|
||||
|
||||
const getValueLabel = (action) => {
|
||||
const getStored = (action) => {
|
||||
const [group, key] = action.path;
|
||||
const stored = mapping?.[group]?.[key] ?? null;
|
||||
return mapping?.[group]?.[key] ?? null;
|
||||
};
|
||||
|
||||
const getValueLabel = (action) => {
|
||||
const stored = getStored(action);
|
||||
return action.type === 'axis' ? formatAxis(stored) : formatButton(stored);
|
||||
};
|
||||
|
||||
@@ -161,6 +165,17 @@ export default function GamepadMappingSettings() {
|
||||
save((prev) => updatePath(prev, action.path, () => null));
|
||||
};
|
||||
|
||||
const handleInvert = (action) => {
|
||||
const stored = getStored(action);
|
||||
if (!stored) return;
|
||||
save((prev) =>
|
||||
updatePath(prev, action.path, (current) => ({
|
||||
...current,
|
||||
invert: !current?.invert,
|
||||
})),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="panel-section space-y-0.5 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -232,6 +247,20 @@ export default function GamepadMappingSettings() {
|
||||
<p className="text-[0.65rem] text-slate-400">{getValueLabel(action)}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5">
|
||||
{action.type === 'axis' && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!getStored(action)}
|
||||
onClick={() => handleInvert(action)}
|
||||
className={`${
|
||||
getStored(action)?.invert
|
||||
? 'px-0.5 py-0.5 bg-amber-400 text-amber-900 hover:bg-amber-300'
|
||||
: 'button-dark'
|
||||
} text-[0.7rem] font-medium disabled:opacity-50 disabled:cursor-not-allowed`}
|
||||
>
|
||||
Invert
|
||||
</button>
|
||||
)}
|
||||
<button type="button" onClick={() => handleClear(action)} className="button-dark text-[0.7rem]">
|
||||
Clear
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useSession } from '../context/SessionContext.jsx';
|
||||
|
||||
function StatusBadge({ label, tone = 'muted' }) {
|
||||
const styles =
|
||||
tone === 'success'
|
||||
? 'bg-emerald-900 text-emerald-100'
|
||||
: tone === 'warn'
|
||||
? 'bg-amber-900 text-amber-100'
|
||||
: 'bg-slate-800 text-slate-200';
|
||||
return (
|
||||
<span className={`rounded px-1 py-0.5 text-xs font-semibold leading-tight ${styles}`}>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function EntityRow({ entity, connected, onToggle }) {
|
||||
const unavailable = entity.state === 'unavailable' || !entity.available;
|
||||
const isOn = entity.state === 'on';
|
||||
const statusTone = unavailable ? 'warn' : isOn ? 'success' : 'muted';
|
||||
const statusLabel = unavailable ? 'Unavailable' : isOn ? 'On' : 'Off';
|
||||
const disableToggle = !connected || unavailable;
|
||||
|
||||
return (
|
||||
<article className="flex items-center justify-between gap-1 rounded border border-slate-800 bg-zinc-950 px-1 py-0.5">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1 text-sm text-white">
|
||||
<span className="truncate font-semibold">{entity.name || entity.id}</span>
|
||||
<StatusBadge label={entity.type === 'light' ? 'Light' : 'Switch'} />
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-xs text-slate-400">
|
||||
<StatusBadge label={statusLabel} tone={statusTone} />
|
||||
{!connected && <span className="text-amber-200"> · Offline</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
className="button-dark whitespace-nowrap px-1 py-0.5 text-xs"
|
||||
disabled={disableToggle}
|
||||
onClick={() => onToggle(entity.id)}
|
||||
>
|
||||
{isOn ? 'Turn off' : 'Turn on'}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HomeAssistantControls() {
|
||||
const { session, homeAssistantToggle } = useSession();
|
||||
const ha = session?.homeAssistant;
|
||||
const entities = useMemo(() => ha?.entities || [], [ha?.entities]);
|
||||
|
||||
if (!ha?.enabled) {
|
||||
return (
|
||||
<section className="panel-section space-y-0.5 text-sm text-slate-400">
|
||||
<p className="text-slate-300">Light Controls</p>
|
||||
<p className="text-slate-500">Not configured on the server.</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (entities.length === 0) {
|
||||
return (
|
||||
<section className="panel-section space-y-0.5 text-sm text-slate-400">
|
||||
<p className="text-slate-300">Light Controls</p>
|
||||
<p className="text-slate-500">No lights or switches configured.</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const connected = Boolean(ha?.connected);
|
||||
|
||||
return (
|
||||
<section className="panel-section space-y-0.5 text-base">
|
||||
<header className="flex items-center justify-between gap-0.5 text-sm text-slate-400">
|
||||
<div className="flex items-center gap-1">
|
||||
<p>Home Assistant</p>
|
||||
<span className="text-xs text-slate-500">{entities.length}</span>
|
||||
</div>
|
||||
<StatusBadge label={connected ? 'Connected' : 'Offline'} tone={connected ? 'success' : 'warn'} />
|
||||
</header>
|
||||
<div className="space-y-0.5">
|
||||
{entities.map((entity) => (
|
||||
<EntityRow
|
||||
key={entity.id}
|
||||
entity={entity}
|
||||
connected={connected}
|
||||
onToggle={homeAssistantToggle}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -2,19 +2,11 @@ import TelemetryPanel from './TelemetryPanel.jsx';
|
||||
import DrivePanel from './DrivePanel.jsx';
|
||||
import CameraServoPanel from './CameraServoPanel.jsx';
|
||||
import RoomCameraPanel from './RoomCameraPanel.jsx';
|
||||
import HomeAssistantControls from './HomeAssistantControls.jsx';
|
||||
import SettingsPanel from './SettingsPanel.jsx';
|
||||
import HelpPanel from './HelpPanel.jsx';
|
||||
import Tabs, { Tab, TabList, TabPanel, TabPanels } from './Tabs.jsx';
|
||||
|
||||
function RoomControlsPlaceholder() {
|
||||
return (
|
||||
<div className="panel-section space-y-0.5 text-sm">
|
||||
<p className="text-slate-400">Room controls</p>
|
||||
<p className="text-slate-200">Coming soon: lighting, docks, and environmental toggles.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RightPaneTabs({ layout }) {
|
||||
return (
|
||||
<section className="panel text-base">
|
||||
@@ -37,7 +29,7 @@ export default function RightPaneTabs({ layout }) {
|
||||
<TabPanel id="room">
|
||||
<div className="space-y-0.5">
|
||||
<RoomCameraPanel defaultOrientation="vertical" />
|
||||
<RoomControlsPlaceholder />
|
||||
<HomeAssistantControls />
|
||||
</div>
|
||||
</TabPanel>
|
||||
<TabPanel id="settings">
|
||||
|
||||
@@ -12,6 +12,8 @@ const SessionContext = createContext({
|
||||
requestControl: async () => {},
|
||||
releaseControl: async () => {},
|
||||
subscribeAll: async () => {},
|
||||
homeAssistantToggle: async () => {},
|
||||
homeAssistantSetState: async () => {},
|
||||
});
|
||||
|
||||
function useAckEmitter(socket) {
|
||||
@@ -89,6 +91,9 @@ export function SessionProvider({ children }) {
|
||||
subscribeAll: () => emitWithAck('session:subscribeAll'),
|
||||
lockRover: (roverId, locked) => emitWithAck('session:lockRover', { roverId, locked }),
|
||||
setMode: (mode) => emitWithAck('setMode', { mode }),
|
||||
homeAssistantToggle: (entityId) => emitWithAck('homeAssistant:toggle', { entityId }),
|
||||
homeAssistantSetState: (entityId, state) =>
|
||||
emitWithAck('homeAssistant:setState', { entityId, state }),
|
||||
}),
|
||||
[emitWithAck],
|
||||
);
|
||||
|
||||
@@ -42,18 +42,22 @@ export default function GamepadInputManager() {
|
||||
const cameraDeadzone = Math.min(Math.max(gamepadSettings.cameraDeadzone ?? 0.25, 0), 0.9);
|
||||
const servoStep = gamepadSettings.servoStep ?? INPUT_SETTINGS_DEFAULTS.gamepad.servoStep;
|
||||
const auxReverseScale = gamepadSettings.auxReverseScale ?? INPUT_SETTINGS_DEFAULTS.gamepad.auxReverseScale;
|
||||
const driveReady = Boolean(mapping?.drive?.horizontal && mapping?.drive?.vertical);
|
||||
const cameraReady = Boolean(mapping?.camera?.vertical);
|
||||
const mainAxisReady = Boolean(mapping?.brushes?.mainAxis);
|
||||
const sideAxisReady = Boolean(mapping?.brushes?.sideAxis);
|
||||
const brushesReady = mainAxisReady || sideAxisReady;
|
||||
const vacuumReady = Boolean(mapping?.buttons?.vacuum);
|
||||
const allAuxReady = Boolean(mapping?.buttons?.allAux);
|
||||
const auxButtonsReady = vacuumReady || allAuxReady;
|
||||
const mainReverseReady = Boolean(mapping?.buttons?.mainReverse);
|
||||
const sideReverseReady = Boolean(mapping?.buttons?.sideReverse);
|
||||
const reverseButtonsReady = mainReverseReady || sideReverseReady;
|
||||
const driveMacroReady = Boolean(mapping?.buttons?.drive);
|
||||
const dockMacroReady = Boolean(mapping?.buttons?.dock);
|
||||
const macrosReady = driveMacroReady || dockMacroReady;
|
||||
const mappingReady =
|
||||
Boolean(mapping?.drive?.horizontal) &&
|
||||
Boolean(mapping?.drive?.vertical) &&
|
||||
Boolean(mapping?.camera?.vertical) &&
|
||||
Boolean(mapping?.brushes?.mainAxis) &&
|
||||
Boolean(mapping?.brushes?.sideAxis) &&
|
||||
Boolean(mapping?.buttons?.mainReverse) &&
|
||||
Boolean(mapping?.buttons?.sideReverse) &&
|
||||
Boolean(mapping?.buttons?.vacuum) &&
|
||||
Boolean(mapping?.buttons?.allAux) &&
|
||||
Boolean(mapping?.buttons?.drive) &&
|
||||
Boolean(mapping?.buttons?.dock);
|
||||
driveReady || cameraReady || brushesReady || auxButtonsReady || reverseButtonsReady || macrosReady;
|
||||
const rafRef = useRef(null);
|
||||
const lastVectorRef = useRef({ x: 0, y: 0, boost: false });
|
||||
const lastAuxRef = useRef({ main: 0, side: 0, vacuum: 0 });
|
||||
@@ -106,32 +110,31 @@ export default function GamepadInputManager() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mappingReady) {
|
||||
registerInputState(SOURCE, { connected: true, mappingReady: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const axisLX = clampUnit(applyDeadzone(getAxisValue(pad, mapping.drive.horizontal), driveDeadzone));
|
||||
const axisLY = clampUnit(applyDeadzone(-getAxisValue(pad, mapping.drive.vertical), driveDeadzone));
|
||||
const axisLX = driveReady
|
||||
? clampUnit(applyDeadzone(getAxisValue(pad, mapping.drive.horizontal), driveDeadzone))
|
||||
: 0;
|
||||
const axisLY = driveReady
|
||||
? clampUnit(applyDeadzone(-getAxisValue(pad, mapping.drive.vertical), driveDeadzone))
|
||||
: 0;
|
||||
const vector = { x: axisLX, y: axisLY, boost: false };
|
||||
if (!vectorsEqual(vector, lastVectorRef.current)) {
|
||||
lastVectorRef.current = vector;
|
||||
setDriveVector(vector, { source: SOURCE });
|
||||
}
|
||||
|
||||
const mainRaw = getAxisValue(pad, mapping.brushes.mainAxis);
|
||||
const mainRaw = mainAxisReady ? getAxisValue(pad, mapping.brushes.mainAxis) : 0;
|
||||
const mainMagnitude = Math.round(Math.min(Math.abs(mainRaw), 1) * 127);
|
||||
const main = reverseStateRef.current.main ? -mainMagnitude : mainMagnitude;
|
||||
|
||||
const sideRaw = getAxisValue(pad, mapping.brushes.sideAxis);
|
||||
const sideRaw = sideAxisReady ? getAxisValue(pad, mapping.brushes.sideAxis) : 0;
|
||||
const sideMagnitude = Math.round(Math.min(Math.abs(sideRaw), 1) * 127);
|
||||
const side = reverseStateRef.current.side
|
||||
? -Math.round(sideMagnitude * auxReverseScale)
|
||||
: Math.round(sideMagnitude * auxReverseScale);
|
||||
|
||||
const vacuum = isButtonPressed(pad, mapping.buttons.vacuum) ? 127 : 0;
|
||||
let aux = { main, side, vacuum };
|
||||
if (isButtonPressed(pad, mapping.buttons.allAux)) {
|
||||
const vacuum = vacuumReady && isButtonPressed(pad, mapping.buttons.vacuum) ? 127 : 0;
|
||||
let aux = { main: mainAxisReady ? main : 0, side: sideAxisReady ? side : 0, vacuum };
|
||||
if (allAuxReady && isButtonPressed(pad, mapping.buttons.allAux)) {
|
||||
aux = { main: 127, side: 127, vacuum: 127 };
|
||||
}
|
||||
if (!auxEqual(aux, lastAuxRef.current)) {
|
||||
@@ -139,36 +142,58 @@ export default function GamepadInputManager() {
|
||||
setAuxMotors(aux);
|
||||
}
|
||||
|
||||
const cameraAxis = clampUnit(applyDeadzone(-getAxisValue(pad, mapping.camera.vertical), cameraDeadzone));
|
||||
const cameraAxis = cameraReady
|
||||
? clampUnit(applyDeadzone(-getAxisValue(pad, mapping.camera.vertical), cameraDeadzone))
|
||||
: 0;
|
||||
const now = performance.now();
|
||||
if (Math.abs(cameraAxis) > 0.25 && now - servoThrottleRef.current > 120) {
|
||||
if (cameraReady && Math.abs(cameraAxis) > 0.25 && now - servoThrottleRef.current > 120) {
|
||||
servoThrottleRef.current = now;
|
||||
nudgeServo(cameraAxis > 0 ? servoStep : -servoStep);
|
||||
}
|
||||
|
||||
if (handleButtonEdge('toggle-main', isButtonPressed(pad, mapping.buttons.mainReverse))) {
|
||||
if (
|
||||
mainReverseReady &&
|
||||
handleButtonEdge('toggle-main', isButtonPressed(pad, mapping.buttons.mainReverse))
|
||||
) {
|
||||
reverseStateRef.current.main = !reverseStateRef.current.main;
|
||||
}
|
||||
if (handleButtonEdge('toggle-side', isButtonPressed(pad, mapping.buttons.sideReverse))) {
|
||||
if (
|
||||
sideReverseReady &&
|
||||
handleButtonEdge('toggle-side', isButtonPressed(pad, mapping.buttons.sideReverse))
|
||||
) {
|
||||
reverseStateRef.current.side = !reverseStateRef.current.side;
|
||||
}
|
||||
|
||||
if (handleButtonEdge(`macro-${mapping.buttons.drive.index}`, isButtonPressed(pad, mapping.buttons.drive))) {
|
||||
if (
|
||||
driveMacroReady &&
|
||||
handleButtonEdge(`macro-${mapping.buttons.drive.index}`, isButtonPressed(pad, mapping.buttons.drive))
|
||||
) {
|
||||
setMode('drive');
|
||||
runMacro('drive-sequence');
|
||||
}
|
||||
if (handleButtonEdge(`macro-${mapping.buttons.dock.index}`, isButtonPressed(pad, mapping.buttons.dock))) {
|
||||
if (
|
||||
dockMacroReady &&
|
||||
handleButtonEdge(`macro-${mapping.buttons.dock.index}`, isButtonPressed(pad, mapping.buttons.dock))
|
||||
) {
|
||||
setMode('dock');
|
||||
runMacro('seek-dock');
|
||||
}
|
||||
|
||||
registerInputState(SOURCE, {
|
||||
connected: true,
|
||||
mappingReady: true,
|
||||
mappingReady,
|
||||
id: pad.id,
|
||||
index: pad.index,
|
||||
axes: [axisLX, axisLY, cameraAxis],
|
||||
reverse: { ...reverseStateRef.current },
|
||||
bindings: {
|
||||
drive: driveReady,
|
||||
camera: cameraReady,
|
||||
brushes: brushesReady,
|
||||
auxButtons: { vacuum: vacuumReady, allAux: allAuxReady },
|
||||
reverseButtons: { main: mainReverseReady, side: sideReverseReady },
|
||||
macros: { drive: driveMacroReady, dock: dockMacroReady },
|
||||
},
|
||||
});
|
||||
}, [
|
||||
auxReverseScale,
|
||||
|
||||
Reference in New Issue
Block a user