neato alerts and better commands

This commit is contained in:
legop3
2026-08-14 23:22:27 -04:00
parent 4430517af5
commit 9f2f819bbd
4 changed files with 208 additions and 6 deletions
@@ -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 <normal|gentle|deep|quick>`.' });
}
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/);
});