Compare commits

11 Commits

Author SHA1 Message Date
77b5b42fec Z-A coupling: drop active jog/MDI auto-coordination, keep refuse-only check
The active rewriter for jogs/MDI didn't help anyway because the
continuous-jog buttons send rate-based /api/jog commands to the AVR
and bypass the planner+MDI path entirely. Rather than build out
continuous-jog coupling on the ESP firmware or fake it with browser
ticks, simplify back to:

  * Runtime check (Planner.__encode + ExternalAxis motion entry
    points) refuses any move that would worsen the Z-A gap. Already
    improvement-aware so X/Y jogs and Z-up/A-down recoveries pass.
  * File preprocessor (AuxPreprocessor) injects pre-position A
    moves into uploaded gcode so well-formed programs run without
    operator intervention.

Operator workflow: jog freely down to the safe band; if you need to
go deeper, lower A first (aux jog mm) or use a step-jog MDI like
'G91 G0 Z-10 A-10' that includes the A delta. Programs do the right
thing on their own.
2026-05-03 15:04:38 +02:00
9526ad797d AuxAxis: push home_preclear_mm via HOMECFG
Tells the auxcnc ESP how far (in steps) to back off if HOME is
invoked while the limit switch is already tripped. The ESP now
hard-fails instead of zeroing blindly when the switch stays active
after the preclear move. Default 10 mm; set home_preclear_mm=0 to
disable the preclear and revert to immediate failure.
2026-05-03 15:04:38 +02:00
683fa673ae Z-A coupling: auto-coordinate A on jogs and MDI
Match the file-preprocessor behaviour for live operator input. When a
Z-down jog or MDI line would push (A-Z) above the safe band, append
the matching A delta to the same line so the planner runs Z and A
together. Same direction-aware refusal: only error when the operator
explicitly asks A to move *up* (delta > 0) past the bound, or when
the required A would violate A's soft minimum.

Implementation:
  * ExternalAxis.coordinate_mdi rewrites a multi-line MDI burst,
    tracking G90/G91 modal across lines (jogs always emit
    M70/G91/G0/M72; standard MDI defaults to G90). Z and A targets
    are computed in machine coords using offset_z and offset_a so
    the work-coord A token we emit is consistent with the operator's
    frame.
  * The 'A0' the jog UI emits for axes that aren't moving is treated
    as 'no A intent' (G91 delta of zero) and freely overridden.
  * Hooked into Mach.mdi after the existing ATC rewrite. On
    ExternalAxisError the burst is dropped with a user message; the
    planner check downstream still fires as defense in depth.
  * Planner.__encode also catches ExternalAxisError now (vs
    bricking on uncaught) - logs to the operator messages list and
    halts the cycle cleanly so subsequent jogs work.
  * check_coupling itself is now improvement-aware: only refuses
    moves that worsen an existing violation. Pure XY jogs and
    Z-up/A-down recovery moves pass even when (A-Z) is currently
    above the bound.

Tested locally with synthetic MDI: small Z jog within band, Z jog
across the boundary (auto-injects A delta), G90 MDI G0 Z-50
(appends A106), explicit A-lift while Z deep (refuses), pure XY
jog (unchanged), G91 A-down (unchanged), G90 G0 A0 with
offset_a=134 (refuses as lift to home).
2026-05-03 15:04:38 +02:00
4d71585a00 Z-A coupling interlock: prevent collision between Z and A tools
The auxiliary A axis carries a tool that hangs below the Z spindle.
Beyond a small Z descent the two physically collide unless A drops
with Z. Enforce in machine coords:

    A_machine - Z_machine <= K
    K = (A_home_mm - z_home_mm) + couple_z_clearance_mm

With our setup K = (134 - 0) + 22 = 156. At rest A=134 Z=0, A-Z=134
which is fine. Z can descend 22mm before the rule starts forcing A
down with it.

Two complementary layers:

