Mach.home: defer external A homing until AVR axes finish

Previously the homing loop iterated zxyabc and processed each axis
in turn, but the AVR axes (Z/X/Y) just queue G-code to the planner
and return immediately - the gantry keeps moving in the background.
The external A homing was then driven synchronously on the same
loop iteration, which meant the W stepper started its limit-seek
*while the gantry was still actively homing Z/X/Y*. Visually
confusing and unsafe.

Split into two phases:

  1. The HTTP handler thread queues every AVR axis (no change) and
     collects external axes into a deferred list.
  2. A background thread polls cycle until it returns to 'idle'
     (signalling the AVR finished its queued homing). It then runs
     each external axis home in order, blocking on the ESP serial
     RPC. Post-home bookkeeping (set_axis to AVR, planner G92, cycle
     reset) is scheduled back onto the IOLoop via add_callback so
     gplan and the AVR command queue are only touched from one
     thread.

A guard prevents overlapping threads if Home is clicked again while
the previous deferred run is still waiting.
This commit is contained in:
2026-05-03 13:24:54 +02:00
parent ca7e30aa05
commit 4a494a101d
3 changed files with 178 additions and 62 deletions

View File

@@ -215,8 +215,6 @@ script#control-view-template(type="text/x-template")
div Position div Position
div Absolute div Absolute
div Offset div Offset
div State
div Toolpath
.actions-cell .actions-cell
// Master Home All. Each row's Actions cell has a per-axis // Master Home All. Each row's Actions cell has a per-axis
// home button; this header-level button homes every // home button; this header-level button homes every
@@ -230,31 +228,24 @@ script#control-view-template(type="text/x-template")
each axis in 'xyzabc' each axis in 'xyzabc'
.dro-row(:class=`${axis}.klass + ' ' + ${axis}.tklass`, .dro-row(:class=`${axis}.klass + ' ' + ${axis}.tklass`,
v-if=`${axis}.enabled`, v-if=`${axis}.enabled`,
:title=`${axis}.title`) :title=`${axis}.toolmsg ? (${axis}.title + ' — ' + ${axis}.toolmsg) : ${axis}.title`)
.dro-axis(:class=`'axis-' + '${axis}'`)= axis.toUpperCase() .dro-axis(:class=`'axis-' + '${axis}'`)= axis.toUpperCase()
.dro-pos: unit-value(:value=`${axis}.pos`, precision=4) .dro-pos: unit-value(:value=`${axis}.pos`, precision=4)
.dro-sec: unit-value(:value=`${axis}.abs`, precision=3) .dro-sec: unit-value(:value=`${axis}.abs`, precision=3)
.dro-sec: unit-value(:value=`${axis}.off`, precision=3) .dro-sec: unit-value(:value=`${axis}.off`, precision=3)
.dro-state
span.chip(:class=`${axis}.tklass.indexOf('error') !== -1 ? 'chip-red' : (${axis}.homed ? 'chip-green' : 'chip-amber')`)
.fa(:class=`'fa-' + ${axis}.icon`)
|  {{#{axis}.state}}
.dro-toolpath
span.chip(:class=`${axis}.tklass.indexOf('error') !== -1 ? 'chip-red' : (${axis}.tklass.indexOf('warn') !== -1 ? 'chip-amber' : 'chip-green')`,
@click=`showToolpathMessageDialog('${axis}')`)
.fa(:class=`'fa-' + ${axis}.ticon`)
|  {{#{axis}.tstate}}
.actions-cell .actions-cell
button.icon-btn(:disabled="!can_set_axis", button.icon-btn(:disabled="!can_set_axis",
:title=`'Set ${axis.toUpperCase()} axis position.'`, :title=`'Set ${axis.toUpperCase()} axis position.'`,
@click=`show_set_position('${axis}')`) @click=`show_set_position('${axis}')`)
.fa.fa-gear .fa.fa-gear
button.icon-btn(:disabled="!can_set_axis", button.icon-btn(:class=`${axis}.tklass.indexOf('error') !== -1 ? 'state-red' : (${axis}.tklass.indexOf('warn') !== -1 ? 'state-amber' : 'state-green')`,
:title=`'Zero ${axis.toUpperCase()} axis offset.'`, :disabled="!can_set_axis",
:title=`${axis}.toolmsg || ('Zero ${axis.toUpperCase()} axis offset.')`,
@click=`zero('${axis}')`) @click=`zero('${axis}')`)
.fa.fa-location-dot .fa.fa-location-dot
button.icon-btn(:disabled="!is_idle", button.icon-btn(:class=`${axis}.klass.indexOf('error') !== -1 ? 'state-red' : (${axis}.homed ? 'state-green' : 'state-amber')`,
:title=`'Home ${axis.toUpperCase()} axis.'`, :disabled="!is_idle",
:title=`${axis}.title`,
@click=`home('${axis}')`) @click=`home('${axis}')`)
.fa.fa-home .fa.fa-home
@@ -270,20 +261,13 @@ script#control-view-template(type="text/x-template")
.dro-pos: unit-value(:value="w.pos", precision=4) .dro-pos: unit-value(:value="w.pos", precision=4)
.dro-sec: unit-value(:value="w.abs", precision=3) .dro-sec: unit-value(:value="w.abs", precision=3)
.dro-sec — .dro-sec —
.dro-state
span.chip(:class="w.homed ? 'chip-green' : 'chip-amber'")
.fa(:class="'fa-' + w.icon")
|  {{w.state}}
.dro-toolpath
span.chip.chip-green
.fa(:class="'fa-' + w.ticon")
|  {{w.tstate}}
.actions-cell .actions-cell
button.icon-btn(disabled, style="visibility:hidden") button.icon-btn(disabled, style="visibility:hidden")
.fa.fa-gear .fa.fa-gear
button.icon-btn(disabled, style="visibility:hidden") button.icon-btn(disabled, style="visibility:hidden")
.fa.fa-location-dot .fa.fa-location-dot
button.icon-btn(:disabled="!w.enabled", button.icon-btn(:class="w.homed ? 'state-green' : 'state-amber'",
:disabled="!w.enabled",
title="Home W axis.", @click="aux_home()") title="Home W axis.", @click="aux_home()")
.fa.fa-home .fa.fa-home

View File

@@ -95,6 +95,10 @@ class Mach(Comm):
self.planner = bbctrl.Planner(ctrl) self.planner = bbctrl.Planner(ctrl)
self.unpausing = False self.unpausing = False
self.stopping = False self.stopping = False
# Guard against overlapping deferred-external-homing threads
# if the user clicks Home (All) again while the previous run
# is still waiting for the AVR cycle to finish.
self._ext_home_thread = None
ctrl.state.set('cycle', 'idle') ctrl.state.set('cycle', 'idle')
@@ -327,6 +331,16 @@ class Mach(Comm):
axes = 'zxybc' if is_rotary_active else 'zxyabc' # TODO This should be configurable axes = 'zxybc' if is_rotary_active else 'zxyabc' # TODO This should be configurable
else: axes = '%c' % axis else: axes = '%c' % axis
# Collect external axes here and process them *after* every
# AVR axis above has finished its homing cycle. Without this,
# the AVR is still running Z/X/Y homing G-code in the
# planner queue while ext.home() synchronously drives the ESP
# to home A in parallel - which is unsafe (the gantry and W
# axis can move at the same time) and visually confusing.
# We defer external homing to a background thread that
# polls cycle until the AVR cycle completes.
external_pending = []
for axis in axes: for axis in axes:
enabled = state.is_axis_enabled(axis) enabled = state.is_axis_enabled(axis)
mode = state.axis_homing_mode(axis) mode = state.axis_homing_mode(axis)
@@ -357,41 +371,10 @@ class Mach(Comm):
and ext.axis_letter == axis.lower(): and ext.axis_letter == axis.lower():
if 1 < len(axes) and not enabled: if 1 < len(axes) and not enabled:
continue continue
self.mlog.info('Homing external %s axis via auxcnc' % # Defer until AVR axes are done. We capture the axis
axis.upper()) # letter and ext reference; the actual homing runs
cycle_started = False # in _run_external_homing below.
try: external_pending.append((axis, ext))
self._begin_cycle('homing')
cycle_started = True
ext.home()
home_mm = ext.home_position_mm
# 1) Update AVR: no motor steps, just position sync.
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()
except Exception as e:
self.mlog.error(
'External axis homing failed: %s' % e)
# Make sure we clean up the cycle so the UI does
# not stay locked out of jog/home/start. The AVR
# never moved during external homing, so its
# state never changes back to READY (which is
# what _update normally relies on to exit the
# cycle). Drop straight to idle.
if cycle_started and self._get_cycle() == 'homing':
try:
self._set_cycle('idle')
except Exception:
self.mlog.exception(
'Failed to reset cycle to idle '
'after external homing error')
continue continue
# If this is not a request to home a specific axis and the # If this is not a request to home a specific axis and the
@@ -424,6 +407,122 @@ class Mach(Comm):
self.planner.mdi(gcode, False) self.planner.mdi(gcode, False)
super().resume() super().resume()
# Kick off the deferred external-axis homing on a background
# thread so we don't block the HTTP handler (which is on the
# IOLoop) waiting for the AVR cycle to finish.
if external_pending:
prev = self._ext_home_thread
if prev is not None and prev.is_alive():
self.mlog.info(
'External homing already in progress; ignoring '
'duplicate request')
else:
import threading
t = threading.Thread(
target=self._run_external_homing,
args=(list(external_pending),),
name='ext-home-deferred',
daemon=True)
self._ext_home_thread = t
t.start()
def _run_external_homing(self, pending):
"""Background worker: wait for the AVR cycle to drop to idle
(meaning all queued AVR-side homing is done), then run each
deferred external-axis home in order.
We split the work between two threads:
- this background thread blocks on the ESP serial RPC
(ext.home(), which can take 5-10 seconds while the
carriage seeks the limit and backs off twice);
- small bookkeeping operations that touch gplan, the AVR
command queue, or shared State are scheduled back onto
the IOLoop via ctrl.ioloop.add_callback() so we don't
race with the rest of the controller.
"""
import time
# Wait up to 5 minutes for the AVR cycle to leave 'homing'.
# Long enough for any reasonable Onefinity full-travel home
# (Y axis at slow rate covers ~800 mm).
deadline = time.time() + 300.0
while time.time() < deadline:
cycle = self._get_cycle()
# 'homing' is the AVR's homing cycle; we wait for it to
# return to idle. If the user estopped or the cycle was
# aborted, cycle goes to idle too - we still proceed and
# the external home will fail-soft if conditions are wrong.
if cycle == 'idle':
break
time.sleep(0.1)
else:
self.mlog.error(
'External axis homing aborted: AVR cycle did not '
'return to idle within timeout')
return
for axis, ext in pending:
self.mlog.info('Homing external %s axis via auxcnc' %
axis.upper())
# Begin the cycle on the IOLoop so cycle-state writes go
# through the same thread that all other state writes do.
self.ctrl.ioloop.add_callback(self._begin_cycle, 'homing')
try:
# ext.home() runs on this background thread - it
# blocks on serial I/O and is fully thread-safe (the
# AuxAxis driver has its own RPC lock).
ext.home()
home_mm = ext.home_position_mm
# All of the post-home bookkeeping touches gplan and
# the AVR command queue, both of which run on the
# IOLoop. Schedule it there in a single callback so
# the steps run in order without intervening events.
self.ctrl.ioloop.add_callback(
self._finish_external_home, axis, home_mm)
except Exception as e:
self.mlog.error(
'External axis homing failed: %s' % e)
# Cycle reset must also happen on the IOLoop. Without
# this the UI stays locked at 'homing' since the AVR
# never moved (no state change to drive _update's
# cycle-end path).
self.ctrl.ioloop.add_callback(
self._abort_external_home_cycle)
def _finish_external_home(self, axis, home_mm):
"""IOLoop-side completion of an external axis home.
Synchronizes AVR position, refreshes the planner, and emits
a G92 to set the user-coord origin at the home position.
"""
try:
# 1) Update AVR: no motor steps, just position sync.
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()
except Exception:
self.mlog.exception(
'Post-home bookkeeping failed for external axis')
self._abort_external_home_cycle()
def _abort_external_home_cycle(self):
"""Reset cycle to idle from the IOLoop after a failed
external axis home. The AVR never moved so _update's normal
cycle-end path won't fire; do it explicitly here.
"""
if self._get_cycle() == 'homing':
try:
self._set_cycle('idle')
except Exception:
self.mlog.exception(
'Failed to reset cycle to idle after external '
'homing error')
def unhome(self, axis): def unhome(self, axis):
# External axes don't have AVR-side homed state to clear; the # External axes don't have AVR-side homed state to clear; the

View File

@@ -1396,7 +1396,7 @@ tt.save
.control-page .dro-head, .control-page .dro-row .control-page .dro-head, .control-page .dro-row
display grid display grid
grid-template-columns 84px 1.4fr 1fr 1fr 170px 170px 280px grid-template-columns 84px 1.4fr 1fr 1fr 280px
column-gap 0.75rem column-gap 0.75rem
align-items center align-items center
padding 14px 22px padding 14px 22px
@@ -1529,6 +1529,39 @@ tt.save
opacity 0.45 opacity 0.45
cursor not-allowed cursor not-allowed
// State-tinted variants used on home + zero buttons in DRO rows
// to communicate per-axis homing / toolpath fit at a glance,
// replacing the explicit HOMED / OK chips that used to live in
// their own columns.
&.state-green
background #dcfce7
border-color #86efac
color #166534
&:hover:not([disabled])
background #bbf7d0
&.state-amber
background #fef3c7
border-color #fcd34d
color #92400e
&:hover:not([disabled])
background #fde68a
&.state-red
background #fee2e2
border-color #fca5a5
color #991b1b
&:hover:not([disabled])
background #fecaca
&[disabled].state-green,
&[disabled].state-amber,
&[disabled].state-red
opacity 0.7
.actions-cell .actions-cell
display flex display flex
justify-content flex-end justify-content flex-end
@@ -2380,7 +2413,7 @@ html.kiosk-mode
gap 6px gap 6px
.control-page .dro-head, .control-page .dro-row .control-page .dro-head, .control-page .dro-row
grid-template-columns 56px 1fr 0.85fr 0.85fr 90px 90px 1fr grid-template-columns 56px 1fr 0.85fr 0.85fr 1fr
column-gap 0.4rem column-gap 0.4rem
padding 6px 10px padding 6px 10px