// 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 { isFeatureEnabled } = require('../../helpers/features'); const { isVerified } = require('../verificationService'); const { getMode, MODES } = require('../modeManager'); const { isAdmin, isLockdownAdmin } = require('../roleService'); const { sendAlert } = require('../alertService'); 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 || {}; const featureEnabled = isFeatureEnabled('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 ALERT_COLOR = '#a855f7'; // BrainSlug exposes these exact select values for Gen 3 robots. Keeping the // allowlist on the server prevents arbitrary Home Assistant select options from // being submitted by a modified browser while preserving BrainSlug's casing. const NAVIGATION_MODES = Object.freeze(['Normal', 'Gentle', 'Deep', 'Quick']); 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'), }, selects: { navigationMode: entityId('select', 'navigation_mode'), }, }; // Alert Feed coverage is intentionally limited to the raw robot lifecycle and // issue fields requested for Neato. Battery and charger telemetry poll often and // would create noise without representing a useful robot status transition. const ALERT_ENTITIES = Object.freeze([ { title: 'Neato UI state', entityId: ENTITY_IDS.textSensors.uiState }, { title: 'Neato robot state', entityId: ENTITY_IDS.textSensors.robotState }, { title: 'Neato robot alert', entityId: ENTITY_IDS.textSensors.robotAlert }, { title: 'Neato robot error', entityId: ENTITY_IDS.textSensors.robotError }, { title: 'Neato external power', entityId: ENTITY_IDS.binarySensors.extPowerPresent }, ]); // Each entity establishes its own baseline because ESPHome entities can become // available on different snapshots. A Map also distinguishes "not observed yet" // from a legitimate raw state string without inventing a sentinel state value. const alertBaselines = new Map(); 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 emitRawStateAlerts() { for (const { title, entityId: entityIdValue } of ALERT_ENTITIES) { const raw = readState(entityIdValue); const normalized = String(raw ?? '').trim().toLowerCase(); // Missing and unavailable values commonly occur while Home Assistant or the // ESPHome device reconnects. Ignoring them preserves the last real baseline // and prevents connection churn from becoming misleading Neato activity. if (!normalized || normalized === 'unavailable' || normalized === 'unknown') continue; const rawMessage = String(raw); if (!alertBaselines.has(entityIdValue)) { // The first real value is startup state, not a transition caused while the // service was watching, so record it without creating an Alert Feed toast. alertBaselines.set(entityIdValue, rawMessage); continue; } if (alertBaselines.get(entityIdValue) === rawMessage) continue; alertBaselines.set(entityIdValue, rawMessage); // The title provides field context, while the message remains exactly the // new Home Assistant state with no friendly translation or previous value. sendAlert({ color: ALERT_COLOR, title, message: rawMessage }); } } 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(featureEnabled && 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), }, navigationMode: { entityId: ENTITY_IDS.selects.navigationMode, available: isEntityAvailable(ENTITY_IDS.selects.navigationMode), value: readState(ENTITY_IDS.selects.navigationMode), // The browser receives the supported choices through the session contract // instead of duplicating BrainSlug-specific values in the presentation layer. options: NAVIGATION_MODES, }, }; 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, 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); } } if (featureEnabled) { /* Neato telemetry is derived from Home Assistant entities. Disabled installs should keep the exported API inert instead of tracking HA snapshots for a robot vacuum feature that does not exist on that server. */ homeAssistantEvents.on('snapshot', () => { emitUpdate(); emitRawStateAlerts(); }); homeAssistantEvents.on('status', () => { emitUpdate(); }); } function assertConfiguredAndConnected() { if (!featureEnabled) { throw new Error('Neato is disabled'); } 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 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'); } async function setNavigationMode(mode) { assertConfiguredAndConnected(); const normalizedMode = String(mode || '').trim(); if (!NAVIGATION_MODES.includes(normalizedMode)) { throw new Error('Invalid Neato navigation mode'); } if (!isEntityAvailable(ENTITY_IDS.selects.navigationMode)) { throw new Error('Neato action unavailable: navigation_mode'); } // ESPHome implements Navigation Mode as a Home Assistant select entity, so // select_option is the native service call and avoids sending raw UART commands. await callHomeAssistantService('select', 'select_option', { entity_id: ENTITY_IDS.selects.navigationMode, option: normalizedMode, }); logger.info('Issued Neato action', { action: 'set_navigation_mode', entityId: ENTITY_IDS.selects.navigationMode, option: normalizedMode, }); } function getState() { cachedState = buildState(); return cachedState; } function hasVerifiedSockets() { for (const socket of io.sockets.sockets.values()) { if (isVerified(socket)) return true; } return false; } if (featureEnabled) { io.on('connection', (socket) => { function assertFeatureAccess() { const mode = getMode(); // Neato shares the same public-activity policy as lift: everyone may use // it in open/turns modes, admin mode requires an admin, and lockdown // requires a lockdown admin. This service-level gate protects every socket // action even if a future client bypasses the current UI presentation. if (mode === MODES.ADMIN && !isAdmin(socket)) throw new Error('Admin mode: admins only'); if (mode === MODES.LOCKDOWN && !isLockdownAdmin(socket)) throw new Error('Server in lockdown'); } socket.on('neato:start', async (_, cb = () => {}) => { try { assertFeatureAccess(); await startCleaning(); cb({ success: true }); } catch (err) { cb({ error: err.message }); } }); socket.on('neato:sendHome', async (_, cb = () => {}) => { try { assertFeatureAccess(); await sendHome(); cb({ success: true }); } catch (err) { cb({ error: err.message }); } }); socket.on('neato:locate', async (_, cb = () => {}) => { try { assertFeatureAccess(); await locateRobot(); cb({ success: true }); } catch (err) { cb({ error: err.message }); } }); socket.on('neato:clearErrors', async (_, cb = () => {}) => { try { assertFeatureAccess(); await clearErrors(); cb({ success: true }); } catch (err) { cb({ error: err.message }); } }); socket.on('neato:powerCycle', async (_, cb = () => {}) => { try { assertFeatureAccess(); await powerCycle(); cb({ success: true }); } catch (err) { cb({ error: err.message }); } }); socket.on('neato:setNavigationMode', async ({ mode } = {}, cb = () => {}) => { try { assertFeatureAccess(); await setNavigationMode(mode); cb({ success: true }); } catch (err) { cb({ error: err.message }); } }); }); } else { logger.info('Neato disabled by config'); } emitUpdate(); module.exports = { getState, startCleaning, sendHome, locateRobot, clearErrors, powerCycle, setNavigationMode, neatoEvents: events, };