Skip to content

Add velocity-mode jogging (M700) - #1264

Open
meeloo wants to merge 8 commits into
Duet3D:3.7-devfrom
meeloo:upstream/velocity-jog-m700
Open

Add velocity-mode jogging (M700)#1264
meeloo wants to merge 8 commits into
Duet3D:3.7-devfrom
meeloo:upstream/velocity-jog-m700

Conversation

@meeloo

@meeloo meeloo commented Aug 22, 2026

Copy link
Copy Markdown

Normal motion commands say where to go. M700 says how fast to go, and in which direction, per axis, so an analogue input such as a joystick can drive the machine directly. The motivating case is a gamepad jogging a CNC.

M700 X<speed> Y<speed> Z<speed> ... [S0] [P<ms>] [R<ms>] [D<n>]

Axis letters give signed speed in mm/sec (degrees/sec for rotational axes). The axis letters present define the whole velocity vector - any axis not mentioned is set to zero, so a truncated or partially-parsed command cannot leave an axis running. S0 stops. P is chunk time (10-200ms, default 20), R a watchdog timeout (default 250ms), D the queued-move depth (2-8, default 2). No parameters reports status.

How it works. JogController generates short constant-velocity chunks into movement system 0 and tops the queue up from GCodes::Spin. It goes through the normal SetMoveBufferDefaults / ToolOffsetTransform / LimitPosition path, so tool offsets, M208 limits and coordinate transforms all apply unchanged.

Safety properties, each of which is deliberate:

  • the watchdog stops the machine if commands stop arriving, so a dropped connection cannot leave it moving;
  • jogging yields to a print (IsReallyPrintingOrResuming), and is stopped by Reset and by LockMovementSystemAndWaitForStandstill;
  • speed is clamped to 2*a*P. DDA::InitStandardMove caps entry speed at sqrt(2*a*d) so any move can be the last one queued and still stop within itself; with d = v*P that solves to v <= 2*a*P. Commanding more would not go faster, it would quietly not be obeyed, so M700 clamps and reports the real ceiling instead.

Latency. Measured on an emulator, from command injection to the step pins changing rate: 38.5ms at the default D2 P20. Above ~40ms of queued motion latency tracks D x P as a FIFO should (P25 -> 67ms, P30 -> 90ms); below it, it degrades, and P10 cannot sustain 15mm/s at all. The defaults are therefore a measured optimum rather than a guess. What the floor is not, all measured: the MoveTiming preparation window (halving it moved 0.1ms), the lookahead grace period (~2ms), and the host command rate (0.3ms).

One behaviour change outside JogController: jog motion no longer counts towards busy in RepRap::GetStatusIndex. Jogging is continuous, so it pinned the status at busy for as long as the stick was held, and DWC and AxisControl grey their controls out when busy - including the controls sending the jog commands. Ordinary G1 moves still report busy.

Documented in Developer-documentation/Velocity jogging (M700).md.

Please treat the command number as a proposal. M700 was free on this branch but you may want it elsewhere; happy to renumber

meeloo and others added 6 commits August 22, 2026 11:41
Adds a second way to command motion: signed speed per axis, instead of a
destination, so that an analogue input such as a joystick can drive the axes
directly. M700 X25 Y-12 means "X at +25 mm/s, Y at -12 mm/s".

Rather than a new execution path, JogController synthesises a stream of short
constant-velocity moves and feeds them to movement system 0 through exactly the
path a G1 takes (MovementState::raw -> GCodes::ReadMove ->
DDARing::AddStandardMove). Reusing that path buys three things that a bespoke
path would have had to reinvent:

 - lookahead blends consecutive chunks, so a steady stick gives steady motion
   and a change of direction produces a normal junction, not a stop-start;
 - DDA::InitStandardMove sets endSpeed = 0 until a following move exists, so the
   last move in the ring always plans to stop. A command stream that dies -
   cable pulled, host crashed, task starved - decelerates the machine under its
   normal limits instead of stopping it dead;
 - per-axis speed and acceleration limits, kinematics, bed compensation and tool
   offsets all keep working unchanged.

