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

@@ -92,17 +92,21 @@ script#control-view-template(type="text/x-template")
.fa.fa-arrow-down.ico(style="transform: rotate(-45deg)") .fa.fa-arrow-down.ico(style="transform: rotate(-45deg)")
button.jbtn(@click="jog_fn(0, 0, -1, 0)") Z button.jbtn(@click="jog_fn(0, 0, -1, 0)") Z
// Row 4 — W axis (auxcnc) when enabled. // Row 4 — A axis (the auxcnc-driven external axis) when enabled.
// W- | W+ | Probe XYZ | Probe Z // A- | A+ | Probe XYZ | Probe Z
// "Home W" lives in the DRO table's actions column on the // "Home A" lives in the DRO table's actions column on the
// right, so it doesn't need a tile here. // right, so it doesn't need a tile here. The legacy w.enabled
template(v-if="w.enabled") // gate is kept so older installs (where the auxcnc axis still
button.jbtn(@click="aux_jog_incr(-1)", :disabled="!w.enabled") // appears as W via the side-channel) keep working.
template(v-if="w.enabled || a.enabled")
button.jbtn(@click="aux_jog_incr(-1)",
:disabled="!(w.enabled || a.enabled)")
.fa.fa-arrow-down.ico .fa.fa-arrow-down.ico
span.lbl W span.lbl A
button.jbtn(@click="aux_jog_incr(+1)", :disabled="!w.enabled") button.jbtn(@click="aux_jog_incr(+1)",
:disabled="!(w.enabled || a.enabled)")
.fa.fa-arrow-up.ico .fa.fa-arrow-up.ico
span.lbl W+ span.lbl A+
button.jbtn(@click="showProbeDialog('xyz')", button.jbtn(@click="showProbeDialog('xyz')",
:class="{'load-on': !state['pw']}") :class="{'load-on': !state['pw']}")
.fa.fa-bullseye.ico .fa.fa-bullseye.ico

View File

@@ -265,7 +265,11 @@ class ExternalAxis(object):
"""Synchronously run an external move. Blocks until the ESP """Synchronously run an external move. Blocks until the ESP
reports done. Used by the legacy /api/aux/move and /api/aux/jog reports done. Used by the legacy /api/aux/move and /api/aux/jog
endpoints which may want to wait. Most planner-driven motion 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: if not self.enabled:
raise ExternalAxisError( raise ExternalAxisError(
'External axis %r not available (aux disabled or ' 'External axis %r not available (aux disabled or '
@@ -286,29 +290,30 @@ class ExternalAxis(object):
self._busy.clear() self._busy.clear()
def enqueue_target_mm(self, ext_mm): def enqueue_target_mm(self, ext_mm):
"""Non-blocking variant: post a target to the worker queue """Non-blocking variant: post a target to the worker queue.
and update the host's notion of the axis position immediately Used by Planner.__encode in the hot path.
so subsequent line splits compute correct deltas.
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 The Planner.__encode hook calls this so the AVR comm thread
is never blocked by serial RPCs to the ESP. v1 accepts that is never blocked by serial RPCs to the ESP."""
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."""
if not self.enabled: if not self.enabled:
raise ExternalAxisError( raise ExternalAxisError(
'External axis %r not available' % self.axis_letter) 'External axis %r not available' % self.axis_letter)
steps, abs_mm = self._compute_move(ext_mm) steps, abs_mm = self._compute_move(ext_mm)
# Update host position immediately so the next line block # Internal mirror only - drives subsequent delta computation.
# sees the new absolute target as the starting point. # state.<axis>p is left to the AVR's status reports.
self._pos_mm = abs_mm self._pos_mm = abs_mm
self.ctrl.state.set(self.axis_letter + 'p', self._pos_mm)
if steps == 0: if steps == 0:
return 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)) self._work_q.put(('move', steps))
def _compute_move(self, ext_mm): def _compute_move(self, ext_mm):

View File

