diff --git a/src/py/bbctrl/ExternalAxis.py b/src/py/bbctrl/ExternalAxis.py index add6856..a963e16 100644 --- a/src/py/bbctrl/ExternalAxis.py +++ b/src/py/bbctrl/ExternalAxis.py @@ -140,6 +140,51 @@ class ExternalAxis(object): except Exception: return 0.0 + # ------------------------------------------------------- soft limits + + def _soft_limits(self): + """Return (min_mm, max_mm) in machine coords, or (None, None) + if soft limits are disabled (max <= min).""" + try: + lo = float(self.aux._cfg.get('min_w', 0.0)) + hi = float(self.aux._cfg.get('max_w', 0.0)) + except Exception: + return (None, None) + if hi <= lo: + return (None, None) + return (lo, hi) + + def _check_soft_limit(self, target_abs_mm): + """Raise ExternalAxisError if target_abs_mm is outside the + configured soft limits. Skips the check when the axis isn't + homed (matching the standard bbctrl convention that soft + limits are gated by homing state) - that lets the user jog + away from a stuck position before homing without false + rejections. + + Called by both planner-driven motion (enqueue_target_mm) and + UI motion (execute_to_mm), so this is the single source of + truth regardless of which path triggered the move.""" + # Honour the homing gate. + try: + homed = bool(self.aux._homed) + except Exception: + homed = False + if not homed: + return + lo, hi = self._soft_limits() + if lo is None: + return + # Use a tiny epsilon so floating-point round-trip targets + # right at the boundary aren't rejected. + eps = 1e-4 + target = float(target_abs_mm) + if target < lo - eps or target > hi + eps: + raise ExternalAxisError( + '%s axis target %.4f mm is outside soft limits ' + '[%.3f, %.3f] mm' % ( + self.axis_letter.upper(), target, lo, hi)) + # ----------------------------------------------------------- conversion def mm_to_steps_delta(self, delta_mm): @@ -267,6 +312,9 @@ class ExternalAxis(object): endpoints which may want to wait. Most planner-driven motion goes through enqueue_target_mm instead, which is non-blocking. + Soft limits are enforced here (not just in gplan) because the + UI jog/move endpoints don't go through the planner. + 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.""" @@ -275,6 +323,7 @@ class ExternalAxis(object): 'External axis %r not available (aux disabled or ' 'not connected)' % self.axis_letter) + self._check_soft_limit(ext_mm) steps, abs_mm = self._compute_move(ext_mm) if steps == 0: self._pos_mm = abs_mm @@ -304,10 +353,15 @@ class ExternalAxis(object): 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.""" + is never blocked by serial RPCs to the ESP. + + Soft limits are enforced here (defense in depth on top of + gplan). Raising stops the planner via Planner.next's + exception handler.""" if not self.enabled: raise ExternalAxisError( 'External axis %r not available' % self.axis_letter) + self._check_soft_limit(ext_mm) steps, abs_mm = self._compute_move(ext_mm) # Internal mirror only - drives subsequent delta computation. # state.p is left to the AVR's status reports.