Skip to content

Add Simulation tab for controlling the simulated mower - #7

Merged
rovo89 merged 10 commits into
mainfrom
feat/simulation-tab
Jul 7, 2026
Merged

Add Simulation tab for controlling the simulated mower#7
rovo89 merged 10 commits into
mainfrom
feat/simulation-tab

Conversation

@ClemensElflein

@ClemensElflein ClemensElflein commented Jul 2, 2026

Copy link
Copy Markdown
Member

Summary

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, so it stays hidden on real-hardware production deployments.

Changes

  • openrpc.jsonsim.* methods (state.get, emergency/movement/battery/gps.set, dock.move, twist.set, displace) + SimState schema; regenerated rpc.ts. sim.battery.set accepts full or an exact volts.
  • mowersStore — subscribe sim/state/json, parse into Mower.simState (also the feature-detect flag), updateSimState` action.
  • Nav gatingcreateNavigationItems(capabilities, simAvailable); simAvailable = selected mower has simState. Wired from Sidebar + MobileBottomBar.
  • useSimControl hook — wraps the RPCs with optimistic state updates and per-action pending/error tracking.
  • Custom widgets:
  • EmergencyCard — latch state + decoded reason chips, Trigger/Clear.
  • BatteryGauge — SVG gauge, Crit low / Empty / Full / Crit high presets, click battery to enter an exact voltage.
  • ToggleCard — traction (stuck simulation) and GPS quality.
  • TwistCard — movement override via the existing VirtualJoystick (throttled RPCs).
  • DockCard, DisplaceCard.

Notes

  • Requires the matching ROS-side change to sim.battery.set (honor volts) — handled separately.
  • Direct navigation to /simulation on a non-sim connection shows a "Simulator not detected" empty state.

Summary by CodeRabbit

  • New Features

    • Added a simulator control panel with buttons and sliders for emergency, movement, GPS, joystick override, battery, docking, and position adjustment.
    • Added a new battery gauge with charging status and voltage presets.
    • Teleoperation now appears in more simulator/manual-drive scenarios and shows a simulator-specific visual mode.
  • Bug Fixes

    • Improved handling of simulator and mower state so controls only appear when supported.
    • Added safer validation for incoming mower parameters and simulation status.

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.
@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 802fbed8-e4b1-4d6e-852e-d08afb87d4c4

📥 Commits

Reviewing files that changed from the base of the PR and between da9879b and 1aa606d.

📒 Files selected for processing (7)
  • openrpc.json
  • src/components/map/MowerMap.tsx
  • src/components/map/SimulatorButton.tsx
  • src/components/map/teleop/TeleopControls.tsx
  • src/hooks/useSimControl.ts
  • src/lib/rpc.ts
  • src/stores/schemas.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • src/hooks/useSimControl.ts
  • src/lib/rpc.ts
  • openrpc.json
  • src/stores/schemas.ts

📝 Walkthrough

Walkthrough

This PR adds simulator control capabilities: new sim.* OpenRPC methods and generated RPC client bindings, Zod schemas for simulator state and ROS parameters, store wiring to subscribe/parse simulator state, a useSimControl hook extended with joy_override, and new UI components (SimulatorButton, BatteryGauge) plus map/teleop wiring for simulator mode.

Changes

Simulator control flow

Layer / File(s) Summary
Schemas and RPC contracts
openrpc.json, src/lib/rpc.ts, src/stores/schemas.ts
Adds sim.emergency.set, sim.movement.set, sim.battery.set, sim.gps.set, sim.joy_override.set, sim.dock.move, sim.displace OpenRPC methods and generated sim RPC namespace; adds simStateSchema/SimState and rosParamsSchema/RosParams Zod schemas.
Store parsing and simulator state
src/stores/mowersStore.ts
Subscribes to sim/state/json, parses params/json via rosParamsSchema, and safe-parses simulator state into a new nullable simState field.
Simulator control hook
src/hooks/useSimControl.ts
Extends SimAction/SimControl with joy_override/setJoyOverride, wiring it and other actions to sim RPC calls via a centralized run helper.
Teleop controls and joystick styling
src/components/map/teleop/TeleopControls.tsx, src/components/map/teleop/VirtualJoystick.tsx
Adds a simulatorMode prop threaded through teleop controls into the joystick, changing its visual theme when enabled.
Map simulator entry points
src/components/map/MowerMap.tsx
Derives simAvailable/manualDrive from mower simState, conditionally renders SimulatorButton, and expands teleop visibility/props.
Simulator controls and battery gauge
src/components/map/SimulatorButton.tsx, src/components/simulation/BatteryGauge.tsx
Adds a portal-mounted simulator popover with toggles, battery slider, GPS jump controls, emergency/dock actions, and a standalone battery gauge component with presets and a voltage dialog.

Estimated code review effort: 3 (Moderate) | ~30 minutes

Poem

A rabbit hops through wires anew,
Sim switches, gauges, voltage too,
Joysticks glow a crimson hue,
Docking calls and displacements true,
Hop hop hooray — the sim's in view! 🐇🔋

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding a Simulation tab for controlling the simulated mower.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/simulation-tab

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

src/components/map/MowerMap.tsx

Oops! Something went wrong! :(

ESLint: 9.39.4

TypeError: Converting circular structure to JSON
--> starting at object with constructor 'Object'
| property 'configs' -> object with constructor 'Object'
| property 'flat' -> object with constructor 'Object'
| ...
| property 'plugins' -> object with constructor 'Object'
--- property 'react' closes the circle
Referenced from:
at JSON.stringify ()
at file:///node_modules/@eslint/eslintrc/lib/shared/config-validator.js:308:45
at Array.map ()
at ConfigValidator.formatErrors (file:///node_modules/@eslint/eslintrc/lib/shared/config-validator.js:299:23)
at ConfigValidator.validateConfigSchema (file:///node_modules/@eslint/eslintrc/lib/shared/config-validator.js:330:84)
at ConfigArrayFactory._normalizeConfigData (file:///node_modules/@eslint/eslintrc/lib/config-array-factory.js:676:19)
at ConfigArrayFactory._loadConfigData (file:///node_modules/@eslint/eslintrc/lib/config-array-factory.js:641:21)
at ConfigArrayFactory._loadExtendedShareableConfig (file:///node_modules/@eslint/eslintrc/lib/config-array-factory.js:946:21)
at ConfigArrayFactory._loadExtends (file:///node_modules/@eslint/eslintrc/lib/config-array-factory.js:814:25)
at ConfigArrayFactory._normalizeObjectConfigDataBody (file:///node_modules/@eslint/eslintrc/lib/config-array-factory.js:752:25)

src/components/map/SimulatorButton.tsx

Oops! Something went wrong! :(

ESLint: 9.39.4

TypeError: Converting circular structure to JSON
--> starting at object with constructor 'Object'
| property 'configs' -> object with constructor 'Object'
| property 'flat' -> object with constructor 'Object'
| ...
| property 'plugins' -> object with constructor 'Object'
--- property 'react' closes the circle
Referenced from:
at JSON.stringify ()
at file:///node_modules/@eslint/eslintrc/lib/shared/config-validator.js:308:45
at Array.map ()
at ConfigValidator.formatErrors (file:///node_modules/@eslint/eslintrc/lib/shared/config-validator.js:299:23)
at ConfigValidator.validateConfigSchema (file:///node_modules/@eslint/eslintrc/lib/shared/config-validator.js:330:84)
at ConfigArrayFactory._normalizeConfigData (file:///node_modules/@eslint/eslintrc/lib/config-array-factory.js:676:19)
at ConfigArrayFactory._loadConfigData (file:///node_modules/@eslint/eslintrc/lib/config-array-factory.js:641:21)
at ConfigArrayFactory._loadExtendedShareableConfig (file:///node_modules/@eslint/eslintrc/lib/config-array-factory.js:946:21)
at ConfigArrayFactory._loadExtends (file:///node_modules/@eslint/eslintrc/lib/config-array-factory.js:814:25)
at ConfigArrayFactory._normalizeObjectConfigDataBody (file:///node_modules/@eslint/eslintrc/lib/config-array-factory.js:752:25)

src/components/map/teleop/TeleopControls.tsx

Oops! Something went wrong! :(

ESLint: 9.39.4

TypeError: Converting circular structure to JSON
--> starting at object with constructor 'Object'
| property 'configs' -> object with constructor 'Object'
| property 'flat' -> object with constructor 'Object'
| ...
| property 'plugins' -> object with constructor 'Object'
--- property 'react' closes the circle
Referenced from:
at JSON.stringify ()
at file:///node_modules/@eslint/eslintrc/lib/shared/config-validator.js:308:45
at Array.map ()
at ConfigValidator.formatErrors (file:///node_modules/@eslint/eslintrc/lib/shared/config-validator.js:299:23)
at ConfigValidator.validateConfigSchema (file:///node_modules/@eslint/eslintrc/lib/shared/config-validator.js:330:84)
at ConfigArrayFactory._normalizeConfigData (file:///node_modules/@eslint/eslintrc/lib/config-array-factory.js:676:19)
at ConfigArrayFactory._loadConfigData (file:///node_modules/@eslint/eslintrc/lib/config-array-factory.js:641:21)
at ConfigArrayFactory._loadExtendedShareableConfig (file:///node_modules/@eslint/eslintrc/lib/config-array-factory.js:946:21)
at ConfigArrayFactory._loadExtends (file:///node_modules/@eslint/eslintrc/lib/config-array-factory.js:814:25)
at ConfigArrayFactory._normalizeObjectConfigDataBody (file:///node_modules/@eslint/eslintrc/lib/config-array-factory.js:752:25)

  • 3 others

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@ClemensElflein
ClemensElflein requested a review from rovo89 July 2, 2026 11:36
@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown

🧪 Test this PR

A multi-arch image (amd64 + arm64) for this PR has been pushed to:

ghcr.io/xtech/openmower-app:pr-7

OpenMowerOS v2

  1. Go to http://<mower-ip>:5001/compose/openmower in your browser.

  2. Switch to edit mode.

  3. In the compose.yaml editor, add the following above the # Dockge-specific extras shown in the UI line (mind the 2-space indentation, like the other containers):

      app:
        image: ghcr.io/xtech/openmower-app:pr-7
        container_name: app
        ports:
          - 3000:3000
        restart: unless-stopped
  4. Add the URL under x-dockge:

    x-dockge:
      urls:
        - http://${HOSTNAME}:8080
        - http://${HOSTNAME}:3000
  5. Click Deploy, then open http://<mower-ip>:3000.

Plain Docker

docker run --rm -p 3000:3000 ghcr.io/xtech/openmower-app:pr-7

This comment updates automatically on every new push to the PR.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/components/simulation/BatteryGauge.tsx (1)

103-115: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Clickable battery graphic/label are not keyboard operable.

Both interactive regions (role="button" SVG wrapper at Line 103-115, and the "Tap to set voltage" box at Line 164-177) only handle onClick; neither has tabIndex, onKeyDown (Enter/Space), nor does the second one even declare role="button". Keyboard-only users cannot open the voltage dialog through these elements.

Also applies to: 164-177

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/simulation/BatteryGauge.tsx` around lines 103 - 115, The
interactive battery graphic and the “Tap to set voltage” box in BatteryGauge are
mouse-only and not keyboard operable. Update both Box wrappers that call
openDialog to behave as proper buttons by adding keyboard focusability and
keyboard activation handling, and ensure the second box also declares an
appropriate button role. Use the existing openDialog handler and the relevant
Box components in BatteryGauge so Enter/Space can open the voltage dialog from
either region.
🧹 Nitpick comments (1)
src/components/simulation/BatteryGauge.tsx (1)

