mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
neeato
This commit is contained in:
@@ -27,6 +27,7 @@ const triggerRuntime = new Map(); // triggerId -> { lastFiredAt, lastState, last
|
||||
const HA_BUTTON_EVENT_TYPE = 'ha.button.action';
|
||||
const LIGHT_IDLE_OFF_MS = 2 * 60 * 1000;
|
||||
const DEFAULT_WHITE_KELVIN = 4000;
|
||||
let latestEntitySnapshot = {};
|
||||
// Rover daemon uses inverted semantics: action "on" powers IR LEDs, which means nightVisionOn=false.
|
||||
const NIGHT_VISION_DISABLE_ACTION = 'on';
|
||||
|
||||
@@ -284,6 +285,8 @@ function evaluateLightAutomation() {
|
||||
}
|
||||
|
||||
function handleEntitySnapshot(snapshot = {}) {
|
||||
latestEntitySnapshot = snapshot || {};
|
||||
events.emit('snapshot', latestEntitySnapshot);
|
||||
let changed = false;
|
||||
entityConfig.forEach((meta, id) => {
|
||||
const raw = snapshot[id];
|
||||
@@ -450,7 +453,7 @@ async function setEntityState(entityId, desiredState) {
|
||||
const nextState = desiredState === 'on' ? 'on' : 'off';
|
||||
const domain = meta.type === 'light' ? 'light' : 'switch';
|
||||
const service = nextState === 'on' ? 'turn_on' : 'turn_off';
|
||||
await callService(connection, domain, service, { entity_id: entityId });
|
||||
await callHomeAssistantService(domain, service, { entity_id: entityId });
|
||||
logger.info('Issued Home Assistant command', { entityId, domain, service });
|
||||
}
|
||||
|
||||
@@ -493,7 +496,7 @@ async function setLightColor(entityId, rgbColor) {
|
||||
if (Number.isNaN(next)) return 0;
|
||||
return Math.max(0, Math.min(255, Math.round(next)));
|
||||
});
|
||||
await callService(connection, 'light', 'turn_on', { entity_id: entityId, rgb_color: normalized });
|
||||
await callHomeAssistantService('light', 'turn_on', { entity_id: entityId, rgb_color: normalized });
|
||||
logger.info('Issued Home Assistant color command', { entityId, rgbColor: normalized });
|
||||
}
|
||||
|
||||
@@ -512,7 +515,7 @@ async function setLightWhite(entityId, kelvin = DEFAULT_WHITE_KELVIN) {
|
||||
const normalizedKelvin = Number.isFinite(nextKelvin)
|
||||
? Math.max(2000, Math.min(6500, Math.round(nextKelvin)))
|
||||
: DEFAULT_WHITE_KELVIN;
|
||||
await callService(connection, 'light', 'turn_on', {
|
||||
await callHomeAssistantService('light', 'turn_on', {
|
||||
entity_id: entityId,
|
||||
color_temp_kelvin: normalizedKelvin,
|
||||
});
|
||||
@@ -581,6 +584,28 @@ function getState() {
|
||||
};
|
||||
}
|
||||
|
||||
function isConnected() {
|
||||
return Boolean(connection && connected);
|
||||
}
|
||||
|
||||
function getRawEntitySnapshot(entityId) {
|
||||
if (!entityId) return null;
|
||||
return latestEntitySnapshot?.[String(entityId)] || null;
|
||||
}
|
||||
|
||||
async function callHomeAssistantService(domain, service, serviceData = {}) {
|
||||
if (!enabled) {
|
||||
throw new Error('Home Assistant not configured');
|
||||
}
|
||||
if (!connection) {
|
||||
throw new Error('Home Assistant not connected');
|
||||
}
|
||||
if (!domain || !service) {
|
||||
throw new Error('domain and service required');
|
||||
}
|
||||
await callService(connection, String(domain), String(service), serviceData || {});
|
||||
}
|
||||
|
||||
loadEntityConfig();
|
||||
loadTriggerConfig();
|
||||
connect();
|
||||
@@ -695,8 +720,12 @@ io.on('connection', (socket) => {
|
||||
|
||||
module.exports = {
|
||||
getState,
|
||||
isConnected,
|
||||
enabled,
|
||||
getLightPolicyState,
|
||||
isLightControlLocked,
|
||||
getRawEntitySnapshot,
|
||||
callHomeAssistantService,
|
||||
toggleEntity,
|
||||
setEntityState,
|
||||
setLightColor,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -8,6 +8,7 @@ const assignmentService = require('./assignmentService');
|
||||
const { getActiveDrivers, getTurnQueues, turnEvents } = require('./turnService');
|
||||
const { getRoomCameras, roomCameraEvents } = require('./roomCameraService');
|
||||
const { getState: getHomeAssistantState, homeAssistantEvents } = require('./homeAssistantService');
|
||||
const { getState: getNeatoState, neatoEvents } = require('./neatoService');
|
||||
const { getNickname, nicknameEvents } = require('./nicknameService');
|
||||
const {
|
||||
getVerificationStateForSocket,
|
||||
@@ -118,6 +119,7 @@ function buildSession(socket) {
|
||||
turnQueues,
|
||||
roomCameras: getRoomCameras(),
|
||||
homeAssistant: getHomeAssistantState(),
|
||||
neato: getNeatoState(),
|
||||
replay: getReplayState(),
|
||||
replaySources: getReplaySources(socket),
|
||||
health: getHealthSnapshot(),
|
||||
@@ -286,6 +288,11 @@ homeAssistantEvents.on('status', () => {
|
||||
syncAll();
|
||||
});
|
||||
|
||||
neatoEvents.on('update', () => {
|
||||
logger.info('Neato state change; syncing all clients');
|
||||
syncAll();
|
||||
});
|
||||
|
||||
replayEvents.on('update', () => {
|
||||
logger.info('Replay cooldown updated; syncing all clients');
|
||||
syncAll();
|
||||
|
||||
Reference in New Issue
Block a user