ATC: split tool-change M-codes into composable atoms

Match auxcnc firmware v3, which dropped the monolithic DROPTOOL /
GRABTOOL ESP tasks in favour of three atoms: RELEASE, CLAMP, EJECT.
This lets host macros interleave Z moves between ejector pulses
(the old DROPTOOL ran open->oscillate->clamp in a single ESP task,
so you couldn't lift Z mid-eject).

  AuxAxis: replace atc_droptool() / atc_grabtool() with atc_eject(
    pulse_ms=, dwell_ms=). atc_release() / atc_clamp() are unchanged.

  Ctrl: register internal hooks for release / clamp / eject only.
    The eject hook parses 'pulse=' and 'dwell=' kwargs out of the
    HOOK:eject:<data> payload so macros can emit
    (MSG,HOOK:eject:pulse=400 dwell=300) for tuned wiggles.

  AuxPreprocessor: M100 now maps to eject (was droptool); M101 is
    unmapped (was grabtool, now a pure host-side macro); M102/M103
    are unchanged. Header comment updated.

  docs/AUX_A_AXIS.md: mention the new atom set.

The drop.nc and grab.nc gcode macros on the controller are
correspondingly rewritten on-device as compositions:
  drop = M102 + 4xM100 + G53 G0 Z0 + M103
  grab = M102 + G4 P2 + M103
This commit is contained in:
2026-05-03 18:15:55 +02:00
parent b59091007c
commit 130c39fad9
4 changed files with 86 additions and 54 deletions

View File

@@ -8,8 +8,9 @@
> blended with XYZ in the same S-curve plan and the gcode surface > blended with XYZ in the same S-curve plan and the gcode surface
> below applies as plain `A` words. > below applies as plain `A` words.
> >
> The HOOK pipeline still exists for ATC pneumatics (M100..M103), > The HOOK pipeline still exists for ATC pneumatic atoms (M100 EJECT,
> see `bbctrl/AuxPreprocessor.py`. > 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 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 auxcnc ESP32 over USB serial (`/dev/ttyUSB0`). The ESP owns step-pulse

View File

@@ -382,38 +382,23 @@ class AuxAxis(object):
except Exception as e: except Exception as e:
self.log.warning('ABORT send failed: %s' % 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 # 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 # two pneumatic valves on relays 1-2:
# sequences itself; the host just kicks them off and waits for the # V1 (clamp, 3/2 valve) - relay 2: ON = collet open, OFF = vent + spring closes
# terminal reply. # V2 (ejector) - relay 1: ON = ejector cylinder extends
#
def atc_droptool(self, timeout=30.0): # The host exposes three composable atoms - RELEASE, CLAMP, EJECT -
"""Eject the current tool. Opens the collet (V1), oscillates the # and composes drop/grab sequences from G-code macros that call
ejector (V2), then re-clamps with a bleed cycle. Blocks until # them in order. (Older firmware exposed monolithic DROPTOOL /
the ESP reports done. Raises on failure.""" # GRABTOOL verbs; protocol v3 dropped them in favour of these
self._require_present() # atoms so callers can interleave Z moves between ejector pulses.)
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)
def atc_release(self, timeout=5.0): def atc_release(self, timeout=5.0):
"""Manually open the collet (release-only, no clamp). Use """Open the collet (V1 on). Instant. Idempotent. Pairs with
atc_clamp() afterwards once the new holder is in place.""" atc_clamp() to bracket a sequence of host-side moves and/or
ejector pulses with the collet held open."""
self._require_present() self._require_present()
line = self._rpc('RELEASE', topic='release', timeout=timeout) line = self._rpc('RELEASE', topic='release', timeout=timeout)
if line.startswith('done'): if line.startswith('done'):
@@ -422,8 +407,8 @@ class AuxAxis(object):
raise AuxAxisError('RELEASE failed: %s' % reason) raise AuxAxisError('RELEASE failed: %s' % reason)
def atc_clamp(self, timeout=10.0): def atc_clamp(self, timeout=10.0):
"""Manually clamp the collet (run a full bleed cycle). Pairs """Close the collet: V1 off, then dwell for the line to vent
with atc_release() for two-step manual tool changes.""" and the spring to re-engage. Idempotent."""
self._require_present() self._require_present()
line = self._rpc('CLAMP', topic='clamp', timeout=timeout) line = self._rpc('CLAMP', topic='clamp', timeout=timeout)
if line.startswith('done'): if line.startswith('done'):
@@ -431,6 +416,29 @@ class AuxAxis(object):
reason = line.split('reason=', 1)[1] if 'reason=' in line else line reason = line.split('reason=', 1)[1] if 'reason=' in line else line
raise AuxAxisError('CLAMP failed: %s' % reason) 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): def close(self):
self._stop.set() self._stop.set()
try: try:

View File

@@ -11,20 +11,28 @@
# so gplan handles W motion natively. The preprocessor no longer # so gplan handles W motion natively. The preprocessor no longer
# touches W tokens. ATC pneumatics still go through the hook # touches W tokens. ATC pneumatics still go through the hook
# channel because they're events, not motion. # 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 # 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:) # M100 EJECT -> (MSG,HOOK:eject:) one V2 ejector pulse
# M101 GRABTOOL -> (MSG,HOOK:grabtool:) # M102 RELEASE -> (MSG,HOOK:release:) open collet (V1 on)
# M102 RELEASE -> (MSG,HOOK:release:) # M103 CLAMP -> (MSG,HOOK:clamp:) close collet (V1 off + vent)
# M103 CLAMP -> (MSG,HOOK:clamp:) #
# 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 # M100-M103 are in LinuxCNC/Buildbotics' user-defined range, so the
# planner won't error if the codes leak through unrewritten - it just # 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 # won't *do* anything. We strip the recognized ones out and emit the
# line in their place. # matching hook line in their place.
# #
# The preprocessor is intentionally conservative: anything it doesn't # The preprocessor is intentionally conservative: anything it doesn't
# understand is left alone. # understand is left alone.
@@ -40,10 +48,10 @@ import tempfile
# Strip line comments so we don't get fooled by "(M100 not really)". # Strip line comments so we don't get fooled by "(M100 not really)".
_PAREN_COMMENT_RE = re.compile(r'\([^)]*\)') _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 = { _ATC_M_CODES = {
100: 'droptool', 100: 'eject',
101: 'grabtool',
102: 'release', 102: 'release',
103: 'clamp', 103: 'clamp',
} }
@@ -339,7 +347,7 @@ class AuxPreprocessor(object):
if self._maybe_inject_a_down(code, fout): if self._maybe_inject_a_down(code, fout):
rewrote_any = True 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:<event>:) line and # is replaced with its (MSG,HOOK:<event>:) line and
# stripped from the residual. # stripped from the residual.
atc_matches = list(_ATC_M_RE.finditer(line)) atc_matches = list(_ATC_M_RE.finditer(line))