31-61: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Inconsistent data sourcing: voltage/charging are props while percentage/params are pulled from the store.

The component already reads params and battery_percentage directly via useSelectedMower (Lines 50-51), but still accepts voltage and charging as external props (Lines 32-33) instead of deriving them the same way (m?.simState.battery_voltage, m?.simState.charging — both fields already used in SimulatorButton.tsx). If a caller ever passes stale or differently-sourced values, the percentage and voltage/charging readouts could diverge and show an inconsistent battery state.

Deriving all battery fields from the same store selector would remove this single source-of-truth risk.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/simulation/BatteryGauge.tsx` around lines 31 - 61,
BatteryGauge is mixing prop-driven and store-driven battery state, which can
make the UI inconsistent. Update BatteryGauge to derive voltage and charging
from the same useSelectedMower selector used for params and battery_percentage,
using the simState fields already referenced in SimulatorButton.tsx, and then
remove or stop relying on the voltage and charging props in BatteryGaugeProps
and the component body.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/components/map/MowerMap.tsx`:
- Around line 65-67: Reset the lingering teleop state in MowerMap when the
selected mower changes so `showTeleop` does not stay true for a new mower.
Update the `manualDrive` state in `MowerMap` (near the `useSelectedMower`,
`useState`, and `showTeleop` logic) to clear it whenever `simAvailable` becomes
false or the selected mower identity changes. Use the existing `manualDrive`,
`simAvailable`, and `showTeleop` symbols to locate the state transition and
ensure `TeleopControls` cannot remain mounted after switching to a non-sim
mower.

