diff --git a/openrpc.json b/openrpc.json index b61e5b3..6cacc37 100644 --- a/openrpc.json +++ b/openrpc.json @@ -261,6 +261,166 @@ "description": "Keys are relative file paths, values are YAML file contents as strings" } } + }, + { + "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": "result", + "description": "No result is returned if successful.", + "schema": { + "type": "null" + } + } + }, + { + "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": "result", + "description": "No result is returned if successful.", + "schema": { + "type": "null" + } + } + }, + { + "name": "sim.battery.set", + "summary": "Set the simulated battery pack voltage.", + "paramStructure": "by-name", + "params": [ + { + "name": "voltage", + "description": "Exact pack voltage to set (V).", + "required": true, + "schema": { + "type": "number" + } + } + ], + "result": { + "name": "result", + "description": "No result is returned if successful.", + "schema": { + "type": "null" + } + } + }, + { + "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": "result", + "description": "No result is returned if successful.", + "schema": { + "type": "null" + } + } + }, + { + "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.", + "params": [], + "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.", + "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": "result", + "description": "No result is returned if successful.", + "schema": { + "type": "null" + } + } } ] } diff --git a/src/components/map/MowerMap.tsx b/src/components/map/MowerMap.tsx index 5197b33..1e46c6a 100644 --- a/src/components/map/MowerMap.tsx +++ b/src/components/map/MowerMap.tsx @@ -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 = 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[], [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..2f97c11 --- /dev/null +++ b/src/components/map/SimulatorButton.tsx @@ -0,0 +1,422 @@ +'use client'; + +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 {useCallback, useRef, useState} from 'react'; +import {createPortal} from 'react-dom'; + + +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() { + 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, setJoyOverride, 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 = + simState?.joy_override || simState?.emergency_latch || simState?.gps_good === false || simState?.movement_allowed === false; + + const content = ( + <> + + + setOpen(false)} + anchorOrigin={{vertical: 'bottom', horizontal: 'left'}} + transformOrigin={{vertical: 'bottom', horizontal: 'right'}} + slotProps={{paper: {sx: {width: 460, py: 1}}}} + > + + Simulator + + + {available && simState ? ( + <> + + {/* Left column */} + + {/* Boolean toggles via Switch */} + setJoyOverride(e.target.checked)} />} + label="Manual drive" + /> + + setGpsGood(e.target.checked)} />} + /> + setMovementAllowed(e.target.checked)} /> + } + /> + + + + {/* 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… + + + )} + + + ); + + return createPortal(content, container); +} diff --git a/src/components/map/teleop/TeleopControls.tsx b/src/components/map/teleop/TeleopControls.tsx index c71c1ae..56008d9 100644 --- a/src/components/map/teleop/TeleopControls.tsx +++ b/src/components/map/teleop/TeleopControls.tsx @@ -4,7 +4,11 @@ import {useTeleop} from '@/hooks/useTeleop'; import {Box, useMediaQuery, useTheme} from '@mui/material'; import VirtualJoystick from './VirtualJoystick'; -export default function TeleopControls() { +interface TeleopControlsProps { + simulatorMode?: boolean; +} + +export default function TeleopControls({simulatorMode = false}: TeleopControlsProps) { const {setVelocity} = useTeleop(); const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down('md')); @@ -19,7 +23,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/hooks/useSimControl.ts b/src/hooks/useSimControl.ts new file mode 100644 index 0000000..67965e0 --- /dev/null +++ b/src/hooks/useSimControl.ts @@ -0,0 +1,63 @@ +'use client'; + +import type {OpenMowerRpc} from '@/lib/rpc'; +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' | 'joy_override'; + +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; + setBatteryVoltage: (voltage: number) => void; + setGpsGood: (good: boolean) => void; + setJoyOverride: (enabled: boolean) => void; + moveToDock: () => 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 [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 { + await fn(rpc); + } catch (e) { + setError(e instanceof Error ? e.message : 'RPC failed'); + } finally { + setPending(null); + } + }, + [rpc, mowerId], + ); + + 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})), + 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 323cab1..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 AnyOfObjectHAgrRKSzStringDoaGddGAStringDoaGddGAStringZDJW5SIjUnorderedSetOfStringDoaGddGADvj0XlFaNullQu0Arl1FStringZDJW5SIjStringDoaGddGAObjectOfUnorderedSetOfObjectOfUnorderedSetOfUnorderedSetOfNumberHo1ClIqDAokMKuEfbuExLS5GObjectOfStringDoaGddGAStringDoaGddGABooleanVyG3AETh8PZSKeKMFruGLaE4Gkga5QR4UnorderedSetOfUnorderedSetOfNumberHo1ClIqDAokMKuEfbuExLS5GA4JKc1WsUnorderedSetOfObjectOfNumberHo1ClIqDStringDoaGddGAJFf7IL19E3WiTXvKUnorderedSetOfObjectOfNumberHo1ClIqDNumberHo1ClIqDStringDoaGddGANumberHo1ClIqDStringDoaGddGAStringDoaGddGAStringDoaGddGABooleanVyG3AEThEg3PskfBvWnyETupUnorderedSetOfStringDoaGddGADvj0XlFaObjectHicl3T4F = ObjectHAgrRKSz | StringDoaGddGA | 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 = { @@ -124,4 +124,46 @@ export class OpenMowerRpc extends OpenMowerBaseRpc { list: async (): Promise => this.call('events.history.list'), }), }; + sim = { + 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 pack voltage. + */ + 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), + }, + 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. + */ + move: async (): Promise => this.call('sim.dock.move'), + }, + /** + * 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..ebc08a0 100644 --- a/src/stores/mowersStore.ts +++ b/src/stores/mowersStore.ts @@ -28,12 +28,16 @@ import { mapDefaults, mapSchema, positionSchema, + rosParamsSchema, + simStateSchema, stateDefaults, stateSchema, type Capabilities, type Datum, type MapData, type PositionWithAttributes, + type RosParams, + type SimState, type StateOptionalPose, type TrackAttributes, } from './schemas'; @@ -53,11 +57,14 @@ 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; 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; @@ -161,6 +168,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) => { @@ -239,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') { @@ -251,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())); @@ -264,6 +266,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; + } + }); } } }); diff --git a/src/stores/schemas.ts b/src/stores/schemas.ts index aa43abf..812e51c 100644 --- a/src/stores/schemas.ts +++ b/src/stores/schemas.ts @@ -48,6 +48,23 @@ 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_voltage: z.number(), + charging: z.boolean(), + joy_override: z.boolean(), +}); + +export type SimState = z.infer; + //////////////////////////////////////////////////////////////////////////////////////////////////// // Map //////////////////////////////////////////////////////////////////////////////////////////////////// @@ -201,3 +218,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;