Jog: enforce A-axis soft limits during hold-to-jog

Pendant hold-to-jog could drive A past min_mm / max_mm because the
JOG path bypassed the planner-driven soft-limit checks. Wire the
host to compute a step-counter target for whichever soft-limit
boundary lies in the requested direction and pass it through the
new JOG target= parameter.

AuxAxis.jog_start now accepts target_steps; when given it emits
'JOG ... target=<n>'. The ESP picks the decel start point so the
motor ramps to a smooth stop AT the boundary, with no overshoot.

Jog._a_soft_limit_target_steps:
  - Returns None when the axis is not homed -- pre-home setup jogs
    are still allowed (matches the rest of the manual-jog API).
  - Otherwise projects min_mm/max_mm into step space (honoring
    dir_sign) and returns the boundary on the requested side of
    the current position.

Jog._a_start additionally refuses to send the JOG when the
position is already at-or-past the boundary in the requested
direction, so we don't depend on the ESP's wrong-side reject path
for the common 'press button while sitting on the limit' case.

Verified end-to-end on hardware (bare ESP, no gantry):
  JOG dir=+ maxrate=400 target=300 stops at pos=299
  JOG dir=+ target=-50 (wrong side) rejected immediately.
This commit is contained in:
2026-05-03 18:06:29 +02:00
parent 7360c437a9
commit 5787855f3f
2 changed files with 79 additions and 17 deletions

View File

@@ -314,9 +314,18 @@ class AuxAxis(object):
# pos=<p>` arrives later; our reader picks it up and resyncs # pos=<p>` arrives later; our reader picks it up and resyncs
# _pos_steps via the same path as STEPS. # _pos_steps via the same path as STEPS.
def jog_start(self, direction, max_rate_sps=None, def jog_start(self, direction, max_rate_sps=None,
accel_sps2=None, ignore_limits=False): accel_sps2=None, ignore_limits=False,
target_steps=None):
"""Begin a continuous-rate jog. `direction` is +1 or -1. """Begin a continuous-rate jog. `direction` is +1 or -1.
Returns once the ESP has accepted the JOG command.""" Returns once the ESP has accepted the JOG command.
target_steps (optional): a signed step-counter value. The
ESP picks the deceleration start point so the motor ramps
smoothly from the current cruise rate to step_start_rate
and stops AT this counter value. Used to enforce host-side
soft limits without overshoot. The target must be on the
side of the current g_pos that matches `direction`; the
ESP rejects a wrong-side target with reason=softlimit."""
self._require_present() self._require_present()
if direction not in (-1, +1): if direction not in (-1, +1):
raise AuxAxisError('jog_start direction must be +/-1') raise AuxAxisError('jog_start direction must be +/-1')
@@ -327,13 +336,10 @@ class AuxAxis(object):
else int(self._cfg['step_accel_sps2'])) else int(self._cfg['step_accel_sps2']))
if rate < 1: rate = 1 if rate < 1: rate = 1
if accel < 1: accel = 1 if accel < 1: accel = 1
# Track the in-flight JOG so the reader can deliver the
# terminal [jog] done line back to us. We use a dedicated
# background thread so jog_start can return as soon as the
# `[jog] started` ack lands -- the terminal line may arrive
# seconds later (after JOGSTOP).
cmd = 'JOG dir=%s maxrate=%d accel=%d safe=%d' % ( cmd = 'JOG dir=%s maxrate=%d accel=%d safe=%d' % (
sign, rate, accel, 0 if ignore_limits else 1) sign, rate, accel, 0 if ignore_limits else 1)
if target_steps is not None:
cmd += ' target=%d' % int(target_steps)
# Capture both the immediate ack AND the eventual terminal # Capture both the immediate ack AND the eventual terminal
# line in a single _rpc call would block; instead fire the # line in a single _rpc call would block; instead fire the
# ack-only RPC here and let _on_line handle the terminal # ack-only RPC here and let _on_line handle the terminal

View File

