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
This commit is contained in:
2026-05-03 11:50:49 +02:00
parent aa747dcc85
commit 3b622d3d17

View File

@@ -140,6 +140,51 @@ class ExternalAxis(object):
except Exception: except Exception:
return 0.0 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 # ----------------------------------------------------------- conversion
def mm_to_steps_delta(self, delta_mm): 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 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.
Soft limits are enforced here (not just in gplan) because the
UI jog/move endpoints don't go through the planner.
Updates state.<axis>p immediately on completion. For the Updates state.<axis>p immediately on completion. For the
planner-driven path that goes through enqueue_target_mm, the planner-driven path that goes through enqueue_target_mm, the
AVR's own ap reports drive state.<axis>p instead.""" AVR's own ap reports drive state.<axis>p instead."""
@@ -275,6 +323,7 @@ class ExternalAxis(object):
'External axis %r not available (aux disabled or ' 'External axis %r not available (aux disabled or '
'not connected)' % self.axis_letter) 'not connected)' % self.axis_letter)
self._check_soft_limit(ext_mm)
steps, abs_mm = self._compute_move(ext_mm) steps, abs_mm = self._compute_move(ext_mm)
if steps == 0: if steps == 0:
self._pos_mm = abs_mm self._pos_mm = abs_mm
@@ -304,10 +353,15 @@ class ExternalAxis(object):
the next line block computes the correct delta. 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.""" 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: if not self.enabled:
raise ExternalAxisError( raise ExternalAxisError(
'External axis %r not available' % self.axis_letter) 'External axis %r not available' % self.axis_letter)
self._check_soft_limit(ext_mm)
steps, abs_mm = self._compute_move(ext_mm) steps, abs_mm = self._compute_move(ext_mm)
# Internal mirror only - drives subsequent delta computation. # Internal mirror only - drives subsequent delta computation.
# state.<axis>p is left to the AVR's status reports. # state.<axis>p is left to the AVR's status reports.