(1) AuxPreprocessor injection (auto-fix uploaded files)
    Tracks modal Z, A and distance mode (G90/G91) while scanning the
    file. When a line would put A above Z by more than the clearance
    we emit a 'G0 A<safe>' BEFORE the line so A is already at the
    safe position when Z descends. Endpoint check is sufficient
    because Z moves monotonically along a single line.

    Errors are raised (not silently auto-fixed) when:
      - the line lifts A above the safe band while Z stays put
        (would require auto-injecting a Z-up which could swing
        through a fixture)
      - the line endpoint targets an A above the safe band

    G91 disables injection with a one-shot warning; the runtime
    check still applies.

(2) Runtime check (ExternalAxis.check_coupling)
    Single source of truth for live motion. Hooked into:
      * Planner.__encode for every line block (covers MDI and
        running programs - gplan emits machine-coord targets)
      * ExternalAxis.execute_to_mm/enqueue_target_mm/enqueue_line
        for direct A motion (covers UI jog/move and planner-A
        dispatch)
    Raises ExternalAxisError on violation; gplan and the API both
    surface the message. Skipped when coupling is disabled or the
    axis isn't homed (mirrors the soft-limit gate).

    Continuous Z jog from the AVR is not gated - it's an active
    operator action without a pre-known endpoint. Operator-driven
    over-travel during continuous jog will be caught by the next
    MDI/file-load attempt.

Configuration in aux.json:
    couple_z_enabled        bool   default true (per agreed setup)
    couple_z_clearance_mm   float  default 22.0
    z_home_mm               float  default 0.0

Surfaced in the new Z-A Coupling section of the A Axis settings
page with a description of the rule. Existing aux.json files get
the new keys via the merged-defaults path on read.

Tested locally with synthetic gcode covering Z descent, combined
moves, A lift while Z deep, G92 reset, G91 mode, and combined
Z+A target violations.
2026-05-03 15:04:38 +02:00
77bda775dd docs: A axis architecture (renamed from W) + README section
- Move docs/AUX_W_AXIS.md to docs/AUX_A_AXIS.md and rebadge W -> A
  throughout, with a header note pointing at ExternalAxis as the
  current implementation.
- README: A-axis fork heading, link to AUX_A_AXIS.md, /api/aux/status
  in verify-flash, small comment in scripts/deploy/local.sh.
2026-05-03 14:20:25 +02:00
99f48309fa Config: idempotent macro file rename W -> A
The auxiliary stepper used to be exposed as a W axis. After the
gplan integration it is exposed as A. Migrate persisted macro
config on every load:
  w_down.nc -> a_down.nc
  w_up.nc   -> a_up.nc
  'W Down'  -> 'A Down'
  'W Up'    -> 'A Up'

Idempotent so a stale in-memory copy can never reintroduce the old
names.
2026-05-03 14:19:14 +02:00
1afb51098e UI: A axis surface (DRO row, jog, Home A, settings page)
Front-end side of the gplan-integrated A axis (B3).

- a-axis-view.{js,pug}: dedicated settings page that mounts the
  AAxisSettings Svelte component and lives at #a-axis in the V09
  settings rail.
- AAxisSettings.svelte: aux.json-backed form (axis letter, port,
  homing direction, soft limits, ATC pin map, etc.) with master
  Save integration via 'onefin:save-all'.
- main.ts + SettingsView.svelte: register AAxisSettings in the
  Svelte component map; SettingsView no longer embeds the W axis
  fieldset.
- settings-shell-view: 'A Axis' rail entry; route to a-axis-view.
- app.js: extend settings family to include 'a-axis'; broadcast
  onefin:save-all from the master Save button.
- control-view: Home All button waits for the gantry cycle to
  finish before firing Home A on a non-virtual setup; A jog
  buttons; aux_jog/aux_home/aux_jog_incr methods.
- control-view.pug: A row in the DRO (with set-position + zero +
  home actions), A- / A+ tiles in the jog grid (gated on
  w.enabled || a.enabled), legacy W row kept for installs that
  haven't migrated to the gplan integration.
