move neato and lift into activities

This commit is contained in:
legop3
2026-06-23 12:36:21 -04:00
parent 0a351b9a46
commit 5634004ebd
9 changed files with 72 additions and 81 deletions
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -78,7 +78,7 @@
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script> <script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script> <script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
<title>Roomba Rover</title> <title>Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-BjmvF4mq.js"></script> <script type="module" crossorigin src="/assets/index-CfAeG6CY.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CdgtactY.css"> <link rel="stylesheet" crossorigin href="/assets/index-CdgtactY.css">
</head> </head>
<body> <body>
+5 -4
View File
@@ -1,11 +1,10 @@
// Lift Service // Lift Service
// Purpose: Provides a single global, verified-gated lift controller with serialized interlocked motion. // Purpose: Provides a single global lift controller with serialized interlocked motion.
// Scope: Owns lift command sequencing, anti-spam controls, HA wiring, and shared state publication for UI sync. // Scope: Owns lift command sequencing, anti-spam controls, HA wiring, and shared state publication for UI sync.
const EventEmitter = require('events'); const EventEmitter = require('events');
const io = require('../../globals/io'); const io = require('../../globals/io');
const logger = require('../../globals/logger').child('liftService'); const logger = require('../../globals/logger').child('liftService');
const { loadConfig } = require('../../helpers/configLoader'); const { loadConfig } = require('../../helpers/configLoader');
const { isVerified } = require('../verificationService');
const { getMode, MODES } = require('../modeManager'); const { getMode, MODES } = require('../modeManager');
const { isLockdownAdmin } = require('../roleService'); const { isLockdownAdmin } = require('../roleService');
const { const {
@@ -183,7 +182,8 @@ io.on('connection', (socket) => {
if (getMode() === MODES.LOCKDOWN && !isLockdownAdmin(socket)) { if (getMode() === MODES.LOCKDOWN && !isLockdownAdmin(socket)) {
throw new Error('Server in lockdown'); throw new Error('Server in lockdown');
} }
if (!isVerified(socket)) throw new Error('VIP verification required'); // Lift movement is now a public activity feature. Lockdown still wins
// above because that mode is the global safety/admin gate for the room.
const resp = await moveUp(socket.id || 'socket'); const resp = await moveUp(socket.id || 'socket');
cb({ success: true, ...resp }); cb({ success: true, ...resp });
} catch (err) { } catch (err) {
@@ -196,7 +196,8 @@ io.on('connection', (socket) => {
if (getMode() === MODES.LOCKDOWN && !isLockdownAdmin(socket)) { if (getMode() === MODES.LOCKDOWN && !isLockdownAdmin(socket)) {
throw new Error('Server in lockdown'); throw new Error('Server in lockdown');
} }
if (!isVerified(socket)) throw new Error('VIP verification required'); // Public access intentionally mirrors lift:up so both directions share
// the same policy and cannot drift into different permission behavior.
const resp = await moveDown(socket.id || 'socket'); const resp = await moveDown(socket.id || 'socket');
cb({ success: true, ...resp }); cb({ success: true, ...resp });
} catch (err) { } catch (err) {
+8 -15
View File
@@ -265,9 +265,8 @@ io.on('connection', (socket) => {
socket.on('neato:start', async (_, cb = () => {}) => { socket.on('neato:start', async (_, cb = () => {}) => {
try { try {
assertLockdownAccess(); assertLockdownAccess();
if (!isVerified(socket)) { // Neato commands are public activity features. The lockdown check above
throw new Error('VIP verification required'); // remains the room-wide safety/admin gate when the server is restricted.
}
await startCleaning(); await startCleaning();
cb({ success: true }); cb({ success: true });
} catch (err) { } catch (err) {
@@ -278,9 +277,7 @@ io.on('connection', (socket) => {
socket.on('neato:sendHome', async (_, cb = () => {}) => { socket.on('neato:sendHome', async (_, cb = () => {}) => {
try { try {
assertLockdownAccess(); assertLockdownAccess();
if (!isVerified(socket)) { // Keep send-home public for consistency with the rest of the Neato card.
throw new Error('VIP verification required');
}
await sendHome(); await sendHome();
cb({ success: true }); cb({ success: true });
} catch (err) { } catch (err) {
@@ -291,9 +288,7 @@ io.on('connection', (socket) => {
socket.on('neato:locate', async (_, cb = () => {}) => { socket.on('neato:locate', async (_, cb = () => {}) => {
try { try {
assertLockdownAccess(); assertLockdownAccess();
if (!isVerified(socket)) { // Locate is a public activity action; lockdown still blocks it above.
throw new Error('VIP verification required');
}
await locateRobot(); await locateRobot();
cb({ success: true }); cb({ success: true });
} catch (err) { } catch (err) {
@@ -304,9 +299,8 @@ io.on('connection', (socket) => {
socket.on('neato:clearErrors', async (_, cb = () => {}) => { socket.on('neato:clearErrors', async (_, cb = () => {}) => {
try { try {
assertLockdownAccess(); assertLockdownAccess();
if (!isVerified(socket)) { // Error clearing is grouped with the public Neato controls so the UI does
throw new Error('VIP verification required'); // not show a button that only some public users can actually run.
}
await clearErrors(); await clearErrors();
cb({ success: true }); cb({ success: true });
} catch (err) { } catch (err) {
@@ -317,9 +311,8 @@ io.on('connection', (socket) => {
socket.on('neato:powerCycle', async (_, cb = () => {}) => { socket.on('neato:powerCycle', async (_, cb = () => {}) => {
try { try {
assertLockdownAccess(); assertLockdownAccess();
if (!isVerified(socket)) { // Power cycle follows the same public policy as the rest of the card;
throw new Error('VIP verification required'); // operational safety remains controlled by lockdown mode.
}
await powerCycle(); await powerCycle();
cb({ success: true }); cb({ success: true });
} catch (err) { } catch (err) {
+4
View File
@@ -43,6 +43,8 @@ import { useTelemetryVisualPolicy } from './context/TelemetryContext.jsx';
import ButtonBoxPanel from './components/ButtonBoxPanel/index.jsx'; import ButtonBoxPanel from './components/ButtonBoxPanel/index.jsx';
import BarcodeGamesPanel from './components/BarcodeGamesPanel/index.jsx'; import BarcodeGamesPanel from './components/BarcodeGamesPanel/index.jsx';
import OdometerPanel from './components/OdometerPanel/index.jsx'; import OdometerPanel from './components/OdometerPanel/index.jsx';
import LiftCard from './components/LiftCard/index.jsx';
import NeatoCard from './components/NeatoCard/index.jsx';
import RewardRunOverlay from './components/RewardRunOverlay/index.jsx'; import RewardRunOverlay from './components/RewardRunOverlay/index.jsx';
import SocketConnectionPill from './components/SocketConnectionPill/index.jsx'; import SocketConnectionPill from './components/SocketConnectionPill/index.jsx';
import DuplicateIdentityOverlay from './components/DuplicateIdentityOverlay/index.jsx'; import DuplicateIdentityOverlay from './components/DuplicateIdentityOverlay/index.jsx';
@@ -195,6 +197,8 @@ function MobileFeatureTabs({
<TabPanel id="activities"> <TabPanel id="activities">
<div className={`flex flex-col ${themeGapClass}`}> <div className={`flex flex-col ${themeGapClass}`}>
<BarcodeGamesPanel /> <BarcodeGamesPanel />
<LiftCard />
<NeatoCard />
<OdometerPanel /> <OdometerPanel />
<ButtonBoxPanel /> <ButtonBoxPanel />
<KinectPanel /> <KinectPanel />
@@ -1,7 +1,8 @@
// Vip Lift Card // Lift Card
// Purpose: Renders a shared lift controller panel synced from server session state. // Purpose: Renders a shared lift controller panel synced from server session state.
// Scope: Presents verified-user controls while reflecting global busy/position/cooldown state. // Scope: Presents public activity controls while reflecting global busy/position/cooldown state.
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
import CardFrame from '../CardFrame/index.jsx'; import CardFrame from '../CardFrame/index.jsx';
function badgeClass(tone) { function badgeClass(tone) {
@@ -19,10 +20,17 @@ function positionLabel(value) {
return '--'; return '--';
} }
export default function VipLiftCard({ lift, onUp, onDown, fullWidth = false }) { export default function LiftCard() {
/*
The lift card is a complete Activities-tab feature, so it reads the shared
lift state and socket actions directly. Keeping that wiring inside the card
means each tab only decides where the card appears, and the command policy
cannot accidentally diverge between mobile and desktop layouts.
*/
const lift = useSessionSelector((state) => state.session?.lift || null);
const { liftUp, liftDown } = useSessionActions();
const [working, setWorking] = useState(''); const [working, setWorking] = useState('');
const [nowMs, setNowMs] = useState(() => Date.now()); const [nowMs, setNowMs] = useState(() => Date.now());
const wrapClass = fullWidth ? 'w-full' : 'w-full max-w-xl';
const configured = Boolean(lift?.configured); const configured = Boolean(lift?.configured);
const connected = Boolean(lift?.enabled && lift?.connected); const connected = Boolean(lift?.enabled && lift?.connected);
@@ -71,7 +79,7 @@ export default function VipLiftCard({ lift, onUp, onDown, fullWidth = false }) {
<CardFrame <CardFrame
title="Lift Controls" title="Lift Controls"
className={`relative ${wrapClass}`} className="relative w-full"
bodyClassName="text-sm text-slate-200" bodyClassName="text-sm text-slate-200"
actions={ actions={
<span className={`inline-flex w-auto rounded px-1 py-0.25 text-xs font-semibold ${badgeClass(statusTone)}`}> <span className={`inline-flex w-auto rounded px-1 py-0.25 text-xs font-semibold ${badgeClass(statusTone)}`}>
@@ -108,7 +116,7 @@ export default function VipLiftCard({ lift, onUp, onDown, fullWidth = false }) {
<button <button
type="button" type="button"
disabled={!canRun} disabled={!canRun}
onClick={() => run('down', onDown)} onClick={() => run('down', liftDown)}
className={`button-dark w-full text-sm disabled:opacity-50 ${position === 'down' || activeTarget === 'down' ? 'bg-emerald-500 text-white hover:bg-emerald-500' : ''}`} 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'} {working === 'down' || activeTarget === 'down' ? 'Lowering...' : 'Down'}
@@ -116,7 +124,7 @@ export default function VipLiftCard({ lift, onUp, onDown, fullWidth = false }) {
<button <button
type="button" type="button"
disabled={!canRun} disabled={!canRun}
onClick={() => run('up', onUp)} onClick={() => run('up', liftUp)}
className={`button-dark w-full text-sm disabled:opacity-50 ${position === 'up' || activeTarget === 'up' ? 'bg-emerald-500 text-white hover:bg-emerald-500' : ''}`} 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'} {working === 'up' || activeTarget === 'up' ? 'Raising...' : 'Up'}
@@ -1,7 +1,8 @@
// Vip Neato Card // Neato Card
// Purpose: Defines the Vip Neato Card module and the local helpers/components used in this file. // Purpose: Defines the public Neato activity card and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit. // Scope: Keeps Neato state display and commands together because each button's availability depends on the same shared robot telemetry.
import { useState } from 'react'; import { useState } from 'react';
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
import CardFrame from '../CardFrame/index.jsx'; import CardFrame from '../CardFrame/index.jsx';
function normalizeState(value) { function normalizeState(value) {
@@ -39,17 +40,21 @@ function StatusTile({ label, value, tone = 'muted', valueClass = '', hideLabel =
); );
} }
export default function VipNeatoCard({ export default function NeatoCard() {
neato, /*
onStart, Neato is a standalone public activity card. It owns its session selector and
onSendHome, command actions so callers do not need to know the socket event names or
onLocate, keep a parallel list of Neato props in every tab layout.
onClearErrors, */
onPowerCycle, const neato = useSessionSelector((state) => state.session?.neato || null);
fullWidth = false, const {
}) { neatoStart,
neatoSendHome,
neatoLocate,
neatoClearErrors,
neatoPowerCycle,
} = useSessionActions();
const [working, setWorking] = useState(''); const [working, setWorking] = useState('');
const wrapClass = fullWidth ? 'w-full' : 'w-full max-w-xl';
const configured = Boolean(neato?.configured); const configured = Boolean(neato?.configured);
const connected = Boolean(neato?.enabled && neato?.connected); const connected = Boolean(neato?.enabled && neato?.connected);
@@ -102,7 +107,7 @@ export default function VipNeatoCard({
<CardFrame <CardFrame
title="Neato Controls" title="Neato Controls"
className={wrapClass} className="w-full"
bodyClassName="text-sm text-slate-200" bodyClassName="text-sm text-slate-200"
actions={ actions={
<span className={`inline-flex w-auto rounded px-1 py-0.25 text-xs font-semibold ${metricToneClass(headerTone)}`}> <span className={`inline-flex w-auto rounded px-1 py-0.25 text-xs font-semibold ${metricToneClass(headerTone)}`}>
@@ -121,7 +126,7 @@ export default function VipNeatoCard({
<button <button
type="button" type="button"
disabled={!canRunStart || Boolean(working)} disabled={!canRunStart || Boolean(working)}
onClick={() => runAction('start', onStart)} onClick={() => runAction('start', neatoStart)}
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" 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'} {working === 'start' ? 'Starting...' : 'Start cleaning'}
@@ -129,7 +134,7 @@ export default function VipNeatoCard({
<button <button
type="button" type="button"
disabled={!canRunSendHome || Boolean(working)} disabled={!canRunSendHome || Boolean(working)}
onClick={() => runAction('sendHome', onSendHome)} onClick={() => runAction('sendHome', neatoSendHome)}
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" 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'} {working === 'sendHome' ? 'Sending...' : 'Send to dock'}
@@ -138,7 +143,7 @@ export default function VipNeatoCard({
<button <button
type="button" type="button"
disabled={!canRunLocate || Boolean(working)} disabled={!canRunLocate || Boolean(working)}
onClick={() => runAction('locate', onLocate)} onClick={() => runAction('locate', neatoLocate)}
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" 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'} {working === 'locate' ? 'Playing...' : 'Play sound'}
@@ -146,7 +151,7 @@ export default function VipNeatoCard({
<button <button
type="button" type="button"
disabled={!canRunClearErrors || Boolean(working)} disabled={!canRunClearErrors || Boolean(working)}
onClick={() => runAction('clearErrors', onClearErrors)} onClick={() => runAction('clearErrors', neatoClearErrors)}
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" 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'} {working === 'clearErrors' ? 'Clearing...' : 'Clear errors'}
@@ -154,7 +159,7 @@ export default function VipNeatoCard({
<button <button
type="button" type="button"
disabled={!canRunPowerCycle || Boolean(working)} disabled={!canRunPowerCycle || Boolean(working)}
onClick={() => runAction('powerCycle', onPowerCycle)} onClick={() => runAction('powerCycle', neatoPowerCycle)}
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" 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'} {working === 'powerCycle' ? 'Cycling...' : 'Power cycle'}
@@ -28,6 +28,8 @@ import { useSettingsNamespace } from '../../settings/index.js';
import ButtonBoxPanel from '../ButtonBoxPanel/index.jsx'; import ButtonBoxPanel from '../ButtonBoxPanel/index.jsx';
import BarcodeGamesPanel from '../BarcodeGamesPanel/index.jsx'; import BarcodeGamesPanel from '../BarcodeGamesPanel/index.jsx';
import OdometerPanel from '../OdometerPanel/index.jsx'; import OdometerPanel from '../OdometerPanel/index.jsx';
import LiftCard from '../LiftCard/index.jsx';
import NeatoCard from '../NeatoCard/index.jsx';
import OverseerPreferencePanel from '../OverseerPreferencePanel/index.jsx'; import OverseerPreferencePanel from '../OverseerPreferencePanel/index.jsx';
import CardFrame from '../CardFrame/index.jsx'; import CardFrame from '../CardFrame/index.jsx';
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
@@ -367,6 +369,8 @@ export default function RightPaneTabs({ layout, onOpenHelpOverlay }) {
<TabPanel id="activities"> <TabPanel id="activities">
<div className={`flex flex-col ${themeGapClass}`}> <div className={`flex flex-col ${themeGapClass}`}>
<BarcodeGamesPanel /> <BarcodeGamesPanel />
<LiftCard />
<NeatoCard />
<OdometerPanel /> <OdometerPanel />
<ButtonBoxPanel /> <ButtonBoxPanel />
<KinectPanel /> <KinectPanel />
+1 -25
View File
@@ -9,8 +9,6 @@ import VipAudioUploadCard from '../vip/VipAudioUploadCard/index.jsx';
import VipVerificationCard from '../vip/VipVerificationCard.jsx'; import VipVerificationCard from '../vip/VipVerificationCard.jsx';
import VipIdentityCard from '../vip/VipIdentityCard.jsx'; import VipIdentityCard from '../vip/VipIdentityCard.jsx';
import VipPrivateRoverAccessCard from '../vip/VipPrivateRoverAccessCard.jsx'; import VipPrivateRoverAccessCard from '../vip/VipPrivateRoverAccessCard.jsx';
import VipNeatoCard from '../vip/VipNeatoCard.jsx';
import VipLiftCard from '../vip/VipLiftCard.jsx';
import VipProfileImageCard from '../vip/VipProfileImageCard.jsx'; import VipProfileImageCard from '../vip/VipProfileImageCard.jsx';
export default function VipPanel({ isActive = true }) { export default function VipPanel({ isActive = true }) {
@@ -24,13 +22,6 @@ export default function VipPanel({ isActive = true }) {
startMicWhip, startMicWhip,
readyMicWhip, readyMicWhip,
stopMicWhip, stopMicWhip,
neatoStart,
neatoSendHome,
neatoLocate,
neatoClearErrors,
neatoPowerCycle,
liftUp,
liftDown,
} = useSessionActions(); } = useSessionActions();
const { value: identity, save: saveIdentity } = useSettingsNamespace('identity', { cookieUserId: '' }); const { value: identity, save: saveIdentity } = useSettingsNamespace('identity', { cookieUserId: '' });
const { value: profile } = useSettingsNamespace('profile', { nickname: '' }); const { value: profile } = useSettingsNamespace('profile', { nickname: '' });
@@ -77,21 +68,6 @@ export default function VipPanel({ isActive = true }) {
<div className="lg:col-span-2"> <div className="lg:col-span-2">
{isVerified ? ( {isVerified ? (
<div className="space-y-2"> <div className="space-y-2">
<VipNeatoCard
neato={session?.neato || null}
onStart={neatoStart}
onSendHome={neatoSendHome}
onLocate={neatoLocate}
onClearErrors={neatoClearErrors}
onPowerCycle={neatoPowerCycle}
fullWidth
/>
<VipLiftCard
lift={session?.lift || null}
onUp={liftUp}
onDown={liftDown}
fullWidth
/>
<VipAudioUploadCard <VipAudioUploadCard
ownRoverId={ownRoverId} ownRoverId={ownRoverId}
audioForwardByRover={session?.audioForward || {}} audioForwardByRover={session?.audioForward || {}}
@@ -105,7 +81,7 @@ export default function VipPanel({ isActive = true }) {
) : ( ) : (
<section className="surface h-full"> <section className="surface h-full">
<div className="flex h-full flex-col items-center justify-center text-center text-xs text-slate-400"> <div className="flex h-full flex-col items-center justify-center text-center text-xs text-slate-400">
Verify your account to unlock VIP controls. Verify your account to unlock VIP features.
</div> </div>
</section> </section>
)} )}