Compare commits

8 Commits

Author SHA1 Message Date
f8be0a6b6f AuxPreprocessor: precede each HOOK with M0 so atoms block
The ATC M-codes are supposed to behave like proper blocking gcode -
M100 should not return until the ejector pulse has actually finished,
and the next block should not run until M100 has returned. Without
that, the drop macro

  M102            (release)
  M100            (eject pulse 1)
  M100            (eject pulse 2)
  M100            (eject pulse 3)
  M100            (eject pulse 4)
  G53 G0 Z0       (lift Z)
  M103            (clamp)

races: gplan emits all the (MSG,HOOK:...) lines and the Z move in
quick succession, the AVR queues them, and Z lifts while V2 is
still wiggling.

The (MSG,...) transport itself is fire-and-forget by gplan's design.
The Hooks framework already implements proper blocking via the
block_unpause / auto_resume mechanism - but it only takes effect
when the program is actually paused. So precede each hook with M0
(program pause) in the rewritten temp file:

  M0 (MSG,HOOK:release:)
  M0 (MSG,HOOK:eject:)
  ...

Sequence becomes:
  M0       -> machine pauses on the AVR side
  (MSG..)  -> hook fires synchronously in a thread
  hook does ESP RPC, blocks until [eject] done
  hook completes; auto_resume unpauses
  next block streams

This also fixes the consecutive-comment-line collapse problem
naturally: each M0 is its own block, so back-to-back HOOK lines
no longer collide.

The M0 lives only in the tempfile gplan loads; the operator's macro
source still reads as plain M100/M102/M103.
2026-05-03 18:39:33 +02:00
692be42f84 AuxPreprocessor: stop mutating the macro source; use a tempfile
Macros and uploaded jobs now pass through gplan untouched on disk.
The (MSG,HOOK:...) substitution that lets the host react to ATC
M-codes mid-program now lands in a tempfile that gplan loads instead
of the operator-authored source.

Why we still rewrite at all: gplan (camotics planner) treats
M100/M102/M103 as no-ops by spec and doesn't expose a callback for
user-defined M-codes. Its only in-band channel back to Python during
a running program is the (MSG,...) message stream, so we substitute
hook messages for those M-codes purely as transport. That mechanism
is fine; what was broken was that we wrote the substitution back
over the macro source. So:

- The macro editor opened drop.nc and saw (MSG,HOOK:...) blobs
  instead of the M100/M102/M103 sequence.
- Re-running compounded any rewrite quirk (paren-comment handling,
  consecutive HOOK lines collapsing) on every load.
- Editing a macro accidentally re-rewrote its already-rewritten
  form.

Now:

- AuxPreprocessor.preprocess_to_tempfile() returns a path to a
  rewritten temp file; the source is never modified. The old
  preprocess_file() in-place wrapper is kept (deprecated) for
  the upload path, where mutating the saved upload is fine.
- Planner.load() goes through preprocess_to_tempfile and tracks
  the temp path on the Planner instance, deleting the previous
  tempfile on each new load() so /tmp doesn't fill up.
- Each rewritten (MSG,HOOK:...) line gets a tiny G4 P0.001
  dwell prefix so gplan doesn't collapse consecutive comment-
  only lines into a single block (which was eating all but the
  last hook in a sequence). The dwell appears only in the
  tempfile, never in the source.

Macros on the controller (drop.nc, grab.nc, release.nc, clamp.nc)
restored to the human-readable M100/M102/M103 form.
2026-05-03 18:32:12 +02:00
d5ad717f78 AuxPreprocessor: ignore M-codes inside paren comments
Two bugs surfaced when macros got prose like:

    (Composed from atoms: M102 = RELEASE (V1 on), M103 = CLAMP)

1. _ATC_M_RE.finditer was being run against the raw line, so the
   M102/M103 *inside* the comment fired spurious release/clamp
   hooks at file load.
2. The simple _PAREN_COMMENT_RE = re.compile(r'\(\[^)]*\)') is a
   greedy non-nested match, so a header with a nested paren
   (e.g. 'M102 = RELEASE (V1 on)') only stripped the inner
   paren, leaving the trailing 'M103 = CLAMP)' visible to the
   matcher.

