removing neato lidar in hopes of future actual robot map access/...

This commit is contained in:
legop3
2026-05-14 22:05:03 -04:00
parent e1a48a985a
commit fe91d825d6
11 changed files with 162 additions and 800 deletions
-61
View File
@@ -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,
};