+ {action.type === 'axis' && (
+
+ )}
diff --git a/webui/src/components/HomeAssistantControls.jsx b/webui/src/components/HomeAssistantControls.jsx
new file mode 100644
index 00000000..b90c2087
--- /dev/null
+++ b/webui/src/components/HomeAssistantControls.jsx
@@ -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 (
+
+ {label}
+
+ );
+}
+
+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 (
+
+
+
+ {entity.name || entity.id}
+
+
+
+
+ {!connected && · Offline}
+
+
+
+
+
+
+ );
+}
+
+export default function HomeAssistantControls() {
+ const { session, homeAssistantToggle } = useSession();
+ const ha = session?.homeAssistant;
+ const entities = useMemo(() => ha?.entities || [], [ha?.entities]);
+
+ if (!ha?.enabled) {
+ return (
+
+ Light Controls
+ Not configured on the server.
+
+ );
+ }
+
+ if (entities.length === 0) {
+ return (
+
+ Light Controls
+ No lights or switches configured.
+
+ );
+ }
+
+ const connected = Boolean(ha?.connected);
+
+ return (
+
+
+
+
Home Assistant
+
{entities.length}
+
+
+
+
+ {entities.map((entity) => (
+
+ ))}
+
+
+ );
+}
diff --git a/webui/src/components/RightPaneTabs.jsx b/webui/src/components/RightPaneTabs.jsx
index 3daba650..90ce1c56 100644
--- a/webui/src/components/RightPaneTabs.jsx
+++ b/webui/src/components/RightPaneTabs.jsx
@@ -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 (
-
-
Room controls
-
Coming soon: lighting, docks, and environmental toggles.
-
- );
-}
-
export default function RightPaneTabs({ layout }) {
return (
@@ -37,7 +29,7 @@ export default function RightPaneTabs({ layout }) {
-
+
diff --git a/webui/src/context/SessionContext.jsx b/webui/src/context/SessionContext.jsx
index 8027f6eb..d5c4981b 100644
--- a/webui/src/context/SessionContext.jsx
+++ b/webui/src/context/SessionContext.jsx
@@ -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],
);
diff --git a/webui/src/controls/inputs/GamepadInputManager.jsx b/webui/src/controls/inputs/GamepadInputManager.jsx
index f0be02a7..3b303671 100644
--- a/webui/src/controls/inputs/GamepadInputManager.jsx
+++ b/webui/src/controls/inputs/GamepadInputManager.jsx
@@ -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,