Fix:

  - Add _strip_comments() that walks the line tracking paren depth
    and drops the trailing semicolon comment. Handles nested parens
    correctly.
  - Run _ATC_M_RE.finditer against the comment-stripped 'code'
    instead of the raw line, so prose mentions are inert.
  - Drop the original line's comments from the rewritten output;
    keeping them around led to the M-codes being matched twice
    (once stripped, once still in the trailing comment).
  - Use _strip_comments in file_uses_aux too.

The grab.nc and drop.nc macros on the controller already had the
prose headers; they now preprocess correctly to clean
release / G4 / clamp and release / N x eject / Z0 / clamp
sequences.
2026-05-03 18:20:19 +02:00
130c39fad9 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
2026-05-03 18:15:55 +02:00
b59091007c Jog: enforce Z-A coupling on hold-to-jog
Pendant hold-to-jog now picks the more restrictive of the soft
limit and the Z-A coupling bound when computing target_steps for
the ESP. The coupling rule (a - z <= K) caps how high A may go
for the current Z; only the +A direction (toward larger machine
A) is constrained, -A jogs are unaffected.

ExternalAxis already exposes couple_K and _z_machine_now; we
project a_max_mm = z_now + K into step space via the same
_mm_to_steps the rest of AuxAxis uses.

The combined helper _a_combined_target_steps picks whichever of
the two targets is reached first when moving in . The
log line includes target_src so journalctl shows whether a stop
was triggered by softlimit or coupling.

Refusal-on-press logic was extended to use the combined target so
we won't even start a jog when sitting on a coupling-blocked
position.

Limitation: the target is computed once at JOG start. If Z drops
during the jog the bound moves with it; this version doesn't
re-evaluate. Z motion during a manual A jog is rare in practice
(both hands are on the pendant), but a periodic re-check is on
the follow-up list.
2026-05-03 18:08:48 +02:00
5787855f3f Jog: enforce A-axis soft limits during hold-to-jog
Pendant hold-to-jog could drive A past min_mm / max_mm because the
JOG path bypassed the planner-driven soft-limit checks. Wire the
host to compute a step-counter target for whichever soft-limit
boundary lies in the requested direction and pass it through the
new JOG target= parameter.

AuxAxis.jog_start now accepts target_steps; when given it emits
'JOG ... target=<n>'. The ESP picks the decel start point so the
motor ramps to a smooth stop AT the boundary, with no overshoot.

Jog._a_soft_limit_target_steps:
  - Returns None when the axis is not homed -- pre-home setup jogs
    are still allowed (matches the rest of the manual-jog API).
  - Otherwise projects min_mm/max_mm into step space (honoring
    dir_sign) and returns the boundary on the requested side of
    the current position.

Jog._a_start additionally refuses to send the JOG when the
position is already at-or-past the boundary in the requested
direction, so we don't depend on the ESP's wrong-side reject path
for the common 'press button while sitting on the limit' case.

Verified end-to-end on hardware (bare ESP, no gantry):
  JOG dir=+ maxrate=400 target=300 stops at pos=299
  JOG dir=+ target=-50 (wrong side) rejected immediately.
2026-05-03 18:06:29 +02:00
7360c437a9 Preplanner: surface plan failures so the Processing dialog exits
When a plan fails (e.g. AuxPreprocessor Z-A coupling rejection at
planner-load time, or any other gplan error in the plan.py
subprocess), the Plan future was never resolved. PathHandler then
1-second-times-out forever returning {progress:0}, and the JS poll
loop in load_toolpath kept the 'Processing New File' dialog up
indefinitely.

- Preplanner.Plan now records .error and always resolves the future.
- PathHandler returns {progress:1, error:...} when the plan failed.
- load_toolpath closes the dialog and alerts the operator on error,
  and breaks out of the poll loop on api errors instead of looping.
- FileHandler upload-time AuxPreprocessor coupling errors now post a
  visible state message instead of being silently swallowed.
2026-05-03 17:58:31 +02:00
01e39722d3 Jog: detailed event/state logging + dry-run env var
Adds visibility into the gamepad event path so future regressions
can be diagnosed without the gantry attached. AJOG EV logs every
incoming KEY event and any ABS event matching the trigger codes;
AJOG STATE logs every transition; the would-be JOG / JOGSTOP is
also logged.

BBCTRL_AJOG_DRYRUN=1 in the bbctrl env disables actuation while
keeping the logging, so the host-side state machine can be tested
without driving the ESP.

Default is live actuation (dry-run off). Used this to prove the
host side was correct on hardware where the firmware bug was
hiding -- pendant taps produced perfect press/release pairs at
~200 ms while the ESP was the one ignoring JOGSTOP.
2026-05-03 17:44:36 +02:00
10 changed files with 516 additions and 122 deletions

