neato updates

This commit is contained in:
legop3
2026-08-14 20:25:07 -04:00
parent b24d453ad1
commit 4430517af5
9 changed files with 116 additions and 47 deletions
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
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -12,7 +12,7 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<!-- site-metadata:inject -->
<!-- analytics:inject -->
<script type="module" crossorigin src="/assets/index-CTIt0so7.js"></script>
<script type="module" crossorigin src="/assets/index-F5hHfgX6.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CaoGM6_n.css">
</head>
<body>
+49
View File
@@ -31,6 +31,10 @@ function normalizeDeviceName(value) {
const device = normalizeDeviceName(neatoConfig.device);
const RESUME_DELAY_MS = 3000;
// BrainSlug exposes these exact select values for Gen 3 robots. Keeping the
// allowlist on the server prevents arbitrary Home Assistant select options from
// being submitted by a modified browser while preserving BrainSlug's casing.
const NAVIGATION_MODES = Object.freeze(['Normal', 'Gentle', 'Deep', 'Quick']);
function entityId(domain, suffix) {
if (!device) return '';
@@ -61,6 +65,9 @@ const ENTITY_IDS = {
robotError: entityId('sensor', 'robot_error'),
robotAlert: entityId('sensor', 'robot_alert'),
},
selects: {
navigationMode: entityId('select', 'navigation_mode'),
},
};
function readRaw(entityIdValue) {
@@ -146,6 +153,14 @@ function buildState() {
entityId: ENTITY_IDS.buttons.powerCycle,
available: hasEntity(ENTITY_IDS.buttons.powerCycle),
},
navigationMode: {
entityId: ENTITY_IDS.selects.navigationMode,
available: isEntityAvailable(ENTITY_IDS.selects.navigationMode),
value: readState(ENTITY_IDS.selects.navigationMode),
// The browser receives the supported choices through the session contract
// instead of duplicating BrainSlug-specific values in the presentation layer.
options: NAVIGATION_MODES,
},
};
const batteryPercentValue = parseNumber(readState(ENTITY_IDS.sensors.batteryPercent));
@@ -255,6 +270,29 @@ async function powerCycle() {
await pressButton(ENTITY_IDS.buttons.powerCycle, 'powercucle');
}
async function setNavigationMode(mode) {
assertConfiguredAndConnected();
const normalizedMode = String(mode || '').trim();
if (!NAVIGATION_MODES.includes(normalizedMode)) {
throw new Error('Invalid Neato navigation mode');
}
if (!isEntityAvailable(ENTITY_IDS.selects.navigationMode)) {
throw new Error('Neato action unavailable: navigation_mode');
}
// ESPHome implements Navigation Mode as a Home Assistant select entity, so
// select_option is the native service call and avoids sending raw UART commands.
await callHomeAssistantService('select', 'select_option', {
entity_id: ENTITY_IDS.selects.navigationMode,
option: normalizedMode,
});
logger.info('Issued Neato action', {
action: 'set_navigation_mode',
entityId: ENTITY_IDS.selects.navigationMode,
option: normalizedMode,
});
}
function getState() {
cachedState = buildState();
return cachedState;
@@ -328,6 +366,16 @@ if (featureEnabled) {
cb({ error: err.message });
}
});
socket.on('neato:setNavigationMode', async ({ mode } = {}, cb = () => {}) => {
try {
assertFeatureAccess();
await setNavigationMode(mode);
cb({ success: true });
} catch (err) {
cb({ error: err.message });
}
});
});
} else {
logger.info('Neato disabled by config');
@@ -342,5 +390,6 @@ module.exports = {
locateRobot,
clearErrors,
powerCycle,
setNavigationMode,
neatoEvents: events,
};
+48 -29
View File
@@ -10,17 +10,12 @@ 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('HOUSECLEANINGRUNNING')) return 'Cleaning';
if (state.includes('SPOTCLEANINGRUNNING')) return 'Spot cleaning';
if (state.includes('STATE_START')) return 'Starting';
if (state.includes('STATE_IDLE')) return 'Idle';
if (state.includes('STATE_STANDBY')) return 'Idle';
return state;
function displayRawState(value) {
// Only substitute a placeholder when Home Assistant supplied no value at all.
// Otherwise preserve the complete string so the status panel is a faithful
// view of BrainSlug output, including identifiers unknown to this frontend.
if (value == null || value === '') return '--';
return String(value);
}
function metricToneClass(tone) {
@@ -66,6 +61,7 @@ function NeatoCardContent() {
neatoLocate,
neatoClearErrors,
neatoPowerCycle,
neatoSetNavigationMode,
} = useSessionActions();
const [working, setWorking] = useState('');
@@ -74,14 +70,17 @@ function NeatoCardContent() {
const docked = Boolean(neato?.telemetry?.extPowerPresent);
const charging = Boolean(neato?.telemetry?.chargingActive);
const uiStateLabel = humanizeUiState(neato?.telemetry?.uiState);
const robotStateRaw = normalizeState(neato?.telemetry?.robotState) || '--';
// These values deliberately stay raw. BrainSlug owns their meaning, and a
// partial frontend translation would create a second, potentially incorrect
// state model instead of showing what the robot actually reported.
const uiStateRaw = displayRawState(neato?.telemetry?.uiState);
const robotStateRaw = displayRawState(neato?.telemetry?.robotState);
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 robotError = normalizeState(neato?.telemetry?.robotError) || '--';
const robotAlert = normalizeState(neato?.telemetry?.robotAlert) || '--';
const robotError = displayRawState(neato?.telemetry?.robotError);
const robotAlert = displayRawState(neato?.telemetry?.robotAlert);
const controls = neato?.controls || {};
const canStart = Boolean(controls?.start?.available);
@@ -89,12 +88,17 @@ function NeatoCardContent() {
const canLocate = Boolean(controls?.locate?.available);
const canClearErrors = Boolean(controls?.clearErrors?.available);
const canPowerCycle = Boolean(controls?.powerCycle?.available);
const navigationMode = controls?.navigationMode || {};
const canSetNavigationMode = Boolean(navigationMode.available);
const navigationModeValue = normalizeState(navigationMode.value);
const navigationModeOptions = Array.isArray(navigationMode.options) ? navigationMode.options : [];
const canRunStart = configured && connected && canStart;
const canRunSendHome = configured && connected && canSendHome;
const canRunLocate = configured && connected && canLocate;
const canRunClearErrors = configured && connected && canClearErrors;
const canRunPowerCycle = configured && connected && canPowerCycle;
const canRunNavigationMode = configured && connected && canSetNavigationMode;
const runAction = async (key, fn) => {
if (!fn) return;
@@ -114,8 +118,6 @@ function NeatoCardContent() {
const batteryTone =
battery == null ? 'muted' : battery >= 60 ? 'good' : battery >= 25 ? 'warn' : 'danger';
const primaryState = docked ? 'Docked' : uiStateLabel !== '--' ? uiStateLabel : 'Away from dock';
return (
<CardFrame
title="Neato Controls"
@@ -178,6 +180,27 @@ function NeatoCardContent() {
{working === 'powerCycle' ? 'Cycling...' : 'Power cycle'}
</button>
</div>
<div className="surface-muted grid gap-0.5 pt-0.25">
<label className="grid gap-0.25 text-xs text-slate-300">
<span className="text-center">Navigation mode</span>
<select
value={navigationModeValue}
disabled={!canRunNavigationMode || Boolean(working)}
onChange={(event) => runAction(
'navigationMode',
() => neatoSetNavigationMode(event.target.value),
)}
className="field-input w-full text-sm disabled:opacity-50"
>
{!navigationModeOptions.includes(navigationModeValue) ? (
<option value="">--</option>
) : null}
{navigationModeOptions.map((mode) => (
<option key={mode} value={mode}>{mode}</option>
))}
</select>
</label>
</div>
<div className="surface-muted grid gap-0.5 pt-0.25">
<p className="text-xs text-slate-300 text-center">Power status</p>
<div className="grid grid-cols-2 gap-0.5">
@@ -204,24 +227,20 @@ function NeatoCardContent() {
<p className="text-xs text-slate-300 text-center">Robot Status</p>
<div className="grid gap-0.5">
<div className="rounded-md bg-slate-800 px-1 py-0.5">
<div className="text-[0.72rem] text-slate-300">Robot state (raw)</div>
<div className="font-mono text-sm text-slate-100 break-all">{robotStateRaw}</div>
</div>
<div className="rounded-md bg-slate-800 px-1 py-0.5">
<div className="text-[0.72rem] text-slate-300">Basic state</div>
<div className="font-mono text-sm text-slate-100 break-all">{primaryState}</div>
</div>
<div className="rounded-md bg-slate-800 px-1 py-0.5">
<div className="text-[0.72rem] text-slate-300">UI state</div>
<div className="font-mono text-sm text-slate-100 break-all">{uiStateLabel}</div>
<div className="text-[0.72rem] text-slate-300">Robot alert</div>
<div className="font-mono text-sm text-slate-100 break-all">{robotAlert}</div>
</div>
<div className="rounded-md bg-slate-800 px-1 py-0.5">
<div className="text-[0.72rem] text-slate-300">Robot error</div>
<div className="font-mono text-sm text-slate-100 break-all">{robotError}</div>
</div>
<div className="rounded-md bg-slate-800 px-1 py-0.5">
<div className="text-[0.72rem] text-slate-300">Robot alert</div>
<div className="font-mono text-sm text-slate-100 break-all">{robotAlert}</div>
<div className="text-[0.72rem] text-slate-300">Robot state</div>
<div className="font-mono text-sm text-slate-100 break-all">{robotStateRaw}</div>
</div>
<div className="rounded-md bg-slate-800 px-1 py-0.5">
<div className="text-[0.72rem] text-slate-300">UI state</div>
<div className="font-mono text-sm text-slate-100 break-all">{uiStateRaw}</div>
</div>
</div>
</div>
+1
View File
@@ -365,6 +365,7 @@ export function SessionProvider({ children }) {
neatoLocate: () => emitWithAck('neato:locate'),
neatoClearErrors: () => emitWithAck('neato:clearErrors'),
neatoPowerCycle: () => emitWithAck('neato:powerCycle'),
neatoSetNavigationMode: (mode) => emitWithAck('neato:setNavigationMode', { mode }),
liftUp: () => emitWithAck('lift:up'),
liftDown: () => emitWithAck('lift:down'),
setNickname: (nickname) => emitWithAck('nickname:set', { nickname }),