neato lidara

This commit is contained in:
legop3
2026-05-01 17:10:00 -04:00
parent 6523ea7417
commit e3e563feee
10 changed files with 437 additions and 92 deletions
+4
View File
@@ -38,6 +38,10 @@ 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"
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
+2 -2
View File
@@ -11,8 +11,8 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
<title>Multi Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-CKRKhxHH.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-EE3dCtON.css">
<script type="module" crossorigin src="/assets/index-CZ7Wc8AL.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BNzmusoM.css">
</head>
<body>
<div id="root"></div>
+52
View File
@@ -6,6 +6,7 @@ const io = require('../../globals/io');
const logger = require('../../globals/logger').child('neatoService');
const { loadConfig } = require('../../helpers/configLoader');
const { isVerified } = require('../verificationService');
const { createLidarRuntime } = require('./lidarRuntime');
const {
homeAssistantEvents,
getRawEntitySnapshot,
@@ -27,6 +28,10 @@ function normalizeDeviceName(value) {
const device = normalizeDeviceName(neatoConfig.device);
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) {
if (!device) return '';
@@ -159,6 +164,7 @@ function buildState() {
enabled,
configured,
connected,
lidarConnected: lidarRuntime?.getState?.().connected || false,
device,
entityPrefix: device ? `${device}_` : '',
controls,
@@ -219,6 +225,14 @@ 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));
@@ -246,6 +260,44 @@ function getState() {
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) => {
socket.on('neato:start', async (_, cb = () => {}) => {
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,
};