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

@@ -272,17 +272,11 @@ class Planner():
if type == 'line':
ext = self._external_axis_for_line(block)
if ext is not None:
# Side-effects: run the ESP move synchronously,
# split the line into ESP (already done) + AVR (rest).
avr_block = self._dispatch_external_line(block, ext)
if avr_block is None:
# Pure external move - no AVR work to issue but
# 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
# Side effect: enqueue the ESP move on the external-
# axis worker. The AVR still receives the full target
# (including A) so ex.position[A] tracks gplan; no
# motor steps for A because no motor maps to it.
self._dispatch_external_line(block, ext)
self._enqueue_line_time(block)
return Cmd.line(block['target'], block['exit-vel'],
block['max-accel'], block['max-jerk'],
@@ -394,34 +388,39 @@ class Planner():
def _dispatch_external_line(self, block, ext):
"""Side-effect: enqueue the ESP move on the external-axis
worker thread (non-blocking). Return a new block dict with
the external axis stripped from `target`, or None if the
line had no other axes.
worker thread (non-blocking). Returns the block (possibly
unchanged) for the AVR.
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
gplan-computed rate while the ESP runs the external delta in
parallel. Pure external moves return None so __encode emits
only the id-sync to keep planner ids advancing."""
target = dict(block['target'])
new_target, ext_mm = ext.split_target(target)
gplan-computed rate while the ESP runs the external delta
in parallel. Final positions agree; intermediate ap reports
from the AVR may briefly disagree with state.ap until the
block completes."""
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:
ext.enqueue_target_mm(ext_mm)
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)
raise
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
return block
def reset(self, *args, **kwargs):
stop = kwargs.get('stop', True)