Compare commits

...

3 Commits

Author SHA1 Message Date
3b622d3d17 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
2026-05-03 11:50:49 +02:00
aa747dcc85 Hide X cursor on kiosk (touchscreen)
Pass -nocursor to startx so the mouse pointer never appears on the\nOnefinity touchscreen. Patched in all three boot paths: rc.local.fast\n(active), legacy rc.local, and the setup_rpi.sh bootstrap.
2026-05-03 11:47:05 +02:00
56c3406f25 ExternalAxis: option (b) homing - user A=0 at home, deterministic on re-home
Three changes that together implement option (b) home semantics:

1. Mach.home for the external axis: replace G28.3 with explicit
   AVR position sync (Cmd.set_axis) + planner abs sync
   (position_change) + G92 a0 (set user-coord origin to current
   physical position, computing offset = home_position_mm).

   G28.3 was wrong: it preserves the current user-coord position
   and adjusts the offset to bridge to the new abs. After a move
   away from home and a re-home, the offset accumulates
   (134 -> 268 -> ...). G92 a0 with a freshly-synced abs always
   produces offset = home_position_mm regardless of prior state.

2. Planner.__encode: stop stripping the external axis target from
   the AVR line. The AVR has no motor mapped to A so it steps no
   motor, but exec_move_to_target updates ex.position[A] which
   gets reported back as ap. Leaving A in the AVR target keeps
   state.ap consistent with gplan's idea of A; stripping it left
   ex.position[A] stale and clobbered ExternalAxis's state.ap on
   the next status report.

   Side benefit: removes the special-case empty-string return for
   pure external moves; every line block follows the same path now.

3. ExternalAxis.enqueue_target_mm: stop writing to state.<axis>p
   from the planner hot path. The AVR's status reports drive it
   instead, which avoids DRO jitter (jump to target then snap back
   to intermediate values as the trapezoid runs). _pos_mm internal
   mirror is still updated for delta computation.

Re-verified with the integration smoke test in tmp/20260503_option_b/:
home/move-down/move-up/re-home/home-from-bottom all produce the
expected DRO position values (0 at home, -134 at bottom).
2026-05-03 10:46:47 +02:00
7 changed files with 152 additions and 66 deletions

View File

@@ -28,4 +28,4 @@ plymouth quit
# Start X in /home/pi # Start X in /home/pi
cd /home/pi cd /home/pi
sudo -u pi startx sudo -u pi startx -- -nocursor

View File

@@ -34,7 +34,9 @@ plymouth quit 2>/dev/null || true
# late-boot units (bbctrl logrotate, etc.) don't block on it. Output # late-boot units (bbctrl logrotate, etc.) don't block on it. Output
# is redirected so the journal doesn't fill up with X warnings. # is redirected so the journal doesn't fill up with X warnings.
cd /home/pi cd /home/pi
nohup sudo -u pi startx >/var/log/onefin-x.log 2>&1 & # `-- -nocursor` hides the X pointer; this is a touchscreen kiosk and
# the mouse cursor only gets in the way.
nohup sudo -u pi startx -- -nocursor >/var/log/onefin-x.log 2>&1 &
disown disown
exit 0 exit 0

View File

@@ -75,7 +75,7 @@ sed -i 's/^PARTUUID=.*\//\/dev\/mmcblk0p2 \//' /etc/fstab
# Enable browser in xorg # Enable browser in xorg
sed -i 's/allowed_users=console/allowed_users=anybody/' /etc/X11/Xwrapper.config sed -i 's/allowed_users=console/allowed_users=anybody/' /etc/X11/Xwrapper.config
echo "sudo -u pi startx" >> /etc/rc.local echo "sudo -u pi startx -- -nocursor" >> /etc/rc.local
cp /mnt/host/xinitrc /home/pi/.xinitrc cp /mnt/host/xinitrc /home/pi/.xinitrc
cp /mnt/host/ratpoisonrc /home/pi/.ratpoisonrc cp /mnt/host/ratpoisonrc /home/pi/.ratpoisonrc
cp /mnt/host/xorg.conf /etc/X11/ cp /mnt/host/xorg.conf /etc/X11/

View File