In `@src/components/map/SimulatorButton.tsx`:
- Around line 253-260: The Popover in SimulatorButton uses a fixed 460px width
that can overflow on mobile or narrow screens. Update the Popover’s paper
styling to keep the existing desktop width behavior while adding a
viewport-aware maxWidth/width constraint so it fits within small screens. Use
the Popover and slotProps.paper styling in SimulatorButton as the place to
adjust this responsive sizing.
- Around line 95-133: The custom SVG slider in SimulatorButton is pointer-only,
so add keyboard accessibility to the same control. Update the SVG in
SimulatorButton to be focusable with an appropriate role like slider, expose
aria-valuemin/aria-valuemax/aria-valuenow, and add onKeyDown handling for
arrow/home/end adjustments that calls the existing onChange/toV path. Keep the
pointer handlers intact, but ensure the same value update logic is reused so
keyboard and pointer interactions stay in sync.
- Around line 95-118: The battery slider is sending an RPC on every pointer move
because `onPointerMove` calls `handlePointer`, which immediately triggers
`onChange` and the `setBatteryVoltage`/`sim.battery.set` path. Update
`SimulatorButton` to keep a local drag value for responsive thumb movement
during pointer tracking, and defer the `onChange` call until `onPointerUp` (or
add throttling inside `handlePointer`). Use the existing `handlePointer`,
`onPointerDown`, `onPointerMove`, and `onPointerUp` handlers as the hook points
for the fix.
- Around line 215-216: The SimulatorButton popover is not surfacing the
per-action loading and failure state returned by useSimControl, so duplicate
clicks can happen and RPC failures are invisible. Update the destructuring in
SimulatorButton to include the hook’s pending and error values, then use pending
to disable the matching Switches/Buttons while each action is in flight and
render error in the popover when present. Apply this consistently to the action
handlers and controls in SimulatorButton so every simulated command has visible
in-flight and failure feedback.
- Around line 66-72: The measuredRef callback in SimulatorButton creates a
ResizeObserver but never cleans it up, so each Popover open/close can leak an
observer. Update the measuredRef logic to keep track of the observer for the
current element and disconnect it when the ref is cleared or the component
unmounts, ensuring the observer tied to setWidth and ResizeObserver is properly
disposed.

