feat: integrate W axis as virtual A axis through gplan

Big-bang refactor of the W-axis integration. The auxcnc ESP stepper
is now exposed to the bbctrl planner (camotics gplan) as a virtual
A axis with no AVR motor mapping. gplan parses gcode for A natively,
applies soft limits, units, accel ramping and S-curve trajectories.
Line blocks with A motion are intercepted in Planner.__encode and
forked to the ESP via ExternalAxis on a worker thread; the residual
XYZ motion goes to the AVR as before.

This replaces the previous (MSG,HOOK:aux:N) side-channel: gcode
authors now write G1 A50 F1000 (or G28 A0 to home) and the planner
handles it the same way it handles X/Y/Z.

## Architecture

The AVR has 4 motor channels (0-3, all assigned to X/Y/Y/Z on
Onefinity). Looking at the AVR source, an axis with no motor
mapping is fully accepted: line blocks with that axis target update
ex.position[axis] in exec.c, but no motor steps because
motor_get_axis(motor)==axis returns -1. The AVR reports 'p' for
all 6 axes regardless. So we expose A to State as a synthetic
motor (index 4, host-only), populated from aux.json with full
kinematic config (vm/am/jm/tn/tm). State.find_motor and the
snapshot projection now walk 0..4. gplan sees A as a real axis.

## New module: ExternalAxis

  - Registers synthetic motor 4 with vm/am/jm/tn/tm so
    State.find_motor('a') returns 4 and gplan picks up
    soft limits + kinematics.
  - Worker thread drains a target queue so ESP RPCs (which can
    take seconds) never block the bbctrl ioloop.
  - execute_to_mm: synchronous, used by HTTP endpoints.
  - enqueue_target_mm: non-blocking, used by Planner.__encode.
  - home(): runs ESP cycle, syncs <axis>p and <axis>_homed.
  - abort(): drains queue.

## Planner

  - __encode splits external-axis target out of line blocks.
  - Pure A move -> emits id-sync only (planner advances cleanly).
  - Mixed XYZ + A -> AVR runs XYZ trapezoid concurrent with the
    ESP move (v1 accepts the slight desync; users wanting strict
    sequencing put A on its own gcode line).
  - _<axis>_homed for the synthetic motor mirrors into State only.
  - Planner.reset drains the worker queue and forces resync.

## Mach

  - Mach.home(axis='a') routes through ext.home() instead of the
    standard G28.2/G38.6 latch sequence (which doesn't apply to an
    ESP-driven axis), then issues G28.3 a<home> to sync gplan.
  - Mach.unhome strips the AVR path for A.
  - Mach.stop / E-stop drain the external-axis worker queue.
  - Mach.jog strips A so the AVR doesn't see it (continuous-rate
    jogging not supported on ESP yet; use /api/aux/jog instead).

## State

  - find_motor walks 0..4 (synthetic motor 4 lives in vars).
  - snapshot projection includes motor 4 so 4tn -> a_tn etc.
  - get_axis_vector picks up motor-4 values without changes.

## AuxAxis

  - Adds set_state_observer hook so ExternalAxis sees homed-flag
    changes after homing/boot-banner.
  - DEFAULTS now include axis_letter, max_velocity_m_per_min,
    max_accel_km_per_min2, max_jerk_km_per_min3 in user-facing
    motor-config units (m/min, km/min^2, km/min^3) matching the
    onefinity per-motor convention.

## AuxPreprocessor

  - Drops W-token rewriting entirely. M100..M103 ATC mapping kept.
  - W tokens in legacy gcode now warn (once per file) instead of
    being rewritten. Migration: replace W with A.

## Hooks

  - aux/aux_rel/aux_setzero hooks retired. aux_home kept as a
    legacy alias routing to ext.home() for older preprocessed
    gcode. ATC hooks (droptool/grabtool/release/clamp) unchanged.
  - E-stop now drains the external-axis worker queue.

## Web.py

  - /api/aux/{home,jog,move} now route through ExternalAxis when
    available so DRO and gplan position stay in sync.

## UI (axis-vars.js + control-view.pug)

  - _get_motor_id and _check_is_enabled fall back to motor index 4
    so the standard A column in the DRO renders state for the
    ESP-driven axis (with full offset / set-position / per-axis
    home support).
  - Legacy W row is gated on !a.enabled - shown only for installs
    that haven't migrated.
  - WAxisSettings.svelte exposes the new max_velocity_m_per_min /
    max_accel_km_per_min2 / max_jerk_km_per_min3 fields and an
    axis_letter selector for picking A/B/C.

## Open follow-ups (validate on hardware)

  - Q1: gplan soft-limit enforcement for A with min/max set.
    Easy smoke test: max_w=50, MDI G1 A100, expect rejection.
  - Q2: AVR behaviour with a target dict containing A values for
    a motorless axis. Read of exec.c suggests it's safe; needs a
    smoke test (no motor faults, no unexpected step counts).
  - Q3: pause/resume mid-A-move semantics. ESP doesn't honour
    bbctrl pauses; ext.abort drains the queue but a move-in-flight
    runs to completion. Acceptable for v1; v2 could add a synced
    pause.
This commit is contained in:
2026-05-02 17:17:20 +02:00
parent 3614a2bcd4
commit 53f26b0be8
14 changed files with 847 additions and 249 deletions

View File

@@ -198,6 +198,17 @@ module.exports = {
}
}
// Synthetic external motor (index 4) used by ExternalAxis
// to expose the auxcnc ESP stepper as a virtual axis.
// Its `Nan` lives in state, not config.
const axes = { x: 0, y: 1, z: 2, a: 3, b: 4, c: 5 };
const wanted = axes[axis];
const extAn = this.state && this.state["4an"];
if (typeof wanted === "number" && typeof extAn === "number"
&& extAn === wanted) {
return 4;
}
return -1;
},
@@ -226,6 +237,16 @@ module.exports = {
}
}
}
// Synthetic external motor (index 4) - the auxcnc ESP
// stepper exposed as A via ExternalAxis.
if (typeof wanted === "number") {
const extAn = this.state["4an"];
const extMe = this.state["4me"];
if (typeof extAn === "number" && extAn === wanted
&& extMe) {
return true;
}
}
return false;
},

View File

