ADR 0039 Spot Mowing By Mowdeck Driven Paths - lukislp/mowdeck GitHub Wiki
Accepted (2026-10-15); amended by ADR 0041 (2026-10-15): a run may start at the dock (undocking, a transit planned on the map instead of the straight approach of at most 10 m, the return to the dock afterwards); amended by ADR 0042 (2026-10-15): the stick scale, steering gains and turn_max come from a drive calibration, and the run-time limit is progress based (stalled after 60 s without progress, overall cap max(20 min, 3 × estimate)); amended by ADR 0043 (2026-10-15): the run turns at lane ends as a turn style says (in_place, arc, multi_point with reverse legs), follows shaped turns at 15 °/s at most and turns in place only with in_place, where no shaped turn applies or at the geofence's edge.
The Planner computes routes for small spots (ADR 0030), but a mower runs them only as a native job (StartMow.spot_plan with capabilities.custom_paths, or the plan's native area job). The LUBA cannot follow an arbitrary route: its firmware accepts whole areas and its own patterns. To mow a missed spot, a strip along a bed or a gap from the heatmap, MowDeck has to drive the mower itself. Since ADR 0038 a cloud mower can carry a Bluetooth bridge, which gives a joystick channel with low latency next to the cloud.
What is known about that channel: frames go out at 5 Hz with a dead-man time (ttl_ms), the mower's report of position and heading arrives about once a second while it streams and every few seconds otherwise, and the stick-to-speed scale and the BLE latency are unmeasured. A control loop on top of it must therefore work at 1 Hz, degrade safely when reports get slower and never trust its own dead reckoning for long.
A new command spot_mow (SpotMow{plan, blades, confirm_blades, speed_mps ≤ 0.3, spot}) makes MowDeck drive the mower along an inline Planner plan with joystick frames; spot_stop ends it. The mower runs no job of its own; MowDeck is the pilot. The run lives in the generic control library, the Device-Gateway wires it, the adapter only has to deliver drive frames and fresh state.
The run (SpotMowRun, libs/mowdeck-control/src/mowdeck_control/spot_run.py).
-
Pose. Position and heading of the latest
MowerState, converted to the map's local frame (from_wgs84,local_angle: the state's heading is a compass course). Between reports the pose is dead-reckoned from the commanded speed (or the measured one) and an assumed yaw rate; every report replaces it. -
Geofence. The mapped areas inset by 0.3 m minus every no-go zone and obstacle buffered by 0.3 m. Every plan point is checked before the first frame; the reported pose on every report and the dead-reckoned pose on every frame. A violation aborts (
geofence). -
Steering. Pure pursuit with a 0.7 m lookahead along the current leg. A heading error above 35° turns in place (
linear0,angular = clamp(0.6·error, ±0.4), left positive = compass heading decreasing); otherwiseangular = clamp(0.8·error, ±0.3)and a speed governor moveslinear(0.2 to 0.5) until the measured ground speed meets the segment speed. Speed at most 0.3 m/s, default 0.2. A waypoint counts as reached within 0.25 m. A straight approach leg from the mower to the first plan point is prepended when it is shorter than 10 m and clear within the geofence; a gap between two plan segments is bridged by a transfer leg with the blades off. -
Pauses. A pose older than 2.5 s or an RTK fix other than
fixedpauses the run (paused_rtk, reasonrtk_float,rtk_noneorposition_stale); zero frames at 2 Hz keep the drive session open. After 30 s without a usable pose the run aborts (rtk_lost). -
Aborts. A cross-track error above 1.0 m, or a heading error that grows over 3 consecutive reports while turning in place (a turn-direction sign error), aborts (
heading_mismatch). So do the run-time limitmin(2 × estimated duration + 60 s, 20 min)(timeout), a refused frame (frame_rejected,link_lostwhen the drive link is not available or was lost,sensor_stop), an emergency stop, a sensor stop, an activity other than idle andspot_stop(stopped_by_user). Every end sends a zero frame, switches the blades off and closes the drive session. -
Blades. On only for segments that ask for them, only with
bladesandconfirm_bladesand only on a mower withcapabilities.manual_drive_blades(the link optionallow_manual_blades); otherwise forced off and the run is markeddry_run. -
Status and report.
SpotRunStatus{run_id, state, progress_percent, segment_index, segments_total, planned_points, driven_points, dry_run, reason}through a callback, and a finalSpotRunReport(state, reason, detail, points, coverage of the mowing waypoints, duration, frames, paused time).
Controller (rule 12 in MowerController). SpotMow needs capabilities.manual_drive, no open drive session or pending confirmation, an idle mower with a state at most 60 s old (a standing cloud-first mower reports about every 20 s; the pause rule above makes the run wait for live positions before it moves), more than 20 % battery, a fixed RTK position inside the geofence and a plan inside the geofence. It is answered accepted at once; spot_run_result(command_id) returns the report. The run's frames are internal ManualDrive envelopes with correlation_id = spot:<run_id> and ttl_ms 1000 that go through handle(), so the dead-man switch, decide_manual_blade, the sensor-stop handling and the updating lock-out apply unchanged. While a run is active every command except emergency_stop, spot_stop and set_light is rejected ("spot run active; stop it first"); spot_stop without a run is rejected. The status is merged into every published MowerState.spot_run; current_path() returns the plan with its progress while the run drives.
Device-Gateway. The session publishes the accepted result as usual, waits for the run's end in a follow-up task and publishes the final executed (plan driven) or failed with the run's summary as the reason, so Fleet's record and Telemetry's command timeline get both rows. The plan with its progress is the ordinary MowerPathSnapshot, so the web app reuses the mow-path layer; the run's end clears it. A lost link or a released mower closes the controller, which ends the run with link_lost; the mower's own dead-man stops it within the frame's ttl. The gateway does not fetch plans (ADR 0030): the web app asks the Planner and sends the plan inline; Fleet gates spot_mow with can_drive and the features control and spot_mowing, refuses it in bulk commands, limits the plan size and records its plan_hash.
Adapter. A control loop needs a pose about once a second. While a drive session is open the Mammotion adapter's refresh loop keeps the report window open and asks for an undebounced report whenever none arrived for a second, and it publishes every report while driving so observed_at stays fresh for a standing mower.
Hardware before blades. The owner script runs the same run against the real mower: --turn-check (turn in place left for 2 s, verify that the compass heading decreases, measure the report interval and the frame-to-report latency) and --spot-dry-run (a 1 m circle from the current pose at 0.2 m/s with the blades off, Ctrl-C sends spot_stop). Blades, speeds above 0.2 m/s and plans from the missed-spots list wait for those measurements.
Generic, and the same for every manufacturer: the contracts (SpotMow, SpotStop, SpotRunStatus, MowerState.spot_run, MAX_SPOT_MOW_SPEED_MPS), SpotMowRun with its geofence, pure pursuit, governor, pauses and aborts, controller rule 12, the gateway's result and path publishing, Fleet's gates and audit, and the simulator's implementation (differential-drive integration of the frames, state_period_s for a report rate and position_noise_m for RTK scatter; the spot-run tests in mowdeck-control and the gateway import no manufacturer package). The run reads only the neutral state (position with heading, rtk_fix, observed_at, activity) and writes only neutral ManualDrive frames.
Adapter work, done once per manufacturer: a drive session that turns linear/angular (left positive) into the vendor's movement command, capabilities.manual_drive (and manual_drive_blades where allowed), a heading in MowerState.position as a compass course, and a state that arrives about once a second while a drive session is open (published on every report, also an unchanged one). For Mammotion that is the drive mode of the refresh loop described above. A second manufacturer's adapter implements exactly these and gets spot mowing without changes to the run, the services or the web app; its owner checks the turn direction and report rate with the same two steps before the first run.
Easier: any spot the Planner can plan can be mown, also on mowers that only accept whole areas, with MowDeck's own safety rules (geofence, RTK, dead-man, time limit) in front of the mower. Harder: the quality depends on the report rate and the link; at 1 Hz the run is slow and conservative by design, and it pauses rather than guesses when reports stop. The stick-to-speed scale, the yaw rate and the latency are measured on hardware first; the defaults are deliberately cautious. Follow-ups: the web spot mode (spot on the map, sheet, run view with STOP), plans from the heatmap's missed spots, blades after a successful dry run, tuning of the governor and the yaw rate from the owner's measurements.
Mirrored from docs/adr/0039-spot-mowing-by-mowdeck-driven-paths.md at commit 2c95bd2e71e632d0ce48646e30681257807fac8c. Edit the file in the repository, not this wiki page.