From 8b4ac09da47f91e5d99322027e734b391f84f8b8 Mon Sep 17 00:00:00 2001 From: Clemens Elflein Date: Thu, 2 Jul 2026 13:19:33 +0200 Subject: [PATCH 01/10] Add Simulation tab for controlling the simulated mower Adds a "Simulation" tab that drives the mower_simulation node over MQTT RPC and reflects its retained sim/state/json stream. The tab is feature-detected: it only appears when a simulator is connected (hidden on real-hardware production deployments). - openrpc.json: sim.* methods (state.get, emergency/movement/battery/gps set, dock.move, twist.set, displace) + SimState schema; regenerated rpc.ts client. battery.set takes `full` or an exact `volts`. - mowersStore: subscribe sim/state/json, parse into Mower.simState (also the feature-detect flag), updateSimState action. - navigationItems: gate the tab on simAvailable (selected mower has simState), wired from Sidebar and MobileBottomBar. - useSimControl hook: wraps the RPCs with optimistic state updates and per-action pending/error tracking. - Custom widgets: EmergencyCard (latch + reason chips), BatteryGauge (SVG gauge, Crit low/Empty/Full/Crit high presets, click-to-enter exact voltage), ToggleCard (traction/GPS), TwistCard (VirtualJoystick override), DockCard, DisplaceCard. --- openrpc.json | 234 +++++++++++++++- src/app/simulation/page.tsx | 173 ++++++++++++ src/components/navigation/MobileBottomBar.tsx | 3 +- src/components/navigation/navigationItems.tsx | 4 +- src/components/navigation/sidebar/Sidebar.tsx | 3 +- src/components/simulation/BatteryGauge.tsx | 249 ++++++++++++++++++ src/components/simulation/DisplaceCard.tsx | 122 +++++++++ src/components/simulation/DockCard.tsx | 58 ++++ src/components/simulation/EmergencyCard.tsx | 92 +++++++ src/components/simulation/ToggleCard.tsx | 110 ++++++++ src/components/simulation/TwistCard.tsx | 157 +++++++++++ src/components/simulation/emergencyReason.ts | 17 ++ src/hooks/useSimControl.ts | 68 +++++ src/lib/rpc.ts | 64 ++++- src/stores/mowersStore.ts | 22 ++ src/stores/schemas.ts | 21 ++ 16 files changed, 1391 insertions(+), 6 deletions(-) create mode 100644 src/app/simulation/page.tsx create mode 100644 src/components/simulation/BatteryGauge.tsx create mode 100644 src/components/simulation/DisplaceCard.tsx create mode 100644 src/components/simulation/DockCard.tsx create mode 100644 src/components/simulation/EmergencyCard.tsx create mode 100644 src/components/simulation/ToggleCard.tsx create mode 100644 src/components/simulation/TwistCard.tsx create mode 100644 src/components/simulation/emergencyReason.ts create mode 100644 src/hooks/useSimControl.ts diff --git a/openrpc.json b/openrpc.json index b61e5b3..0c8b4b3 100644 --- a/openrpc.json +++ b/openrpc.json @@ -261,6 +261,236 @@ "description": "Keys are relative file paths, values are YAML file contents as strings" } } + }, + { + "name": "sim.state.get", + "summary": "Get the current simulation control state. Only available against the simulator.", + "params": [], + "result": { + "name": "state", + "schema": { + "$ref": "#/components/schemas/SimState" + } + } + }, + { + "name": "sim.emergency.set", + "summary": "Trigger or clear a latched emergency in the simulator.", + "paramStructure": "by-name", + "params": [ + { + "name": "active", + "description": "true triggers a latched emergency, false clears it.", + "required": true, + "schema": { + "type": "boolean" + } + } + ], + "result": { + "name": "state", + "schema": { + "$ref": "#/components/schemas/SimState" + } + } + }, + { + "name": "sim.movement.set", + "summary": "Allow or block physical movement to simulate a stuck robot.", + "paramStructure": "by-name", + "params": [ + { + "name": "allowed", + "description": "true = robot moves normally, false = robot is stuck.", + "required": true, + "schema": { + "type": "boolean" + } + } + ], + "result": { + "name": "state", + "schema": { + "$ref": "#/components/schemas/SimState" + } + } + }, + { + "name": "sim.battery.set", + "summary": "Set the simulated battery. Pass `full` to snap to an extreme, or `volts` for an exact pack voltage.", + "paramStructure": "by-name", + "params": [ + { + "name": "full", + "description": "true = full charge voltage, false = empty (min) voltage. Ignored when `volts` is given.", + "required": false, + "schema": { + "type": "boolean" + } + }, + { + "name": "volts", + "description": "Exact pack voltage to set (V). Takes precedence over `full`.", + "required": false, + "schema": { + "type": "number" + } + } + ], + "result": { + "name": "state", + "schema": { + "$ref": "#/components/schemas/SimState" + } + } + }, + { + "name": "sim.gps.set", + "summary": "Set the simulated GPS quality.", + "paramStructure": "by-name", + "params": [ + { + "name": "good", + "description": "true = RTK fix (~2 cm), false = no RTK fix (~1 m).", + "required": true, + "schema": { + "type": "boolean" + } + } + ], + "result": { + "name": "state", + "schema": { + "$ref": "#/components/schemas/SimState" + } + } + }, + { + "name": "sim.dock.move", + "summary": "Drive the simulated robot onto the dock and start charging.", + "params": [], + "result": { + "name": "state", + "schema": { + "$ref": "#/components/schemas/SimState" + } + } + }, + { + "name": "sim.twist.set", + "summary": "Override the commanded twist (like cmd_vel). enabled=false releases the override.", + "paramStructure": "by-name", + "params": [ + { + "name": "linear", + "description": "Linear velocity in m/s (default 0).", + "required": false, + "schema": { + "type": "number" + } + }, + { + "name": "angular", + "description": "Angular velocity in rad/s (default 0).", + "required": false, + "schema": { + "type": "number" + } + }, + { + "name": "enabled", + "description": "Enable the override (default true); false hands control back to the diff drive.", + "required": false, + "schema": { + "type": "boolean" + } + } + ], + "result": { + "name": "state", + "schema": { + "$ref": "#/components/schemas/SimState" + } + } + }, + { + "name": "sim.displace", + "summary": "Teleport the robot by (dx, dy) meters and dheading radians to simulate a GPS jump.", + "paramStructure": "by-name", + "params": [ + { + "name": "dx", + "description": "X offset in meters (default 0).", + "required": false, + "schema": { + "type": "number" + } + }, + { + "name": "dy", + "description": "Y offset in meters (default 0).", + "required": false, + "schema": { + "type": "number" + } + }, + { + "name": "dheading", + "description": "Heading offset in radians (default 0).", + "required": false, + "schema": { + "type": "number" + } + } + ], + "result": { + "name": "state", + "schema": { + "$ref": "#/components/schemas/SimState" + } + } + } + ], + "components": { + "schemas": { + "SimState": { + "type": "object", + "properties": { + "emergency_active": { + "type": "boolean" + }, + "emergency_latch": { + "type": "boolean" + }, + "emergency_reason": { + "type": "number" + }, + "movement_allowed": { + "type": "boolean" + }, + "gps_good": { + "type": "boolean" + }, + "battery_volts": { + "type": "number" + }, + "battery_percentage": { + "type": "number" + }, + "charging": { + "type": "boolean" + }, + "twist_override": { + "type": "boolean" + }, + "override_linear": { + "type": "number" + }, + "override_angular": { + "type": "number" + } + } + } } - ] -} + } +} \ No newline at end of file diff --git a/src/app/simulation/page.tsx b/src/app/simulation/page.tsx new file mode 100644 index 0000000..eb61817 --- /dev/null +++ b/src/app/simulation/page.tsx @@ -0,0 +1,173 @@ +'use client'; + +import BatteryGauge from '@/components/simulation/BatteryGauge'; +import DockCard from '@/components/simulation/DockCard'; +import DisplaceCard from '@/components/simulation/DisplaceCard'; +import EmergencyCard from '@/components/simulation/EmergencyCard'; +import TwistCard from '@/components/simulation/TwistCard'; +import ToggleCard from '@/components/simulation/ToggleCard'; +import {HeaderStat, Page, PageContent, PageHeader} from '@/components/page'; +import {useSimControl} from '@/hooks/useSimControl'; +import {outerCardStyles} from '@/lib/cardStyles'; +import { + BatteryFull as BatteryIcon, + GpsFixed as GpsFixedIcon, + GpsOff as GpsOffIcon, + MoveDown as MoveIcon, + ReportProblem as EmergencyIcon, + ScienceOutlined as ScienceIcon, + DoNotDisturbOn as StuckIcon, +} from '@mui/icons-material'; +import {Alert, Box, Card, CardContent, Snackbar, Typography, useTheme} from '@mui/material'; + +export default function SimulationPage() { + const theme = useTheme(); + const { + simState, + available, + pending, + error, + setEmergency, + setMovementAllowed, + setBatteryFull, + setBatteryVolts, + setGpsGood, + moveToDock, + setTwist, + displace, + } = useSimControl(); + + return ( + + + } + value={simState ? `${Math.round(simState.battery_percentage * 100)}%` : '—'} + label="Battery" + /> + } value={simState ? (simState.gps_good ? 'RTK' : 'No fix') : '—'} label="GPS" /> + } + value={simState ? (simState.emergency_latch ? 'Latched' : 'Clear') : '—'} + label="Emergency" + /> + + + + {!available ? ( + + + + + + Simulator not detected + + + These controls are only available when connected to the{' '} + mower_simulation node. Waiting for a retained sim/state/json message… + + + + + ) : ( + simState && ( + + + + + + + setBatteryFull(true)} + onEmpty={() => setBatteryFull(false)} + onSetVolts={setBatteryVolts} + /> + + + + + + : } + title="Traction (stuck simulation)" + description="Wheels keep turning but position is frozen — like a mower physically blocked while spinning. Unlike a 0/0 joystick command, the wheels don't stop." + value={simState.movement_allowed} + pending={pending === 'movement'} + onChange={setMovementAllowed} + trueOption={{label: 'Free', sub: 'Wheels turn, robot moves', color: 'success'}} + falseOption={{label: 'Stuck', sub: 'Wheels turn, position frozen', color: 'warning'}} + /> + + + + + + : } + title="GPS quality" + value={simState.gps_good} + pending={pending === 'gps'} + onChange={setGpsGood} + trueOption={{label: 'RTK Fix', sub: '~2 cm', color: 'success'}} + falseOption={{label: 'No Fix', sub: '~1 m', color: 'error'}} + /> + + + + + + + + + + + + + + + + + + setTwist(0, 0, false)} + /> + + + + + ) + )} + + + + + {error} + + + + ); +} diff --git a/src/components/navigation/MobileBottomBar.tsx b/src/components/navigation/MobileBottomBar.tsx index 667606e..7c2be54 100644 --- a/src/components/navigation/MobileBottomBar.tsx +++ b/src/components/navigation/MobileBottomBar.tsx @@ -16,7 +16,8 @@ export default function MobileBottomBar({onMenuOpen}: MobileBottomBarProps) { const router = useRouter(); const pathname = usePathname(); const mowerCapabilities = useSelectedMower((s) => s?.capabilities); - const navigationItems = createNavigationItems(mowerCapabilities); + const simAvailable = useSelectedMower((s) => s?.simState != null); + const navigationItems = createNavigationItems(mowerCapabilities, simAvailable); const activeEmergency = useSelectedMowerActiveEmergency(); const handleNavigation = (path: string) => { diff --git a/src/components/navigation/navigationItems.tsx b/src/components/navigation/navigationItems.tsx index cf083c7..c30e17c 100644 --- a/src/components/navigation/navigationItems.tsx +++ b/src/components/navigation/navigationItems.tsx @@ -4,12 +4,13 @@ import { Dashboard as DashboardIcon, EventNote as EventIcon, Map as MapIcon, + ScienceOutlined as SimulationIcon, Sensors as SensorIcon, Settings as SettingsIcon, Assignment as TaskIcon, } from '@mui/icons-material'; -export function createNavigationItems(capabilities: Capabilities = {}): NavigationItem[] { +export function createNavigationItems(capabilities: Capabilities = {}, simAvailable = false): NavigationItem[] { const isDev = process.env.NEXT_PUBLIC_IS_DEV === 'true'; const hasCapability = (name: string, minLevel?: number) => { const level = capabilities[name]; @@ -20,6 +21,7 @@ export function createNavigationItems(capabilities: Capabilities = {}): Navigati isDev && {label: 'Dashboard', icon: , path: '/', isGlobal: true}, {label: 'Map', icon: , path: '/map', isGlobal: false}, hasCapability('events') && {label: 'Events', icon: , path: '/events', isGlobal: false}, + simAvailable && {label: 'Simulation', icon: , path: '/simulation', isGlobal: false}, isDev && {label: 'Tasks', icon: , path: '/tasks', isGlobal: false}, isDev && {label: 'Sensors', icon: , path: '/sensors', isGlobal: false}, isDev && {label: 'Settings', icon: , path: '/settings', isGlobal: true}, diff --git a/src/components/navigation/sidebar/Sidebar.tsx b/src/components/navigation/sidebar/Sidebar.tsx index 8bb98ea..764cb55 100644 --- a/src/components/navigation/sidebar/Sidebar.tsx +++ b/src/components/navigation/sidebar/Sidebar.tsx @@ -26,7 +26,8 @@ export default function Sidebar({open, onClose}: SidebarProps) { const selectedMowerId = useSelectedMower((s) => s?.id); const selectedMower = mowerConfigs.find((mower) => mower.id === selectedMowerId); const mowerCapabilities = useSelectedMower((s) => s?.capabilities); - const navigationItems = createNavigationItems(mowerCapabilities); + const simAvailable = useSelectedMower((s) => s?.simState != null); + const navigationItems = createNavigationItems(mowerCapabilities, simAvailable); const handleMowerMenuOpen = (event: React.MouseEvent) => { setMowerMenuAnchor(event.currentTarget); diff --git a/src/components/simulation/BatteryGauge.tsx b/src/components/simulation/BatteryGauge.tsx new file mode 100644 index 0000000..500fc88 --- /dev/null +++ b/src/components/simulation/BatteryGauge.tsx @@ -0,0 +1,249 @@ +'use client'; + +import { + BatteryChargingFull as ChargingIcon, + BatteryFull as FullIcon, + BatteryAlert as EmptyIcon, + WarningAmber as CritIcon, + Tune as CustomIcon, +} from '@mui/icons-material'; +import { + Box, + Button, + Chip, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + InputAdornment, + Slider, + TextField, + Tooltip, + Typography, + useTheme, +} from '@mui/material'; +import type {Theme} from '@mui/material/styles'; +import {useState} from 'react'; + +interface BatteryGaugeProps { + percentage: number; // 0..1 + volts: number; + charging: boolean; + pending: boolean; + onFull: () => void; + onEmpty: () => void; + onSetVolts: (volts: number) => void; +} + +// 7-cell pack: empty ≈ 22.4 V, full ≈ 29.26 V. Criticals sit just outside that. +const CRIT_LOW_VOLTS = 21.0; +const CRIT_HIGH_VOLTS = 30.0; +const MIN_VOLTS = 18; +const MAX_VOLTS = 32; + +function levelColor(pct: number, theme: Theme): string { + if (pct <= 0.15) return theme.palette.error.main; + if (pct <= 0.35) return theme.palette.warning.main; + return theme.palette.success.main; +} + +export default function BatteryGauge({ + percentage, + volts, + charging, + pending, + onFull, + onEmpty, + onSetVolts, +}: BatteryGaugeProps) { + const theme = useTheme(); + const pct = Math.max(0, Math.min(1, percentage)); + const color = charging ? theme.palette.info.main : levelColor(pct, theme); + + const [dialogOpen, setDialogOpen] = useState(false); + const [voltInput, setVoltInput] = useState(volts.toFixed(2)); + + const openDialog = () => { + setVoltInput(volts.toFixed(2)); + setDialogOpen(true); + }; + + const parsed = parseFloat(voltInput); + const valid = Number.isFinite(parsed); + + const submit = () => { + if (!valid) return; + const clamped = Math.min(MAX_VOLTS, Math.max(MIN_VOLTS, parsed)); + onSetVolts(clamped); + setDialogOpen(false); + }; + + // Battery body geometry (viewBox units). + const bodyX = 4; + const bodyY = 10; + const bodyW = 150; + const bodyH = 70; + const pad = 6; + const fillMax = bodyW - pad * 2; + const fillW = fillMax * pct; + + return ( + + + + Battery + + {charging && ( + } label="Charging" sx={{fontWeight: 600}} /> + )} + + + + + + + + {/* Battery shell */} + + {/* Positive terminal */} + + {/* Fill */} + 0 ? 4 : 0)} + height={bodyH - pad * 2} + rx={5} + fill={color} + style={{transition: 'width 0.5s ease, fill 0.3s ease'}} + /> + + {charging && ( + + )} + + + + + Tap to set voltage + + + + + + {Math.round(pct * 100)}% + + + {volts.toFixed(2)} V + + + + + + + + + + + + setDialogOpen(false)} maxWidth="xs" fullWidth> + Set battery voltage + + setVoltInput(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && submit()} + error={voltInput !== '' && !valid} + slotProps={{ + input: {endAdornment: V}, + htmlInput: {inputMode: 'decimal', pattern: '[0-9]*[.,]?[0-9]*'}, + }} + sx={{mt: 1}} + /> + + setVoltInput((v as number).toFixed(1))} + /> + + + + + + + + + ); +} diff --git a/src/components/simulation/DisplaceCard.tsx b/src/components/simulation/DisplaceCard.tsx new file mode 100644 index 0000000..b481779 --- /dev/null +++ b/src/components/simulation/DisplaceCard.tsx @@ -0,0 +1,122 @@ +'use client'; + +import { + KeyboardArrowDown as DownIcon, + KeyboardArrowLeft as LeftIcon, + KeyboardArrowRight as RightIcon, + KeyboardArrowUp as UpIcon, + MyLocation as JumpIcon, +} from '@mui/icons-material'; +import {Box, CircularProgress, IconButton, ToggleButton, ToggleButtonGroup, Typography, useTheme} from '@mui/material'; +import {useState} from 'react'; + +interface DisplaceCardProps { + pending: boolean; + /** dx, dy in meters (map frame). */ + onDisplace: (dx: number, dy: number) => void; +} + +const STEPS = [0.25, 0.5, 1, 2]; + +export default function DisplaceCard({pending, onDisplace}: DisplaceCardProps) { + const theme = useTheme(); + const [step, setStep] = useState(0.5); + + const PadButton = ({dx, dy, children}: {dx: number; dy: number; children: React.ReactNode}) => ( + onDisplace(dx * step, dy * step)} + sx={{ + border: `1px solid ${theme.palette.divider}`, + borderRadius: 1.5, + bgcolor: 'action.hover', + }} + > + {children} + + ); + + return ( + + + + + + + + GPS jump + + + Teleport the robot to simulate a GPS jump + + + {pending && } + + + + {/* Directional pad: +Y = up (map north). */} + + + + + + + + + + + + + + + + + + + + + + + + + + Step size + + v != null && setStep(v)} + disabled={pending} + > + {STEPS.map((s) => ( + + {s} m + + ))} + + + + + ); +} diff --git a/src/components/simulation/DockCard.tsx b/src/components/simulation/DockCard.tsx new file mode 100644 index 0000000..6c5b3fd --- /dev/null +++ b/src/components/simulation/DockCard.tsx @@ -0,0 +1,58 @@ +'use client'; + +import {EvStation as DockIcon} from '@mui/icons-material'; +import {Box, Button, Chip, CircularProgress, Typography, useTheme} from '@mui/material'; + +interface DockCardProps { + charging: boolean; + pending: boolean; + onMoveToDock: () => void; +} + +export default function DockCard({charging, pending, onMoveToDock}: DockCardProps) { + const theme = useTheme(); + const color = charging ? theme.palette.success.main : theme.palette.info.main; + + return ( + + + + + + + + Dock + + + {charging && } + {pending && } + + + + + Teleports the robot onto the docking pose and starts charging. + + + ); +} diff --git a/src/components/simulation/EmergencyCard.tsx b/src/components/simulation/EmergencyCard.tsx new file mode 100644 index 0000000..6f71e86 --- /dev/null +++ b/src/components/simulation/EmergencyCard.tsx @@ -0,0 +1,92 @@ +'use client'; + +import {outerCardStyles} from '@/lib/cardStyles'; +import { + CheckCircle as OkIcon, + ReportProblem as EmergencyIcon, + RestartAlt as ClearIcon, +} from '@mui/icons-material'; +import {Box, Button, Card, CardContent, Chip, CircularProgress, Typography, useTheme} from '@mui/material'; +import {decodeEmergencyReasons} from './emergencyReason'; + +interface EmergencyCardProps { + latched: boolean; + reason: number; + pending: boolean; + onSet: (active: boolean) => void; +} + +export default function EmergencyCard({latched, reason, pending, onSet}: EmergencyCardProps) { + const theme = useTheme(); + const reasons = decodeEmergencyReasons(reason); + + return ( + + + + + {latched ? : } + + + + Emergency + + + {latched ? 'Latched — robot stopped' : 'Clear — normal operation'} + + + + + + {latched && reasons.length > 0 && ( + + {reasons.map((r) => ( + + ))} + + )} + + + ); +} diff --git a/src/components/simulation/ToggleCard.tsx b/src/components/simulation/ToggleCard.tsx new file mode 100644 index 0000000..6335a22 --- /dev/null +++ b/src/components/simulation/ToggleCard.tsx @@ -0,0 +1,110 @@ +'use client'; + +import {Box, CircularProgress, Typography, useTheme} from '@mui/material'; +import type {ReactNode} from 'react'; + +type ToggleColor = 'success' | 'error' | 'warning' | 'info'; + +interface ToggleOption { + label: string; + sub?: string; + color: ToggleColor; +} + +interface ToggleCardProps { + icon: ReactNode; + title: string; + /** Optional explanatory line shown under the title. */ + description?: string; + value: boolean; + pending: boolean; + onChange: (value: boolean) => void; + /** Option shown/selected when value === true. */ + trueOption: ToggleOption; + /** Option shown/selected when value === false. */ + falseOption: ToggleOption; +} + +export default function ToggleCard({icon, title, description, value, pending, onChange, trueOption, falseOption}: ToggleCardProps) { + const theme = useTheme(); + const active = value ? trueOption : falseOption; + const activeColor = theme.palette[active.color].main; + + const Segment = ({option, selected, target}: {option: ToggleOption; selected: boolean; target: boolean}) => ( + !pending && !selected && onChange(target)} + sx={{ + flex: 1, + textAlign: 'center', + py: 1, + px: 1.5, + borderRadius: 1.5, + cursor: pending || selected ? 'default' : 'pointer', + userSelect: 'none', + transition: 'all 0.2s ease', + bgcolor: selected ? theme.palette[option.color].main : 'transparent', + color: selected ? theme.palette[option.color].contrastText : theme.palette.text.secondary, + fontWeight: selected ? 700 : 500, + '&:hover': pending || selected ? {} : {bgcolor: theme.palette.action.hover}, + }} + > + + {option.label} + + {option.sub && ( + + {option.sub} + + )} + + ); + + return ( + + + + {icon} + + + + {title} + + {description && ( + + {description} + + )} + + {pending && } + + + + + + + + ); +} diff --git a/src/components/simulation/TwistCard.tsx b/src/components/simulation/TwistCard.tsx new file mode 100644 index 0000000..9eee9e5 --- /dev/null +++ b/src/components/simulation/TwistCard.tsx @@ -0,0 +1,157 @@ +'use client'; + +import VirtualJoystick from '@/components/map/teleop/VirtualJoystick'; +import {Gamepad as TwistIcon, Stop as ReleaseIcon} from '@mui/icons-material'; +import {Box, Button, Chip, CircularProgress, Typography, useTheme} from '@mui/material'; +import {useCallback, useEffect, useRef, useState} from 'react'; + +interface TwistCardProps { + active: boolean; + linear: number; + angular: number; + pending: boolean; + onApply: (linear: number, angular: number) => void; + onRelease: () => void; +} + +const LINEAR_MAX = 1.0; // m/s at full stick +const ANGULAR_MAX = 2.0; // rad/s at full stick +// VirtualJoystick scales its angular axis (vz) by this internally, so undo it to +// normalize vz back to [-1, 1] before applying our own max. +const JOYSTICK_ANGULAR_FACTOR = 1.6; +const SEND_INTERVAL_MS = 100; // throttle RPCs while the stick is held + +export default function TwistCard({active, linear, angular, pending, onApply, onRelease}: TwistCardProps) { + const theme = useTheme(); + const [display, setDisplay] = useState({lin: linear, ang: angular}); + + const latest = useRef({lin: 0, ang: 0}); + const lastSent = useRef(0); + const trailing = useRef | null>(null); + + const clearTrailing = () => { + if (trailing.current !== null) { + clearTimeout(trailing.current); + trailing.current = null; + } + }; + + const flush = useCallback(() => { + lastSent.current = performance.now(); + onApply(latest.current.lin, latest.current.ang); + }, [onApply]); + + // Rate-limit sends: fire immediately if enough time passed, otherwise schedule a + // single trailing send so the sim always ends up with the final stick value. + const schedule = useCallback(() => { + const dt = performance.now() - lastSent.current; + if (dt >= SEND_INTERVAL_MS) { + clearTrailing(); + flush(); + } else if (trailing.current === null) { + trailing.current = setTimeout(() => { + trailing.current = null; + flush(); + }, SEND_INTERVAL_MS - dt); + } + }, [flush]); + + const handleVelocity = useCallback( + (vx: number, vz: number) => { + const next = {lin: vx * LINEAR_MAX, ang: (vz / JOYSTICK_ANGULAR_FACTOR) * ANGULAR_MAX}; + latest.current = next; + setDisplay(next); + schedule(); + }, + [schedule], + ); + + // Reflect override values coming back from the sim while active and the stick is idle. + useEffect(() => { + if (active && latest.current.lin === 0 && latest.current.ang === 0) { + setDisplay({lin: linear, ang: angular}); + } + }, [active, linear, angular]); + + useEffect(() => () => clearTrailing(), []); + + const color = active ? theme.palette.info.main : theme.palette.text.secondary; + + return ( + + + + + + + + Movement override + + + Drive the robot directly with the joystick, ignoring the mower logic + + + {active && } + {pending && } + + + + + + + + + + + Linear + + + {display.lin.toFixed(2)} m/s + + + Angular + + + {display.ang.toFixed(2)} rad/s + + + + + + + + ); +} diff --git a/src/components/simulation/emergencyReason.ts b/src/components/simulation/emergencyReason.ts new file mode 100644 index 0000000..d71ec58 --- /dev/null +++ b/src/components/simulation/emergencyReason.ts @@ -0,0 +1,17 @@ +// Decode the `emergency_reason` bitfield from the sim state into human labels. +// Mirrors the table in SIM_CONTROL_API.md. +const EMERGENCY_FLAGS: {bit: number; label: string}[] = [ + {bit: 1, label: 'Latch'}, + {bit: 2, label: 'Input timeout'}, + {bit: 4, label: 'Stop'}, + {bit: 8, label: 'Lift'}, + {bit: 16, label: 'Multi-lift'}, + {bit: 32, label: 'Collision'}, + {bit: 64, label: 'High-level timeout'}, + {bit: 128, label: 'High level'}, + {bit: 256, label: 'Service not ready'}, +]; + +export function decodeEmergencyReasons(mask: number): string[] { + return EMERGENCY_FLAGS.filter(({bit}) => (mask & bit) !== 0).map(({label}) => label); +} diff --git a/src/hooks/useSimControl.ts b/src/hooks/useSimControl.ts new file mode 100644 index 0000000..6420874 --- /dev/null +++ b/src/hooks/useSimControl.ts @@ -0,0 +1,68 @@ +'use client'; + +import type {OpenMowerRpc} from '@/lib/rpc'; +import {useMowersStore, useSelectedMower} from '@/stores/mowersStore'; +import {simStateSchema, type SimState} from '@/stores/schemas'; +import {useCallback, useState} from 'react'; + +export type SimAction = 'emergency' | 'movement' | 'battery' | 'gps' | 'dock' | 'twist' | 'displace'; + +export interface SimControl { + /** null while no simulator has been detected (no retained sim/state/json message). */ + simState: SimState | null; + available: boolean; + /** The action currently being sent, or null when idle. */ + pending: SimAction | null; + error: string | null; + setEmergency: (active: boolean) => void; + setMovementAllowed: (allowed: boolean) => void; + setBatteryFull: (full: boolean) => void; + setBatteryVolts: (volts: number) => void; + setGpsGood: (good: boolean) => void; + moveToDock: () => void; + setTwist: (linear: number, angular: number, enabled?: boolean) => void; + displace: (dx: number, dy: number, dheading?: number) => void; +} + +export function useSimControl(): SimControl { + const mowerId = useSelectedMower((m) => m?.id); + const rpc = useSelectedMower((m) => m?.rpc); + const simState = useSelectedMower((m) => m?.simState) ?? null; + const updateSimState = useMowersStore((s) => s.updateSimState); + + const [pending, setPending] = useState(null); + const [error, setError] = useState(null); + + const run = useCallback( + async (action: SimAction, fn: (rpc: OpenMowerRpc) => Promise) => { + if (!rpc || !mowerId) return; + setPending(action); + setError(null); + try { + const raw = await fn(rpc); + // Every mutating sim RPC returns the full updated state as its result. + updateSimState(mowerId, simStateSchema.parse(raw)); + } catch (e) { + setError(e instanceof Error ? e.message : 'RPC failed'); + } finally { + setPending(null); + } + }, + [rpc, mowerId, updateSimState], + ); + + return { + simState, + available: simState !== null, + pending, + error, + setEmergency: (active) => run('emergency', (r) => r.sim.emergency.set({active})), + setMovementAllowed: (allowed) => run('movement', (r) => r.sim.movement.set({allowed})), + setBatteryFull: (full) => run('battery', (r) => r.sim.battery.set({full})), + setBatteryVolts: (volts) => run('battery', (r) => r.sim.battery.set({volts})), + setGpsGood: (good) => run('gps', (r) => r.sim.gps.set({good})), + moveToDock: () => run('dock', (r) => r.sim.dock.move()), + setTwist: (linear, angular, enabled = true) => run('twist', (r) => r.sim.twist.set({linear, angular, enabled})), + displace: (dx, dy, dheading = 0) => run('displace', (r) => r.sim.displace({dx, dy, dheading})), + }; +} diff --git a/src/lib/rpc.ts b/src/lib/rpc.ts index 323cab1..fec1a75 100644 --- a/src/lib/rpc.ts +++ b/src/lib/rpc.ts @@ -60,12 +60,26 @@ export type UnorderedSetOfObjectOfNumberHo1ClIqDNumberHo1ClIqDStringDoaGddGANumb * */ export interface ObjectHicl3T4F { [key: string]: any; } +export interface ObjectOfBooleanVyG3AEThNumberHo1ClIqDNumberHo1ClIqDBooleanVyG3AEThBooleanVyG3AEThNumberHo1ClIqDBooleanVyG3AEThBooleanVyG3AEThBooleanVyG3AEThNumberHo1ClIqDNumberHo1ClIqDNf3Pcd5O { + emergency_active?: BooleanVyG3AETh; + emergency_latch?: BooleanVyG3AETh; + emergency_reason?: NumberHo1ClIqD; + movement_allowed?: BooleanVyG3AETh; + gps_good?: BooleanVyG3AETh; + battery_volts?: NumberHo1ClIqD; + battery_percentage?: NumberHo1ClIqD; + charging?: BooleanVyG3AETh; + twist_override?: BooleanVyG3AETh; + override_linear?: NumberHo1ClIqD; + override_angular?: NumberHo1ClIqD; + [k: string]: any; +} /** * * Generated! Represents an alias to any of the provided schemas * */ -export type AnyOfObjectHAgrRKSzStringDoaGddGAStringDoaGddGAStringZDJW5SIjUnorderedSetOfStringDoaGddGADvj0XlFaNullQu0Arl1FStringZDJW5SIjStringDoaGddGAObjectOfUnorderedSetOfObjectOfUnorderedSetOfUnorderedSetOfNumberHo1ClIqDAokMKuEfbuExLS5GObjectOfStringDoaGddGAStringDoaGddGABooleanVyG3AETh8PZSKeKMFruGLaE4Gkga5QR4UnorderedSetOfUnorderedSetOfNumberHo1ClIqDAokMKuEfbuExLS5GA4JKc1WsUnorderedSetOfObjectOfNumberHo1ClIqDStringDoaGddGAJFf7IL19E3WiTXvKUnorderedSetOfObjectOfNumberHo1ClIqDNumberHo1ClIqDStringDoaGddGANumberHo1ClIqDStringDoaGddGAStringDoaGddGAStringDoaGddGABooleanVyG3AEThEg3PskfBvWnyETupUnorderedSetOfStringDoaGddGADvj0XlFaObjectHicl3T4F = ObjectHAgrRKSz | StringDoaGddGA | StringZDJW5SIj | UnorderedSetOfStringDoaGddGADvj0XlFa | NullQu0Arl1F | ObjectOfUnorderedSetOfObjectOfUnorderedSetOfUnorderedSetOfNumberHo1ClIqDAokMKuEfbuExLS5GObjectOfStringDoaGddGAStringDoaGddGABooleanVyG3AETh8PZSKeKMFruGLaE4Gkga5QR4UnorderedSetOfUnorderedSetOfNumberHo1ClIqDAokMKuEfbuExLS5GA4JKc1Ws | UnorderedSetOfObjectOfNumberHo1ClIqDStringDoaGddGAJFf7IL19E3WiTXvK | UnorderedSetOfObjectOfNumberHo1ClIqDNumberHo1ClIqDStringDoaGddGANumberHo1ClIqDStringDoaGddGAStringDoaGddGAStringDoaGddGABooleanVyG3AEThEg3PskfBvWnyETup | ObjectHicl3T4F; +export type AnyOfObjectHAgrRKSzStringDoaGddGAStringDoaGddGABooleanVyG3AEThBooleanVyG3AEThBooleanVyG3AEThNumberHo1ClIqDBooleanVyG3AEThNumberHo1ClIqDNumberHo1ClIqDBooleanVyG3AEThNumberHo1ClIqDNumberHo1ClIqDNumberHo1ClIqDStringZDJW5SIjUnorderedSetOfStringDoaGddGADvj0XlFaNullQu0Arl1FStringZDJW5SIjStringDoaGddGAObjectOfUnorderedSetOfObjectOfUnorderedSetOfUnorderedSetOfNumberHo1ClIqDAokMKuEfbuExLS5GObjectOfStringDoaGddGAStringDoaGddGABooleanVyG3AETh8PZSKeKMFruGLaE4Gkga5QR4UnorderedSetOfUnorderedSetOfNumberHo1ClIqDAokMKuEfbuExLS5GA4JKc1WsUnorderedSetOfObjectOfNumberHo1ClIqDStringDoaGddGAJFf7IL19E3WiTXvKUnorderedSetOfObjectOfNumberHo1ClIqDNumberHo1ClIqDStringDoaGddGANumberHo1ClIqDStringDoaGddGAStringDoaGddGAStringDoaGddGABooleanVyG3AEThEg3PskfBvWnyETupUnorderedSetOfStringDoaGddGADvj0XlFaObjectHicl3T4FObjectOfBooleanVyG3AEThNumberHo1ClIqDNumberHo1ClIqDBooleanVyG3AEThBooleanVyG3AEThNumberHo1ClIqDBooleanVyG3AEThBooleanVyG3AEThBooleanVyG3AEThNumberHo1ClIqDNumberHo1ClIqDNf3Pcd5OObjectOfBooleanVyG3AEThNumberHo1ClIqDNumberHo1ClIqDBooleanVyG3AEThBooleanVyG3AEThNumberHo1ClIqDBooleanVyG3AEThBooleanVyG3AEThBooleanVyG3AEThNumberHo1ClIqDNumberHo1ClIqDNf3Pcd5OObjectOfBooleanVyG3AEThNumberHo1ClIqDNumberHo1ClIqDBooleanVyG3AEThBooleanVyG3AEThNumberHo1ClIqDBooleanVyG3AEThBooleanVyG3AEThBooleanVyG3AEThNumberHo1ClIqDNumberHo1ClIqDNf3Pcd5OObjectOfBooleanVyG3AEThNumberHo1ClIqDNumberHo1ClIqDBooleanVyG3AEThBooleanVyG3AEThNumberHo1ClIqDBooleanVyG3AEThBooleanVyG3AEThBooleanVyG3AEThNumberHo1ClIqDNumberHo1ClIqDNf3Pcd5OObjectOfBooleanVyG3AEThNumberHo1ClIqDNumberHo1ClIqDBooleanVyG3AEThBooleanVyG3AEThNumberHo1ClIqDBooleanVyG3AEThBooleanVyG3AEThBooleanVyG3AEThNumberHo1ClIqDNumberHo1ClIqDNf3Pcd5OObjectOfBooleanVyG3AEThNumberHo1ClIqDNumberHo1ClIqDBooleanVyG3AEThBooleanVyG3AEThNumberHo1ClIqDBooleanVyG3AEThBooleanVyG3AEThBooleanVyG3AEThNumberHo1ClIqDNumberHo1ClIqDNf3Pcd5OObjectOfBooleanVyG3AEThNumberHo1ClIqDNumberHo1ClIqDBooleanVyG3AEThBooleanVyG3AEThNumberHo1ClIqDBooleanVyG3AEThBooleanVyG3AEThBooleanVyG3AEThNumberHo1ClIqDNumberHo1ClIqDNf3Pcd5OObjectOfBooleanVyG3AEThNumberHo1ClIqDNumberHo1ClIqDBooleanVyG3AEThBooleanVyG3AEThNumberHo1ClIqDBooleanVyG3AEThBooleanVyG3AEThBooleanVyG3AEThNumberHo1ClIqDNumberHo1ClIqDNf3Pcd5O = ObjectHAgrRKSz | StringDoaGddGA | BooleanVyG3AETh | NumberHo1ClIqD | StringZDJW5SIj | UnorderedSetOfStringDoaGddGADvj0XlFa | NullQu0Arl1F | ObjectOfUnorderedSetOfObjectOfUnorderedSetOfUnorderedSetOfNumberHo1ClIqDAokMKuEfbuExLS5GObjectOfStringDoaGddGAStringDoaGddGABooleanVyG3AETh8PZSKeKMFruGLaE4Gkga5QR4UnorderedSetOfUnorderedSetOfNumberHo1ClIqDAokMKuEfbuExLS5GA4JKc1Ws | UnorderedSetOfObjectOfNumberHo1ClIqDStringDoaGddGAJFf7IL19E3WiTXvK | UnorderedSetOfObjectOfNumberHo1ClIqDNumberHo1ClIqDStringDoaGddGANumberHo1ClIqDStringDoaGddGAStringDoaGddGAStringDoaGddGABooleanVyG3AEThEg3PskfBvWnyETup | ObjectHicl3T4F | ObjectOfBooleanVyG3AEThNumberHo1ClIqDNumberHo1ClIqDBooleanVyG3AEThBooleanVyG3AEThNumberHo1ClIqDBooleanVyG3AEThBooleanVyG3AEThBooleanVyG3AEThNumberHo1ClIqDNumberHo1ClIqDNf3Pcd5O; export class OpenMowerRpc extends OpenMowerBaseRpc { rpc = { @@ -124,4 +138,52 @@ export class OpenMowerRpc extends OpenMowerBaseRpc { list: async (): Promise => this.call('events.history.list'), }), }; + sim = { + state: { + /** + * Get the current simulation control state. Only available against the simulator. + */ + get: async (): Promise => this.call('sim.state.get'), + }, + emergency: { + /** + * Trigger or clear a latched emergency in the simulator. + */ + set: async (args: {active: BooleanVyG3AETh}): Promise => this.call('sim.emergency.set', args), + }, + movement: { + /** + * Allow or block physical movement to simulate a stuck robot. + */ + set: async (args: {allowed: BooleanVyG3AETh}): Promise => this.call('sim.movement.set', args), + }, + battery: { + /** + * Set the simulated battery. Pass `full` to snap to an extreme, or `volts` for an exact pack voltage. + */ + set: async (args: {full?: BooleanVyG3AETh, volts?: NumberHo1ClIqD}): Promise => this.call('sim.battery.set', args), + }, + gps: { + /** + * Set the simulated GPS quality. + */ + set: async (args: {good: BooleanVyG3AETh}): Promise => this.call('sim.gps.set', args), + }, + dock: { + /** + * Drive the simulated robot onto the dock and start charging. + */ + move: async (): Promise => this.call('sim.dock.move'), + }, + twist: { + /** + * Override the commanded twist (like cmd_vel). enabled=false releases the override. + */ + set: async (args: {linear?: NumberHo1ClIqD, angular?: NumberHo1ClIqD, enabled?: BooleanVyG3AETh}): Promise => this.call('sim.twist.set', args), + }, + /** + * Teleport the robot by (dx, dy) meters and dheading radians to simulate a GPS jump. + */ + displace: async (args: {dx?: NumberHo1ClIqD, dy?: NumberHo1ClIqD, dheading?: NumberHo1ClIqD}): Promise => this.call('sim.displace', args), + }; } diff --git a/src/stores/mowersStore.ts b/src/stores/mowersStore.ts index f081c72..f0b691a 100644 --- a/src/stores/mowersStore.ts +++ b/src/stores/mowersStore.ts @@ -28,12 +28,14 @@ import { mapDefaults, mapSchema, positionSchema, + simStateSchema, stateDefaults, stateSchema, type Capabilities, type Datum, type MapData, type PositionWithAttributes, + type SimState, type StateOptionalPose, type TrackAttributes, } from './schemas'; @@ -58,6 +60,9 @@ class Mower { track: TrackPipeline = new TrackPipeline(); jobList: {job_id: string; epoch: number}[] | null = null; events: MowerEventState = mowerEventDefaults; + // null until a retained sim/state/json message arrives — also used as the + // "is this a simulator?" feature-detection flag. + simState: SimState | null = null; constructor(config: MowerConfig, mqttClient: MqttClient) { this.id = config.id; @@ -95,6 +100,7 @@ interface MowersStore { selected: number; loadMowers: () => void; fetchEventsForDate: (mowerId: string, date: string) => Promise; + updateSimState: (mowerId: string, simState: SimState) => void; } export const useMowersStore = create()( @@ -161,6 +167,7 @@ export const useMowersStore = create()( client.subscribe(clientMower.prefix + 'position/json'); client.subscribe(clientMower.prefix + 'params/json'); client.subscribe(clientMower.prefix + 'events/json'); + client.subscribe(clientMower.prefix + 'sim/state/json'); mowers[clientMower.idx].rpc.events.history .list() .then((dates) => { @@ -264,6 +271,13 @@ export const useMowersStore = create()( applyLiveEvent(state.mowers[idx].events, parsed.data); } }); + } else if (partialTopic === 'sim/state/json') { + set((state) => { + const parsed = simStateSchema.safeParse(JSON.parse(payload.toString())); + if (parsed.success) { + state.mowers[idx].simState = parsed.data; + } + }); } } }); @@ -291,6 +305,14 @@ export const useMowersStore = create()( // server may not support events.history yet } }, + updateSimState: (mowerId, simState) => { + set((state) => { + const target = state.mowers.find((m) => m.id === mowerId); + if (target) { + target.simState = simState; + } + }); + }, })), ); diff --git a/src/stores/schemas.ts b/src/stores/schemas.ts index aa43abf..5aa0e6a 100644 --- a/src/stores/schemas.ts +++ b/src/stores/schemas.ts @@ -48,6 +48,27 @@ export const stateSchema = z.object({ export type State = z.infer; export type StateOptionalPose = Omit & {pose?: State['pose']}; +//////////////////////////////////////////////////////////////////////////////////////////////////// +// Simulation control (from sim/state/json topic and sim.* RPCs) +//////////////////////////////////////////////////////////////////////////////////////////////////// + +export const simStateSchema = z.object({ + emergency_active: z.boolean(), + emergency_latch: z.boolean(), + emergency_reason: z.number().int(), + movement_allowed: z.boolean(), + gps_good: z.boolean(), + battery_volts: z.number(), + // Normalized 0.0–1.0 (unlike robot_state, kept raw here and formatted in the UI). + battery_percentage: z.number(), + charging: z.boolean(), + twist_override: z.boolean(), + override_linear: z.number(), + override_angular: z.number(), +}); + +export type SimState = z.infer; + //////////////////////////////////////////////////////////////////////////////////////////////////// // Map //////////////////////////////////////////////////////////////////////////////////////////////////// From b24debca5c6f1f43230a1a665c77579e2e921ae4 Mon Sep 17 00:00:00 2001 From: Robert Vollmer Date: Thu, 2 Jul 2026 23:35:27 +0000 Subject: [PATCH 02/10] Get sim state only via normal MQTT, not RPC --- openrpc.json | 92 ++++++++++---------------------------- src/hooks/useSimControl.ts | 11 ++--- src/stores/mowersStore.ts | 9 ---- 3 files changed, 27 insertions(+), 85 deletions(-) diff --git a/openrpc.json b/openrpc.json index 0c8b4b3..e2dbc9e 100644 --- a/openrpc.json +++ b/openrpc.json @@ -262,17 +262,6 @@ } } }, - { - "name": "sim.state.get", - "summary": "Get the current simulation control state. Only available against the simulator.", - "params": [], - "result": { - "name": "state", - "schema": { - "$ref": "#/components/schemas/SimState" - } - } - }, { "name": "sim.emergency.set", "summary": "Trigger or clear a latched emergency in the simulator.", @@ -288,9 +277,10 @@ } ], "result": { - "name": "state", + "name": "result", + "description": "No result is returned if successful.", "schema": { - "$ref": "#/components/schemas/SimState" + "type": "null" } } }, @@ -309,9 +299,10 @@ } ], "result": { - "name": "state", + "name": "result", + "description": "No result is returned if successful.", "schema": { - "$ref": "#/components/schemas/SimState" + "type": "null" } } }, @@ -338,9 +329,10 @@ } ], "result": { - "name": "state", + "name": "result", + "description": "No result is returned if successful.", "schema": { - "$ref": "#/components/schemas/SimState" + "type": "null" } } }, @@ -359,9 +351,10 @@ } ], "result": { - "name": "state", + "name": "result", + "description": "No result is returned if successful.", "schema": { - "$ref": "#/components/schemas/SimState" + "type": "null" } } }, @@ -370,9 +363,10 @@ "summary": "Drive the simulated robot onto the dock and start charging.", "params": [], "result": { - "name": "state", + "name": "result", + "description": "No result is returned if successful.", "schema": { - "$ref": "#/components/schemas/SimState" + "type": "null" } } }, @@ -407,9 +401,10 @@ } ], "result": { - "name": "state", + "name": "result", + "description": "No result is returned if successful.", "schema": { - "$ref": "#/components/schemas/SimState" + "type": "null" } } }, @@ -444,53 +439,12 @@ } ], "result": { - "name": "state", + "name": "result", + "description": "No result is returned if successful.", "schema": { - "$ref": "#/components/schemas/SimState" - } - } - } - ], - "components": { - "schemas": { - "SimState": { - "type": "object", - "properties": { - "emergency_active": { - "type": "boolean" - }, - "emergency_latch": { - "type": "boolean" - }, - "emergency_reason": { - "type": "number" - }, - "movement_allowed": { - "type": "boolean" - }, - "gps_good": { - "type": "boolean" - }, - "battery_volts": { - "type": "number" - }, - "battery_percentage": { - "type": "number" - }, - "charging": { - "type": "boolean" - }, - "twist_override": { - "type": "boolean" - }, - "override_linear": { - "type": "number" - }, - "override_angular": { - "type": "number" - } + "type": "null" } } } - } -} \ No newline at end of file + ] +} diff --git a/src/hooks/useSimControl.ts b/src/hooks/useSimControl.ts index 6420874..5ee9f53 100644 --- a/src/hooks/useSimControl.ts +++ b/src/hooks/useSimControl.ts @@ -1,8 +1,8 @@ 'use client'; import type {OpenMowerRpc} from '@/lib/rpc'; -import {useMowersStore, useSelectedMower} from '@/stores/mowersStore'; -import {simStateSchema, type SimState} from '@/stores/schemas'; +import {useSelectedMower} from '@/stores/mowersStore'; +import type {SimState} from '@/stores/schemas'; import {useCallback, useState} from 'react'; export type SimAction = 'emergency' | 'movement' | 'battery' | 'gps' | 'dock' | 'twist' | 'displace'; @@ -28,7 +28,6 @@ export function useSimControl(): SimControl { const mowerId = useSelectedMower((m) => m?.id); const rpc = useSelectedMower((m) => m?.rpc); const simState = useSelectedMower((m) => m?.simState) ?? null; - const updateSimState = useMowersStore((s) => s.updateSimState); const [pending, setPending] = useState(null); const [error, setError] = useState(null); @@ -39,16 +38,14 @@ export function useSimControl(): SimControl { setPending(action); setError(null); try { - const raw = await fn(rpc); - // Every mutating sim RPC returns the full updated state as its result. - updateSimState(mowerId, simStateSchema.parse(raw)); + await fn(rpc); } catch (e) { setError(e instanceof Error ? e.message : 'RPC failed'); } finally { setPending(null); } }, - [rpc, mowerId, updateSimState], + [rpc, mowerId], ); return { diff --git a/src/stores/mowersStore.ts b/src/stores/mowersStore.ts index f0b691a..382641d 100644 --- a/src/stores/mowersStore.ts +++ b/src/stores/mowersStore.ts @@ -100,7 +100,6 @@ interface MowersStore { selected: number; loadMowers: () => void; fetchEventsForDate: (mowerId: string, date: string) => Promise; - updateSimState: (mowerId: string, simState: SimState) => void; } export const useMowersStore = create()( @@ -305,14 +304,6 @@ export const useMowersStore = create()( // server may not support events.history yet } }, - updateSimState: (mowerId, simState) => { - set((state) => { - const target = state.mowers.find((m) => m.id === mowerId); - if (target) { - target.simState = simState; - } - }); - }, })), ); From 8d16558c9e240ca332787ecb5efd7efb351c332f Mon Sep 17 00:00:00 2001 From: Robert Vollmer Date: Fri, 3 Jul 2026 00:30:20 +0000 Subject: [PATCH 03/10] Battery RPC changes --- openrpc.json | 16 +-- src/app/simulation/page.tsx | 38 +++---- src/components/simulation/BatteryGauge.tsx | 115 +++++++++++---------- src/hooks/useSimControl.ts | 6 +- src/lib/rpc.ts | 38 ++----- src/stores/mowersStore.ts | 12 +-- src/stores/schemas.ts | 23 ++++- 7 files changed, 117 insertions(+), 131 deletions(-) diff --git a/openrpc.json b/openrpc.json index e2dbc9e..2017afa 100644 --- a/openrpc.json +++ b/openrpc.json @@ -308,21 +308,13 @@ }, { "name": "sim.battery.set", - "summary": "Set the simulated battery. Pass `full` to snap to an extreme, or `volts` for an exact pack voltage.", + "summary": "Set the simulated battery pack voltage.", "paramStructure": "by-name", "params": [ { - "name": "full", - "description": "true = full charge voltage, false = empty (min) voltage. Ignored when `volts` is given.", - "required": false, - "schema": { - "type": "boolean" - } - }, - { - "name": "volts", - "description": "Exact pack voltage to set (V). Takes precedence over `full`.", - "required": false, + "name": "voltage", + "description": "Exact pack voltage to set (V).", + "required": true, "schema": { "type": "number" } diff --git a/src/app/simulation/page.tsx b/src/app/simulation/page.tsx index eb61817..ed61bd1 100644 --- a/src/app/simulation/page.tsx +++ b/src/app/simulation/page.tsx @@ -1,20 +1,21 @@ 'use client'; +import {HeaderStat, Page, PageContent, PageHeader} from '@/components/page'; import BatteryGauge from '@/components/simulation/BatteryGauge'; -import DockCard from '@/components/simulation/DockCard'; import DisplaceCard from '@/components/simulation/DisplaceCard'; +import DockCard from '@/components/simulation/DockCard'; import EmergencyCard from '@/components/simulation/EmergencyCard'; -import TwistCard from '@/components/simulation/TwistCard'; import ToggleCard from '@/components/simulation/ToggleCard'; -import {HeaderStat, Page, PageContent, PageHeader} from '@/components/page'; +import TwistCard from '@/components/simulation/TwistCard'; import {useSimControl} from '@/hooks/useSimControl'; import {outerCardStyles} from '@/lib/cardStyles'; +import {useSelectedMower} from '@/stores/mowersStore'; import { BatteryFull as BatteryIcon, + ReportProblem as EmergencyIcon, GpsFixed as GpsFixedIcon, GpsOff as GpsOffIcon, MoveDown as MoveIcon, - ReportProblem as EmergencyIcon, ScienceOutlined as ScienceIcon, DoNotDisturbOn as StuckIcon, } from '@mui/icons-material'; @@ -22,6 +23,7 @@ import {Alert, Box, Card, CardContent, Snackbar, Typography, useTheme} from '@mu export default function SimulationPage() { const theme = useTheme(); + const robotBattery = useSelectedMower((m) => m?.state.battery_percentage ?? null); const { simState, available, @@ -29,8 +31,7 @@ export default function SimulationPage() { error, setEmergency, setMovementAllowed, - setBatteryFull, - setBatteryVolts, + setBatteryVoltage, setGpsGood, moveToDock, setTwist, @@ -40,12 +41,12 @@ export default function SimulationPage() { return ( + } value={robotBattery !== null ? `${robotBattery}%` : '—'} label="Battery" /> } - value={simState ? `${Math.round(simState.battery_percentage * 100)}%` : '—'} - label="Battery" + icon={} + value={simState ? (simState.gps_good ? 'RTK' : 'No fix') : '—'} + label="GPS" /> - } value={simState ? (simState.gps_good ? 'RTK' : 'No fix') : '—'} label="GPS" /> } value={simState ? (simState.emergency_latch ? 'Latched' : 'Clear') : '—'} @@ -63,8 +64,8 @@ export default function SimulationPage() { Simulator not detected - These controls are only available when connected to the{' '} - mower_simulation node. Waiting for a retained sim/state/json message… + These controls are only available when connected to the mower_simulation node. Waiting + for a retained sim/state/json message… @@ -89,13 +90,10 @@ export default function SimulationPage() { setBatteryFull(true)} - onEmpty={() => setBatteryFull(false)} - onSetVolts={setBatteryVolts} + onSetVoltage={setBatteryVoltage} /> @@ -159,11 +157,7 @@ export default function SimulationPage() { )} - + {error} diff --git a/src/components/simulation/BatteryGauge.tsx b/src/components/simulation/BatteryGauge.tsx index 500fc88..22b9738 100644 --- a/src/components/simulation/BatteryGauge.tsx +++ b/src/components/simulation/BatteryGauge.tsx @@ -24,23 +24,17 @@ import { } from '@mui/material'; import type {Theme} from '@mui/material/styles'; import {useState} from 'react'; +import {useSelectedMower} from '@/stores/mowersStore'; + +const VOLTAGE_MARGIN = 2; interface BatteryGaugeProps { - percentage: number; // 0..1 - volts: number; + voltage: number; charging: boolean; pending: boolean; - onFull: () => void; - onEmpty: () => void; - onSetVolts: (volts: number) => void; + onSetVoltage: (voltage: number) => void; } -// 7-cell pack: empty ≈ 22.4 V, full ≈ 29.26 V. Criticals sit just outside that. -const CRIT_LOW_VOLTS = 21.0; -const CRIT_HIGH_VOLTS = 30.0; -const MIN_VOLTS = 18; -const MAX_VOLTS = 32; - function levelColor(pct: number, theme: Theme): string { if (pct <= 0.15) return theme.palette.error.main; if (pct <= 0.35) return theme.palette.warning.main; @@ -48,33 +42,38 @@ function levelColor(pct: number, theme: Theme): string { } export default function BatteryGauge({ - percentage, - volts, + voltage, charging, pending, - onFull, - onEmpty, - onSetVolts, + onSetVoltage, }: BatteryGaugeProps) { + const params = useSelectedMower((m) => m?.params ?? {}); + const percentage = useSelectedMower((m) => (m?.state.battery_percentage ?? 0) / 100); + const fullVoltage = params['/ll/services/power/battery_full_voltage']; + const emptyVoltage = params['/ll/services/power/battery_empty_voltage']; + const critLowVoltage = params['/ll/services/power/battery_critical_voltage']; + const critHighVoltage = params['/ll/services/power/battery_critical_high_voltage']; + const minVoltage = critLowVoltage !== undefined ? critLowVoltage - VOLTAGE_MARGIN : undefined; + const maxVoltage = critHighVoltage !== undefined ? critHighVoltage + VOLTAGE_MARGIN : undefined; const theme = useTheme(); const pct = Math.max(0, Math.min(1, percentage)); const color = charging ? theme.palette.info.main : levelColor(pct, theme); const [dialogOpen, setDialogOpen] = useState(false); - const [voltInput, setVoltInput] = useState(volts.toFixed(2)); + const [voltageInput, setVoltageInput] = useState(voltage.toFixed(2)); const openDialog = () => { - setVoltInput(volts.toFixed(2)); + setVoltageInput(voltage.toFixed(2)); setDialogOpen(true); }; - const parsed = parseFloat(voltInput); + const parsed = parseFloat(voltageInput); const valid = Number.isFinite(parsed); const submit = () => { - if (!valid) return; - const clamped = Math.min(MAX_VOLTS, Math.max(MIN_VOLTS, parsed)); - onSetVolts(clamped); + if (!valid || minVoltage === undefined || maxVoltage === undefined) return; + const clamped = Math.min(maxVoltage, Math.max(minVoltage, parsed)); + onSetVoltage(clamped); setDialogOpen(false); }; @@ -183,23 +182,31 @@ export default function BatteryGauge({ {Math.round(pct * 100)}% - {volts.toFixed(2)} V + {voltage.toFixed(2)} V - - - - + {critLowVoltage !== undefined && ( + + )} + {emptyVoltage !== undefined && ( + + )} + {fullVoltage !== undefined && ( + + )} + {critHighVoltage !== undefined && ( + + )} @@ -210,36 +217,38 @@ export default function BatteryGauge({ autoFocus fullWidth label="Pack voltage" - value={voltInput} - onChange={(e) => setVoltInput(e.target.value)} + value={voltageInput} + onChange={(e) => setVoltageInput(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && submit()} - error={voltInput !== '' && !valid} + error={voltageInput !== '' && !valid} slotProps={{ input: {endAdornment: V}, htmlInput: {inputMode: 'decimal', pattern: '[0-9]*[.,]?[0-9]*'}, }} sx={{mt: 1}} /> - - setVoltInput((v as number).toFixed(1))} - /> - + {minVoltage !== undefined && maxVoltage !== undefined && ( + + setVoltageInput((v as number).toFixed(1))} + /> + + )} - diff --git a/src/hooks/useSimControl.ts b/src/hooks/useSimControl.ts index 5ee9f53..3ab595a 100644 --- a/src/hooks/useSimControl.ts +++ b/src/hooks/useSimControl.ts @@ -16,8 +16,7 @@ export interface SimControl { error: string | null; setEmergency: (active: boolean) => void; setMovementAllowed: (allowed: boolean) => void; - setBatteryFull: (full: boolean) => void; - setBatteryVolts: (volts: number) => void; + setBatteryVoltage: (voltage: number) => void; setGpsGood: (good: boolean) => void; moveToDock: () => void; setTwist: (linear: number, angular: number, enabled?: boolean) => void; @@ -55,8 +54,7 @@ export function useSimControl(): SimControl { error, setEmergency: (active) => run('emergency', (r) => r.sim.emergency.set({active})), setMovementAllowed: (allowed) => run('movement', (r) => r.sim.movement.set({allowed})), - setBatteryFull: (full) => run('battery', (r) => r.sim.battery.set({full})), - setBatteryVolts: (volts) => run('battery', (r) => r.sim.battery.set({volts})), + setBatteryVoltage: (voltage) => run('battery', (r) => r.sim.battery.set({voltage})), setGpsGood: (good) => run('gps', (r) => r.sim.gps.set({good})), moveToDock: () => run('dock', (r) => r.sim.dock.move()), setTwist: (linear, angular, enabled = true) => run('twist', (r) => r.sim.twist.set({linear, angular, enabled})), diff --git a/src/lib/rpc.ts b/src/lib/rpc.ts index fec1a75..ba42847 100644 --- a/src/lib/rpc.ts +++ b/src/lib/rpc.ts @@ -60,26 +60,12 @@ export type UnorderedSetOfObjectOfNumberHo1ClIqDNumberHo1ClIqDStringDoaGddGANumb * */ export interface ObjectHicl3T4F { [key: string]: any; } -export interface ObjectOfBooleanVyG3AEThNumberHo1ClIqDNumberHo1ClIqDBooleanVyG3AEThBooleanVyG3AEThNumberHo1ClIqDBooleanVyG3AEThBooleanVyG3AEThBooleanVyG3AEThNumberHo1ClIqDNumberHo1ClIqDNf3Pcd5O { - emergency_active?: BooleanVyG3AETh; - emergency_latch?: BooleanVyG3AETh; - emergency_reason?: NumberHo1ClIqD; - movement_allowed?: BooleanVyG3AETh; - gps_good?: BooleanVyG3AETh; - battery_volts?: NumberHo1ClIqD; - battery_percentage?: NumberHo1ClIqD; - charging?: BooleanVyG3AETh; - twist_override?: BooleanVyG3AETh; - override_linear?: NumberHo1ClIqD; - override_angular?: NumberHo1ClIqD; - [k: string]: any; -} /** * * Generated! Represents an alias to any of the provided schemas * */ -export type AnyOfObjectHAgrRKSzStringDoaGddGAStringDoaGddGABooleanVyG3AEThBooleanVyG3AEThBooleanVyG3AEThNumberHo1ClIqDBooleanVyG3AEThNumberHo1ClIqDNumberHo1ClIqDBooleanVyG3AEThNumberHo1ClIqDNumberHo1ClIqDNumberHo1ClIqDStringZDJW5SIjUnorderedSetOfStringDoaGddGADvj0XlFaNullQu0Arl1FStringZDJW5SIjStringDoaGddGAObjectOfUnorderedSetOfObjectOfUnorderedSetOfUnorderedSetOfNumberHo1ClIqDAokMKuEfbuExLS5GObjectOfStringDoaGddGAStringDoaGddGABooleanVyG3AETh8PZSKeKMFruGLaE4Gkga5QR4UnorderedSetOfUnorderedSetOfNumberHo1ClIqDAokMKuEfbuExLS5GA4JKc1WsUnorderedSetOfObjectOfNumberHo1ClIqDStringDoaGddGAJFf7IL19E3WiTXvKUnorderedSetOfObjectOfNumberHo1ClIqDNumberHo1ClIqDStringDoaGddGANumberHo1ClIqDStringDoaGddGAStringDoaGddGAStringDoaGddGABooleanVyG3AEThEg3PskfBvWnyETupUnorderedSetOfStringDoaGddGADvj0XlFaObjectHicl3T4FObjectOfBooleanVyG3AEThNumberHo1ClIqDNumberHo1ClIqDBooleanVyG3AEThBooleanVyG3AEThNumberHo1ClIqDBooleanVyG3AEThBooleanVyG3AEThBooleanVyG3AEThNumberHo1ClIqDNumberHo1ClIqDNf3Pcd5OObjectOfBooleanVyG3AEThNumberHo1ClIqDNumberHo1ClIqDBooleanVyG3AEThBooleanVyG3AEThNumberHo1ClIqDBooleanVyG3AEThBooleanVyG3AEThBooleanVyG3AEThNumberHo1ClIqDNumberHo1ClIqDNf3Pcd5OObjectOfBooleanVyG3AEThNumberHo1ClIqDNumberHo1ClIqDBooleanVyG3AEThBooleanVyG3AEThNumberHo1ClIqDBooleanVyG3AEThBooleanVyG3AEThBooleanVyG3AEThNumberHo1ClIqDNumberHo1ClIqDNf3Pcd5OObjectOfBooleanVyG3AEThNumberHo1ClIqDNumberHo1ClIqDBooleanVyG3AEThBooleanVyG3AEThNumberHo1ClIqDBooleanVyG3AEThBooleanVyG3AEThBooleanVyG3AEThNumberHo1ClIqDNumberHo1ClIqDNf3Pcd5OObjectOfBooleanVyG3AEThNumberHo1ClIqDNumberHo1ClIqDBooleanVyG3AEThBooleanVyG3AEThNumberHo1ClIqDBooleanVyG3AEThBooleanVyG3AEThBooleanVyG3AEThNumberHo1ClIqDNumberHo1ClIqDNf3Pcd5OObjectOfBooleanVyG3AEThNumberHo1ClIqDNumberHo1ClIqDBooleanVyG3AEThBooleanVyG3AEThNumberHo1ClIqDBooleanVyG3AEThBooleanVyG3AEThBooleanVyG3AEThNumberHo1ClIqDNumberHo1ClIqDNf3Pcd5OObjectOfBooleanVyG3AEThNumberHo1ClIqDNumberHo1ClIqDBooleanVyG3AEThBooleanVyG3AEThNumberHo1ClIqDBooleanVyG3AEThBooleanVyG3AEThBooleanVyG3AEThNumberHo1ClIqDNumberHo1ClIqDNf3Pcd5OObjectOfBooleanVyG3AEThNumberHo1ClIqDNumberHo1ClIqDBooleanVyG3AEThBooleanVyG3AEThNumberHo1ClIqDBooleanVyG3AEThBooleanVyG3AEThBooleanVyG3AEThNumberHo1ClIqDNumberHo1ClIqDNf3Pcd5O = ObjectHAgrRKSz | StringDoaGddGA | BooleanVyG3AETh | NumberHo1ClIqD | StringZDJW5SIj | UnorderedSetOfStringDoaGddGADvj0XlFa | NullQu0Arl1F | ObjectOfUnorderedSetOfObjectOfUnorderedSetOfUnorderedSetOfNumberHo1ClIqDAokMKuEfbuExLS5GObjectOfStringDoaGddGAStringDoaGddGABooleanVyG3AETh8PZSKeKMFruGLaE4Gkga5QR4UnorderedSetOfUnorderedSetOfNumberHo1ClIqDAokMKuEfbuExLS5GA4JKc1Ws | UnorderedSetOfObjectOfNumberHo1ClIqDStringDoaGddGAJFf7IL19E3WiTXvK | UnorderedSetOfObjectOfNumberHo1ClIqDNumberHo1ClIqDStringDoaGddGANumberHo1ClIqDStringDoaGddGAStringDoaGddGAStringDoaGddGABooleanVyG3AEThEg3PskfBvWnyETup | ObjectHicl3T4F | ObjectOfBooleanVyG3AEThNumberHo1ClIqDNumberHo1ClIqDBooleanVyG3AEThBooleanVyG3AEThNumberHo1ClIqDBooleanVyG3AEThBooleanVyG3AEThBooleanVyG3AEThNumberHo1ClIqDNumberHo1ClIqDNf3Pcd5O; +export type AnyOfObjectHAgrRKSzStringDoaGddGAStringDoaGddGABooleanVyG3AEThBooleanVyG3AEThNumberHo1ClIqDBooleanVyG3AEThNumberHo1ClIqDNumberHo1ClIqDBooleanVyG3AEThNumberHo1ClIqDNumberHo1ClIqDNumberHo1ClIqDStringZDJW5SIjUnorderedSetOfStringDoaGddGADvj0XlFaNullQu0Arl1FStringZDJW5SIjStringDoaGddGAObjectOfUnorderedSetOfObjectOfUnorderedSetOfUnorderedSetOfNumberHo1ClIqDAokMKuEfbuExLS5GObjectOfStringDoaGddGAStringDoaGddGABooleanVyG3AETh8PZSKeKMFruGLaE4Gkga5QR4UnorderedSetOfUnorderedSetOfNumberHo1ClIqDAokMKuEfbuExLS5GA4JKc1WsUnorderedSetOfObjectOfNumberHo1ClIqDStringDoaGddGAJFf7IL19E3WiTXvKUnorderedSetOfObjectOfNumberHo1ClIqDNumberHo1ClIqDStringDoaGddGANumberHo1ClIqDStringDoaGddGAStringDoaGddGAStringDoaGddGABooleanVyG3AEThEg3PskfBvWnyETupUnorderedSetOfStringDoaGddGADvj0XlFaObjectHicl3T4FNullQu0Arl1FNullQu0Arl1FNullQu0Arl1FNullQu0Arl1FNullQu0Arl1FNullQu0Arl1FNullQu0Arl1F = ObjectHAgrRKSz | StringDoaGddGA | BooleanVyG3AETh | NumberHo1ClIqD | StringZDJW5SIj | UnorderedSetOfStringDoaGddGADvj0XlFa | NullQu0Arl1F | ObjectOfUnorderedSetOfObjectOfUnorderedSetOfUnorderedSetOfNumberHo1ClIqDAokMKuEfbuExLS5GObjectOfStringDoaGddGAStringDoaGddGABooleanVyG3AETh8PZSKeKMFruGLaE4Gkga5QR4UnorderedSetOfUnorderedSetOfNumberHo1ClIqDAokMKuEfbuExLS5GA4JKc1Ws | UnorderedSetOfObjectOfNumberHo1ClIqDStringDoaGddGAJFf7IL19E3WiTXvK | UnorderedSetOfObjectOfNumberHo1ClIqDNumberHo1ClIqDStringDoaGddGANumberHo1ClIqDStringDoaGddGAStringDoaGddGAStringDoaGddGABooleanVyG3AEThEg3PskfBvWnyETup | ObjectHicl3T4F; export class OpenMowerRpc extends OpenMowerBaseRpc { rpc = { @@ -139,51 +125,45 @@ export class OpenMowerRpc extends OpenMowerBaseRpc { }), }; sim = { - state: { - /** - * Get the current simulation control state. Only available against the simulator. - */ - get: async (): Promise => this.call('sim.state.get'), - }, emergency: { /** * Trigger or clear a latched emergency in the simulator. */ - set: async (args: {active: BooleanVyG3AETh}): Promise => this.call('sim.emergency.set', args), + set: async (args: {active: BooleanVyG3AETh}): Promise => this.call('sim.emergency.set', args), }, movement: { /** * Allow or block physical movement to simulate a stuck robot. */ - set: async (args: {allowed: BooleanVyG3AETh}): Promise => this.call('sim.movement.set', args), + set: async (args: {allowed: BooleanVyG3AETh}): Promise => this.call('sim.movement.set', args), }, battery: { /** - * Set the simulated battery. Pass `full` to snap to an extreme, or `volts` for an exact pack voltage. + * Set the simulated battery pack voltage. */ - set: async (args: {full?: BooleanVyG3AETh, volts?: NumberHo1ClIqD}): Promise => this.call('sim.battery.set', args), + set: async (args: {voltage: NumberHo1ClIqD}): Promise => this.call('sim.battery.set', args), }, gps: { /** * Set the simulated GPS quality. */ - set: async (args: {good: BooleanVyG3AETh}): Promise => this.call('sim.gps.set', args), + set: async (args: {good: BooleanVyG3AETh}): Promise => this.call('sim.gps.set', args), }, dock: { /** * Drive the simulated robot onto the dock and start charging. */ - move: async (): Promise => this.call('sim.dock.move'), + move: async (): Promise => this.call('sim.dock.move'), }, twist: { /** * Override the commanded twist (like cmd_vel). enabled=false releases the override. */ - set: async (args: {linear?: NumberHo1ClIqD, angular?: NumberHo1ClIqD, enabled?: BooleanVyG3AETh}): Promise => this.call('sim.twist.set', args), + set: async (args: {linear?: NumberHo1ClIqD, angular?: NumberHo1ClIqD, enabled?: BooleanVyG3AETh}): Promise => this.call('sim.twist.set', args), }, /** * Teleport the robot by (dx, dy) meters and dheading radians to simulate a GPS jump. */ - displace: async (args: {dx?: NumberHo1ClIqD, dy?: NumberHo1ClIqD, dheading?: NumberHo1ClIqD}): Promise => this.call('sim.displace', args), + displace: async (args: {dx?: NumberHo1ClIqD, dy?: NumberHo1ClIqD, dheading?: NumberHo1ClIqD}): Promise => this.call('sim.displace', args), }; } diff --git a/src/stores/mowersStore.ts b/src/stores/mowersStore.ts index 382641d..ebc08a0 100644 --- a/src/stores/mowersStore.ts +++ b/src/stores/mowersStore.ts @@ -28,6 +28,7 @@ import { mapDefaults, mapSchema, positionSchema, + rosParamsSchema, simStateSchema, stateDefaults, stateSchema, @@ -35,6 +36,7 @@ import { type Datum, type MapData, type PositionWithAttributes, + type RosParams, type SimState, type StateOptionalPose, type TrackAttributes, @@ -55,7 +57,7 @@ class Mower { capabilities: Capabilities = {}; state: StateOptionalPose = stateDefaults; map: MapData = mapDefaults; - params: Record = {}; + params: RosParams = {}; position: PositionWithAttributes | null = null; track: TrackPipeline = new TrackPipeline(); jobList: {job_id: string; epoch: number}[] | null = null; @@ -245,7 +247,7 @@ export const useMowersStore = create()( } else if (partialTopic === 'params/json') { set((state) => { const mower = state.mowers[idx]; - mower.params = JSON.parse(payload.toString()) as Record; + mower.params = rosParamsSchema.parse(JSON.parse(payload.toString())); mower.map.datum ??= mower.getDatumFromParams(); }); } else if (partialTopic === 'position/json') { @@ -257,12 +259,6 @@ export const useMowersStore = create()( mower.track.addPoint(parsed); } }); - } else if (partialTopic === 'params/json') { - set((state) => { - const mower = state.mowers[idx]; - mower.params = JSON.parse(payload.toString()) as Record; - mower.map.datum ??= mower.getDatumFromParams(); - }); } else if (partialTopic === 'events/json') { set((state) => { const parsed = eventSchema.safeParse(JSON.parse(payload.toString())); diff --git a/src/stores/schemas.ts b/src/stores/schemas.ts index 5aa0e6a..66766db 100644 --- a/src/stores/schemas.ts +++ b/src/stores/schemas.ts @@ -58,9 +58,7 @@ export const simStateSchema = z.object({ emergency_reason: z.number().int(), movement_allowed: z.boolean(), gps_good: z.boolean(), - battery_volts: z.number(), - // Normalized 0.0–1.0 (unlike robot_state, kept raw here and formatted in the UI). - battery_percentage: z.number(), + battery_voltage: z.number(), charging: z.boolean(), twist_override: z.boolean(), override_linear: z.number(), @@ -222,3 +220,22 @@ export const stateDefaults: StateOptionalPose = { is_charging: false, pose: undefined, }; + +//////////////////////////////////////////////////////////////////////////////////////////////////// +// ROS params +//////////////////////////////////////////////////////////////////////////////////////////////////// + +export const rosParamsSchema = z + .object({ + '/ll/services/power/battery_full_voltage': z.number(), + '/ll/services/power/battery_empty_voltage': z.number(), + '/ll/services/power/battery_critical_voltage': z.number(), + '/ll/services/power/battery_critical_high_voltage': z.number(), + '/ll/services/gps/datum_lat': z.number(), + '/ll/services/gps/datum_long': z.number(), + '/ll/services/gps/datum_height': z.number(), + }) + .partial() + .catchall(z.unknown()); + +export type RosParams = z.infer; From bdf79fccacea775bb1f89f7486f74d3b33b4d86c Mon Sep 17 00:00:00 2001 From: Robert Vollmer Date: Sat, 4 Jul 2026 22:10:52 +0000 Subject: [PATCH 04/10] Use teleop instead of custom twist override --- openrpc.json | 38 --------- src/app/simulation/page.tsx | 10 +-- src/components/simulation/TwistCard.tsx | 101 ++++++------------------ src/hooks/useSimControl.ts | 4 +- src/hooks/useTeleop.ts | 70 ++++++++++++---- src/lib/rpc.ts | 8 +- src/stores/schemas.ts | 3 - 7 files changed, 81 insertions(+), 153 deletions(-) diff --git a/openrpc.json b/openrpc.json index 2017afa..fa31d5d 100644 --- a/openrpc.json +++ b/openrpc.json @@ -362,44 +362,6 @@ } } }, - { - "name": "sim.twist.set", - "summary": "Override the commanded twist (like cmd_vel). enabled=false releases the override.", - "paramStructure": "by-name", - "params": [ - { - "name": "linear", - "description": "Linear velocity in m/s (default 0).", - "required": false, - "schema": { - "type": "number" - } - }, - { - "name": "angular", - "description": "Angular velocity in rad/s (default 0).", - "required": false, - "schema": { - "type": "number" - } - }, - { - "name": "enabled", - "description": "Enable the override (default true); false hands control back to the diff drive.", - "required": false, - "schema": { - "type": "boolean" - } - } - ], - "result": { - "name": "result", - "description": "No result is returned if successful.", - "schema": { - "type": "null" - } - } - }, { "name": "sim.displace", "summary": "Teleport the robot by (dx, dy) meters and dheading radians to simulate a GPS jump.", diff --git a/src/app/simulation/page.tsx b/src/app/simulation/page.tsx index ed61bd1..269d23e 100644 --- a/src/app/simulation/page.tsx +++ b/src/app/simulation/page.tsx @@ -34,7 +34,6 @@ export default function SimulationPage() { setBatteryVoltage, setGpsGood, moveToDock, - setTwist, displace, } = useSimControl(); @@ -141,14 +140,7 @@ export default function SimulationPage() { - setTwist(0, 0, false)} - /> + diff --git a/src/components/simulation/TwistCard.tsx b/src/components/simulation/TwistCard.tsx index 9eee9e5..2060de4 100644 --- a/src/components/simulation/TwistCard.tsx +++ b/src/components/simulation/TwistCard.tsx @@ -1,79 +1,28 @@ 'use client'; +import {useTeleop} from '@/hooks/useTeleop'; import VirtualJoystick from '@/components/map/teleop/VirtualJoystick'; import {Gamepad as TwistIcon, Stop as ReleaseIcon} from '@mui/icons-material'; -import {Box, Button, Chip, CircularProgress, Typography, useTheme} from '@mui/material'; -import {useCallback, useEffect, useRef, useState} from 'react'; +import {Box, Button, Chip, Typography, useTheme} from '@mui/material'; +import {useCallback, useState} from 'react'; -interface TwistCardProps { - active: boolean; - linear: number; - angular: number; - pending: boolean; - onApply: (linear: number, angular: number) => void; - onRelease: () => void; -} - -const LINEAR_MAX = 1.0; // m/s at full stick -const ANGULAR_MAX = 2.0; // rad/s at full stick -// VirtualJoystick scales its angular axis (vz) by this internally, so undo it to -// normalize vz back to [-1, 1] before applying our own max. -const JOYSTICK_ANGULAR_FACTOR = 1.6; -const SEND_INTERVAL_MS = 100; // throttle RPCs while the stick is held - -export default function TwistCard({active, linear, angular, pending, onApply, onRelease}: TwistCardProps) { +export default function TwistCard() { const theme = useTheme(); - const [display, setDisplay] = useState({lin: linear, ang: angular}); - - const latest = useRef({lin: 0, ang: 0}); - const lastSent = useRef(0); - const trailing = useRef | null>(null); - - const clearTrailing = () => { - if (trailing.current !== null) { - clearTimeout(trailing.current); - trailing.current = null; - } - }; - - const flush = useCallback(() => { - lastSent.current = performance.now(); - onApply(latest.current.lin, latest.current.ang); - }, [onApply]); - - // Rate-limit sends: fire immediately if enough time passed, otherwise schedule a - // single trailing send so the sim always ends up with the final stick value. - const schedule = useCallback(() => { - const dt = performance.now() - lastSent.current; - if (dt >= SEND_INTERVAL_MS) { - clearTrailing(); - flush(); - } else if (trailing.current === null) { - trailing.current = setTimeout(() => { - trailing.current = null; - flush(); - }, SEND_INTERVAL_MS - dt); - } - }, [flush]); + const {setVelocity, release, active} = useTeleop({holdUntilRelease: true}); + const [display, setDisplay] = useState({vx: 0, vz: 0}); const handleVelocity = useCallback( (vx: number, vz: number) => { - const next = {lin: vx * LINEAR_MAX, ang: (vz / JOYSTICK_ANGULAR_FACTOR) * ANGULAR_MAX}; - latest.current = next; - setDisplay(next); - schedule(); + setDisplay({vx, vz}); + setVelocity(vx, vz); }, - [schedule], + [setVelocity], ); - // Reflect override values coming back from the sim while active and the stick is idle. - useEffect(() => { - if (active && latest.current.lin === 0 && latest.current.ang === 0) { - setDisplay({lin: linear, ang: angular}); - } - }, [active, linear, angular]); - - useEffect(() => () => clearTrailing(), []); + const handleRelease = useCallback(() => { + setDisplay({vx: 0, vz: 0}); + release(); + }, [release]); const color = active ? theme.palette.info.main : theme.palette.text.secondary; @@ -96,14 +45,13 @@ export default function TwistCard({active, linear, angular, pending, onApply, on - Movement override + Manual drive - Drive the robot directly with the joystick, ignoring the mower logic + Joystick keeps publishing until Release is pressed - {active && } - {pending && } + {active && } @@ -123,30 +71,25 @@ export default function TwistCard({active, linear, angular, pending, onApply, on }} > - Linear + Speed - {display.lin.toFixed(2)} m/s + {display.vx.toFixed(2)} - Angular + Turn - {display.ang.toFixed(2)} rad/s + {display.vz.toFixed(2)} diff --git a/src/hooks/useSimControl.ts b/src/hooks/useSimControl.ts index 3ab595a..a0c5749 100644 --- a/src/hooks/useSimControl.ts +++ b/src/hooks/useSimControl.ts @@ -5,7 +5,7 @@ import {useSelectedMower} from '@/stores/mowersStore'; import type {SimState} from '@/stores/schemas'; import {useCallback, useState} from 'react'; -export type SimAction = 'emergency' | 'movement' | 'battery' | 'gps' | 'dock' | 'twist' | 'displace'; +export type SimAction = 'emergency' | 'movement' | 'battery' | 'gps' | 'dock' | 'displace'; export interface SimControl { /** null while no simulator has been detected (no retained sim/state/json message). */ @@ -19,7 +19,6 @@ export interface SimControl { setBatteryVoltage: (voltage: number) => void; setGpsGood: (good: boolean) => void; moveToDock: () => void; - setTwist: (linear: number, angular: number, enabled?: boolean) => void; displace: (dx: number, dy: number, dheading?: number) => void; } @@ -57,7 +56,6 @@ export function useSimControl(): SimControl { setBatteryVoltage: (voltage) => run('battery', (r) => r.sim.battery.set({voltage})), setGpsGood: (good) => run('gps', (r) => r.sim.gps.set({good})), moveToDock: () => run('dock', (r) => r.sim.dock.move()), - setTwist: (linear, angular, enabled = true) => run('twist', (r) => r.sim.twist.set({linear, angular, enabled})), displace: (dx, dy, dheading = 0) => run('displace', (r) => r.sim.displace({dx, dy, dheading})), }; } diff --git a/src/hooks/useTeleop.ts b/src/hooks/useTeleop.ts index caf19d8..7c60c40 100644 --- a/src/hooks/useTeleop.ts +++ b/src/hooks/useTeleop.ts @@ -1,43 +1,85 @@ import {useMowersStore} from '@/stores/mowersStore'; -import {useCallback, useEffect, useRef} from 'react'; +import {useCallback, useEffect, useRef, useState} from 'react'; const PUBLISH_INTERVAL_MS = 100; -export function useTeleop() { +interface UseTeleopOptions { + /** + * When true, the publish interval keeps running (sending the latest velocity, + * even 0,0) after the stick returns to centre. Only an explicit release() call + * stops it. Used by the Simulation tab joystick. Default: false (map behavior). + */ + holdUntilRelease?: boolean; +} + +interface UseTeleopResult { + setVelocity: (vx: number, vz: number) => void; + /** Only meaningful when holdUntilRelease is true. */ + release: () => void; + /** True while the hold-mode interval is running. */ + active: boolean; +} + +export function useTeleop(options: UseTeleopOptions = {}): UseTeleopResult { + const {holdUntilRelease = false} = options; + const vel = useRef({vx: 0, vz: 0}); const interval = useRef | null>(null); + const [active, setActive] = useState(false); const publish = useCallback(() => { const {mowers, selected} = useMowersStore.getState(); mowers[selected]?.publishTeleop(vel.current.vx, vel.current.vz); }, []); + const stopInterval = useCallback(() => { + if (interval.current !== null) { + clearInterval(interval.current); + interval.current = null; + } + }, []); + + const release = useCallback(() => { + stopInterval(); + vel.current = {vx: 0, vz: 0}; + publish(); + setActive(false); + }, [stopInterval, publish]); + const setVelocity = useCallback( (vx: number, vz: number) => { vel.current = {vx: Math.max(-1, Math.min(1, vx)), vz: Math.max(-1, Math.min(1, vz))}; - const moving = vx !== 0 || vz !== 0; - const wasMoving = interval.current !== null; + if (holdUntilRelease) { + if (interval.current === null) { + publish(); + interval.current = setInterval(publish, PUBLISH_INTERVAL_MS); + setActive(true); + } + // interval keeps running; next tick picks up the updated vel ref + } else { + const moving = vx !== 0 || vz !== 0; + const wasMoving = interval.current !== null; - if (moving && !wasMoving) { - publish(); - interval.current = setInterval(publish, PUBLISH_INTERVAL_MS); - } else if (!moving && wasMoving) { - clearInterval(interval.current!); - interval.current = null; - publish(); + if (moving && !wasMoving) { + publish(); + interval.current = setInterval(publish, PUBLISH_INTERVAL_MS); + } else if (!moving && wasMoving) { + stopInterval(); + publish(); + } } }, - [publish], + [holdUntilRelease, publish, stopInterval], ); useEffect(() => { return () => { - if (interval.current !== null) clearInterval(interval.current); + stopInterval(); vel.current = {vx: 0, vz: 0}; publish(); }; }, []); - return {setVelocity}; + return {setVelocity, release, active}; } diff --git a/src/lib/rpc.ts b/src/lib/rpc.ts index ba42847..fb4fdb7 100644 --- a/src/lib/rpc.ts +++ b/src/lib/rpc.ts @@ -65,7 +65,7 @@ export interface ObjectHicl3T4F { [key: string]: any; } * Generated! Represents an alias to any of the provided schemas * */ -export type AnyOfObjectHAgrRKSzStringDoaGddGAStringDoaGddGABooleanVyG3AEThBooleanVyG3AEThNumberHo1ClIqDBooleanVyG3AEThNumberHo1ClIqDNumberHo1ClIqDBooleanVyG3AEThNumberHo1ClIqDNumberHo1ClIqDNumberHo1ClIqDStringZDJW5SIjUnorderedSetOfStringDoaGddGADvj0XlFaNullQu0Arl1FStringZDJW5SIjStringDoaGddGAObjectOfUnorderedSetOfObjectOfUnorderedSetOfUnorderedSetOfNumberHo1ClIqDAokMKuEfbuExLS5GObjectOfStringDoaGddGAStringDoaGddGABooleanVyG3AETh8PZSKeKMFruGLaE4Gkga5QR4UnorderedSetOfUnorderedSetOfNumberHo1ClIqDAokMKuEfbuExLS5GA4JKc1WsUnorderedSetOfObjectOfNumberHo1ClIqDStringDoaGddGAJFf7IL19E3WiTXvKUnorderedSetOfObjectOfNumberHo1ClIqDNumberHo1ClIqDStringDoaGddGANumberHo1ClIqDStringDoaGddGAStringDoaGddGAStringDoaGddGABooleanVyG3AEThEg3PskfBvWnyETupUnorderedSetOfStringDoaGddGADvj0XlFaObjectHicl3T4FNullQu0Arl1FNullQu0Arl1FNullQu0Arl1FNullQu0Arl1FNullQu0Arl1FNullQu0Arl1FNullQu0Arl1F = ObjectHAgrRKSz | StringDoaGddGA | BooleanVyG3AETh | NumberHo1ClIqD | StringZDJW5SIj | UnorderedSetOfStringDoaGddGADvj0XlFa | NullQu0Arl1F | ObjectOfUnorderedSetOfObjectOfUnorderedSetOfUnorderedSetOfNumberHo1ClIqDAokMKuEfbuExLS5GObjectOfStringDoaGddGAStringDoaGddGABooleanVyG3AETh8PZSKeKMFruGLaE4Gkga5QR4UnorderedSetOfUnorderedSetOfNumberHo1ClIqDAokMKuEfbuExLS5GA4JKc1Ws | UnorderedSetOfObjectOfNumberHo1ClIqDStringDoaGddGAJFf7IL19E3WiTXvK | UnorderedSetOfObjectOfNumberHo1ClIqDNumberHo1ClIqDStringDoaGddGANumberHo1ClIqDStringDoaGddGAStringDoaGddGAStringDoaGddGABooleanVyG3AEThEg3PskfBvWnyETup | ObjectHicl3T4F; +export type AnyOfObjectHAgrRKSzStringDoaGddGAStringDoaGddGABooleanVyG3AEThBooleanVyG3AEThNumberHo1ClIqDBooleanVyG3AEThNumberHo1ClIqDNumberHo1ClIqDNumberHo1ClIqDStringZDJW5SIjUnorderedSetOfStringDoaGddGADvj0XlFaNullQu0Arl1FStringZDJW5SIjStringDoaGddGAObjectOfUnorderedSetOfObjectOfUnorderedSetOfUnorderedSetOfNumberHo1ClIqDAokMKuEfbuExLS5GObjectOfStringDoaGddGAStringDoaGddGABooleanVyG3AETh8PZSKeKMFruGLaE4Gkga5QR4UnorderedSetOfUnorderedSetOfNumberHo1ClIqDAokMKuEfbuExLS5GA4JKc1WsUnorderedSetOfObjectOfNumberHo1ClIqDStringDoaGddGAJFf7IL19E3WiTXvKUnorderedSetOfObjectOfNumberHo1ClIqDNumberHo1ClIqDStringDoaGddGANumberHo1ClIqDStringDoaGddGAStringDoaGddGAStringDoaGddGABooleanVyG3AEThEg3PskfBvWnyETupUnorderedSetOfStringDoaGddGADvj0XlFaObjectHicl3T4FNullQu0Arl1FNullQu0Arl1FNullQu0Arl1FNullQu0Arl1FNullQu0Arl1FNullQu0Arl1F = ObjectHAgrRKSz | StringDoaGddGA | BooleanVyG3AETh | NumberHo1ClIqD | StringZDJW5SIj | UnorderedSetOfStringDoaGddGADvj0XlFa | NullQu0Arl1F | ObjectOfUnorderedSetOfObjectOfUnorderedSetOfUnorderedSetOfNumberHo1ClIqDAokMKuEfbuExLS5GObjectOfStringDoaGddGAStringDoaGddGABooleanVyG3AETh8PZSKeKMFruGLaE4Gkga5QR4UnorderedSetOfUnorderedSetOfNumberHo1ClIqDAokMKuEfbuExLS5GA4JKc1Ws | UnorderedSetOfObjectOfNumberHo1ClIqDStringDoaGddGAJFf7IL19E3WiTXvK | UnorderedSetOfObjectOfNumberHo1ClIqDNumberHo1ClIqDStringDoaGddGANumberHo1ClIqDStringDoaGddGAStringDoaGddGAStringDoaGddGABooleanVyG3AEThEg3PskfBvWnyETup | ObjectHicl3T4F; export class OpenMowerRpc extends OpenMowerBaseRpc { rpc = { @@ -155,12 +155,6 @@ export class OpenMowerRpc extends OpenMowerBaseRpc { */ move: async (): Promise => this.call('sim.dock.move'), }, - twist: { - /** - * Override the commanded twist (like cmd_vel). enabled=false releases the override. - */ - set: async (args: {linear?: NumberHo1ClIqD, angular?: NumberHo1ClIqD, enabled?: BooleanVyG3AETh}): Promise => this.call('sim.twist.set', args), - }, /** * Teleport the robot by (dx, dy) meters and dheading radians to simulate a GPS jump. */ diff --git a/src/stores/schemas.ts b/src/stores/schemas.ts index 66766db..a974a86 100644 --- a/src/stores/schemas.ts +++ b/src/stores/schemas.ts @@ -60,9 +60,6 @@ export const simStateSchema = z.object({ gps_good: z.boolean(), battery_voltage: z.number(), charging: z.boolean(), - twist_override: z.boolean(), - override_linear: z.number(), - override_angular: z.number(), }); export type SimState = z.infer; From ec4aa823a55f5ba28fb2d370368f4b307a2f499f Mon Sep 17 00:00:00 2001 From: Robert Vollmer Date: Sun, 5 Jul 2026 00:48:09 +0000 Subject: [PATCH 05/10] Move simulator joystick to map --- src/app/simulation/page.tsx | 7 -- src/components/map/MowerMap.tsx | 10 +- src/components/map/SimulatorButton.tsx | 68 ++++++++++++ src/components/map/teleop/TeleopControls.tsx | 11 +- src/components/map/teleop/VirtualJoystick.tsx | 9 +- src/components/simulation/TwistCard.tsx | 100 ------------------ src/hooks/useTeleop.ts | 82 +++++--------- 7 files changed, 116 insertions(+), 171 deletions(-) create mode 100644 src/components/map/SimulatorButton.tsx delete mode 100644 src/components/simulation/TwistCard.tsx diff --git a/src/app/simulation/page.tsx b/src/app/simulation/page.tsx index 269d23e..782888f 100644 --- a/src/app/simulation/page.tsx +++ b/src/app/simulation/page.tsx @@ -6,7 +6,6 @@ import DisplaceCard from '@/components/simulation/DisplaceCard'; import DockCard from '@/components/simulation/DockCard'; import EmergencyCard from '@/components/simulation/EmergencyCard'; import ToggleCard from '@/components/simulation/ToggleCard'; -import TwistCard from '@/components/simulation/TwistCard'; import {useSimControl} from '@/hooks/useSimControl'; import {outerCardStyles} from '@/lib/cardStyles'; import {useSelectedMower} from '@/stores/mowersStore'; @@ -137,12 +136,6 @@ export default function SimulationPage() { - - - - - - ) diff --git a/src/components/map/MowerMap.tsx b/src/components/map/MowerMap.tsx index 5197b33..b823767 100644 --- a/src/components/map/MowerMap.tsx +++ b/src/components/map/MowerMap.tsx @@ -17,7 +17,7 @@ import {FocusIcon, LayoutListIcon, PencilIcon} from 'lucide-react'; import type {Map} from 'maplibre-gl'; import 'maplibre-gl/dist/maplibre-gl.css'; import {RFullscreenControl, RMap} from 'maplibre-react-components'; -import {useCallback, useEffect, useEffectEvent, useMemo, useRef} from 'react'; +import {useCallback, useEffect, useEffectEvent, useMemo, useRef, useState} from 'react'; import {DialogOutlet, useDialog} from 'react-dialog-async'; import AreasList from './AreasList'; import ControlButton from './ControlButton'; @@ -33,6 +33,7 @@ import LayersButton from './LayersButton'; import MapDialog from './MapDialog'; import {mapStyles} from './mapStyles'; import MowerMarker from './MowerMarker'; +import SimulatorButton from './SimulatorButton'; import TeleopControls from './teleop/TeleopControls'; import TrackLayer from './TrackLayer'; @@ -61,7 +62,9 @@ export function MowerMap({mapData, saveMapToMower, sx}: MowerMapProps) { const currentState = useSelectedMower((s) => s?.state.current_state); const isDocked = useSelectedMower((s) => s?.state.is_charging ?? false); const mowerPosition = useSelectedMower((s) => s?.position ?? s?.state.pose); - const showTeleop = currentState === 'AREA_RECORDING' && !editMode; + const simAvailable = useSelectedMower((s) => s?.simState != null); + const [manualDrive, setManualDrive] = useState(false); + const showTeleop = (currentState === 'AREA_RECORDING' || manualDrive) && !editMode; const areas = useMemo( () => features.features.filter((feature) => feature.geometry.type === 'Polygon') as Feature[], [features], @@ -246,6 +249,7 @@ export function MowerMap({mapData, saveMapToMower, sx}: MowerMapProps) { onClick={() => fitToBounds(false, padding)} /> + {simAvailable && } } - {showTeleop && } + {showTeleop && } diff --git a/src/components/map/SimulatorButton.tsx b/src/components/map/SimulatorButton.tsx new file mode 100644 index 0000000..e04b04d --- /dev/null +++ b/src/components/map/SimulatorButton.tsx @@ -0,0 +1,68 @@ +'use client'; + +import {ScienceOutlined as SimulatorIcon} from '@mui/icons-material'; +import {FormControlLabel, Popover, Switch, Typography} from '@mui/material'; +import {useRControl} from 'maplibre-react-components'; +import {useRef, useState} from 'react'; +import {createPortal} from 'react-dom'; + +interface SimulatorButtonProps { + manualDrive: boolean; + onManualDriveChange: (enabled: boolean) => void; +} + +export default function SimulatorButton({manualDrive, onManualDriveChange}: SimulatorButtonProps) { + const {container} = useRControl({position: 'bottom-right'}); + const buttonRef = useRef(null); + const [open, setOpen] = useState(false); + + const content = ( + <> + + + setOpen(false)} + anchorOrigin={{vertical: 'bottom', horizontal: 'left'}} + transformOrigin={{vertical: 'bottom', horizontal: 'right'}} + slotProps={{paper: {sx: {minWidth: 220, p: 1}}}} + > + + Simulator + + + onManualDriveChange(e.target.checked)} /> + } + label="Manual drive" + /> + + + ); + + return createPortal(content, container); +} diff --git a/src/components/map/teleop/TeleopControls.tsx b/src/components/map/teleop/TeleopControls.tsx index c71c1ae..9e15027 100644 --- a/src/components/map/teleop/TeleopControls.tsx +++ b/src/components/map/teleop/TeleopControls.tsx @@ -4,8 +4,13 @@ import {useTeleop} from '@/hooks/useTeleop'; import {Box, useMediaQuery, useTheme} from '@mui/material'; import VirtualJoystick from './VirtualJoystick'; -export default function TeleopControls() { - const {setVelocity} = useTeleop(); +interface TeleopControlsProps { + publishAtRest?: boolean; + simulatorMode?: boolean; +} + +export default function TeleopControls({publishAtRest = false, simulatorMode = false}: TeleopControlsProps) { + const {setVelocity} = useTeleop({publishAtRest}); const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down('md')); @@ -19,7 +24,7 @@ export default function TeleopControls() { zIndex: 10, }} > - + ); } diff --git a/src/components/map/teleop/VirtualJoystick.tsx b/src/components/map/teleop/VirtualJoystick.tsx index 0a0e156..d4613b4 100644 --- a/src/components/map/teleop/VirtualJoystick.tsx +++ b/src/components/map/teleop/VirtualJoystick.tsx @@ -15,9 +15,10 @@ type DpadDirection = 'up' | 'down' | 'left' | 'right' | null; interface VirtualJoystickProps { onVelocityChange: (vx: number, vz: number) => void; + simulatorMode?: boolean; } -export default function VirtualJoystick({onVelocityChange}: VirtualJoystickProps) { +export default function VirtualJoystick({onVelocityChange, simulatorMode = false}: VirtualJoystickProps) { const containerRef = useRef(null); const [knobPos, setKnobPos] = useState({x: 0, y: 0}); const [dragging, setDragging] = useState(false); @@ -153,8 +154,10 @@ export default function VirtualJoystick({onVelocityChange}: VirtualJoystickProps position: 'relative', touchAction: 'none', userSelect: 'none', - background: 'radial-gradient(circle, rgba(0,0,0,0.25) 0%, rgba(0,0,0,0.4) 100%)', - border: '2px solid rgba(255,255,255,0.3)', + background: simulatorMode + ? 'radial-gradient(circle, rgba(180,0,0,0.35) 0%, rgba(120,0,0,0.5) 100%)' + : 'radial-gradient(circle, rgba(0,0,0,0.25) 0%, rgba(0,0,0,0.4) 100%)', + border: simulatorMode ? '2px solid rgba(255,80,80,0.5)' : '2px solid rgba(255,255,255,0.3)', backdropFilter: 'blur(4px)', }} > diff --git a/src/components/simulation/TwistCard.tsx b/src/components/simulation/TwistCard.tsx deleted file mode 100644 index 2060de4..0000000 --- a/src/components/simulation/TwistCard.tsx +++ /dev/null @@ -1,100 +0,0 @@ -'use client'; - -import {useTeleop} from '@/hooks/useTeleop'; -import VirtualJoystick from '@/components/map/teleop/VirtualJoystick'; -import {Gamepad as TwistIcon, Stop as ReleaseIcon} from '@mui/icons-material'; -import {Box, Button, Chip, Typography, useTheme} from '@mui/material'; -import {useCallback, useState} from 'react'; - -export default function TwistCard() { - const theme = useTheme(); - const {setVelocity, release, active} = useTeleop({holdUntilRelease: true}); - const [display, setDisplay] = useState({vx: 0, vz: 0}); - - const handleVelocity = useCallback( - (vx: number, vz: number) => { - setDisplay({vx, vz}); - setVelocity(vx, vz); - }, - [setVelocity], - ); - - const handleRelease = useCallback(() => { - setDisplay({vx: 0, vz: 0}); - release(); - }, [release]); - - const color = active ? theme.palette.info.main : theme.palette.text.secondary; - - return ( - - - - - - - - Manual drive - - - Joystick keeps publishing until Release is pressed - - - {active && } - - - - - - - - - - - Speed - - - {display.vx.toFixed(2)} - - - Turn - - - {display.vz.toFixed(2)} - - - - - - - - ); -} diff --git a/src/hooks/useTeleop.ts b/src/hooks/useTeleop.ts index 7c60c40..4967a43 100644 --- a/src/hooks/useTeleop.ts +++ b/src/hooks/useTeleop.ts @@ -1,85 +1,57 @@ import {useMowersStore} from '@/stores/mowersStore'; -import {useCallback, useEffect, useRef, useState} from 'react'; +import {useCallback, useEffect, useRef} from 'react'; const PUBLISH_INTERVAL_MS = 100; interface UseTeleopOptions { /** - * When true, the publish interval keeps running (sending the latest velocity, - * even 0,0) after the stick returns to centre. Only an explicit release() call - * stops it. Used by the Simulation tab joystick. Default: false (map behavior). + * When true, starts publishing immediately (at 0,0) and keeps publishing + * even when the stick is centred. When false, only publishes while moving. */ - holdUntilRelease?: boolean; + publishAtRest?: boolean; } -interface UseTeleopResult { - setVelocity: (vx: number, vz: number) => void; - /** Only meaningful when holdUntilRelease is true. */ - release: () => void; - /** True while the hold-mode interval is running. */ - active: boolean; -} - -export function useTeleop(options: UseTeleopOptions = {}): UseTeleopResult { - const {holdUntilRelease = false} = options; - +export function useTeleop({publishAtRest = false}: UseTeleopOptions = {}) { const vel = useRef({vx: 0, vz: 0}); const interval = useRef | null>(null); - const [active, setActive] = useState(false); const publish = useCallback(() => { const {mowers, selected} = useMowersStore.getState(); mowers[selected]?.publishTeleop(vel.current.vx, vel.current.vz); }, []); - const stopInterval = useCallback(() => { - if (interval.current !== null) { - clearInterval(interval.current); - interval.current = null; - } - }, []); - - const release = useCallback(() => { - stopInterval(); - vel.current = {vx: 0, vz: 0}; - publish(); - setActive(false); - }, [stopInterval, publish]); + const updatePublishInterval = useCallback( + (override?: boolean) => { + const shouldPublish = override ?? (publishAtRest || vel.current.vx !== 0 || vel.current.vz !== 0); + const isPublishing = interval.current !== null; + if (shouldPublish && !isPublishing) { + publish(); + interval.current = setInterval(publish, PUBLISH_INTERVAL_MS); + } else if (!shouldPublish && isPublishing) { + clearInterval(interval.current!); + interval.current = null; + publish(); + } + }, + [publishAtRest, publish], + ); const setVelocity = useCallback( (vx: number, vz: number) => { vel.current = {vx: Math.max(-1, Math.min(1, vx)), vz: Math.max(-1, Math.min(1, vz))}; - - if (holdUntilRelease) { - if (interval.current === null) { - publish(); - interval.current = setInterval(publish, PUBLISH_INTERVAL_MS); - setActive(true); - } - // interval keeps running; next tick picks up the updated vel ref - } else { - const moving = vx !== 0 || vz !== 0; - const wasMoving = interval.current !== null; - - if (moving && !wasMoving) { - publish(); - interval.current = setInterval(publish, PUBLISH_INTERVAL_MS); - } else if (!moving && wasMoving) { - stopInterval(); - publish(); - } - } + updatePublishInterval(); }, - [holdUntilRelease, publish, stopInterval], + [updatePublishInterval], ); useEffect(() => { + if (publishAtRest) { + updatePublishInterval(true); + } return () => { - stopInterval(); - vel.current = {vx: 0, vz: 0}; - publish(); + updatePublishInterval(false); }; }, []); - return {setVelocity, release, active}; + return {setVelocity}; } From e7a04e11c4b64eaf2709e86e2d7c05f80c697985 Mon Sep 17 00:00:00 2001 From: Robert Vollmer Date: Sun, 5 Jul 2026 02:12:44 +0000 Subject: [PATCH 06/10] Replace sim page with map flyout --- src/app/simulation/page.tsx | 152 ------- src/components/map/SimulatorButton.tsx | 395 +++++++++++++++++- src/components/navigation/MobileBottomBar.tsx | 3 +- src/components/navigation/navigationItems.tsx | 4 +- src/components/navigation/sidebar/Sidebar.tsx | 3 +- 5 files changed, 380 insertions(+), 177 deletions(-) delete mode 100644 src/app/simulation/page.tsx diff --git a/src/app/simulation/page.tsx b/src/app/simulation/page.tsx deleted file mode 100644 index 782888f..0000000 --- a/src/app/simulation/page.tsx +++ /dev/null @@ -1,152 +0,0 @@ -'use client'; - -import {HeaderStat, Page, PageContent, PageHeader} from '@/components/page'; -import BatteryGauge from '@/components/simulation/BatteryGauge'; -import DisplaceCard from '@/components/simulation/DisplaceCard'; -import DockCard from '@/components/simulation/DockCard'; -import EmergencyCard from '@/components/simulation/EmergencyCard'; -import ToggleCard from '@/components/simulation/ToggleCard'; -import {useSimControl} from '@/hooks/useSimControl'; -import {outerCardStyles} from '@/lib/cardStyles'; -import {useSelectedMower} from '@/stores/mowersStore'; -import { - BatteryFull as BatteryIcon, - ReportProblem as EmergencyIcon, - GpsFixed as GpsFixedIcon, - GpsOff as GpsOffIcon, - MoveDown as MoveIcon, - ScienceOutlined as ScienceIcon, - DoNotDisturbOn as StuckIcon, -} from '@mui/icons-material'; -import {Alert, Box, Card, CardContent, Snackbar, Typography, useTheme} from '@mui/material'; - -export default function SimulationPage() { - const theme = useTheme(); - const robotBattery = useSelectedMower((m) => m?.state.battery_percentage ?? null); - const { - simState, - available, - pending, - error, - setEmergency, - setMovementAllowed, - setBatteryVoltage, - setGpsGood, - moveToDock, - displace, - } = useSimControl(); - - return ( - - - } value={robotBattery !== null ? `${robotBattery}%` : '—'} label="Battery" /> - } - value={simState ? (simState.gps_good ? 'RTK' : 'No fix') : '—'} - label="GPS" - /> - } - value={simState ? (simState.emergency_latch ? 'Latched' : 'Clear') : '—'} - label="Emergency" - /> - - - - {!available ? ( - - - - - - Simulator not detected - - - These controls are only available when connected to the mower_simulation node. Waiting - for a retained sim/state/json message… - - - - - ) : ( - simState && ( - - - - - - - - - - - - - : } - title="Traction (stuck simulation)" - description="Wheels keep turning but position is frozen — like a mower physically blocked while spinning. Unlike a 0/0 joystick command, the wheels don't stop." - value={simState.movement_allowed} - pending={pending === 'movement'} - onChange={setMovementAllowed} - trueOption={{label: 'Free', sub: 'Wheels turn, robot moves', color: 'success'}} - falseOption={{label: 'Stuck', sub: 'Wheels turn, position frozen', color: 'warning'}} - /> - - - - - - : } - title="GPS quality" - value={simState.gps_good} - pending={pending === 'gps'} - onChange={setGpsGood} - trueOption={{label: 'RTK Fix', sub: '~2 cm', color: 'success'}} - falseOption={{label: 'No Fix', sub: '~1 m', color: 'error'}} - /> - - - - - - - - - - - - - - - - - ) - )} - - - - - {error} - - - - ); -} diff --git a/src/components/map/SimulatorButton.tsx b/src/components/map/SimulatorButton.tsx index e04b04d..2a55e1a 100644 --- a/src/components/map/SimulatorButton.tsx +++ b/src/components/map/SimulatorButton.tsx @@ -1,9 +1,29 @@ 'use client'; -import {ScienceOutlined as SimulatorIcon} from '@mui/icons-material'; -import {FormControlLabel, Popover, Switch, Typography} from '@mui/material'; +import {useSimControl} from '@/hooks/useSimControl'; +import {useSelectedMower} from '@/stores/mowersStore'; +import { + KeyboardArrowDown as DownIcon, + KeyboardArrowLeft as LeftIcon, + KeyboardArrowRight as RightIcon, + ScienceOutlined as SimulatorIcon, + KeyboardArrowUp as UpIcon, +} from '@mui/icons-material'; +import { + Box, + Button, + Divider, + FormControlLabel, + IconButton, + Popover, + Switch, + ToggleButton, + ToggleButtonGroup, + Typography, + useTheme, +} from '@mui/material'; import {useRControl} from 'maplibre-react-components'; -import {useRef, useState} from 'react'; +import {useCallback, useRef, useState} from 'react'; import {createPortal} from 'react-dom'; interface SimulatorButtonProps { @@ -11,10 +31,199 @@ interface SimulatorButtonProps { onManualDriveChange: (enabled: boolean) => void; } +const STEPS = [0.25, 0.5, 1, 2]; + +interface BatterySliderProps { + value: number; + onChange: (v: number) => void; + min: number; + max: number; + emptyVoltage?: number; + fullVoltage?: number; + critLowVoltage: number; + critHighVoltage: number; +} + +const TRACK_H = 20; +const PAD_X = 0; +const SVG_H = TRACK_H; + +function BatterySlider({ + value, + onChange, + min, + max, + emptyVoltage, + fullVoltage, + critLowVoltage, + critHighVoltage, +}: BatterySliderProps) { + const theme = useTheme(); + const svgRef = useRef(null); + const [width, setWidth] = useState(300); + const [dragging, setDragging] = useState(false); + + const measuredRef = useCallback((el: HTMLDivElement | null) => { + if (!el) return; + const update = () => setWidth(el.clientWidth); + update(); + const ro = new ResizeObserver(update); + ro.observe(el); + }, []); + + const trackW = Math.max(1, width - PAD_X * 2); + const toX = useCallback((v: number) => PAD_X + ((v - min) / (max - min)) * trackW, [min, max, trackW]); + const toV = useCallback( + (x: number) => { + const clamped = Math.max(0, Math.min(trackW, x - PAD_X)); + return Math.round((min + (clamped / trackW) * (max - min)) * 10) / 10; + }, + [min, max, trackW], + ); + + const thumbX = Math.max(PAD_X, Math.min(PAD_X + trackW, toX(value))); + const xCritLow = toX(critLowVoltage); + const xEmpty = emptyVoltage !== undefined ? toX(emptyVoltage) : xCritLow; + const xFull = fullVoltage !== undefined ? toX(fullVoltage) : toX(max); + const xCritHigh = toX(critHighVoltage); + + const dark = theme.palette.mode === 'dark'; + const colRed = dark ? '#ef4444' : '#f87171'; + const colYellow = dark ? '#eab308' : '#facc15'; + const colGreen = dark ? '#22c55e' : '#4ade80'; + + const handlePointer = useCallback( + (e: React.PointerEvent) => { + if (!svgRef.current) return; + const rect = svgRef.current.getBoundingClientRect(); + // scale from CSS pixels to viewBox units + const scale = width / rect.width; + const x = (e.clientX - rect.left) * scale; + onChange(toV(x)); + }, + [onChange, toV, width], + ); + + const onPointerDown = (e: React.PointerEvent) => { + (e.currentTarget as SVGSVGElement).setPointerCapture(e.pointerId); + setDragging(true); + handlePointer(e); + }; + const onPointerMove = (e: React.PointerEvent) => { + if (dragging) handlePointer(e); + }; + const onPointerUp = (e: React.PointerEvent) => { + setDragging(false); + (e.currentTarget as SVGSVGElement).releasePointerCapture(e.pointerId); + }; + + const rx = 3; + + return ( + + + + + + + + + {/* Zone 1: red (min → critLow) */} + + {/* Zone 2: yellow (critLow → empty) */} + + {/* Zone 3: green (empty → full) */} + + {/* Zone 4: yellow (full → critHigh) */} + + {/* Zone 5: red (critHigh → max) */} + + + {/* Track border */} + + + {/* Thumb: vertical line */} + + + + ); +} + +const rowSx = {mx: 0, px: 1, py: 0.25, width: '100%', justifyContent: 'space-between'} as const; + export default function SimulatorButton({manualDrive, onManualDriveChange}: SimulatorButtonProps) { const {container} = useRControl({position: 'bottom-right'}); const buttonRef = useRef(null); const [open, setOpen] = useState(false); + const [displaceStep, setDisplaceStep] = useState(0.5); + + const theme = useTheme(); + + const {simState, available, setEmergency, setMovementAllowed, setBatteryVoltage, setGpsGood, moveToDock, displace} = + useSimControl(); + + const params = useSelectedMower((m) => m?.params ?? {}); + const fullVoltage = params['/ll/services/power/battery_full_voltage']; + const emptyVoltage = params['/ll/services/power/battery_empty_voltage']; + const critLowVoltage = params['/ll/services/power/battery_critical_voltage']; + const critHighVoltage = params['/ll/services/power/battery_critical_high_voltage']; + const batteryPct = useSelectedMower((m) => m?.state.battery_percentage ?? null); + + const hasActivity = + manualDrive || simState?.emergency_latch || simState?.gps_good === false || simState?.movement_allowed === false; const content = ( <> @@ -26,16 +235,16 @@ export default function SimulatorButton({manualDrive, onManualDriveChange}: Simu style={{padding: 0, position: 'relative'}} > - {manualDrive && ( + {hasActivity && ( )} @@ -47,19 +256,169 @@ export default function SimulatorButton({manualDrive, onManualDriveChange}: Simu onClose={() => setOpen(false)} anchorOrigin={{vertical: 'bottom', horizontal: 'left'}} transformOrigin={{vertical: 'bottom', horizontal: 'right'}} - slotProps={{paper: {sx: {minWidth: 220, p: 1}}}} + slotProps={{paper: {sx: {width: 460, py: 1}}}} > - + Simulator - onManualDriveChange(e.target.checked)} /> - } - label="Manual drive" - /> + {available && simState ? ( + + {/* Left column */} + + {/* Boolean toggles via Switch */} + onManualDriveChange(e.target.checked)} />} + label="Manual drive" + /> + + setGpsGood(e.target.checked)} />} + /> + setMovementAllowed(e.target.checked)} /> + } + /> + + + + {/* Emergency + Dock */} + + + + + + + + {/* Battery slider */} + + Battery{batteryPct !== null ? ` — ${batteryPct}%` : ''}{' '} + + {simState.battery_voltage.toFixed(1)} V + + + {critLowVoltage !== undefined && critHighVoltage !== undefined ? ( + + ) : ( + + Voltage params not available + + )} + + + + + {/* Right column — GPS jump */} + + + GPS jump + + + + + displace(0, displaceStep)} + sx={{border: `1px solid ${theme.palette.divider}`, borderRadius: 1}} + > + + + + + displace(-displaceStep, 0)} + sx={{border: `1px solid ${theme.palette.divider}`, borderRadius: 1}} + > + + + + + displace(displaceStep, 0)} + sx={{border: `1px solid ${theme.palette.divider}`, borderRadius: 1}} + > + + + + + displace(0, -displaceStep)} + sx={{border: `1px solid ${theme.palette.divider}`, borderRadius: 1}} + > + + + + + v != null && setDisplaceStep(v)} + orientation="vertical" + > + {STEPS.map((s) => ( + + {s} m + + ))} + + + + + ) : ( + <> + + + Simulator not detected — waiting for sim/state/json… + + + )} ); diff --git a/src/components/navigation/MobileBottomBar.tsx b/src/components/navigation/MobileBottomBar.tsx index 7c2be54..667606e 100644 --- a/src/components/navigation/MobileBottomBar.tsx +++ b/src/components/navigation/MobileBottomBar.tsx @@ -16,8 +16,7 @@ export default function MobileBottomBar({onMenuOpen}: MobileBottomBarProps) { const router = useRouter(); const pathname = usePathname(); const mowerCapabilities = useSelectedMower((s) => s?.capabilities); - const simAvailable = useSelectedMower((s) => s?.simState != null); - const navigationItems = createNavigationItems(mowerCapabilities, simAvailable); + const navigationItems = createNavigationItems(mowerCapabilities); const activeEmergency = useSelectedMowerActiveEmergency(); const handleNavigation = (path: string) => { diff --git a/src/components/navigation/navigationItems.tsx b/src/components/navigation/navigationItems.tsx index c30e17c..cf083c7 100644 --- a/src/components/navigation/navigationItems.tsx +++ b/src/components/navigation/navigationItems.tsx @@ -4,13 +4,12 @@ import { Dashboard as DashboardIcon, EventNote as EventIcon, Map as MapIcon, - ScienceOutlined as SimulationIcon, Sensors as SensorIcon, Settings as SettingsIcon, Assignment as TaskIcon, } from '@mui/icons-material'; -export function createNavigationItems(capabilities: Capabilities = {}, simAvailable = false): NavigationItem[] { +export function createNavigationItems(capabilities: Capabilities = {}): NavigationItem[] { const isDev = process.env.NEXT_PUBLIC_IS_DEV === 'true'; const hasCapability = (name: string, minLevel?: number) => { const level = capabilities[name]; @@ -21,7 +20,6 @@ export function createNavigationItems(capabilities: Capabilities = {}, simAvaila isDev && {label: 'Dashboard', icon: , path: '/', isGlobal: true}, {label: 'Map', icon: , path: '/map', isGlobal: false}, hasCapability('events') && {label: 'Events', icon: , path: '/events', isGlobal: false}, - simAvailable && {label: 'Simulation', icon: , path: '/simulation', isGlobal: false}, isDev && {label: 'Tasks', icon: , path: '/tasks', isGlobal: false}, isDev && {label: 'Sensors', icon: , path: '/sensors', isGlobal: false}, isDev && {label: 'Settings', icon: , path: '/settings', isGlobal: true}, diff --git a/src/components/navigation/sidebar/Sidebar.tsx b/src/components/navigation/sidebar/Sidebar.tsx index 764cb55..8bb98ea 100644 --- a/src/components/navigation/sidebar/Sidebar.tsx +++ b/src/components/navigation/sidebar/Sidebar.tsx @@ -26,8 +26,7 @@ export default function Sidebar({open, onClose}: SidebarProps) { const selectedMowerId = useSelectedMower((s) => s?.id); const selectedMower = mowerConfigs.find((mower) => mower.id === selectedMowerId); const mowerCapabilities = useSelectedMower((s) => s?.capabilities); - const simAvailable = useSelectedMower((s) => s?.simState != null); - const navigationItems = createNavigationItems(mowerCapabilities, simAvailable); + const navigationItems = createNavigationItems(mowerCapabilities); const handleMowerMenuOpen = (event: React.MouseEvent) => { setMowerMenuAnchor(event.currentTarget); From da9879b49b342436cdfa0adac85f746a4f1bec52 Mon Sep 17 00:00:00 2001 From: Robert Vollmer Date: Sun, 5 Jul 2026 02:19:53 +0000 Subject: [PATCH 07/10] Make buttons smaller --- src/components/map/SimulatorButton.tsx | 47 +++++++++++++------------- 1 file changed, 23 insertions(+), 24 deletions(-) diff --git a/src/components/map/SimulatorButton.tsx b/src/components/map/SimulatorButton.tsx index 2a55e1a..da2878a 100644 --- a/src/components/map/SimulatorButton.tsx +++ b/src/components/map/SimulatorButton.tsx @@ -263,6 +263,7 @@ export default function SimulatorButton({manualDrive, onManualDriveChange}: Simu {available && simState ? ( + <> {/* Left column */} @@ -291,30 +292,6 @@ export default function SimulatorButton({manualDrive, onManualDriveChange}: Simu - {/* Emergency + Dock */} - - - - - - - {/* Battery slider */} Battery{batteryPct !== null ? ` — ${batteryPct}%` : ''}{' '} @@ -411,6 +388,28 @@ export default function SimulatorButton({manualDrive, onManualDriveChange}: Simu + + + + + + ) : ( <> From 599a85d3433976634027f528c88326d76a8617ef Mon Sep 17 00:00:00 2001 From: Robert Vollmer Date: Mon, 6 Jul 2026 21:57:58 +0000 Subject: [PATCH 08/10] Fix manual drive --- openrpc.json | 22 ++++++++++++++++++++++ src/components/map/MowerMap.tsx | 6 +++--- src/components/map/SimulatorButton.tsx | 12 ++++-------- src/hooks/useSimControl.ts | 4 +++- src/lib/rpc.ts | 8 +++++++- src/stores/schemas.ts | 1 + 6 files changed, 40 insertions(+), 13 deletions(-) diff --git a/openrpc.json b/openrpc.json index fa31d5d..6cacc37 100644 --- a/openrpc.json +++ b/openrpc.json @@ -350,6 +350,28 @@ } } }, + { + "name": "sim.joy_override.set", + "summary": "Enable or disable joystick (manual drive) override in the simulator.", + "paramStructure": "by-name", + "params": [ + { + "name": "enabled", + "description": "true = joystick override active (manual drive), false = normal autonomous operation.", + "required": true, + "schema": { + "type": "boolean" + } + } + ], + "result": { + "name": "result", + "description": "No result is returned if successful.", + "schema": { + "type": "null" + } + } + }, { "name": "sim.dock.move", "summary": "Drive the simulated robot onto the dock and start charging.", diff --git a/src/components/map/MowerMap.tsx b/src/components/map/MowerMap.tsx index b823767..c07c4af 100644 --- a/src/components/map/MowerMap.tsx +++ b/src/components/map/MowerMap.tsx @@ -17,7 +17,7 @@ import {FocusIcon, LayoutListIcon, PencilIcon} from 'lucide-react'; import type {Map} from 'maplibre-gl'; import 'maplibre-gl/dist/maplibre-gl.css'; import {RFullscreenControl, RMap} from 'maplibre-react-components'; -import {useCallback, useEffect, useEffectEvent, useMemo, useRef, useState} from 'react'; +import {useCallback, useEffect, useEffectEvent, useMemo, useRef} from 'react'; import {DialogOutlet, useDialog} from 'react-dialog-async'; import AreasList from './AreasList'; import ControlButton from './ControlButton'; @@ -63,7 +63,7 @@ export function MowerMap({mapData, saveMapToMower, sx}: MowerMapProps) { const isDocked = useSelectedMower((s) => s?.state.is_charging ?? false); const mowerPosition = useSelectedMower((s) => s?.position ?? s?.state.pose); const simAvailable = useSelectedMower((s) => s?.simState != null); - const [manualDrive, setManualDrive] = useState(false); + const manualDrive = useSelectedMower((s) => s?.simState?.joy_override ?? false); const showTeleop = (currentState === 'AREA_RECORDING' || manualDrive) && !editMode; const areas = useMemo( () => features.features.filter((feature) => feature.geometry.type === 'Polygon') as Feature[], @@ -249,7 +249,7 @@ export function MowerMap({mapData, saveMapToMower, sx}: MowerMapProps) { onClick={() => fitToBounds(false, padding)} /> - {simAvailable && } + {simAvailable && } void; -} const STEPS = [0.25, 0.5, 1, 2]; @@ -204,7 +200,7 @@ function BatterySlider({ const rowSx = {mx: 0, px: 1, py: 0.25, width: '100%', justifyContent: 'space-between'} as const; -export default function SimulatorButton({manualDrive, onManualDriveChange}: SimulatorButtonProps) { +export default function SimulatorButton() { const {container} = useRControl({position: 'bottom-right'}); const buttonRef = useRef(null); const [open, setOpen] = useState(false); @@ -212,7 +208,7 @@ export default function SimulatorButton({manualDrive, onManualDriveChange}: Simu const theme = useTheme(); - const {simState, available, setEmergency, setMovementAllowed, setBatteryVoltage, setGpsGood, moveToDock, displace} = + const {simState, available, setEmergency, setMovementAllowed, setBatteryVoltage, setGpsGood, setJoyOverride, moveToDock, displace} = useSimControl(); const params = useSelectedMower((m) => m?.params ?? {}); @@ -223,7 +219,7 @@ export default function SimulatorButton({manualDrive, onManualDriveChange}: Simu const batteryPct = useSelectedMower((m) => m?.state.battery_percentage ?? null); const hasActivity = - manualDrive || simState?.emergency_latch || simState?.gps_good === false || simState?.movement_allowed === false; + simState?.joy_override || simState?.emergency_latch || simState?.gps_good === false || simState?.movement_allowed === false; const content = ( <> @@ -271,7 +267,7 @@ export default function SimulatorButton({manualDrive, onManualDriveChange}: Simu onManualDriveChange(e.target.checked)} />} + control={ setJoyOverride(e.target.checked)} />} label="Manual drive" /> diff --git a/src/hooks/useSimControl.ts b/src/hooks/useSimControl.ts index a0c5749..67965e0 100644 --- a/src/hooks/useSimControl.ts +++ b/src/hooks/useSimControl.ts @@ -5,7 +5,7 @@ import {useSelectedMower} from '@/stores/mowersStore'; import type {SimState} from '@/stores/schemas'; import {useCallback, useState} from 'react'; -export type SimAction = 'emergency' | 'movement' | 'battery' | 'gps' | 'dock' | 'displace'; +export type SimAction = 'emergency' | 'movement' | 'battery' | 'gps' | 'dock' | 'displace' | 'joy_override'; export interface SimControl { /** null while no simulator has been detected (no retained sim/state/json message). */ @@ -18,6 +18,7 @@ export interface SimControl { setMovementAllowed: (allowed: boolean) => void; setBatteryVoltage: (voltage: number) => void; setGpsGood: (good: boolean) => void; + setJoyOverride: (enabled: boolean) => void; moveToDock: () => void; displace: (dx: number, dy: number, dheading?: number) => void; } @@ -55,6 +56,7 @@ export function useSimControl(): SimControl { setMovementAllowed: (allowed) => run('movement', (r) => r.sim.movement.set({allowed})), setBatteryVoltage: (voltage) => run('battery', (r) => r.sim.battery.set({voltage})), setGpsGood: (good) => run('gps', (r) => r.sim.gps.set({good})), + setJoyOverride: (enabled) => run('joy_override', (r) => r.sim.joy_override.set({enabled})), moveToDock: () => run('dock', (r) => r.sim.dock.move()), displace: (dx, dy, dheading = 0) => run('displace', (r) => r.sim.displace({dx, dy, dheading})), }; diff --git a/src/lib/rpc.ts b/src/lib/rpc.ts index fb4fdb7..a333883 100644 --- a/src/lib/rpc.ts +++ b/src/lib/rpc.ts @@ -65,7 +65,7 @@ export interface ObjectHicl3T4F { [key: string]: any; } * Generated! Represents an alias to any of the provided schemas * */ -export type AnyOfObjectHAgrRKSzStringDoaGddGAStringDoaGddGABooleanVyG3AEThBooleanVyG3AEThNumberHo1ClIqDBooleanVyG3AEThNumberHo1ClIqDNumberHo1ClIqDNumberHo1ClIqDStringZDJW5SIjUnorderedSetOfStringDoaGddGADvj0XlFaNullQu0Arl1FStringZDJW5SIjStringDoaGddGAObjectOfUnorderedSetOfObjectOfUnorderedSetOfUnorderedSetOfNumberHo1ClIqDAokMKuEfbuExLS5GObjectOfStringDoaGddGAStringDoaGddGABooleanVyG3AETh8PZSKeKMFruGLaE4Gkga5QR4UnorderedSetOfUnorderedSetOfNumberHo1ClIqDAokMKuEfbuExLS5GA4JKc1WsUnorderedSetOfObjectOfNumberHo1ClIqDStringDoaGddGAJFf7IL19E3WiTXvKUnorderedSetOfObjectOfNumberHo1ClIqDNumberHo1ClIqDStringDoaGddGANumberHo1ClIqDStringDoaGddGAStringDoaGddGAStringDoaGddGABooleanVyG3AEThEg3PskfBvWnyETupUnorderedSetOfStringDoaGddGADvj0XlFaObjectHicl3T4FNullQu0Arl1FNullQu0Arl1FNullQu0Arl1FNullQu0Arl1FNullQu0Arl1FNullQu0Arl1F = ObjectHAgrRKSz | StringDoaGddGA | BooleanVyG3AETh | NumberHo1ClIqD | StringZDJW5SIj | UnorderedSetOfStringDoaGddGADvj0XlFa | NullQu0Arl1F | ObjectOfUnorderedSetOfObjectOfUnorderedSetOfUnorderedSetOfNumberHo1ClIqDAokMKuEfbuExLS5GObjectOfStringDoaGddGAStringDoaGddGABooleanVyG3AETh8PZSKeKMFruGLaE4Gkga5QR4UnorderedSetOfUnorderedSetOfNumberHo1ClIqDAokMKuEfbuExLS5GA4JKc1Ws | UnorderedSetOfObjectOfNumberHo1ClIqDStringDoaGddGAJFf7IL19E3WiTXvK | UnorderedSetOfObjectOfNumberHo1ClIqDNumberHo1ClIqDStringDoaGddGANumberHo1ClIqDStringDoaGddGAStringDoaGddGAStringDoaGddGABooleanVyG3AEThEg3PskfBvWnyETup | ObjectHicl3T4F; +export type AnyOfObjectHAgrRKSzStringDoaGddGAStringDoaGddGABooleanVyG3AEThBooleanVyG3AEThNumberHo1ClIqDBooleanVyG3AEThBooleanVyG3AEThNumberHo1ClIqDNumberHo1ClIqDNumberHo1ClIqDStringZDJW5SIjUnorderedSetOfStringDoaGddGADvj0XlFaNullQu0Arl1FStringZDJW5SIjStringDoaGddGAObjectOfUnorderedSetOfObjectOfUnorderedSetOfUnorderedSetOfNumberHo1ClIqDAokMKuEfbuExLS5GObjectOfStringDoaGddGAStringDoaGddGABooleanVyG3AETh8PZSKeKMFruGLaE4Gkga5QR4UnorderedSetOfUnorderedSetOfNumberHo1ClIqDAokMKuEfbuExLS5GA4JKc1WsUnorderedSetOfObjectOfNumberHo1ClIqDStringDoaGddGAJFf7IL19E3WiTXvKUnorderedSetOfObjectOfNumberHo1ClIqDNumberHo1ClIqDStringDoaGddGANumberHo1ClIqDStringDoaGddGAStringDoaGddGAStringDoaGddGABooleanVyG3AEThEg3PskfBvWnyETupUnorderedSetOfStringDoaGddGADvj0XlFaObjectHicl3T4FNullQu0Arl1FNullQu0Arl1FNullQu0Arl1FNullQu0Arl1FNullQu0Arl1FNullQu0Arl1FNullQu0Arl1F = ObjectHAgrRKSz | StringDoaGddGA | BooleanVyG3AETh | NumberHo1ClIqD | StringZDJW5SIj | UnorderedSetOfStringDoaGddGADvj0XlFa | NullQu0Arl1F | ObjectOfUnorderedSetOfObjectOfUnorderedSetOfUnorderedSetOfNumberHo1ClIqDAokMKuEfbuExLS5GObjectOfStringDoaGddGAStringDoaGddGABooleanVyG3AETh8PZSKeKMFruGLaE4Gkga5QR4UnorderedSetOfUnorderedSetOfNumberHo1ClIqDAokMKuEfbuExLS5GA4JKc1Ws | UnorderedSetOfObjectOfNumberHo1ClIqDStringDoaGddGAJFf7IL19E3WiTXvK | UnorderedSetOfObjectOfNumberHo1ClIqDNumberHo1ClIqDStringDoaGddGANumberHo1ClIqDStringDoaGddGAStringDoaGddGAStringDoaGddGABooleanVyG3AEThEg3PskfBvWnyETup | ObjectHicl3T4F; export class OpenMowerRpc extends OpenMowerBaseRpc { rpc = { @@ -149,6 +149,12 @@ export class OpenMowerRpc extends OpenMowerBaseRpc { */ set: async (args: {good: BooleanVyG3AETh}): Promise => this.call('sim.gps.set', args), }, + joy_override: { + /** + * Enable or disable joystick (manual drive) override in the simulator. + */ + set: async (args: {enabled: BooleanVyG3AETh}): Promise => this.call('sim.joy_override.set', args), + }, dock: { /** * Drive the simulated robot onto the dock and start charging. diff --git a/src/stores/schemas.ts b/src/stores/schemas.ts index a974a86..812e51c 100644 --- a/src/stores/schemas.ts +++ b/src/stores/schemas.ts @@ -60,6 +60,7 @@ export const simStateSchema = z.object({ gps_good: z.boolean(), battery_voltage: z.number(), charging: z.boolean(), + joy_override: z.boolean(), }); export type SimState = z.infer; From 1aa606da7e5eb22ce683d14599d50dc8e30b432b Mon Sep 17 00:00:00 2001 From: Robert Vollmer Date: Mon, 6 Jul 2026 22:04:09 +0000 Subject: [PATCH 09/10] Revert useTeleop to simpler implementation --- src/components/map/MowerMap.tsx | 2 +- src/components/map/teleop/TeleopControls.tsx | 5 +-- src/hooks/useTeleop.ts | 42 +++++++------------- 3 files changed, 17 insertions(+), 32 deletions(-) diff --git a/src/components/map/MowerMap.tsx b/src/components/map/MowerMap.tsx index c07c4af..1e46c6a 100644 --- a/src/components/map/MowerMap.tsx +++ b/src/components/map/MowerMap.tsx @@ -299,7 +299,7 @@ export function MowerMap({mapData, saveMapToMower, sx}: MowerMapProps) { ))} {mowerPosition && !isDocked && } - {showTeleop && } + {showTeleop && } diff --git a/src/components/map/teleop/TeleopControls.tsx b/src/components/map/teleop/TeleopControls.tsx index 9e15027..56008d9 100644 --- a/src/components/map/teleop/TeleopControls.tsx +++ b/src/components/map/teleop/TeleopControls.tsx @@ -5,12 +5,11 @@ import {Box, useMediaQuery, useTheme} from '@mui/material'; import VirtualJoystick from './VirtualJoystick'; interface TeleopControlsProps { - publishAtRest?: boolean; simulatorMode?: boolean; } -export default function TeleopControls({publishAtRest = false, simulatorMode = false}: TeleopControlsProps) { - const {setVelocity} = useTeleop({publishAtRest}); +export default function TeleopControls({simulatorMode = false}: TeleopControlsProps) { + const {setVelocity} = useTeleop(); const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down('md')); diff --git a/src/hooks/useTeleop.ts b/src/hooks/useTeleop.ts index 4967a43..caf19d8 100644 --- a/src/hooks/useTeleop.ts +++ b/src/hooks/useTeleop.ts @@ -3,15 +3,7 @@ import {useCallback, useEffect, useRef} from 'react'; const PUBLISH_INTERVAL_MS = 100; -interface UseTeleopOptions { - /** - * When true, starts publishing immediately (at 0,0) and keeps publishing - * even when the stick is centred. When false, only publishes while moving. - */ - publishAtRest?: boolean; -} - -export function useTeleop({publishAtRest = false}: UseTeleopOptions = {}) { +export function useTeleop() { const vel = useRef({vx: 0, vz: 0}); const interval = useRef | null>(null); @@ -20,36 +12,30 @@ export function useTeleop({publishAtRest = false}: UseTeleopOptions = {}) { mowers[selected]?.publishTeleop(vel.current.vx, vel.current.vz); }, []); - const updatePublishInterval = useCallback( - (override?: boolean) => { - const shouldPublish = override ?? (publishAtRest || vel.current.vx !== 0 || vel.current.vz !== 0); - const isPublishing = interval.current !== null; - if (shouldPublish && !isPublishing) { + const setVelocity = useCallback( + (vx: number, vz: number) => { + vel.current = {vx: Math.max(-1, Math.min(1, vx)), vz: Math.max(-1, Math.min(1, vz))}; + + const moving = vx !== 0 || vz !== 0; + const wasMoving = interval.current !== null; + + if (moving && !wasMoving) { publish(); interval.current = setInterval(publish, PUBLISH_INTERVAL_MS); - } else if (!shouldPublish && isPublishing) { + } else if (!moving && wasMoving) { clearInterval(interval.current!); interval.current = null; publish(); } }, - [publishAtRest, publish], - ); - - const setVelocity = useCallback( - (vx: number, vz: number) => { - vel.current = {vx: Math.max(-1, Math.min(1, vx)), vz: Math.max(-1, Math.min(1, vz))}; - updatePublishInterval(); - }, - [updatePublishInterval], + [publish], ); useEffect(() => { - if (publishAtRest) { - updatePublishInterval(true); - } return () => { - updatePublishInterval(false); + if (interval.current !== null) clearInterval(interval.current); + vel.current = {vx: 0, vz: 0}; + publish(); }; }, []); From f0ccec02f6921e366f1a39df15bd4cf60beb25fd Mon Sep 17 00:00:00 2001 From: Robert Vollmer Date: Tue, 7 Jul 2026 23:37:19 +0000 Subject: [PATCH 10/10] Remove obsolete files --- src/components/simulation/BatteryGauge.tsx | 258 ------------------- src/components/simulation/DisplaceCard.tsx | 122 --------- src/components/simulation/DockCard.tsx | 58 ----- src/components/simulation/EmergencyCard.tsx | 92 ------- src/components/simulation/ToggleCard.tsx | 110 -------- src/components/simulation/emergencyReason.ts | 17 -- 6 files changed, 657 deletions(-) delete mode 100644 src/components/simulation/BatteryGauge.tsx delete mode 100644 src/components/simulation/DisplaceCard.tsx delete mode 100644 src/components/simulation/DockCard.tsx delete mode 100644 src/components/simulation/EmergencyCard.tsx delete mode 100644 src/components/simulation/ToggleCard.tsx delete mode 100644 src/components/simulation/emergencyReason.ts diff --git a/src/components/simulation/BatteryGauge.tsx b/src/components/simulation/BatteryGauge.tsx deleted file mode 100644 index 22b9738..0000000 --- a/src/components/simulation/BatteryGauge.tsx +++ /dev/null @@ -1,258 +0,0 @@ -'use client'; - -import { - BatteryChargingFull as ChargingIcon, - BatteryFull as FullIcon, - BatteryAlert as EmptyIcon, - WarningAmber as CritIcon, - Tune as CustomIcon, -} from '@mui/icons-material'; -import { - Box, - Button, - Chip, - Dialog, - DialogActions, - DialogContent, - DialogTitle, - InputAdornment, - Slider, - TextField, - Tooltip, - Typography, - useTheme, -} from '@mui/material'; -import type {Theme} from '@mui/material/styles'; -import {useState} from 'react'; -import {useSelectedMower} from '@/stores/mowersStore'; - -const VOLTAGE_MARGIN = 2; - -interface BatteryGaugeProps { - voltage: number; - charging: boolean; - pending: boolean; - onSetVoltage: (voltage: number) => void; -} - -function levelColor(pct: number, theme: Theme): string { - if (pct <= 0.15) return theme.palette.error.main; - if (pct <= 0.35) return theme.palette.warning.main; - return theme.palette.success.main; -} - -export default function BatteryGauge({ - voltage, - charging, - pending, - onSetVoltage, -}: BatteryGaugeProps) { - const params = useSelectedMower((m) => m?.params ?? {}); - const percentage = useSelectedMower((m) => (m?.state.battery_percentage ?? 0) / 100); - const fullVoltage = params['/ll/services/power/battery_full_voltage']; - const emptyVoltage = params['/ll/services/power/battery_empty_voltage']; - const critLowVoltage = params['/ll/services/power/battery_critical_voltage']; - const critHighVoltage = params['/ll/services/power/battery_critical_high_voltage']; - const minVoltage = critLowVoltage !== undefined ? critLowVoltage - VOLTAGE_MARGIN : undefined; - const maxVoltage = critHighVoltage !== undefined ? critHighVoltage + VOLTAGE_MARGIN : undefined; - const theme = useTheme(); - const pct = Math.max(0, Math.min(1, percentage)); - const color = charging ? theme.palette.info.main : levelColor(pct, theme); - - const [dialogOpen, setDialogOpen] = useState(false); - const [voltageInput, setVoltageInput] = useState(voltage.toFixed(2)); - - const openDialog = () => { - setVoltageInput(voltage.toFixed(2)); - setDialogOpen(true); - }; - - const parsed = parseFloat(voltageInput); - const valid = Number.isFinite(parsed); - - const submit = () => { - if (!valid || minVoltage === undefined || maxVoltage === undefined) return; - const clamped = Math.min(maxVoltage, Math.max(minVoltage, parsed)); - onSetVoltage(clamped); - setDialogOpen(false); - }; - - // Battery body geometry (viewBox units). - const bodyX = 4; - const bodyY = 10; - const bodyW = 150; - const bodyH = 70; - const pad = 6; - const fillMax = bodyW - pad * 2; - const fillW = fillMax * pct; - - return ( - - - - Battery - - {charging && ( - } label="Charging" sx={{fontWeight: 600}} /> - )} - - - - - - - - {/* Battery shell */} - - {/* Positive terminal */} - - {/* Fill */} - 0 ? 4 : 0)} - height={bodyH - pad * 2} - rx={5} - fill={color} - style={{transition: 'width 0.5s ease, fill 0.3s ease'}} - /> - - {charging && ( - - )} - - - - - Tap to set voltage - - - - - - {Math.round(pct * 100)}% - - - {voltage.toFixed(2)} V - - - - - {critLowVoltage !== undefined && ( - - )} - {emptyVoltage !== undefined && ( - - )} - {fullVoltage !== undefined && ( - - )} - {critHighVoltage !== undefined && ( - - )} - - - - setDialogOpen(false)} maxWidth="xs" fullWidth> - Set battery voltage - - setVoltageInput(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && submit()} - error={voltageInput !== '' && !valid} - slotProps={{ - input: {endAdornment: V}, - htmlInput: {inputMode: 'decimal', pattern: '[0-9]*[.,]?[0-9]*'}, - }} - sx={{mt: 1}} - /> - {minVoltage !== undefined && maxVoltage !== undefined && ( - - setVoltageInput((v as number).toFixed(1))} - /> - - )} - - - - - - - - ); -} diff --git a/src/components/simulation/DisplaceCard.tsx b/src/components/simulation/DisplaceCard.tsx deleted file mode 100644 index b481779..0000000 --- a/src/components/simulation/DisplaceCard.tsx +++ /dev/null @@ -1,122 +0,0 @@ -'use client'; - -import { - KeyboardArrowDown as DownIcon, - KeyboardArrowLeft as LeftIcon, - KeyboardArrowRight as RightIcon, - KeyboardArrowUp as UpIcon, - MyLocation as JumpIcon, -} from '@mui/icons-material'; -import {Box, CircularProgress, IconButton, ToggleButton, ToggleButtonGroup, Typography, useTheme} from '@mui/material'; -import {useState} from 'react'; - -interface DisplaceCardProps { - pending: boolean; - /** dx, dy in meters (map frame). */ - onDisplace: (dx: number, dy: number) => void; -} - -const STEPS = [0.25, 0.5, 1, 2]; - -export default function DisplaceCard({pending, onDisplace}: DisplaceCardProps) { - const theme = useTheme(); - const [step, setStep] = useState(0.5); - - const PadButton = ({dx, dy, children}: {dx: number; dy: number; children: React.ReactNode}) => ( - onDisplace(dx * step, dy * step)} - sx={{ - border: `1px solid ${theme.palette.divider}`, - borderRadius: 1.5, - bgcolor: 'action.hover', - }} - > - {children} - - ); - - return ( - - - - - - - - GPS jump - - - Teleport the robot to simulate a GPS jump - - - {pending && } - - - - {/* Directional pad: +Y = up (map north). */} - - - - - - - - - - - - - - - - - - - - - - - - - - Step size - - v != null && setStep(v)} - disabled={pending} - > - {STEPS.map((s) => ( - - {s} m - - ))} - - - - - ); -} diff --git a/src/components/simulation/DockCard.tsx b/src/components/simulation/DockCard.tsx deleted file mode 100644 index 6c5b3fd..0000000 --- a/src/components/simulation/DockCard.tsx +++ /dev/null @@ -1,58 +0,0 @@ -'use client'; - -import {EvStation as DockIcon} from '@mui/icons-material'; -import {Box, Button, Chip, CircularProgress, Typography, useTheme} from '@mui/material'; - -interface DockCardProps { - charging: boolean; - pending: boolean; - onMoveToDock: () => void; -} - -export default function DockCard({charging, pending, onMoveToDock}: DockCardProps) { - const theme = useTheme(); - const color = charging ? theme.palette.success.main : theme.palette.info.main; - - return ( - - - - - - - - Dock - - - {charging && } - {pending && } - - - - - Teleports the robot onto the docking pose and starts charging. - - - ); -} diff --git a/src/components/simulation/EmergencyCard.tsx b/src/components/simulation/EmergencyCard.tsx deleted file mode 100644 index 6f71e86..0000000 --- a/src/components/simulation/EmergencyCard.tsx +++ /dev/null @@ -1,92 +0,0 @@ -'use client'; - -import {outerCardStyles} from '@/lib/cardStyles'; -import { - CheckCircle as OkIcon, - ReportProblem as EmergencyIcon, - RestartAlt as ClearIcon, -} from '@mui/icons-material'; -import {Box, Button, Card, CardContent, Chip, CircularProgress, Typography, useTheme} from '@mui/material'; -import {decodeEmergencyReasons} from './emergencyReason'; - -interface EmergencyCardProps { - latched: boolean; - reason: number; - pending: boolean; - onSet: (active: boolean) => void; -} - -export default function EmergencyCard({latched, reason, pending, onSet}: EmergencyCardProps) { - const theme = useTheme(); - const reasons = decodeEmergencyReasons(reason); - - return ( - - - - - {latched ? : } - - - - Emergency - - - {latched ? 'Latched — robot stopped' : 'Clear — normal operation'} - - - - - - {latched && reasons.length > 0 && ( - - {reasons.map((r) => ( - - ))} - - )} - - - ); -} diff --git a/src/components/simulation/ToggleCard.tsx b/src/components/simulation/ToggleCard.tsx deleted file mode 100644 index 6335a22..0000000 --- a/src/components/simulation/ToggleCard.tsx +++ /dev/null @@ -1,110 +0,0 @@ -'use client'; - -import {Box, CircularProgress, Typography, useTheme} from '@mui/material'; -import type {ReactNode} from 'react'; - -type ToggleColor = 'success' | 'error' | 'warning' | 'info'; - -interface ToggleOption { - label: string; - sub?: string; - color: ToggleColor; -} - -interface ToggleCardProps { - icon: ReactNode; - title: string; - /** Optional explanatory line shown under the title. */ - description?: string; - value: boolean; - pending: boolean; - onChange: (value: boolean) => void; - /** Option shown/selected when value === true. */ - trueOption: ToggleOption; - /** Option shown/selected when value === false. */ - falseOption: ToggleOption; -} - -export default function ToggleCard({icon, title, description, value, pending, onChange, trueOption, falseOption}: ToggleCardProps) { - const theme = useTheme(); - const active = value ? trueOption : falseOption; - const activeColor = theme.palette[active.color].main; - - const Segment = ({option, selected, target}: {option: ToggleOption; selected: boolean; target: boolean}) => ( - !pending && !selected && onChange(target)} - sx={{ - flex: 1, - textAlign: 'center', - py: 1, - px: 1.5, - borderRadius: 1.5, - cursor: pending || selected ? 'default' : 'pointer', - userSelect: 'none', - transition: 'all 0.2s ease', - bgcolor: selected ? theme.palette[option.color].main : 'transparent', - color: selected ? theme.palette[option.color].contrastText : theme.palette.text.secondary, - fontWeight: selected ? 700 : 500, - '&:hover': pending || selected ? {} : {bgcolor: theme.palette.action.hover}, - }} - > - - {option.label} - - {option.sub && ( - - {option.sub} - - )} - - ); - - return ( - - - - {icon} - - - - {title} - - {description && ( - - {description} - - )} - - {pending && } - - - - - - - - ); -} diff --git a/src/components/simulation/emergencyReason.ts b/src/components/simulation/emergencyReason.ts deleted file mode 100644 index d71ec58..0000000 --- a/src/components/simulation/emergencyReason.ts +++ /dev/null @@ -1,17 +0,0 @@ -// Decode the `emergency_reason` bitfield from the sim state into human labels. -// Mirrors the table in SIM_CONTROL_API.md. -const EMERGENCY_FLAGS: {bit: number; label: string}[] = [ - {bit: 1, label: 'Latch'}, - {bit: 2, label: 'Input timeout'}, - {bit: 4, label: 'Stop'}, - {bit: 8, label: 'Lift'}, - {bit: 16, label: 'Multi-lift'}, - {bit: 32, label: 'Collision'}, - {bit: 64, label: 'High-level timeout'}, - {bit: 128, label: 'High level'}, - {bit: 256, label: 'Service not ready'}, -]; - -export function decodeEmergencyReasons(mask: number): string[] { - return EMERGENCY_FLAGS.filter(({bit}) => (mask & bit) !== 0).map(({label}) => label); -}