mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
month
This commit is contained in:
@@ -9,73 +9,107 @@ function humanizeUiState(value) {
|
||||
if (!state) return '--';
|
||||
if (state.includes('DOCKINGRUNNING')) return 'Returning';
|
||||
if (state.includes('PAUSED')) return 'Paused';
|
||||
if (state.includes('CLEANINGRUNNING')) return 'Cleaning';
|
||||
if (state.includes('HOUSECLEANINGRUNNING')) return 'Cleaning House';
|
||||
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 'Standby';
|
||||
return state;
|
||||
}
|
||||
|
||||
function badgeClass(active) {
|
||||
return active
|
||||
? 'rounded bg-emerald-500 px-1 py-0.5 text-[0.7rem] font-semibold text-white'
|
||||
: 'rounded bg-slate-700 px-1 py-0.5 text-[0.7rem] font-semibold text-slate-200';
|
||||
}
|
||||
|
||||
function statusToneClass(tone) {
|
||||
if (tone === 'good') return 'bg-emerald-500';
|
||||
if (tone === 'warn') return 'bg-amber-500 text-slate-900';
|
||||
if (tone === 'danger') return 'bg-rose-600';
|
||||
if (tone === 'info') return 'bg-sky-500';
|
||||
return 'bg-slate-700';
|
||||
if (tone === 'good') return 'border-emerald-200 bg-emerald-600 text-white';
|
||||
if (tone === 'warn') return 'border-amber-200 bg-amber-600 text-white';
|
||||
if (tone === 'danger') return 'border-rose-200 bg-rose-600 text-white';
|
||||
if (tone === 'info') return 'border-sky-200 bg-sky-600 text-white';
|
||||
return 'border-slate-200/30 bg-slate-700 text-slate-100';
|
||||
}
|
||||
|
||||
function StatusIndicator({ label, tone = 'muted', detail = '' }) {
|
||||
function StatusRow({ label, value, tone = 'muted' }) {
|
||||
return (
|
||||
<div className={`rounded-md px-0.5 py-0.5 text-xs text-slate-100 ${statusToneClass(tone)}`}>
|
||||
<div className="text-center font-medium">{label}</div>
|
||||
<div className="text-center text-[0.72rem] opacity-90">{detail || '--'}</div>
|
||||
<div
|
||||
className={`flex items-center justify-between rounded-lg border px-1 py-0.5 text-xs ${statusToneClass(tone)}`}
|
||||
>
|
||||
<span className="font-semibold uppercase tracking-wide opacity-90">{label}</span>
|
||||
<span className="font-semibold">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({ label, value, tone = 'muted' }) {
|
||||
return (
|
||||
<div className={`rounded-lg border px-1 py-0.75 text-center ${statusToneClass(tone)}`}>
|
||||
<div className="text-[0.7rem] uppercase tracking-wide opacity-90">{label}</div>
|
||||
<div className="text-sm font-semibold">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionPill({ label, tone = 'indigo' }) {
|
||||
const toneClasses =
|
||||
tone === 'emerald'
|
||||
? 'border-emerald-200/70 bg-emerald-600/70 text-emerald-50'
|
||||
: tone === 'amber'
|
||||
? 'border-amber-200/70 bg-amber-600/70 text-amber-50'
|
||||
: 'border-indigo-200/70 bg-indigo-600/70 text-indigo-50';
|
||||
return (
|
||||
<span className={`rounded-full border px-0.5 py-0.15 text-[0.7rem] font-semibold ${toneClasses}`}>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function VipNeatoCard({ neato, onStart, onSendHome, onLocate, fullWidth = false }) {
|
||||
const [working, setWorking] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const wrapClass = fullWidth ? 'w-full' : 'w-full max-w-xl';
|
||||
|
||||
const status = useMemo(() => {
|
||||
if (!neato?.enabled) {
|
||||
if (!neato?.configured) return 'Not configured';
|
||||
return 'Disabled';
|
||||
}
|
||||
if (!neato?.connected) return 'Offline';
|
||||
return 'Online';
|
||||
}, [neato?.configured, neato?.connected, neato?.enabled]);
|
||||
|
||||
const canControl =
|
||||
Boolean(neato?.enabled) &&
|
||||
Boolean(neato?.connected) &&
|
||||
Boolean(neato?.controls?.start?.available) &&
|
||||
Boolean(neato?.controls?.sendHome?.available) &&
|
||||
Boolean(neato?.controls?.locate?.available);
|
||||
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 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 charging = Boolean(neato?.telemetry?.chargingActive);
|
||||
const docked = Boolean(neato?.telemetry?.extPowerPresent);
|
||||
const robotError = normalizeState(neato?.telemetry?.robotError) || '--';
|
||||
const robotAlert = normalizeState(neato?.telemetry?.robotAlert) || '--';
|
||||
|
||||
const hasError = robotError !== '--' && !/^no errors$/i.test(robotError) && !/^200/.test(robotError);
|
||||
const hasAlert = robotAlert !== '--' && !/^200/.test(robotAlert);
|
||||
const batteryTone =
|
||||
battery == null ? 'muted' : battery >= 60 ? 'good' : battery >= 25 ? 'warn' : 'danger';
|
||||
const voltageTone = voltage == null ? 'muted' : 'info';
|
||||
const chargingTone = charging ? 'info' : 'muted';
|
||||
const dockedTone = docked ? 'info' : 'muted';
|
||||
|
||||
const controls = neato?.controls || {};
|
||||
const canStart = Boolean(controls?.start?.available);
|
||||
const canSendHome = Boolean(controls?.sendHome?.available);
|
||||
const canLocate = Boolean(controls?.locate?.available);
|
||||
|
||||
const primaryAction = useMemo(() => {
|
||||
if (docked) {
|
||||
return {
|
||||
key: 'start',
|
||||
label: 'Start Cleaning',
|
||||
pending: 'Starting...',
|
||||
tone: 'emerald',
|
||||
canRun: canStart,
|
||||
fn: onStart,
|
||||
successMessage: 'Start cleaning command sent.',
|
||||
};
|
||||
}
|
||||
return {
|
||||
key: 'home',
|
||||
label: 'Send to Dock',
|
||||
pending: 'Sending...',
|
||||
tone: 'indigo',
|
||||
canRun: canSendHome,
|
||||
fn: onSendHome,
|
||||
successMessage: 'Send to dock command sent.',
|
||||
};
|
||||
}, [docked, canStart, onStart, canSendHome, onSendHome]);
|
||||
|
||||
const canRunPrimary = configured && connected && primaryAction.canRun;
|
||||
const canRunLocate = configured && connected && canLocate;
|
||||
|
||||
const runAction = async (key, fn, successMessage) => {
|
||||
if (!fn) return;
|
||||
@@ -91,94 +125,101 @@ export default function VipNeatoCard({ neato, onStart, onSendHome, onLocate, ful
|
||||
}
|
||||
};
|
||||
|
||||
const statusText = !configured
|
||||
? 'Not configured'
|
||||
: !connected
|
||||
? 'Offline'
|
||||
: 'Online';
|
||||
|
||||
const statusTone = !configured ? 'warn' : !connected ? 'danger' : 'good';
|
||||
|
||||
const batteryTone =
|
||||
battery == null ? 'muted' : battery >= 60 ? 'good' : battery >= 25 ? 'warn' : 'danger';
|
||||
|
||||
return (
|
||||
<section className={`surface text-sm text-slate-300 ${wrapClass}`}>
|
||||
<section className={`surface text-sm text-slate-200 ${wrapClass}`}>
|
||||
<div className="grid gap-0.5">
|
||||
<div className="flex w-full items-center justify-between gap-0.5 text-left">
|
||||
<p className="text-sm text-slate-100">Neato Controls (Gen3)</p>
|
||||
<span className={badgeClass(status === 'Online')}>{status}</span>
|
||||
<div className="flex items-center justify-between gap-0.5">
|
||||
<p className="text-sm font-semibold text-slate-100">Neato Control Surface (Gen3)</p>
|
||||
<ActionPill label={statusText} tone={connected ? 'emerald' : 'amber'} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-0.5 grid-cols-1 lg:grid-cols-2">
|
||||
<section className="surface-muted h-full px-0.5 py-0.5">
|
||||
<div className="grid h-full gap-0.5 grid-rows-[auto_1fr]">
|
||||
<p className="text-sm text-slate-200 text-center">Robot Status</p>
|
||||
<div className="grid gap-0.5 content-start">
|
||||
<div className="rounded bg-slate-800 px-1 py-0.5 text-xs text-slate-300">
|
||||
<span className="font-semibold text-slate-100">UI State:</span> {uiStateLabel}
|
||||
</div>
|
||||
<div
|
||||
className={`rounded px-1 py-0.5 text-xs ${
|
||||
hasError ? 'bg-rose-600 text-white' : 'bg-slate-800 text-slate-300'
|
||||
}`}
|
||||
title={robotError}
|
||||
>
|
||||
<span className="font-semibold">Error:</span> {robotError}
|
||||
</div>
|
||||
<div
|
||||
className={`rounded px-1 py-0.5 text-xs ${
|
||||
hasAlert ? 'bg-amber-500 text-slate-900' : 'bg-slate-800 text-slate-300'
|
||||
}`}
|
||||
title={robotAlert}
|
||||
>
|
||||
<span className="font-semibold">Alert:</span> {robotAlert}
|
||||
<div className="grid gap-0.5 grid-cols-1 lg:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)]">
|
||||
<section className="rounded-xl border-2 border-indigo-300/70 bg-indigo-900 px-0.75 py-0.75">
|
||||
<div className="grid gap-0.5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-0.5">
|
||||
<span className="text-base font-semibold text-indigo-50">
|
||||
{docked ? 'Docked State' : 'Undocked State'}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-0.5">
|
||||
<ActionPill label={docked ? 'Primary: Start' : 'Primary: Dock'} tone="indigo" />
|
||||
<ActionPill label={uiStateLabel} tone="amber" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-indigo-100/90">
|
||||
This Neato tile mirrors rover mode controls: one context-aware primary action based on dock state.
|
||||
</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canRunPrimary || Boolean(working)}
|
||||
onClick={() => runAction(primaryAction.key, primaryAction.fn, primaryAction.successMessage)}
|
||||
className={`w-full rounded-xl border-2 px-1 py-1 text-center text-base font-semibold text-white transition disabled:opacity-50 ${
|
||||
primaryAction.tone === 'emerald'
|
||||
? 'border-emerald-200/70 bg-emerald-700 hover:bg-emerald-600'
|
||||
: 'border-indigo-200/70 bg-indigo-700 hover:bg-indigo-600'
|
||||
}`}
|
||||
>
|
||||
{working === primaryAction.key ? primaryAction.pending : primaryAction.label}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canRunLocate || Boolean(working)}
|
||||
onClick={() => runAction('locate', onLocate, 'Play sound command sent.')}
|
||||
className="w-full rounded-xl border-2 border-fuchsia-200/70 bg-fuchsia-700 px-1 py-0.75 text-center text-sm font-semibold text-fuchsia-50 transition hover:bg-fuchsia-600 disabled:opacity-50"
|
||||
>
|
||||
{working === 'locate' ? 'Playing...' : 'Play Sound'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="surface-muted h-full px-0.5 py-0.5">
|
||||
<div className="grid h-full gap-0.5 grid-rows-[auto_1fr]">
|
||||
<p className="text-sm text-slate-200 text-center">Power & Dock</p>
|
||||
<div className="grid gap-0.5 content-start grid-cols-2">
|
||||
<StatusIndicator label="Battery" tone={batteryTone} detail={batteryLabel} />
|
||||
<StatusIndicator label="Voltage" tone={voltageTone} detail={voltageLabel} />
|
||||
<StatusIndicator label="Charging" tone={chargingTone} detail={charging ? 'active' : 'idle'} />
|
||||
<StatusIndicator label="Docked" tone={dockedTone} detail={docked ? 'on base' : 'away'} />
|
||||
</div>
|
||||
<section className="surface-muted px-0.5 py-0.5">
|
||||
<div className="grid gap-0.5">
|
||||
<StatusRow label="Connection" value={statusText} tone={statusTone} />
|
||||
<StatusRow label="Dock" value={docked ? 'On Base' : 'Away'} tone={docked ? 'info' : 'muted'} />
|
||||
<StatusRow label="Charging" value={charging ? 'Active' : 'Idle'} tone={charging ? 'info' : 'muted'} />
|
||||
<StatusRow label="UI State" value={uiStateLabel} tone="muted" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="surface-muted px-0.5 py-0.5">
|
||||
<div className="grid gap-0.5">
|
||||
<p className="text-sm text-slate-200 text-center">Actions</p>
|
||||
<div className="grid w-full gap-0.5 grid-cols-1 sm:grid-cols-3">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-emerald-300 bg-emerald-500 px-1.5 py-1 text-base font-semibold text-white disabled:opacity-50"
|
||||
onClick={() => runAction('start', onStart, 'Start cleaning command sent.')}
|
||||
disabled={!canControl || Boolean(working)}
|
||||
>
|
||||
{working === 'start' ? 'Starting...' : 'Start Cleaning'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-sky-300 bg-sky-500 px-1.5 py-1 text-base font-semibold text-white disabled:opacity-50"
|
||||
onClick={() => runAction('home', onSendHome, 'Send to dock command sent.')}
|
||||
disabled={!canControl || Boolean(working)}
|
||||
>
|
||||
{working === 'home' ? 'Sending...' : 'Send to Dock'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-fuchsia-300 bg-fuchsia-500 px-1.5 py-1 text-base font-semibold text-white disabled:opacity-50"
|
||||
onClick={() => runAction('locate', onLocate, 'Play sound command sent.')}
|
||||
disabled={!canControl || Boolean(working)}
|
||||
>
|
||||
{working === 'locate' ? 'Playing...' : 'Play Sound'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid gap-0.5 grid-cols-2 sm:grid-cols-3 lg:grid-cols-6">
|
||||
<MetricCard label="Battery" value={batteryLabel} tone={batteryTone} />
|
||||
<MetricCard label="Voltage" value={voltageLabel} tone={voltage == null ? 'muted' : 'info'} />
|
||||
<MetricCard label="Docked" value={docked ? 'Yes' : 'No'} tone={docked ? 'info' : 'muted'} />
|
||||
<MetricCard label="Charging" value={charging ? 'Yes' : 'No'} tone={charging ? 'info' : 'muted'} />
|
||||
<MetricCard label="Error" value={hasError ? 'Yes' : 'No'} tone={hasError ? 'danger' : 'good'} />
|
||||
<MetricCard label="Alert" value={hasAlert ? 'Yes' : 'No'} tone={hasAlert ? 'warn' : 'good'} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{!canControl ? (
|
||||
<p className="text-xs text-slate-500 text-center">
|
||||
{!neato?.configured
|
||||
<section className="surface-muted px-0.5 py-0.5">
|
||||
<div className="grid gap-0.5">
|
||||
<StatusRow label="Robot Error" value={robotError} tone={hasError ? 'danger' : 'muted'} />
|
||||
<StatusRow label="Robot Alert" value={robotAlert} tone={hasAlert ? 'warn' : 'muted'} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{!configured || !connected || !primaryAction.canRun || !canLocate ? (
|
||||
<p className="text-xs text-slate-400 text-center">
|
||||
{!configured
|
||||
? 'Set homeAssistant.neato.device in server config to enable Neato controls.'
|
||||
: !neato?.connected
|
||||
? 'Home Assistant is offline.'
|
||||
: 'Waiting for required Neato entities in Home Assistant.'}
|
||||
: !connected
|
||||
? 'Home Assistant is offline.'
|
||||
: 'Waiting for required Neato entities in Home Assistant.'}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user