In `@src/components/simulation/BatteryGauge.tsx`:
- Around line 70-78: The voltage parsing in BatteryGauge is accepting
comma-decimal input but using parseFloat, which truncates values like “21,5” to
21 without failing. Update the parsing/validation in BatteryGauge’s submit flow
(and the related input handling around the pattern/voltageInput state) to
normalize comma decimals before converting, or otherwise explicitly reject
invalid comma-formatted values, so onSetVoltage receives the correct numeric
value and the dialog shows validation instead of silently submitting a wrong
voltage.

In `@src/stores/mowersStore.ts`:
- Around line 247-252: The params/json branch in the message handler is throwing
on malformed MQTT payloads because it uses rosParamsSchema.parse directly.
Update the mowersStore message callback to use rosParamsSchema.safeParse for
this branch, and only assign mower.params and call mower.getDatumFromParams()
when parsing succeeds, matching the invalid-data handling used by events/json
and sim/state/json.

---

Outside diff comments:
In `@src/components/simulation/BatteryGauge.tsx`:
- Around line 103-115: The interactive battery graphic and the “Tap to set
voltage” box in BatteryGauge are mouse-only and not keyboard operable. Update
both Box wrappers that call openDialog to behave as proper buttons by adding
keyboard focusability and keyboard activation handling, and ensure the second
box also declares an appropriate button role. Use the existing openDialog
handler and the relevant Box components in BatteryGauge so Enter/Space can open
the voltage dialog from either region.

---

Nitpick comments:
In `@src/components/simulation/BatteryGauge.tsx`:
- Around line 31-61: BatteryGauge is mixing prop-driven and store-driven battery
state, which can make the UI inconsistent. Update BatteryGauge to derive voltage
and charging from the same useSelectedMower selector used for params and
battery_percentage, using the simState fields already referenced in
SimulatorButton.tsx, and then remove or stop relying on the voltage and charging
props in BatteryGaugeProps and the component body.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 073bb2fb-794a-4e5a-9bb7-5422613da8d0

