This commit is contained in:
legop3
2026-04-26 20:58:05 -04:00
parent de3e32e0f0
commit 374d1448ac
5 changed files with 161 additions and 120 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -11,8 +11,8 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
<title>Multi Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-DE-iql3f.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BJs-84A7.css">
<script type="module" crossorigin src="/assets/index-CauLH22S.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Bs-rYi06.css">
</head>
<body>
<div id="root"></div>
+149 -108
View File
@@ -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}