mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
home assistant lights and other tweaks
This commit is contained in:
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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