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:
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user