This commit is contained in:
legop3
2026-04-26 20:32:29 -04:00
parent e63eddc314
commit 286cc1c62a
13 changed files with 585 additions and 140 deletions
+232
View File
@@ -0,0 +1,232 @@
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 {
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);
function entityId(domain, suffix) {
if (!device) return '';
return `${domain}.${device}_${suffix}`;
}
const ENTITY_IDS = {
buttons: {
start: entityId('button', 'house_clean'),
sendHome: entityId('button', 'send_to_base'),
locate: entityId('button', 'locate_robot'),
},
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: {
uiState: entityId('text_sensor', 'ui_state'),
robotError: entityId('text_sensor', 'robot_error'),
robotAlert: entityId('text_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 buildState() {
const configured = Boolean(device);
const connected = isHomeAssistantConnected();
const enabled = Boolean(homeAssistantEnabled && configured);
const controls = {
start: {
entityId: ENTITY_IDS.buttons.start,
available: hasEntity(ENTITY_IDS.buttons.start),
},
sendHome: {
entityId: ENTITY_IDS.buttons.sendHome,
available: hasEntity(ENTITY_IDS.buttons.sendHome),
},
locate: {
entityId: ENTITY_IDS.buttons.locate,
available: hasEntity(ENTITY_IDS.buttons.locate),
},
};
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 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,
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 startCleaning() {
await pressButton(ENTITY_IDS.buttons.start, 'start');
}
async function sendHome() {
await pressButton(ENTITY_IDS.buttons.sendHome, 'send_home');
}
async function locateRobot() {
await pressButton(ENTITY_IDS.buttons.locate, 'locate');
}
function getState() {
cachedState = buildState();
return cachedState;
}
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 });
}
});
});
emitUpdate();
module.exports = {
getState,
startCleaning,
sendHome,
locateRobot,
neatoEvents: events,
};