diff --git a/src/pug/templates/control-view.pug b/src/pug/templates/control-view.pug index cd4de06..ccf2913 100644 --- a/src/pug/templates/control-view.pug +++ b/src/pug/templates/control-view.pug @@ -215,8 +215,6 @@ script#control-view-template(type="text/x-template") div Position div Absolute div Offset - div State - div Toolpath .actions-cell // Master Home All. Each row's Actions cell has a per-axis // home button; this header-level button homes every @@ -230,31 +228,24 @@ script#control-view-template(type="text/x-template") each axis in 'xyzabc' .dro-row(:class=`${axis}.klass + ' ' + ${axis}.tklass`, v-if=`${axis}.enabled`, - :title=`${axis}.title`) + :title=`${axis}.toolmsg ? (${axis}.title + ' — ' + ${axis}.toolmsg) : ${axis}.title`) .dro-axis(:class=`'axis-' + '${axis}'`)= axis.toUpperCase() .dro-pos: unit-value(:value=`${axis}.pos`, precision=4) .dro-sec: unit-value(:value=`${axis}.abs`, 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 button.icon-btn(:disabled="!can_set_axis", :title=`'Set ${axis.toUpperCase()} axis position.'`, @click=`show_set_position('${axis}')`) .fa.fa-gear - button.icon-btn(:disabled="!can_set_axis", - :title=`'Zero ${axis.toUpperCase()} axis offset.'`, + button.icon-btn(:class=`${axis}.tklass.indexOf('error') !== -1 ? 'state-red' : (${axis}.tklass.indexOf('warn') !== -1 ? 'state-amber' : 'state-green')`, + :disabled="!can_set_axis", + :title=`${axis}.toolmsg || ('Zero ${axis.toUpperCase()} axis offset.')`, @click=`zero('${axis}')`) .fa.fa-location-dot - button.icon-btn(:disabled="!is_idle", - :title=`'Home ${axis.toUpperCase()} axis.'`, + button.icon-btn(:class=`${axis}.klass.indexOf('error') !== -1 ? 'state-red' : (${axis}.homed ? 'state-green' : 'state-amber')`, + :disabled="!is_idle", + :title=`${axis}.title`, @click=`home('${axis}')`) .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-sec: unit-value(:value="w.abs", precision=3) .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 button.icon-btn(disabled, style="visibility:hidden") .fa.fa-gear button.icon-btn(disabled, style="visibility:hidden") .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()") .fa.fa-home diff --git a/src/py/bbctrl/Mach.py b/src/py/bbctrl/Mach.py index ef99ccf..fd18680 100644 --- a/src/py/bbctrl/Mach.py +++ b/src/py/bbctrl/Mach.py @@ -95,6 +95,10 @@ class Mach(Comm): self.planner = bbctrl.Planner(ctrl) self.unpausing = 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') @@ -327,6 +331,16 @@ class Mach(Comm): axes = 'zxybc' if is_rotary_active else 'zxyabc' # TODO This should be configurable 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: enabled = state.is_axis_enabled(axis) mode = state.axis_homing_mode(axis) @@ -357,41 +371,10 @@ class Mach(Comm): and ext.axis_letter == axis.lower(): if 1 < len(axes) and not enabled: continue - self.mlog.info('Homing external %s axis via auxcnc' % - axis.upper()) - cycle_started = False - try: - 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 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') + # Defer until AVR axes are done. We capture the axis + # letter and ext reference; the actual homing runs + # in _run_external_homing below. + external_pending.append((axis, ext)) continue # 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) 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 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): # External axes don't have AVR-side homed state to clear; the diff --git a/src/stylus/style.styl b/src/stylus/style.styl index abdcbba..345a26a 100644 --- a/src/stylus/style.styl +++ b/src/stylus/style.styl @@ -1396,7 +1396,7 @@ tt.save .control-page .dro-head, .control-page .dro-row 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 align-items center padding 14px 22px @@ -1529,6 +1529,39 @@ tt.save opacity 0.45 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 display flex justify-content flex-end @@ -2380,7 +2413,7 @@ html.kiosk-mode gap 6px .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 padding 6px 10px