Compare commits
11 Commits
private-mo
...
77b5b42fec
| Author | SHA1 | Date | |
|---|---|---|---|
| 77b5b42fec | |||
| 9526ad797d | |||
| 683fa673ae | |||
| 4d71585a00 | |||
| 77bda775dd | |||
| 99f48309fa | |||
| 1afb51098e | |||
| 576957da4a | |||
| 6dbc7e6d04 | |||
| 46fa0765f5 | |||
| fe362e10ab |
77
AGENTS.md
77
AGENTS.md
@@ -1,77 +0,0 @@
|
|||||||
# Onefinity firmware — agent guidelines
|
|
||||||
|
|
||||||
## Branch model
|
|
||||||
|
|
||||||
This fork lives on **two long-lived branches**:
|
|
||||||
|
|
||||||
- **`master`** — public-facing fork. General-use upgrades on top of
|
|
||||||
upstream OneFinity firmware: V09 UX redesign, Font Awesome 6, faster
|
|
||||||
cold boot, macOS dev/deploy tooling, build & flash docs, SD-card
|
|
||||||
backup, `/api/diag/timing`, kiosk/tablet polish, and assorted
|
|
||||||
bug-fixes. **No A-axis, ATC, hooks, or auxcnc/ESP content.** Aim for
|
|
||||||
changes that benefit any Onefinity owner.
|
|
||||||
|
|
||||||
- **`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),
|
|
||||||
Z-A coupling interlock, the A-axis UI surface, and the
|
|
||||||
`/api/aux/*` endpoints.
|
|
||||||
|
|
||||||
Upstream:
|
|
||||||
- `upstream` → `https://github.com/OneFinityCNC/onefinity-firmware.git`
|
|
||||||
- `origin` → Gitea (`https://gitea.home.muehe.org/muehe/onefinity-firmware.git`)
|
|
||||||
|
|
||||||
`origin/pre-split-backup` is a tag preserving the pre-split master
|
|
||||||
tip. Keep it indefinitely until further notice.
|
|
||||||
|
|
||||||
## Where does a change go?
|
|
||||||
|
|
||||||
| Change | Branch |
|
|
||||||
|---|---|
|
|
||||||
| 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` | `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 → `private-mods`.
|
|
||||||
|
|
||||||
## Workflow
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Day-to-day shop / hardware work (default)
|
|
||||||
git checkout private-mods
|
|
||||||
# … do work, commit …
|
|
||||||
git push origin private-mods
|
|
||||||
|
|
||||||
# Generic improvement to master
|
|
||||||
git checkout master
|
|
||||||
# … do work, commit …
|
|
||||||
git push origin master
|
|
||||||
|
|
||||||
# After landing on master, replay private-mods on top
|
|
||||||
git checkout private-mods
|
|
||||||
git rebase master
|
|
||||||
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 `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 `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.
|
|
||||||
|
|
||||||
## Commit before ending a turn; push after significant changes.
|
|
||||||
@@ -8,9 +8,8 @@
|
|||||||
> 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 pneumatic atoms (M100 EJECT,
|
> The HOOK pipeline still exists for ATC pneumatics (M100..M103),
|
||||||
> M102 RELEASE, M103 CLAMP) - see `bbctrl/AuxPreprocessor.py`. Macros
|
> see `bbctrl/AuxPreprocessor.py`.
|
||||||
> 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,17 +232,6 @@ 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;
|
||||||
|
|
||||||
@@ -259,11 +248,7 @@ 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,75 +303,6 @@ 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:
|
||||||
@@ -382,23 +313,38 @@ 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 atoms
|
# ---------------------------------------------------------- ATC commands
|
||||||
#
|
#
|
||||||
# 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
|
||||||
# two pneumatic valves on relays 1-2:
|
# three pneumatic valves on relays 1-3. The ESP runs the timed
|
||||||
# V1 (clamp, 3/2 valve) - relay 2: ON = collet open, OFF = vent + spring closes
|
# sequences itself; the host just kicks them off and waits for the
|
||||||
# V2 (ejector) - relay 1: ON = ejector cylinder extends
|
# terminal reply.
|
||||||
#
|
|
||||||
# The host exposes three composable atoms - RELEASE, CLAMP, EJECT -
|
def atc_droptool(self, timeout=30.0):
|
||||||
# and composes drop/grab sequences from G-code macros that call
|
"""Eject the current tool. Opens the collet (V1), oscillates the
|
||||||
# them in order. (Older firmware exposed monolithic DROPTOOL /
|
ejector (V2), then re-clamps with a bleed cycle. Blocks until
|
||||||
# GRABTOOL verbs; protocol v3 dropped them in favour of these
|
the ESP reports done. Raises on failure."""
|
||||||
# atoms so callers can interleave Z moves between ejector pulses.)
|
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)
|
||||||
|
|
||||||
def atc_release(self, timeout=5.0):
|
def atc_release(self, timeout=5.0):
|
||||||
"""Open the collet (V1 on). Instant. Idempotent. Pairs with
|
"""Manually open the collet (release-only, no clamp). Use
|
||||||
atc_clamp() to bracket a sequence of host-side moves and/or
|
atc_clamp() afterwards once the new holder is in place."""
|
||||||
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'):
|
||||||
@@ -407,8 +353,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):
|
||||||
"""Close the collet: V1 off, then dwell for the line to vent
|
"""Manually clamp the collet (run a full bleed cycle). Pairs
|
||||||
and the spring to re-engage. Idempotent."""
|
with atc_release() for two-step manual tool changes."""
|
||||||
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'):
|
||||||
@@ -416,29 +362,6 @@ 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:
|
||||||
@@ -692,22 +615,7 @@ 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.
|
# Async informational line; just log.
|
||||||
#
|
|
||||||
# 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,28 +11,20 @@
|
|||||||
# 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 three user-defined M-codes onto pneumatic-tool-changer atoms:
|
# Maps four user-defined M-codes onto pneumatic-tool-changer events:
|
||||||
#
|
#
|
||||||
# M100 EJECT -> (MSG,HOOK:eject:) one V2 ejector pulse
|
# M100 DROPTOOL -> (MSG,HOOK:droptool:)
|
||||||
# M102 RELEASE -> (MSG,HOOK:release:) open collet (V1 on)
|
# M101 GRABTOOL -> (MSG,HOOK:grabtool:)
|
||||||
# M103 CLAMP -> (MSG,HOOK:clamp:) close collet (V1 off + vent)
|
# M102 RELEASE -> (MSG,HOOK:release:)
|
||||||
#
|
# 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 the recognized ones out and emit the
|
# won't *do* anything. We strip them out and emit the matching hook
|
||||||
# matching hook line in their place.
|
# 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.
|
||||||
@@ -46,46 +38,12 @@ 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: 'eject',
|
100: 'droptool',
|
||||||
|
101: 'grabtool',
|
||||||
102: 'release',
|
102: 'release',
|
||||||
103: 'clamp',
|
103: 'clamp',
|
||||||
}
|
}
|
||||||
@@ -169,7 +127,8 @@ 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 = _strip_comments(line)
|
code = _PAREN_COMMENT_RE.sub('', 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:
|
||||||
@@ -360,7 +319,8 @@ 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 = _strip_comments(line)
|
code = _PAREN_COMMENT_RE.sub('', line)
|
||||||
|
code = code.split(';', 1)[0]
|
||||||
if not code.strip():
|
if not code.strip():
|
||||||
fout.write(raw)
|
fout.write(raw)
|
||||||
continue
|
continue
|
||||||
@@ -379,14 +339,10 @@ 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/M102/M103). Match against the
|
# ATC M-codes (M100-M103). Each ATC M-code on the line
|
||||||
# comment-stripped `code` so prose mentions like
|
# is replaced with its (MSG,HOOK:<event>:) line and
|
||||||
# `(M102 = RELEASE)` inside a comment don't spuriously
|
# stripped from the residual.
|
||||||
# fire hooks. Each match emits a (MSG,HOOK:<event>:)
|
atc_matches = list(_ATC_M_RE.finditer(line))
|
||||||
# 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:
|
||||||
@@ -394,49 +350,19 @@ 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:
|
||||||
# We need two things here that aren't
|
fout.write('(MSG,HOOK:%s:)\n' % event)
|
||||||
# naturally provided by the (MSG,...)
|
line = _ATC_M_RE.sub('', line)
|
||||||
# transport:
|
code = _PAREN_COMMENT_RE.sub('', line)
|
||||||
#
|
code = code.split(';', 1)[0]
|
||||||
# (1) Synchronization. (MSG,HOOK:...) is
|
if not code.strip():
|
||||||
# fire-and-forget from gplan's view -
|
# Nothing meaningful left; preserve any trailing
|
||||||
# gplan emits the message and keeps
|
# comment text but skip empty lines.
|
||||||
# streaming subsequent blocks (Z
|
rest = line.rstrip()
|
||||||
# moves, the next eject, etc.) to the
|
if rest:
|
||||||
# AVR. Meanwhile the hook handler
|
fout.write(rest + '\n')
|
||||||
# runs the actual ESP RPC in a
|
continue
|
||||||
# thread, and Z lifts while V2 is
|
# Other gcode remains on the line - emit it.
|
||||||
# still wiggling. To make M-codes
|
fout.write(line + '\n')
|
||||||
# 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.
|
||||||
@@ -445,26 +371,16 @@ class AuxPreprocessor(object):
|
|||||||
return rewrote_any
|
return rewrote_any
|
||||||
|
|
||||||
|
|
||||||
def preprocess_to_tempfile(src_path, log=None, coupling=None):
|
def preprocess_file(src_path, log=None, coupling=None, **_unused):
|
||||||
"""Run the preprocessor on `src_path` and return the path to a
|
"""Convenience: rewrite src_path in place if it contains ATC
|
||||||
rewritten temp file (or None if no rewriting was needed). Caller
|
M-codes or needs Z-A coupling injection. Returns True if the
|
||||||
owns the temp file and must os.unlink() it when done.
|
file was rewritten.
|
||||||
|
|
||||||
The original source file is never modified - this is the
|
`coupling` is an optional dict (see AuxPreprocessor.__init__).
|
||||||
intentional design: the macro / job file the operator authored
|
Extra keyword args are accepted for backwards compat (the old
|
||||||
is what they see in the macro editor and the file viewer; the
|
w_first arg is no longer used)."""
|
||||||
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 None
|
return False
|
||||||
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)
|
||||||
@@ -472,36 +388,13 @@ def preprocess_to_tempfile(src_path, log=None, coupling=None):
|
|||||||
try:
|
try:
|
||||||
rewrote = pre.process(src_path, tmp)
|
rewrote = pre.process(src_path, tmp)
|
||||||
if rewrote:
|
if rewrote:
|
||||||
return tmp
|
shutil.move(tmp, src_path)
|
||||||
|
return True
|
||||||
os.unlink(tmp)
|
os.unlink(tmp)
|
||||||
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
|
return False
|
||||||
try:
|
|
||||||
shutil.move(tmp, src_path)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
try:
|
try:
|
||||||
os.unlink(tmp)
|
os.unlink(tmp)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
raise
|
raise
|
||||||
return True
|
|
||||||
|
|||||||
@@ -166,46 +166,31 @@ 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 pneumatic atoms. block_unpause + auto_resume so a
|
# ATC pneumatics. block_unpause + auto_resume so a program
|
||||||
# program using M100/M102/M103 pauses at the right point and
|
# using M100/M101/M102/M103 pauses at the right point and
|
||||||
# resumes once each atom finishes. Macros compose drop/grab
|
# resumes once the sequence is done.
|
||||||
# sequences from these primitives.
|
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)
|
||||||
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,27 +107,15 @@ 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 (
|
from bbctrl.AuxPreprocessor import preprocess_file
|
||||||
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)
|
||||||
try:
|
if preprocess_file(filename.decode('utf8'),
|
||||||
if preprocess_file(filename.decode('utf8'),
|
log=log, coupling=coupling):
|
||||||
log=log, coupling=coupling):
|
log.info('Rewrote upload (ATC / Z-A coupling) in %s'
|
||||||
log.info('Rewrote upload (ATC / Z-A coupling) in %s'
|
% self.uploadFilename)
|
||||||
% 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,21 +25,10 @@
|
|||||||
# #
|
# #
|
||||||
################################################################################
|
################################################################################
|
||||||
|
|
||||||
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):
|
||||||
@@ -62,23 +51,12 @@ 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], # L1 = horiz-lock; RB/RT now A axis
|
"lock": [0x136, 0x137],
|
||||||
# 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()
|
||||||
@@ -86,276 +64,6 @@ 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()
|
||||||
@@ -382,7 +90,4 @@ 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,7 +27,6 @@
|
|||||||
|
|
||||||
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
|
||||||
@@ -77,10 +76,6 @@ 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)
|
||||||
|
|
||||||
@@ -512,57 +507,28 @@ class Planner():
|
|||||||
|
|
||||||
def load(self, path):
|
def load(self, path):
|
||||||
self.where = path
|
self.where = path
|
||||||
src_path = self.ctrl.get_path('upload', path)
|
path = self.ctrl.get_path('upload', path)
|
||||||
self.log.info('GCode:' + src_path)
|
self.log.info('GCode:' + path)
|
||||||
|
# Rewrite ATC M-codes (M100..M103) before gplan sees them.
|
||||||
# Clean up any leftover temp file from a previous load.
|
# preprocess_file is a no-op when no rewriting is needed and
|
||||||
self._cleanup_aux_tempfile()
|
# idempotent when run twice on the same file, so this is
|
||||||
|
# safe on every load. W tokens are no longer rewritten - the
|
||||||
# Rewrite ATC M-codes (M100/M102/M103) and inject Z-A
|
# auxcnc stepper is now exposed as a virtual A axis and gcode
|
||||||
# coupling moves before gplan sees them. The rewriting goes
|
# should use A directly.
|
||||||
# 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_to_tempfile
|
from bbctrl.AuxPreprocessor import preprocess_file
|
||||||
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)
|
||||||
tmp = preprocess_to_tempfile(
|
if preprocess_file(path, log=self.log, coupling=coupling):
|
||||||
src_path, log=self.log, coupling=coupling)
|
self.log.info('Rewrote (ATC / Z-A coupling) in %s' % path)
|
||||||
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(load_path, self.get_config(False, True))
|
self.planner.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,7 +74,6 @@ 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)
|
||||||
@@ -203,16 +202,8 @@ 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 Exception as e:
|
except:
|
||||||
# Record the error and ALWAYS resolve the future, otherwise
|
self.preplanner.log.exception("Failed to load file - doesn't appear to be GCode.")
|
||||||
# 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):
|
||||||
@@ -277,6 +268,3 @@ 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,22 +411,11 @@ class PathHandler(bbctrl.APIHandler):
|
|||||||
|
|
||||||
except gen.TimeoutError:
|
except gen.TimeoutError:
|
||||||
progress = preplanner.get_plan_progress(filename)
|
progress = preplanner.get_plan_progress(filename)
|
||||||
err = preplanner.get_plan_error(filename)
|
self.write_json(dict(progress = progress))
|
||||||
resp = dict(progress = progress)
|
|
||||||
if err: resp['error'] = err
|
|
||||||
self.write_json(resp)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Plan finished but produced no data (planner subprocess
|
if data is None: return
|
||||||
# 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