Safety:
 - the axis letters present define the whole velocity vector, so an axis that is
   not mentioned is stopped and a truncated command cannot leave one running;
 - watchdog: no M700 within R ms (default 250) zeroes the velocity;
 - each chunk goes through Kinematics::LimitPosition with initialCoords set, so
   the whole line is checked. An axis that reaches its limit stops while the
   others keep their commanded speed, because the chunk still takes one chunk
   time to execute;
 - speed is clamped to M203 and to 2.a.chunkTime, which is the ceiling
   InitStandardMove imposes anyway - commanding more would silently not be
   obeyed rather than going faster;
 - starting a jog requires the same axes homed that a G1 would, refuses while
   printing, and takes the movement lock once to start from a known standstill;
 - jogging is cancelled by M112/M999 and by anything that waits for standstill
   on movement system 0, which would otherwise never be reached.

Response to a stick movement is about D * P plus the ~50ms Move prepares ahead,
so ~150-200ms at the defaults. See Developer-documentation for the trade-offs.

Builds clean for Duet3_MB6HC (arm-gnu-toolchain 15.3.Rel1), no new warnings
under -Wall -Werror. Untested on hardware.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0132JC621uq1434yhpzBNNgJ
The original 3 does not work, and the emulator says so precisely. A 20Hz M700
stream at 10mm/s with D3 collapses to 2.5mm/s at chunk boundaries and loses 7% of
the commanded distance (711 steps against 767 over the same interval). D4 is clean;
D5 is the default for margin, because a real machine has heaters, networking and
the SD card competing for the same main loop that tops the queue up.

The cause is not a blending failure, which is what the stutter looks like. It is
starvation: JogController::Spin adds at most one chunk per pass because it waits
for ms.segmentsLeft to fall back to zero, so the fill rate is a ping-pong between
the GCode and Move tasks. When the ring runs down to a single move, lookahead
correctly plans that move to stop at its end - the deceleration is right, there was
just nothing queued behind it.

This costs latency: about 300ms now rather than the 150-200ms the documentation
claimed, which was wrong on both counts. The docs now say so, and record that the
real lever is the one-chunk-per-pass handoff rather than the queue depth.
Four claims, all now measured under the emulator rather than argued: blending,
the 2.a.P speed ceiling, decelerate-on-loss-of-input, and per-axis limit clamping.

The ceiling turned out to be exactly right and linear in P - 20.08, 40.23 and 81.31
mm/s measured against 20, 40 and 80 predicted, all from a commanded 90mm/s. The
limit clamping holds too: with M208 X0:5 and M700 X10 Y10, X stops at 5.000mm while
Y continues to 10.000mm at an undisturbed 10.00mm/s, which is the subtlest of the
four and the one most likely to have been wrong.

Also notes what this does not prove: step timing comes from the TC model, and
nothing here checks that a real TMC5160 would follow the pulses.
Defaults move from D5/P50 to D2/P20, measured on the emulator by timing from
command injection to the step pins changing rate.

  D5 P50 -> 257ms    D3 P20 -> 126ms    D2 P20 -> 50ms
  D2 P15 ->  62ms    D2 P10 -> 127ms

D2/P20 is an optimum rather than a compromise. Below about 40ms of queued motion
the latency stops following D*P and gets worse, because Move wants roughly
MoveTiming::UsualMinimumPreparedTime queued before it will run moves - so
shortening the chunk or the queue past that point makes jogging less responsive,
not more. Doubling the command rate changed the result by 0.3ms, so the floor is in
the firmware rather than in how fast a host can send.

The earlier stutter that pushed the depth up to 5 was with 50ms chunks, where the
ring holds far more time and the producer has correspondingly longer to fall
behind; depth 2 with 20ms chunks measures clean over a 20Hz stream.

