mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
390 lines
11 KiB
JavaScript
390 lines
11 KiB
JavaScript
// neato Service
|
|
// Purpose: Defines the neato Service module and the helpers/state used by this service unit.
|
|
// Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary.
|
|
const EventEmitter = require('events');
|
|
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,
|
|
callHomeAssistantService,
|
|
isConnected: isHomeAssistantConnected,
|
|
enabled: homeAssistantEnabled,
|
|
} = require('../homeAssistantService');
|
|
|
|
const events = new EventEmitter();
|
|
const config = loadConfig();
|
|
const haConfig = config.homeAssistant || {};
|
|
const neatoConfig = haConfig.neato || {};
|
|
|
|
function normalizeDeviceName(value) {
|
|
const raw = String(value || '').trim().toLowerCase();
|
|
if (!raw) return '';
|
|
return raw.replace(/[^a-z0-9_]+/g, '_').replace(/^_+|_+$/g, '');
|
|
}
|
|
|
|
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 '';
|
|
return `${domain}.${device}_${suffix}`;
|
|
}
|
|
|
|
const ENTITY_IDS = {
|
|
buttons: {
|
|
start: entityId('button', 'house_clean'),
|
|
resume: entityId('button', 'resume_cleaning'),
|
|
sendHome: entityId('button', 'send_to_base'),
|
|
locate: entityId('button', 'locate_robot'),
|
|
clearErrors: entityId('button', 'clear_errors'),
|
|
powerCycle: entityId('button', 'powercycle'),
|
|
},
|
|
sensors: {
|
|
batteryPercent: entityId('sensor', 'fuel_percent'),
|
|
batteryVoltage: entityId('sensor', 'battery_voltage_v'),
|
|
},
|
|
binarySensors: {
|
|
chargingActive: entityId('binary_sensor', 'charging_active'),
|
|
extPowerPresent: entityId('binary_sensor', 'ext_power_present'),
|
|
},
|
|
textSensors: {
|
|
// ESPHome text_sensor entities surface in Home Assistant under the sensor domain.
|
|
robotState: entityId('sensor', 'robot_state'),
|
|
uiState: entityId('sensor', 'ui_state'),
|
|
robotError: entityId('sensor', 'robot_error'),
|
|
robotAlert: entityId('sensor', 'robot_alert'),
|
|
},
|
|
};
|
|
|
|
function readRaw(entityIdValue) {
|
|
if (!entityIdValue) return null;
|
|
return getRawEntitySnapshot(entityIdValue);
|
|
}
|
|
|
|
function readState(entityIdValue) {
|
|
const raw = readRaw(entityIdValue);
|
|
return raw?.state ?? null;
|
|
}
|
|
|
|
function parseNumber(value) {
|
|
const next = Number(value);
|
|
return Number.isFinite(next) ? next : null;
|
|
}
|
|
|
|
function isBinaryOn(value) {
|
|
return String(value || '').toLowerCase() === 'on';
|
|
}
|
|
|
|
function hasEntity(entityIdValue) {
|
|
return Boolean(readRaw(entityIdValue));
|
|
}
|
|
|
|
function isEntityAvailable(entityIdValue) {
|
|
const raw = readRaw(entityIdValue);
|
|
if (!raw) return false;
|
|
const state = String(raw.state ?? '').trim().toLowerCase();
|
|
if (!state) return false;
|
|
return state !== 'unavailable';
|
|
}
|
|
|
|
function requiredEntityIds() {
|
|
return [
|
|
ENTITY_IDS.buttons.start,
|
|
ENTITY_IDS.buttons.resume,
|
|
ENTITY_IDS.buttons.sendHome,
|
|
ENTITY_IDS.buttons.locate,
|
|
ENTITY_IDS.buttons.clearErrors,
|
|
ENTITY_IDS.buttons.powerCycle,
|
|
ENTITY_IDS.sensors.batteryPercent,
|
|
ENTITY_IDS.sensors.batteryVoltage,
|
|
ENTITY_IDS.binarySensors.chargingActive,
|
|
ENTITY_IDS.binarySensors.extPowerPresent,
|
|
ENTITY_IDS.textSensors.robotState,
|
|
ENTITY_IDS.textSensors.uiState,
|
|
ENTITY_IDS.textSensors.robotError,
|
|
ENTITY_IDS.textSensors.robotAlert,
|
|
].filter(Boolean);
|
|
}
|
|
|
|
function buildState() {
|
|
const configured = Boolean(device);
|
|
const haConnected = isHomeAssistantConnected();
|
|
const requiredIds = requiredEntityIds();
|
|
const entitiesAvailable = requiredIds.length > 0 && requiredIds.every((id) => isEntityAvailable(id));
|
|
const connected = Boolean(haConnected && entitiesAvailable);
|
|
const enabled = Boolean(homeAssistantEnabled && configured);
|
|
|
|
const controls = {
|
|
start: {
|
|
entityId: ENTITY_IDS.buttons.start,
|
|
available: hasEntity(ENTITY_IDS.buttons.start),
|
|
},
|
|
resume: {
|
|
entityId: ENTITY_IDS.buttons.resume,
|
|
available: hasEntity(ENTITY_IDS.buttons.resume),
|
|
},
|
|
sendHome: {
|
|
entityId: ENTITY_IDS.buttons.sendHome,
|
|
available: hasEntity(ENTITY_IDS.buttons.sendHome),
|
|
},
|
|
locate: {
|
|
entityId: ENTITY_IDS.buttons.locate,
|
|
available: hasEntity(ENTITY_IDS.buttons.locate),
|
|
},
|
|
clearErrors: {
|
|
entityId: ENTITY_IDS.buttons.clearErrors,
|
|
available: hasEntity(ENTITY_IDS.buttons.clearErrors),
|
|
},
|
|
powerCycle: {
|
|
entityId: ENTITY_IDS.buttons.powerCycle,
|
|
available: hasEntity(ENTITY_IDS.buttons.powerCycle),
|
|
},
|
|
};
|
|
|
|
const batteryPercentValue = parseNumber(readState(ENTITY_IDS.sensors.batteryPercent));
|
|
const batteryPercent =
|
|
batteryPercentValue == null ? null : Math.max(0, Math.min(100, Math.round(batteryPercentValue)));
|
|
const batteryVoltage = parseNumber(readState(ENTITY_IDS.sensors.batteryVoltage));
|
|
const robotState = readState(ENTITY_IDS.textSensors.robotState);
|
|
const uiState = readState(ENTITY_IDS.textSensors.uiState);
|
|
const robotError = readState(ENTITY_IDS.textSensors.robotError);
|
|
const robotAlert = readState(ENTITY_IDS.textSensors.robotAlert);
|
|
const chargingActive = isBinaryOn(readState(ENTITY_IDS.binarySensors.chargingActive));
|
|
const extPowerPresent = isBinaryOn(readState(ENTITY_IDS.binarySensors.extPowerPresent));
|
|
|
|
return {
|
|
enabled,
|
|
configured,
|
|
connected,
|
|
lidarConnected: lidarRuntime?.getState?.().connected || false,
|
|
device,
|
|
entityPrefix: device ? `${device}_` : '',
|
|
controls,
|
|
telemetry: {
|
|
batteryPercent,
|
|
batteryVoltage,
|
|
chargingActive,
|
|
extPowerPresent,
|
|
robotState,
|
|
uiState,
|
|
robotError,
|
|
robotAlert,
|
|
},
|
|
entities: ENTITY_IDS,
|
|
};
|
|
}
|
|
|
|
let cachedState = buildState();
|
|
|
|
function emitUpdate() {
|
|
const next = buildState();
|
|
const changed = JSON.stringify(next) !== JSON.stringify(cachedState);
|
|
cachedState = next;
|
|
if (changed) {
|
|
events.emit('update', next);
|
|
}
|
|
}
|
|
|
|
homeAssistantEvents.on('snapshot', () => {
|
|
emitUpdate();
|
|
});
|
|
|
|
homeAssistantEvents.on('status', () => {
|
|
emitUpdate();
|
|
});
|
|
|
|
function assertConfiguredAndConnected() {
|
|
if (!device) {
|
|
throw new Error('Neato not configured');
|
|
}
|
|
if (!homeAssistantEnabled) {
|
|
throw new Error('Home Assistant not configured');
|
|
}
|
|
if (!isHomeAssistantConnected()) {
|
|
throw new Error('Home Assistant not connected');
|
|
}
|
|
}
|
|
|
|
async function pressButton(entityIdValue, actionLabel) {
|
|
assertConfiguredAndConnected();
|
|
if (!entityIdValue) {
|
|
throw new Error(`Neato ${actionLabel} entity missing`);
|
|
}
|
|
if (!hasEntity(entityIdValue)) {
|
|
throw new Error(`Neato action unavailable: ${actionLabel}`);
|
|
}
|
|
await callHomeAssistantService('button', 'press', { entity_id: entityIdValue });
|
|
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));
|
|
await pressButton(ENTITY_IDS.buttons.resume, 'resume_cleaning');
|
|
}
|
|
|
|
async function sendHome() {
|
|
await pressButton(ENTITY_IDS.buttons.sendHome, 'send_home');
|
|
}
|
|
|
|
async function locateRobot() {
|
|
await pressButton(ENTITY_IDS.buttons.locate, 'locate');
|
|
}
|
|
|
|
async function clearErrors() {
|
|
await pressButton(ENTITY_IDS.buttons.clearErrors, 'clear_errors');
|
|
}
|
|
|
|
async function powerCycle() {
|
|
await pressButton(ENTITY_IDS.buttons.powerCycle, 'powercucle');
|
|
}
|
|
|
|
function getState() {
|
|
cachedState = buildState();
|
|
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,
|
|
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) => {
|
|
socket.on('neato:start', async (_, cb = () => {}) => {
|
|
try {
|
|
if (!isVerified(socket)) {
|
|
throw new Error('VIP verification required');
|
|
}
|
|
await startCleaning();
|
|
cb({ success: true });
|
|
} catch (err) {
|
|
cb({ error: err.message });
|
|
}
|
|
});
|
|
|
|
socket.on('neato:sendHome', async (_, cb = () => {}) => {
|
|
try {
|
|
if (!isVerified(socket)) {
|
|
throw new Error('VIP verification required');
|
|
}
|
|
await sendHome();
|
|
cb({ success: true });
|
|
} catch (err) {
|
|
cb({ error: err.message });
|
|
}
|
|
});
|
|
|
|
socket.on('neato:locate', async (_, cb = () => {}) => {
|
|
try {
|
|
if (!isVerified(socket)) {
|
|
throw new Error('VIP verification required');
|
|
}
|
|
await locateRobot();
|
|
cb({ success: true });
|
|
} catch (err) {
|
|
cb({ error: err.message });
|
|
}
|
|
});
|
|
|
|
socket.on('neato:clearErrors', async (_, cb = () => {}) => {
|
|
try {
|
|
if (!isVerified(socket)) {
|
|
throw new Error('VIP verification required');
|
|
}
|
|
await clearErrors();
|
|
cb({ success: true });
|
|
} catch (err) {
|
|
cb({ error: err.message });
|
|
}
|
|
});
|
|
|
|
socket.on('neato:powerCycle', async (_, cb = () => {}) => {
|
|
try {
|
|
if (!isVerified(socket)) {
|
|
throw new Error('VIP verification required');
|
|
}
|
|
await powerCycle();
|
|
cb({ success: true });
|
|
} catch (err) {
|
|
cb({ error: err.message });
|
|
}
|
|
});
|
|
});
|
|
|
|
emitUpdate();
|
|
|
|
module.exports = {
|
|
getState,
|
|
startCleaning,
|
|
sendHome,
|
|
locateRobot,
|
|
clearErrors,
|
|
powerCycle,
|
|
neatoEvents: events,
|
|
};
|