From 56c3406f25e77bca3de82a9c2619ce52285cd4e1 Mon Sep 17 00:00:00 2001 From: Henrik Muehe Date: Sun, 3 May 2026 10:46:47 +0200 Subject: [PATCH] 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.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). --- src/pug/templates/control-view.pug | 22 ++++++----- src/py/bbctrl/ExternalAxis.py | 35 ++++++++++------- src/py/bbctrl/Mach.py | 36 +++++++++++++---- src/py/bbctrl/Planner.py | 63 +++++++++++++++--------------- 4 files changed, 93 insertions(+), 63 deletions(-) diff --git a/src/pug/templates/control-view.pug b/src/pug/templates/control-view.pug index 3231d35..cd4de06 100644 --- a/src/pug/templates/control-view.pug +++ b/src/pug/templates/control-view.pug @@ -92,17 +92,21 @@ script#control-view-template(type="text/x-template") .fa.fa-arrow-down.ico(style="transform: rotate(-45deg)") button.jbtn(@click="jog_fn(0, 0, -1, 0)") Z− - // Row 4 — W axis (auxcnc) when enabled. - // W- | W+ | Probe XYZ | Probe Z - // "Home W" lives in the DRO table's actions column on the - // right, so it doesn't need a tile here. - template(v-if="w.enabled") - button.jbtn(@click="aux_jog_incr(-1)", :disabled="!w.enabled") + // Row 4 — A axis (the auxcnc-driven external axis) when enabled. + // A- | A+ | Probe XYZ | Probe Z + // "Home A" lives in the DRO table's actions column on the + // right, so it doesn't need a tile here. The legacy w.enabled + // gate is kept so older installs (where the auxcnc axis still + // 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 - span.lbl W− - button.jbtn(@click="aux_jog_incr(+1)", :disabled="!w.enabled") + span.lbl A− + button.jbtn(@click="aux_jog_incr(+1)", + :disabled="!(w.enabled || a.enabled)") .fa.fa-arrow-up.ico - span.lbl W+ + span.lbl A+ button.jbtn(@click="showProbeDialog('xyz')", :class="{'load-on': !state['pw']}") .fa.fa-bullseye.ico diff --git a/src/py/bbctrl/ExternalAxis.py b/src/py/bbctrl/ExternalAxis.py index 4cc615f..add6856 100644 --- a/src/py/bbctrl/ExternalAxis.py +++ b/src/py/bbctrl/ExternalAxis.py @@ -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.p immediately on completion. For the + planner-driven path that goes through enqueue_target_mm, the + AVR's own ap reports drive state.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.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.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): diff --git a/src/py/bbctrl/Mach.py b/src/py/bbctrl/Mach.py index 7a611e8..4628dc4 100644 --- a/src/py/bbctrl/Mach.py +++ b/src/py/bbctrl/Mach.py @@ -334,6 +334,24 @@ class Mach(Comm): # External axes (e.g. the auxcnc-driven A axis) home via # their own ESP-side homing routine; the standard # 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) if ext is not None and ext.enabled \ and ext.axis_letter == axis.lower(): @@ -341,16 +359,20 @@ class Mach(Comm): continue self.mlog.info('Homing external %s axis via auxcnc' % 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: self._begin_cycle('homing') ext.home() - self.planner.mdi( - 'G28.3 %c%f' % (axis, ext.home_position_mm), - False) + home_mm = ext.home_position_mm + # 1) Update AVR: no motor steps, just position sync. + 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 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() except Exception as e: self.mlog.error( diff --git a/src/py/bbctrl/Planner.py b/src/py/bbctrl/Planner.py index 333fd7e..85b8367 100644 --- a/src/py/bbctrl/Planner.py +++ b/src/py/bbctrl/Planner.py @@ -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)