From 3b622d3d17086dbfe3e63e4e3f0adc447dc47493 Mon Sep 17 00:00:00 2001 From: Henrik Muehe Date: Sun, 3 May 2026 11:50:49 +0200 Subject: [PATCH] ExternalAxis: enforce soft limits in execute_to_mm and enqueue_target_mm Soft limits in machine coords (min_w/max_w from aux.json) were only checked by gplan. UI jog/move endpoints went through ExternalAxis directly without any check, so the W+ button at home would happily push past max_w into a physical crash. Add _check_soft_limit(target_abs_mm) called by both motion paths: the synchronous execute_to_mm (UI) and the non-blocking enqueue_target_mm (planner). Boundaries inclusive within a 1e-4 epsilon for floating-point round-trip stability. Skipped when the axis isn't homed, matching the standard bbctrl convention that soft limits are gated by homing state. Skipped when max <= min (disabled). Tested locally: - pre-home: 200mm allowed (jog-out-of-trouble path) - post-home: 0 and 134 (boundaries) accepted - post-home: 135 and -1 rejected with clear error - 134.00005 accepted (within epsilon), 134.001 rejected - enqueue path also rejects, propagating up through Planner.next() - max==min config skips check --- src/py/bbctrl/ExternalAxis.py | 56 ++++++++++++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) 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.