mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
removing neato lidar in hopes of future actual robot map access/...
This commit is contained in:
+8
-15
@@ -1,24 +1,17 @@
|
||||
1. add faster way for admins to login
|
||||
2. custom webhook profile pictures for chat bridge in discord
|
||||
3. add tool call embeds or something for the llm bot in discord, probably not in web ui
|
||||
4. add discord bot typing thing for when someone requests a replay
|
||||
5. change replay title for ones requested from discord, something other than "requester driving rover"
|
||||
6. fix rover request spam queue cheat
|
||||
7. reorganize internal structure of video, HUD stuff...
|
||||
8. button box reward: ping @everyone, 7567 presses
|
||||
|
||||
7. rover descriptions. show in the HUD at the bottom or top for a few seconds, then fade away.
|
||||
8. reorganize internal structure of video, HUD stuff... [x]
|
||||
9. button box reward: ping @everyone, 7567 presses
|
||||
10. enable scrolling on horn frequencies, turn down frequency limit to like 3500
|
||||
11. make replays more instant, probably make segments shorter
|
||||
2. add tool call embeds or something for the llm bot in discord, probably not in web ui
|
||||
3. add discord bot typing thing for when someone requests a replay
|
||||
4. change replay title for ones requested from discord, something other than "requester driving rover"
|
||||
5. fix rover request spam queue cheat
|
||||
6. button box reward: ping @everyone, 7567 presses
|
||||
7. enable scrolling on horn frequencies, turn down frequency limit to like 3500
|
||||
8. make replays more instant, probably make segments shorter
|
||||
1. fix replay UI so it doesnt save anything in cookie
|
||||
12. rework drive / dock panel somehow to explain how to dock manually instead of relying on auto docking
|
||||
9. rework drive / dock panel somehow to explain how to dock manually instead of relying on auto docking
|
||||
1. maybe have a flag in the rover to choose between auto or manual directions
|
||||
2. probably have a short inline video that plays and shows the process
|
||||
3. manual docking mode
|
||||
1. have camera move down automatically and limit speed during manual docking mode
|
||||
13. add new rules section to overseer
|
||||
|
||||
# relative pipe dreams:
|
||||
1. VPS video forwarding
|
||||
|
||||
@@ -48,12 +48,6 @@ homeAssistant:
|
||||
# ESPHome device name, used to derive gen3 entities:
|
||||
# button.<device>_house_clean, button.<device>_send_to_base, button.<device>_locate_robot, etc.
|
||||
device: "neato_vacuum"
|
||||
# Direct ESPHome API connection used for lidar log streaming.
|
||||
brainslugHost: "neato-vacuum.local"
|
||||
brainslugPort: 6053
|
||||
brainslugKey: "REPLACE_WITH_ESPHOME_NOISE_PSK"
|
||||
# Optional: mirror raw ESPHome lidar log output to a local file for debugging.
|
||||
brainslugLogFile: "/tmp/brainslug-lidar.log"
|
||||
lift:
|
||||
# Two Home Assistant switches controlling lift direction.
|
||||
# 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-title" content="Roomba Rover" />
|
||||
<title>Roomba Rover</title>
|
||||
<script type="module" crossorigin src="/assets/index-D2WWdWRm.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DaFUl5D3.css">
|
||||
<script type="module" crossorigin src="/assets/index-DJ5y_1ZB.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-H2_aYh9X.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -8,7 +8,6 @@ const { loadConfig } = require('../../helpers/configLoader');
|
||||
const { isVerified } = require('../verificationService');
|
||||
const { getMode, MODES } = require('../modeManager');
|
||||
const { isLockdownAdmin } = require('../roleService');
|
||||
const { createLidarRuntime } = require('./lidarRuntime');
|
||||
const {
|
||||
homeAssistantEvents,
|
||||
getRawEntitySnapshot,
|
||||
@@ -30,12 +29,6 @@ function normalizeDeviceName(value) {
|
||||
|
||||
const device = normalizeDeviceName(neatoConfig.device);
|
||||
const RESUME_DELAY_MS = 3000;
|
||||
const OFFLINE_LIDAR_RETRY_MS = 10000;
|
||||
const brainslugHost = String(neatoConfig.brainslugHost || '').trim();
|
||||
const brainslugPort = Number(neatoConfig.brainslugPort) || 6053;
|
||||
const brainslugKey = String(neatoConfig.brainslugKey || '').trim();
|
||||
const brainslugLogFile = String(neatoConfig.brainslugLogFile || '').trim();
|
||||
let lidarRuntime = null;
|
||||
|
||||
function entityId(domain, suffix) {
|
||||
if (!device) return '';
|
||||
@@ -168,7 +161,6 @@ function buildState() {
|
||||
enabled,
|
||||
configured,
|
||||
connected,
|
||||
lidarConnected: lidarRuntime?.getState?.().connected || false,
|
||||
device,
|
||||
entityPrefix: device ? `${device}_` : '',
|
||||
controls,
|
||||
@@ -229,14 +221,6 @@ async function pressButton(entityIdValue, actionLabel) {
|
||||
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() {
|
||||
await pressButton(ENTITY_IDS.buttons.start, 'start');
|
||||
await new Promise((resolve) => setTimeout(resolve, RESUME_DELAY_MS));
|
||||
@@ -271,51 +255,6 @@ function hasVerifiedSockets() {
|
||||
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,
|
||||
logFile: brainslugLogFile,
|
||||
getPollReadiness: () => {
|
||||
const verifiedSockets = hasVerifiedSockets();
|
||||
if (!verifiedSockets) {
|
||||
return { allowed: false, delayMs: 1000 };
|
||||
}
|
||||
if (!homeAssistantEnabled || !isHomeAssistantConnected()) {
|
||||
return { allowed: false, delayMs: OFFLINE_LIDAR_RETRY_MS };
|
||||
}
|
||||
const neatoOnline = buildState().connected;
|
||||
if (!neatoOnline) {
|
||||
return { allowed: false, delayMs: OFFLINE_LIDAR_RETRY_MS };
|
||||
}
|
||||
return { allowed: true, delayMs: 0 };
|
||||
},
|
||||
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) => {
|
||||
function assertLockdownAccess() {
|
||||
if (getMode() === MODES.LOCKDOWN && !isLockdownAdmin(socket)) {
|
||||
|
||||
@@ -1,406 +0,0 @@
|
||||
const { spawn, spawnSync } = require('child_process');
|
||||
const EventEmitter = require('events');
|
||||
const fs = require('fs');
|
||||
|
||||
const SCAN_TIMEOUT_MS = 8000;
|
||||
const RECONNECT_DELAY_MS = 5000;
|
||||
const IDLE_RETRY_MS = 1000;
|
||||
const OFFLINE_RETRY_MS = 10000;
|
||||
const REQUEST_FAILURE_RETRY_MS = 10000;
|
||||
|
||||
function sanitizeLogText(value) {
|
||||
return String(value || '')
|
||||
.replace(/\u0000/g, '')
|
||||
.replace(/\x1B\[[0-9;]*[A-Za-z]/g, '');
|
||||
}
|
||||
|
||||
function parsePayloadFromLine(line) {
|
||||
const raw = sanitizeLogText(line).trim();
|
||||
if (!raw) return null;
|
||||
const quotedMatch = raw.match(/<<< "(.*)"$/);
|
||||
if (!quotedMatch) return null;
|
||||
const payload = quotedMatch[1];
|
||||
if (!payload) return null;
|
||||
if (
|
||||
payload.includes('\\r\\n') ||
|
||||
payload.includes('\\n') ||
|
||||
payload.includes('\\r') ||
|
||||
payload.includes('\r') ||
|
||||
payload.includes('\n')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (payload === 'AngleInDegrees,DistInMM,Intensity,ErrorCodeHEX') return payload;
|
||||
if (/^ROTATION_SPEED,[0-9.]+$/.test(payload)) return payload;
|
||||
if (/^\d+,\d+,\d+,[0-9A-Fa-f]+$/.test(payload)) return payload;
|
||||
return null;
|
||||
}
|
||||
|
||||
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, logFile = '', shouldPoll, getPollReadiness, requestScan }) {
|
||||
const events = new EventEmitter();
|
||||
const state = {
|
||||
connected: false,
|
||||
process: null,
|
||||
reconnectTimer: null,
|
||||
pollSoonTimer: null,
|
||||
requestTimeoutTimer: null,
|
||||
stdoutBuffer: '',
|
||||
stderrBuffer: '',
|
||||
currentScan: null,
|
||||
requestInFlight: false,
|
||||
requestStartedAt: 0,
|
||||
logStream: null,
|
||||
stats: {
|
||||
headersSeen: 0,
|
||||
rotationsSeen: 0,
|
||||
scansOk: 0,
|
||||
scansTimedOut: 0,
|
||||
scansRestarted: 0,
|
||||
rotationsWithoutScan: 0,
|
||||
parseablePayloads: 0,
|
||||
pointPayloads: 0,
|
||||
},
|
||||
};
|
||||
|
||||
function emitStatus() {
|
||||
events.emit('status', { connected: state.connected });
|
||||
}
|
||||
|
||||
function clearReconnectTimer() {
|
||||
if (!state.reconnectTimer) return;
|
||||
clearTimeout(state.reconnectTimer);
|
||||
state.reconnectTimer = null;
|
||||
}
|
||||
|
||||
function clearPollSoonTimer() {
|
||||
if (!state.pollSoonTimer) return;
|
||||
clearTimeout(state.pollSoonTimer);
|
||||
state.pollSoonTimer = null;
|
||||
}
|
||||
|
||||
function clearRequestTimeoutTimer() {
|
||||
if (!state.requestTimeoutTimer) return;
|
||||
clearTimeout(state.requestTimeoutTimer);
|
||||
state.requestTimeoutTimer = null;
|
||||
}
|
||||
|
||||
function triggerPollSoon() {
|
||||
if (state.pollSoonTimer) return;
|
||||
state.pollSoonTimer = setTimeout(() => {
|
||||
state.pollSoonTimer = null;
|
||||
tickPoll();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function schedulePollRetry(delayMs = IDLE_RETRY_MS) {
|
||||
if (state.pollSoonTimer) return;
|
||||
state.pollSoonTimer = setTimeout(() => {
|
||||
state.pollSoonTimer = null;
|
||||
tickPoll();
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (state.reconnectTimer) return;
|
||||
state.reconnectTimer = setTimeout(() => {
|
||||
state.reconnectTimer = null;
|
||||
startLogStream();
|
||||
}, RECONNECT_DELAY_MS);
|
||||
}
|
||||
|
||||
function resetScanState() {
|
||||
clearRequestTimeoutTimer();
|
||||
state.currentScan = null;
|
||||
state.requestInFlight = false;
|
||||
state.requestStartedAt = 0;
|
||||
}
|
||||
|
||||
function buildPoints(scan) {
|
||||
return Array.from(scan?.points?.values?.() || []).sort((a, b) => a.angleDeg - b.angleDeg);
|
||||
}
|
||||
|
||||
function emitFrame({ status, reason, scan = state.currentScan }) {
|
||||
const points = buildPoints(scan);
|
||||
const payload = {
|
||||
points,
|
||||
rotationSpeed: scan?.rotationSpeed ?? null,
|
||||
status,
|
||||
debug: {
|
||||
reason,
|
||||
pointsReceived: points.length,
|
||||
validPoints: points.filter((point) => point?.valid).length,
|
||||
requestInFlight: state.requestInFlight,
|
||||
requestStartedAt: state.requestStartedAt || null,
|
||||
stats: { ...state.stats },
|
||||
},
|
||||
};
|
||||
events.emit('scan', payload);
|
||||
return payload;
|
||||
}
|
||||
|
||||
function finalizeScan(reason = 'rotation_complete') {
|
||||
if (!state.currentScan) {
|
||||
state.stats.rotationsWithoutScan += 1;
|
||||
emitFrame({ status: 'error', reason: 'rotation_without_scan', scan: null });
|
||||
resetScanState();
|
||||
triggerPollSoon();
|
||||
return;
|
||||
}
|
||||
state.stats.scansOk += 1;
|
||||
const payload = emitFrame({ status: 'ok', reason });
|
||||
logger.info('Neato lidar scan parsed', {
|
||||
points: payload.points.length,
|
||||
rotationSpeed: payload.rotationSpeed,
|
||||
reason,
|
||||
});
|
||||
resetScanState();
|
||||
triggerPollSoon();
|
||||
}
|
||||
|
||||
function handlePayload(payload) {
|
||||
if (!payload) return;
|
||||
state.stats.parseablePayloads += 1;
|
||||
if (payload === 'AngleInDegrees,DistInMM,Intensity,ErrorCodeHEX') {
|
||||
state.stats.headersSeen += 1;
|
||||
if (state.currentScan) {
|
||||
state.stats.scansRestarted += 1;
|
||||
emitFrame({ status: 'error', reason: 'header_restart' });
|
||||
}
|
||||
state.currentScan = {
|
||||
points: new Map(),
|
||||
rotationSpeed: null,
|
||||
};
|
||||
return;
|
||||
}
|
||||
const rotationSpeed = parseRotationSpeed(payload);
|
||||
if (rotationSpeed != null) {
|
||||
state.stats.rotationsSeen += 1;
|
||||
if (state.currentScan) {
|
||||
state.currentScan.rotationSpeed = rotationSpeed;
|
||||
}
|
||||
finalizeScan('rotation_complete');
|
||||
return;
|
||||
}
|
||||
const point = parsePointPayload(payload);
|
||||
if (!point || !state.currentScan) return;
|
||||
state.stats.pointPayloads += 1;
|
||||
state.currentScan.points.set(point.angleDeg, point);
|
||||
}
|
||||
|
||||
function handleTextChunk(chunk, bufferKey, sourceLabel = '') {
|
||||
const text = String(chunk || '');
|
||||
if (state.logStream) {
|
||||
const prefix = sourceLabel ? `[${sourceLabel}] ` : '';
|
||||
state.logStream.write(
|
||||
text
|
||||
.split(/\r?\n/)
|
||||
.map((line, index, parts) => (index === parts.length - 1 && line === '' ? '' : `${prefix}${line}`))
|
||||
.join('\n'),
|
||||
);
|
||||
}
|
||||
state[bufferKey] += sanitizeLogText(text);
|
||||
const lines = state[bufferKey].split(/\r?\n/);
|
||||
state[bufferKey] = lines.pop() || '';
|
||||
for (const line of lines) {
|
||||
events.emit('line', {
|
||||
source: sourceLabel || 'unknown',
|
||||
line: sanitizeLogText(line),
|
||||
});
|
||||
const payload = parsePayloadFromLine(line);
|
||||
handlePayload(payload);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureConnected() {
|
||||
if (state.connected) return;
|
||||
state.connected = true;
|
||||
emitStatus();
|
||||
triggerPollSoon();
|
||||
}
|
||||
|
||||
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 ensureLogStream() {
|
||||
if (!logFile || state.logStream) return;
|
||||
try {
|
||||
state.logStream = fs.createWriteStream(logFile, { flags: 'a' });
|
||||
logger.info('Neato lidar raw log capture enabled', { logFile });
|
||||
} catch (err) {
|
||||
logger.warn('Failed to open Neato lidar raw log file', { logFile, error: err.message });
|
||||
state.logStream = null;
|
||||
}
|
||||
}
|
||||
|
||||
function closeLogStream() {
|
||||
if (!state.logStream) return;
|
||||
try {
|
||||
state.logStream.end();
|
||||
} catch (err) {
|
||||
logger.warn('Failed to close Neato lidar raw log file', err.message);
|
||||
}
|
||||
state.logStream = null;
|
||||
}
|
||||
|
||||
function startLogStream() {
|
||||
if (!host || !key) return;
|
||||
if (state.process) return;
|
||||
ensureLogStream();
|
||||
|
||||
logger.info('Starting Neato lidar log stream', { host, port: Number(port || 6053) });
|
||||
const env = {
|
||||
...process.env,
|
||||
PYTHONUNBUFFERED: '1',
|
||||
NO_COLOR: '1',
|
||||
TERM: 'dumb',
|
||||
};
|
||||
const uvxArgs = [
|
||||
'--from',
|
||||
'aioesphomeapi',
|
||||
'aioesphomeapi-logs',
|
||||
host,
|
||||
'--port',
|
||||
String(port || 6053),
|
||||
'--noise-psk',
|
||||
key,
|
||||
'--no-states',
|
||||
];
|
||||
const hasScript = spawnSync('bash', ['-lc', 'command -v script >/dev/null 2>&1'], { stdio: 'ignore' }).status === 0;
|
||||
const proc = hasScript
|
||||
? spawn('script', ['-qefc', `uvx ${uvxArgs.map((arg) => JSON.stringify(arg)).join(' ')}`, '/dev/null'], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env,
|
||||
})
|
||||
: spawn('uvx', uvxArgs, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env,
|
||||
});
|
||||
logger.info('Neato lidar log launch mode', { mode: hasScript ? 'script' : 'direct' });
|
||||
state.process = proc;
|
||||
state.stdoutBuffer = '';
|
||||
state.stderrBuffer = '';
|
||||
|
||||
proc.stdout.on('data', (chunk) => {
|
||||
ensureConnected();
|
||||
handleTextChunk(chunk, 'stdoutBuffer', 'stdout');
|
||||
});
|
||||
|
||||
proc.stderr.on('data', (chunk) => {
|
||||
ensureConnected();
|
||||
handleTextChunk(chunk, 'stderrBuffer', 'stderr');
|
||||
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) {
|
||||
schedulePollRetry(OFFLINE_RETRY_MS);
|
||||
return;
|
||||
}
|
||||
const readiness =
|
||||
typeof getPollReadiness === 'function'
|
||||
? getPollReadiness()
|
||||
: { allowed: shouldPoll?.() !== false, delayMs: IDLE_RETRY_MS };
|
||||
if (!readiness?.allowed) {
|
||||
schedulePollRetry(Number(readiness?.delayMs) || OFFLINE_RETRY_MS);
|
||||
return;
|
||||
}
|
||||
if (state.requestInFlight) return;
|
||||
state.requestInFlight = true;
|
||||
state.requestStartedAt = Date.now();
|
||||
clearRequestTimeoutTimer();
|
||||
state.requestTimeoutTimer = setTimeout(() => {
|
||||
logger.warn('Neato lidar scan timed out; resetting parser state');
|
||||
state.stats.scansTimedOut += 1;
|
||||
emitFrame({ status: 'error', reason: 'scan_timeout' });
|
||||
resetScanState();
|
||||
triggerPollSoon();
|
||||
}, SCAN_TIMEOUT_MS);
|
||||
try {
|
||||
await requestScan?.();
|
||||
} catch (err) {
|
||||
logger.warn('Failed to request Neato lidar scan', err.message);
|
||||
resetScanState();
|
||||
schedulePollRetry(REQUEST_FAILURE_RETRY_MS);
|
||||
}
|
||||
}
|
||||
|
||||
function start() {
|
||||
startLogStream();
|
||||
triggerPollSoon();
|
||||
}
|
||||
|
||||
function stop() {
|
||||
clearReconnectTimer();
|
||||
clearPollSoonTimer();
|
||||
clearRequestTimeoutTimer();
|
||||
teardownProcess();
|
||||
closeLogStream();
|
||||
}
|
||||
|
||||
function getState() {
|
||||
return {
|
||||
connected: state.connected,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
start,
|
||||
stop,
|
||||
getState,
|
||||
on: (...args) => events.on(...args),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createLidarRuntime,
|
||||
};
|
||||
@@ -15,7 +15,6 @@ import VipProfileImageCard from '../vip/VipProfileImageCard.jsx';
|
||||
|
||||
export default function VipPanel({ isActive = true }) {
|
||||
const session = useSessionSelector((state) => state.session);
|
||||
const neatoLidar = useSessionSelector((state) => state.neatoLidar);
|
||||
const {
|
||||
identifySession,
|
||||
requestVerification,
|
||||
@@ -80,8 +79,6 @@ export default function VipPanel({ isActive = true }) {
|
||||
<div className="space-y-2">
|
||||
<VipNeatoCard
|
||||
neato={session?.neato || null}
|
||||
lidar={neatoLidar}
|
||||
lidarActive={isActive}
|
||||
onStart={neatoStart}
|
||||
onSendHome={neatoSendHome}
|
||||
onLocate={neatoLocate}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Vip Neato Card
|
||||
// Purpose: Defines the Vip Neato Card module and the local helpers/components used in this file.
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
function normalizeState(value) {
|
||||
return String(value || '').trim();
|
||||
@@ -38,83 +38,8 @@ function StatusTile({ label, value, tone = 'muted', valueClass = '', hideLabel =
|
||||
);
|
||||
}
|
||||
|
||||
function buildLidarRenderPoints(points = []) {
|
||||
const center = 110;
|
||||
const plotRadius = 102;
|
||||
const validPoints = points.filter(
|
||||
(point) => point && point.valid && Number.isFinite(point.angleDeg) && Number.isFinite(point.distanceMm),
|
||||
);
|
||||
const sortedPoints = [...validPoints].sort((a, b) => Number(a.angleDeg) - Number(b.angleDeg));
|
||||
const groups = [];
|
||||
let currentGroup = [];
|
||||
|
||||
for (const point of sortedPoints) {
|
||||
if (!currentGroup.length) {
|
||||
currentGroup.push(point);
|
||||
continue;
|
||||
}
|
||||
const previousPoint = currentGroup[currentGroup.length - 1];
|
||||
const angleGap = Math.abs(Number(point.angleDeg) - Number(previousPoint.angleDeg));
|
||||
const prevDistanceMm = Math.max(0, Number(previousPoint.distanceMm));
|
||||
const distanceMm = Math.max(0, Number(point.distanceMm));
|
||||
const distanceGap = Math.abs(distanceMm - prevDistanceMm);
|
||||
|
||||
if (angleGap <= 2 && distanceGap <= 900) {
|
||||
currentGroup.push(point);
|
||||
continue;
|
||||
}
|
||||
|
||||
groups.push(currentGroup);
|
||||
currentGroup = [point];
|
||||
}
|
||||
if (currentGroup.length) groups.push(currentGroup);
|
||||
|
||||
const groupStats = groups.map((group) => {
|
||||
const distances = group.map((point) => Math.max(0, Number(point.distanceMm)));
|
||||
const avgDistanceMm = distances.reduce((sum, value) => sum + value, 0) / Math.max(1, distances.length);
|
||||
return {
|
||||
points: group,
|
||||
size: group.length,
|
||||
avgDistanceMm,
|
||||
};
|
||||
});
|
||||
const largestGroup = groupStats.reduce((best, group) => (group.size > (best?.size || 0) ? group : best), null);
|
||||
const referenceDistanceMm = largestGroup?.avgDistanceMm || 1000;
|
||||
const filteredPoints = groupStats
|
||||
.filter((group) => {
|
||||
const tinyCluster = group.size <= 3;
|
||||
const farFromMainBody =
|
||||
group.avgDistanceMm > referenceDistanceMm * 1.8 && group.avgDistanceMm - referenceDistanceMm > 1400;
|
||||
return !(tinyCluster && farFromMainBody);
|
||||
})
|
||||
.flatMap((group) => group.points);
|
||||
const scaleDistances = filteredPoints.map((point) => Math.max(0, Number(point.distanceMm))).sort((a, b) => a - b);
|
||||
const percentileIndex = Math.max(0, Math.floor((scaleDistances.length - 1) * 0.95));
|
||||
const maxDistanceMm = Math.max(1000, scaleDistances[percentileIndex] || 1000);
|
||||
|
||||
return filteredPoints
|
||||
.map((point) => {
|
||||
const angleRad = ((Number(point.angleDeg) - 90) * Math.PI) / 180;
|
||||
const distanceMm = Math.max(0, Number(point.distanceMm));
|
||||
const normalizedDistance = Math.max(0, Math.min(1, distanceMm / maxDistanceMm));
|
||||
const scaledRadius = normalizedDistance * plotRadius;
|
||||
const x = center + Math.cos(angleRad) * scaledRadius;
|
||||
const y = center + Math.sin(angleRad) * scaledRadius;
|
||||
const intensity = Math.max(0, Math.min(255, Number(point.intensity) || 0));
|
||||
const colorLightness = 38 + (intensity / 255) * 38;
|
||||
return {
|
||||
key: `${point.angleDeg}-${point.distanceMm}-${point.intensity}`,
|
||||
x,
|
||||
y,
|
||||
fill: `hsl(198 100% ${colorLightness}%)`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export default function VipNeatoCard({
|
||||
neato,
|
||||
lidar,
|
||||
lidarActive = true,
|
||||
onStart,
|
||||
onSendHome,
|
||||
onLocate,
|
||||
@@ -123,8 +48,6 @@ export default function VipNeatoCard({
|
||||
fullWidth = false,
|
||||
}) {
|
||||
const [working, setWorking] = useState('');
|
||||
const [lidarFlash, setLidarFlash] = useState(false);
|
||||
const [showLidarDebug, setShowLidarDebug] = useState(false);
|
||||
const wrapClass = fullWidth ? 'w-full' : 'w-full max-w-xl';
|
||||
|
||||
const configured = Boolean(neato?.configured);
|
||||
@@ -140,15 +63,6 @@ export default function VipNeatoCard({
|
||||
const voltageLabel = Number.isFinite(voltage) ? `${voltage.toFixed(2)} V` : '--';
|
||||
const robotError = normalizeState(neato?.telemetry?.robotError) || '--';
|
||||
const robotAlert = normalizeState(neato?.telemetry?.robotAlert) || '--';
|
||||
const lidarPoints = Array.isArray(lidar?.points) ? lidar.points : [];
|
||||
const lidarRenderPoints = useMemo(
|
||||
() => (lidarActive ? buildLidarRenderPoints(lidarPoints) : []),
|
||||
[lidarActive, lidarPoints],
|
||||
);
|
||||
const lidarStatus = normalizeState(lidar?.status) || '--';
|
||||
const lidarDebug = lidar?.debug && typeof lidar.debug === 'object' ? lidar.debug : null;
|
||||
const lidarReason = normalizeState(lidarDebug?.reason) || '--';
|
||||
const lidarStats = lidarDebug?.stats && typeof lidarDebug.stats === 'object' ? lidarDebug.stats : null;
|
||||
|
||||
const controls = neato?.controls || {};
|
||||
const canStart = Boolean(controls?.start?.available);
|
||||
@@ -183,16 +97,6 @@ export default function VipNeatoCard({
|
||||
|
||||
const primaryState = docked ? 'Docked' : uiStateLabel !== '--' ? uiStateLabel : 'Away from dock';
|
||||
|
||||
useEffect(() => {
|
||||
if (!lidarActive) return undefined;
|
||||
if (!lidar || !Array.isArray(lidar.points)) return undefined;
|
||||
setLidarFlash(true);
|
||||
const timer = setTimeout(() => {
|
||||
setLidarFlash(false);
|
||||
}, 120);
|
||||
return () => clearTimeout(timer);
|
||||
}, [lidar, lidarActive]);
|
||||
|
||||
return (
|
||||
<section className={`surface text-sm text-slate-200 ${wrapClass}`}>
|
||||
<div className="grid gap-0.5">
|
||||
@@ -277,78 +181,26 @@ export default function VipNeatoCard({
|
||||
<div className="grid gap-0.5">
|
||||
<div className="surface-muted grid gap-0.5">
|
||||
<p className="text-xs text-slate-300 text-center">Robot Status</p>
|
||||
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_minmax(13rem,15rem)] gap-0.5 items-start">
|
||||
<div className="grid gap-0.5">
|
||||
<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 className="grid gap-0.5">
|
||||
<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 px-1 py-0.5 ${lidarFlash ? 'bg-emerald-900' : 'bg-slate-800'}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowLidarDebug((value) => !value)}
|
||||
className="w-full text-left text-[0.72rem] text-slate-300"
|
||||
>
|
||||
Lidar
|
||||
</button>
|
||||
<div className="mt-0.25 text-center text-[0.68rem] text-slate-400">Front of robot</div>
|
||||
<div className="mt-0.25 aspect-square rounded-md bg-slate-800 p-0">
|
||||
{lidarRenderPoints.length ? (
|
||||
<svg viewBox="0 0 220 220" className="h-full w-full">
|
||||
<rect x="0" y="0" width="220" height="220" fill="#1e293b" />
|
||||
<rect x="7" y="7" width="206" height="206" fill="none" stroke="#64748b" strokeWidth="1" />
|
||||
<rect x="33" y="33" width="154" height="154" fill="none" stroke="#475569" strokeWidth="1" />
|
||||
<rect x="59" y="59" width="102" height="102" fill="none" stroke="#475569" strokeWidth="1" />
|
||||
<rect x="85" y="85" width="50" height="50" fill="none" stroke="#334155" strokeWidth="1" />
|
||||
<line x1="110" y1="7" x2="110" y2="213" stroke="#475569" strokeWidth="1" />
|
||||
<line x1="7" y1="110" x2="213" y2="110" stroke="#475569" strokeWidth="1" />
|
||||
<circle cx="110" cy="110" r="4" fill="#e2e8f0" />
|
||||
{lidarRenderPoints.map((point) => (
|
||||
<circle key={point.key} cx={point.x} cy={point.y} r="1.8" fill={point.fill} />
|
||||
))}
|
||||
</svg>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-center text-xs text-slate-400">
|
||||
Waiting for scan
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showLidarDebug ? (
|
||||
<div className="mt-0.5 rounded-md bg-slate-900 px-0.75 py-0.5 font-mono text-[0.68rem] text-slate-300">
|
||||
<div>Status: {lidarStatus}</div>
|
||||
<div>Reason: {lidarReason}</div>
|
||||
<div>Points: {lidarPoints.length}</div>
|
||||
<div>Rotation: {Number.isFinite(lidar?.rotationSpeed) ? lidar.rotationSpeed.toFixed(2) : '--'}</div>
|
||||
<div>Request in flight: {lidarDebug?.requestInFlight ? 'yes' : 'no'}</div>
|
||||
<div>Started at: {lidarDebug?.requestStartedAt || '--'}</div>
|
||||
<div>Headers: {lidarStats?.headersSeen ?? '--'}</div>
|
||||
<div>Rotations: {lidarStats?.rotationsSeen ?? '--'}</div>
|
||||
<div>Scans ok: {lidarStats?.scansOk ?? '--'}</div>
|
||||
<div>Timeouts: {lidarStats?.scansTimedOut ?? '--'}</div>
|
||||
<div>Restarts: {lidarStats?.scansRestarted ?? '--'}</div>
|
||||
<div>Rotation no scan: {lidarStats?.rotationsWithoutScan ?? '--'}</div>
|
||||
<div>Parseable payloads: {lidarStats?.parseablePayloads ?? '--'}</div>
|
||||
<div>Point payloads: {lidarStats?.pointPayloads ?? '--'}</div>
|
||||
</div>
|
||||
) : null}
|
||||
<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>
|
||||
|
||||
@@ -8,7 +8,6 @@ import { useSocket } from './SocketContext.jsx';
|
||||
const INITIAL_STATE = {
|
||||
connected: false,
|
||||
session: null,
|
||||
neatoLidar: null,
|
||||
logs: [],
|
||||
adminLogs: [],
|
||||
llmCommentaryState: null,
|
||||
@@ -133,12 +132,7 @@ export function SessionProvider({ children }) {
|
||||
const state = payload && typeof payload === 'object' ? payload : null;
|
||||
setState((prev) => ({ ...prev, overseerControlState: state }));
|
||||
}
|
||||
function handleNeatoLidar(payload = null) {
|
||||
const next = payload && typeof payload === 'object' ? payload : null;
|
||||
setState((prev) => ({ ...prev, neatoLidar: next }));
|
||||
}
|
||||
socket.on('session:sync', handleSession);
|
||||
socket.on('neato:lidar', handleNeatoLidar);
|
||||
socket.on('log:init', handleLogInit);
|
||||
socket.on('log:entry', handleLogEntry);
|
||||
socket.on('adminlog:init', handleAdminLogInit);
|
||||
@@ -148,7 +142,6 @@ export function SessionProvider({ children }) {
|
||||
socket.on('alert:new', handleAlertNew);
|
||||
return () => {
|
||||
socket.off('session:sync', handleSession);
|
||||
socket.off('neato:lidar', handleNeatoLidar);
|
||||
socket.off('log:init', handleLogInit);
|
||||
socket.off('log:entry', handleLogEntry);
|
||||
socket.off('adminlog:init', handleAdminLogInit);
|
||||
|
||||
Reference in New Issue
Block a user