Going below ~50ms means reducing the preparation window in MoveTiming, which also
affects print moves and CAN expansion timing. Not done here.
The comment and docs claimed the sub-40ms floor was Move refusing to run until
MoveTiming::UsualMinimumPreparedTime was queued. Measured, that is false.

  hypothesis                              test                        result
  UsualMinimumPreparedTime is the floor   halved it, 50ms -> 25ms     50.3 -> 50.2 ms
  lookahead grace period                  M595 R0, and R0 P40         ~2 ms
  host command rate                       doubled to 10ms cadence     0.3 ms

Also filled in the D=2 curve, which is cleaner than the numbers previously
recorded: P=15 -> 44.6, P=20 -> 38.5, P=25 -> 67.2, P=30 -> 90.2 ms. Above the
optimum latency tracks the queued time D*P as a FIFO should.

Sizing each chunk to the requested speed (T >= v/2a, so a slow jog gets a short
chunk) was implemented and measured before being dropped: 3->15mm/s went
39.5 -> 75.4 ms and 1->3mm/s went 79 -> 245 ms. Not in the tree; recorded so it
is not tried again.

The floor is real and its cause is still unknown. No behaviour change here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0132JC621uq1434yhpzBNNgJ
RepRap::GetStatusIndex treats any live movement as busy. Jogging is continuous
by nature, so M700 pinned the status at busy for as long as the operator held
the stick - and DWC and AxisControl grey their controls out when the machine is
busy, including the controls that send the jog commands. Reported from a real
AxisControl session against the emulator: the jog panel disabled itself on every
speed update.

Jog motion no longer counts towards busy. The machine is manually controlled and
accepting commands, which is what idle means to a client.

Measured on the emulator, state.status sampled during a jog:
  before   idle busy busy busy
  after    idle idle idle idle
A normal G1 still reports busy, so only the jog case changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0132JC621uq1434yhpzBNNgJ
@github-actions

github-actions Bot commented Aug 22, 2026

Copy link
Copy Markdown

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

@T3P3

T3P3 commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Please discuss this feature on the Duet3D forum first. We try not to clash with existing allocated gcodes as much as possible even if they are not allocated in RRF. for example M700:
https://reprap.org/wiki/G-code#M700:_Level_plate

@meeloo

meeloo commented Aug 22, 2026

Copy link
Copy Markdown
Author

I have read the Duet3D CLA v2.0 and I hereby sign it

@meeloo

meeloo commented Aug 22, 2026

Copy link
Copy Markdown
Author

Thanks — raised on the forum as asked: https://forum.duet3d.com/topic/39395/velocity-mode-jogging-joystick-control

Happy to take whatever number (or sub-code under an M-code RRF already owns) you would prefer, and I will renumber this branch. Leaving the PR open for the code, but the numbering decision belongs in that thread.

GenerateChunk advanced ms.currentUserPosition before the distance test and the
sub-threshold path returned without putting it back. SetMoveBufferDefaults seeds
initialCoords from raw.coords, so the rejected target became the next chunk's
baseline and the error compounded instead of recovering.

Commanding below MinChunkDistance/chunkTime (0.05 mm/s at the default P=20) made
M114 and the object model climb at the commanded speed while the machine stood
still. Measured with 40 x "M700 X0.01":

  before   X:3.447   Count 0 0 0 0      <- 3.4mm claimed, not one step taken
  after    X:0.000   Count 0 0 0 0

Fixed at both ends: the target is restored on the reject path, and ClampSpeeds
zeroes a speed too low to express in one chunk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0132JC621uq1434yhpzBNNgJ
JogController::Spin only stood aside for IsReallyPrintingOrResuming. A macro or a
tool change moves axes on its own account, so jogging carried on underneath it and
the two fought over the same movement system.

DoingFileMacro deliberately excludes daemon.g (GCodes.cpp:374), so a daemon on its
normal cycle does not chop the jog stream up - which is what made this safe to add.

Deliberately NOT included: WaitingForAcknowledgement. 'Jog to the workpiece corner,
then press OK' is a standard CNC setup pattern; the machine is stationary and the
operator is at the controls, so blocking it would remove a useful workflow for no
safety gain.

No regression: 25 x 'M700 X5' gives 536 step edges before and after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0132JC621uq1434yhpzBNNgJ
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