@@ -92,17 +92,21 @@ script#control-view-template(type="text/x-template")
.fa.fa-arrow-down.ico(style="transform: rotate(-45deg)") .fa.fa-arrow-down.ico(style="transform: rotate(-45deg)")
button.jbtn(@click="jog_fn(0, 0, -1, 0)") Z button.jbtn(@click="jog_fn(0, 0, -1, 0)") Z
// Row 4 — W axis (auxcnc) when enabled. // Row 4 — A axis (the auxcnc-driven external axis) when enabled.
// W- | W+ | Probe XYZ | Probe Z // A- | A+ | Probe XYZ | Probe Z
// "Home W" lives in the DRO table's actions column on the // "Home A" lives in the DRO table's actions column on the
// right, so it doesn't need a tile here. // right, so it doesn't need a tile here. The legacy w.enabled
template(v-if="w.enabled") // gate is kept so older installs (where the auxcnc axis still
button.jbtn(@click="aux_jog_incr(-1)", :disabled="!w.enabled") // 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 .fa.fa-arrow-down.ico
span.lbl W span.lbl A
button.jbtn(@click="aux_jog_incr(+1)", :disabled="!w.enabled") button.jbtn(@click="aux_jog_incr(+1)",
:disabled="!(w.enabled || a.enabled)")
.fa.fa-arrow-up.ico .fa.fa-arrow-up.ico
span.lbl W+ span.lbl A+
button.jbtn(@click="showProbeDialog('xyz')", button.jbtn(@click="showProbeDialog('xyz')",
:class="{'load-on': !state['pw']}") :class="{'load-on': !state['pw']}")
.fa.fa-bullseye.ico .fa.fa-bullseye.ico

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):
@@ -265,12 +310,20 @@ class ExternalAxis(object):
"""Synchronously run an external move. Blocks until the ESP """Synchronously run an external move. Blocks until the ESP
reports done. Used by the legacy /api/aux/move and /api/aux/jog reports done. Used by the legacy /api/aux/move and /api/aux/jog
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
planner-driven path that goes through enqueue_target_mm, the
AVR's own ap reports drive state.<axis>p instead."""
if not self.enabled: if not self.enabled:
raise ExternalAxisError( raise ExternalAxisError(
'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
@@ -286,29 +339,35 @@ class ExternalAxis(object):
self._busy.clear() self._busy.clear()
def enqueue_target_mm(self, ext_mm): def enqueue_target_mm(self, ext_mm):
"""Non-blocking variant: post a target to the worker queue """Non-blocking variant: post a target to the worker queue.
and update the host's notion of the axis position immediately Used by Planner.__encode in the hot path.
so subsequent line splits compute correct deltas.
We deliberately do NOT mirror the new target into state.<axis>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 The Planner.__encode hook calls this so the AVR comm thread
is never blocked by serial RPCs to the ESP. v1 accepts that is never blocked by serial RPCs to the ESP.
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 Soft limits are enforced here (defense in depth on top of
applied to both, so velocities and accelerations are bounded gplan). Raising stops the planner via Planner.next's
by whichever axis is most constrained.""" 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)
# Update host position immediately so the next line block # Internal mirror only - drives subsequent delta computation.
# sees the new absolute target as the starting point. # state.<axis>p is left to the AVR's status reports.
self._pos_mm = abs_mm self._pos_mm = abs_mm
self.ctrl.state.set(self.axis_letter + 'p', self._pos_mm)
if steps == 0: if steps == 0:
return 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)) self._work_q.put(('move', steps))
def _compute_move(self, ext_mm): def _compute_move(self, ext_mm):

View File

@@ -334,6 +334,24 @@ class Mach(Comm):
# External axes (e.g. the auxcnc-driven A axis) home via # External axes (e.g. the auxcnc-driven A axis) home via
# their own ESP-side homing routine; the standard # their own ESP-side homing routine; the standard
# G28.2 / G38.6 / latch sequence doesn't apply. # 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) ext = getattr(self.ctrl, 'ext_axis', None)
if ext is not None and ext.enabled \ if ext is not None and ext.enabled \
and ext.axis_letter == axis.lower(): and ext.axis_letter == axis.lower():
@@ -341,16 +359,20 @@ class Mach(Comm):
continue continue
self.mlog.info('Homing external %s axis via auxcnc' % self.mlog.info('Homing external %s axis via auxcnc' %
axis.upper()) 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: try:
self._begin_cycle('homing') self._begin_cycle('homing')
ext.home() ext.home()
self.planner.mdi( home_mm = ext.home_position_mm
'G28.3 %c%f' % (axis, ext.home_position_mm), # 1) Update AVR: no motor steps, just position sync.
False) 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 <axis>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() super().resume()
except Exception as e: except Exception as e:
self.mlog.error( self.mlog.error(

View File

@@ -272,17 +272,11 @@ class Planner():
if type == 'line': if type == 'line':
ext = self._external_axis_for_line(block) ext = self._external_axis_for_line(block)
if ext is not None: if ext is not None:
# Side-effects: run the ESP move synchronously, # Side effect: enqueue the ESP move on the external-
# split the line into ESP (already done) + AVR (rest). # axis worker. The AVR still receives the full target
avr_block = self._dispatch_external_line(block, ext) # (including A) so ex.position[A] tracks gplan; no
if avr_block is None: # motor steps for A because no motor maps to it.
# Pure external move - no AVR work to issue but self._dispatch_external_line(block, ext)
# 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
self._enqueue_line_time(block) self._enqueue_line_time(block)
return Cmd.line(block['target'], block['exit-vel'], return Cmd.line(block['target'], block['exit-vel'],
block['max-accel'], block['max-jerk'], block['max-accel'], block['max-jerk'],
@@ -394,34 +388,39 @@ class Planner():
def _dispatch_external_line(self, block, ext): def _dispatch_external_line(self, block, ext):
"""Side-effect: enqueue the ESP move on the external-axis """Side-effect: enqueue the ESP move on the external-axis
worker thread (non-blocking). Return a new block dict with worker thread (non-blocking). Returns the block (possibly
the external axis stripped from `target`, or None if the unchanged) for the AVR.
line had no other axes.
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 For mixed XYZ + external moves the AVR runs XYZ at the
gplan-computed rate while the ESP runs the external delta in gplan-computed rate while the ESP runs the external delta
parallel. Pure external moves return None so __encode emits in parallel. Final positions agree; intermediate ap reports
only the id-sync to keep planner ids advancing.""" from the AVR may briefly disagree with state.ap until the
target = dict(block['target']) block completes."""
new_target, ext_mm = ext.split_target(target) 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: try:
ext.enqueue_target_mm(ext_mm) ext.enqueue_target_mm(ext_mm)
except Exception as e: 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) self.log.error('External axis enqueue failed: %s' % e)
raise raise
return block
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
def reset(self, *args, **kwargs): def reset(self, *args, **kwargs):
stop = kwargs.get('stop', True) stop = kwargs.get('stop', True)