mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-17 10:00:46 -04:00
move neato and lift into activities
This commit is contained in:
@@ -1,141 +0,0 @@
|
||||
// Vip Lift Card
|
||||
// Purpose: Renders a shared lift controller panel synced from server session state.
|
||||
// Scope: Presents verified-user controls while reflecting global busy/position/cooldown state.
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
|
||||
function badgeClass(tone) {
|
||||
if (tone === 'good') return 'bg-emerald-600 text-white';
|
||||
if (tone === 'warn') return 'bg-amber-500 text-slate-900';
|
||||
if (tone === 'danger') return 'bg-rose-600 text-white';
|
||||
return 'bg-slate-700 text-slate-100';
|
||||
}
|
||||
|
||||
function positionLabel(value) {
|
||||
if (value === 'up') return 'Up';
|
||||
if (value === 'down') return 'Down';
|
||||
if (value === 'stopped') return 'Transitioning...';
|
||||
if (value === 'conflict') return 'Conflict';
|
||||
return '--';
|
||||
}
|
||||
|
||||
export default function VipLiftCard({ lift, onUp, onDown, fullWidth = false }) {
|
||||
const [working, setWorking] = useState('');
|
||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||
const wrapClass = fullWidth ? 'w-full' : 'w-full max-w-xl';
|
||||
|
||||
const configured = Boolean(lift?.configured);
|
||||
const connected = Boolean(lift?.enabled && lift?.connected);
|
||||
const busy = Boolean(lift?.busy);
|
||||
const activeTarget = String(lift?.target || '').toLowerCase();
|
||||
const position = String(lift?.position || '').toLowerCase();
|
||||
const upAvailable = Boolean(lift?.availability?.upSwitch);
|
||||
const downAvailable = Boolean(lift?.availability?.downSwitch);
|
||||
const lastActionAt = Number(lift?.lastActionAt || 0);
|
||||
const commandCooldownMs = Math.max(0, Number(lift?.commandCooldownMs || 0));
|
||||
const cooldownRemainingMs = Math.max(0, lastActionAt + commandCooldownMs - nowMs);
|
||||
const cooldownActive = !busy && cooldownRemainingMs > 0;
|
||||
const blocked = busy || cooldownActive;
|
||||
|
||||
useEffect(() => {
|
||||
if (!blocked) return undefined;
|
||||
const t = setInterval(() => {
|
||||
setNowMs(Date.now());
|
||||
}, 100);
|
||||
return () => clearInterval(t);
|
||||
}, [blocked]);
|
||||
|
||||
useEffect(() => {
|
||||
setNowMs(Date.now());
|
||||
}, [lastActionAt, commandCooldownMs, busy]);
|
||||
|
||||
const status = !configured ? 'Not configured' : !connected ? 'Offline' : blocked ? 'Busy' : 'Ready';
|
||||
const statusTone = !configured ? 'warn' : !connected ? 'danger' : blocked ? 'warn' : 'good';
|
||||
|
||||
const canRun = configured && connected && !blocked && !working && upAvailable && downAvailable;
|
||||
const cooldownSeconds = useMemo(() => Math.ceil(cooldownRemainingMs / 1000), [cooldownRemainingMs]);
|
||||
|
||||
const run = async (dir, fn) => {
|
||||
if (!fn) return;
|
||||
setWorking(dir);
|
||||
try {
|
||||
await fn();
|
||||
} catch {
|
||||
// Errors are surfaced by shared state and command ack handling.
|
||||
} finally {
|
||||
setWorking('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<CardFrame
|
||||
title="Lift Controls"
|
||||
|
||||
className={`relative ${wrapClass}`}
|
||||
bodyClassName="text-sm text-slate-200"
|
||||
actions={
|
||||
<span className={`inline-flex w-auto rounded px-1 py-0.25 text-xs font-semibold ${badgeClass(statusTone)}`}>
|
||||
{status}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{blocked ? (
|
||||
<div className="absolute inset-0 z-20 flex items-center justify-center rounded-md bg-slate-950/80 px-1.5 text-center">
|
||||
<div className="space-y-0.25">
|
||||
<p className="text-sm font-semibold text-slate-100">
|
||||
{busy ? 'Preparing to move' : 'Moving!'}
|
||||
</p>
|
||||
<p className="text-xs text-slate-300">
|
||||
Controls are disabled while the lift is moving, otherwise its tiny brain would get confused.
|
||||
</p>
|
||||
{!busy && cooldownActive ? (
|
||||
<p className="text-xs text-slate-400">About {cooldownSeconds}s remaining.</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="grid gap-0.5">
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-slate-400">Move the lift up and down. Please don't break anything...</p>
|
||||
</div>
|
||||
|
||||
<section className="surface-muted px-0.5 py-0.5">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-0.5">
|
||||
<div className="rounded-md bg-slate-800 px-1 py-0.75 text-center">
|
||||
<div className="text-xs text-slate-300">Position</div>
|
||||
<div className="text-base font-semibold text-slate-100">{positionLabel(position)}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canRun}
|
||||
onClick={() => run('down', onDown)}
|
||||
className={`button-dark w-full text-sm disabled:opacity-50 ${position === 'down' || activeTarget === 'down' ? 'bg-emerald-500 text-white hover:bg-emerald-500' : ''}`}
|
||||
>
|
||||
{working === 'down' || activeTarget === 'down' ? 'Lowering...' : 'Down'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canRun}
|
||||
onClick={() => run('up', onUp)}
|
||||
className={`button-dark w-full text-sm disabled:opacity-50 ${position === 'up' || activeTarget === 'up' ? 'bg-emerald-500 text-white hover:bg-emerald-500' : ''}`}
|
||||
>
|
||||
{working === 'up' || activeTarget === 'up' ? 'Raising...' : 'Up'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{!configured || !connected || !upAvailable || !downAvailable ? (
|
||||
<p className="text-xs text-slate-400 text-center">
|
||||
{!configured
|
||||
? 'Set homeAssistant.lift.upSwitch and homeAssistant.lift.downSwitch in server config.'
|
||||
: !connected
|
||||
? 'Home Assistant is offline.'
|
||||
: 'Waiting for required lift switch entities in Home Assistant.'}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{lift?.lastError ? <p className="text-xs text-rose-300 text-center">Last error: {lift.lastError}</p> : null}
|
||||
</div>
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
// Vip Neato Card
|
||||
// Purpose: Defines the Vip Neato Card module and the local helpers/components used in this file.
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
import { useState } from 'react';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
|
||||
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 metricToneClass(tone) {
|
||||
if (tone === 'good') return 'bg-emerald-600 text-white';
|
||||
if (tone === 'warn') return 'bg-amber-500 text-slate-900';
|
||||
if (tone === 'danger') return 'bg-rose-600 text-white';
|
||||
return 'bg-slate-700 text-slate-100';
|
||||
}
|
||||
|
||||
function StatusTile({ label, value, tone = 'muted', valueClass = '', hideLabel = false }) {
|
||||
return (
|
||||
<div className={`rounded-md px-1 py-0.75 text-center ${metricToneClass(tone)}`}>
|
||||
{!hideLabel ? <div className="text-[0.72rem] opacity-90">{label}</div> : null}
|
||||
<div className={`text-sm font-semibold ${valueClass}`} title={String(value || '')}>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function VipNeatoCard({
|
||||
neato,
|
||||
onStart,
|
||||
onSendHome,
|
||||
onLocate,
|
||||
onClearErrors,
|
||||
onPowerCycle,
|
||||
fullWidth = false,
|
||||
}) {
|
||||
const [working, setWorking] = useState('');
|
||||
const wrapClass = fullWidth ? 'w-full' : 'w-full max-w-xl';
|
||||
|
||||
const configured = Boolean(neato?.configured);
|
||||
const connected = Boolean(neato?.enabled && neato?.connected);
|
||||
const docked = Boolean(neato?.telemetry?.extPowerPresent);
|
||||
const charging = Boolean(neato?.telemetry?.chargingActive);
|
||||
|
||||
const uiStateLabel = humanizeUiState(neato?.telemetry?.uiState);
|
||||
const robotStateRaw = normalizeState(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 controls = neato?.controls || {};
|
||||
const canStart = Boolean(controls?.start?.available);
|
||||
const canSendHome = Boolean(controls?.sendHome?.available);
|
||||
const canLocate = Boolean(controls?.locate?.available);
|
||||
const canClearErrors = Boolean(controls?.clearErrors?.available);
|
||||
const canPowerCycle = Boolean(controls?.powerCycle?.available);
|
||||
|
||||
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 runAction = async (key, fn) => {
|
||||
if (!fn) return;
|
||||
setWorking(key);
|
||||
try {
|
||||
await fn();
|
||||
} catch {
|
||||
// Keep UI minimal; errors are intentionally silent in-panel.
|
||||
} finally {
|
||||
setWorking('');
|
||||
}
|
||||
};
|
||||
|
||||
const headerStatus = !configured ? 'Not configured' : !connected ? 'Offline' : 'Online';
|
||||
const headerTone = !configured ? 'warn' : !connected ? 'danger' : 'good';
|
||||
|
||||
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"
|
||||
|
||||
className={wrapClass}
|
||||
bodyClassName="text-sm text-slate-200"
|
||||
actions={
|
||||
<span className={`inline-flex w-auto rounded px-1 py-0.25 text-xs font-semibold ${metricToneClass(headerTone)}`}>
|
||||
{headerStatus}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-0.5">
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-slate-400">Control the autonomous Neato robovac. Be nice to him.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-0.5">
|
||||
<div className="surface-muted grid gap-0.5">
|
||||
<p className="text-xs text-slate-300 text-center">Controls</p>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canRunStart || Boolean(working)}
|
||||
onClick={() => runAction('start', onStart)}
|
||||
className="rounded-md border border-sky-300 bg-emerald-600 px-1 py-1 text-base font-semibold text-white transition hover:border-sky-500 hover:bg-emerald-500 disabled:opacity-50"
|
||||
>
|
||||
{working === 'start' ? 'Starting...' : 'Start cleaning'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canRunSendHome || Boolean(working)}
|
||||
onClick={() => runAction('sendHome', onSendHome)}
|
||||
className="rounded-md border border-sky-300 bg-sky-600 px-1 py-1 text-base font-semibold text-white transition hover:border-sky-500 hover:bg-sky-500 disabled:opacity-50"
|
||||
>
|
||||
{working === 'sendHome' ? 'Sending...' : 'Send to dock'}
|
||||
</button>
|
||||
<div className="grid grid-cols-3 gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canRunLocate || Boolean(working)}
|
||||
onClick={() => runAction('locate', onLocate)}
|
||||
className="w-full rounded-md border border-sky-300 bg-fuchsia-600 px-1 py-0.5 text-xs font-semibold text-white transition hover:border-sky-500 hover:bg-fuchsia-500 disabled:opacity-50"
|
||||
>
|
||||
{working === 'locate' ? 'Playing...' : 'Play sound'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canRunClearErrors || Boolean(working)}
|
||||
onClick={() => runAction('clearErrors', onClearErrors)}
|
||||
className="w-full rounded-md border border-sky-300 bg-amber-500 px-1 py-0.5 text-xs font-semibold text-slate-900 transition hover:border-sky-500 hover:bg-amber-400 disabled:opacity-50"
|
||||
>
|
||||
{working === 'clearErrors' ? 'Clearing...' : 'Clear errors'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canRunPowerCycle || Boolean(working)}
|
||||
onClick={() => runAction('powerCycle', onPowerCycle)}
|
||||
className="w-full rounded-md border border-rose-300 bg-rose-600 px-1 py-0.5 text-xs font-semibold text-white transition hover:border-rose-500 hover:bg-rose-500 disabled:opacity-50"
|
||||
>
|
||||
{working === 'powerCycle' ? 'Cycling...' : 'Power cycle'}
|
||||
</button>
|
||||
</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">
|
||||
<StatusTile label="Battery" value={batteryLabel} tone={batteryTone} />
|
||||
<StatusTile label="Voltage" value={voltageLabel} tone="muted" />
|
||||
<StatusTile
|
||||
label="Docked"
|
||||
value={docked ? 'Docked' : 'Not docked'}
|
||||
tone={docked ? 'good' : 'muted'}
|
||||
hideLabel
|
||||
/>
|
||||
<StatusTile
|
||||
label="Charging"
|
||||
value={charging ? 'charging' : 'not charging'}
|
||||
tone={charging ? 'good' : 'muted'}
|
||||
hideLabel
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-0.5">
|
||||
<div className="surface-muted grid gap-0.5">
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!configured || !connected || !canStart || !canSendHome || !canLocate || !canClearErrors || !canPowerCycle ? (
|
||||
<p className="text-xs text-slate-400 text-center">
|
||||
{!configured
|
||||
? 'Set homeAssistant.neato.device in server config to enable Neato controls.'
|
||||
: !connected
|
||||
? 'Home Assistant is offline.'
|
||||
: 'Waiting for required Neato entities in Home Assistant.'}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user