################################################################################ # # # This file is part of the Buildbotics firmware. # # # # Copyright (c) 2015 - 2018, Buildbotics LLC # # All rights reserved. # # # # This file ("the software") is free software: you can redistribute it # # and/or modify it under the terms of the GNU General Public License, # # version 2 as published by the Free Software Foundation. You should # # have received a copy of the GNU General Public License, version 2 # # along with the software. If not, see . # # # # The software is distributed in the hope that it will be useful, but # # WITHOUT ANY WARRANTY; without even the implied warranty of # # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # # Lesser General Public License for more details. # # # # You should have received a copy of the GNU Lesser General Public # # License along with the software. If not, see # # . # # # # For information regarding this software email: # # "Joseph Coffland" # # # ################################################################################ import bbctrl from bbctrl.Comm import Comm import bbctrl.Cmd as Cmd # Axis homing procedure: # # Mark axis unhomed # Set feed rate to search_vel # Seek closed by search_dist # Set feed rate to latch_vel # Seek open latch_backoff # Seek closed latch_backoff * -1.5 # Rapid to zero_backoff # Mark axis homed and set absolute position axis_homing_procedure = ''' G28.2 %(axis)s0 F[#<_%(axis)s_search_velocity>] G38.6 %(axis)s[#<_%(axis)s_home_travel>] G38.8 %(axis)s[#<_%(axis)s_latch_backoff>] F[#<_%(axis)s_latch_velocity>] G38.6 %(axis)s[#<_%(axis)s_latch_backoff> * -8] G91 G0 G53 %(axis)s[#<_%(axis)s_zero_backoff>] G90 G28.3 %(axis)s[#<_%(axis)s_home_position>] ''' # The stepper drivers have a stall flag that is reset # by moving the motors without a stall condition. # The "wiggle" in the stall procedure is to clear the flag. # This was to correct the issue where the stepper motors # may already be in a stall condition when homing is started. # For example, if a user tried to home twice in a row # the second homing attempt would immediately think it # was stalled if we didn't first back it up a bit stall_homing_procedure = ''' G91 G1 %(axis)s [#<_%(axis)s_zero_backoff>] F[#<_%(axis)s_search_velocity>] G4 P0.25 G28.2 %(axis)s0 F[#<_%(axis)s_search_velocity>] G38.6 %(axis)s[#<_%(axis)s_home_travel>] G91 G1 G53 %(axis)s[#<_%(axis)s_zero_backoff>] F100 G90 G28.3 %(axis)s[#<_%(axis)s_home_position>] ''' motor_fault_error = '''\ Motor %d driver fault. A potentially damaging electrical condition was \ detected and the motor driver was shutdown. Please power down the controller \ and check your motor cabling. See the "Motor Faults" table on the "Indicators" \ for more information.\ ''' def overrides(interface_class): def overrider(method): if not method.__name__ in dir(interface_class): raise Exception('%s does not override %s' % ( method.__name__, interface_class.__name__)) return method return overrider class Mach(Comm): def __init__(self, ctrl, avr): super().__init__(ctrl, avr) self.ctrl = ctrl self.mlog = self.ctrl.log.get('Mach') 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') ctrl.state.add_listener(self._update) super().reboot() def _get_state(self): return self.ctrl.state.get('xx', '') def _is_estopped(self): return self._get_state() == 'ESTOPPED' def _is_holding(self): return self._get_state() == 'HOLDING' def _is_ready(self): return self._get_state() == 'READY' def _get_pause_reason(self): return self.ctrl.state.get('pr', '') def _get_cycle(self): return self.ctrl.state.get('cycle', 'idle') def _is_paused(self): if not self._is_holding() or self.unpausing: return False return self._get_pause_reason() in ( 'User pause', 'Program pause', 'Optional pause') def _set_cycle(self, cycle): self.ctrl.state.set('cycle', cycle) def _begin_cycle(self, cycle): current = self._get_cycle() if current == cycle: return # No change if current != 'idle': raise Exception('Cannot enter %s cycle while in %s cycle' % (cycle, current)) # TODO handle jogging during pause # if current == 'idle' or (cycle == 'jogging' and self._is_paused()): self._set_cycle(cycle) def process_log(self, log): # When a probe has failed, we have to e-stop or things # end up in a bad state, where positions and offsets are incorrect if log['msg'] == 'Switch not found': self.estop() def _end_cycle(self): if (self._get_cycle() != 'idle' and self._is_ready() and not self.planner.is_busy() and not super().is_active()): self.planner.position_change() self._set_cycle('idle') def _update(self, update): # Detect motor faults for motor in range(4): key = '%ddf' % motor if key in update and update[key] & 0x1f: self.mlog.error(motor_fault_error % motor) # Get state state_changed = 'xc' in update state = self._get_state() # Handle EStop if state_changed and state == 'ESTOPPED': self.planner.reset(stop = False) # Exit cycle if state changed to READY if (state_changed and self._get_cycle() != 'idle' and self._is_ready() and not self.planner.is_busy() and not super().is_active()): self.planner.position_change() self._set_cycle('idle') # Planner stop if state == 'READY' and self.stopping: self.planner.stop() self.ctrl.state.set('line', 0) self.stopping = False # Unpause sync if state_changed and state != 'HOLDING': self.unpausing = False # Entering HOLDING state if state_changed and state == 'HOLDING': # Always flush queue after pause super().i2c_command(Cmd.FLUSH) super().resume() # Automatically unpause after seek or stop hold # Must be after holding commands above op = self.ctrl.state.get('optional_pause', False) pr = self._get_pause_reason() if ((state_changed or 'pr' in update) and self._is_holding() and (pr in ('Switch found', 'User stop') or (pr == 'Optional pause' and not op))): self._unpause() def _unpause(self): pause_reason = self._get_pause_reason() self.mlog.info('Unpause: ' + pause_reason) if pause_reason == 'User stop': self.planner.stop() self.ctrl.state.set('line', 0) else: self.planner.restart() super().i2c_command(Cmd.UNPAUSE) self.unpausing = True def _i2c_block(self, block): super().i2c_command(block[0], block = block[1:]) def _i2c_set(self, name, value): self._i2c_block(Cmd.set(name, value)) @overrides(Comm) def comm_next(self): if self.planner.is_running() and not self._is_holding(): return self.planner.next() @overrides(Comm) def comm_error(self): self.planner.reset() @overrides(Comm) def connect(self): self.planner.reset() super().connect() def _query_var(self, cmd): equal = cmd.find('=') if equal == -1: self.mlog.info('%s=%s' % (cmd, self.ctrl.state.get(cmd[1:]))) else: name, value = cmd[1:equal], cmd[equal + 1:] if value.lower() == 'true': value = True elif value.lower() == 'false': value = False else: try: value = float(value) except: pass self.ctrl.state.config(name, value) def mdi(self, cmd, with_limits = True): try: if not len(cmd): return if cmd[0] == '$': self._query_var(cmd) elif cmd[0] == '\\': super().queue_command(cmd[1:]) else: # Rewrite ATC M-codes in MDI input the same way the # FileHandler rewrites uploaded files. Motion (X/Y/Z/A) # is left unchanged: the planner handles it natively # now that the auxcnc stepper is exposed as a virtual # A axis (see ExternalAxis). cmd = self._rewrite_aux_mdi(cmd) self._begin_cycle('mdi') self.planner.mdi(cmd, with_limits) super().resume() except BaseException as err: self.mlog.info("Exception during MDI: %s" % err) pass def _rewrite_aux_mdi(self, cmd): """Apply the ATC M-code preprocessor to a single MDI line. Returns possibly-multi-line G-code with HOOK: comments inserted.""" try: from bbctrl.AuxPreprocessor import AuxPreprocessor, _ATC_M_RE if not _ATC_M_RE.search(cmd): return cmd import io, tempfile, os # AuxPreprocessor.process is file-based; route through # tempfiles so we don't fork the regex/state logic. pre = AuxPreprocessor(log=self.mlog) with tempfile.NamedTemporaryFile('w', suffix='.nc', delete=False) as fi: fi.write(cmd if cmd.endswith('\n') else cmd + '\n') ipath = fi.name opath = ipath + '.out' try: pre.process(ipath, opath) rewritten = open(opath).read() finally: try: os.unlink(ipath) except OSError: pass try: os.unlink(opath) except OSError: pass return rewritten except Exception as e: self.mlog.warning('Aux MDI rewrite failed: %s' % e) return cmd def set(self, code, value): super().queue_command('${}={}'.format(code, value)) def jog(self, axes): # Strip the external axis from the jog request before sending # to the AVR. v1 doesn't support continuous-rate jogging on # the ESP-driven axis - users jog A via /api/aux/jog (relative # mm steps) instead. Sending A to the AVR is harmless (no # motor maps to it) but cleaner to strip. ext = getattr(self.ctrl, 'ext_axis', None) if ext is not None and isinstance(axes, dict): axes = {k: v for k, v in axes.items() if k.lower() != ext.axis_letter} if not axes: return self._begin_cycle('jogging') self.planner.position_change() super().queue_command(Cmd.jog(axes)) def home(self, axis, position = None): state = self.ctrl.state if axis is None: is_rotary_active = state.get('2an', None) == 3 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) # External axes (e.g. the auxcnc-driven A axis) home via # their own ESP-side homing routine; the standard # 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) if ext is not None and ext.enabled \ and ext.axis_letter == axis.lower(): if 1 < len(axes) and not enabled: continue # 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 # axis is disabled or in manual homing mode, don't show any # warnings if 1 < len(axes) and (not enabled or mode == 'manual'): continue # Error when axes cannot be homed reason = state.axis_home_fail_reason(axis) if reason is not None: self.mlog.error('Cannot home %s axis: %s' % ( axis.upper(), reason)) continue if mode == 'manual': if position is None: raise Exception('Position not set') self.mdi('G28.3 %c%f' % (axis, position)) continue # Home axis self.mlog.info('Homing %s axis' % axis) self._begin_cycle('homing') if mode.startswith('stall-'): procedure = stall_homing_procedure else: procedure = axis_homing_procedure gcode = procedure % {'axis': axis} 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 # ESP holds its own homed flag. We don't have an explicit # "unhome" verb on the ESP, but a stale homed flag is harmless # because the next absolute move will fail-soft via # ExternalAxis._pos_mm sync. Still mirror the cleared flag # into State for the UI. ext = getattr(self.ctrl, 'ext_axis', None) if ext is not None and ext.enabled \ and chr(axis).lower() == ext.axis_letter: from bbctrl.ExternalAxis import EXTERNAL_MOTOR_INDEX self.ctrl.state.set('%dh' % EXTERNAL_MOTOR_INDEX, 0) self.ctrl.state.set(ext.axis_letter + '_homed', False) return self.mdi('G28.2 %c0' % axis) def estop(self): super().estop() def clear(self): if self._is_estopped(): self.planner.reset() super().clear() def start(self): filename = self.ctrl.state.get('selected', '') if not filename: return self._begin_cycle('running') self.planner.load(filename) super().resume() def step(self): raise Exception('NYI') # TODO if self._get_cycle() != 'running': self.start() else: super().i2c_command(Cmd.UNPAUSE) def stop(self): if self._get_state() != 'jogging': self.stopping = True super().i2c_command(Cmd.STOP) # Drain the external-axis worker queue so post-stop resumption # doesn't replay queued moves that the user wanted cancelled. ext = getattr(self.ctrl, 'ext_axis', None) if ext is not None: try: ext.abort() except Exception: pass def pause(self): super().pause() def unpause(self): if self._is_paused(): # Gate unpause on hook completion if hasattr(self.ctrl, 'hooks') and \ not self.ctrl.hooks.can_unpause(): return self.ctrl.state.set('optional_pause', False) self._unpause() def optional_pause(self, enable = True): self.ctrl.state.set('optional_pause', enable) def set_position(self, axis, position, set_config = None): axis = axis.lower() state = self.ctrl.state if state.is_axis_homed(axis): # If homed, change the offset rather than the absolute position self.mdi('G92%s%f' % (axis, position)) #storing the offset to config if set_config is not None: self.ctrl.config.set('axes', {'offset_' + axis : state.get(axis + 'p')}) self.log.info('set_position: {} = {} '.format(axis, state.get(axis + 'p'))) elif state.is_axis_enabled(axis): if self._get_cycle() != 'idle' and not self._is_paused(): raise Exception('Cannot set position during ' + self._get_cycle()) # Set the absolute position both locally and via the AVR target = position + state.get('offset_' + axis) state.set(axis + 'p', target) super().queue_command(Cmd.set_axis(axis, target)) def override_feed(self, override): self._i2c_set('fo', int(1000 * override)) def override_speed(self, override): self._i2c_set('so', int(1000 * override)) def modbus_read(self, addr): self._i2c_block(Cmd.modbus_read(addr)) def modbus_write(self, addr, value): self._i2c_block(Cmd.modbus_write(addr, value))