📥 Commits

Reviewing files that changed from the base of the PR and between 8b4ac09 and da9879b.

📒 Files selected for processing (11)
  • openrpc.json
  • src/components/map/MowerMap.tsx
  • src/components/map/SimulatorButton.tsx
  • src/components/map/teleop/TeleopControls.tsx
  • src/components/map/teleop/VirtualJoystick.tsx
  • src/components/simulation/BatteryGauge.tsx
  • src/hooks/useSimControl.ts
  • src/hooks/useTeleop.ts
  • src/lib/rpc.ts
  • src/stores/mowersStore.ts
  • src/stores/schemas.ts

Comment thread src/components/map/MowerMap.tsx
Comment on lines +66 to +72
const measuredRef = useCallback((el: HTMLDivElement | null) => {
if (!el) return;
const update = () => setWidth(el.clientWidth);
update();
const ro = new ResizeObserver(update);
ro.observe(el);
}, []);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

ResizeObserver is created but never disconnected.

measuredRef creates a new ResizeObserver on every mount but the early-return on unmount (if (!el) return;) never calls ro.disconnect(). Since this Box lives inside a Popover that mounts/unmounts on open/close, every open cycle leaks an observer that keeps firing setWidth on a detached node.

🔧 Proposed fix
+  const observerRef = useRef<ResizeObserver | null>(null);
   const measuredRef = useCallback((el: HTMLDivElement | null) => {
+    observerRef.current?.disconnect();
+    observerRef.current = null;
     if (!el) return;
     const update = () => setWidth(el.clientWidth);
     update();
     const ro = new ResizeObserver(update);
     ro.observe(el);
+    observerRef.current = ro;
   }, []);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const measuredRef = useCallback((el: HTMLDivElement | null) => {
if (!el) return;
const update = () => setWidth(el.clientWidth);
update();
const ro = new ResizeObserver(update);
ro.observe(el);
}, []);
const observerRef = useRef<ResizeObserver | null>(null);
const measuredRef = useCallback((el: HTMLDivElement | null) => {
observerRef.current?.disconnect();
observerRef.current = null;
if (!el) return;
const update = () => setWidth(el.clientWidth);
update();
const ro = new ResizeObserver(update);
ro.observe(el);
observerRef.current = ro;
}, []);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/map/SimulatorButton.tsx` around lines 66 - 72, The measuredRef
callback in SimulatorButton creates a ResizeObserver but never cleans it up, so
each Popover open/close can leak an observer. Update the measuredRef logic to
keep track of the observer for the current element and disconnect it when the
ref is cleared or the component unmounts, ensuring the observer tied to setWidth
and ResizeObserver is properly disposed.

Comment on lines +95 to +118
const handlePointer = useCallback(
(e: React.PointerEvent<SVGSVGElement>) => {
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<SVGSVGElement>) => {
(e.currentTarget as SVGSVGElement).setPointerCapture(e.pointerId);
setDragging(true);
handlePointer(e);
};
const onPointerMove = (e: React.PointerEvent<SVGSVGElement>) => {
if (dragging) handlePointer(e);
};
const onPointerUp = (e: React.PointerEvent<SVGSVGElement>) => {
setDragging(false);
(e.currentTarget as SVGSVGElement).releasePointerCapture(e.pointerId);
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Battery slider dispatches an RPC on every pointer-move event with no throttling.

handlePointer is invoked directly from onPointerMove while dragging is true, and it calls onChange (→ setBatteryVoltagesim.battery.set RPC) synchronously for every event. Since value is bound to the externally-stored simState.battery_voltage (not local drag state), a single drag gesture can fire dozens of RPC calls per second, and the thumb only moves once each round-trip completes, causing visible jank plus needless load on the RPC/MQTT path.

Track a local "dragging value" for immediate visual feedback and only invoke onChange on onPointerUp (or throttle it during drag).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/map/SimulatorButton.tsx` around lines 95 - 118, The battery
slider is sending an RPC on every pointer move because `onPointerMove` calls
`handlePointer`, which immediately triggers `onChange` and the
`setBatteryVoltage`/`sim.battery.set` path. Update `SimulatorButton` to keep a
local drag value for responsive thumb movement during pointer tracking, and
defer the `onChange` call until `onPointerUp` (or add throttling inside
`handlePointer`). Use the existing `handlePointer`, `onPointerDown`,
`onPointerMove`, and `onPointerUp` handlers as the hook points for the fix.

