Compare commits
10 Commits
9d7bc57056
...
private-mo
| Author | SHA1 | Date | |
|---|---|---|---|
| f8be0a6b6f | |||
| 692be42f84 | |||
| d5ad717f78 | |||
| 130c39fad9 | |||
| b59091007c | |||
| 5787855f3f | |||
| 7360c437a9 | |||
| 01e39722d3 | |||
| b63e5bb55a | |||
| 99b5af56cc |
@@ -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
|
||||||
|
|||||||
@@ -232,6 +232,17 @@ module.exports = {
|
|||||||
const toolpath = await api.get(`path/${file}`);
|
const toolpath = await api.get(`path/${file}`);
|
||||||
this.toolpath_progress = toolpath.progress;
|
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") {
|
if (toolpath.progress === 1 || typeof toolpath.progress == "undefined") {
|
||||||
this.showGcodeMessage = false;
|
this.showGcodeMessage = false;
|
||||||
|
|
||||||
@@ -248,7 +259,11 @@ module.exports = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// api.get throws on non-2xx; log and break the loop so the
|
||||||
|
// dialog doesn't stay up forever.
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
this.showGcodeMessage = false;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -303,6 +303,75 @@ class AuxAxis(object):
|
|||||||
return
|
return
|
||||||
self._do_steps(int(steps), ignore_limits=True)
|
self._do_steps(int(steps), ignore_limits=True)
|
||||||
|
|
||||||
|
# ----------------------------------------------- continuous-rate jog
|
||||||
|
#
|
||||||
|
# Hold-to-jog support for the gamepad pendant. JOG / JOGSTOP on
|
||||||
|
# the ESP give a smooth ramp-up, cruise-until-released, ramp-down
|
||||||
|
# profile - much better than streaming small STEPS chunks.
|
||||||
|
#
|
||||||
|
# `jog_start` returns immediately after the ESP acknowledges with
|
||||||
|
# `[jog] started ...`. The terminal `[jog] done count=<n>
|
||||||
|
# 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,
|
||||||
|
target_steps=None):
|
||||||
|
"""Begin a continuous-rate jog. `direction` is +1 or -1.
|
||||||
|
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')
|
||||||
|
sign = '+' if direction > 0 else '-'
|
||||||
|
rate = (int(max_rate_sps) if max_rate_sps is not None
|
||||||
|
else int(self._cfg['step_max_sps']))
|
||||||
|
accel = (int(accel_sps2) if accel_sps2 is not None
|
||||||
|
else int(self._cfg['step_accel_sps2']))
|
||||||
|
if rate < 1: rate = 1
|
||||||
|
if accel < 1: accel = 1
|
||||||
|
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
|
||||||
|
# `[jog] done` async (it falls through to the info log path,
|
||||||
|
# but we hook _on_line to update _pos_steps).
|
||||||
|
line = self._rpc(cmd, topic='jog', timeout=2.0)
|
||||||
|
if line.startswith('error'):
|
||||||
|
raise AuxAxisError('JOG rejected: %s' % line)
|
||||||
|
if not line.startswith('started'):
|
||||||
|
# Could be "done count=0 pos=..." if a near-instant abort
|
||||||
|
# raced; treat as completed.
|
||||||
|
self._pos_steps = self._parse_kv_int(
|
||||||
|
line, 'pos', self._pos_steps)
|
||||||
|
self._publish_state()
|
||||||
|
# else: cruising, terminal [jog] reply will arrive later.
|
||||||
|
|
||||||
|
def jog_stop(self):
|
||||||
|
"""Request the running JOG to ramp down to a stop. Returns
|
||||||
|
immediately; the terminal `[jog] done` arrives async and is
|
||||||
|
picked up by `_on_line` to resync _pos_steps.
|
||||||
|
|
||||||
|
Like abort(), this does NOT take the RPC lock - JOGSTOP is
|
||||||
|
the on-release path of a hold-to-jog UI and must not block
|
||||||
|
on whatever else is in flight."""
|
||||||
|
if not self._present:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self.log.info('aux >> JOGSTOP')
|
||||||
|
self._send_raw('JOGSTOP')
|
||||||
|
except Exception as e:
|
||||||
|
self.log.warning('JOGSTOP send failed: %s' % e)
|
||||||
|
|
||||||
def abort(self):
|
def abort(self):
|
||||||
"""Cancel any running ESP motion immediately."""
|
"""Cancel any running ESP motion immediately."""
|
||||||
if not self._present:
|
if not self._present:
|
||||||
@@ -313,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'):
|
||||||
@@ -353,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'):
|
||||||
@@ -362,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:
|
||||||
@@ -615,7 +692,22 @@ class AuxAxis(object):
|
|||||||
self._pending_replies.append(body)
|
self._pending_replies.append(body)
|
||||||
self._pending_cv.notify_all()
|
self._pending_cv.notify_all()
|
||||||
return
|
return
|
||||||
# Async informational line; just log.
|
# Async informational line.
|
||||||
|
#
|
||||||
|
# The terminal [jog] done|aborted line for a continuous
|
||||||
|
# JOG arrives long after the JOG _rpc returned (the JOG
|
||||||
|
# _rpc only waits for the immediate `[jog] started`
|
||||||
|
# ack). Use this async path to keep _pos_steps in sync
|
||||||
|
# so subsequent moves compute the correct delta.
|
||||||
|
if topic == 'jog' and ('pos=' in body):
|
||||||
|
try:
|
||||||
|
self._pos_steps = self._parse_kv_int(
|
||||||
|
body, 'pos', self._pos_steps)
|
||||||
|
if 'reason=limit' in body:
|
||||||
|
self._homed = False
|
||||||
|
self._publish_state()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
self.log.info('aux: %s' % line)
|
self.log.info('aux: %s' % line)
|
||||||
else:
|
else:
|
||||||
self.log.info('aux: %s' % line)
|
self.log.info('aux: %s' % line)
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -38,12 +46,46 @@ 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)".
|
||||||
|
# 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'\([^)]*\)')
|
_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 = {
|
_ATC_M_CODES = {
|
||||||
100: 'droptool',
|
100: 'eject',
|
||||||
101: 'grabtool',
|
|
||||||
102: 'release',
|
102: 'release',
|
||||||
103: 'clamp',
|
103: 'clamp',
|
||||||
}
|
}
|
||||||
@@ -127,8 +169,7 @@ class AuxPreprocessor(object):
|
|||||||
try:
|
try:
|
||||||
with open(path, 'r', encoding='utf-8', errors='replace') as f:
|
with open(path, 'r', encoding='utf-8', errors='replace') as f:
|
||||||
for line in f:
|
for line in f:
|
||||||
code = _PAREN_COMMENT_RE.sub('', line)
|
code = _strip_comments(line)
|
||||||
code = code.split(';', 1)[0]
|
|
||||||
if _ATC_M_RE.search(code):
|
if _ATC_M_RE.search(code):
|
||||||
return True
|
return True
|
||||||
if couple_active:
|
if couple_active:
|
||||||
@@ -319,8 +360,7 @@ class AuxPreprocessor(object):
|
|||||||
line = raw.rstrip('\n')
|
line = raw.rstrip('\n')
|
||||||
|
|
||||||
# Comment-only or blank lines pass through verbatim.
|
# Comment-only or blank lines pass through verbatim.
|
||||||
code = _PAREN_COMMENT_RE.sub('', line)
|
code = _strip_comments(line)
|
||||||
code = code.split(';', 1)[0]
|
|
||||||
if not code.strip():
|
if not code.strip():
|
||||||
fout.write(raw)
|
fout.write(raw)
|
||||||
continue
|
continue
|
||||||
@@ -339,10 +379,14 @@ 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). Match against the
|
||||||
# is replaced with its (MSG,HOOK:<event>:) line and
|
# comment-stripped `code` so prose mentions like
|
||||||
# stripped from the residual.
|
# `(M102 = RELEASE)` inside a comment don't spuriously
|
||||||
atc_matches = list(_ATC_M_RE.finditer(line))
|
# 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:
|
if atc_matches:
|
||||||
rewrote_any = True
|
rewrote_any = True
|
||||||
for m in atc_matches:
|
for m in atc_matches:
|
||||||
@@ -350,19 +394,49 @@ class AuxPreprocessor(object):
|
|||||||
except ValueError: continue
|
except ValueError: continue
|
||||||
event = _ATC_M_CODES.get(num)
|
event = _ATC_M_CODES.get(num)
|
||||||
if event:
|
if event:
|
||||||
fout.write('(MSG,HOOK:%s:)\n' % event)
|
# We need two things here that aren't
|
||||||
line = _ATC_M_RE.sub('', line)
|
# naturally provided by the (MSG,...)
|
||||||
code = _PAREN_COMMENT_RE.sub('', line)
|
# transport:
|
||||||
code = code.split(';', 1)[0]
|
#
|
||||||
if not code.strip():
|
# (1) Synchronization. (MSG,HOOK:...) is
|
||||||
# Nothing meaningful left; preserve any trailing
|
# fire-and-forget from gplan's view -
|
||||||
# comment text but skip empty lines.
|
# gplan emits the message and keeps
|
||||||
rest = line.rstrip()
|
# streaming subsequent blocks (Z
|
||||||
if rest:
|
# moves, the next eject, etc.) to the
|
||||||
fout.write(rest + '\n')
|
# AVR. Meanwhile the hook handler
|
||||||
continue
|
# runs the actual ESP RPC in a
|
||||||
# Other gcode remains on the line - emit it.
|
# thread, and Z lifts while V2 is
|
||||||
fout.write(line + '\n')
|
# 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
|
continue
|
||||||
|
|
||||||
# No rewrite needed.
|
# No rewrite needed.
|
||||||
@@ -371,16 +445,26 @@ class AuxPreprocessor(object):
|
|||||||
return rewrote_any
|
return rewrote_any
|
||||||
|
|
||||||
|
|
||||||
def preprocess_file(src_path, log=None, coupling=None, **_unused):
|
def preprocess_to_tempfile(src_path, log=None, coupling=None):
|
||||||
"""Convenience: rewrite src_path in place if it contains ATC
|
"""Run the preprocessor on `src_path` and return the path to a
|
||||||
M-codes or needs Z-A coupling injection. Returns True if the
|
rewritten temp file (or None if no rewriting was needed). Caller
|
||||||
file was rewritten.
|
owns the temp file and must os.unlink() it when done.
|
||||||
|
|
||||||
`coupling` is an optional dict (see AuxPreprocessor.__init__).
|
The original source file is never modified - this is the
|
||||||
Extra keyword args are accepted for backwards compat (the old
|
intentional design: the macro / job file the operator authored
|
||||||
w_first arg is no longer used)."""
|
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):
|
if not AuxPreprocessor.file_uses_aux(src_path, coupling=coupling):
|
||||||
return False
|
return None
|
||||||
pre = AuxPreprocessor(log=log, coupling=coupling)
|
pre = AuxPreprocessor(log=log, coupling=coupling)
|
||||||
fd, tmp = tempfile.mkstemp(prefix='auxpre_', suffix='.nc',
|
fd, tmp = tempfile.mkstemp(prefix='auxpre_', suffix='.nc',
|
||||||
dir=os.path.dirname(src_path) or None)
|
dir=os.path.dirname(src_path) or None)
|
||||||
@@ -388,13 +472,36 @@ def preprocess_file(src_path, log=None, coupling=None, **_unused):
|
|||||||
try:
|
try:
|
||||||
rewrote = pre.process(src_path, tmp)
|
rewrote = pre.process(src_path, tmp)
|
||||||
if rewrote:
|
if rewrote:
|
||||||
shutil.move(tmp, src_path)
|
return tmp
|
||||||
return True
|
|
||||||
os.unlink(tmp)
|
os.unlink(tmp)
|
||||||
return False
|
return None
|
||||||
except Exception:
|
except Exception:
|
||||||
try:
|
try:
|
||||||
os.unlink(tmp)
|
os.unlink(tmp)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
raise
|
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
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -107,15 +107,27 @@ class FileHandler(bbctrl.APIHandler):
|
|||||||
# auxcnc stepper is exposed as a virtual A axis (see
|
# auxcnc stepper is exposed as a virtual A axis (see
|
||||||
# ExternalAxis).
|
# ExternalAxis).
|
||||||
try:
|
try:
|
||||||
from bbctrl.AuxPreprocessor import preprocess_file
|
from bbctrl.AuxPreprocessor import (
|
||||||
|
preprocess_file, AuxPreprocessorError)
|
||||||
log = self.get_log('AuxPreprocessor')
|
log = self.get_log('AuxPreprocessor')
|
||||||
ext = getattr(self.get_ctrl(), 'ext_axis', None)
|
ext = getattr(self.get_ctrl(), 'ext_axis', None)
|
||||||
coupling = (ext.coupling_for_preprocessor()
|
coupling = (ext.coupling_for_preprocessor()
|
||||||
if ext is not None else None)
|
if ext is not None else None)
|
||||||
if preprocess_file(filename.decode('utf8'),
|
try:
|
||||||
log=log, coupling=coupling):
|
if preprocess_file(filename.decode('utf8'),
|
||||||
log.info('Rewrote upload (ATC / Z-A coupling) in %s'
|
log=log, coupling=coupling):
|
||||||
% self.uploadFilename)
|
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:
|
except Exception:
|
||||||
self.get_log('AuxPreprocessor').exception(
|
self.get_log('AuxPreprocessor').exception(
|
||||||
'Aux preprocess failed; uploading unchanged')
|
'Aux preprocess failed; uploading unchanged')
|
||||||
|
|||||||
@@ -25,10 +25,21 @@
|
|||||||
# #
|
# #
|
||||||
################################################################################
|
################################################################################
|
||||||
|
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
import inevent
|
import inevent
|
||||||
from inevent.Constants import *
|
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
|
# Listen for input events
|
||||||
class Jog(inevent.JogHandler):
|
class Jog(inevent.JogHandler):
|
||||||
def __init__(self, ctrl):
|
def __init__(self, ctrl):
|
||||||
@@ -51,12 +62,23 @@ class Jog(inevent.JogHandler):
|
|||||||
"dir": [1, -1, -1, 1],
|
"dir": [1, -1, -1, 1],
|
||||||
"arrows": [ABS_HAT0X, ABS_HAT0Y],
|
"arrows": [ABS_HAT0X, ABS_HAT0Y],
|
||||||
"speed": [0x133, 0x130, 0x131, 0x134],
|
"speed": [0x133, 0x130, 0x131, 0x134],
|
||||||
"lock": [0x136, 0x137],
|
"lock": [0x136], # L1 = horiz-lock; RB/RT now A axis
|
||||||
|
# Right back controls drive the A axis while held.
|
||||||
|
# Verified on Xbox 360 pad (Vendor=045e Product=028e):
|
||||||
|
# RB (upper-right bumper) -> BTN_TR (0x137) digital -> A+
|
||||||
|
# RT (lower-right trigger) -> ABS_RZ analog 0..255 -> A-
|
||||||
|
# Some pads expose RT as BTN_TR2 (0x139) instead -- that
|
||||||
|
# works too via a_neg_btn.
|
||||||
|
"a_pos_btn": 0x137,
|
||||||
|
"a_neg_btn": 0x139,
|
||||||
|
"a_neg_abs": ABS_RZ,
|
||||||
|
"a_abs_thresh": 32, # 0..255 trigger press threshold
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
super().__init__(config)
|
super().__init__(config)
|
||||||
|
|
||||||
|
self.a_button = 0 # -1, 0, +1 from RB / RT hold state
|
||||||
self.v = [0.0] * 4
|
self.v = [0.0] * 4
|
||||||
self.lastV = self.v
|
self.lastV = self.v
|
||||||
self.callback()
|
self.callback()
|
||||||
@@ -64,6 +86,276 @@ class Jog(inevent.JogHandler):
|
|||||||
self.processor = inevent.InEvent(ctrl.ioloop, self, types = ['js'])
|
self.processor = inevent.InEvent(ctrl.ioloop, self, types = ['js'])
|
||||||
|
|
||||||
|
|
||||||
|
# -------- A-axis (external, ESP-driven) hold-to-jog ---------------
|
||||||
|
#
|
||||||
|
# The Mach jog path only knows about AVR axes; the A axis is
|
||||||
|
# handled by ExternalAxis on the auxcnc ESP, which has a proper
|
||||||
|
# JOG / JOGSTOP protocol added for hold-to-jog: ramp up on press,
|
||||||
|
# cruise while held, ramp down on release.
|
||||||
|
#
|
||||||
|
# Speed buttons (X/A/B/Y) scale the cruise rate (1/128, 1/32,
|
||||||
|
# 1/4, 1.0x of the configured step_max_sps).
|
||||||
|
def _a_speed_scale(self):
|
||||||
|
if self.speed == 1: return 1.0 / 128.0
|
||||||
|
if self.speed == 2: return 1.0 / 32.0
|
||||||
|
if self.speed == 3: return 1.0 / 4.0
|
||||||
|
return 1.0
|
||||||
|
|
||||||
|
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:
|
||||||
|
ext.aux.jog_stop()
|
||||||
|
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
|
||||||
|
try:
|
||||||
|
aux = ext.aux
|
||||||
|
max_rate = max(1, int(int(aux._cfg['step_max_sps']) * scale))
|
||||||
|
accel = int(aux._cfg['step_accel_sps2'])
|
||||||
|
# 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,
|
||||||
|
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)
|
||||||
|
|
||||||
|
def _a_apply(self, new_dir, old_dir):
|
||||||
|
if new_dir == old_dir:
|
||||||
|
return
|
||||||
|
# On any state change we stop the current jog and (if the
|
||||||
|
# new direction is non-zero) start a fresh one. JOG / JOGSTOP
|
||||||
|
# are non-blocking on the host side.
|
||||||
|
if old_dir != 0:
|
||||||
|
self._a_stop()
|
||||||
|
if new_dir != 0:
|
||||||
|
self._a_start(new_dir)
|
||||||
|
|
||||||
|
def _a_resync_pos(self):
|
||||||
|
"""Pull the ESP step counter back into ExternalAxis after a
|
||||||
|
JOG ends, so subsequent gplan-driven A motion computes the
|
||||||
|
right delta. Called opportunistically on state changes; the
|
||||||
|
AuxAxis reader also updates _pos_steps from the terminal
|
||||||
|
[jog] done line."""
|
||||||
|
ext = getattr(self.ctrl, 'ext_axis', None)
|
||||||
|
if ext is None or not ext.enabled:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
ext._pos_mm = ext.aux.position_mm
|
||||||
|
self.ctrl.state.set(ext.axis_letter + 'p', ext._pos_mm)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def event(self, event, state, dev_name):
|
||||||
|
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
|
||||||
|
elif self.a_button == 1: self.a_button = 0
|
||||||
|
elif event.code == cfg.get('a_neg_btn'):
|
||||||
|
if event.value: self.a_button = -1
|
||||||
|
elif self.a_button == -1: self.a_button = 0
|
||||||
|
|
||||||
|
elif event.type == EV_ABS:
|
||||||
|
thresh = cfg.get('a_abs_thresh', 32)
|
||||||
|
if event.code == cfg.get('a_neg_abs'):
|
||||||
|
if event.value >= thresh: self.a_button = -1
|
||||||
|
elif self.a_button == -1: self.a_button = 0
|
||||||
|
|
||||||
|
if self.a_button != old:
|
||||||
|
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 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)
|
||||||
|
|
||||||
|
super().event(event, state, dev_name)
|
||||||
|
|
||||||
|
|
||||||
def up(self): self.ctrl.lcd.page_up()
|
def up(self): self.ctrl.lcd.page_up()
|
||||||
def down(self): self.ctrl.lcd.page_down()
|
def down(self): self.ctrl.lcd.page_down()
|
||||||
def left(self): self.ctrl.lcd.page_left()
|
def left(self): self.ctrl.lcd.page_left()
|
||||||
@@ -90,4 +382,7 @@ class Jog(inevent.JogHandler):
|
|||||||
if self.speed == 2: scale = 1.0 / 32.0
|
if self.speed == 2: scale = 1.0 / 32.0
|
||||||
if self.speed == 3: scale = 1.0 / 4.0
|
if self.speed == 3: scale = 1.0 / 4.0
|
||||||
|
|
||||||
|
# axes[3] is left untouched by RB/RT -- the A axis is the
|
||||||
|
# ESP-driven external axis on this branch and is jogged via
|
||||||
|
# discrete relative moves through ExternalAxis (see _a_pump).
|
||||||
self.v = [x * scale for x in self.axes]
|
self.v = [x * scale for x in self.axes]
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
from collections import deque
|
from collections import deque
|
||||||
@@ -76,6 +77,10 @@ class Planner():
|
|||||||
self.planner = None
|
self.planner = None
|
||||||
self._position_dirty = False
|
self._position_dirty = False
|
||||||
self.where = ''
|
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)
|
ctrl.state.add_listener(self._update)
|
||||||
|
|
||||||
@@ -507,28 +512,57 @@ class Planner():
|
|||||||
|
|
||||||
def load(self, path):
|
def load(self, path):
|
||||||
self.where = path
|
self.where = path
|
||||||
path = self.ctrl.get_path('upload', path)
|
src_path = self.ctrl.get_path('upload', path)
|
||||||
self.log.info('GCode:' + path)
|
self.log.info('GCode:' + src_path)
|
||||||
# Rewrite ATC M-codes (M100..M103) before gplan sees them.
|
|
||||||
# preprocess_file is a no-op when no rewriting is needed and
|
# Clean up any leftover temp file from a previous load.
|
||||||
# idempotent when run twice on the same file, so this is
|
self._cleanup_aux_tempfile()
|
||||||
# safe on every load. W tokens are no longer rewritten - the
|
|
||||||
# auxcnc stepper is now exposed as a virtual A axis and gcode
|
# Rewrite ATC M-codes (M100/M102/M103) and inject Z-A
|
||||||
# should use A directly.
|
# 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:
|
try:
|
||||||
from bbctrl.AuxPreprocessor import preprocess_file
|
from bbctrl.AuxPreprocessor import preprocess_to_tempfile
|
||||||
ext = getattr(self.ctrl, 'ext_axis', None)
|
ext = getattr(self.ctrl, 'ext_axis', None)
|
||||||
coupling = (ext.coupling_for_preprocessor()
|
coupling = (ext.coupling_for_preprocessor()
|
||||||
if ext is not None else None)
|
if ext is not None else None)
|
||||||
if preprocess_file(path, log=self.log, coupling=coupling):
|
tmp = preprocess_to_tempfile(
|
||||||
self.log.info('Rewrote (ATC / Z-A coupling) in %s' % path)
|
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:
|
except Exception:
|
||||||
self.log.exception('Aux preprocess at load failed; '
|
self.log.exception('Aux preprocess at load failed; '
|
||||||
'attempting to load file unchanged')
|
'attempting to load file unchanged')
|
||||||
self._sync_position()
|
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()
|
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):
|
def stop(self):
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ class Plan(object):
|
|||||||
self.progress = 0
|
self.progress = 0
|
||||||
self.cancel = False
|
self.cancel = False
|
||||||
self.pid = None
|
self.pid = None
|
||||||
|
self.error = None
|
||||||
|
|
||||||
root = ctrl.get_path()
|
root = ctrl.get_path()
|
||||||
self.gcode = '%s/upload/%s' % (root, filename)
|
self.gcode = '%s/upload/%s' % (root, filename)
|
||||||
@@ -202,8 +203,16 @@ class Plan(object):
|
|||||||
if not self._exists(): yield self._exec()
|
if not self._exists(): yield self._exec()
|
||||||
self.future.set_result(self._read())
|
self.future.set_result(self._read())
|
||||||
|
|
||||||
except:
|
except Exception as e:
|
||||||
self.preplanner.log.exception("Failed to load file - doesn't appear to be GCode.")
|
# 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):
|
class Preplanner(object):
|
||||||
@@ -268,3 +277,6 @@ class Preplanner(object):
|
|||||||
|
|
||||||
def get_plan_progress(self, filename):
|
def get_plan_progress(self, filename):
|
||||||
return self.plans[filename].progress if filename in self.plans else 0
|
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
|
||||||
|
|||||||
@@ -411,11 +411,22 @@ class PathHandler(bbctrl.APIHandler):
|
|||||||
|
|
||||||
except gen.TimeoutError:
|
except gen.TimeoutError:
|
||||||
progress = preplanner.get_plan_progress(filename)
|
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
|
return
|
||||||
|
|
||||||
try:
|
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
|
meta, positions, speeds = data
|
||||||
|
|
||||||
if dataType == '/positions': data = positions
|
if dataType == '/positions': data = positions
|
||||||
|
|||||||
Reference in New Issue
Block a user