mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-17 01:50:47 -04:00
neato alerts and better commands
This commit is contained in:
@@ -9,6 +9,7 @@ const { isFeatureEnabled } = require('../../helpers/features');
|
|||||||
const { isVerified } = require('../verificationService');
|
const { isVerified } = require('../verificationService');
|
||||||
const { getMode, MODES } = require('../modeManager');
|
const { getMode, MODES } = require('../modeManager');
|
||||||
const { isAdmin, isLockdownAdmin } = require('../roleService');
|
const { isAdmin, isLockdownAdmin } = require('../roleService');
|
||||||
|
const { sendAlert } = require('../alertService');
|
||||||
const {
|
const {
|
||||||
homeAssistantEvents,
|
homeAssistantEvents,
|
||||||
getRawEntitySnapshot,
|
getRawEntitySnapshot,
|
||||||
@@ -31,6 +32,7 @@ function normalizeDeviceName(value) {
|
|||||||
|
|
||||||
const device = normalizeDeviceName(neatoConfig.device);
|
const device = normalizeDeviceName(neatoConfig.device);
|
||||||
const RESUME_DELAY_MS = 3000;
|
const RESUME_DELAY_MS = 3000;
|
||||||
|
const ALERT_COLOR = '#a855f7';
|
||||||
// BrainSlug exposes these exact select values for Gen 3 robots. Keeping the
|
// BrainSlug exposes these exact select values for Gen 3 robots. Keeping the
|
||||||
// allowlist on the server prevents arbitrary Home Assistant select options from
|
// allowlist on the server prevents arbitrary Home Assistant select options from
|
||||||
// being submitted by a modified browser while preserving BrainSlug's casing.
|
// 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) {
|
function readRaw(entityIdValue) {
|
||||||
if (!entityIdValue) return null;
|
if (!entityIdValue) return null;
|
||||||
return getRawEntitySnapshot(entityIdValue);
|
return getRawEntitySnapshot(entityIdValue);
|
||||||
@@ -101,6 +119,32 @@ function isEntityAvailable(entityIdValue) {
|
|||||||
return state !== 'unavailable';
|
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() {
|
function requiredEntityIds() {
|
||||||
return [
|
return [
|
||||||
ENTITY_IDS.buttons.start,
|
ENTITY_IDS.buttons.start,
|
||||||
@@ -214,6 +258,7 @@ if (featureEnabled) {
|
|||||||
*/
|
*/
|
||||||
homeAssistantEvents.on('snapshot', () => {
|
homeAssistantEvents.on('snapshot', () => {
|
||||||
emitUpdate();
|
emitUpdate();
|
||||||
|
emitRawStateAlerts();
|
||||||
});
|
});
|
||||||
|
|
||||||
homeAssistantEvents.on('status', () => {
|
homeAssistantEvents.on('status', () => {
|
||||||
|
|||||||
@@ -1,26 +1,76 @@
|
|||||||
// Neato Feature Command
|
// Neato Feature Command
|
||||||
// Purpose: Exposes Neato state and supported actions through the shared text command route.
|
// 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.
|
// 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 = {}) {
|
function describeState(state = {}) {
|
||||||
const telemetry = state.telemetry || {};
|
const telemetry = state.telemetry || {};
|
||||||
const connection = state.connected ? 'connected' : 'offline';
|
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 }) {
|
function createNeatoCommand({ neatoService, sanitizeMentions }) {
|
||||||
return async function handleNeatoCommand(message, tokens = []) {
|
return async function handleNeatoCommand(message, tokens = []) {
|
||||||
const action = String(tokens.shift() || 'status').toLowerCase();
|
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 = {
|
const actions = {
|
||||||
start: ['now cleaning', neatoService.startCleaning],
|
start: ['now cleaning', neatoService.startCleaning],
|
||||||
home: ['returning home', neatoService.sendHome],
|
home: ['returning home', neatoService.sendHome],
|
||||||
locate: ['playing locate sound', neatoService.locateRobot],
|
sound: ['playing sound', neatoService.locateRobot],
|
||||||
'clear-errors': ['clearing errors', neatoService.clearErrors],
|
clear: ['clearing errors', neatoService.clearErrors],
|
||||||
};
|
};
|
||||||
const selected = actions[action];
|
const selected = actions[action];
|
||||||
if (!selected) {
|
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 <normal|gentle|deep|quick>`.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
});
|
||||||
@@ -62,7 +62,18 @@ function buildCommandRegistry(prefix, timeCommand) {
|
|||||||
permission: 'admin',
|
permission: 'admin',
|
||||||
},
|
},
|
||||||
lift: { category: 'features', summary: 'Show or move the lift.', usage: [`${prefix} lift <status|up|down>`], access: 'Public unless server access is restricted', permission: 'access-mode', requiredFeature: 'lift', unavailableLabel: 'Lift' },
|
lift: { category: 'features', summary: 'Show or move the lift.', usage: [`${prefix} lift <status|up|down>`], 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 <status|start|home|locate|clear-errors>`], 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 <status|start|home|sound|clear>`,
|
||||||
|
`${prefix} neato navigation <normal|gentle|deep|quick>`,
|
||||||
|
],
|
||||||
|
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 <global|private>`, `${prefix} bridge mode <global|private>`, `${prefix} bridge off`], access: 'Discord server manager' },
|
bridge: { category: 'discord', summary: 'Configure this Discord server chat bridge.', usage: [`${prefix} bridge`, `${prefix} bridge here <global|private>`, `${prefix} bridge mode <global|private>`, `${prefix} bridge off`], access: 'Discord server manager' },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user