mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
neeato
This commit is contained in:
@@ -34,6 +34,10 @@ audioLevels:
|
|||||||
homeAssistant:
|
homeAssistant:
|
||||||
url: "http://homeassistant.local:8123"
|
url: "http://homeassistant.local:8123"
|
||||||
token: "REPLACE_WITH_LONG_LIVED_TOKEN"
|
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:
|
entities:
|
||||||
- id: "light.lab_main"
|
- id: "light.lab_main"
|
||||||
name: "Lab Lights"
|
name: "Lab Lights"
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ require('./src/services/embedHttpService');
|
|||||||
require('./src/services/logStreamService');
|
require('./src/services/logStreamService');
|
||||||
require('./src/services/adminLogService');
|
require('./src/services/adminLogService');
|
||||||
require('./src/services/homeAssistantService');
|
require('./src/services/homeAssistantService');
|
||||||
|
require('./src/services/neatoService');
|
||||||
require('./src/services/audioLevelsService');
|
require('./src/services/audioLevelsService');
|
||||||
require('./src/services/audioForwardService');
|
require('./src/services/audioForwardService');
|
||||||
require('./src/services/buttonBoxService');
|
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-status-bar-style" content="black-translucent" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
||||||
<title>Multi Roomba Rover</title>
|
<title>Multi Roomba Rover</title>
|
||||||
<script type="module" crossorigin src="/assets/index-CdNFroGb.js"></script>
|
<script type="module" crossorigin src="/assets/index-ClNMcZx6.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-gFnxuq00.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-mInHHreL.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<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 HA_BUTTON_EVENT_TYPE = 'ha.button.action';
|
||||||
const LIGHT_IDLE_OFF_MS = 2 * 60 * 1000;
|
const LIGHT_IDLE_OFF_MS = 2 * 60 * 1000;
|
||||||
const DEFAULT_WHITE_KELVIN = 4000;
|
const DEFAULT_WHITE_KELVIN = 4000;
|
||||||
|
let latestEntitySnapshot = {};
|
||||||
// Rover daemon uses inverted semantics: action "on" powers IR LEDs, which means nightVisionOn=false.
|
// Rover daemon uses inverted semantics: action "on" powers IR LEDs, which means nightVisionOn=false.
|
||||||
const NIGHT_VISION_DISABLE_ACTION = 'on';
|
const NIGHT_VISION_DISABLE_ACTION = 'on';
|
||||||
|
|
||||||
@@ -284,6 +285,8 @@ function evaluateLightAutomation() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleEntitySnapshot(snapshot = {}) {
|
function handleEntitySnapshot(snapshot = {}) {
|
||||||
|
latestEntitySnapshot = snapshot || {};
|
||||||
|
events.emit('snapshot', latestEntitySnapshot);
|
||||||
let changed = false;
|
let changed = false;
|
||||||
entityConfig.forEach((meta, id) => {
|
entityConfig.forEach((meta, id) => {
|
||||||
const raw = snapshot[id];
|
const raw = snapshot[id];
|
||||||
@@ -450,7 +453,7 @@ async function setEntityState(entityId, desiredState) {
|
|||||||
const nextState = desiredState === 'on' ? 'on' : 'off';
|
const nextState = desiredState === 'on' ? 'on' : 'off';
|
||||||
const domain = meta.type === 'light' ? 'light' : 'switch';
|
const domain = meta.type === 'light' ? 'light' : 'switch';
|
||||||
const service = nextState === 'on' ? 'turn_on' : 'turn_off';
|
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 });
|
logger.info('Issued Home Assistant command', { entityId, domain, service });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -493,7 +496,7 @@ async function setLightColor(entityId, rgbColor) {
|
|||||||
if (Number.isNaN(next)) return 0;
|
if (Number.isNaN(next)) return 0;
|
||||||
return Math.max(0, Math.min(255, Math.round(next)));
|
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 });
|
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)
|
const normalizedKelvin = Number.isFinite(nextKelvin)
|
||||||
? Math.max(2000, Math.min(6500, Math.round(nextKelvin)))
|
? Math.max(2000, Math.min(6500, Math.round(nextKelvin)))
|
||||||
: DEFAULT_WHITE_KELVIN;
|
: DEFAULT_WHITE_KELVIN;
|
||||||
await callService(connection, 'light', 'turn_on', {
|
await callHomeAssistantService('light', 'turn_on', {
|
||||||
entity_id: entityId,
|
entity_id: entityId,
|
||||||
color_temp_kelvin: normalizedKelvin,
|
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();
|
loadEntityConfig();
|
||||||
loadTriggerConfig();
|
loadTriggerConfig();
|
||||||
connect();
|
connect();
|
||||||
@@ -695,8 +720,12 @@ io.on('connection', (socket) => {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getState,
|
getState,
|
||||||
|
isConnected,
|
||||||
|
enabled,
|
||||||
getLightPolicyState,
|
getLightPolicyState,
|
||||||
isLightControlLocked,
|
isLightControlLocked,
|
||||||
|
getRawEntitySnapshot,
|
||||||
|
callHomeAssistantService,
|
||||||
toggleEntity,
|
toggleEntity,
|
||||||
setEntityState,
|
setEntityState,
|
||||||
setLightColor,
|
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 { getActiveDrivers, getTurnQueues, turnEvents } = require('./turnService');
|
||||||
const { getRoomCameras, roomCameraEvents } = require('./roomCameraService');
|
const { getRoomCameras, roomCameraEvents } = require('./roomCameraService');
|
||||||
const { getState: getHomeAssistantState, homeAssistantEvents } = require('./homeAssistantService');
|
const { getState: getHomeAssistantState, homeAssistantEvents } = require('./homeAssistantService');
|
||||||
|
const { getState: getNeatoState, neatoEvents } = require('./neatoService');
|
||||||
const { getNickname, nicknameEvents } = require('./nicknameService');
|
const { getNickname, nicknameEvents } = require('./nicknameService');
|
||||||
const {
|
const {
|
||||||
getVerificationStateForSocket,
|
getVerificationStateForSocket,
|
||||||
@@ -118,6 +119,7 @@ function buildSession(socket) {
|
|||||||
turnQueues,
|
turnQueues,
|
||||||
roomCameras: getRoomCameras(),
|
roomCameras: getRoomCameras(),
|
||||||
homeAssistant: getHomeAssistantState(),
|
homeAssistant: getHomeAssistantState(),
|
||||||
|
neato: getNeatoState(),
|
||||||
replay: getReplayState(),
|
replay: getReplayState(),
|
||||||
replaySources: getReplaySources(socket),
|
replaySources: getReplaySources(socket),
|
||||||
health: getHealthSnapshot(),
|
health: getHealthSnapshot(),
|
||||||
@@ -286,6 +288,11 @@ homeAssistantEvents.on('status', () => {
|
|||||||
syncAll();
|
syncAll();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
neatoEvents.on('update', () => {
|
||||||
|
logger.info('Neato state change; syncing all clients');
|
||||||
|
syncAll();
|
||||||
|
});
|
||||||
|
|
||||||
replayEvents.on('update', () => {
|
replayEvents.on('update', () => {
|
||||||
logger.info('Replay cooldown updated; syncing all clients');
|
logger.info('Replay cooldown updated; syncing all clients');
|
||||||
syncAll();
|
syncAll();
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import VipAudioUploadCard from './vip/VipAudioUploadCard.jsx';
|
|||||||
import VipVerificationCard from './vip/VipVerificationCard.jsx';
|
import VipVerificationCard from './vip/VipVerificationCard.jsx';
|
||||||
import VipIdentityCard from './vip/VipIdentityCard.jsx';
|
import VipIdentityCard from './vip/VipIdentityCard.jsx';
|
||||||
import VipPrivateRoverAccessCard from './vip/VipPrivateRoverAccessCard.jsx';
|
import VipPrivateRoverAccessCard from './vip/VipPrivateRoverAccessCard.jsx';
|
||||||
|
import VipNeatoCard from './vip/VipNeatoCard.jsx';
|
||||||
|
|
||||||
export default function VipPanel() {
|
export default function VipPanel() {
|
||||||
const {
|
const {
|
||||||
@@ -18,6 +19,9 @@ export default function VipPanel() {
|
|||||||
startMicWhip,
|
startMicWhip,
|
||||||
readyMicWhip,
|
readyMicWhip,
|
||||||
stopMicWhip,
|
stopMicWhip,
|
||||||
|
neatoStart,
|
||||||
|
neatoSendHome,
|
||||||
|
neatoLocate,
|
||||||
} = useSession();
|
} = useSession();
|
||||||
const { value: identity, save: saveIdentity } = useSettingsNamespace('identity', { cookieUserId: '' });
|
const { value: identity, save: saveIdentity } = useSettingsNamespace('identity', { cookieUserId: '' });
|
||||||
const { value: profile } = useSettingsNamespace('profile', { nickname: '' });
|
const { value: profile } = useSettingsNamespace('profile', { nickname: '' });
|
||||||
@@ -70,15 +74,25 @@ export default function VipPanel() {
|
|||||||
|
|
||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
{isVerified ? (
|
{isVerified ? (
|
||||||
<VipAudioUploadCard
|
<div className="space-y-0.5">
|
||||||
ownRoverId={ownRoverId}
|
<VipNeatoCard
|
||||||
audioForwardByRover={session?.audioForward || {}}
|
neato={session?.neato || null}
|
||||||
playUploadedAudio={playUploadedAudio}
|
onStart={neatoStart}
|
||||||
stopUploadedAudio={stopUploadedAudio}
|
onSendHome={neatoSendHome}
|
||||||
startMicWhip={startMicWhip}
|
onLocate={neatoLocate}
|
||||||
readyMicWhip={readyMicWhip}
|
onMessage={setMessage}
|
||||||
stopMicWhip={stopMicWhip}
|
fullWidth
|
||||||
/>
|
/>
|
||||||
|
<VipAudioUploadCard
|
||||||
|
ownRoverId={ownRoverId}
|
||||||
|
audioForwardByRover={session?.audioForward || {}}
|
||||||
|
playUploadedAudio={playUploadedAudio}
|
||||||
|
stopUploadedAudio={stopUploadedAudio}
|
||||||
|
startMicWhip={startMicWhip}
|
||||||
|
readyMicWhip={readyMicWhip}
|
||||||
|
stopMicWhip={stopMicWhip}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<section className="surface h-full">
|
<section className="surface h-full">
|
||||||
<div className="flex h-full flex-col items-center justify-center text-center text-xs text-slate-400">
|
<div className="flex h-full flex-col items-center justify-center text-center text-xs text-slate-400">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -163,6 +163,9 @@ export function SessionProvider({ children }) {
|
|||||||
emitWithAck('homeAssistant:lightColor', { entityId, rgbColor }),
|
emitWithAck('homeAssistant:lightColor', { entityId, rgbColor }),
|
||||||
homeAssistantSetLightWhite: (entityId) =>
|
homeAssistantSetLightWhite: (entityId) =>
|
||||||
emitWithAck('homeAssistant:lightWhite', { entityId }),
|
emitWithAck('homeAssistant:lightWhite', { entityId }),
|
||||||
|
neatoStart: () => emitWithAck('neato:start'),
|
||||||
|
neatoSendHome: () => emitWithAck('neato:sendHome'),
|
||||||
|
neatoLocate: () => emitWithAck('neato:locate'),
|
||||||
setNickname: (nickname) => emitWithAck('nickname:set', { nickname }),
|
setNickname: (nickname) => emitWithAck('nickname:set', { nickname }),
|
||||||
requestVerification: () => emitWithAck('verification:request'),
|
requestVerification: () => emitWithAck('verification:request'),
|
||||||
requestPrivateRoverAccess: (roverId) =>
|
requestPrivateRoverAccess: (roverId) =>
|
||||||
|
|||||||
Reference in New Issue
Block a user