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)