@@ -334,6 +334,24 @@ class Mach(Comm):
# External axes (e.g. the auxcnc-driven A axis) home via # External axes (e.g. the auxcnc-driven A axis) home via
# their own ESP-side homing routine; the standard # their own ESP-side homing routine; the standard
# G28.2 / G38.6 / latch sequence doesn't apply. # G28.2 / G38.6 / latch sequence doesn't apply.
#
# After homing we want a deterministic outcome regardless
# of where the user was before:
# physical position = home_position_mm (e.g. 134 mm)
# work-coord origin = home position (user A = 0)
# work offset = home_position_mm (so abs - off = 0)
#
# ext.home() blocks on the ESP and updates state.ap to
# home_position_mm. We then need to tell the AVR (so its
# ex.position[A] matches physical reality) and gplan
# (so trajectory planning sees abs at home).
#
# We deliberately avoid G28.3 here: gplan's G28.3 keeps the
# current user-coord position fixed and adjusts the offset
# to match the new abs, which means re-homing after a move
# accumulates offset (134 -> 268 -> ...). Using G92 a0
# *after* syncing abs gives the desired "user A = 0 here"
# outcome with offset = home_position every time.
ext = getattr(self.ctrl, 'ext_axis', None) ext = getattr(self.ctrl, 'ext_axis', None)
if ext is not None and ext.enabled \ if ext is not None and ext.enabled \
and ext.axis_letter == axis.lower(): and ext.axis_letter == axis.lower():
@@ -341,16 +359,20 @@ class Mach(Comm):
continue continue
self.mlog.info('Homing external %s axis via auxcnc' % self.mlog.info('Homing external %s axis via auxcnc' %
axis.upper()) axis.upper())
# ext.home() blocks on the ESP. Use the lower-level
# planner.mdi (not Mach.mdi) so we don't try to
# _begin_cycle('mdi') from inside the home-all loop
# which is already in the 'homing' cycle.
try: try:
self._begin_cycle('homing') self._begin_cycle('homing')
ext.home() ext.home()
self.planner.mdi( home_mm = ext.home_position_mm
'G28.3 %c%f' % (axis, ext.home_position_mm), # 1) Update AVR: no motor steps, just position sync.
False) super().queue_command(Cmd.set_axis(axis, home_mm))
# 2) Force planner to resync abs from State on the
# next planner call (which is the MDI below).
self.planner.position_change()
# 3) G92 <axis>0: with abs already at home_mm,
# sets user-coord A = 0 and offset = home_mm.
# Use planner.mdi (not Mach.mdi) so we don't
# flip cycle to 'mdi' inside the 'homing' cycle.
self.planner.mdi('G92 %c0' % axis, False)
super().resume() super().resume()
except Exception as e: except Exception as e:
self.mlog.error( self.mlog.error(

View File

@@ -272,17 +272,11 @@ class Planner():
if type == 'line': if type == 'line':
ext = self._external_axis_for_line(block) ext = self._external_axis_for_line(block)
if ext is not None: if ext is not None:
# Side-effects: run the ESP move synchronously, # Side effect: enqueue the ESP move on the external-
# split the line into ESP (already done) + AVR (rest). # axis worker. The AVR still receives the full target
avr_block = self._dispatch_external_line(block, ext) # (including A) so ex.position[A] tracks gplan; no
if avr_block is None: # motor steps for A because no motor maps to it.
# Pure external move - no AVR work to issue but self._dispatch_external_line(block, ext)
# we still need to ack the block id so the planner
# advances. CommandQueue.enqueue with no callback
# at block id is what _encode does, so return an
# empty cmd to short-circuit there.
return ''
block = avr_block
self._enqueue_line_time(block) self._enqueue_line_time(block)
return Cmd.line(block['target'], block['exit-vel'], return Cmd.line(block['target'], block['exit-vel'],
block['max-accel'], block['max-jerk'], block['max-accel'], block['max-jerk'],
@@ -394,34 +388,39 @@ class Planner():
def _dispatch_external_line(self, block, ext): def _dispatch_external_line(self, block, ext):
"""Side-effect: enqueue the ESP move on the external-axis """Side-effect: enqueue the ESP move on the external-axis
worker thread (non-blocking). Return a new block dict with worker thread (non-blocking). Returns the block (possibly
the external axis stripped from `target`, or None if the unchanged) for the AVR.
line had no other axes.
We do NOT strip the external axis target from the AVR line.
The AVR's exec_move_to_target updates ex.position[axis] for
every axis in the target dict regardless of motor mapping,
and reports it back via the `p` indexed var. Leaving A in
the target keeps state.ap in sync with gplan's idea of A
(otherwise the AVR's stale ex.position[A] would clobber
ExternalAxis's state.ap=N update on the next status report).
The AVR doesn't step any motor for the external axis (no
motor maps to it) - so leaving A in the target is
physically a no-op for the steppers, while keeping the
host-side state coherent.
For mixed XYZ + external moves the AVR runs XYZ at the For mixed XYZ + external moves the AVR runs XYZ at the
gplan-computed rate while the ESP runs the external delta in gplan-computed rate while the ESP runs the external delta
parallel. Pure external moves return None so __encode emits in parallel. Final positions agree; intermediate ap reports
only the id-sync to keep planner ids advancing.""" from the AVR may briefly disagree with state.ap until the
target = dict(block['target']) block completes."""
new_target, ext_mm = ext.split_target(target) target = block.get('target') or {}
# Read the external target (case-insensitive) without modifying
# the dict so the AVR still sees A.
ext_mm = target.get(ext.axis_letter)
if ext_mm is None:
ext_mm = target.get(ext.axis_letter.upper())
try: try:
ext.enqueue_target_mm(ext_mm) ext.enqueue_target_mm(ext_mm)
except Exception as e: except Exception as e:
# Non-blocking enqueue should rarely fail; if it does we
# still want the planner to stop so the user notices.
self.log.error('External axis enqueue failed: %s' % e) self.log.error('External axis enqueue failed: %s' % e)
raise raise
return block
if not new_target:
# Pure external move; nothing left for the AVR. Track the
# trajectory time so the planner's plan_time stays correct.
self._enqueue_line_time(block)
return None
# Build a clean copy with only the AVR axes left.
avr_block = dict(block)
avr_block['target'] = new_target
return avr_block
def reset(self, *args, **kwargs): def reset(self, *args, **kwargs):
stop = kwargs.get('stop', True) stop = kwargs.get('stop', True)