mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-17 01:50:47 -04:00
month
This commit is contained in:
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
@@ -11,8 +11,8 @@
|
|||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
||||||
<title>Multi Roomba Rover</title>
|
<title>Multi Roomba Rover</title>
|
||||||
<script type="module" crossorigin src="/assets/index-DE-iql3f.js"></script>
|
<script type="module" crossorigin src="/assets/index-CauLH22S.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-BJs-84A7.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-Bs-rYi06.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -9,73 +9,107 @@ function humanizeUiState(value) {
|
|||||||
if (!state) return '--';
|
if (!state) return '--';
|
||||||
if (state.includes('DOCKINGRUNNING')) return 'Returning';
|
if (state.includes('DOCKINGRUNNING')) return 'Returning';
|
||||||
if (state.includes('PAUSED')) return 'Paused';
|
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_START')) return 'Starting';
|
||||||
if (state.includes('STATE_IDLE')) return 'Idle';
|
if (state.includes('STATE_IDLE')) return 'Idle';
|
||||||
if (state.includes('STATE_STANDBY')) return 'Standby';
|
if (state.includes('STATE_STANDBY')) return 'Standby';
|
||||||
return state;
|
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) {
|
function statusToneClass(tone) {
|
||||||
if (tone === 'good') return 'bg-emerald-500';
|
if (tone === 'good') return 'border-emerald-200 bg-emerald-600 text-white';
|
||||||
if (tone === 'warn') return 'bg-amber-500 text-slate-900';
|
if (tone === 'warn') return 'border-amber-200 bg-amber-600 text-white';
|
||||||
if (tone === 'danger') return 'bg-rose-600';
|
if (tone === 'danger') return 'border-rose-200 bg-rose-600 text-white';
|
||||||
if (tone === 'info') return 'bg-sky-500';
|
if (tone === 'info') return 'border-sky-200 bg-sky-600 text-white';
|
||||||
return 'bg-slate-700';
|
return 'border-slate-200/30 bg-slate-700 text-slate-100';
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusIndicator({ label, tone = 'muted', detail = '' }) {
|
function StatusRow({ label, value, tone = 'muted' }) {
|
||||||
return (
|
return (
|
||||||
<div className={`rounded-md px-0.5 py-0.5 text-xs text-slate-100 ${statusToneClass(tone)}`}>
|
<div
|
||||||
<div className="text-center font-medium">{label}</div>
|
className={`flex items-center justify-between rounded-lg border px-1 py-0.5 text-xs ${statusToneClass(tone)}`}
|
||||||
<div className="text-center text-[0.72rem] opacity-90">{detail || '--'}</div>
|
>
|
||||||
|
<span className="font-semibold uppercase tracking-wide opacity-90">{label}</span>
|
||||||
|
<span className="font-semibold">{value}</span>
|
||||||
</div>
|
</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 }) {
|
export default function VipNeatoCard({ neato, onStart, onSendHome, onLocate, fullWidth = false }) {
|
||||||
const [working, setWorking] = useState('');
|
const [working, setWorking] = useState('');
|
||||||
const [message, setMessage] = useState('');
|
const [message, setMessage] = useState('');
|
||||||
const wrapClass = fullWidth ? 'w-full' : 'w-full max-w-xl';
|
const wrapClass = fullWidth ? 'w-full' : 'w-full max-w-xl';
|
||||||
|
|
||||||
const status = useMemo(() => {
|
const configured = Boolean(neato?.configured);
|
||||||
if (!neato?.enabled) {
|
const connected = Boolean(neato?.enabled && neato?.connected);
|
||||||
if (!neato?.configured) return 'Not configured';
|
const docked = Boolean(neato?.telemetry?.extPowerPresent);
|
||||||
return 'Disabled';
|
const charging = Boolean(neato?.telemetry?.chargingActive);
|
||||||
}
|
|
||||||
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 uiStateLabel = humanizeUiState(neato?.telemetry?.uiState);
|
const uiStateLabel = humanizeUiState(neato?.telemetry?.uiState);
|
||||||
const battery = neato?.telemetry?.batteryPercent;
|
const battery = neato?.telemetry?.batteryPercent;
|
||||||
const batteryLabel = Number.isFinite(battery) ? `${battery}%` : '--';
|
const batteryLabel = Number.isFinite(battery) ? `${battery}%` : '--';
|
||||||
const voltage = neato?.telemetry?.batteryVoltage;
|
const voltage = neato?.telemetry?.batteryVoltage;
|
||||||
const voltageLabel = Number.isFinite(voltage) ? `${voltage.toFixed(2)} V` : '--';
|
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 robotError = normalizeState(neato?.telemetry?.robotError) || '--';
|
||||||
const robotAlert = normalizeState(neato?.telemetry?.robotAlert) || '--';
|
const robotAlert = normalizeState(neato?.telemetry?.robotAlert) || '--';
|
||||||
|
|
||||||
const hasError = robotError !== '--' && !/^no errors$/i.test(robotError) && !/^200/.test(robotError);
|
const hasError = robotError !== '--' && !/^no errors$/i.test(robotError) && !/^200/.test(robotError);
|
||||||
const hasAlert = robotAlert !== '--' && !/^200/.test(robotAlert);
|
const hasAlert = robotAlert !== '--' && !/^200/.test(robotAlert);
|
||||||
const batteryTone =
|
|
||||||
battery == null ? 'muted' : battery >= 60 ? 'good' : battery >= 25 ? 'warn' : 'danger';
|
const controls = neato?.controls || {};
|
||||||
const voltageTone = voltage == null ? 'muted' : 'info';
|
const canStart = Boolean(controls?.start?.available);
|
||||||
const chargingTone = charging ? 'info' : 'muted';
|
const canSendHome = Boolean(controls?.sendHome?.available);
|
||||||
const dockedTone = docked ? 'info' : 'muted';
|
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) => {
|
const runAction = async (key, fn, successMessage) => {
|
||||||
if (!fn) return;
|
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 (
|
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="grid gap-0.5">
|
||||||
<div className="flex w-full items-center justify-between gap-0.5 text-left">
|
<div className="flex items-center justify-between gap-0.5">
|
||||||
<p className="text-sm text-slate-100">Neato Controls (Gen3)</p>
|
<p className="text-sm font-semibold text-slate-100">Neato Control Surface (Gen3)</p>
|
||||||
<span className={badgeClass(status === 'Online')}>{status}</span>
|
<ActionPill label={statusText} tone={connected ? 'emerald' : 'amber'} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-0.5 grid-cols-1 lg:grid-cols-2">
|
<div className="grid gap-0.5 grid-cols-1 lg:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)]">
|
||||||
<section className="surface-muted h-full px-0.5 py-0.5">
|
<section className="rounded-xl border-2 border-indigo-300/70 bg-indigo-900 px-0.75 py-0.75">
|
||||||
<div className="grid h-full gap-0.5 grid-rows-[auto_1fr]">
|
<div className="grid gap-0.5">
|
||||||
<p className="text-sm text-slate-200 text-center">Robot Status</p>
|
<div className="flex flex-wrap items-center justify-between gap-0.5">
|
||||||
<div className="grid gap-0.5 content-start">
|
<span className="text-base font-semibold text-indigo-50">
|
||||||
<div className="rounded bg-slate-800 px-1 py-0.5 text-xs text-slate-300">
|
{docked ? 'Docked State' : 'Undocked State'}
|
||||||
<span className="font-semibold text-slate-100">UI State:</span> {uiStateLabel}
|
</span>
|
||||||
</div>
|
<div className="flex flex-wrap gap-0.5">
|
||||||
<div
|
<ActionPill label={docked ? 'Primary: Start' : 'Primary: Dock'} tone="indigo" />
|
||||||
className={`rounded px-1 py-0.5 text-xs ${
|
<ActionPill label={uiStateLabel} tone="amber" />
|
||||||
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>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="surface-muted h-full px-0.5 py-0.5">
|
<section className="surface-muted px-0.5 py-0.5">
|
||||||
<div className="grid h-full gap-0.5 grid-rows-[auto_1fr]">
|
<div className="grid gap-0.5">
|
||||||
<p className="text-sm text-slate-200 text-center">Power & Dock</p>
|
<StatusRow label="Connection" value={statusText} tone={statusTone} />
|
||||||
<div className="grid gap-0.5 content-start grid-cols-2">
|
<StatusRow label="Dock" value={docked ? 'On Base' : 'Away'} tone={docked ? 'info' : 'muted'} />
|
||||||
<StatusIndicator label="Battery" tone={batteryTone} detail={batteryLabel} />
|
<StatusRow label="Charging" value={charging ? 'Active' : 'Idle'} tone={charging ? 'info' : 'muted'} />
|
||||||
<StatusIndicator label="Voltage" tone={voltageTone} detail={voltageLabel} />
|
<StatusRow label="UI State" value={uiStateLabel} tone="muted" />
|
||||||
<StatusIndicator label="Charging" tone={chargingTone} detail={charging ? 'active' : 'idle'} />
|
|
||||||
<StatusIndicator label="Docked" tone={dockedTone} detail={docked ? 'on base' : 'away'} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="surface-muted px-0.5 py-0.5">
|
<section className="surface-muted px-0.5 py-0.5">
|
||||||
<div className="grid gap-0.5">
|
<div className="grid gap-0.5 grid-cols-2 sm:grid-cols-3 lg:grid-cols-6">
|
||||||
<p className="text-sm text-slate-200 text-center">Actions</p>
|
<MetricCard label="Battery" value={batteryLabel} tone={batteryTone} />
|
||||||
<div className="grid w-full gap-0.5 grid-cols-1 sm:grid-cols-3">
|
<MetricCard label="Voltage" value={voltageLabel} tone={voltage == null ? 'muted' : 'info'} />
|
||||||
<button
|
<MetricCard label="Docked" value={docked ? 'Yes' : 'No'} tone={docked ? 'info' : 'muted'} />
|
||||||
type="button"
|
<MetricCard label="Charging" value={charging ? 'Yes' : 'No'} tone={charging ? 'info' : 'muted'} />
|
||||||
className="rounded-md border border-emerald-300 bg-emerald-500 px-1.5 py-1 text-base font-semibold text-white disabled:opacity-50"
|
<MetricCard label="Error" value={hasError ? 'Yes' : 'No'} tone={hasError ? 'danger' : 'good'} />
|
||||||
onClick={() => runAction('start', onStart, 'Start cleaning command sent.')}
|
<MetricCard label="Alert" value={hasAlert ? 'Yes' : 'No'} tone={hasAlert ? 'warn' : 'good'} />
|
||||||
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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{!canControl ? (
|
<section className="surface-muted px-0.5 py-0.5">
|
||||||
<p className="text-xs text-slate-500 text-center">
|
<div className="grid gap-0.5">
|
||||||
{!neato?.configured
|
<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.'
|
? 'Set homeAssistant.neato.device in server config to enable Neato controls.'
|
||||||
: !neato?.connected
|
: !connected
|
||||||
? 'Home Assistant is offline.'
|
? 'Home Assistant is offline.'
|
||||||
: 'Waiting for required Neato entities in Home Assistant.'}
|
: 'Waiting for required Neato entities in Home Assistant.'}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user