@@ -254,8 +254,13 @@ script#control-view-template(type="text/x-template")
@click=`home('${axis}')`)
.fa.fa-home
// W axis (auxiliary) — no offset, no set-zero / no set-position
.dro-row(:class="w.klass + ' ' + w.tklass", v-if="w.enabled",
// Legacy W axis row - shown only when the auxcnc stepper is
// *not* exposed as a virtual A axis. After v2 the standard
// A row above renders this axis natively (with full offset
// + set-position support); the W row stays for backwards
// compatibility with installs that haven't migrated.
.dro-row(:class="w.klass + ' ' + w.tklass",
v-if="w.enabled && !a.enabled",
:title="w.title")
.dro-axis.axis-w W
.dro-pos: unit-value(:value="w.pos", precision=4)

View File

@@ -33,11 +33,27 @@ DEFAULTS = {
'enabled': False,
'port': '/dev/ttyUSB0',
'baud': 115200,
'steps_per_mm': 80.0, # logical steps per mm of W travel
'steps_per_mm': 80.0, # logical steps per mm of axis travel
'dir_sign': 1, # +1 or -1: maps logical+ to motor+ steps
'min_w': 0.0, # soft limit min (mm)
'max_w': 100.0, # soft limit max (mm)
'max_feed_mm_min': 600.0, # informational; rate caps are on the ESP
# Logical axis letter exposed to gplan. The auxcnc ESP stepper
# is presented to the planner as this axis (default 'a' = standard
# 4th axis). gcode uses A for moves; the host ExternalAxis layer
# forks A motion to the ESP transparently.
'axis_letter': 'a',
'min_w': 0.0, # soft limit min (mm), exposed as 4tn
'max_w': 100.0, # soft limit max (mm), exposed as 4tm
# Per-axis kinematic limits used to populate the planner's config.
# Units match the bbctrl/onefinity per-motor convention so the
# values are directly comparable to motors 0-3:
# max_velocity_m_per_min m/min (planner sees * 1000 = mm/min)
# max_accel_km_per_min2 km/min2 (planner sees * 1e6 = mm/min2)
# max_jerk_km_per_min3 km/min3 (planner sees * 1e6 = mm/min3)
'max_velocity_m_per_min': 6.0,
'max_accel_km_per_min2': 100.0,
'max_jerk_km_per_min3': 500.0,
# Informational only - rate caps that actually clamp the move
# are on the ESP via step_max_sps below.
'max_feed_mm_min': 600.0,
'home_dir': '-', # which direction is "toward limit" (host's view)
'home_position_mm': 0.0, # mm value to assign at home
# ESP-side homing rates (steps/sec). Pushed via HOMECFG on connect.
@@ -157,6 +173,11 @@ class AuxAxis(object):
def position_mm(self):
return self._steps_to_mm(self._pos_steps)
def set_state_observer(self, fn):
"""Register a callback invoked after every _publish_state.
Used by ExternalAxis to mirror the homed flag into State."""
self._state_observer = fn
def home(self):
"""Run the homing cycle on the ESP. Blocks until done. Raises on
failure. Updates aux_homed and aux_pos.
@@ -531,3 +552,11 @@ class AuxAxis(object):
except Exception:
# During very early startup, state may not be ready.
pass
# Notify the external-axis layer so it can mirror state
# (e.g. homed flag) into the synthetic motor vars.
observer = getattr(self, '_state_observer', None)
if observer is not None:
try:
observer()
except Exception:
pass

View File

@@ -1,25 +1,33 @@
################################################################################
#
# AuxPreprocessor - rewrite W-axis G-code into hook calls
# AuxPreprocessor - rewrite ATC M-codes into hook calls
#
# The bbctrl planner only understands xyzabc. We expose a virtual W axis by
# rewriting the G-code file *before* it is fed to gplan, replacing each W
# move with a (MSG,HOOK:aux:...) line that the host's hook handler turns
# into a STEPS or HOME command on the ESP.
# History
# -------
# v1: rewrote W tokens into (MSG,HOOK:aux:N) lines because the bbctrl
# planner only understood XYZABC and the W axis was driven via a
# side-channel.
# v2: W is now exposed to gplan as a virtual A axis (see ExternalAxis),
# 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.
#
# Rules:
# - Mixed-axis blocks (W together with XYZABC) are split into two
# sequential blocks. By default the W move runs first; configurable.
# - G90/G91/G20/G21 modal state is tracked so we can convert relative-W
# and inch-W into the absolute mm value the hook handler expects.
# - G28 W0 / G28.2 W0 -> HOOK:aux_home
# - G92 Wx -> HOOK:aux_setzero:<mm>
# - G53 + W not specially handled (W only knows machine coords)
# - Lines inside parentheses or after `;` are passed through.
# What this still does
# --------------------
# Maps four user-defined M-codes onto pneumatic-tool-changer events:
#
# 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 them out and emit the matching hook
# line in their place.
#
# The preprocessor is intentionally conservative: anything it doesn't
# understand involving W is left alone with a warning, so motion lands in
# gplan which will complain loudly rather than silently misbehaving.
# understand is left alone.
#
################################################################################
@@ -29,129 +37,69 @@ import shutil
import tempfile
# Match a word like "W12.5" or "W-3" or "w0". Also matches inside the same
# line as XYZ words. We pull W out specifically.
_W_TOKEN_RE = re.compile(r'(?<![A-Za-z_0-9])[Ww]\s*([-+]?\d*\.?\d+)')
# Detect any axis-bearing word (so we can tell mixed-axis lines apart).
_AXIS_WORD_RE = re.compile(r'(?<![A-Za-z_0-9])[XYZABCxyzabc]\s*[-+]?\d*\.?\d+')
# Strip line comments so we don't get fooled by "(W axis)".
# Strip line comments so we don't get fooled by "(M100 not really)".
_PAREN_COMMENT_RE = re.compile(r'\([^)]*\)')
# Modal G-code groups we care about.
_MODAL_RE = re.compile(r'(?<![A-Za-z_0-9])[Gg]\s*0*(\d+(?:\.\d+)?)')
# ATC pneumatics. We map a small range of user-defined M-codes onto
# our existing HOOK: events. M100-M103 are in LinuxCNC/Buildbotics'
# user-defined range so the planner won't error on them - but it also
# won't *do* anything with them, so we strip them out and emit the
# matching hook line in their place. M6 is intentionally NOT mapped:
# users keep their own probe-and-prompt M6 override.
#
# M100 DROPTOOL (eject current tool, automatic sequence)
# M101 GRABTOOL (auto-clamp on inserted holder)
# M102 RELEASE (manually open collet, no clamp)
# M103 CLAMP (manually close collet with bleed)
# ATC pneumatics M-codes mapped onto hook events.
_ATC_M_CODES = {
100: 'droptool',
101: 'grabtool',
102: 'release',
103: 'clamp',
}
# A token like 'M100' or 'm103', not preceded/followed by alnum.
_ATC_M_RE = re.compile(
r'(?<![A-Za-z_0-9])[Mm]\s*0*(' +
'|'.join(str(n) for n in _ATC_M_CODES) +
r')(?![\w.])'
)
# Detect a W axis token. We no longer rewrite W to A automatically;
# instead we warn so the user knows their old gcode needs migration.
# (The W support was removed when the axis was integrated as a real
# A axis through gplan.)
_W_TOKEN_RE = re.compile(r'(?<![A-Za-z_0-9])[Ww]\s*[-+]?\d*\.?\d+')
class AuxPreprocessorError(Exception):
pass
class AuxPreprocessor(object):
def __init__(self, log=None, w_first=True):
def __init__(self, log=None):
self.log = log
# If True, on a mixed-axis line (e.g. G1 X10 W5), emit the W move
# first, then the XYZ move. Set False to invert.
self.w_first = w_first
self._w_warned = False
def _info(self, msg):
if self.log:
self.log.info(msg)
if self.log: self.log.info(msg)
def _warn(self, msg):
if self.log:
self.log.warning(msg)
if self.log: self.log.warning(msg)
# ------------------------------------------------------------------ scan
@staticmethod
def file_uses_aux(path):
"""Quick check: does this file contain anything the preprocessor
would rewrite (W axis tokens or ATC M-codes)? Used to skip
preprocessing entirely for files that don't need it."""
would rewrite (currently: just ATC M-codes)?"""
try:
with open(path, 'r', encoding='utf-8', errors='replace') as f:
for line in f:
code = _PAREN_COMMENT_RE.sub('', line)
code = code.split(';', 1)[0]
if _W_TOKEN_RE.search(code):
return True
if _ATC_M_RE.search(code):
return True
except Exception:
pass
return False
# Backwards-compat alias: external callers used file_uses_w before
# the ATC M-codes were added.
# Backwards-compat alias.
file_uses_w = file_uses_aux
# ------------------------------------------------------------------ core
def _strip_w(self, line):
"""Return (line_without_w, w_value_str_or_None). Only first W kept."""
m = _W_TOKEN_RE.search(line)
if m is None:
return line, None
# Remove just the matched W<num> token, preserving surrounding spaces.
rewritten = line[:m.start()] + line[m.end():]
return rewritten, m.group(1)
def _has_other_axis(self, code_no_w):
return _AXIS_WORD_RE.search(code_no_w) is not None
def _detect_modals(self, code, modal):
"""Update modal dict in-place from G-codes on this line."""
for mm in _MODAL_RE.finditer(code):
try:
g = float(mm.group(1))
except ValueError:
continue
if g == 90: modal['abs'] = True
elif g == 91: modal['abs'] = False
elif g == 20: modal['inch'] = True
elif g == 21: modal['inch'] = False
# G28 / G28.2 / G92 are detected case-by-case below.
@staticmethod
def _is_g28_like(code):
# Match G28 or G28.2 (homing).
return bool(re.search(r'(?<![A-Za-z_0-9])[Gg]\s*0*28(?:\.2)?(?![\w.])', code))
@staticmethod
def _is_g92(code):
return bool(re.search(r'(?<![A-Za-z_0-9])[Gg]\s*0*92(?![\w.])', code))
# ------------------------------------------------------------------ run
def process(self, src_path, dst_path):
"""Read src_path, write rewritten G-code to dst_path. Returns True
if any rewrite happened."""
modal = {'abs': True, 'inch': False} # G90 G21 are common defaults
"""Read src_path, write rewritten G-code to dst_path. Returns
True if any rewrite happened."""
rewrote_any = False
with open(src_path, 'r', encoding='utf-8', errors='replace') as fin, \
@@ -166,16 +114,19 @@ class AuxPreprocessor(object):
fout.write(raw)
continue
# Update modal from G-codes on this line first (so absolute
# vs incremental matches what the planner sees for XYZ).
self._detect_modals(code, modal)
# Warn (once) if the file still uses W tokens. The
# standard way is now G1 A<value>; old files must be
# migrated by hand.
if (not self._w_warned) and _W_TOKEN_RE.search(code):
self._warn('Found W axis token in gcode; W is no '
'longer recognized by bbctrl. Use A '
'instead. (warning suppressed for '
'subsequent W tokens in this file)')
self._w_warned = True
# ATC M-codes (M100-M103). Each ATC M-code on the line
# is replaced with its (MSG,HOOK:<event>:) line and
# stripped from the residual. Multiple ATC codes per
# line are honoured but order is left-to-right. We do
# this BEFORE the W-axis path so a line like
# "M100 W10" cleanly emits drop+move.
# stripped from the residual.
atc_matches = list(_ATC_M_RE.finditer(line))
if atc_matches:
rewrote_any = True
@@ -188,99 +139,32 @@ class AuxPreprocessor(object):
line = _ATC_M_RE.sub('', line)
code = _PAREN_COMMENT_RE.sub('', line)
code = code.split(';', 1)[0]
# If nothing else is left on the line, we're done.
if not code.strip():
# Preserve any trailing comment, but skip if the
# whole line is empty after the M-code strip.
# Nothing meaningful left; preserve any trailing
# comment text but skip empty lines.
rest = line.rstrip()
if rest:
fout.write(rest + '\n')
continue
if not _W_TOKEN_RE.search(code):
# No W work to do; emit whatever's left after ATC
# M-code stripping (or the original line if there
# were no ATC codes).
fout.write(line + '\n' if atc_matches else raw)
# Other gcode remains on the line - emit it.
fout.write(line + '\n')
continue
rewrote_any = True
# G28[.2] W... -> aux_home (W value is ignored except as
# a flag that W is being homed).
if self._is_g28_like(code):
code_no_w, _ = self._strip_w(line)
fout.write('(MSG,HOOK:aux_home:)\n')
# Only keep the residual line if other axes were also
# present (e.g. G28.2 X0 Y0 W0 still homes X+Y). A bare
# "G28" without axis args means "home all" in gcode
# which we explicitly DON'T want to trigger from a
# W-only home command.
rest_code = _PAREN_COMMENT_RE.sub('', code_no_w)
rest_code = rest_code.split(';', 1)[0]
if self._has_other_axis(rest_code):
fout.write(code_no_w + '\n')
continue
# G92 W... -> set W zero (or other value) without motion.
if self._is_g92(code):
line_no_w, w_val = self._strip_w(line)
target_mm = self._w_to_mm(w_val, modal, set_pos=True)
fout.write('(MSG,HOOK:aux_setzero:%g)\n' % target_mm)
rest_code = _PAREN_COMMENT_RE.sub('', line_no_w)
rest_code = rest_code.split(';', 1)[0]
if self._has_other_axis(rest_code):
fout.write(line_no_w + '\n')
continue
# Plain motion: G0/G1 etc with W word.
line_no_w, w_val = self._strip_w(line)
target_mm = self._w_to_mm(w_val, modal, set_pos=False)
# Distinguish absolute vs relative: encode both, the hook
# handler will pick the right operation.
if modal['abs']:
hook_line = '(MSG,HOOK:aux:%g)' % target_mm
else:
hook_line = '(MSG,HOOK:aux_rel:%g)' % target_mm
rest_code = _PAREN_COMMENT_RE.sub('', line_no_w)
rest_code = rest_code.split(';', 1)[0]
has_xyz = self._has_other_axis(rest_code)
if not has_xyz:
# Pure W move; drop the (now-empty) original line.
fout.write(hook_line + '\n')
continue
# Mixed-axis: split. Default order is W first.
if self.w_first:
fout.write(hook_line + '\n')
fout.write(line_no_w + '\n')
else:
fout.write(line_no_w + '\n')
fout.write(hook_line + '\n')
# No rewrite needed.
fout.write(raw)
return rewrote_any
# ------------------------------------------------------------ unit conv
def _w_to_mm(self, w_str, modal, set_pos):
try:
v = float(w_str)
except (TypeError, ValueError):
raise AuxPreprocessorError('Invalid W value: %r' % w_str)
if modal['inch']:
v *= 25.4
return v
def preprocess_file(src_path, log=None, **_unused):
"""Convenience: rewrite src_path in place if it contains ATC
M-codes. Returns True if the file was rewritten.
def preprocess_file(src_path, log=None, w_first=True):
"""Convenience: rewrite src_path in place if it uses anything the
preprocessor handles (W axis tokens or ATC M-codes).
Returns True if the file was rewritten."""
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):
return False
pre = AuxPreprocessor(log=log, w_first=w_first)
pre = AuxPreprocessor(log=log)
fd, tmp = tempfile.mkstemp(prefix='auxpre_', suffix='.nc',
dir=os.path.dirname(src_path) or None)
os.close(fd)

View File

@@ -75,6 +75,19 @@ class Ctrl(object):
self.hooks = bbctrl.Hooks(self)
with Trace.span('ctrl.aux'):
self.aux = bbctrl.AuxAxis(self)
with Trace.span('ctrl.ext_axis'):
# ExternalAxis exposes the auxcnc ESP stepper as a
# virtual A axis that gplan handles natively. Created
# unconditionally so State sees the synthetic motor
# vars even when aux is disabled (kept inert in that
# case via ext_axis.enabled).
axis_letter = self.aux._cfg.get('axis_letter', 'a')
self.ext_axis = bbctrl.ExternalAxis(
self, self.aux, axis_letter=axis_letter)
# Hook AuxAxis post-publish callback so homed flag
# mirrors into State after homing.
self.aux.set_state_observer(
self.ext_axis.refresh_homed)
self._register_aux_hooks()
with Trace.span('ctrl.mach.connect'):
@@ -133,46 +146,39 @@ class Ctrl(object):
def _register_aux_hooks(self):
"""Wire up the auxcnc HOOK: events to AuxAxis methods."""
"""Wire up auxcnc HOOK: events to AuxAxis methods.
v2: motion hooks (aux/aux_rel/aux_home/aux_setzero) are
retired now that the W axis is integrated through gplan as
a virtual A axis (see ExternalAxis). Only the ATC pneumatic
hooks remain - those are events, not motion.
For backwards compatibility with files that still contain
(MSG,HOOK:aux_home:) (e.g. older preprocessed gcode), keep
an aux_home alias that routes to the standard ext_axis homing
path."""
log = self.log.get('AuxAxis')
def _hook_move(ctx):
data = (ctx.get('data') or '').strip()
if not data:
raise Exception('aux hook missing target')
self.aux.move_abs_mm(float(data))
def _hook_move_rel(ctx):
data = (ctx.get('data') or '').strip()
if not data:
raise Exception('aux_rel hook missing delta')
self.aux.move_rel_mm(float(data))
def _hook_home(ctx):
def _hook_aux_home(ctx):
# Legacy: route to the standard external-axis homing.
if self.ext_axis is not None and self.ext_axis.enabled:
self.ext_axis.home()
else:
self.aux.home()
def _hook_setzero(ctx):
data = (ctx.get('data') or '').strip()
mm = float(data) if data else 0.0
self.aux.set_position_mm(mm)
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()
self.hooks.register_internal('aux', _hook_move,
block_unpause=True, auto_resume=True)
self.hooks.register_internal('aux_rel', _hook_move_rel,
block_unpause=True, auto_resume=True)
self.hooks.register_internal('aux_home', _hook_home,
# 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)
self.hooks.register_internal('aux_setzero', _hook_setzero,
block_unpause=True, auto_resume=True)
# ATC pneumatics. block_unpause + auto_resume so a program
# using M6 - implemented as (MSG,HOOK:droptool:) etc - pauses
# at the right point and resumes once the sequence is done.
# 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)
@@ -193,5 +199,7 @@ class Ctrl(object):
self.ioloop.close()
self.avr.close()
self.mach.planner.close()
try: self.ext_axis.close()
except Exception: pass
try: self.aux.close()
except Exception: pass

View File

@@ -0,0 +1,433 @@
################################################################################
#
# ExternalAxis - bridges a logical motorless axis to step generation on
# the auxcnc ESP, so the Buildbotics planner can drive a stepper that
# isn't on the AVR.
#
# Architecture
# ------------
# The bbctrl planner (camotics gplan) handles parsing, units, modal
# state, soft limits, accel ramping and S-curve timing for axes
# X, Y, Z, A, B, C. The AVR has 4 motor channels (0-3) and only
# generates step pulses for axes that have a motor mapped to them.
# An axis with no mapped motor is fully accepted by the AVR - it
# updates its internal `ex.position[axis]` and reports `<axis>p` to
# the host, but no stepper turns.
#
# We exploit that: the W stepper is exposed to gplan as A, but no
# AVR motor maps to A. The planner does all the gcode-level work
# correctly (G90/G91, soft limits, accel, units, modal feed rate);
# we intercept the resulting `Cmd.line` blocks in `Planner.__encode`,
# strip A out, and forward the A delta to the auxcnc ESP as STEPS.
#
# To make gplan and State *believe* A is enabled we register a
# synthetic motor (index 4) into State.vars, populated from
# aux.json, with `4an=3` (axis A), `4me=1` (enabled), and the
# usual velocity/accel/jerk/soft-limit vars. State.find_motor and
# the snapshot projection are extended to walk index 4. Motor-4
# vars never leave the host (they're not in the AVR's schema) so
# the AVR is undisturbed.
#
# v1 coupling: serialize. If a line has any A delta we wait for
# the ESP to finish before letting subsequent commands flow. This
# matches the behaviour of the previous hook-based approach (no
# XYZ+A blending) but with all the planner's correctness guarantees.
#
# v2 could match ESP move duration to the gplan trapezoid time and
# allow concurrent motion; out of scope for v1.
#
################################################################################
import threading
try:
from queue import Queue
except ImportError:
from Queue import Queue # py2 just in case
# Synthetic motor index used to expose the external axis to State.
# The AVR has motors 0..3; we use 4 as a host-only sentinel.
EXTERNAL_MOTOR_INDEX = 4
# Axis letters in their canonical order; 'a' is index 3.
_AXIS_LETTERS = 'xyzabc'
class ExternalAxisError(Exception):
pass
class ExternalAxis(object):
"""Bridge between Planner line blocks and AuxAxis serial RPCs.
Owns no thread; runs RPC calls inline on whatever thread invokes
execute_to_mm / home / abort. The Planner runs `__encode` on its
own thread which is allowed to block on planner I/O, so blocking
inside the interceptor is fine.
Position tracking: gplan emits absolute targets in mm; the ESP
counts steps relative to home_zero. We mirror the last commanded
mm position so subsequent line blocks compute the correct delta.
`_pos_mm` is also published as `<axis>p` so DRO updates."""
def __init__(self, ctrl, aux, axis_letter='a'):
self.ctrl = ctrl
self.aux = aux
self.log = ctrl.log.get('ExternalAxis')
self.axis_letter = (axis_letter or 'a').lower()[:1]
if self.axis_letter not in _AXIS_LETTERS:
raise ExternalAxisError(
'Invalid external axis letter: %r' % axis_letter)
# Index in 'xyzabc' (0..5)
self.axis_index = _AXIS_LETTERS.index(self.axis_letter)
self._busy = threading.Event()
# Last absolute mm we committed; None until first move /
# homing event syncs us up.
self._pos_mm = None
# Single-slot worker queue: __encode posts (target_mm,) tuples
# here; the worker thread runs the ESP RPC. Capacity is
# intentionally bounded - if it fills it means motion is
# outpacing the ESP and we should backpressure the planner.
self._work_q = Queue(maxsize=64)
self._stop = threading.Event()
self._worker = threading.Thread(
target=self._worker_loop,
name='ExternalAxis-worker', daemon=True)
self._worker.start()
# Push synthetic motor vars into State so the planner sees
# this axis as enabled with proper limits/velocity/accel.
self._publish_synthetic_motor()
# Also seed <axis>p so the DRO has something to render.
self.ctrl.state.set(self.axis_letter + 'p', 0.0)
# -------------------------------------------------------------- enabled
@property
def enabled(self):
try:
return bool(self.aux is not None
and self.aux.enabled
and self.aux.present)
except Exception:
return False
# -------------------------------------------------------- configuration
@property
def steps_per_mm(self):
try:
return float(self.aux._cfg.get('steps_per_mm', 25.0))
except Exception:
return 25.0
@property
def dir_sign(self):
try:
v = int(self.aux._cfg.get('dir_sign', 1))
return -1 if v < 0 else 1
except Exception:
return 1
@property
def home_position_mm(self):
try:
return float(self.aux._cfg.get('home_position_mm', 0.0))
except Exception:
return 0.0
# ----------------------------------------------------------- conversion
def mm_to_steps_delta(self, delta_mm):
return int(round(float(delta_mm) * self.steps_per_mm * self.dir_sign))
def steps_to_mm(self, steps):
return (float(steps) / self.steps_per_mm) * self.dir_sign
# ---------------------------------------------------- synthetic motor
def _publish_synthetic_motor(self):
"""Write motor-4 vars into State so find_motor('a') and
get_axis_vector('vm') see A as a real axis. The AVR never
sees these (motor index 4 is not in its var schema)."""
cfg = self.aux._cfg if self.aux is not None else {}
st = self.ctrl.state
i = str(EXTERNAL_MOTOR_INDEX)
# Axis assignment: 'an' is the 0-based axis index in xyzabc.
st.set(i + 'an', self.axis_index)
# Motor enabled.
st.set(i + 'me', 1 if (self.aux and self.aux.enabled) else 0)
# Homed flag - cleared until aux reports homed.
try:
homed = bool(self.aux._homed)
except Exception:
homed = False
st.set(i + 'h', 1 if homed else 0)
# Velocity / accel / jerk: the planner reads these via
# state.get_axis_vector('<code>', SCALE) which multiplies the
# stored raw value by SCALE. The bbctrl convention (matching
# what motors 0-3 store) is:
# vm: stored in m/min, planner expects mm/min (scale 1000)
# am: stored in km/min^2, planner expects mm/min^2 (scale 1e6)
# jm: stored in km/min^3, planner expects mm/min^3 (scale 1e6)
# Onefinity defaults for XY are vm=10, am=750, jm=1000. We
# follow the same convention; aux.json exposes the values in
# those user-facing units so they're directly comparable.
st.set(i + 'vm', float(cfg.get('max_velocity_m_per_min', 6.0)))
st.set(i + 'am', float(cfg.get('max_accel_km_per_min2', 100.0)))
st.set(i + 'jm', float(cfg.get('max_jerk_km_per_min3', 500.0)))
# Soft limits in machine units (mm). State.get_soft_limit_vector
# returns these directly, no scaling.
st.set(i + 'tn', float(cfg.get('min_w', 0.0)))
st.set(i + 'tm', float(cfg.get('max_w', 0.0)))
# home_position / home_travel are exposed as callbacks for
# motors 0..3 (see State.__init__). Register the same lazy
# callbacks for motor 4 so gplan's resolver lookup
# (_<axis>_home_position / _<axis>_home_travel) returns the
# right values for the external axis.
st.set_callback(
i + 'home_position', lambda name: self.home_position_mm)
st.set_callback(
i + 'home_travel',
lambda name: float(self.aux._cfg.get('max_w', 0.0))
- self.home_position_mm)
# Misc fields that other code paths might query. Defaults
# mirror what the AVR pushes for motors 0-3.
st.set(i + 'sa', 1.8)
st.set(i + 'mi', 16)
st.set(i + 'tr', 4.0)
st.set(i + 'sp', 200)
st.set(i + 'ic', 0.0)
st.set(i + 'dc', 0.0)
st.set(i + 'rv', False)
st.set(i + 'tc', 1)
st.set(i + 'lb', 5)
st.set(i + 'ho', 0)
st.set(i + 'os', 0)
st.set(i + 'oa', False)
st.set(i + 'lm', 8)
st.set(i + 'lv', 0.1)
st.set(i + 'sv', 1.688)
st.set(i + 'tv', 1.997)
st.set(i + 'lw', 2) # min-switch
st.set(i + 'xw', 2) # max-switch
st.set(i + 'ls', 0)
st.set(i + 'xs', 0)
st.set(i + 'df', 0)
def refresh_homed(self):
"""Called when AuxAxis updates its homed flag. Mirrors into
State so is_axis_homed('a') returns the right answer.
Updates several places at once because different layers read
the homed state via different keys:
- synthetic motor flag: 4h (used by snapshot -> a_h)
- axis-level flag: a_homed (used by State.is_axis_homed
and gplan _a_homed resolver)"""
try:
homed = bool(self.aux._homed)
except Exception:
homed = False
st = self.ctrl.state
st.set(str(EXTERNAL_MOTOR_INDEX) + 'h', 1 if homed else 0)
st.set(self.axis_letter + '_homed', bool(homed))
# ----------------------------------------------------------- line split
def split_target(self, target):
"""Pop the external axis out of a target dict and return
(target_without_ext, ext_mm_or_None). Both case variants
accepted defensively."""
if not target:
return target, None
ax = self.axis_letter
new_target = dict(target)
ext_mm = new_target.pop(ax, None)
if ext_mm is None:
ext_mm = new_target.pop(ax.upper(), None)
return new_target, ext_mm
# -------------------------------------------------------- execution API
def is_busy(self):
return self._busy.is_set()
def execute_to_mm(self, ext_mm):
"""Synchronously run an external move. Blocks until the ESP
reports done. Used by the legacy /api/aux/move and /api/aux/jog
endpoints which may want to wait. Most planner-driven motion
goes through enqueue_target_mm instead, which is non-blocking."""
if not self.enabled:
raise ExternalAxisError(
'External axis %r not available (aux disabled or '
'not connected)' % self.axis_letter)
steps, abs_mm = self._compute_move(ext_mm)
if steps == 0:
self._pos_mm = abs_mm
self.ctrl.state.set(self.axis_letter + 'p', self._pos_mm)
return
self._busy.set()
try:
self.aux._do_steps(steps, ignore_limits=True)
self._pos_mm = abs_mm
self.ctrl.state.set(self.axis_letter + 'p', self._pos_mm)
finally:
self._busy.clear()
def enqueue_target_mm(self, ext_mm):
"""Non-blocking variant: post a target to the worker queue
and update the host's notion of the axis position immediately
so subsequent line splits compute correct deltas.
The Planner.__encode hook calls this so the AVR comm thread
is never blocked by serial RPCs to the ESP. v1 accepts that
XYZ on the AVR and A on the ESP run concurrently when they
appear on the same gcode line; the planner's S-curve math is
applied to both, so velocities and accelerations are bounded
by whichever axis is most constrained."""
if not self.enabled:
raise ExternalAxisError(
'External axis %r not available' % self.axis_letter)
steps, abs_mm = self._compute_move(ext_mm)
# Update host position immediately so the next line block
# sees the new absolute target as the starting point.
self._pos_mm = abs_mm
self.ctrl.state.set(self.axis_letter + 'p', self._pos_mm)
if steps == 0:
return
# Enqueue. The worker fires the RPC; if it fails it logs
# and we keep going - aborting motion is the user's job
# via the planner stop / e-stop.
self._work_q.put(('move', steps))
def _compute_move(self, ext_mm):
"""Return (signed_steps, absolute_mm) for a target in mm.
Caches first-time position from the ESP."""
if self._pos_mm is None:
self._pos_mm = self._read_esp_position_mm()
delta_mm = float(ext_mm) - self._pos_mm
return self.mm_to_steps_delta(delta_mm), float(ext_mm)
def _worker_loop(self):
"""Background thread that drains the work queue. RPCs to the
ESP are slow (multi-second moves) and must not run on the
ioloop thread. We serialize ESP commands here so multiple
line-block enqueues for the external axis are processed in
the order the planner emitted them."""
while not self._stop.is_set():
try:
op = self._work_q.get(timeout=0.5)
except Exception:
continue
if op is None:
continue
kind = op[0]
try:
self._busy.set()
if kind == 'move':
steps = op[1]
self.aux._do_steps(steps, ignore_limits=True)
elif kind == 'home':
self.aux.home()
# _pos_mm and DRO updated by the caller's enqueue.
except Exception as e:
self.log.error('External axis worker failed on %s: %s'
% (kind, e))
finally:
self._busy.clear()
self._work_q.task_done()
def wait_idle(self, timeout=None):
"""Block until the worker queue is empty. Used by callers
that need post-motion state to be settled (e.g. homing,
stop/abort handlers)."""
try:
# Queue.join blocks until task_done has been called for
# every item put. It does not honour a timeout, so we
# poll instead when one is requested.
if timeout is None:
self._work_q.join()
return True
import time
deadline = time.time() + float(timeout)
while time.time() < deadline:
if self._work_q.unfinished_tasks == 0:
return True
time.sleep(0.05)
return False
except Exception:
return False
def close(self):
self._stop.set()
try:
self._work_q.put(None, block=False)
except Exception:
pass
def home(self):
"""Run the ESP homing cycle and sync our recorded position
to the configured home_position_mm. Blocks; called from
Mach.home (which already runs synchronously per axis)."""
if not self.enabled:
raise ExternalAxisError(
'External axis %r not available' % self.axis_letter)
# Drain pending moves so we don't home into stale work.
self.wait_idle(timeout=30.0)
self._busy.set()
try:
self.aux.home()
self._pos_mm = self.home_position_mm
self.ctrl.state.set(self.axis_letter + 'p', self._pos_mm)
self.refresh_homed()
finally:
self._busy.clear()
def abort(self):
"""Cancel the ESP move and drop pending queued work.
Caller (estop / stop handler) is responsible for the
planner-side cleanup."""
try:
if self.aux is not None:
self.aux.abort()
finally:
self._busy.clear()
# Drain any pending ops so resume after an abort doesn't
# replay stale targets.
try:
while True:
self._work_q.get_nowait()
self._work_q.task_done()
except Exception:
pass
# ------------------------------------------------------- ESP introspection
def _read_esp_position_mm(self):
"""Convert AuxAxis._pos_steps mirror to mm. Falls back to 0."""
try:
steps = int(self.aux._pos_steps)
except Exception:
steps = 0
return self.steps_to_mm(steps)
# ---------------------------------------------------------- DRO update
def sync_dro(self):
"""Push the current position to State as <axis>p so the DRO
reflects what we believe gplan/ESP agreed on. Called after
moves; also safe to call from external code."""
if self._pos_mm is None:
return
self.ctrl.state.set(self.axis_letter + 'p', self._pos_mm)

View File

@@ -99,18 +99,22 @@ class FileHandler(bbctrl.APIHandler):
del (self.uploadFile)
# If the uploaded G-code uses the virtual W axis, rewrite the
# file in place so the planner sees (MSG,HOOK:aux:*) lines
# instead of W tokens it can't parse.
# If the uploaded G-code uses ATC M-codes (M100..M103),
# rewrite them into (MSG,HOOK:droptool:) etc so the hook
# layer can dispatch them at runtime. The planner accepts
# M100-M103 in user-defined range but doesn't *do* anything
# with them. Motion in A goes through gplan unchanged - the
# auxcnc stepper is exposed as a virtual A axis (see
# ExternalAxis).
try:
from bbctrl.AuxPreprocessor import preprocess_file
log = self.get_log('AuxPreprocessor')
if preprocess_file(filename.decode('utf8'), log=log):
log.info('Rewrote W-axis tokens in %s' %
log.info('Rewrote ATC M-codes in %s' %
self.uploadFilename)
except Exception:
self.get_log('AuxPreprocessor').exception(
'W-axis preprocess failed; uploading unchanged')
'Aux preprocess failed; uploading unchanged')
self.get_ctrl().preplanner.invalidate(self.uploadFilename)
self.get_ctrl().state.add_file(self.uploadFilename)

View File

@@ -201,7 +201,15 @@ class Hooks:
# Cancel any running hook on estop. The hook thread
# cannot be killed from Python, but we can ask the
# AuxAxis to send ABORT to the ESP so its in-flight
# motion stops.
# motion stops. Also drain the external-axis
# worker queue so resume after clear doesn't replay
# stale moves.
try:
ext = getattr(self.ctrl, 'ext_axis', None)
if ext is not None:
ext.abort()
except Exception:
pass
if self._hook_busy:
self.log.warning('E-stop: cancelling hook "%s"' %
self._hook_busy_event)

View File

@@ -256,9 +256,12 @@ class Mach(Comm):
if cmd[0] == '$': self._query_var(cmd)
elif cmd[0] == '\\': super().queue_command(cmd[1:])
else:
# Rewrite W-axis tokens in MDI input the same way the
# FileHandler rewrites uploaded files.
cmd = self._rewrite_w_mdi(cmd)
# Rewrite ATC M-codes in MDI input the same way the
# FileHandler rewrites uploaded files. Motion (X/Y/Z/A)
# is left unchanged: the planner handles it natively
# now that the auxcnc stepper is exposed as a virtual
# A axis (see ExternalAxis).
cmd = self._rewrite_aux_mdi(cmd)
self._begin_cycle('mdi')
self.planner.mdi(cmd, with_limits)
super().resume()
@@ -266,13 +269,12 @@ class Mach(Comm):
self.mlog.info("Exception during MDI: %s" % err)
pass
def _rewrite_w_mdi(self, cmd):
"""Apply the W-axis preprocessor to a single MDI line. Returns
possibly-multi-line G-code with HOOK: comments inserted."""
def _rewrite_aux_mdi(self, cmd):
"""Apply the ATC M-code preprocessor to a single MDI line.
Returns possibly-multi-line G-code with HOOK: comments inserted."""
try:
from bbctrl.AuxPreprocessor import (
AuxPreprocessor, _W_TOKEN_RE, _ATC_M_RE)
if not _W_TOKEN_RE.search(cmd) and not _ATC_M_RE.search(cmd):
from bbctrl.AuxPreprocessor import AuxPreprocessor, _ATC_M_RE
if not _ATC_M_RE.search(cmd):
return cmd
import io, tempfile, os
# AuxPreprocessor.process is file-based; route through
@@ -293,7 +295,7 @@ class Mach(Comm):
except OSError: pass
return rewritten
except Exception as e:
self.mlog.warning('W-axis MDI rewrite failed: %s' % e)
self.mlog.warning('Aux MDI rewrite failed: %s' % e)
return cmd
def set(self, code, value):
@@ -301,6 +303,17 @@ class Mach(Comm):
def jog(self, axes):
# Strip the external axis from the jog request before sending
# to the AVR. v1 doesn't support continuous-rate jogging on
# the ESP-driven axis - users jog A via /api/aux/jog (relative
# mm steps) instead. Sending A to the AVR is harmless (no
# motor maps to it) but cleaner to strip.
ext = getattr(self.ctrl, 'ext_axis', None)
if ext is not None and isinstance(axes, dict):
axes = {k: v for k, v in axes.items()
if k.lower() != ext.axis_letter}
if not axes:
return
self._begin_cycle('jogging')
self.planner.position_change()
super().queue_command(Cmd.jog(axes))
@@ -318,6 +331,32 @@ class Mach(Comm):
enabled = state.is_axis_enabled(axis)
mode = state.axis_homing_mode(axis)
# External axes (e.g. the auxcnc-driven A axis) home via
# their own ESP-side homing routine; the standard
# G28.2 / G38.6 / latch sequence doesn't apply.
ext = getattr(self.ctrl, 'ext_axis', None)
if ext is not None and ext.enabled \
and ext.axis_letter == axis.lower():
if 1 < len(axes) and not enabled:
continue
self.mlog.info('Homing external %s axis via auxcnc' %
axis.upper())
# ext.home() blocks on the ESP. Use the lower-level
# planner.mdi (not Mach.mdi) so we don't try to
# _begin_cycle('mdi') from inside the home-all loop
# which is already in the 'homing' cycle.
try:
self._begin_cycle('homing')
ext.home()
self.planner.mdi(
'G28.3 %c%f' % (axis, ext.home_position_mm),
False)
super().resume()
except Exception as e:
self.mlog.error(
'External axis homing failed: %s' % e)
continue
# If this is not a request to home a specific axis and the
# axis is disabled or in manual homing mode, don't show any
# warnings
@@ -349,7 +388,21 @@ class Mach(Comm):
super().resume()
def unhome(self, axis): self.mdi('G28.2 %c0' % axis)
def unhome(self, axis):
# External axes don't have AVR-side homed state to clear; the
# ESP holds its own homed flag. We don't have an explicit
# "unhome" verb on the ESP, but a stale homed flag is harmless
# because the next absolute move will fail-soft via
# ExternalAxis._pos_mm sync. Still mirror the cleared flag
# into State for the UI.
ext = getattr(self.ctrl, 'ext_axis', None)
if ext is not None and ext.enabled \
and chr(axis).lower() == ext.axis_letter:
from bbctrl.ExternalAxis import EXTERNAL_MOTOR_INDEX
self.ctrl.state.set('%dh' % EXTERNAL_MOTOR_INDEX, 0)
self.ctrl.state.set(ext.axis_letter + '_homed', False)
return
self.mdi('G28.2 %c0' % axis)
def estop(self): super().estop()
@@ -376,6 +429,12 @@ class Mach(Comm):
def stop(self):
if self._get_state() != 'jogging': self.stopping = True
super().i2c_command(Cmd.STOP)
# Drain the external-axis worker queue so post-stop resumption
# doesn't replay queued moves that the user wanted cancelled.
ext = getattr(self.ctrl, 'ext_axis', None)
if ext is not None:
try: ext.abort()
except Exception: pass
def pause(self): super().pause()

View File

@@ -270,6 +270,19 @@ class Planner():
if type != 'set': self.log.info('Cmd:' + log_json(block))
if type == 'line':
ext = self._external_axis_for_line(block)
if ext is not None:
# Side-effects: run the ESP move synchronously,
# split the line into ESP (already done) + AVR (rest).
avr_block = self._dispatch_external_line(block, ext)
if avr_block is None:
# Pure external move - no AVR work to issue but
# we still need to ack the block id so the planner
# advances. CommandQueue.enqueue with no callback
# at block id is what _encode does, so return an
# empty cmd to short-circuit there.
return ''
block = avr_block
self._enqueue_line_time(block)
return Cmd.line(block['target'], block['exit-vel'],
block['max-accel'], block['max-jerk'],
@@ -300,8 +313,17 @@ class Planner():
if name[2:] == '_homed':
motor = self.ctrl.state.find_motor(name[1])
if motor is not None:
# Synthetic external motor (index 4) doesn't exist
# on the AVR; mirror the homed flag in State only.
from bbctrl.ExternalAxis import EXTERNAL_MOTOR_INDEX
if motor is not None and motor < EXTERNAL_MOTOR_INDEX:
return Cmd.set_sync('%dh' % motor, value)
if motor == EXTERNAL_MOTOR_INDEX:
# Update synthetic motor flag and the<axis>_homed
# projection consumed by the DRO.
self.cmdq.enqueue(
id, self.ctrl.state.set,
'%dh' % EXTERNAL_MOTOR_INDEX, value)
return
@@ -350,6 +372,57 @@ class Planner():
self.planner.set_logger(None)
# ----------------------------------------------- external-axis routing
#
# When an axis is exposed to gplan via a synthetic motor (no AVR
# channel), we need to fork its motion off to the ESP at line
# encode time and let the rest of the line proceed to the AVR.
# The split is done here rather than in gplan because gplan
# treats all six axes uniformly and just emits target dicts; we
# don't want to teach it about the ESP.
def _external_axis_for_line(self, block):
"""Return the ExternalAxis instance for whichever axis in
block['target'] is external, or None."""
ext = getattr(self.ctrl, 'ext_axis', None)
if ext is None or not ext.enabled:
return None
target = block.get('target') or {}
if ext.axis_letter in target or ext.axis_letter.upper() in target:
return ext
return None
def _dispatch_external_line(self, block, ext):
"""Side-effect: enqueue the ESP move on the external-axis
worker thread (non-blocking). Return a new block dict with
the external axis stripped from `target`, or None if the
line had no other axes.
For mixed XYZ + external moves the AVR runs XYZ at the
gplan-computed rate while the ESP runs the external delta in
parallel. Pure external moves return None so __encode emits
only the id-sync to keep planner ids advancing."""
target = dict(block['target'])
new_target, ext_mm = ext.split_target(target)
try:
ext.enqueue_target_mm(ext_mm)
except Exception as e:
# Non-blocking enqueue should rarely fail; if it does we
# still want the planner to stop so the user notices.
self.log.error('External axis enqueue failed: %s' % e)
raise
if not new_target:
# Pure external move; nothing left for the AVR. Track the
# trajectory time so the planner's plan_time stays correct.
self._enqueue_line_time(block)
return None
# Build a clean copy with only the AVR axes left.
avr_block = dict(block)
avr_block['target'] = new_target
return avr_block
def reset(self, *args, **kwargs):
stop = kwargs.get('stop', True)
if stop:
@@ -363,6 +436,16 @@ class Planner():
self.cmdq.clear()
self.reset_times()
# Drain the external-axis worker queue and force the next
# move to re-sync position from the ESP (since State.reset
# below will zero <axis>p which makes ext._pos_mm stale).
ext = getattr(self.ctrl, 'ext_axis', None)
if ext is not None:
try: ext.abort()
except Exception: pass
try: ext._pos_mm = None
except Exception: pass
resetState = kwargs.get('resetState', True)
if resetState:
self.ctrl.state.reset()
@@ -380,16 +463,18 @@ class Planner():
self.where = path
path = self.ctrl.get_path('upload', path)
self.log.info('GCode:' + path)
# Make sure W-axis tokens are rewritten before the planner sees
# the file. preprocess_file is a no-op for files without W and
# for files already rewritten (no W tokens remain after the
# first pass), so this is safe to run on every load.
# 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_file
if preprocess_file(path, log = self.log):
self.log.info('Rewrote W-axis tokens in %s' % path)
self.log.info('Rewrote ATC M-codes in %s' % path)
except Exception:
self.log.exception('W-axis preprocess at load failed; '
self.log.exception('Aux preprocess at load failed; '
'attempting to load file unchanged')
self._sync_position()
self.planner.load(path, self.get_config(False, True))

View File

@@ -280,8 +280,11 @@ class State(object):
axis_motors = {axis: self.find_motor(axis) for axis in 'xyzabc'}
axis_vars = {}
# NOTE: motor index '4' is a host-only synthetic motor used
# by ExternalAxis to expose the auxcnc ESP-driven stepper as
# an additional axis. Real AVR motors are 0..3.
for name, value in vars.items():
if name[0] in '0123':
if name[0] in '01234':
motor = int(name[0])
for axis in 'xyzabc':
@@ -330,6 +333,9 @@ class State(object):
def get_axis_vector(self, name, scale = 1):
v = {}
# 0..3 are AVR motor channels. 4 is the host-side synthetic
# motor used by ExternalAxis. find_motor returns the right
# index regardless of whether the axis is physical or external.
for axis in 'xyzabc':
motor = self.find_motor(axis)
@@ -351,7 +357,10 @@ class State(object):
def find_motor(self, axis):
for motor in range(4):
# Walk 0..4: 0..3 are real AVR motors, 4 is the synthetic
# host-side motor used to expose the auxcnc ESP stepper as
# an external axis.
for motor in range(5):
if not ('%dan' % motor) in self.vars: continue
motor_axis = 'xyzabc'[self.vars['%dan' % motor]]
if motor_axis == axis.lower() and self.vars.get('%dme' % motor, 0):

View File

@@ -812,8 +812,12 @@ class AuxStatusHandler(bbctrl.APIHandler):
class AuxHomeHandler(bbctrl.APIHandler):
def put_ok(self):
# Run synchronously via the AuxAxis' own RPC; this blocks the
# request. Fine because the UI shows a spinner.
# Run synchronously. Route through ExternalAxis so the
# synthetic motor's homed flag and DRO update.
ext = getattr(self.get_ctrl(), 'ext_axis', None)
if ext is not None and ext.enabled:
ext.home()
else:
self.get_ctrl().aux.home()
@@ -824,12 +828,22 @@ class AuxAbortHandler(bbctrl.APIHandler):
class AuxJogHandler(bbctrl.APIHandler):
"""Body: {"mm": 1.5} for relative-mm move,
{"steps": 200} for raw step move (bypasses soft limits)."""
{"steps": 200} for raw step move (bypasses soft limits).
Note: with the gplan-integrated W axis, jog-by-mm goes through
ExternalAxis so the DRO updates and gplan's idea of A's position
stays in sync. jog-by-steps still bypasses everything for the
homing/setup workflow where the axis isn't homed yet."""
def put_ok(self):
body = self.json or {}
aux = self.get_ctrl().aux
ext = getattr(self.get_ctrl(), 'ext_axis', None)
if 'mm' in body:
aux.move_rel_mm(float(body['mm']))
delta_mm = float(body['mm'])
if ext is not None and ext.enabled and ext._pos_mm is not None:
ext.execute_to_mm(ext._pos_mm + delta_mm)
else:
aux.move_rel_mm(delta_mm)
elif 'steps' in body:
aux.jog_steps(int(body['steps']))
else:
@@ -842,6 +856,10 @@ class AuxMoveHandler(bbctrl.APIHandler):
body = self.json or {}
if 'mm' not in body:
raise HTTPError(400, 'mm required')
ext = getattr(self.get_ctrl(), 'ext_axis', None)
if ext is not None and ext.enabled:
ext.execute_to_mm(float(body['mm']))
else:
self.get_ctrl().aux.move_abs_mm(float(body['mm']))

View File

@@ -68,6 +68,7 @@ from bbctrl.IOLoop import IOLoop
from bbctrl.MonitorTemp import MonitorTemp
from bbctrl.Hooks import Hooks
from bbctrl.AuxAxis import AuxAxis
from bbctrl.ExternalAxis import ExternalAxis
import bbctrl.Cmd as Cmd
import bbctrl.v4l2 as v4l2
import bbctrl.Log as log

View File

@@ -13,9 +13,13 @@
baud: number;
steps_per_mm: number;
dir_sign: number;
axis_letter: string;
min_w: number;
max_w: number;
max_feed_mm_min: number;
max_velocity_m_per_min: number;
max_accel_km_per_min2: number;
max_jerk_km_per_min3: number;
home_dir: string;
home_position_mm: number;
home_fast_sps: number;
@@ -130,7 +134,7 @@
<label for="" class="units">steps/mm</label>
</div>
<div class="pure-control-group" title="Direction sign: +1 or -1. Flip if W+ moves the wrong way.">
<div class="pure-control-group" title="Direction sign: +1 or -1. Flip if A+ moves the wrong way.">
<label for="dir_sign">direction sign</label>
<select id="dir_sign" bind:value={cfg.dir_sign}>
<option value={1}>+1</option>
@@ -138,19 +142,49 @@
</select>
</div>
<div class="pure-control-group" title="Soft-limit minimum W in mm.">
<div class="pure-control-group" title="gcode axis letter exposed to the planner. Default 'a' (the standard 4th axis).">
<label for="axis_letter">axis letter</label>
<select id="axis_letter" bind:value={cfg.axis_letter}>
<option value="a">A</option>
<option value="b">B</option>
<option value="c">C</option>
</select>
</div>
<div class="pure-control-group" title="Soft-limit minimum in mm.">
<label for="min_w">soft min</label>
<input id="min_w" type="number" bind:value={cfg.min_w} step="any" />
<label for="" class="units">mm</label>
</div>
<div class="pure-control-group" title="Soft-limit maximum W in mm.">
<div class="pure-control-group" title="Soft-limit maximum in mm.">
<label for="max_w">soft max</label>
<input id="max_w" type="number" bind:value={cfg.max_w} step="any" />
<label for="" class="units">mm</label>
</div>
</fieldset>
<div class="pure-control-group" title="Informational max feed; rate caps live on the ESP.">
<h3>Planner Limits</h3>
<fieldset>
<div class="pure-control-group" title="Maximum velocity used by gplan trajectory planning.">
<label for="max_velocity_m_per_min">max velocity</label>
<input id="max_velocity_m_per_min" type="number" bind:value={cfg.max_velocity_m_per_min} step="any" />
<label for="" class="units">m/min</label>
</div>
<div class="pure-control-group" title="Maximum acceleration used by gplan trajectory planning.">
<label for="max_accel_km_per_min2">max acceleration</label>
<input id="max_accel_km_per_min2" type="number" bind:value={cfg.max_accel_km_per_min2} step="any" />
<label for="" class="units">km/min²</label>
</div>
<div class="pure-control-group" title="Maximum jerk used by gplan trajectory planning.">
<label for="max_jerk_km_per_min3">max jerk</label>
<input id="max_jerk_km_per_min3" type="number" bind:value={cfg.max_jerk_km_per_min3} step="any" />
<label for="" class="units">km/min³</label>
</div>
<div class="pure-control-group" title="Informational max feed; rate caps live on the ESP via step_max_sps.">
<label for="max_feed_mm_min">max feed</label>
<input id="max_feed_mm_min" type="number" bind:value={cfg.max_feed_mm_min} step="any" />
<label for="" class="units">mm/min</label>