diff --git a/docs/AUX_A_AXIS.md b/docs/AUX_A_AXIS.md index 43ca1a7..a1a6857 100644 --- a/docs/AUX_A_AXIS.md +++ b/docs/AUX_A_AXIS.md @@ -8,8 +8,9 @@ > blended with XYZ in the same S-curve plan and the gcode surface > below applies as plain `A` words. > -> The HOOK pipeline still exists for ATC pneumatics (M100..M103), -> see `bbctrl/AuxPreprocessor.py`. +> The HOOK pipeline still exists for ATC pneumatic atoms (M100 EJECT, +> M102 RELEASE, M103 CLAMP) - see `bbctrl/AuxPreprocessor.py`. Macros +> compose drop/grab tool sequences from those atoms. This adds a virtual `A` axis to the bbctrl controller, driven by the auxcnc ESP32 over USB serial (`/dev/ttyUSB0`). The ESP owns step-pulse diff --git a/src/py/bbctrl/AuxAxis.py b/src/py/bbctrl/AuxAxis.py index 5629265..e7ab75c 100644 --- a/src/py/bbctrl/AuxAxis.py +++ b/src/py/bbctrl/AuxAxis.py @@ -382,38 +382,23 @@ class AuxAxis(object): except Exception as e: self.log.warning('ABORT send failed: %s' % e) - # ---------------------------------------------------------- ATC commands + # ---------------------------------------------------------- ATC atoms # # The auxcnc firmware drives an AMB 1050 FME-W DI tool changer via - # three pneumatic valves on relays 1-3. The ESP runs the timed - # sequences itself; the host just kicks them off and waits for the - # terminal reply. - - def atc_droptool(self, timeout=30.0): - """Eject the current tool. Opens the collet (V1), oscillates the - ejector (V2), then re-clamps with a bleed cycle. Blocks until - the ESP reports done. Raises on failure.""" - self._require_present() - line = self._rpc('DROPTOOL', topic='droptool', timeout=timeout) - if line.startswith('done'): - return - reason = line.split('reason=', 1)[1] if 'reason=' in line else line - raise AuxAxisError('DROPTOOL failed: %s' % reason) - - def atc_grabtool(self, timeout=30.0): - """Pick up a tool that's already been seated by the operator. - Opens V1 (releases the collet), waits for the operator to insert - the holder, then re-clamps with a bleed cycle. Blocks.""" - self._require_present() - line = self._rpc('GRABTOOL', topic='grabtool', timeout=timeout) - if line.startswith('done'): - return - reason = line.split('reason=', 1)[1] if 'reason=' in line else line - raise AuxAxisError('GRABTOOL failed: %s' % reason) + # two pneumatic valves on relays 1-2: + # V1 (clamp, 3/2 valve) - relay 2: ON = collet open, OFF = vent + spring closes + # V2 (ejector) - relay 1: ON = ejector cylinder extends + # + # The host exposes three composable atoms - RELEASE, CLAMP, EJECT - + # and composes drop/grab sequences from G-code macros that call + # them in order. (Older firmware exposed monolithic DROPTOOL / + # GRABTOOL verbs; protocol v3 dropped them in favour of these + # atoms so callers can interleave Z moves between ejector pulses.) def atc_release(self, timeout=5.0): - """Manually open the collet (release-only, no clamp). Use - atc_clamp() afterwards once the new holder is in place.""" + """Open the collet (V1 on). Instant. Idempotent. Pairs with + atc_clamp() to bracket a sequence of host-side moves and/or + ejector pulses with the collet held open.""" self._require_present() line = self._rpc('RELEASE', topic='release', timeout=timeout) if line.startswith('done'): @@ -422,8 +407,8 @@ class AuxAxis(object): raise AuxAxisError('RELEASE failed: %s' % reason) def atc_clamp(self, timeout=10.0): - """Manually clamp the collet (run a full bleed cycle). Pairs - with atc_release() for two-step manual tool changes.""" + """Close the collet: V1 off, then dwell for the line to vent + and the spring to re-engage. Idempotent.""" self._require_present() line = self._rpc('CLAMP', topic='clamp', timeout=timeout) if line.startswith('done'): @@ -431,6 +416,29 @@ class AuxAxis(object): reason = line.split('reason=', 1)[1] if 'reason=' in line else line raise AuxAxisError('CLAMP failed: %s' % reason) + def atc_eject(self, pulse_ms=None, dwell_ms=None, timeout=10.0): + """One ejector wiggle: V2 on for pulse_ms, then off for + dwell_ms. The collet (V1) is left in whatever state the caller + set it to via atc_release/atc_clamp - typically RELEASE first + so the holder can actually drop. + + Repeatedly calling atc_eject gives the wiggle that the old + monolithic DROPTOOL did internally, but as discrete blocking + calls so a macro can interleave Z moves between pulses. + + pulse_ms / dwell_ms default to the ESP-side defaults + (currently 500 / 500). Pass explicit values to override.""" + self._require_present() + parts = ['EJECT'] + if pulse_ms is not None: parts.append('pulse=%d' % int(pulse_ms)) + if dwell_ms is not None: parts.append('dwell=%d' % int(dwell_ms)) + cmd = ' '.join(parts) + line = self._rpc(cmd, topic='eject', timeout=timeout) + if line.startswith('done'): + return + reason = line.split('reason=', 1)[1] if 'reason=' in line else line + raise AuxAxisError('EJECT failed: %s' % reason) + def close(self): self._stop.set() try: diff --git a/src/py/bbctrl/AuxPreprocessor.py b/src/py/bbctrl/AuxPreprocessor.py index fcf9f28..c4084be 100644 --- a/src/py/bbctrl/AuxPreprocessor.py +++ b/src/py/bbctrl/AuxPreprocessor.py @@ -11,20 +11,28 @@ # so gplan handles W motion natively. The preprocessor no longer # touches W tokens. ATC pneumatics still go through the hook # channel because they're events, not motion. +# v3: ATC primitives split into atoms. The composite DROPTOOL / +# GRABTOOL hooks are gone; macros now compose tool changes from +# RELEASE / CLAMP / EJECT. # # What this still does # -------------------- -# Maps four user-defined M-codes onto pneumatic-tool-changer events: +# Maps three user-defined M-codes onto pneumatic-tool-changer atoms: # -# M100 DROPTOOL -> (MSG,HOOK:droptool:) -# M101 GRABTOOL -> (MSG,HOOK:grabtool:) -# M102 RELEASE -> (MSG,HOOK:release:) -# M103 CLAMP -> (MSG,HOOK:clamp:) +# M100 EJECT -> (MSG,HOOK:eject:) one V2 ejector pulse +# M102 RELEASE -> (MSG,HOOK:release:) open collet (V1 on) +# M103 CLAMP -> (MSG,HOOK:clamp:) close collet (V1 off + vent) +# +# M101 (formerly GRABTOOL) is intentionally unmapped - it's now a +# pure host-side macro composed from RELEASE / dwell / CLAMP. If a +# legacy file still emits M101 the preprocessor leaves it alone and +# the planner ignores it (M101 is in the user-defined range, so it +# won't error - it just won't do anything). # # M100-M103 are in LinuxCNC/Buildbotics' user-defined range, so the # planner won't error if the codes leak through unrewritten - it just -# won't *do* anything. We strip them out and emit the matching hook -# line in their place. +# won't *do* anything. We strip the recognized ones out and emit the +# matching hook line in their place. # # The preprocessor is intentionally conservative: anything it doesn't # understand is left alone. @@ -40,10 +48,10 @@ import tempfile # Strip line comments so we don't get fooled by "(M100 not really)". _PAREN_COMMENT_RE = re.compile(r'\([^)]*\)') -# ATC pneumatics M-codes mapped onto hook events. +# ATC pneumatics M-codes mapped onto hook events. M101 is +# deliberately unassigned (see header). _ATC_M_CODES = { - 100: 'droptool', - 101: 'grabtool', + 100: 'eject', 102: 'release', 103: 'clamp', } @@ -339,7 +347,7 @@ class AuxPreprocessor(object): if self._maybe_inject_a_down(code, fout): rewrote_any = True - # ATC M-codes (M100-M103). Each ATC M-code on the line + # ATC M-codes (M100/M102/M103). Each ATC M-code on the line # is replaced with its (MSG,HOOK::) line and # stripped from the residual. atc_matches = list(_ATC_M_RE.finditer(line)) diff --git a/src/py/bbctrl/Ctrl.py b/src/py/bbctrl/Ctrl.py index 70ee11c..bab0425 100644 --- a/src/py/bbctrl/Ctrl.py +++ b/src/py/bbctrl/Ctrl.py @@ -166,31 +166,46 @@ class Ctrl(object): else: self.aux.home() - def _hook_droptool(ctx): self.aux.atc_droptool() - def _hook_grabtool(ctx): self.aux.atc_grabtool() def _hook_release(ctx): self.aux.atc_release() def _hook_clamp(ctx): self.aux.atc_clamp() + def _hook_eject(ctx): + # ctx['data'] is the payload after HOOK:eject:. Allow + # operators to override pulse / dwell from gcode via + # (MSG,HOOK:eject:pulse=400 dwell=300). Empty data -> + # ESP defaults. + data = (ctx.get('data') or '').strip() + kw = {} + for tok in data.split(): + if '=' not in tok: continue + k, v = tok.split('=', 1) + k = k.strip().lower() + if k in ('pulse', 'pulse_ms'): + try: kw['pulse_ms'] = int(v) + except ValueError: pass + elif k in ('dwell', 'dwell_ms'): + try: kw['dwell_ms'] = int(v) + except ValueError: pass + self.aux.atc_eject(**kw) + # Legacy alias for older gcode that used aux_home. self.hooks.register_internal('aux_home', _hook_aux_home, block_unpause=True, auto_resume=True, timeout=180) - # ATC pneumatics. block_unpause + auto_resume so a program - # using M100/M101/M102/M103 pauses at the right point and - # resumes once the sequence is done. - self.hooks.register_internal('droptool', _hook_droptool, - block_unpause=True, auto_resume=True, - timeout=60) - self.hooks.register_internal('grabtool', _hook_grabtool, - block_unpause=True, auto_resume=True, - timeout=60) + # ATC pneumatic atoms. block_unpause + auto_resume so a + # program using M100/M102/M103 pauses at the right point and + # resumes once each atom finishes. Macros compose drop/grab + # sequences from these primitives. self.hooks.register_internal('release', _hook_release, block_unpause=True, auto_resume=True, timeout=10) self.hooks.register_internal('clamp', _hook_clamp, block_unpause=True, auto_resume=True, timeout=15) + self.hooks.register_internal('eject', _hook_eject, + block_unpause=True, auto_resume=True, + timeout=15) log.info('Aux hooks registered')