home assistant lights and other tweaks

This commit is contained in:
legop3
2025-11-21 01:38:53 -05:00
parent e514b1b9e1
commit e2a3d78bc0
18 changed files with 497 additions and 63 deletions
+15
View File
@@ -0,0 +1,15 @@
# general idea:
- control on / off switches and lights in home assistant from the web UI
- use this:
- https://www.npmjs.com/package/home-assistant-js-websocket/v/3.1.2
- remember to ignore updates that are of the same states, this library will give you a lot of those
- switches and lights are configured in the server's config file
- give each one a name (no description)
- auto detect a switch type or light type
- deliver list of lights to the UI through the session service
- show realtime on/off status of the switches / lights in the UI
- create a react component for the controls
- it will automatically create a control for each switch / light
## permissions:
- even if someone isnt assigned to a rover, they should be able to control the switches / lights
+10
View File
@@ -12,6 +12,16 @@ media:
# http://<base>/<roverId>/whep
# Example: http://192.168.0.86:8889/video
whepBaseUrl: "http://192.168.0.86:8889/video"
homeAssistant:
url: "http://homeassistant.local:8123"
token: "REPLACE_WITH_LONG_LIVED_TOKEN"
entities:
- id: "light.lab_main"
name: "Lab Lights"
- id: "switch.dock_power"
name: "Dock Power"
# type is optional; if omitted it is inferred from the entity id (light/switch)
roomCameras:
- id: "lobby"
name: "Lobby Camera"
+1
View File
@@ -19,6 +19,7 @@ require('./src/services/videoSessions');
require('./src/services/videoAuthService');
require('./src/services/videoSocketService');
require('./src/services/logStreamService');
require('./src/services/homeAssistantService');
require('./src/services/sessionService');
require('./src/services/batteryManager');
require('./src/services/httpServer');
+1
View File
@@ -10,6 +10,7 @@
"dependencies": {
"bcrypt": "^6.0.0",
"express": "^4.19.2",
"home-assistant-js-websocket": "3.1.2",
"js-yaml": "^4.1.1",
"morgan": "^1.10.0",
"socket.io": "^4.7.5",
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>webui</title>
<script type="module" crossorigin src="/assets/index-BuX6SHdt.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BIPzbwX3.css">
<script type="module" crossorigin src="/assets/index-CDUUNtXX.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Cgkk5Ipu.css">
</head>
<body>
<div id="root"></div>
+243
View File
@@ -0,0 +1,243 @@
const EventEmitter = require('events');
const WebSocket = require('ws');
const {
createConnection,
createLongLivedTokenAuth,
subscribeEntities,
callService,
} = require('home-assistant-js-websocket');
const io = require('../globals/io');
const logger = require('../globals/logger').child('homeAssistantService');
const { loadConfig } = require('../helpers/configLoader');
// home-assistant-js-websocket expects a global WebSocket in Node.
if (!global.WebSocket) {
global.WebSocket = WebSocket;
}
const config = loadConfig();
const haConfig = config.homeAssistant || {};
const events = new EventEmitter();
const entityConfig = new Map(); // entityId -> { id, name, type }
const entityState = new Map(); // entityId -> normalized state
let connection = null;
let unsubscribeEntities = null;
let reconnectTimer = null;
let connected = false;
const enabled = Boolean(haConfig?.url && haConfig?.token);
function inferType(entityId, explicitType) {
if (explicitType === 'light' || explicitType === 'switch') {
return explicitType;
}
const domain = String(entityId || '').split('.')[0];
if (domain === 'light') return 'light';
return 'switch';
}
function normalizeConfigEntry(entry) {
if (!entry) return null;
const id = entry.id || entry.entityId || entry.entity_id;
if (!id) return null;
const type = inferType(id, entry.type);
const name = entry.name || null;
return { id: String(id), name, type };
}
function loadEntityConfig() {
entityConfig.clear();
const list = Array.isArray(haConfig?.entities) ? haConfig.entities : [];
list.forEach((entry) => {
const normalized = normalizeConfigEntry(entry);
if (normalized) {
entityConfig.set(normalized.id, normalized);
if (!entityState.has(normalized.id)) {
entityState.set(normalized.id, buildState(normalized, null));
}
}
});
logger.info('Loaded Home Assistant entities', { count: entityConfig.size });
}
function buildState(meta, raw) {
if (!meta) return null;
const name = meta.name || raw?.attributes?.friendly_name || meta.id;
if (!raw) {
return {
id: meta.id,
name,
type: meta.type,
state: 'unknown',
available: false,
lastChanged: null,
lastUpdated: null,
};
}
const rawState = raw.state;
const unavailable = rawState === 'unavailable' || rawState === 'unknown';
const state = unavailable ? 'unavailable' : rawState === 'on' ? 'on' : 'off';
return {
id: meta.id,
name,
type: meta.type,
state,
available: !unavailable,
lastChanged: raw.last_changed || null,
lastUpdated: raw.last_updated || null,
};
}
function emitUpdate() {
events.emit('update', getState());
}
function emitStatus() {
events.emit('status', getState());
}
function handleEntitySnapshot(snapshot = {}) {
let changed = false;
entityConfig.forEach((meta, id) => {
const raw = snapshot[id];
const next = buildState(meta, raw);
const prev = entityState.get(id);
if (
!prev ||
prev.state !== next.state ||
prev.available !== next.available ||
prev.lastChanged !== next.lastChanged
) {
entityState.set(id, next);
changed = true;
}
});
if (changed) {
emitUpdate();
}
}
function teardownConnection() {
if (unsubscribeEntities) {
try {
unsubscribeEntities();
} catch (err) {
logger.warn('Failed to unsubscribe entity stream', err.message);
}
}
unsubscribeEntities = null;
if (connection) {
try {
connection.close();
} catch (err) {
logger.warn('Error closing Home Assistant connection', err.message);
}
}
connection = null;
const wasConnected = connected;
connected = false;
if (wasConnected) {
emitStatus();
}
}
function scheduleReconnect(delayMs = 5000) {
if (!enabled) return;
if (reconnectTimer) return;
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
connect();
}, delayMs);
}
async function connect() {
if (!enabled) {
logger.info('Home Assistant integration disabled; missing url/token in config');
return;
}
if (connection) {
return;
}
try {
const auth = await createLongLivedTokenAuth(haConfig.url, haConfig.token);
connection = await createConnection({ auth });
connected = true;
emitStatus();
logger.info('Connected to Home Assistant');
unsubscribeEntities = subscribeEntities(connection, handleEntitySnapshot);
connection.addEventListener('disconnected', () => {
logger.warn('Home Assistant connection lost');
teardownConnection();
scheduleReconnect();
});
} catch (err) {
logger.warn('Home Assistant connection failed', err.message);
teardownConnection();
scheduleReconnect();
}
}
async function setEntityState(entityId, desiredState) {
if (!enabled) {
throw new Error('Home Assistant not configured');
}
const meta = entityConfig.get(entityId);
if (!meta) {
throw new Error('Unknown Home Assistant entity');
}
if (!connection) {
throw new Error('Home Assistant not connected');
}
const nextState = desiredState === 'on' ? 'on' : 'off';
const domain = meta.type === 'light' ? 'light' : 'switch';
const service = nextState === 'on' ? 'turn_on' : 'turn_off';
await callService(connection, domain, service, { entity_id: entityId });
logger.info('Issued Home Assistant command', { entityId, domain, service });
}
async function toggleEntity(entityId) {
const current = entityState.get(entityId);
const nextState = current?.state === 'on' ? 'off' : 'on';
return setEntityState(entityId, nextState);
}
function getState() {
const entities = Array.from(entityConfig.values()).map(
(meta) => entityState.get(meta.id) || buildState(meta, null),
);
return { enabled, connected, entities };
}
loadEntityConfig();
connect();
io.on('connection', (socket) => {
socket.on('homeAssistant:toggle', async ({ entityId } = {}, cb = () => {}) => {
try {
if (!entityId) throw new Error('entityId required');
await toggleEntity(entityId);
cb({ success: true });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('homeAssistant:setState', async ({ entityId, state } = {}, cb = () => {}) => {
try {
if (!entityId) throw new Error('entityId required');
await setEntityState(entityId, state);
cb({ success: true });
} catch (err) {
cb({ error: err.message });
}
});
});
module.exports = {
getState,
toggleEntity,
setEntityState,
homeAssistantEvents: events,
};
+14 -2
View File
@@ -7,6 +7,7 @@ const { managerEvents } = roverManager;
const assignmentService = require('./assignmentService');
const { getActiveDrivers, turnEvents } = require('./turnService');
const { getRoomCameras, roomCameraEvents } = require('./roomCameraService');
const { getState: getHomeAssistantState, homeAssistantEvents } = require('./homeAssistantService');
function buildSession(socket) {
return {
@@ -17,6 +18,7 @@ function buildSession(socket) {
assignment: assignmentService.describeAssignment(socket?.id || ''),
activeDrivers: getActiveDrivers(),
roomCameras: getRoomCameras(),
homeAssistant: getHomeAssistantState(),
};
}
@@ -85,11 +87,21 @@ roomCameraEvents.on('update', () => {
syncAll();
});
// sync all sockets 5 seconds
homeAssistantEvents.on('update', () => {
logger.info('Home Assistant state change; syncing all clients');
syncAll();
});
homeAssistantEvents.on('status', () => {
logger.info('Home Assistant status change; syncing all clients');
syncAll();
});
// sync all sockets 20 seconds
setInterval(() => {
logger.info('Periodic session sync for all clients');
syncAll();
}, 5000);
}, 20000);
module.exports = {
buildSession,
+3
View File
@@ -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">
+6 -5
View File
@@ -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 -10
View File
@@ -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">
+5
View File
@@ -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,