mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 17:40:46 -04:00
neato lidara
This commit is contained in:
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user