mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
neato lidara
This commit is contained in:
@@ -38,6 +38,10 @@ homeAssistant:
|
|||||||
# ESPHome device name, used to derive gen3 entities:
|
# ESPHome device name, used to derive gen3 entities:
|
||||||
# button.<device>_house_clean, button.<device>_send_to_base, button.<device>_locate_robot, etc.
|
# button.<device>_house_clean, button.<device>_send_to_base, button.<device>_locate_robot, etc.
|
||||||
device: "neato_vacuum"
|
device: "neato_vacuum"
|
||||||
|
# Direct ESPHome API connection used for lidar log streaming.
|
||||||
|
brainslugHost: "neato-vacuum.local"
|
||||||
|
brainslugPort: 6053
|
||||||
|
brainslugKey: "REPLACE_WITH_ESPHOME_NOISE_PSK"
|
||||||
lift:
|
lift:
|
||||||
# Two Home Assistant switches controlling lift direction.
|
# Two Home Assistant switches controlling lift direction.
|
||||||
# Raise sequence: down off -> wait interlockMs -> up on
|
# Raise sequence: down off -> wait interlockMs -> up on
|
||||||
|
|||||||
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
@@ -11,8 +11,8 @@
|
|||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
||||||
<title>Multi Roomba Rover</title>
|
<title>Multi Roomba Rover</title>
|
||||||
<script type="module" crossorigin src="/assets/index-CKRKhxHH.js"></script>
|
<script type="module" crossorigin src="/assets/index-CZ7Wc8AL.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-EE3dCtON.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-BNzmusoM.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const io = require('../../globals/io');
|
|||||||
const logger = require('../../globals/logger').child('neatoService');
|
const logger = require('../../globals/logger').child('neatoService');
|
||||||
const { loadConfig } = require('../../helpers/configLoader');
|
const { loadConfig } = require('../../helpers/configLoader');
|
||||||
const { isVerified } = require('../verificationService');
|
const { isVerified } = require('../verificationService');
|
||||||
|
const { createLidarRuntime } = require('./lidarRuntime');
|
||||||
const {
|
const {
|
||||||
homeAssistantEvents,
|
homeAssistantEvents,
|
||||||
getRawEntitySnapshot,
|
getRawEntitySnapshot,
|
||||||
@@ -27,6 +28,10 @@ function normalizeDeviceName(value) {
|
|||||||
|
|
||||||
const device = normalizeDeviceName(neatoConfig.device);
|
const device = normalizeDeviceName(neatoConfig.device);
|
||||||
const RESUME_DELAY_MS = 3000;
|
const RESUME_DELAY_MS = 3000;
|
||||||
|
const brainslugHost = String(neatoConfig.brainslugHost || '').trim();
|
||||||
|
const brainslugPort = Number(neatoConfig.brainslugPort) || 6053;
|
||||||
|
const brainslugKey = String(neatoConfig.brainslugKey || '').trim();
|
||||||
|
let lidarRuntime = null;
|
||||||
|
|
||||||
function entityId(domain, suffix) {
|
function entityId(domain, suffix) {
|
||||||
if (!device) return '';
|
if (!device) return '';
|
||||||
@@ -159,6 +164,7 @@ function buildState() {
|
|||||||
enabled,
|
enabled,
|
||||||
configured,
|
configured,
|
||||||
connected,
|
connected,
|
||||||
|
lidarConnected: lidarRuntime?.getState?.().connected || false,
|
||||||
device,
|
device,
|
||||||
entityPrefix: device ? `${device}_` : '',
|
entityPrefix: device ? `${device}_` : '',
|
||||||
controls,
|
controls,
|
||||||
@@ -219,6 +225,14 @@ async function pressButton(entityIdValue, actionLabel) {
|
|||||||
logger.info('Issued Neato action', { action: actionLabel, entityId: entityIdValue });
|
logger.info('Issued Neato action', { action: actionLabel, entityId: entityIdValue });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function requestLidarScan() {
|
||||||
|
assertConfiguredAndConnected();
|
||||||
|
if (!device) {
|
||||||
|
throw new Error('Neato not configured');
|
||||||
|
}
|
||||||
|
await callHomeAssistantService('esphome', `${device}_send_cmd`, { command: 'GetLDSScan' });
|
||||||
|
}
|
||||||
|
|
||||||
async function startCleaning() {
|
async function startCleaning() {
|
||||||
await pressButton(ENTITY_IDS.buttons.start, 'start');
|
await pressButton(ENTITY_IDS.buttons.start, 'start');
|
||||||
await new Promise((resolve) => setTimeout(resolve, RESUME_DELAY_MS));
|
await new Promise((resolve) => setTimeout(resolve, RESUME_DELAY_MS));
|
||||||
@@ -246,6 +260,44 @@ function getState() {
|
|||||||
return cachedState;
|
return cachedState;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasVerifiedSockets() {
|
||||||
|
for (const socket of io.sockets.sockets.values()) {
|
||||||
|
if (isVerified(socket)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function broadcastLidarScan(payload) {
|
||||||
|
for (const socket of io.sockets.sockets.values()) {
|
||||||
|
if (!isVerified(socket)) continue;
|
||||||
|
socket.emit('neato:lidar', payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lidarRuntime =
|
||||||
|
brainslugHost && brainslugKey
|
||||||
|
? createLidarRuntime({
|
||||||
|
logger,
|
||||||
|
host: brainslugHost,
|
||||||
|
port: brainslugPort,
|
||||||
|
key: brainslugKey,
|
||||||
|
shouldPoll: () => Boolean(homeAssistantEnabled && isHomeAssistantConnected() && hasVerifiedSockets()),
|
||||||
|
requestScan: requestLidarScan,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (lidarRuntime) {
|
||||||
|
lidarRuntime.on('scan', (payload) => {
|
||||||
|
broadcastLidarScan(payload);
|
||||||
|
});
|
||||||
|
lidarRuntime.on('status', () => {
|
||||||
|
emitUpdate();
|
||||||
|
});
|
||||||
|
lidarRuntime.start();
|
||||||
|
} else if (device) {
|
||||||
|
logger.info('Neato lidar stream disabled; brainslugHost/brainslugKey missing');
|
||||||
|
}
|
||||||
|
|
||||||
io.on('connection', (socket) => {
|
io.on('connection', (socket) => {
|
||||||
socket.on('neato:start', async (_, cb = () => {}) => {
|
socket.on('neato:start', async (_, cb = () => {}) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
const { spawn } = require('child_process');
|
||||||
|
const EventEmitter = require('events');
|
||||||
|
|
||||||
|
const POLL_INTERVAL_MS = 1000;
|
||||||
|
const SCAN_TIMEOUT_MS = 6000;
|
||||||
|
const RECONNECT_DELAY_MS = 5000;
|
||||||
|
|
||||||
|
function parseQuotedPayload(line) {
|
||||||
|
const match = String(line || '').match(/<<< "(.*)"$/);
|
||||||
|
if (!match) return null;
|
||||||
|
return match[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePointPayload(payload) {
|
||||||
|
const match = String(payload || '').match(/^(\d+),(\d+),(\d+),([0-9A-Fa-f]+)$/);
|
||||||
|
if (!match) return null;
|
||||||
|
const angleDeg = Number(match[1]);
|
||||||
|
const distanceMm = Number(match[2]);
|
||||||
|
const intensity = Number(match[3]);
|
||||||
|
const errorCodeHex = String(match[4]).toUpperCase();
|
||||||
|
return {
|
||||||
|
angleDeg,
|
||||||
|
distanceMm,
|
||||||
|
intensity,
|
||||||
|
errorCodeHex,
|
||||||
|
valid: errorCodeHex === '0' && distanceMm > 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRotationSpeed(payload) {
|
||||||
|
const match = String(payload || '').match(/^ROTATION_SPEED,([0-9.]+)$/);
|
||||||
|
if (!match) return null;
|
||||||
|
return Number(match[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLidarRuntime({ logger, host, port = 6053, key, shouldPoll, requestScan }) {
|
||||||
|
const events = new EventEmitter();
|
||||||
|
const state = {
|
||||||
|
connected: false,
|
||||||
|
process: null,
|
||||||
|
reconnectTimer: null,
|
||||||
|
pollTimer: null,
|
||||||
|
stdoutBuffer: '',
|
||||||
|
currentScan: null,
|
||||||
|
requestInFlight: false,
|
||||||
|
requestStartedAt: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
function emitStatus() {
|
||||||
|
events.emit('status', { connected: state.connected });
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearReconnectTimer() {
|
||||||
|
if (!state.reconnectTimer) return;
|
||||||
|
clearTimeout(state.reconnectTimer);
|
||||||
|
state.reconnectTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleReconnect() {
|
||||||
|
if (state.reconnectTimer) return;
|
||||||
|
state.reconnectTimer = setTimeout(() => {
|
||||||
|
state.reconnectTimer = null;
|
||||||
|
startLogStream();
|
||||||
|
}, RECONNECT_DELAY_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetScanState() {
|
||||||
|
state.currentScan = null;
|
||||||
|
state.requestInFlight = false;
|
||||||
|
state.requestStartedAt = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function finalizeScan() {
|
||||||
|
if (!state.currentScan) {
|
||||||
|
resetScanState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const points = Array.from(state.currentScan.points.values()).sort((a, b) => a.angleDeg - b.angleDeg);
|
||||||
|
const payload = {
|
||||||
|
points,
|
||||||
|
rotationSpeed: state.currentScan.rotationSpeed,
|
||||||
|
};
|
||||||
|
resetScanState();
|
||||||
|
events.emit('scan', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePayload(payload) {
|
||||||
|
if (!payload) return;
|
||||||
|
if (payload === 'AngleInDegrees,DistInMM,Intensity,ErrorCodeHEX') {
|
||||||
|
state.currentScan = {
|
||||||
|
points: new Map(),
|
||||||
|
rotationSpeed: null,
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rotationSpeed = parseRotationSpeed(payload);
|
||||||
|
if (rotationSpeed != null) {
|
||||||
|
if (state.currentScan) state.currentScan.rotationSpeed = rotationSpeed;
|
||||||
|
finalizeScan();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const point = parsePointPayload(payload);
|
||||||
|
if (!point || !state.currentScan) return;
|
||||||
|
state.currentScan.points.set(point.angleDeg, point);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleStdoutChunk(chunk) {
|
||||||
|
state.stdoutBuffer += String(chunk || '');
|
||||||
|
const lines = state.stdoutBuffer.split(/\r?\n/);
|
||||||
|
state.stdoutBuffer = lines.pop() || '';
|
||||||
|
for (const line of lines) {
|
||||||
|
const payload = parseQuotedPayload(line);
|
||||||
|
handlePayload(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function teardownProcess() {
|
||||||
|
const proc = state.process;
|
||||||
|
state.process = null;
|
||||||
|
state.connected = false;
|
||||||
|
emitStatus();
|
||||||
|
resetScanState();
|
||||||
|
if (!proc) return;
|
||||||
|
proc.removeAllListeners();
|
||||||
|
proc.stdout?.removeAllListeners();
|
||||||
|
proc.stderr?.removeAllListeners();
|
||||||
|
try {
|
||||||
|
proc.kill();
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Failed to stop Neato lidar log process', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startLogStream() {
|
||||||
|
if (!host || !key) return;
|
||||||
|
if (state.process) return;
|
||||||
|
|
||||||
|
const args = [
|
||||||
|
'--from',
|
||||||
|
'aioesphomeapi',
|
||||||
|
'aioesphomeapi-logs',
|
||||||
|
host,
|
||||||
|
'--port',
|
||||||
|
String(port || 6053),
|
||||||
|
'--noise-psk',
|
||||||
|
key,
|
||||||
|
'--no-states',
|
||||||
|
];
|
||||||
|
|
||||||
|
logger.info('Starting Neato lidar log stream', { host, port: Number(port || 6053) });
|
||||||
|
const proc = spawn('uvx', args, {
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
state.process = proc;
|
||||||
|
state.stdoutBuffer = '';
|
||||||
|
|
||||||
|
proc.stdout.on('data', (chunk) => {
|
||||||
|
if (!state.connected) {
|
||||||
|
state.connected = true;
|
||||||
|
emitStatus();
|
||||||
|
}
|
||||||
|
handleStdoutChunk(chunk);
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.stderr.on('data', (chunk) => {
|
||||||
|
const message = String(chunk || '').trim();
|
||||||
|
if (message) logger.warn('Neato lidar log stream stderr', message);
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.on('close', (code, signal) => {
|
||||||
|
logger.warn('Neato lidar log stream stopped', { code, signal });
|
||||||
|
teardownProcess();
|
||||||
|
scheduleReconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.on('error', (err) => {
|
||||||
|
logger.warn('Neato lidar log stream error', err.message);
|
||||||
|
teardownProcess();
|
||||||
|
scheduleReconnect();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tickPoll() {
|
||||||
|
if (!state.connected) return;
|
||||||
|
if (!shouldPoll?.()) return;
|
||||||
|
if (state.requestInFlight) {
|
||||||
|
if (Date.now() - state.requestStartedAt >= SCAN_TIMEOUT_MS) {
|
||||||
|
logger.warn('Neato lidar scan timed out; resetting parser state');
|
||||||
|
resetScanState();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.requestInFlight = true;
|
||||||
|
state.requestStartedAt = Date.now();
|
||||||
|
try {
|
||||||
|
await requestScan?.();
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Failed to request Neato lidar scan', err.message);
|
||||||
|
resetScanState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
startLogStream();
|
||||||
|
if (!state.pollTimer) {
|
||||||
|
state.pollTimer = setInterval(() => {
|
||||||
|
tickPoll();
|
||||||
|
}, POLL_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop() {
|
||||||
|
clearReconnectTimer();
|
||||||
|
if (state.pollTimer) {
|
||||||
|
clearInterval(state.pollTimer);
|
||||||
|
state.pollTimer = null;
|
||||||
|
}
|
||||||
|
teardownProcess();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getState() {
|
||||||
|
return {
|
||||||
|
connected: state.connected,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
start,
|
||||||
|
stop,
|
||||||
|
getState,
|
||||||
|
on: (...args) => events.on(...args),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createLidarRuntime,
|
||||||
|
};
|
||||||
@@ -15,6 +15,7 @@ import VipLiftCard from '../vip/VipLiftCard.jsx';
|
|||||||
export default function VipPanel() {
|
export default function VipPanel() {
|
||||||
const {
|
const {
|
||||||
session,
|
session,
|
||||||
|
neatoLidar,
|
||||||
identifySession,
|
identifySession,
|
||||||
requestVerification,
|
requestVerification,
|
||||||
requestPrivateRoverAccess,
|
requestPrivateRoverAccess,
|
||||||
@@ -78,6 +79,7 @@ export default function VipPanel() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<VipNeatoCard
|
<VipNeatoCard
|
||||||
neato={session?.neato || null}
|
neato={session?.neato || null}
|
||||||
|
lidar={neatoLidar}
|
||||||
onStart={neatoStart}
|
onStart={neatoStart}
|
||||||
onSendHome={neatoSendHome}
|
onSendHome={neatoSendHome}
|
||||||
onLocate={neatoLocate}
|
onLocate={neatoLocate}
|
||||||
|
|||||||
@@ -38,8 +38,26 @@ function StatusTile({ label, value, tone = 'muted', valueClass = '', hideLabel =
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildLidarDots(points = []) {
|
||||||
|
const center = 110;
|
||||||
|
const radius = 92;
|
||||||
|
const maxDistanceMm = 4000;
|
||||||
|
return points
|
||||||
|
.filter((point) => point && point.valid && Number.isFinite(point.angleDeg) && Number.isFinite(point.distanceMm))
|
||||||
|
.map((point) => {
|
||||||
|
const angleRad = ((Number(point.angleDeg) - 90) * Math.PI) / 180;
|
||||||
|
const normalized = Math.max(0, Math.min(1, Number(point.distanceMm) / maxDistanceMm));
|
||||||
|
const scaledRadius = normalized * radius;
|
||||||
|
const x = center + Math.cos(angleRad) * scaledRadius;
|
||||||
|
const y = center + Math.sin(angleRad) * scaledRadius;
|
||||||
|
return `${x},${y}`;
|
||||||
|
})
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
export default function VipNeatoCard({
|
export default function VipNeatoCard({
|
||||||
neato,
|
neato,
|
||||||
|
lidar,
|
||||||
onStart,
|
onStart,
|
||||||
onSendHome,
|
onSendHome,
|
||||||
onLocate,
|
onLocate,
|
||||||
@@ -63,9 +81,8 @@ export default function VipNeatoCard({
|
|||||||
const voltageLabel = Number.isFinite(voltage) ? `${voltage.toFixed(2)} V` : '--';
|
const voltageLabel = Number.isFinite(voltage) ? `${voltage.toFixed(2)} V` : '--';
|
||||||
const robotError = normalizeState(neato?.telemetry?.robotError) || '--';
|
const robotError = normalizeState(neato?.telemetry?.robotError) || '--';
|
||||||
const robotAlert = normalizeState(neato?.telemetry?.robotAlert) || '--';
|
const robotAlert = normalizeState(neato?.telemetry?.robotAlert) || '--';
|
||||||
|
const lidarPoints = Array.isArray(lidar?.points) ? lidar.points : [];
|
||||||
const hasError = robotError !== '--' && !/^no errors$/i.test(robotError) && !/^200/.test(robotError);
|
const lidarDots = buildLidarDots(lidarPoints);
|
||||||
const hasAlert = robotAlert !== '--' && !/^200/.test(robotAlert);
|
|
||||||
|
|
||||||
const controls = neato?.controls || {};
|
const controls = neato?.controls || {};
|
||||||
const canStart = Boolean(controls?.start?.available);
|
const canStart = Boolean(controls?.start?.available);
|
||||||
@@ -116,96 +133,122 @@ export default function VipNeatoCard({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-0.5">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-0.5">
|
||||||
<div className="surface-muted grid gap-0.5">
|
<div className="surface-muted grid gap-0.5">
|
||||||
<p className="text-xs text-slate-300 text-center">Controls</p>
|
<p className="text-xs text-slate-300 text-center">Controls</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!canRunStart || Boolean(working)}
|
||||||
|
onClick={() => runAction('start', onStart)}
|
||||||
|
className="rounded-md border border-sky-300 bg-emerald-600 px-1 py-1 text-base font-semibold text-white transition hover:border-sky-500 hover:bg-emerald-500 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{working === 'start' ? 'Starting...' : 'Start cleaning'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!canRunSendHome || Boolean(working)}
|
||||||
|
onClick={() => runAction('sendHome', onSendHome)}
|
||||||
|
className="rounded-md border border-sky-300 bg-sky-600 px-1 py-1 text-base font-semibold text-white transition hover:border-sky-500 hover:bg-sky-500 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{working === 'sendHome' ? 'Sending...' : 'Send to dock'}
|
||||||
|
</button>
|
||||||
|
<div className="grid grid-cols-3 gap-0.5">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!canRunStart || Boolean(working)}
|
disabled={!canRunLocate || Boolean(working)}
|
||||||
onClick={() => runAction('start', onStart)}
|
onClick={() => runAction('locate', onLocate)}
|
||||||
className="rounded-md border border-sky-300 bg-emerald-600 px-1 py-1 text-base font-semibold text-white transition hover:border-sky-500 hover:bg-emerald-500 disabled:opacity-50"
|
className="w-full rounded-md border border-sky-300 bg-fuchsia-600 px-1 py-0.5 text-xs font-semibold text-white transition hover:border-sky-500 hover:bg-fuchsia-500 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{working === 'start' ? 'Starting...' : 'Start cleaning'}
|
{working === 'locate' ? 'Playing...' : 'Play sound'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!canRunSendHome || Boolean(working)}
|
disabled={!canRunClearErrors || Boolean(working)}
|
||||||
onClick={() => runAction('sendHome', onSendHome)}
|
onClick={() => runAction('clearErrors', onClearErrors)}
|
||||||
className="rounded-md border border-sky-300 bg-sky-600 px-1 py-1 text-base font-semibold text-white transition hover:border-sky-500 hover:bg-sky-500 disabled:opacity-50"
|
className="w-full rounded-md border border-sky-300 bg-amber-500 px-1 py-0.5 text-xs font-semibold text-slate-900 transition hover:border-sky-500 hover:bg-amber-400 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{working === 'sendHome' ? 'Sending...' : 'Send to dock'}
|
{working === 'clearErrors' ? 'Clearing...' : 'Clear errors'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!canRunPowerCycle || Boolean(working)}
|
||||||
|
onClick={() => runAction('powerCycle', onPowerCycle)}
|
||||||
|
className="w-full rounded-md border border-rose-300 bg-rose-600 px-1 py-0.5 text-xs font-semibold text-white transition hover:border-rose-500 hover:bg-rose-500 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{working === 'powerCycle' ? 'Cycling...' : 'Power cycle'}
|
||||||
</button>
|
</button>
|
||||||
<div className="grid grid-cols-3 gap-0.5">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={!canRunLocate || Boolean(working)}
|
|
||||||
onClick={() => runAction('locate', onLocate)}
|
|
||||||
className="w-full rounded-md border border-sky-300 bg-fuchsia-600 px-1 py-0.5 text-xs font-semibold text-white transition hover:border-sky-500 hover:bg-fuchsia-500 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{working === 'locate' ? 'Playing...' : 'Play sound'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={!canRunClearErrors || Boolean(working)}
|
|
||||||
onClick={() => runAction('clearErrors', onClearErrors)}
|
|
||||||
className="w-full rounded-md border border-sky-300 bg-amber-500 px-1 py-0.5 text-xs font-semibold text-slate-900 transition hover:border-sky-500 hover:bg-amber-400 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{working === 'clearErrors' ? 'Clearing...' : 'Clear errors'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={!canRunPowerCycle || Boolean(working)}
|
|
||||||
onClick={() => runAction('powerCycle', onPowerCycle)}
|
|
||||||
className="w-full rounded-md border border-rose-300 bg-rose-600 px-1 py-0.5 text-xs font-semibold text-white transition hover:border-rose-500 hover:bg-rose-500 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{working === 'powerCycle' ? 'Cycling...' : 'Power cycle'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="surface-muted grid gap-0.5 pt-0.25">
|
|
||||||
<p className="text-xs text-slate-300 text-center">Power status</p>
|
|
||||||
<div className="grid grid-cols-2 gap-0.5">
|
|
||||||
<StatusTile label="Battery" value={batteryLabel} tone={batteryTone} />
|
|
||||||
<StatusTile label="Voltage" value={voltageLabel} tone="muted" />
|
|
||||||
<StatusTile
|
|
||||||
label="Docked"
|
|
||||||
value={docked ? 'Docked' : 'Not docked'}
|
|
||||||
tone={docked ? 'good' : 'muted'}
|
|
||||||
hideLabel
|
|
||||||
/>
|
|
||||||
<StatusTile
|
|
||||||
label="Charging"
|
|
||||||
value={charging ? 'Charging' : 'Not charging'}
|
|
||||||
tone={charging ? 'good' : 'muted'}
|
|
||||||
hideLabel
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="surface-muted grid gap-0.5 pt-0.25">
|
||||||
<div className="surface-muted grid gap-0.5">
|
<p className="text-xs text-slate-300 text-center">Power status</p>
|
||||||
<p className="text-xs text-slate-300 text-center">Robot Status</p>
|
<div className="grid grid-cols-2 gap-0.5">
|
||||||
<div className="rounded-md bg-slate-800 px-1 py-0.5">
|
<StatusTile label="Battery" value={batteryLabel} tone={batteryTone} />
|
||||||
<div className="text-[0.72rem] text-slate-300">Robot state (raw)</div>
|
<StatusTile label="Voltage" value={voltageLabel} tone="muted" />
|
||||||
<div className="font-mono text-sm text-slate-100 break-all">{robotStateRaw}</div>
|
<StatusTile
|
||||||
</div>
|
label="Docked"
|
||||||
<div className="rounded-md bg-slate-800 px-1 py-0.5">
|
value={docked ? 'Docked' : 'Not docked'}
|
||||||
<div className="text-[0.72rem] text-slate-300">Basic state</div>
|
tone={docked ? 'good' : 'muted'}
|
||||||
<div className="font-mono text-sm text-slate-100 break-all">{primaryState}</div>
|
hideLabel
|
||||||
</div>
|
/>
|
||||||
<div className="rounded-md bg-slate-800 px-1 py-0.5">
|
<StatusTile
|
||||||
<div className="text-[0.72rem] text-slate-300">UI state</div>
|
label="Charging"
|
||||||
<div className="font-mono text-sm text-slate-100 break-all">{uiStateLabel}</div>
|
value={charging ? 'charging' : 'not charging'}
|
||||||
</div>
|
tone={charging ? 'good' : 'muted'}
|
||||||
<div className="rounded-md bg-slate-800 px-1 py-0.5">
|
hideLabel
|
||||||
<div className="text-[0.72rem] text-slate-300">Robot error</div>
|
/>
|
||||||
<div className="font-mono text-sm text-slate-100 break-all">{robotError}</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-md bg-slate-800 px-1 py-0.5">
|
|
||||||
<div className="text-[0.72rem] text-slate-300">Robot alert</div>
|
|
||||||
<div className="font-mono text-sm text-slate-100 break-all">{robotAlert}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="surface-muted grid gap-0.5">
|
||||||
|
<p className="text-xs text-slate-300 text-center">Robot Status</p>
|
||||||
|
<div className="rounded-md bg-slate-800 px-1 py-0.5">
|
||||||
|
<div className="text-[0.72rem] text-slate-300">Robot state (raw)</div>
|
||||||
|
<div className="font-mono text-sm text-slate-100 break-all">{robotStateRaw}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md bg-slate-800 px-1 py-0.5">
|
||||||
|
<div className="text-[0.72rem] text-slate-300">Basic state</div>
|
||||||
|
<div className="font-mono text-sm text-slate-100 break-all">{primaryState}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md bg-slate-800 px-1 py-0.5">
|
||||||
|
<div className="text-[0.72rem] text-slate-300">UI state</div>
|
||||||
|
<div className="font-mono text-sm text-slate-100 break-all">{uiStateLabel}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md bg-slate-800 px-1 py-0.5">
|
||||||
|
<div className="text-[0.72rem] text-slate-300">Robot error</div>
|
||||||
|
<div className="font-mono text-sm text-slate-100 break-all">{robotError}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md bg-slate-800 px-1 py-0.5">
|
||||||
|
<div className="text-[0.72rem] text-slate-300">Robot alert</div>
|
||||||
|
<div className="font-mono text-sm text-slate-100 break-all">{robotAlert}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="surface-muted grid gap-0.5">
|
||||||
|
<p className="text-xs text-slate-300 text-center">Lidar</p>
|
||||||
|
<div className="rounded-md bg-slate-800 p-0.5">
|
||||||
|
{lidarDots ? (
|
||||||
|
<svg viewBox="0 0 220 220" className="w-full max-w-[26rem] mx-auto">
|
||||||
|
<circle cx="110" cy="110" r="92" fill="none" stroke="#475569" strokeWidth="1" />
|
||||||
|
<circle cx="110" cy="110" r="61" fill="none" stroke="#334155" strokeWidth="1" />
|
||||||
|
<circle cx="110" cy="110" r="31" fill="none" stroke="#1e293b" strokeWidth="1" />
|
||||||
|
<line x1="110" y1="18" x2="110" y2="202" stroke="#334155" strokeWidth="1" />
|
||||||
|
<line x1="18" y1="110" x2="202" y2="110" stroke="#334155" strokeWidth="1" />
|
||||||
|
<circle cx="110" cy="110" r="4" fill="#e2e8f0" />
|
||||||
|
<polyline
|
||||||
|
points={lidarDots}
|
||||||
|
fill="none"
|
||||||
|
stroke="#38bdf8"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<div className="py-3 text-center text-xs text-slate-400">Waiting for lidar scan...</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{!configured || !connected || !canStart || !canSendHome || !canLocate || !canClearErrors || !canPowerCycle ? (
|
{!configured || !connected || !canStart || !canSendHome || !canLocate || !canClearErrors || !canPowerCycle ? (
|
||||||
<p className="text-xs text-slate-400 text-center">
|
<p className="text-xs text-slate-400 text-center">
|
||||||
{!configured
|
{!configured
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useSocket } from './SocketContext.jsx';
|
|||||||
const INITIAL_STATE = {
|
const INITIAL_STATE = {
|
||||||
connected: false,
|
connected: false,
|
||||||
session: null,
|
session: null,
|
||||||
|
neatoLidar: null,
|
||||||
logs: [],
|
logs: [],
|
||||||
adminLogs: [],
|
adminLogs: [],
|
||||||
llmCommentaryState: null,
|
llmCommentaryState: null,
|
||||||
@@ -127,8 +128,13 @@ export function SessionProvider({ children }) {
|
|||||||
],
|
],
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
function handleNeatoLidar(payload = null) {
|
||||||
|
const next = payload && typeof payload === 'object' ? payload : null;
|
||||||
|
setState((prev) => ({ ...prev, neatoLidar: next }));
|
||||||
|
}
|
||||||
|
|
||||||
socket.on('session:sync', handleSession);
|
socket.on('session:sync', handleSession);
|
||||||
|
socket.on('neato:lidar', handleNeatoLidar);
|
||||||
socket.on('log:init', handleLogInit);
|
socket.on('log:init', handleLogInit);
|
||||||
socket.on('log:entry', handleLogEntry);
|
socket.on('log:entry', handleLogEntry);
|
||||||
socket.on('adminlog:init', handleAdminLogInit);
|
socket.on('adminlog:init', handleAdminLogInit);
|
||||||
@@ -137,6 +143,7 @@ export function SessionProvider({ children }) {
|
|||||||
socket.on('alert:new', handleAlertNew);
|
socket.on('alert:new', handleAlertNew);
|
||||||
return () => {
|
return () => {
|
||||||
socket.off('session:sync', handleSession);
|
socket.off('session:sync', handleSession);
|
||||||
|
socket.off('neato:lidar', handleNeatoLidar);
|
||||||
socket.off('log:init', handleLogInit);
|
socket.off('log:init', handleLogInit);
|
||||||
socket.off('log:entry', handleLogEntry);
|
socket.off('log:entry', handleLogEntry);
|
||||||
socket.off('adminlog:init', handleAdminLogInit);
|
socket.off('adminlog:init', handleAdminLogInit);
|
||||||
|
|||||||
Reference in New Issue
Block a user