- style.styl: dro-axis.axis-w color.
2026-05-03 14:19:05 +02:00
576957da4a ATC: M100..M103 preprocessor + Mach MDI rewrite + hook handlers
ATC pneumatics in g-code (drop tool / grab tool / release clamp /
engage clamp) are expressed as M100..M103. AuxPreprocessor rewrites
those into (MSG,HOOK:droptool:) etc on file upload + on planner
load + on MDI input, so the Hooks layer (B1) can dispatch them via
registered ATC handlers in Ctrl.

- AuxPreprocessor.py: regex-based file rewriter, idempotent.
- FileHandler: invoke preprocessor on every upload.
- Planner.init: also re-preprocess on load (catches files written
  before this version).
- Mach.mdi: same rewrite for ad-hoc MDI input so M101 typed at the
  console produces a HOOK message.
- Ctrl: register the four ATC hooks (droptool/grabtool/release/clamp)
  with block_unpause + auto_resume so programs using them pause at
  the right point and resume cleanly. aux_home retained as a legacy
  alias for older preprocessed files.
2026-05-03 14:18:28 +02:00
6dbc7e6d04 ExternalAxis: virtual A axis through gplan, mirrored on the ESP
ExternalAxis exposes the auxcnc-driven ESP stepper as motor 4 (a
synthetic, host-only motor that gplan sees but the AVR doesn't). The
result is a virtual A axis that is fully integrated with the planner:
G1 A25 F1500 schedules a coordinated S-curve and the ESP runs the
exact same 7-segment trajectory the AVR would have run if A were a
real motor.

- ExternalAxis.py: synthetic-motor state, S-curve LINE block forward
  to the ESP, soft-limit enforcement, option-(b) homing (user A=0
  at the home limit).
- State: walk motors 0..4 in find_motor; clear both homed and h on
  reset; expose synthetic motor vars.
- axis-vars.js: motor-4 guard so the JS computed axis bindings don't
  throw when motor 4 has no entry in config.motors; resolve motor_id
  for the synthetic axis by scanning state['4an'].
- Ctrl: instantiate ExternalAxis after AuxAxis, share the axis_letter
  setting, wire AuxAxis state observer.
- Web: route /api/aux/{home,jog,move} through ExternalAxis when it
  is enabled so the DRO and synthetic-motor flags stay in sync.
2026-05-03 14:17:46 +02:00
46fa0765f5 AuxAxis: ESP32-driven external stepper (auxcnc)
bbctrl.AuxAxis manages a stepper driven by an auxcnc-style ESP32
over /dev/ttyUSB0 (or whichever serial port). Persistent config in
aux.json; UI talks to it via /api/aux/* endpoints.

- AuxAxis: serial framing, position tracking, soft-limit enforcement,
  homing state machine, ATC pneumatic control (M100..M103 wrappers).
- Ctrl: instantiate self.aux alongside the other subsystems and
  close it during shutdown.
- Web: handlers for /api/aux/{config,status,home,abort,jog,move,set-zero}.
2026-05-03 14:16:54 +02:00
fe362e10ab Hooks: ATC IPC layer between gcode preprocessor and runtime
Adds bbctrl.Hooks: a small dispatch layer for HOOK:<event>:<data>
messages embedded in g-code as (MSG,HOOK:droptool:) etc. Hooks can
block the unpause until the registered callback completes and
auto-resume after.

- bbctrl.Hooks: registry, fire, dispatch_hook_message, persistent
  config in hooks.json, REST surface (/api/hooks, /api/hooks/save,
  /api/hooks/status, /api/hooks/fire/<event>).
- Ctrl: instantiate self.hooks alongside the other subsystems.
- Planner._add_message: when a (MSG,...) line is HOOK:<event>:<data>,
  route it through ctrl.hooks instead of state.messages so it never
  surfaces as a UI popup and dispatch is immediate (state.messages
  has a 250ms debounce).
- Web: handlers for the /api/hooks routes.
2026-05-03 14:16:21 +02:00
11 changed files with 107 additions and 778 deletions

View File

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

View File

@@ -8,9 +8,8 @@
> 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 pneumatic atoms (M100 EJECT,
> M102 RELEASE, M103 CLAMP) - see `bbctrl/AuxPreprocessor.py`. Macros
> compose drop/grab tool sequences from those atoms.
> The HOOK pipeline still exists for ATC pneumatics (M100..M103),
> see `bbctrl/AuxPreprocessor.py`.
This adds a virtual `A` axis to the bbctrl controller, driven by the
auxcnc ESP32 over USB serial (`/dev/ttyUSB0`). The ESP owns step-pulse

View File

@@ -232,17 +232,6 @@ 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;
@@ -259,11 +248,7 @@ module.exports = {
}
}
} catch (error) {
// api.get throws on non-2xx; log and break the loop so the
// dialog doesn't stay up forever.
console.error(error);
this.showGcodeMessage = false;
return;
}
}
},

View File

@@ -303,75 +303,6 @@ 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:
@@ -382,23 +313,38 @@ class AuxAxis(object):
except Exception as 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
# 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.)
# 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)
def atc_release(self, timeout=5.0):
"""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."""
"""Manually open the collet (release-only, no clamp). Use
atc_clamp() afterwards once the new holder is in place."""
self._require_present()
line = self._rpc('RELEASE', topic='release', timeout=timeout)
if line.startswith('done'):
@@ -407,8 +353,8 @@ class AuxAxis(object):
raise AuxAxisError('RELEASE failed: %s' % reason)
def atc_clamp(self, timeout=10.0):
"""Close the collet: V1 off, then dwell for the line to vent
and the spring to re-engage. Idempotent."""
"""Manually clamp the collet (run a full bleed cycle). Pairs
with atc_release() for two-step manual tool changes."""
self._require_present()
line = self._rpc('CLAMP', topic='clamp', timeout=timeout)
if line.startswith('done'):
@@ -416,29 +362,6 @@ 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:
@@ -692,22 +615,7 @@ class AuxAxis(object):
self._pending_replies.append(body)
self._pending_cv.notify_all()
return
# 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
# Async informational line; just log.
self.log.info('aux: %s' % line)
else:
self.log.info('aux: %s' % line)

View File

@@ -11,28 +11,20 @@
# 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 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
# 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 DROPTOOL -> (MSG,HOOK:droptool:)
# M101 GRABTOOL -> (MSG,HOOK:grabtool:)
# M102 RELEASE -> (MSG,HOOK:release:)
# M103 CLAMP -> (MSG,HOOK:clamp:)
#
# 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 the recognized ones out and emit the
# matching hook line in their place.
# won't *do* anything. We strip them out and emit the matching hook
# line in their place.
#
# The preprocessor is intentionally conservative: anything it doesn't
# understand is left alone.
@@ -46,46 +38,12 @@ 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'\([^)]*\)')
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 pneumatics M-codes mapped onto hook events.
_ATC_M_CODES = {
100: 'eject',
100: 'droptool',
101: 'grabtool',
102: 'release',
103: 'clamp',
}
@@ -169,7 +127,8 @@ class AuxPreprocessor(object):
try:
with open(path, 'r', encoding='utf-8', errors='replace') as 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):
return True
if couple_active:
@@ -360,7 +319,8 @@ class AuxPreprocessor(object):
line = raw.rstrip('\n')
# 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():
fout.write(raw)
continue
@@ -379,14 +339,10 @@ class AuxPreprocessor(object):
if self._maybe_inject_a_down(code, fout):
rewrote_any = True
# 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))
# 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))
if atc_matches:
rewrote_any = True
for m in atc_matches:
@@ -394,49 +350,19 @@ class AuxPreprocessor(object):
except ValueError: continue
event = _ATC_M_CODES.get(num)
if event:
# 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')
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')
continue
# No rewrite needed.
@@ -445,26 +371,16 @@ class AuxPreprocessor(object):
return rewrote_any
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.
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.
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.
"""
`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)."""
if not AuxPreprocessor.file_uses_aux(src_path, coupling=coupling):
return None
return False
pre = AuxPreprocessor(log=log, coupling=coupling)
fd, tmp = tempfile.mkstemp(prefix='auxpre_', suffix='.nc',
dir=os.path.dirname(src_path) or None)
@@ -472,36 +388,13 @@ def preprocess_to_tempfile(src_path, log=None, coupling=None):
try:
rewrote = pre.process(src_path, tmp)
if rewrote:
return tmp
shutil.move(tmp, src_path)
return True
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
try:
shutil.move(tmp, src_path)
except Exception:
try:
os.unlink(tmp)
except OSError:
pass
raise
return True

View File

@@ -166,46 +166,31 @@ 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 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.
# 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)
self.hooks.register_internal('release', _hook_release,
block_unpause=True, auto_resume=True,
timeout=10)
self.hooks.register_internal('clamp', _hook_clamp,
block_unpause=True, auto_resume=True,
timeout=15)
self.hooks.register_internal('eject', _hook_eject,
block_unpause=True, auto_resume=True,
timeout=15)
log.info('Aux hooks registered')

View File

@@ -107,27 +107,15 @@ class FileHandler(bbctrl.APIHandler):
# auxcnc stepper is exposed as a virtual A axis (see
# ExternalAxis).
try:
from bbctrl.AuxPreprocessor import (
preprocess_file, AuxPreprocessorError)
from bbctrl.AuxPreprocessor import preprocess_file
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)
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
if preprocess_file(filename.decode('utf8'),
log=log, coupling=coupling):
log.info('Rewrote upload (ATC / Z-A coupling) in %s'
% self.uploadFilename)
except Exception:
self.get_log('AuxPreprocessor').exception(
'Aux preprocess failed; uploading unchanged')

View File

@@ -25,21 +25,10 @@
# #
################################################################################
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):
@@ -62,23 +51,12 @@ class Jog(inevent.JogHandler):
"dir": [1, -1, -1, 1],
"arrows": [ABS_HAT0X, ABS_HAT0Y],
"speed": [0x133, 0x130, 0x131, 0x134],
"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
"lock": [0x136, 0x137],
}
}
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()
@@ -86,276 +64,6 @@ 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()
@@ -382,7 +90,4 @@ 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]

View File

@@ -27,7 +27,6 @@
import json
import math
import os
import re
import time
from collections import deque
@@ -77,10 +76,6 @@ 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)
@@ -512,57 +507,28 @@ class Planner():
def load(self, path):
self.where = path
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
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.
try:
from bbctrl.AuxPreprocessor import preprocess_to_tempfile
from bbctrl.AuxPreprocessor import preprocess_file
ext = getattr(self.ctrl, 'ext_axis', None)
coupling = (ext.coupling_for_preprocessor()
if ext is not None else None)
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))
if preprocess_file(path, log=self.log, coupling=coupling):
self.log.info('Rewrote (ATC / Z-A coupling) in %s' % path)
except Exception:
self.log.exception('Aux preprocess at load failed; '
'attempting to load file unchanged')
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()
def _cleanup_aux_tempfile(self):
if self._aux_tempfile and os.path.exists(self._aux_tempfile):
try: os.unlink(self._aux_tempfile)
except OSError: pass
self._aux_tempfile = None
def stop(self):
try:

View File

@@ -74,7 +74,6 @@ 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)
@@ -203,16 +202,8 @@ class Plan(object):
if not self._exists(): yield self._exec()
self.future.set_result(self._read())
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)
except:
self.preplanner.log.exception("Failed to load file - doesn't appear to be GCode.")
class Preplanner(object):
@@ -277,6 +268,3 @@ class Preplanner(object):
def get_plan_progress(self, filename):
return self.plans[filename].progress if filename in self.plans else 0
def get_plan_error(self, filename):
return self.plans[filename].error if filename in self.plans else None

View File

@@ -411,22 +411,11 @@ class PathHandler(bbctrl.APIHandler):
except gen.TimeoutError:
progress = preplanner.get_plan_progress(filename)
err = preplanner.get_plan_error(filename)
resp = dict(progress = progress)
if err: resp['error'] = err
self.write_json(resp)
self.write_json(dict(progress = progress))
return
try:
# 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
if data is None: return
meta, positions, speeds = data
if dataType == '/positions': data = positions