From 9f2f819bbd69463f57b85b97cbf7095cc8519be0 Mon Sep 17 00:00:00 2001 From: legop3 Date: Fri, 14 Aug 2026 23:22:27 -0400 Subject: [PATCH] neato alerts and better commands --- server/src/services/neatoService/index.js | 45 +++++++++ .../operatorCommandService/commands/neato.js | 60 +++++++++++- .../commands/neato.test.js | 96 +++++++++++++++++++ .../operatorCommandService/registry.js | 13 ++- 4 files changed, 208 insertions(+), 6 deletions(-) create mode 100644 server/src/services/operatorCommandService/commands/neato.test.js diff --git a/server/src/services/neatoService/index.js b/server/src/services/neatoService/index.js index 13737a68..e2bb8031 100644 --- a/server/src/services/neatoService/index.js +++ b/server/src/services/neatoService/index.js @@ -9,6 +9,7 @@ const { isFeatureEnabled } = require('../../helpers/features'); const { isVerified } = require('../verificationService'); const { getMode, MODES } = require('../modeManager'); const { isAdmin, isLockdownAdmin } = require('../roleService'); +const { sendAlert } = require('../alertService'); const { homeAssistantEvents, getRawEntitySnapshot, @@ -31,6 +32,7 @@ function normalizeDeviceName(value) { const device = normalizeDeviceName(neatoConfig.device); const RESUME_DELAY_MS = 3000; +const ALERT_COLOR = '#a855f7'; // 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. @@ -70,6 +72,22 @@ const ENTITY_IDS = { }, }; +// Alert Feed coverage is intentionally limited to the raw robot lifecycle and +// issue fields requested for Neato. Battery and charger telemetry poll often and +// would create noise without representing a useful robot status transition. +const ALERT_ENTITIES = Object.freeze([ + { title: 'Neato UI state', entityId: ENTITY_IDS.textSensors.uiState }, + { title: 'Neato robot state', entityId: ENTITY_IDS.textSensors.robotState }, + { title: 'Neato robot alert', entityId: ENTITY_IDS.textSensors.robotAlert }, + { title: 'Neato robot error', entityId: ENTITY_IDS.textSensors.robotError }, + { title: 'Neato external power', entityId: ENTITY_IDS.binarySensors.extPowerPresent }, +]); + +// Each entity establishes its own baseline because ESPHome entities can become +// available on different snapshots. A Map also distinguishes "not observed yet" +// from a legitimate raw state string without inventing a sentinel state value. +const alertBaselines = new Map(); + function readRaw(entityIdValue) { if (!entityIdValue) return null; return getRawEntitySnapshot(entityIdValue); @@ -101,6 +119,32 @@ function isEntityAvailable(entityIdValue) { return state !== 'unavailable'; } +function emitRawStateAlerts() { + for (const { title, entityId: entityIdValue } of ALERT_ENTITIES) { + const raw = readState(entityIdValue); + const normalized = String(raw ?? '').trim().toLowerCase(); + + // Missing and unavailable values commonly occur while Home Assistant or the + // ESPHome device reconnects. Ignoring them preserves the last real baseline + // and prevents connection churn from becoming misleading Neato activity. + if (!normalized || normalized === 'unavailable' || normalized === 'unknown') continue; + + const rawMessage = String(raw); + if (!alertBaselines.has(entityIdValue)) { + // The first real value is startup state, not a transition caused while the + // service was watching, so record it without creating an Alert Feed toast. + alertBaselines.set(entityIdValue, rawMessage); + continue; + } + if (alertBaselines.get(entityIdValue) === rawMessage) continue; + + alertBaselines.set(entityIdValue, rawMessage); + // The title provides field context, while the message remains exactly the + // new Home Assistant state with no friendly translation or previous value. + sendAlert({ color: ALERT_COLOR, title, message: rawMessage }); + } +} + function requiredEntityIds() { return [ ENTITY_IDS.buttons.start, @@ -214,6 +258,7 @@ if (featureEnabled) { */ homeAssistantEvents.on('snapshot', () => { emitUpdate(); + emitRawStateAlerts(); }); homeAssistantEvents.on('status', () => { diff --git a/server/src/services/operatorCommandService/commands/neato.js b/server/src/services/operatorCommandService/commands/neato.js index cfa2a5d3..3ee253ab 100644 --- a/server/src/services/operatorCommandService/commands/neato.js +++ b/server/src/services/operatorCommandService/commands/neato.js @@ -1,26 +1,76 @@ // Neato Feature Command // Purpose: Exposes Neato state and supported actions through the shared text command route. // Scope: Delegates device availability, Home Assistant calls, and operational errors to neatoService. +const NAVIGATION_MODES = Object.freeze({ + normal: 'Normal', + gentle: 'Gentle', + deep: 'Deep', + quick: 'Quick', +}); + +function rawValue(value) { + // The command mirrors the Neato card's raw status contract. Only genuinely + // absent values receive a placeholder; known BrainSlug strings are not + // shortened, humanized, or interpreted by the command layer. + if (value == null || value === '') return 'unknown'; + return String(value); +} + function describeState(state = {}) { const telemetry = state.telemetry || {}; const connection = state.connected ? 'connected' : 'offline'; - return `Neato: ${connection}; state ${telemetry.robotState || 'unknown'}; battery ${telemetry.batteryLevel ?? 'unknown'}%.`; + // batteryPercent is the canonical neatoService field. The old command read a + // nonexistent batteryLevel property, which made every status report unknown. + const battery = Number.isFinite(telemetry.batteryPercent) + ? `${telemetry.batteryPercent}%` + : 'unknown'; + const voltage = Number.isFinite(telemetry.batteryVoltage) + ? `${telemetry.batteryVoltage.toFixed(2)} V` + : 'unknown'; + + return [ + `Neato: ${connection}`, + `Battery: ${battery}`, + `Battery voltage: ${voltage}`, + `Robot alert: ${rawValue(telemetry.robotAlert)}`, + `Robot error: ${rawValue(telemetry.robotError)}`, + `Robot state: ${rawValue(telemetry.robotState)}`, + `UI state: ${rawValue(telemetry.uiState)}`, + ].join('\n'); } function createNeatoCommand({ neatoService, sanitizeMentions }) { return async function handleNeatoCommand(message, tokens = []) { const action = String(tokens.shift() || 'status').toLowerCase(); - if (action === 'status') return message.reply({ content: describeState(neatoService.getState()) }); + if (action === 'status') { + // Raw device strings still pass through the transport's mention sanitizer + // so Home Assistant state cannot create an accidental Discord mention. + return message.reply({ content: sanitizeMentions(describeState(neatoService.getState())) }); + } + + if (action === 'navigation') { + const requestedMode = String(tokens.shift() || '').toLowerCase(); + const navigationMode = NAVIGATION_MODES[requestedMode]; + if (!navigationMode || tokens.length > 0) { + return message.reply({ content: 'Invalid Neato navigation mode. Use `neato navigation normal`, `neato navigation gentle`, `neato navigation deep`, or `neato navigation quick`.' }); + } + try { + await neatoService.setNavigationMode(navigationMode); + return message.reply({ content: `Neato navigation mode set to ${navigationMode}.` }); + } catch (err) { + return message.reply({ content: sanitizeMentions(`Neato command failed: ${err.message}`) }); + } + } const actions = { start: ['now cleaning', neatoService.startCleaning], home: ['returning home', neatoService.sendHome], - locate: ['playing locate sound', neatoService.locateRobot], - 'clear-errors': ['clearing errors', neatoService.clearErrors], + sound: ['playing sound', neatoService.locateRobot], + clear: ['clearing errors', neatoService.clearErrors], }; const selected = actions[action]; if (!selected) { - return message.reply({ content: 'Invalid Neato command. Use `neato status`, `neato start`, `neato home`, `neato locate`, or `neato clear-errors`.' }); + return message.reply({ content: 'Invalid Neato command. Use `neato status`, `neato start`, `neato home`, `neato sound`, `neato clear`, or `neato navigation `.' }); } try { diff --git a/server/src/services/operatorCommandService/commands/neato.test.js b/server/src/services/operatorCommandService/commands/neato.test.js new file mode 100644 index 00000000..84087e50 --- /dev/null +++ b/server/src/services/operatorCommandService/commands/neato.test.js @@ -0,0 +1,96 @@ +// Neato Feature Command Tests +// Purpose: Pins the public Neato status report and its intentionally small control vocabulary. +// Scope: Uses a service double so hardware, Home Assistant, and access-mode behavior remain in their owning tests. +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { createNeatoCommand } = require('./neato'); + +function createHarness(state = {}) { + const replies = []; + const calls = []; + const neatoService = { + getState: () => state, + startCleaning: async () => calls.push(['start']), + sendHome: async () => calls.push(['home']), + locateRobot: async () => calls.push(['sound']), + clearErrors: async () => calls.push(['clear']), + setNavigationMode: async (mode) => calls.push(['navigation', mode]), + }; + const handler = createNeatoCommand({ neatoService, sanitizeMentions: String }); + const message = { + actor: { id: 'test-user' }, + reply: async (payload) => replies.push(payload.content), + }; + return { handler, message, replies, calls }; +} + +test('status reports the canonical battery fields and every raw UI status value', async () => { + const state = { + connected: true, + telemetry: { + batteryPercent: 82, + batteryVoltage: 14.671, + robotAlert: '200 (UI_ALERT_NONE)', + robotError: '200 (UI_ERROR_NONE)', + robotState: 'ROBOT_STATE_HOUSECLEANING', + uiState: 'UIMGR_STATE_HOUSECLEANINGRUNNING', + }, + }; + const { handler, message, replies } = createHarness(state); + + await handler(message, ['status']); + + assert.equal(replies[0], [ + 'Neato: connected', + 'Battery: 82%', + 'Battery voltage: 14.67 V', + 'Robot alert: 200 (UI_ALERT_NONE)', + 'Robot error: 200 (UI_ERROR_NONE)', + 'Robot state: ROBOT_STATE_HOUSECLEANING', + 'UI state: UIMGR_STATE_HOUSECLEANINGRUNNING', + ].join('\n')); +}); + +test('bare neato status uses unknown only for values the service did not provide', async () => { + const { handler, message, replies } = createHarness({ connected: false, telemetry: {} }); + + await handler(message, []); + + assert.match(replies[0], /^Neato: offline\nBattery: unknown\nBattery voltage: unknown/m); + assert.match(replies[0], /Robot alert: unknown/); + assert.match(replies[0], /UI state: unknown/); +}); + +test('sound and clear are the only names for their renamed actions', async () => { + const { handler, message, replies, calls } = createHarness(); + + await handler(message, ['sound']); + await handler(message, ['clear']); + await handler(message, ['locate']); + await handler(message, ['clear-errors']); + + assert.deepEqual(calls, [['sound'], ['clear']]); + assert.match(replies[2], /Invalid Neato command/); + assert.match(replies[3], /Invalid Neato command/); +}); + +test('navigation normalizes command input to the exact service option', async () => { + const { handler, message, replies, calls } = createHarness(); + + await handler(message, ['navigation', 'gEnTlE']); + + assert.deepEqual(calls, [['navigation', 'Gentle']]); + assert.equal(replies[0], 'Neato navigation mode set to Gentle.'); +}); + +test('navigation rejects missing, unknown, and extra arguments', async () => { + const { handler, message, replies, calls } = createHarness(); + + await handler(message, ['navigation']); + await handler(message, ['navigation', 'turbo']); + await handler(message, ['navigation', 'normal', 'extra']); + + assert.deepEqual(calls, []); + assert.equal(replies.length, 3); + for (const reply of replies) assert.match(reply, /navigation normal/); +}); diff --git a/server/src/services/operatorCommandService/registry.js b/server/src/services/operatorCommandService/registry.js index ba5ea416..5b224594 100644 --- a/server/src/services/operatorCommandService/registry.js +++ b/server/src/services/operatorCommandService/registry.js @@ -62,7 +62,18 @@ function buildCommandRegistry(prefix, timeCommand) { permission: 'admin', }, lift: { category: 'features', summary: 'Show or move the lift.', usage: [`${prefix} lift `], access: 'Public unless server access is restricted', permission: 'access-mode', requiredFeature: 'lift', unavailableLabel: 'Lift' }, - neato: { category: 'features', summary: 'Show or control Neato.', usage: [`${prefix} neato `], access: 'Public unless server access is restricted', permission: 'access-mode', requiredFeature: 'neato', unavailableLabel: 'Neato' }, + neato: { + category: 'features', + summary: 'Show or control Neato.', + usage: [ + `${prefix} neato `, + `${prefix} neato navigation `, + ], + access: 'Public unless server access is restricted', + permission: 'access-mode', + requiredFeature: 'neato', + unavailableLabel: 'Neato', + }, bridge: { category: 'discord', summary: 'Configure this Discord server chat bridge.', usage: [`${prefix} bridge`, `${prefix} bridge here `, `${prefix} bridge mode `, `${prefix} bridge off`], access: 'Discord server manager' }, }; }