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
+4
View File
@@ -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"
+1
View File
@@ -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
+2 -2
View File
@@ -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>
+32 -3
View File
@@ -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,
+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,
};
+7
View File
@@ -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();
+14
View File
@@ -6,6 +6,7 @@ import VipAudioUploadCard from './vip/VipAudioUploadCard.jsx';
import VipVerificationCard from './vip/VipVerificationCard.jsx';
import VipIdentityCard from './vip/VipIdentityCard.jsx';
import VipPrivateRoverAccessCard from './vip/VipPrivateRoverAccessCard.jsx';
import VipNeatoCard from './vip/VipNeatoCard.jsx';
export default function VipPanel() {
const {
@@ -18,6 +19,9 @@ export default function VipPanel() {
startMicWhip,
readyMicWhip,
stopMicWhip,
neatoStart,
neatoSendHome,
neatoLocate,
} = useSession();
const { value: identity, save: saveIdentity } = useSettingsNamespace('identity', { cookieUserId: '' });
const { value: profile } = useSettingsNamespace('profile', { nickname: '' });
@@ -70,6 +74,15 @@ export default function VipPanel() {
<div className="lg:col-span-2">
{isVerified ? (
<div className="space-y-0.5">
<VipNeatoCard
neato={session?.neato || null}
onStart={neatoStart}
onSendHome={neatoSendHome}
onLocate={neatoLocate}
onMessage={setMessage}
fullWidth
/>
<VipAudioUploadCard
ownRoverId={ownRoverId}
audioForwardByRover={session?.audioForward || {}}
@@ -79,6 +92,7 @@ export default function VipPanel() {
readyMicWhip={readyMicWhip}
stopMicWhip={stopMicWhip}
/>
</div>
) : (
<section className="surface h-full">
<div className="flex h-full flex-col items-center justify-center text-center text-xs text-slate-400">
+155
View File
@@ -0,0 +1,155 @@
import { useMemo, useState } from 'react';
import { innerFlowClass } from './constants.js';
function normalizeState(value) {
return String(value || '').trim();
}
function humanizeUiState(value) {
const state = normalizeState(value);
if (!state) return '--';
if (state.includes('DOCKINGRUNNING')) return 'Returning';
if (state.includes('PAUSED')) return 'Paused';
if (state.includes('CLEANINGRUNNING')) return 'Cleaning';
if (state.includes('STATE_START')) return 'Starting';
if (state.includes('STATE_IDLE')) return 'Idle';
if (state.includes('STATE_STANDBY')) return 'Standby';
return state;
}
function badgeClass(active) {
return active
? 'rounded bg-emerald-600/80 px-1 py-0.5 text-[0.7rem] font-semibold text-white'
: 'rounded bg-slate-700 px-1 py-0.5 text-[0.7rem] font-semibold text-slate-200';
}
export default function VipNeatoCard({
neato,
onStart,
onSendHome,
onLocate,
onMessage,
fullWidth = false,
}) {
const [working, setWorking] = useState('');
const wrapClass = fullWidth ? 'w-full' : 'w-full max-w-xl';
const status = useMemo(() => {
if (!neato?.enabled) {
if (!neato?.configured) return 'Not configured';
return 'Disabled';
}
if (!neato?.connected) return 'Offline';
return 'Online';
}, [neato?.configured, neato?.connected, neato?.enabled]);
const canControl =
Boolean(neato?.enabled) &&
Boolean(neato?.connected) &&
Boolean(neato?.controls?.start?.available) &&
Boolean(neato?.controls?.sendHome?.available) &&
Boolean(neato?.controls?.locate?.available);
const uiStateLabel = humanizeUiState(neato?.telemetry?.uiState);
const battery = neato?.telemetry?.batteryPercent;
const batteryLabel = Number.isFinite(battery) ? `${battery}%` : '--';
const voltage = neato?.telemetry?.batteryVoltage;
const voltageLabel = Number.isFinite(voltage) ? `${voltage.toFixed(2)} V` : '--';
const charging = Boolean(neato?.telemetry?.chargingActive);
const docked = Boolean(neato?.telemetry?.extPowerPresent);
const robotError = normalizeState(neato?.telemetry?.robotError) || '--';
const robotAlert = normalizeState(neato?.telemetry?.robotAlert) || '--';
const runAction = async (key, fn, successMessage) => {
if (!fn) return;
setWorking(key);
onMessage?.('');
try {
await fn();
onMessage?.(successMessage);
} catch (err) {
onMessage?.(err?.message || 'Action failed.');
} finally {
setWorking('');
}
};
return (
<section className={`surface text-sm text-slate-300 ${wrapClass}`}>
<div className={innerFlowClass}>
<div className="flex w-full items-center justify-between gap-0.5 text-left">
<p className="text-sm text-slate-100">Neato (Gen3)</p>
<span className={badgeClass(status === 'Online')}>{status}</span>
</div>
<div className="grid w-full grid-cols-2 gap-0.5 text-xs">
<div className="surface-muted flex items-center justify-between px-1 py-0.5">
<span>UI State</span>
<span className="font-semibold text-slate-100">{uiStateLabel}</span>
</div>
<div className="surface-muted flex items-center justify-between px-1 py-0.5">
<span>Battery</span>
<span className="font-semibold text-slate-100">{batteryLabel}</span>
</div>
<div className="surface-muted flex items-center justify-between px-1 py-0.5">
<span>Docked</span>
<span className="font-semibold text-slate-100">{docked ? 'Yes' : 'No'}</span>
</div>
<div className="surface-muted flex items-center justify-between px-1 py-0.5">
<span>Charging</span>
<span className="font-semibold text-slate-100">{charging ? 'Yes' : 'No'}</span>
</div>
<div className="surface-muted flex items-center justify-between px-1 py-0.5">
<span>Voltage</span>
<span className="font-semibold text-slate-100">{voltageLabel}</span>
</div>
<div className="surface-muted flex items-center justify-between px-1 py-0.5">
<span>Error</span>
<span className="truncate pl-1 font-semibold text-slate-100" title={robotError}>{robotError}</span>
</div>
<div className="surface-muted col-span-2 flex items-center justify-between px-1 py-0.5">
<span>Alert</span>
<span className="truncate pl-1 font-semibold text-slate-100" title={robotAlert}>{robotAlert}</span>
</div>
</div>
<div className="flex w-full justify-center gap-0.5">
<button
type="button"
className="button-dark text-sm disabled:opacity-50"
onClick={() => runAction('start', onStart, 'Neato start sent.')}
disabled={!canControl || Boolean(working)}
>
{working === 'start' ? 'Starting...' : 'Start'}
</button>
<button
type="button"
className="button-dark text-sm disabled:opacity-50"
onClick={() => runAction('home', onSendHome, 'Neato send-home sent.')}
disabled={!canControl || Boolean(working)}
>
{working === 'home' ? 'Sending...' : 'Send Home'}
</button>
<button
type="button"
className="button-dark text-sm disabled:opacity-50"
onClick={() => runAction('locate', onLocate, 'Neato locate sent.')}
disabled={!canControl || Boolean(working)}
>
{working === 'locate' ? 'Locating...' : 'Locate Robot'}
</button>
</div>
{!canControl ? (
<p className="text-xs text-slate-500 text-center">
{!neato?.configured
? 'Set homeAssistant.neato.device in server config to enable Neato controls.'
: !neato?.connected
? 'Home Assistant is offline.'
: 'Waiting for required Neato entities in Home Assistant.'}
</p>
) : null}
</div>
</section>
);
}
+3
View File
@@ -163,6 +163,9 @@ export function SessionProvider({ children }) {
emitWithAck('homeAssistant:lightColor', { entityId, rgbColor }),
homeAssistantSetLightWhite: (entityId) =>
emitWithAck('homeAssistant:lightWhite', { entityId }),
neatoStart: () => emitWithAck('neato:start'),
neatoSendHome: () => emitWithAck('neato:sendHome'),
neatoLocate: () => emitWithAck('neato:locate'),
setNickname: (nickname) => emitWithAck('nickname:set', { nickname }),
requestVerification: () => emitWithAck('verification:request'),
requestPrivateRoverAccess: (roverId) =>