ExternalAxis: option (b) homing - user A=0 at home, deterministic on re-home

Three changes that together implement option (b) home semantics:

1. Mach.home for the external axis: replace G28.3 with explicit
   AVR position sync (Cmd.set_axis) + planner abs sync
   (position_change) + G92 a0 (set user-coord origin to current
   physical position, computing offset = home_position_mm).

   G28.3 was wrong: it preserves the current user-coord position
   and adjusts the offset to bridge to the new abs. After a move
   away from home and a re-home, the offset accumulates
   (134 -> 268 -> ...). G92 a0 with a freshly-synced abs always
   produces offset = home_position_mm regardless of prior state.

2. Planner.__encode: stop stripping the external axis target from
   the AVR line. The AVR has no motor mapped to A so it steps no
   motor, but exec_move_to_target updates ex.position[A] which
   gets reported back as ap. Leaving A in the AVR target keeps
   state.ap consistent with gplan's idea of A; stripping it left
   ex.position[A] stale and clobbered ExternalAxis's state.ap on
   the next status report.

   Side benefit: removes the special-case empty-string return for
   pure external moves; every line block follows the same path now.

3. ExternalAxis.enqueue_target_mm: stop writing to state.<axis>p
   from the planner hot path. The AVR's status reports drive it
   instead, which avoids DRO jitter (jump to target then snap back
   to intermediate values as the trapezoid runs). _pos_mm internal
   mirror is still updated for delta computation.

Re-verified with the integration smoke test in tmp/20260503_option_b/:
home/move-down/move-up/re-home/home-from-bottom all produce the
expected DRO position values (0 at home, -134 at bottom).
This commit is contained in:
2026-05-03 10:46:47 +02:00
parent 7cdab010b3
commit 56c3406f25
4 changed files with 93 additions and 63 deletions

View File

@@ -265,7 +265,11 @@ class ExternalAxis(object):
"""Synchronously run an external move. Blocks until the ESP
reports done. Used by the legacy /api/aux/move and /api/aux/jog
endpoints which may want to wait. Most planner-driven motion
goes through enqueue_target_mm instead, which is non-blocking."""
goes through enqueue_target_mm instead, which is non-blocking.
Updates state.<axis>p immediately on completion. For the
planner-driven path that goes through enqueue_target_mm, the
AVR's own ap reports drive state.<axis>p instead."""
if not self.enabled:
raise ExternalAxisError(
'External axis %r not available (aux disabled or '
@@ -286,29 +290,30 @@ class ExternalAxis(object):
self._busy.clear()
def enqueue_target_mm(self, ext_mm):
"""Non-blocking variant: post a target to the worker queue
and update the host's notion of the axis position immediately
so subsequent line splits compute correct deltas.
"""Non-blocking variant: post a target to the worker queue.
Used by Planner.__encode in the hot path.
We deliberately do NOT mirror the new target into state.<axis>p
here. The AVR also receives the line block (we don't strip
the external axis from the AVR target) and its trapezoid
progressively updates ex.position[A] -> reports back as ap.
If we set state.ap here ahead of the AVR, the DRO would jump
to the target and then snap back to intermediate values as
AVR reports stream in. Instead we let the AVR drive state.ap.
We still mirror the new abs into our internal _pos_mm so
the next line block computes the correct delta.
The Planner.__encode hook calls this so the AVR comm thread
is never blocked by serial RPCs to the ESP. v1 accepts that
XYZ on the AVR and A on the ESP run concurrently when they
appear on the same gcode line; the planner's S-curve math is
applied to both, so velocities and accelerations are bounded
by whichever axis is most constrained."""
is never blocked by serial RPCs to the ESP."""
if not self.enabled:
raise ExternalAxisError(
'External axis %r not available' % self.axis_letter)
steps, abs_mm = self._compute_move(ext_mm)
# Update host position immediately so the next line block
# sees the new absolute target as the starting point.
# Internal mirror only - drives subsequent delta computation.
# state.<axis>p is left to the AVR's status reports.
self._pos_mm = abs_mm
self.ctrl.state.set(self.axis_letter + 'p', self._pos_mm)
if steps == 0:
return
# Enqueue. The worker fires the RPC; if it fails it logs
# and we keep going - aborting motion is the user's job
# via the planner stop / e-stop.
self._work_q.put(('move', steps))
def _compute_move(self, ext_mm):