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
+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,
};