@@ -116,12 +116,52 @@ class Jog(inevent.JogHandler):
except Exception as e: except Exception as e:
self.log.warning('A-axis jog_stop failed: %s', e) self.log.warning('A-axis jog_stop failed: %s', e)
def _a_soft_limit_target_steps(self, aux, direction):
"""Return a step-counter target that the ESP should ramp to
a smooth stop at when jogging in `direction`. Returns None
when no soft limits should be enforced (axis unhomed or
limits not configured).
The ESP's `g_pos` is the raw signed step counter; the host
side mirror (`aux._pos_steps`) tracks it. Soft limits are
configured in machine-mm; we project them into step space
with the same `_mm_to_steps` used for ordinary moves.
Direction sign: when `dir_sign=+1` (typical), positive jog
direction increases g_pos. We pick the limit whose step
value is on the `direction` side of the current g_pos."""
try:
if not bool(aux._homed):
return None
cfg = aux._cfg
lo_mm = float(cfg.get('min_mm', 0.0))
hi_mm = float(cfg.get('max_mm', 0.0))
if hi_mm <= lo_mm:
return None
lo_steps = aux._mm_to_steps(lo_mm)
hi_steps = aux._mm_to_steps(hi_mm)
# _mm_to_steps applies dir_sign, so lo_steps may be
# numerically larger than hi_steps when dir_sign<0.
# Sort so we know which is "more positive in g_pos".
top_steps = max(lo_steps, hi_steps)
bottom_steps = min(lo_steps, hi_steps)
return top_steps if direction > 0 else bottom_steps
except Exception:
return None
def _a_start(self, direction): def _a_start(self, direction):
ext = getattr(self.ctrl, 'ext_axis', None) ext = getattr(self.ctrl, 'ext_axis', None)
ext_state = ('present' if (ext is not None and ext.enabled) ext_state = ('present' if (ext is not None and ext.enabled)
else 'unavailable') else 'unavailable')
if A_DRY_RUN:
scale = self._a_speed_scale() scale = self._a_speed_scale()
target_steps = None
cur_steps = None
if ext is not None and ext.enabled:
target_steps = self._a_soft_limit_target_steps(
ext.aux, direction)
try: cur_steps = int(ext.aux._pos_steps)
except Exception: cur_steps = None
if A_DRY_RUN:
try: try:
step_max = (int(ext.aux._cfg['step_max_sps']) step_max = (int(ext.aux._cfg['step_max_sps'])
if ext is not None and ext.enabled else -1) if ext is not None and ext.enabled else -1)
@@ -131,26 +171,42 @@ class Jog(inevent.JogHandler):
step_max, accel = -1, -1 step_max, accel = -1, -1
self.log.info( self.log.info(
'AJOG DRYRUN _a_start dir=%+d ext=%s speed=%d scale=%.4f ' 'AJOG DRYRUN _a_start dir=%+d ext=%s speed=%d scale=%.4f '
'step_max=%d accel=%d (would send JOG)', 'step_max=%d accel=%d cur_steps=%s target_steps=%s '
direction, ext_state, self.speed, scale, step_max, accel) '(would send JOG)',
direction, ext_state, self.speed, scale, step_max, accel,
cur_steps, target_steps)
return return
if ext is None or not ext.enabled or direction == 0: if ext is None or not ext.enabled or direction == 0:
return return
scale = self._a_speed_scale()
try: try:
aux = ext.aux aux = ext.aux
max_rate = max(1, int(int(aux._cfg['step_max_sps']) * scale)) max_rate = max(1, int(int(aux._cfg['step_max_sps']) * scale))
accel = int(aux._cfg['step_accel_sps2']) accel = int(aux._cfg['step_accel_sps2'])
# ignore_limits=True (safe=0): pendant jog is allowed # If the axis is already at-or-past the soft-limit
# before homing, matching the rest of the manual-jog API. # boundary in the requested direction, refuse the jog
# When the axis IS homed, the ESP still aborts on a # rather than sending a wrong-side target the ESP would
# limit-toward hit because it tracks home_dir separately # reject. The host knows position immediately whereas
# from `safe` in our updated firmware (see jogTask). # the ESP only learns g_pos via WPOS?.
if target_steps is not None and cur_steps is not None:
at_limit = ((direction > 0 and cur_steps >= target_steps)
or (direction < 0 and cur_steps <= target_steps))
if at_limit:
self.log.info(
'A-axis jog refused: at soft limit '
'(cur=%d target=%d dir=%+d)',
cur_steps, target_steps, direction)
return
# ignore_limits=True (safe=0) when the axis is unhomed:
# pendant jog is allowed before homing for setup. When
# homed, soft limits are enforced via target_steps and
# the ESP's hardware-limit abort still applies
# unconditionally (movingTowardLimit in jogTask).
ignore = not bool(aux._homed) ignore = not bool(aux._homed)
aux.jog_start(direction, aux.jog_start(direction,
max_rate_sps=max_rate, max_rate_sps=max_rate,
accel_sps2=accel, accel_sps2=accel,
ignore_limits=ignore) ignore_limits=ignore,
target_steps=target_steps)
except Exception as e: except Exception as e:
self.log.warning('A-axis jog_start failed: %s', e) self.log.warning('A-axis jog_start failed: %s', e)