Comment on lines +95 to +133
const handlePointer = useCallback(
(e: React.PointerEvent<SVGSVGElement>) => {
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<SVGSVGElement>) => {
(e.currentTarget as SVGSVGElement).setPointerCapture(e.pointerId);
setDragging(true);
handlePointer(e);
};
const onPointerMove = (e: React.PointerEvent<SVGSVGElement>) => {
if (dragging) handlePointer(e);
};
const onPointerUp = (e: React.PointerEvent<SVGSVGElement>) => {
setDragging(false);
(e.currentTarget as SVGSVGElement).releasePointerCapture(e.pointerId);
};

const rx = 3;

return (
<Box ref={measuredRef} sx={{px: 1.5, pb: 1}}>
<svg
ref={svgRef}
width="100%"
height={SVG_H}
viewBox={`0 0 ${width} ${SVG_H}`}
style={{display: 'block', cursor: 'text', userSelect: 'none', overflow: 'visible'}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Custom battery slider has no keyboard support.

The SVG only wires onPointerDown/Move/Up; there's no tabIndex, role="slider", aria-valuenow/min/max, or onKeyDown for arrow-key adjustment. Keyboard-only users cannot operate this control at all, unlike the standard MUI Switch/Button/ToggleButtonGroup used elsewhere in the same popover.

Also applies to: 199-200

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/map/SimulatorButton.tsx` around lines 95 - 133, The custom SVG
slider in SimulatorButton is pointer-only, so add keyboard accessibility to the
same control. Update the SVG in SimulatorButton to be focusable with an
appropriate role like slider, expose aria-valuemin/aria-valuemax/aria-valuenow,
and add onKeyDown handling for arrow/home/end adjustments that calls the
existing onChange/toV path. Keep the pointer handlers intact, but ensure the
same value update logic is reused so keyboard and pointer interactions stay in
sync.

Comment thread src/components/map/SimulatorButton.tsx Outdated
Comment on lines +215 to +216
const {simState, available, setEmergency, setMovementAllowed, setBatteryVoltage, setGpsGood, moveToDock, displace} =
useSimControl();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

pending/error from useSimControl are never surfaced in the UI.

The hook exposes per-action pending and error, but the popover destructures only simState, available, setEmergency, setMovementAllowed, setBatteryVoltage, setGpsGood, moveToDock, displace (Line 215-216). Since there's no optimistic update (state only changes once the retained sim/state/json message round-trips), none of the Switches/Buttons are disabled while a call is in flight, and a failed RPC (error) is silently swallowed with zero user feedback. This allows rapid duplicate clicks on toggles like "Trigger emergency" and leaves users unaware when an action fails.

🔧 Suggested direction
-  const {simState, available, setEmergency, setMovementAllowed, setBatteryVoltage, setGpsGood, moveToDock, displace} =
+  const {simState, available, pending, error, setEmergency, setMovementAllowed, setBatteryVoltage, setGpsGood, moveToDock, displace} =
     useSimControl();

Then pass disabled={pending === 'gps'} etc. to the relevant switches/buttons, and render error (e.g. via an Alert) when non-null.

Also applies to: 271-411

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/map/SimulatorButton.tsx` around lines 215 - 216, The
SimulatorButton popover is not surfacing the per-action loading and failure
state returned by useSimControl, so duplicate clicks can happen and RPC failures
are invisible. Update the destructuring in SimulatorButton to include the hook’s
pending and error values, then use pending to disable the matching
Switches/Buttons while each action is in flight and render error in the popover
when present. Apply this consistently to the action handlers and controls in
SimulatorButton so every simulated command has visible in-flight and failure
feedback.

Comment on lines +253 to +260
<Popover
open={open}
anchorEl={buttonRef.current}
onClose={() => setOpen(false)}
anchorOrigin={{vertical: 'bottom', horizontal: 'left'}}
transformOrigin={{vertical: 'bottom', horizontal: 'right'}}
slotProps={{paper: {sx: {width: 460, py: 1}}}}
>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Fixed Popover width (460px) may overflow small/mobile viewports.

The map (and this control) is also used on mobile per the nav-gating changes in this PR; a hardcoded width: 460 with no maxWidth constraint tied to viewport size can clip or push the popover off-screen on narrow devices.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/map/SimulatorButton.tsx` around lines 253 - 260, The Popover
in SimulatorButton uses a fixed 460px width that can overflow on mobile or
narrow screens. Update the Popover’s paper styling to keep the existing desktop
width behavior while adding a viewport-aware maxWidth/width constraint so it
fits within small screens. Use the Popover and slotProps.paper styling in
SimulatorButton as the place to adjust this responsive sizing.

Comment on lines +70 to +78
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);
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Comma decimal input is silently mis-parsed.

The input's pattern (Line 226) explicitly permits a comma decimal separator ('[0-9]*[.,]?[0-9]*'), but parseFloat (Line 70) does not understand commas — parseFloat("21,5") returns 21, not 21.5. Since Number.isFinite(21) is true, valid stays true and no error is shown, so a user typing a comma-decimal value (common in EU locales) silently submits a truncated voltage.

🔧 Proposed fix
-  const parsed = parseFloat(voltageInput);
+  const parsed = parseFloat(voltageInput.replace(',', '.'));

Also applies to: 216-229

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/simulation/BatteryGauge.tsx` around lines 70 - 78, The voltage
parsing in BatteryGauge is accepting comma-decimal input but using parseFloat,
which truncates values like “21,5” to 21 without failing. Update the
parsing/validation in BatteryGauge’s submit flow (and the related input handling
around the pattern/voltageInput state) to normalize comma decimals before
converting, or otherwise explicitly reject invalid comma-formatted values, so
onSetVoltage receives the correct numeric value and the dialog shows validation
instead of silently submitting a wrong voltage.

Comment thread src/stores/mowersStore.ts
Comment on lines 247 to 252
} else if (partialTopic === 'params/json') {
set((state) => {
const mower = state.mowers[idx];
mower.params = JSON.parse(payload.toString()) as Record<string, unknown>;
mower.params = rosParamsSchema.parse(JSON.parse(payload.toString()));
mower.map.datum ??= mower.getDatumFromParams();
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the relevant store and schema definitions.
git ls-files | rg 'src/stores/mowersStore\.ts|rosParamsSchema|simStateSchema|events/json|params/json'

echo '--- mowersStore outline ---'
ast-grep outline src/stores/mowersStore.ts --view expanded || true

echo '--- relevant line ranges ---'
nl -ba src/stores/mowersStore.ts | sed -n '220,290p'

echo '--- rosParamsSchema references ---'
rg -n "rosParamsSchema|params/json|safeParse|parse\(" src/stores/mowersStore.ts src -g '!**/*.map' -g '!**/dist/**' || true

Repository: xtech/openmower-app

Length of output: 2042


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- mowersStore around getDatumFromParams and the message handler ---'
nl -ba src/stores/mowersStore.ts | sed -n '1,120p'
echo
nl -ba src/stores/mowersStore.ts | sed -n '150,290p'

Repository: xtech/openmower-app

Length of output: 267


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- handler around params/json ---'
sed -n '220,270p' src/stores/mowersStore.ts

echo
echo '--- getDatumFromParams and nearby methods ---'
sed -n '79,100p' src/stores/mowersStore.ts

Repository: xtech/openmower-app

Length of output: 3433


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the Ros params schema definition and any related types.
rg -n "rosParamsSchema|type RosParams|interface RosParams|RosParams =" src

echo
echo '--- schema definition candidates ---'
rg -n "datum_lat|datum_long|datum_height|gps/datum|z\.object|z\.record|z\.number|catchall" src

Repository: xtech/openmower-app

Length of output: 3926


Use safeParse for params/json to avoid throwing on malformed params payloads.

rosParamsSchema requires the seven numeric values here, so a bad retained MQTT message will throw inside the message callback. This branch should skip invalid data the same way events/json and sim/state/json do.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/stores/mowersStore.ts` around lines 247 - 252, The params/json branch in
the message handler is throwing on malformed MQTT payloads because it uses
rosParamsSchema.parse directly. Update the mowersStore message callback to use
rosParamsSchema.safeParse for this branch, and only assign mower.params and call
mower.getDatumFromParams() when parsing succeeds, matching the invalid-data
handling used by events/json and sim/state/json.

@rovo89
rovo89 merged commit 9baa804 into main Jul 7, 2026
4 checks passed
@rovo89
rovo89 deleted the feat/simulation-tab branch July 7, 2026 23:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants