From cb58e4425bd8d90be65f77c1c00b2a86c91e1198 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Metrot?= Date: Thu, 20 Aug 2026 10:53:27 +0200 Subject: [PATCH 1/9] Add velocity-mode jogging (M700) 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 Claude-Session: https://claude.ai/code/session_0132JC621uq1434yhpzBNNgJ --- .../Velocity jogging (M700).md | 89 ++++++ src/GCodes/GCodes.cpp | 9 + src/GCodes/GCodes.h | 7 + src/GCodes/GCodes2.cpp | 4 + src/Movement/JogController.cpp | 289 ++++++++++++++++++ src/Movement/JogController.h | 55 ++++ 6 files changed, 453 insertions(+) create mode 100644 Developer-documentation/Velocity jogging (M700).md create mode 100644 src/Movement/JogController.cpp create mode 100644 src/Movement/JogController.h diff --git a/Developer-documentation/Velocity jogging (M700).md b/Developer-documentation/Velocity jogging (M700).md new file mode 100644 index 0000000000..f018902986 --- /dev/null +++ b/Developer-documentation/Velocity jogging (M700).md @@ -0,0 +1,89 @@ +# Velocity jogging — M700 + +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. + +## Command + +``` +M700 X Y Z ... [S0] [P] [R] [D] +``` + +| Parameter | Meaning | +|---|---| +| axis letters | Signed speed for that axis in **mm/sec** (degrees/sec for rotational axes). `G20` does *not* rescale these. | +| `S0` | Stop jogging now. | +| `P` | Chunk time in ms, 10..200, default 50. See *Latency* below. | +| `R` | Watchdog timeout in ms, default 250. | +| `D` | How many moves to keep queued, 2..8, default 3. | +| none | Report status. | + +**The axis letters present define the whole velocity vector.** Any axis you do not mention is set to zero. +A truncated or partially-parsed command therefore cannot leave an axis running. + +Send a fresh `M700` whenever the stick moves, and at least every `R` milliseconds while it is off centre. + +```gcode +M700 X25 Y-12 ; X at +25 mm/s, Y at -12 mm/s, everything else stopped +M700 X25 ; Y now stops, X carries on +M700 S0 ; stop +``` + +## How it works + +Jogging synthesises a stream of short constant-velocity moves and feeds them to movement system 0 through +exactly the same path a `G1` takes: `MovementState::raw` → `GCodes::ReadMove` → `DDARing::AddStandardMove`. + +That reuse is the whole point of the design: + +* **Lookahead blends the chunks**, so a steady stick gives steady motion, and changing the stick direction + produces a normal cornered junction rather than a stop-start. +* **The last move in the ring is always planned to end at zero speed.** If the command stream dies — cable + pulled, host crashed, task starved — the machine decelerates to a stop under its normal acceleration + limits instead of stopping dead and losing steps. +* Per-axis speed and acceleration limits, kinematics, bed compensation and tool offsets all apply + unchanged. + +`JogController::Spin()` is called from `GCodes::Spin()` and tops the queue up whenever it holds fewer than +`D` moves. + +## Latency + +Response to a stick movement is roughly `D × P` plus the ~50 ms that `Move` prepares ahead +(`MoveTiming::UsualMinimumPreparedTime`) — about 150–200 ms at the defaults. Reducing `D` or `P` sharpens +the response but lowers the speed ceiling (see below) and leaves less slack for a busy main loop. + +## Safety + +* **Speed clamp.** Each axis is clamped to its `M203` maximum *and* to `2·a·P`. That second limit is not + arbitrary: `DDA::InitStandardMove` caps the entry speed of every move at `sqrt(2·a·d)` for that move + alone, so that any move can be the last one in the ring and still stop at its end. With `d = v·P` that + solves to `v ≤ 2·a·P`. Commanding more would not go faster, it would silently not be obeyed, so `M700` + clamps to it. At the defaults with a = 1000 mm/s² the ceiling is 100 mm/s (6000 mm/min); **to jog faster, + raise `P`** — and accept the extra latency. +* **Watchdog.** If no `M700` arrives within `R` ms, the velocity is zeroed and the machine decelerates. +* **Axis limits.** Every chunk is passed through `Kinematics::LimitPosition` with `initialCoords` set, so + the whole line is checked, not just its end point. An axis that reaches its limit simply stops; the + others keep their commanded speed, because the chunk still takes one chunk time to execute. +* **Homing.** Starting a jog requires the same axes to be homed that a `G1` would, subject to `M564`. +* **Interlocks.** Jogging refuses to start while a print is running, stops if one starts, and is cancelled + by anything that waits for standstill on movement system 0 (`G28`, `G30`, most `M` codes that move) and + by `M112`/`M999`. + +For an immediate halt, use `M112` — `M700 S0` decelerates. + +## Limitations + +* Movement system 0 only. +* Mentioning linear and rotational axes in the same command works, but RepRapFirmware treats the two + groups' feedrates separately, so the resulting speeds are only approximately as commanded. +* RepRapFirmware has no USB host stack: the joystick has to be read by an SBC, Pi or other host that then + sends `M700` over USB or the network, ideally on its own input channel. + +## Where the code is + +| | | +|---|---| +| `src/Movement/JogController.{h,cpp}` | all of the logic | +| `src/GCodes/GCodes.cpp` | `Spin()` calls `jogController.Spin()`; `Reset()` and `LockMovementSystemAndWaitForStandstill()` stop it | +| `src/GCodes/GCodes2.cpp` | `M700` dispatch | diff --git a/src/GCodes/GCodes.cpp b/src/GCodes/GCodes.cpp index 6995174b00..1a3066044d 100644 --- a/src/GCodes/GCodes.cpp +++ b/src/GCodes/GCodes.cpp @@ -238,6 +238,8 @@ void GCodes::Reset() noexcept nextGcodeSource = 0; + jogController.Stop(); + #if HAS_MASS_STORAGE || HAS_EMBEDDED_FILES fileToPrint.Close(); #endif @@ -467,6 +469,8 @@ void GCodes::Spin() noexcept CheckTriggers(); + jogController.Spin(); // keep the movement queue topped up if we are jogging + // The autoPause buffer has priority, so spin that one first. It may have to wait for other buffers to release locks etc. (void)SpinGCodeBuffer(*AutoPauseGCode()); @@ -1853,6 +1857,11 @@ bool GCodes::LockAllMovementSystemsAndWaitForStandstill(GCodeBuffer& gb) noexcep // As a side-effect it updates the user coordinates from the machine coordinates. bool GCodes::LockMovementSystemAndWaitForStandstill(GCodeBuffer& gb, MovementSystemNumber msNumber) noexcept { + if (msNumber == 0) + { + jogController.Stop(); // jogging keeps feeding the queue, so we would never reach standstill while it is running + } + // Lock movement to stop another source adding moves to the queue if (!LockResource(gb, MoveResourceBase + msNumber)) { diff --git a/src/GCodes/GCodes.h b/src/GCodes/GCodes.h index 8b7ca05d76..a2eacfa836 100644 --- a/src/GCodes/GCodes.h +++ b/src/GCodes/GCodes.h @@ -42,6 +42,7 @@ Licence: GPL #include #include #include +#include #if HAS_MASS_STORAGE || HAS_EMBEDDED_FILES # include @@ -98,6 +99,8 @@ class SbcInterface; class GCodes { + friend class JogController; // it builds moves for movement system 0 using the same private machinery that G1 does + public: explicit GCodes(Platform& p) noexcept; void Spin() noexcept; // Called in a tight loop to make this class work @@ -270,6 +273,8 @@ class GCodes const MovementState& GetCurrentMovementState(const ObjectExplorationContext& context) const noexcept; const MovementState& GetConstMovementState(const GCodeBuffer& gb) const noexcept; // Get a reference to the movement state associated with the specified GCode buffer (there is a private non-const version) + JogController& GetJogController() noexcept { return jogController; } + void RecordEndstopTriggered(size_t axis, HomingMode hmode) noexcept; bool IsHeaterUsedByDifferentCurrentTool(int heaterNumber, const Tool *tool) const noexcept; // Check if the specified heater is used by a current tool other than the specified one @@ -671,6 +676,8 @@ class GCodes // The following contain the details of moves that the Move module fetches MovementState moveStates[NumMovementSystems]; // Move details + JogController jogController; // Velocity-mode movement, e.g. from a joystick + size_t numTotalAxes; // How many axes we have size_t numVisibleAxes; // How many axes are visible size_t numExtruders; // How many extruders we have, or may have diff --git a/src/GCodes/GCodes2.cpp b/src/GCodes/GCodes2.cpp index cb8ddf2514..ae21648580 100644 --- a/src/GCodes/GCodes2.cpp +++ b/src/GCodes/GCodes2.cpp @@ -4367,6 +4367,10 @@ bool GCodes::HandleMcode(GCodeBuffer& gb, const StringRef& reply) THROWS(GCodeEx result = FindCenterOfCavity(gb, reply); break; + case 700: // Set jog velocities + result = jogController.ProcessM700(gb, reply); + break; + case 701: // Load filament result = LoadFilament(gb, reply); break; diff --git a/src/Movement/JogController.cpp b/src/Movement/JogController.cpp new file mode 100644 index 0000000000..64358c3235 --- /dev/null +++ b/src/Movement/JogController.cpp @@ -0,0 +1,289 @@ +/* + * JogController.cpp + * + * See JogController.h for what this does. + */ + +#include "JogController.h" + +#include +#include +#include +#include + +// Below this the chunk is not worth queueing; it is well under one microstep on any sane machine. +constexpr float MinChunkDistance = 0.001; + +JogController::JogController() noexcept + : jogAxes(), chunkMillis(DefaultChunkMillis), chunkClocks((DefaultChunkMillis * StepClockRate)/1000), + timeoutMillis(DefaultTimeoutMillis), whenLastCommanded(0), maxQueuedMoves(DefaultMaxQueuedMoves), active(false) +{ + for (float& s : requestedSpeeds) + { + s = 0.0; + } +} + +// The highest speed we are prepared to run this axis at. +// Besides the configured maximum there is a limit inherent in feeding the queue a chunk at a time. DDA::InitStandardMove +// caps the entry speed of every move at sqrt(2.a.d) for that move alone, so that the move can always be the last one in +// the ring and still stop at its end. With d = v.chunkTime that solves to v <= 2.a.chunkTime. Commanding more than this +// would not go any faster, it would just quietly not be obeyed - so clamp to it and let the caller see the real ceiling. +// Jogging faster means longer chunks, which means more latency; that trade-off is the P parameter of M700. +float JogController::MaxSpeedForAxis(size_t axis) const noexcept +{ + const Move& move = reprap.GetMove(); + return min(move.MaxFeedrate(axis), 2.0 * move.NormalAcceleration(axis) * (float)chunkClocks); +} + +void JogController::ClampSpeeds() noexcept +{ + const size_t numVisibleAxes = reprap.GetGCodes().GetVisibleAxes(); + jogAxes.Clear(); + for (size_t axis = 0; axis < numVisibleAxes; ++axis) + { + const float limit = MaxSpeedForAxis(axis); + requestedSpeeds[axis] = constrain(requestedSpeeds[axis], -limit, limit); + if (requestedSpeeds[axis] != 0.0) + { + jogAxes.SetBit(axis); + } + } + for (size_t axis = numVisibleAxes; axis < MaxAxes; ++axis) + { + requestedSpeeds[axis] = 0.0; + } +} + +void JogController::Stop() noexcept +{ + for (float& s : requestedSpeeds) + { + s = 0.0; + } + jogAxes.Clear(); + active = false; +} + +void JogController::ReportStatus(const StringRef& reply) const noexcept +{ + const GCodes& gcodes = reprap.GetGCodes(); + const char *_ecv_array const axisLetters = gcodes.GetAxisLetters(); + reply.printf("Jogging %s, chunk %" PRIu32 "ms, timeout %" PRIu32 "ms, queue %u", + (active) ? "active" : "inactive", chunkMillis, timeoutMillis, maxQueuedMoves); + if (active) + { + reply.cat(", speeds"); + for (size_t axis = 0; axis < gcodes.GetVisibleAxes(); ++axis) + { + if (jogAxes.IsBitSet(axis)) + { + reply.catf(" %c%.1f", axisLetters[axis], (double)InverseConvertSpeedToMmPerSec(requestedSpeeds[axis])); + } + } + } +} + +// M700: set the jog velocity of each axis, in mm (or degrees) per second. +// The axis letters that are present define the whole velocity vector: any axis not mentioned is set to zero, so that a +// truncated or lost command can never leave an axis running. +GCodeResult JogController::ProcessM700(GCodeBuffer& gb, const StringRef& reply) THROWS(GCodeException) +{ + GCodes& gcodes = reprap.GetGCodes(); + const char *_ecv_array const axisLetters = gcodes.GetAxisLetters(); + const size_t numVisibleAxes = gcodes.GetVisibleAxes(); + + // Tuning parameters. These take effect on the next chunk. + bool seenParam = false; + gb.TryGetLimitedUIValue('P', chunkMillis, seenParam, MinChunkMillis, MaxChunkMillis + 1); + gb.TryGetLimitedUIValue('R', timeoutMillis, seenParam, 1, MaxTimeoutMillis + 1); + uint32_t queueDepth = maxQueuedMoves; + gb.TryGetLimitedUIValue('D', queueDepth, seenParam, MinMaxQueuedMoves, MaxMaxQueuedMoves + 1); + chunkClocks = (chunkMillis * StepClockRate)/1000; + maxQueuedMoves = queueDepth; + + // S0 is an explicit stop. + if (gb.Seen('S') && gb.GetUIValue() == 0) + { + Stop(); + return GCodeResult::ok; + } + + float newSpeeds[MaxAxes] = { 0.0 }; + bool seenAxis = false; + for (size_t axis = 0; axis < numVisibleAxes; ++axis) + { + if (gb.Seen(axisLetters[axis])) + { + // Speeds are always in mm (or degrees) per second. G20 deliberately does not rescale them: the sender of a + // velocity command should not have its meaning changed by modal state it may know nothing about. + newSpeeds[axis] = ConvertSpeedFromMmPerSec(gb.GetFValue()); + seenAxis = true; + } + else + { + newSpeeds[axis] = 0.0; + } + } + + if (!seenAxis) + { + if (!seenParam) + { + ReportStatus(reply); + } + return GCodeResult::ok; + } + + AxesBitmap newJogAxes; + for (size_t axis = 0; axis < numVisibleAxes; ++axis) + { + if (newSpeeds[axis] != 0.0) + { + newJogAxes.SetBit(axis); + } + } + + if (newJogAxes.IsNonEmpty() && gcodes.CheckEnoughAxesHomed(newJogAxes)) + { + reply.copy("Insufficient axes homed"); + return GCodeResult::error; + } + + if (!active && newJogAxes.IsNonEmpty()) + { + // Starting up. We take over the axis positions of movement system 0, so it must be at a standstill and not printing. + if (gcodes.IsReallyPrintingOrResuming()) + { + reply.copy("Cannot jog while a print is running"); + return GCodeResult::error; + } + if (!gcodes.LockMovementSystemAndWaitForStandstill(gb, 0)) + { + return GCodeResult::notFinished; + } + } + +#if SUPPORT_ASYNC_MOVES + if ((newJogAxes & ~jogAxes).IsNonEmpty() // an axis we are not already moving has been added, so it may not be ours yet + && gcodes.moveStates[0].AllocateAxes(newJogAxes, ParameterLettersBitmap()).IsNonEmpty()) + { + reply.copy("Cannot jog: axes are in use by another movement system"); + return GCodeResult::error; + } +#endif + + memcpyf(requestedSpeeds, newSpeeds, numVisibleAxes); + ClampSpeeds(); + whenLastCommanded = millis(); + active = jogAxes.IsNonEmpty(); + return GCodeResult::ok; +} + +// Top the movement queue up. Called regularly from GCodes::Spin. +void JogController::Spin() noexcept +{ + if (!active) + { + return; + } + + GCodes& gcodes = reprap.GetGCodes(); + Move& move = reprap.GetMove(); + + // Watchdog: an input that stops sending must not leave the machine moving. + if (millis() - whenLastCommanded > timeoutMillis) + { + Stop(); + return; + } + + // Something else has taken over movement, so get out of the way. + if (gcodes.IsReallyPrintingOrResuming()) + { + Stop(); + return; + } + + MovementState& ms = gcodes.moveStates[0]; + if (ms.segmentsLeft != 0) + { + return; // the previous chunk has not been picked up yet + } + if (move.GetScheduledMoves() - move.GetCompletedMoves() >= maxQueuedMoves) + { + return; // far enough ahead already; queueing more would only add latency + } + + (void)GenerateChunk(ms); +} + +// Build one constant-velocity chunk and hand it to the Move subsystem. Return true if we queued anything. +bool JogController::GenerateChunk(MovementState& ms) noexcept +{ + GCodes& gcodes = reprap.GetGCodes(); + Move& move = reprap.GetMove(); + const size_t numVisibleAxes = gcodes.GetVisibleAxes(); + + gcodes.SetMoveBufferDefaults(ms); // this also copies the previous target into ms.initialCoords + + for (size_t axis = 0; axis < numVisibleAxes; ++axis) + { + if (requestedSpeeds[axis] != 0.0) + { + ms.currentUserPosition[axis] += requestedSpeeds[axis] * (float)chunkClocks; + } + } + + ms.raw.movementTool = ms.currentTool; + gcodes.ToolOffsetTransform(ms, jogAxes); + + // Limit the whole line rather than just its end point, so that kinematics with a non-rectangular envelope stay inside it. + const LimitPositionResult lp = move.GetKinematics().LimitPosition(ms.raw.coords, ms.initialCoords, numVisibleAxes, + gcodes.axesVirtuallyHomed & jogAxes, true, gcodes.limitAxes); + if (lp == LimitPositionResult::intermediateUnreachable || lp == LimitPositionResult::adjustedAndIntermediateUnreachable) + { + Stop(); + return false; + } + if (lp == LimitPositionResult::adjusted) + { + gcodes.ToolOffsetInverseTransform(ms, ms.raw.coords, ms.currentUserPosition); // the target was clipped, so put the user position back in step with it + } + + // Axes that have run into their limit contribute nothing to the distance, which is exactly what keeps the remaining + // axes at their commanded speed: every axis still covers its own delta in one chunk time. + float distanceSquared = 0.0; + for (size_t axis = 0; axis < numVisibleAxes; ++axis) + { + const float d = ms.raw.coords[axis] - ms.initialCoords[axis]; + if (d != 0.0) + { + distanceSquared += fsquare(d); + if (move.IsAxisRotational(axis)) + { + ms.raw.rotationalAxesMentioned = true; + } + else + { + ms.raw.linearAxesMentioned = true; + } + } + } + + if (distanceSquared < fsquare(MinChunkDistance)) + { + return false; // every jogged axis is up against its limit + } + + ms.raw.isCoordinated = true; + ms.raw.canPauseAfter = true; + ms.raw.feedRate = fastSqrtf(distanceSquared)/(float)chunkClocks; + ms.raw.originalFeedRate = (float16_t)(InverseConvertSpeedToMmPerSec(ms.raw.feedRate) * MinutesToSeconds); // this field is in mm/min + ms.raw.moveStartVirtualExtruderPosition = ms.latestVirtualExtruderPosition; + + gcodes.NewSegmentableMoveAvailable(ms); + return true; +} + +// End diff --git a/src/Movement/JogController.h b/src/Movement/JogController.h new file mode 100644 index 0000000000..c38d9deb14 --- /dev/null +++ b/src/Movement/JogController.h @@ -0,0 +1,55 @@ +/* + * JogController.h + * + * Velocity-mode movement: axes are commanded by signed speed rather than by destination, so that a + * joystick or similar analogue input can drive them directly. + * + * The commanded velocity vector is turned into a stream of short constant-velocity moves that are fed + * into movement system 0. Lookahead blends consecutive chunks, and because the last move in the ring is + * always planned to end at zero speed, a stream that stops arriving decelerates the machine normally + * instead of stopping it dead. + */ + +#ifndef SRC_MOVEMENT_JOGCONTROLLER_H_ +#define SRC_MOVEMENT_JOGCONTROLLER_H_ + +#include + +class MovementState; + +class JogController +{ +public: + JogController() noexcept; + + GCodeResult ProcessM700(GCodeBuffer& gb, const StringRef& reply) THROWS(GCodeException); + void Spin() noexcept; // keep the movement queue topped up; called from GCodes::Spin + void Stop() noexcept; // stop jogging; queued motion decelerates to a halt + bool IsActive() const noexcept { return active; } + +private: + bool GenerateChunk(MovementState& ms) noexcept; + float MaxSpeedForAxis(size_t axis) const noexcept; + void ClampSpeeds() noexcept; + void ReportStatus(const StringRef& reply) const noexcept; + + static constexpr uint32_t DefaultChunkMillis = 50; + static constexpr uint32_t MinChunkMillis = 10; + static constexpr uint32_t MaxChunkMillis = 200; + static constexpr uint32_t DefaultTimeoutMillis = 250; + static constexpr uint32_t MaxTimeoutMillis = 10000; + static constexpr unsigned int DefaultMaxQueuedMoves = 3; + static constexpr unsigned int MinMaxQueuedMoves = 2; + static constexpr unsigned int MaxMaxQueuedMoves = 8; + + float requestedSpeeds[MaxAxes]; // signed commanded speed per axis, in mm (or degrees) per step clock + AxesBitmap jogAxes; // the axes with a non-zero commanded speed + uint32_t chunkMillis; // how much travel time one chunk represents + uint32_t chunkClocks; // the same, in step clocks + uint32_t timeoutMillis; // speeds are zeroed if no fresh command arrives within this time + uint32_t whenLastCommanded; + unsigned int maxQueuedMoves; // bounds both the response latency and the distance available to stop in + volatile bool active; // written by the GCode task, read by the same task only, but kept volatile for clarity +}; + +#endif /* SRC_MOVEMENT_JOGCONTROLLER_H_ */ From 706abf72f7b5547f61401d64a31a553acb9723db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien=20Metrot?= Date: Thu, 20 Aug 2026 21:23:00 +0200 Subject: [PATCH 2/9] Raise the default jog queue depth from 3 to 5 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. --- .../Velocity jogging (M700).md | 18 +++++++++++++++--- src/Movement/JogController.h | 6 +++++- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/Developer-documentation/Velocity jogging (M700).md b/Developer-documentation/Velocity jogging (M700).md index f018902986..2e6a6a39ae 100644 --- a/Developer-documentation/Velocity jogging (M700).md +++ b/Developer-documentation/Velocity jogging (M700).md @@ -15,7 +15,7 @@ M700 X Y Z ... [S0] [P] [R] [D] | `S0` | Stop jogging now. | | `P` | Chunk time in ms, 10..200, default 50. See *Latency* below. | | `R` | Watchdog timeout in ms, default 250. | -| `D` | How many moves to keep queued, 2..8, default 3. | +| `D` | How many moves to keep queued, 2..8, default 5. | | none | Report status. | **The axis letters present define the whole velocity vector.** Any axis you do not mention is set to zero. @@ -50,8 +50,20 @@ That reuse is the whole point of the design: ## Latency Response to a stick movement is roughly `D × P` plus the ~50 ms that `Move` prepares ahead -(`MoveTiming::UsualMinimumPreparedTime`) — about 150–200 ms at the defaults. Reducing `D` or `P` sharpens -the response but lowers the speed ceiling (see below) and leaves less slack for a busy main loop. +(`MoveTiming::UsualMinimumPreparedTime`) — about **300 ms** at the defaults. + +`D` defaulted to 3 originally, for ~200 ms. That does not work. Measured under the Renode emulator, a +20 Hz `M700` stream at 10 mm/s with `D3` collapses to 2.5 mm/s at chunk boundaries and loses 7% of the +commanded distance: the producer cannot keep the ring topped up, so the ring repeatedly holds a single +move, and lookahead correctly plans that move to stop at its end. `D4` is clean and `D5` is the default +for margin, since a real machine has heaters, networking and the SD card competing for the same main +loop that tops the queue up. + +If 300 ms is too slow, the honest lever is not `D` — dropping it back reintroduces the stutter. Either +shorten `P` (which also lowers the speed ceiling below) and raise `D` to keep the same buffer, or fix +the underlying handoff: `JogController::Spin` can only add one chunk per pass because it waits for +`ms.segmentsLeft` to return to zero, which makes the fill rate a ping-pong between the GCode and Move +tasks. Letting it enqueue more per pass would allow a shallower queue and lower latency. ## Safety diff --git a/src/Movement/JogController.h b/src/Movement/JogController.h index c38d9deb14..7587b5005f 100644 --- a/src/Movement/JogController.h +++ b/src/Movement/JogController.h @@ -38,7 +38,11 @@ class JogController static constexpr uint32_t MaxChunkMillis = 200; static constexpr uint32_t DefaultTimeoutMillis = 250; static constexpr uint32_t MaxTimeoutMillis = 10000; - static constexpr unsigned int DefaultMaxQueuedMoves = 3; + // Measured, not guessed. Under the Renode emulator a 20Hz M700 stream at 10mm/s stutters badly with + // a depth of 3 - velocity collapses to 2.5mm/s at chunk boundaries and 7% of the commanded distance + // is lost - because the producer cannot keep the ring topped up. 4 is clean; 5 leaves margin for a + // real machine, which has heaters, networking and the SD card competing for the same main loop. + static constexpr unsigned int DefaultMaxQueuedMoves = 5; static constexpr unsigned int MinMaxQueuedMoves = 2; static constexpr unsigned int MaxMaxQueuedMoves = 8; From 1fffee6e09a74a745f1643a50565e8ea8e4a4d82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien=20Metrot?= Date: Thu, 20 Aug 2026 21:44:23 +0200 Subject: [PATCH 3/9] Record the measured results for the jog claims 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. --- .../Velocity jogging (M700).md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/Developer-documentation/Velocity jogging (M700).md b/Developer-documentation/Velocity jogging (M700).md index 2e6a6a39ae..fdaa1b237c 100644 --- a/Developer-documentation/Velocity jogging (M700).md +++ b/Developer-documentation/Velocity jogging (M700).md @@ -65,6 +65,22 @@ the underlying handoff: `JogController::Spin` can only add one chunk per pass be `ms.segmentsLeft` to return to zero, which makes the fill rate a ping-pong between the GCode and Move tasks. Letting it enqueue more per pass would allow a shallower queue and lower latency. +## What has actually been measured + +Everything below was run under the Renode emulator (`duet3-emulation/`), driving `M700` over the +emulated aux UART and reconstructing velocity from timestamped STEP-pin edges. These are measurements, +not arguments. + +| Claim | Result | +|---|---| +| Chunks blend into steady motion | Confirmed **at `D5`**. At the original `D3` it did not: a 20Hz stream at 10mm/s collapsed to 2.5mm/s at chunk boundaries and lost 7% of the commanded distance. That is what raised the default. | +| Speed ceiling is `2·a·P` | Confirmed and linear in `P`. Commanding 90mm/s gave a measured peak of 20.08, 40.23 and 81.31 mm/s at `P` = 10, 20 and 40ms, against 20, 40 and 80 predicted. | +| Losing the command stream decelerates rather than stops dead | Confirmed. A single `M700 X10` travels 3mm and stops: the 250ms watchdog plus the chunks already queued behind it. | +| An axis at its limit stops, others keep their speed | Confirmed. With `M208 X0:5` and `M700 X10 Y10`, X stopped at exactly 5.000mm while Y carried on to 10.000mm at a steady 10.00mm/s. | + +Untested on real hardware. An emulator models what it was told to model: step timing here comes from +the TC model, and nothing checks that a real TMC5160 driver would follow the pulses. + ## Safety * **Speed clamp.** Each axis is clamped to its `M203` maximum *and* to `2·a·P`. That second limit is not From 03e9bb1a4d2683a77a5726878aa0b34c4a73757c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien=20Metrot?= Date: Fri, 21 Aug 2026 15:49:14 +0200 Subject: [PATCH 4/9] Cut jog latency from 257ms to 50ms 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. --- .../Velocity jogging (M700).md | 49 ++++++++----------- src/Movement/JogController.h | 18 ++++--- 2 files changed, 33 insertions(+), 34 deletions(-) diff --git a/Developer-documentation/Velocity jogging (M700).md b/Developer-documentation/Velocity jogging (M700).md index fdaa1b237c..f5de31497f 100644 --- a/Developer-documentation/Velocity jogging (M700).md +++ b/Developer-documentation/Velocity jogging (M700).md @@ -13,9 +13,9 @@ M700 X Y Z ... [S0] [P] [R] [D] |---|---| | axis letters | Signed speed for that axis in **mm/sec** (degrees/sec for rotational axes). `G20` does *not* rescale these. | | `S0` | Stop jogging now. | -| `P` | Chunk time in ms, 10..200, default 50. See *Latency* below. | +| `P` | Chunk time in ms, 10..200, default 20. See *Latency* below. | | `R` | Watchdog timeout in ms, default 250. | -| `D` | How many moves to keep queued, 2..8, default 5. | +| `D` | How many moves to keep queued, 2..8, default 2. | | none | Report status. | **The axis letters present define the whole velocity vector.** Any axis you do not mention is set to zero. @@ -49,37 +49,30 @@ That reuse is the whole point of the design: ## Latency -Response to a stick movement is roughly `D × P` plus the ~50 ms that `Move` prepares ahead -(`MoveTiming::UsualMinimumPreparedTime`) — about **300 ms** at the defaults. +Measured on the emulator, timing from command injection to the step pins changing rate: -`D` defaulted to 3 originally, for ~200 ms. That does not work. Measured under the Renode emulator, a -20 Hz `M700` stream at 10 mm/s with `D3` collapses to 2.5 mm/s at chunk boundaries and loses 7% of the -commanded distance: the producer cannot keep the ring topped up, so the ring repeatedly holds a single -move, and lookahead correctly plans that move to stop at its end. `D4` is clean and `D5` is the default -for margin, since a real machine has heaters, networking and the SD card competing for the same main -loop that tops the queue up. +| `D` | `P` | Latency | Ceiling | +|---|---|---|---| +| 5 | 50 ms | 257 ms | 100 mm/s | +| 3 | 20 ms | 126 ms | 40 mm/s | +| **2** | **20 ms** | **50 ms** | **40 mm/s** | +| 2 | 15 ms | 62 ms | 30 mm/s | +| 2 | 10 ms | 127 ms | 20 mm/s | -If 300 ms is too slow, the honest lever is not `D` — dropping it back reintroduces the stutter. Either -shorten `P` (which also lowers the speed ceiling below) and raise `D` to keep the same buffer, or fix -the underlying handoff: `JogController::Spin` can only add one chunk per pass because it waits for -`ms.segmentsLeft` to return to zero, which makes the fill rate a ping-pong between the GCode and Move -tasks. Letting it enqueue more per pass would allow a shallower queue and lower latency. +The defaults are `D2 P20` because that is the measured optimum, not a compromise: latency stops +following `D x P` below roughly 40ms of queued motion and then gets **worse**, because `Move` wants +about `MoveTiming::UsualMinimumPreparedTime` (50ms, absolute minimum 25ms) queued before it will run +moves. Shortening the chunk or the queue past that point makes jogging slower to respond, not faster. -## What has actually been measured +Doubling the command rate (20ms to 10ms cadence) changed the result by 0.3ms, so this floor is in the +firmware, not in how fast a host can send. -Everything below was run under the Renode emulator (`duet3-emulation/`), driving `M700` over the -emulated aux UART and reconstructing velocity from timestamped STEP-pin edges. These are measurements, -not arguments. +**To go below ~50ms** you have to reduce the preparation window in `MoveTiming`, which also affects +print move preparation and CAN expansion timing. That is a machine-wide trade and has not been made +here. -| Claim | Result | -|---|---| -| Chunks blend into steady motion | Confirmed **at `D5`**. At the original `D3` it did not: a 20Hz stream at 10mm/s collapsed to 2.5mm/s at chunk boundaries and lost 7% of the commanded distance. That is what raised the default. | -| Speed ceiling is `2·a·P` | Confirmed and linear in `P`. Commanding 90mm/s gave a measured peak of 20.08, 40.23 and 81.31 mm/s at `P` = 10, 20 and 40ms, against 20, 40 and 80 predicted. | -| Losing the command stream decelerates rather than stops dead | Confirmed. A single `M700 X10` travels 3mm and stops: the 250ms watchdog plus the chunks already queued behind it. | -| An axis at its limit stops, others keep their speed | Confirmed. With `M208 X0:5` and `M700 X10 Y10`, X stopped at exactly 5.000mm while Y carried on to 10.000mm at a steady 10.00mm/s. | - -Untested on real hardware. An emulator models what it was told to model: step timing here comes from -the TC model, and nothing checks that a real TMC5160 driver would follow the pulses. +**To get more speed at the same latency, raise acceleration** rather than lengthening the chunk. The +ceiling is `2.a.P`, so `M201 X4000` with `P=20` gives 160mm/s at the same 50ms. ## Safety diff --git a/src/Movement/JogController.h b/src/Movement/JogController.h index 7587b5005f..5339d292fa 100644 --- a/src/Movement/JogController.h +++ b/src/Movement/JogController.h @@ -33,16 +33,22 @@ class JogController void ClampSpeeds() noexcept; void ReportStatus(const StringRef& reply) const noexcept; - static constexpr uint32_t DefaultChunkMillis = 50; + // Measured on the emulator, timing from command injection to the step pins changing rate: + // D=5 P=50 -> 257ms D=3 P=20 -> 126ms D=2 P=20 -> 50ms + // D=2 P=15 -> 62ms D=2 P=10 -> 127ms + // Latency stops following D*P below about 40ms of queued motion and then gets worse, because Move + // wants roughly MoveTiming::UsualMinimumPreparedTime queued before it will run moves. So P=20/D=2 + // is an optimum rather than a compromise - shortening either makes it slower, not faster. + // Doubling the command rate changed nothing (50.3 -> 50.0ms), so this is the firmware, not the host. + static constexpr uint32_t DefaultChunkMillis = 20; static constexpr uint32_t MinChunkMillis = 10; static constexpr uint32_t MaxChunkMillis = 200; static constexpr uint32_t DefaultTimeoutMillis = 250; static constexpr uint32_t MaxTimeoutMillis = 10000; - // Measured, not guessed. Under the Renode emulator a 20Hz M700 stream at 10mm/s stutters badly with - // a depth of 3 - velocity collapses to 2.5mm/s at chunk boundaries and 7% of the commanded distance - // is lost - because the producer cannot keep the ring topped up. 4 is clean; 5 leaves margin for a - // real machine, which has heaters, networking and the SD card competing for the same main loop. - static constexpr unsigned int DefaultMaxQueuedMoves = 5; + // 2 with a 20ms chunk measured clean - no stutter over a 20Hz stream - and is what gets latency to + // 50ms. The earlier stutter at depth 3 was with 50ms chunks, where the ring holds far more time and + // the producer has correspondingly longer to fall behind. + static constexpr unsigned int DefaultMaxQueuedMoves = 2; static constexpr unsigned int MinMaxQueuedMoves = 2; static constexpr unsigned int MaxMaxQueuedMoves = 8; From 202bffd98a10c6700ed79376a162f18ef9ca8807 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien=20Metrot?= Date: Fri, 21 Aug 2026 20:56:02 +0200 Subject: [PATCH 5/9] Correct the M700 latency explanation: the stated cause was wrong 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 Claude-Session: https://claude.ai/code/session_0132JC621uq1434yhpzBNNgJ --- .../Velocity jogging (M700).md | 36 ++++++++++++------- src/Movement/JogController.h | 17 +++++---- 2 files changed, 33 insertions(+), 20 deletions(-) diff --git a/Developer-documentation/Velocity jogging (M700).md b/Developer-documentation/Velocity jogging (M700).md index f5de31497f..b988d3bfcf 100644 --- a/Developer-documentation/Velocity jogging (M700).md +++ b/Developer-documentation/Velocity jogging (M700).md @@ -55,24 +55,34 @@ Measured on the emulator, timing from command injection to the step pins changin |---|---|---|---| | 5 | 50 ms | 257 ms | 100 mm/s | | 3 | 20 ms | 126 ms | 40 mm/s | -| **2** | **20 ms** | **50 ms** | **40 mm/s** | -| 2 | 15 ms | 62 ms | 30 mm/s | -| 2 | 10 ms | 127 ms | 20 mm/s | +| 2 | 30 ms | 90 ms | 60 mm/s | +| 2 | 25 ms | 67 ms | 50 mm/s | +| **2** | **20 ms** | **38.5 ms** | **40 mm/s** | +| 2 | 15 ms | 44.6 ms | 30 mm/s | +| 2 | 10 ms | never reaches 15 mm/s | 20 mm/s | -The defaults are `D2 P20` because that is the measured optimum, not a compromise: latency stops -following `D x P` below roughly 40ms of queued motion and then gets **worse**, because `Move` wants -about `MoveTiming::UsualMinimumPreparedTime` (50ms, absolute minimum 25ms) queued before it will run -moves. Shortening the chunk or the queue past that point makes jogging slower to respond, not faster. +The defaults are `D2 P20` because that is the measured optimum, not a compromise. Above it latency +tracks the queued chunk time `D x P`, as a FIFO should. Below roughly 40ms of queued motion it stops +following `D x P` and gets **worse**, and shortening `P` far enough stops the axis reaching the +commanded speed at all. -Doubling the command rate (20ms to 10ms cadence) changed the result by 0.3ms, so this floor is in the -firmware, not in how fast a host can send. +**What that floor is not.** Three plausible explanations were measured and none of them holds: -**To go below ~50ms** you have to reduce the preparation window in `MoveTiming`, which also affects -print move preparation and CAN expansion timing. That is a machine-wide trade and has not been made -here. +| hypothesis | test | result | +|---|---|---| +| `Move` wants `MoveTiming::UsualMinimumPreparedTime` queued | halved it, 50ms to 25ms | 50.3 -> 50.2 ms: no effect | +| lookahead grace period delays the first move | `M595 R0`, and `R0 P40` | ~2 ms | +| the host cannot send fast enough | doubled command rate to 10ms cadence | 0.3 ms | + +Sizing the chunk adaptively to the requested speed - a short chunk for a slow jog, which the `2.a.P` +ceiling says should be safe - was also implemented and measured, and is much worse: 3 to 15 mm/s went +39.5 -> 75.4 ms and 1 to 3 mm/s went 79 -> 245 ms. It is not in the tree. + +So the sub-40ms floor is real and its cause is not yet identified. Going below it needs a mechanism +this design does not have: revising a chunk that is already queued, rather than waiting it out. **To get more speed at the same latency, raise acceleration** rather than lengthening the chunk. The -ceiling is `2.a.P`, so `M201 X4000` with `P=20` gives 160mm/s at the same 50ms. +ceiling is `2.a.P`, so `M201 X4000` with `P=20` gives 160mm/s at the same ~38ms. ## Safety diff --git a/src/Movement/JogController.h b/src/Movement/JogController.h index 5339d292fa..b631d95566 100644 --- a/src/Movement/JogController.h +++ b/src/Movement/JogController.h @@ -33,13 +33,16 @@ class JogController void ClampSpeeds() noexcept; void ReportStatus(const StringRef& reply) const noexcept; - // Measured on the emulator, timing from command injection to the step pins changing rate: - // D=5 P=50 -> 257ms D=3 P=20 -> 126ms D=2 P=20 -> 50ms - // D=2 P=15 -> 62ms D=2 P=10 -> 127ms - // Latency stops following D*P below about 40ms of queued motion and then gets worse, because Move - // wants roughly MoveTiming::UsualMinimumPreparedTime queued before it will run moves. So P=20/D=2 - // is an optimum rather than a compromise - shortening either makes it slower, not faster. - // Doubling the command rate changed nothing (50.3 -> 50.0ms), so this is the firmware, not the host. + // Latency is dominated by the chunk time already queued ahead of the change: the chunks are a FIFO + // and a new speed takes effect only once the queued ones have run. Measured on the emulator, command + // injection to the step pins changing rate, D=2: P=15 -> 44.6ms, P=20 -> 38.5ms, P=25 -> 67.2ms, + // P=30 -> 90.2ms, and P=10 cannot sustain 15mm/s at all (see MaxSpeedForAxis). So P=20/D=2 is a real + // optimum: shortening P makes it worse, not better. Sizing the chunk adaptively to the requested + // speed - short chunk for a slow jog - was tried and is much worse (3->15mm/s: 39.5 -> 75.4ms; + // 1->3mm/s: 79 -> 245ms). Whatever sets the floor below ~40ms of queued motion, it is not the + // queue arithmetic, and two plausible culprits were measured and cleared: MoveTiming's preparation + // window (halving UsualMinimumPreparedTime to 25ms moved 50.3 -> 50.2ms) and the lookahead grace + // period (M595 R0 is worth about 2ms). Doubling the host command rate changed nothing either. static constexpr uint32_t DefaultChunkMillis = 20; static constexpr uint32_t MinChunkMillis = 10; static constexpr uint32_t MaxChunkMillis = 200; From 16e033c1ac4e1a93248f70c83977cb55450ed5bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien=20Metrot?= Date: Fri, 21 Aug 2026 21:03:04 +0200 Subject: [PATCH 6/9] Do not report "busy" while jogging 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 Claude-Session: https://claude.ai/code/session_0132JC621uq1434yhpzBNNgJ --- src/Platform/RepRap.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Platform/RepRap.cpp b/src/Platform/RepRap.cpp index d6c1338649..f1845ecd9f 100644 --- a/src/Platform/RepRap.cpp +++ b/src/Platform/RepRap.cpp @@ -1936,7 +1936,12 @@ size_t RepRap::GetStatusIndex() const noexcept : 9 // Printing ) : (gCodes->IsDoingToolChange()) ? 10 // Changing tool - : (gCodes->DoingFileMacro() || !move->NoLiveMovement() || + // Jog motion deliberately does not count as busy. Jogging is continuous by nature, so it would + // pin the status at "busy" for as long as the operator holds the stick, and clients such as DWC + // and AxisControl grey their controls out when busy - including the very controls sending the + // jog commands. The machine is manually controlled and accepting commands, which is idle. + : (gCodes->DoingFileMacro() || + (!move->NoLiveMovement() && !gCodes->GetJogController().IsActive()) || gCodes->WaitingForAcknowledgement() || heat->IsTuningHeater()) ? 11 // Busy : 12; // Idle From 2f09a574fe3deff52fbba63ca82d5742289cd9a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien=20Metrot?= Date: Sun, 23 Aug 2026 12:41:36 +0200 Subject: [PATCH 7/9] Fix a jog position leak on sub-threshold speeds 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 Claude-Session: https://claude.ai/code/session_0132JC621uq1434yhpzBNNgJ --- .gitignore | 8 ++++++-- src/Movement/JogController.cpp | 15 ++++++++++++++- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 776b630011..b8b9ab2f7e 100644 --- a/.gitignore +++ b/.gitignore @@ -26,9 +26,13 @@ /FMDC_V02_Debug/ /FMDC_V03/ /Duet3_MB6HC_no_SD/ +/Duet3_MB6HC_embedded/ /Duet3_MB6HC_no_S_curve/ -/INDX/ +/tests/build/ /.clangd /.clang-format /src/Temp/ -/.settings/ + +# Build intermediates and editor droppings that have been committed by accident before +INDX/ +.DS_Store diff --git a/src/Movement/JogController.cpp b/src/Movement/JogController.cpp index 64358c3235..ca0a30fd45 100644 --- a/src/Movement/JogController.cpp +++ b/src/Movement/JogController.cpp @@ -44,6 +44,13 @@ void JogController::ClampSpeeds() noexcept { const float limit = MaxSpeedForAxis(axis); requestedSpeeds[axis] = constrain(requestedSpeeds[axis], -limit, limit); + // A speed below one chunk's minimum distance cannot be expressed at all, so treat it as zero rather + // than as a jog that generates nothing. Otherwise the axis counts as jogging, keeps the machine out + // of idle, and produces a chunk per pass that is only thrown away. + if (fabsf(requestedSpeeds[axis]) * (float)chunkClocks < MinChunkDistance) + { + requestedSpeeds[axis] = 0.0; + } if (requestedSpeeds[axis] != 0.0) { jogAxes.SetBit(axis); @@ -273,7 +280,13 @@ bool JogController::GenerateChunk(MovementState& ms) noexcept if (distanceSquared < fsquare(MinChunkDistance)) { - return false; // every jogged axis is up against its limit + // Nothing worth moving. The target has to be put back, not just abandoned: currentUserPosition was + // already advanced above, and SetMoveBufferDefaults seeds initialCoords from raw.coords, so a + // rejected chunk would otherwise become the next chunk's baseline and the reported position would + // climb at the commanded speed while the machine stood still, with nothing ever resyncing it. + memcpyf(ms.raw.coords, ms.initialCoords, numVisibleAxes); + gcodes.ToolOffsetInverseTransform(ms, ms.raw.coords, ms.currentUserPosition); + return false; } ms.raw.isCoordinated = true; From 1cccfb19138cd0ab7fbfc21ab5631ffb2d8e84a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien=20Metrot?= Date: Sun, 23 Aug 2026 13:08:45 +0200 Subject: [PATCH 8/9] Stop jogging while a macro or a tool change is running 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 Claude-Session: https://claude.ai/code/session_0132JC621uq1434yhpzBNNgJ --- src/Movement/JogController.cpp | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/Movement/JogController.cpp b/src/Movement/JogController.cpp index ca0a30fd45..d6b8305c51 100644 --- a/src/Movement/JogController.cpp +++ b/src/Movement/JogController.cpp @@ -205,8 +205,14 @@ void JogController::Spin() noexcept return; } - // Something else has taken over movement, so get out of the way. - if (gcodes.IsReallyPrintingOrResuming()) + // Something else has taken over movement, so get out of the way. A macro or a tool change moves axes + // on its own account, and jogging underneath it would fight it for the same movement system. + // DoingFileMacro deliberately excludes daemon.g (GCodes.cpp:374), so a daemon running on its usual + // cycle does not chop the jog stream up. + // Deliberately NOT included: WaitingForAcknowledgement. "Jog to the workpiece corner, then press OK" + // is a standard CNC setup pattern, and the machine is stationary with the operator at the controls, + // so blocking it would remove a genuinely useful workflow for no safety gain. + if (gcodes.IsReallyPrintingOrResuming() || gcodes.DoingFileMacro() || gcodes.IsDoingToolChange()) { Stop(); return; From df6c377610c508fe5d5cf9bdb3c9ac4ac79fd202 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien=20Metrot?= Date: Fri, 28 Aug 2026 12:29:01 +0200 Subject: [PATCH 9/9] Remove the M700 speed ceiling; let M203 be the only per-axis limit MaxSpeedForAxis clamped to 2.a.P, so a machine with M203 X6000 could only be jogged at 20mm/s at the default chunk time, and reaching real traverse speed meant inflating P to ~170ms and paying D*P of latency for it. That clamp was never a safety property. It came from every chunk having to be stoppable within itself, because DDA::InitStandardMove gives a move endSpeed = 0 (DDA.cpp:624) until a following move exists, and a singly-generated chunk never had one. The machine does not need to stop within one chunk; it needs to be able to decelerate from its current speed, which takes v/a however it was commanded. Queueing enough chunks that each has a successor lets lookahead blend them, which is what an ordinary G-code stream already relies on. Measured at 100mm/s on a two-driver axis (M584 Y0.1:0.2, M569 P0.2 S0): D2 P20 (old) 53% of commanded speed delivered D6 P10 57% - short chunks do not give the planner enough to blend D10 P6 62% - nor does adding more of them (reproduced 3/3) D6 P20 93% D8 P15 97%, S0 stops in 153ms (reproduced 2/2) <- new defaults Also fixed a misdiagnosis recorded in the previous defaults: the dead gap after every chunk was not a GCode-to-Move refill latency. Instrumenting the gates showed the segmentsLeft handoff never blocks at all. A chunk with no successor is planned to stop within itself, and a 2mm chunk decelerating to rest takes 2.sqrt(d/a) = 89ms rather than its nominal 20ms. That was the gap. Per-axis clamping now reports itself, since the ceiling is no longer predictable from P: Jogging active, chunk 15ms, timeout 250ms, queue 8, clamped to axis maximum: Y100.0, speeds Y100.0 Verified on the emulator, all on the two-driver axis: step 0->100->0 ramps under M201; reversal at 100mm/s ramps through zero rather than stepping; the watchdog decelerates over 88.7ms; S0 stops in 153ms. Not delivered: S0 within one deceleration ramp. The floor is queued time plus v/a, ~153ms at 100mm/s. Discarding queued chunks would need canPauseAfter, which requires endSpeed <= M566 (DDA.cpp:1093); a blended chunk ends near full jog speed, so nothing in a fast jog is abandonable. Blending and instant truncation are mutually exclusive without feed hold, which RRF does not have (DDA_3rdOrder.cpp:119, and the unused 'stopping' parameter to PlanMoves). P, D and R keep their meaning and no longer bound the achievable speed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0132JC621uq1434yhpzBNNgJ --- src/Movement/JogController.cpp | 33 +++++++++++++++++++++++++-------- src/Movement/JogController.h | 23 +++++++++++++++++++---- 2 files changed, 44 insertions(+), 12 deletions(-) diff --git a/src/Movement/JogController.cpp b/src/Movement/JogController.cpp index d6b8305c51..bf5ce22458 100644 --- a/src/Movement/JogController.cpp +++ b/src/Movement/JogController.cpp @@ -24,25 +24,31 @@ JogController::JogController() noexcept } } -// The highest speed we are prepared to run this axis at. -// Besides the configured maximum there is a limit inherent in feeding the queue a chunk at a time. DDA::InitStandardMove -// caps the entry speed of every move at sqrt(2.a.d) for that move alone, so that the move can always be the last one in -// the ring and still stop at its end. With d = v.chunkTime that solves to v <= 2.a.chunkTime. Commanding more than this -// would not go any faster, it would just quietly not be obeyed - so clamp to it and let the caller see the real ceiling. -// Jogging faster means longer chunks, which means more latency; that trade-off is the P parameter of M700. +// The highest speed we are prepared to run this axis at: its configured maximum, and nothing else. +// +// This used to also clamp to 2.a.P. That came from every chunk having to be stoppable within itself, +// because DDA::InitStandardMove sets endSpeed = 0 (DDA.cpp:624) until a following move exists, and a +// singly-generated chunk never had one. The machine never needed to stop within one chunk; it needs to +// be able to decelerate from its current speed, which takes v/a however the motion was commanded. +// Keeping enough chunks queued that each has a successor lets lookahead blend them, which is what an +// ordinary G-code stream already relies on. float JogController::MaxSpeedForAxis(size_t axis) const noexcept { - const Move& move = reprap.GetMove(); - return min(move.MaxFeedrate(axis), 2.0 * move.NormalAcceleration(axis) * (float)chunkClocks); + return reprap.GetMove().MaxFeedrate(axis); } void JogController::ClampSpeeds() noexcept { const size_t numVisibleAxes = reprap.GetGCodes().GetVisibleAxes(); jogAxes.Clear(); + clampedAxes.Clear(); for (size_t axis = 0; axis < numVisibleAxes; ++axis) { const float limit = MaxSpeedForAxis(axis); + if (fabsf(requestedSpeeds[axis]) > limit) + { + clampedAxes.SetBit(axis); // the host asked for more than M203 allows; say so rather than silently obeying something else + } requestedSpeeds[axis] = constrain(requestedSpeeds[axis], -limit, limit); // A speed below one chunk's minimum distance cannot be expressed at all, so treat it as zero rather // than as a jog that generates nothing. Otherwise the axis counts as jogging, keeps the machine out @@ -78,6 +84,17 @@ void JogController::ReportStatus(const StringRef& reply) const noexcept const char *_ecv_array const axisLetters = gcodes.GetAxisLetters(); reply.printf("Jogging %s, chunk %" PRIu32 "ms, timeout %" PRIu32 "ms, queue %u", (active) ? "active" : "inactive", chunkMillis, timeoutMillis, maxQueuedMoves); + if (clampedAxes.IsNonEmpty()) + { + reply.cat(", clamped to axis maximum:"); + for (size_t axis = 0; axis < gcodes.GetVisibleAxes(); ++axis) + { + if (clampedAxes.IsBitSet(axis)) + { + reply.catf(" %c%.1f", axisLetters[axis], (double)InverseConvertSpeedToMmPerSec(requestedSpeeds[axis])); + } + } + } if (active) { reply.cat(", speeds"); diff --git a/src/Movement/JogController.h b/src/Movement/JogController.h index b631d95566..3c827740cd 100644 --- a/src/Movement/JogController.h +++ b/src/Movement/JogController.h @@ -43,7 +43,7 @@ class JogController // queue arithmetic, and two plausible culprits were measured and cleared: MoveTiming's preparation // window (halving UsualMinimumPreparedTime to 25ms moved 50.3 -> 50.2ms) and the lookahead grace // period (M595 R0 is worth about 2ms). Doubling the host command rate changed nothing either. - static constexpr uint32_t DefaultChunkMillis = 20; + static constexpr uint32_t DefaultChunkMillis = 15; static constexpr uint32_t MinChunkMillis = 10; static constexpr uint32_t MaxChunkMillis = 200; static constexpr uint32_t DefaultTimeoutMillis = 250; @@ -51,12 +51,27 @@ class JogController // 2 with a 20ms chunk measured clean - no stutter over a 20Hz stream - and is what gets latency to // 50ms. The earlier stutter at depth 3 was with 50ms chunks, where the ring holds far more time and // the producer has correspondingly longer to fall behind. - static constexpr unsigned int DefaultMaxQueuedMoves = 2; + // Blending depends on how MANY moves are queued; stopping distance depends on how much TIME they + // represent. Those are separable, which is why the defaults are a deep queue of short chunks rather + // than a shallow queue of long ones. Measured at 100mm/s on a two-driver axis: + // D2 P20 (old) 53% of commanded delivered + // D6 P10 57% delivered - short chunks do not give the planner enough to blend + // D10 P6 62% delivered - nor does adding more of them (reproduced 3/3) + // D6 P20 93% delivered, S0 stops in 156ms + // D8 P15 97% delivered, S0 stops in 153ms (reproduced 2/2) <- these defaults + // Shrinking P to cut the queued time was the obvious way to make S0 stop sooner while keeping + // enough moves to blend. It does not work: blending needs chunks long enough to be worth planning + // together, not merely numerous, so P below about 15ms costs a third of the commanded speed. + // A move with no successor is planned to stop within itself, and a 2mm chunk decelerating to rest + // takes 2.sqrt(d/a) = 89ms rather than its nominal 20ms. That, not any task handoff, is what used + // to leave a dead gap after every chunk. + static constexpr unsigned int DefaultMaxQueuedMoves = 8; static constexpr unsigned int MinMaxQueuedMoves = 2; - static constexpr unsigned int MaxMaxQueuedMoves = 8; + static constexpr unsigned int MaxMaxQueuedMoves = 16; float requestedSpeeds[MaxAxes]; // signed commanded speed per axis, in mm (or degrees) per step clock - AxesBitmap jogAxes; // the axes with a non-zero commanded speed + AxesBitmap jogAxes; + AxesBitmap clampedAxes; // axes whose requested speed was reduced to the axis maximum // the axes with a non-zero commanded speed uint32_t chunkMillis; // how much travel time one chunk represents uint32_t chunkClocks; // the same, in step clocks uint32_t timeoutMillis; // speeds are zeroed if no fresh command arrives within this time