View File

@@ -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

View File

@@ -232,6 +232,17 @@ module.exports = {
const toolpath = await api.get(`path/${file}`);
this.toolpath_progress = toolpath.progress;
// Planner failure (e.g. AuxPreprocessor Z-A coupling
// rejection). Close the dialog and surface the message
// instead of polling the same broken plan forever.
if (toolpath.error) {
this.showGcodeMessage = false;
this.toolpath_progress = 0;
console.error("Plan failed:", toolpath.error);
alert("Could not plan G-code:\n\n" + toolpath.error);
return;
}
if (toolpath.progress === 1 || typeof toolpath.progress == "undefined") {
this.showGcodeMessage = false;
@@ -248,7 +259,11 @@ module.exports = {
}
}
} catch (error) {
// api.get throws on non-2xx; log and break the loop so the
// dialog doesn't stay up forever.
console.error(error);
this.showGcodeMessage = false;
return;
}
}
},

View File

@@ -314,9 +314,18 @@ class AuxAxis(object):
# pos=<p>` arrives later; our reader picks it up and resyncs
# _pos_steps via the same path as STEPS.
def jog_start(self, direction, max_rate_sps=None,
accel_sps2=None, ignore_limits=False):
accel_sps2=None, ignore_limits=False,
target_steps=None):
"""Begin a continuous-rate jog. `direction` is +1 or -1.
Returns once the ESP has accepted the JOG command."""
Returns once the ESP has accepted the JOG command.
target_steps (optional): a signed step-counter value. The
ESP picks the deceleration start point so the motor ramps
smoothly from the current cruise rate to step_start_rate
and stops AT this counter value. Used to enforce host-side
soft limits without overshoot. The target must be on the
side of the current g_pos that matches `direction`; the
ESP rejects a wrong-side target with reason=softlimit."""
self._require_present()
if direction not in (-1, +1):
raise AuxAxisError('jog_start direction must be +/-1')
@@ -327,13 +336,10 @@ class AuxAxis(object):
else int(self._cfg['step_accel_sps2']))
if rate < 1: rate = 1
if accel < 1: accel = 1
# Track the in-flight JOG so the reader can deliver the
# terminal [jog] done line back to us. We use a dedicated
# background thread so jog_start can return as soon as the
# `[jog] started` ack lands -- the terminal line may arrive
# seconds later (after JOGSTOP).
cmd = 'JOG dir=%s maxrate=%d accel=%d safe=%d' % (
sign, rate, accel, 0 if ignore_limits else 1)
if target_steps is not None:
cmd += ' target=%d' % int(target_steps)
# Capture both the immediate ack AND the eventual terminal
# line in a single _rpc call would block; instead fire the
# ack-only RPC here and let _on_line handle the terminal
@@ -376,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'):
@@ -416,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'):
@@ -425,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:

View File

@@ -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.
@@ -38,12 +46,46 @@ import tempfile
# Strip line comments so we don't get fooled by "(M100 not really)".
# Note this is a simple regex and doesn't handle nested parentheses
# - which actually occur in real macro headers like
# `(Composed from atoms: M102 = RELEASE (V1 on), M103 = CLAMP)`.
# Use _strip_comments() below for a parser that does handle them.
_PAREN_COMMENT_RE = re.compile(r'\([^)]*\)')
# ATC pneumatics M-codes mapped onto hook events.
def _strip_comments(line):
"""Return `line` with paren comments and the trailing semicolon
comment removed. Handles arbitrarily nested parentheses (RS274
technically forbids them but real-world gcode comments often
contain prose with parens, e.g. `(M102 = RELEASE (V1 on))`).
Returns just the executable code, with the original whitespace
preserved between tokens."""
out = []
depth = 0
i = 0
n = len(line)
while i < n:
c = line[i]
if c == ';' and depth == 0:
break
if c == '(':
depth += 1
i += 1
continue
if c == ')':
if depth > 0: depth -= 1
i += 1
continue
if depth == 0:
out.append(c)
i += 1
return ''.join(out)
# 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',
}
@@ -127,8 +169,7 @@ class AuxPreprocessor(object):
try:
with open(path, 'r', encoding='utf-8', errors='replace') as f:
for line in f:
code = _PAREN_COMMENT_RE.sub('', line)
code = code.split(';', 1)[0]
code = _strip_comments(line)
if _ATC_M_RE.search(code):
return True
if couple_active:
@@ -319,8 +360,7 @@ class AuxPreprocessor(object):
line = raw.rstrip('\n')
# Comment-only or blank lines pass through verbatim.
code = _PAREN_COMMENT_RE.sub('', line)
code = code.split(';', 1)[0]
code = _strip_comments(line)
if not code.strip():
fout.write(raw)
continue
@@ -339,10 +379,14 @@ 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
# is replaced with its (MSG,HOOK:<event>:) line and
# stripped from the residual.
atc_matches = list(_ATC_M_RE.finditer(line))
# ATC M-codes (M100/M102/M103). Match against the
# comment-stripped `code` so prose mentions like
# `(M102 = RELEASE)` inside a comment don't spuriously
# fire hooks. Each match emits a (MSG,HOOK:<event>:)
# line; the M-code is stripped from the executable
# residual but the original line's comments are kept
# for log readability.
atc_matches = list(_ATC_M_RE.finditer(code))
if atc_matches:
rewrote_any = True
for m in atc_matches:
@@ -350,19 +394,49 @@ class AuxPreprocessor(object):
except ValueError: continue
event = _ATC_M_CODES.get(num)
if event:
fout.write('(MSG,HOOK:%s:)\n' % event)
line = _ATC_M_RE.sub('', line)
code = _PAREN_COMMENT_RE.sub('', line)
code = code.split(';', 1)[0]
if not code.strip():
# Nothing meaningful left; preserve any trailing
# comment text but skip empty lines.
rest = line.rstrip()
if rest:
fout.write(rest + '\n')
continue
# Other gcode remains on the line - emit it.
fout.write(line + '\n')
# We need two things here that aren't
# naturally provided by the (MSG,...)
# transport:
#
# (1) Synchronization. (MSG,HOOK:...) is
# fire-and-forget from gplan's view -
# gplan emits the message and keeps
# streaming subsequent blocks (Z
# moves, the next eject, etc.) to the
# AVR. Meanwhile the hook handler
# runs the actual ESP RPC in a
# thread, and Z lifts while V2 is
# still wiggling. To make M-codes
# behave like proper blocking gcode,
# we precede each HOOK with M0
# (program pause). The Hooks layer
# registers the atom as block_unpause
# + auto_resume, so:
# M0 -> machine pauses
# (MSG,HOOK:event:) fires hook
# hook thread runs ESP RPC
# hook completes, auto-unpauses
# next block streams
# End result: M100/M102/M103 block
# until the ESP says done, just like
# a G-code dwell.
#
# (2) Block separation. gplan collapses
# consecutive comment-only lines
# into a single block, so back-to-
# back HOOK lines used to drop all
# but the last. M0 is its own block
# so this falls out automatically -
# the (MSG,...) attaches cleanly to
# each M0.
fout.write('M0 (MSG,HOOK:%s:)\n' % event)
code_stripped = _ATC_M_RE.sub('', code).strip()
if code_stripped:
# Mixed line: keep the residual executable
# gcode. Drop the comments to keep the
# rewritten file tidy (the original line's
# text already appears once as the input).
fout.write(code_stripped + '\n')
continue
# No rewrite needed.
@@ -371,16 +445,26 @@ class AuxPreprocessor(object):
return rewrote_any
def preprocess_file(src_path, log=None, coupling=None, **_unused):
"""Convenience: rewrite src_path in place if it contains ATC
M-codes or needs Z-A coupling injection. Returns True if the
file was rewritten.
def preprocess_to_tempfile(src_path, log=None, coupling=None):
"""Run the preprocessor on `src_path` and return the path to a
rewritten temp file (or None if no rewriting was needed). Caller
owns the temp file and must os.unlink() it when done.
`coupling` is an optional dict (see AuxPreprocessor.__init__).
Extra keyword args are accepted for backwards compat (the old
w_first arg is no longer used)."""
The original source file is never modified - this is the
intentional design: the macro / job file the operator authored
is what they see in the macro editor and the file viewer; the
rewriting happens only on the in-memory copy that gplan loads.
Why we rewrite at all: gplan (the camotics planner) treats the
user-defined M-codes M100/M102/M103 as no-ops. The only callback
channel it exposes during a running program is the (MSG,...)
message stream, so the only way for the host to react to those
M-codes mid-program is to substitute (MSG,HOOK:<event>:) lines
in their place. This rewriting is an implementation detail the
operator should never have to know about - hence the tempfile.
"""
if not AuxPreprocessor.file_uses_aux(src_path, coupling=coupling):
return False
return None
pre = AuxPreprocessor(log=log, coupling=coupling)
fd, tmp = tempfile.mkstemp(prefix='auxpre_', suffix='.nc',
dir=os.path.dirname(src_path) or None)
@@ -388,13 +472,36 @@ def preprocess_file(src_path, log=None, coupling=None, **_unused):
try:
rewrote = pre.process(src_path, tmp)
if rewrote:
shutil.move(tmp, src_path)
return True
return tmp
os.unlink(tmp)
return False
return None
except Exception:
try:
os.unlink(tmp)
except OSError:
pass
raise
def preprocess_file(src_path, log=None, coupling=None, **_unused):
"""DEPRECATED in-place version of the preprocessor. Kept for
callers that still rewrite their input on disk (chiefly the
upload path, where mutating the file is fine because there's no
operator-authored source to preserve).
Returns True if the file was rewritten, False otherwise.
For new callers prefer preprocess_to_tempfile() which never
touches the source."""
tmp = preprocess_to_tempfile(src_path, log=log, coupling=coupling)
if tmp is None:
return False
try:
shutil.move(tmp, src_path)
except Exception:
try:
os.unlink(tmp)
except OSError:
pass
raise
return True

View File

@@ -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')

View File

@@ -107,15 +107,27 @@ class FileHandler(bbctrl.APIHandler):
# auxcnc stepper is exposed as a virtual A axis (see
# ExternalAxis).
try:
from bbctrl.AuxPreprocessor import preprocess_file
from bbctrl.AuxPreprocessor import (
preprocess_file, AuxPreprocessorError)
log = self.get_log('AuxPreprocessor')
ext = getattr(self.get_ctrl(), 'ext_axis', None)
coupling = (ext.coupling_for_preprocessor()
if ext is not None else None)
if preprocess_file(filename.decode('utf8'),
log=log, coupling=coupling):
log.info('Rewrote upload (ATC / Z-A coupling) in %s'
% self.uploadFilename)
try:
if preprocess_file(filename.decode('utf8'),
log=log, coupling=coupling):
log.info('Rewrote upload (ATC / Z-A coupling) in %s'
% self.uploadFilename)
except AuxPreprocessorError as e:
# Surface coupling-violation errors to the operator
# via the message stream so the upload doesn't go
# silently un-rewritten and then trip the runtime
# check (which can hang the planner dialog).
log.warning('Aux preprocess refused upload: %s' % e)
try:
self.get_ctrl().state.add_message(
'Z-A coupling: ' + str(e))
except Exception: pass
except Exception:
self.get_log('AuxPreprocessor').exception(
'Aux preprocess failed; uploading unchanged')

View File

@@ -25,12 +25,21 @@
# #
################################################################################
import os
import threading
import time
import inevent
from inevent.Constants import *
# Set to True (or BBCTRL_AJOG_DRYRUN=1 in env) to log press/release
# events and would-be ESP commands without actually sending JOG /
# JOGSTOP. Useful for debugging the gamepad event path without
# touching the gantry. Defaults to live actuation.
A_DRY_RUN = os.environ.get('BBCTRL_AJOG_DRYRUN', '') == '1'
# Listen for input events
class Jog(inevent.JogHandler):
def __init__(self, ctrl):
@@ -94,6 +103,12 @@ class Jog(inevent.JogHandler):
def _a_stop(self):
ext = getattr(self.ctrl, 'ext_axis', None)
ext_state = ('present' if (ext is not None and ext.enabled)
else 'unavailable')
if A_DRY_RUN:
self.log.info('AJOG DRYRUN _a_stop ext=%s (would send JOGSTOP)',
ext_state)
return
if ext is None or not ext.enabled:
return
try:
@@ -101,25 +116,159 @@ class Jog(inevent.JogHandler):
except Exception as e:
self.log.warning('A-axis jog_stop failed: %s', e)
def _a_soft_limit_target_steps(self, aux, direction):
"""Return a step-counter target for the configured soft
limit (`min_mm` / `max_mm`) on the `direction` side of the
current position, or None when no limit applies (axis
unhomed or limits not configured)."""
try:
if not bool(aux._homed):
return None
cfg = aux._cfg
lo_mm = float(cfg.get('min_mm', 0.0))
hi_mm = float(cfg.get('max_mm', 0.0))
if hi_mm <= lo_mm:
return None
lo_steps = aux._mm_to_steps(lo_mm)
hi_steps = aux._mm_to_steps(hi_mm)
# _mm_to_steps applies dir_sign; sort so we know which
# is "more positive in g_pos".
top_steps = max(lo_steps, hi_steps)
bottom_steps = min(lo_steps, hi_steps)
return top_steps if direction > 0 else bottom_steps
except Exception:
return None
def _a_coupling_target_steps(self, ext, direction):
"""Return a step-counter target that prevents the Z-A
coupling rule (a - z <= K) from being violated by this jog.
Returns None when coupling is disabled or doesn't constrain
motion in `direction`.
The constraint is on machine-mm: the rule limits how far A
may go *up* (toward larger machine A) for the current Z. So
only the +A jog direction can ever violate it; -A jogs are
unconstrained by coupling and we return None for them.
Note: 'direction' here refers to the gamepad axis sign, not
machine-mm. dir_sign in aux config maps gamepad+ to
machine+ steps. We translate via the existing
ext._a_machine_now / aux._mm_to_steps so the result is in
the same g_pos space as _a_soft_limit_target_steps."""
try:
if not ext.couple_z_enabled:
return None
if not bool(ext.aux._homed):
return None
K = ext.couple_K
if K is None:
return None
z_now = ext._z_machine_now()
if z_now is None:
return None
# Max permitted A in machine-mm: a_max = z_now + K.
a_max_mm = float(z_now) + float(K)
a_max_steps = ext.aux._mm_to_steps(a_max_mm)
# The coupling only caps the *upper* side (more-positive
# machine A). With dir_sign=+1 that's g_pos+; with
# dir_sign=-1 it's g_pos-. Jogs in the opposite gamepad
# direction don't approach the coupling bound, return
# None so the soft-limit target alone applies.
dir_sign = 1 if int(ext.aux._cfg.get('dir_sign', 1)) >= 0 else -1
# Gamepad+ moves toward larger machine-mm when dir_sign>0.
machine_dir = direction * dir_sign
if machine_dir <= 0:
return None
return a_max_steps
except Exception:
return None
def _a_combined_target_steps(self, ext, direction):
"""Pick the more restrictive of soft-limit and coupling
targets. Returns (target_steps, source_label) where
target_steps is None when neither rule applies."""
soft = self._a_soft_limit_target_steps(ext.aux, direction)
couple = self._a_coupling_target_steps(ext, direction)
if soft is None and couple is None:
return None, 'none'
if soft is None: return couple, 'coupling'
if couple is None: return soft, 'softlimit'
# Both present: pick whichever is reached first when moving
# in `direction` from the current g_pos.
try:
cur = int(ext.aux._pos_steps)
except Exception:
cur = 0
if direction > 0:
return ((soft, 'softlimit') if soft <= couple
else (couple, 'coupling'))
else:
return ((soft, 'softlimit') if soft >= couple
else (couple, 'coupling'))
def _a_start(self, direction):
ext = getattr(self.ctrl, 'ext_axis', None)
ext_state = ('present' if (ext is not None and ext.enabled)
else 'unavailable')
scale = self._a_speed_scale()
target_steps = None
target_src = 'none'
cur_steps = None
if ext is not None and ext.enabled:
target_steps, target_src = self._a_combined_target_steps(
ext, direction)
try: cur_steps = int(ext.aux._pos_steps)
except Exception: cur_steps = None
if A_DRY_RUN:
try:
step_max = (int(ext.aux._cfg['step_max_sps'])
if ext is not None and ext.enabled else -1)
accel = (int(ext.aux._cfg['step_accel_sps2'])
if ext is not None and ext.enabled else -1)
except Exception:
step_max, accel = -1, -1
self.log.info(
'AJOG DRYRUN _a_start dir=%+d ext=%s speed=%d scale=%.4f '
'step_max=%d accel=%d cur_steps=%s target_steps=%s '
'target_src=%s (would send JOG)',
direction, ext_state, self.speed, scale, step_max, accel,
cur_steps, target_steps, target_src)
return
if ext is None or not ext.enabled or direction == 0:
return
scale = self._a_speed_scale()
try:
aux = ext.aux
max_rate = max(1, int(int(aux._cfg['step_max_sps']) * scale))
accel = int(aux._cfg['step_accel_sps2'])
# ignore_limits=True (safe=0): pendant jog is allowed
# before homing, matching the rest of the manual-jog API.
# When the axis IS homed, the ESP still aborts on a
# limit-toward hit because it tracks home_dir separately
# from `safe` in our updated firmware (see jogTask).
# If the axis is already at-or-past the more-restrictive
# boundary (soft limit OR Z-A coupling) in the requested
# direction, refuse the jog rather than sending a
# wrong-side target the ESP would reject.
if target_steps is not None and cur_steps is not None:
at_limit = ((direction > 0 and cur_steps >= target_steps)
or (direction < 0 and cur_steps <= target_steps))
if at_limit:
self.log.info(
'A-axis jog refused: at %s limit '
'(cur=%d target=%d dir=%+d)',
target_src, cur_steps, target_steps, direction)
return
# ignore_limits=True (safe=0) when the axis is unhomed:
# pendant jog is allowed before homing for setup. When
# homed, soft limits AND Z-A coupling are enforced via
# target_steps and the ESP's hardware-limit abort still
# applies unconditionally (movingTowardLimit in
# jogTask).
ignore = not bool(aux._homed)
aux.jog_start(direction,
max_rate_sps=max_rate,
accel_sps2=accel,
ignore_limits=ignore)
ignore_limits=ignore,
target_steps=target_steps)
if target_steps is not None:
self.log.info(
'A-axis jog_start dir=%+d cur=%d target=%d (%s)',
direction, cur_steps, target_steps, target_src)
except Exception as e:
self.log.warning('A-axis jog_start failed: %s', e)
@@ -154,6 +303,28 @@ class Jog(inevent.JogHandler):
cfg = self.get_config(dev_name)
old = self.a_button
# DEBUG: log EVERY incoming gamepad event so we can see
# exactly what the pendant is producing on press/release.
# Skip noisy stick / report-syn events to keep the journal
# readable but log all KEY events and any ABS event whose
# code matches one we care about.
try:
tname = ev_type_name.get(event.type, '?')
except Exception:
tname = '?'
if event.type == EV_KEY:
self.log.info(
'AJOG EV dev=%r type=%s(%d) code=0x%x val=%d '
'cfg.a_pos_btn=0x%x cfg.a_neg_btn=0x%x',
dev_name, tname, event.type, event.code, event.value,
cfg.get('a_pos_btn', 0), cfg.get('a_neg_btn', 0))
elif event.type == EV_ABS and event.code in (
cfg.get('a_neg_abs', -1),
cfg.get('a_pos_abs', -1)):
self.log.info(
'AJOG EV dev=%r type=%s(%d) code=0x%x val=%d (trigger ABS)',
dev_name, tname, event.type, event.code, event.value)
if event.type == EV_KEY:
if event.code == cfg.get('a_pos_btn'):
if event.value: self.a_button = 1
@@ -169,13 +340,15 @@ class Jog(inevent.JogHandler):
elif self.a_button == -1: self.a_button = 0
if self.a_button != old:
self.log.info('A-axis trigger -> %s', self.a_button)
self.log.info(
'AJOG STATE %+d -> %+d (t=%.3f dry_run=%s)',
old, self.a_button, time.monotonic(), A_DRY_RUN)
self._a_apply(self.a_button, old)
# On every release pull a fresh position mirror in case
# the user does a gplan-driven A move next. The terminal
# [jog] done line itself already updates aux._pos_steps;
# this propagates that into ExternalAxis._pos_mm.
if self.a_button == 0:
if self.a_button == 0 and not A_DRY_RUN:
# Wait briefly so the [jog] done line has time to
# arrive before we read aux.position_mm.
self.ctrl.ioloop.call_later(0.2, self._a_resync_pos)

View File

@@ -27,6 +27,7 @@
import json
import math
import os
import re
import time
from collections import deque
@@ -76,6 +77,10 @@ class Planner():
self.planner = None
self._position_dirty = False
self.where = ''
# Tracks the rewritten temp file (if any) returned by the
# AuxPreprocessor for the currently-loaded program. We delete
# it on the next load() so it doesn't pile up under /tmp.
self._aux_tempfile = None
ctrl.state.add_listener(self._update)
@@ -507,28 +512,57 @@ class Planner():
def load(self, path):
self.where = path
path = self.ctrl.get_path('upload', path)
self.log.info('GCode:' + path)
# Rewrite ATC M-codes (M100..M103) before gplan sees them.
# preprocess_file is a no-op when no rewriting is needed and
# idempotent when run twice on the same file, so this is
# safe on every load. W tokens are no longer rewritten - the
# auxcnc stepper is now exposed as a virtual A axis and gcode
# should use A directly.
src_path = self.ctrl.get_path('upload', path)
self.log.info('GCode:' + src_path)
# Clean up any leftover temp file from a previous load.
self._cleanup_aux_tempfile()
# Rewrite ATC M-codes (M100/M102/M103) and inject Z-A
# coupling moves before gplan sees them. The rewriting goes
# to a temp file -- the operator's macro / job source is
# never modified. This matters because:
#
# 1. The macro editor reads back the source. If we
# rewrote in place, the operator would open `drop.nc`
# and see (MSG,HOOK:...) blobs instead of the M-code
# sequence they wrote.
# 2. Re-running a rewritten file would re-rewrite it; any
# bug in the regex (e.g. with paren comments) would
# compound on every load.
#
# Why we rewrite at all: gplan treats M100..M103 as no-ops
# by spec and exposes no callback for user M-codes. Its only
# in-band channel back to Python during a running program is
# the (MSG,...) message stream, so we substitute hook
# messages for the M-codes purely as transport.
load_path = src_path
try:
from bbctrl.AuxPreprocessor import preprocess_file
from bbctrl.AuxPreprocessor import preprocess_to_tempfile
ext = getattr(self.ctrl, 'ext_axis', None)
coupling = (ext.coupling_for_preprocessor()
if ext is not None else None)
if preprocess_file(path, log=self.log, coupling=coupling):
self.log.info('Rewrote (ATC / Z-A coupling) in %s' % path)
tmp = preprocess_to_tempfile(
src_path, log=self.log, coupling=coupling)
if tmp is not None:
self._aux_tempfile = tmp
load_path = tmp
self.log.info(
'Rewrote (ATC / Z-A coupling) for gplan: %s -> %s'
% (src_path, tmp))
except Exception:
self.log.exception('Aux preprocess at load failed; '
'attempting to load file unchanged')
self._sync_position()
self.planner.load(path, self.get_config(False, True))
self.planner.load(load_path, self.get_config(False, True))
self.reset_times()
def _cleanup_aux_tempfile(self):
if self._aux_tempfile and os.path.exists(self._aux_tempfile):
try: os.unlink(self._aux_tempfile)
except OSError: pass
self._aux_tempfile = None
def stop(self):
try:

View File

@@ -74,6 +74,7 @@ class Plan(object):
self.progress = 0
self.cancel = False
self.pid = None
self.error = None
root = ctrl.get_path()
self.gcode = '%s/upload/%s' % (root, filename)
@@ -202,8 +203,16 @@ class Plan(object):
if not self._exists(): yield self._exec()
self.future.set_result(self._read())
except:
self.preplanner.log.exception("Failed to load file - doesn't appear to be GCode.")
except Exception as e:
# Record the error and ALWAYS resolve the future, otherwise
# PathHandler.get keeps timing out at 1s forever and the UI
# gets stuck on the "Processing New File" dialog.
self.preplanner.log.exception(
"Failed to plan file: " + str(e))
self.error = str(e) or 'Plan failed'
self.progress = 1
if not self.future.done():
self.future.set_result(None)
class Preplanner(object):
@@ -268,3 +277,6 @@ class Preplanner(object):
def get_plan_progress(self, filename):
return self.plans[filename].progress if filename in self.plans else 0
def get_plan_error(self, filename):
return self.plans[filename].error if filename in self.plans else None

View File

@@ -411,11 +411,22 @@ class PathHandler(bbctrl.APIHandler):
except gen.TimeoutError:
progress = preplanner.get_plan_progress(filename)
self.write_json(dict(progress = progress))
err = preplanner.get_plan_error(filename)
resp = dict(progress = progress)
if err: resp['error'] = err
self.write_json(resp)
return
try:
if data is None: return
# Plan finished but produced no data (planner subprocess
# failed, e.g. AuxPreprocessor coupling rejection at
# planner-load time). Surface the error so the UI can
# close the "Processing New File" dialog instead of
# polling forever.
if data is None:
err = preplanner.get_plan_error(filename) or 'Plan failed'
self.write_json(dict(progress = 1, error = err))
return
meta, positions, speeds = data
if dataType == '/positions': data = positions