mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
neeato
This commit is contained in:
@@ -34,6 +34,10 @@ audioLevels:
|
||||
homeAssistant:
|
||||
url: "http://homeassistant.local:8123"
|
||||
token: "REPLACE_WITH_LONG_LIVED_TOKEN"
|
||||
neato:
|
||||
# ESPHome device name, used to derive gen3 entities:
|
||||
# button.<device>_house_clean, button.<device>_send_to_base, button.<device>_locate_robot, etc.
|
||||
device: "neato_vacuum"
|
||||
entities:
|
||||
- id: "light.lab_main"
|
||||
name: "Lab Lights"
|
||||
|
||||
@@ -32,6 +32,7 @@ require('./src/services/embedHttpService');
|
||||
require('./src/services/logStreamService');
|
||||
require('./src/services/adminLogService');
|
||||
require('./src/services/homeAssistantService');
|
||||
require('./src/services/neatoService');
|
||||
require('./src/services/audioLevelsService');
|
||||
require('./src/services/audioForwardService');
|
||||
require('./src/services/buttonBoxService');
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -11,8 +11,8 @@
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
||||
<title>Multi Roomba Rover</title>
|
||||
<script type="module" crossorigin src="/assets/index-CdNFroGb.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-gFnxuq00.css">
|
||||
<script type="module" crossorigin src="/assets/index-ClNMcZx6.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-mInHHreL.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -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