Compare commits
22 Commits
214de86bdf
...
private-mo
| Author | SHA1 | Date | |
|---|---|---|---|
| f8be0a6b6f | |||
| 692be42f84 | |||
| d5ad717f78 | |||
| 130c39fad9 | |||
| b59091007c | |||
| 5787855f3f | |||
| 7360c437a9 | |||
| 01e39722d3 | |||
| b63e5bb55a | |||
| 99b5af56cc | |||
| 9d7bc57056 | |||
| 90fd8533fe | |||
| ad4815d822 | |||
| 5150c3e4a8 | |||
| 4109f9f838 | |||
| ad846b6033 | |||
| 7a3c2bbb0d | |||
| da619bd56c | |||
| 800cb04e3b | |||
| d797f1d4fc | |||
| 80a00978b7 | |||
| f0a37828a4 |
28
AGENTS.md
28
AGENTS.md
@@ -11,7 +11,7 @@ This fork lives on **two long-lived branches**:
|
||||
bug-fixes. **No A-axis, ATC, hooks, or auxcnc/ESP content.** Aim for
|
||||
changes that benefit any Onefinity owner.
|
||||
|
||||
- **`esp-a-axis`** — bespoke shop branch. Stacks on top of `master`
|
||||
- **`private-mods`** — bespoke shop branch. Stacks on top of `master`
|
||||
and adds everything specific to the auxcnc-ESP-driven A axis and
|
||||
the ATC: `Hooks` (ATC IPC), `AuxAxis` (ESP serial driver),
|
||||
`ExternalAxis` (virtual A through gplan), `AuxPreprocessor` (M100-M103),
|
||||
@@ -32,44 +32,44 @@ tip. Keep it indefinitely until further notice.
|
||||
| UI polish, theme, layout that any user benefits from | `master` |
|
||||
| Build / install / boot performance | `master` |
|
||||
| Diagnostics, logging, generic Python / Tornado fixes | `master` |
|
||||
| Anything that touches `AuxAxis`, `ExternalAxis`, `Hooks`, `AuxPreprocessor` | `esp-a-axis` |
|
||||
| Anything mentioning the auxcnc ESP, `/dev/ttyUSB0`, the M100-M103 ATC pneumatics, or motor index 4 | `esp-a-axis` |
|
||||
| Z-A coupling interlock, ATC tool change sequencing | `esp-a-axis` |
|
||||
| A-axis UI (DRO row, jog tile, settings page, A-axis routes) | `esp-a-axis` |
|
||||
| W → A renames or aux.json migrations | `esp-a-axis` |
|
||||
| Anything that touches `AuxAxis`, `ExternalAxis`, `Hooks`, `AuxPreprocessor` | `private-mods` |
|
||||
| Anything mentioning the auxcnc ESP, `/dev/ttyUSB0`, the M100-M103 ATC pneumatics, or motor index 4 | `private-mods` |
|
||||
| Z-A coupling interlock, ATC tool change sequencing | `private-mods` |
|
||||
| A-axis UI (DRO row, jog tile, settings page, A-axis routes) | `private-mods` |
|
||||
| W → A renames or aux.json migrations | `private-mods` |
|
||||
|
||||
When in doubt: ask "would this be useful on a stock Onefinity with no
|
||||
ESP attached?" If yes → `master`. If no → `esp-a-axis`.
|
||||
ESP attached?" If yes → `master`. If no → `private-mods`.
|
||||
|
||||
## Workflow
|
||||
|
||||
```bash
|
||||
# Day-to-day shop / hardware work (default)
|
||||
git checkout esp-a-axis
|
||||
git checkout private-mods
|
||||
# … do work, commit …
|
||||
git push origin esp-a-axis
|
||||
git push origin private-mods
|
||||
|
||||
# Generic improvement to master
|
||||
git checkout master
|
||||
# … do work, commit …
|
||||
git push origin master
|
||||
|
||||
# After landing on master, replay esp-a-axis on top
|
||||
git checkout esp-a-axis
|
||||
# After landing on master, replay private-mods on top
|
||||
git checkout private-mods
|
||||
git rebase master
|
||||
git push --force-with-lease origin esp-a-axis
|
||||
git push --force-with-lease origin private-mods
|
||||
```
|
||||
|
||||
If a change accidentally lands on `master` but is bespoke (touches
|
||||
the file table above), move it: `git reset --hard <prev>` on master,
|
||||
cherry-pick onto `esp-a-axis`, force-push master.
|
||||
cherry-pick onto `private-mods`, force-push master.
|
||||
|
||||
## Deploy
|
||||
|
||||
- `./deploy.sh local` — UI bundle on `localhost:8770` (tmux session
|
||||
`onefin-local`). No controller backend; A-axis row stays hidden.
|
||||
- `./deploy.sh hardware` — rsync to the Pi over SSH, restart
|
||||
`bbctrl.service`. Use the `esp-a-axis` branch on the shop Pi.
|
||||
`bbctrl.service`. Use the `private-mods` branch on the shop Pi.
|
||||
- `./deploy.sh prod` — bundle a release tarball.
|
||||
|
||||
See `.pi/BUILD.md` for the full build / flash / cross-compile flow.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -303,6 +303,75 @@ class AuxAxis(object):
|
||||
return
|
||||
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):
|
||||
"""Cancel any running ESP motion immediately."""
|
||||
if not self._present:
|
||||
@@ -313,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'):
|
||||
@@ -353,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'):
|
||||
@@ -362,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:
|
||||
@@ -615,7 +692,22 @@ class AuxAxis(object):
|
||||
self._pending_replies.append(body)
|
||||
self._pending_cv.notify_all()
|
||||
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)
|
||||
else:
|
||||
self.log.info('aux: %s' % line)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -25,10 +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):
|
||||
@@ -51,12 +62,23 @@ class Jog(inevent.JogHandler):
|
||||
"dir": [1, -1, -1, 1],
|
||||
"arrows": [ABS_HAT0X, ABS_HAT0Y],
|
||||
"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)
|
||||
|
||||
self.a_button = 0 # -1, 0, +1 from RB / RT hold state
|
||||
self.v = [0.0] * 4
|
||||
self.lastV = self.v
|
||||
self.callback()
|
||||
@@ -64,6 +86,276 @@ class Jog(inevent.JogHandler):
|
||||
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 down(self): self.ctrl.lcd.page_down()
|
||||
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 == 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]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user