View File

@@ -166,31 +166,46 @@ class Ctrl(object):
else: else:
self.aux.home() 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_release(ctx): self.aux.atc_release()
def _hook_clamp(ctx): self.aux.atc_clamp() 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. # Legacy alias for older gcode that used aux_home.
self.hooks.register_internal('aux_home', _hook_aux_home, self.hooks.register_internal('aux_home', _hook_aux_home,
block_unpause=True, auto_resume=True, block_unpause=True, auto_resume=True,
timeout=180) timeout=180)
# ATC pneumatics. block_unpause + auto_resume so a program # ATC pneumatic atoms. block_unpause + auto_resume so a
# using M100/M101/M102/M103 pauses at the right point and # program using M100/M102/M103 pauses at the right point and
# resumes once the sequence is done. # resumes once each atom finishes. Macros compose drop/grab
self.hooks.register_internal('droptool', _hook_droptool, # sequences from these primitives.
block_unpause=True, auto_resume=True,
timeout=60)
self.hooks.register_internal('grabtool', _hook_grabtool,
block_unpause=True, auto_resume=True,
timeout=60)
self.hooks.register_internal('release', _hook_release, self.hooks.register_internal('release', _hook_release,
block_unpause=True, auto_resume=True, block_unpause=True, auto_resume=True,
timeout=10) timeout=10)
self.hooks.register_internal('clamp', _hook_clamp, self.hooks.register_internal('clamp', _hook_clamp,
block_unpause=True, auto_resume=True, block_unpause=True, auto_resume=True,
timeout=15) timeout=15)
self.hooks.register_internal('eject', _hook_eject,
block_unpause=True, auto_resume=True,
timeout=15)
log.info('Aux hooks registered') log.info('Aux hooks registered')