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:
@@ -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;
|
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;
|
return false;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -254,8 +254,13 @@ script#control-view-template(type="text/x-template")
|
|||||||
@click=`home('${axis}')`)
|
@click=`home('${axis}')`)
|
||||||
.fa.fa-home
|
.fa.fa-home
|
||||||
|
|
||||||
// W axis (auxiliary) — no offset, no set-zero / no set-position
|
// Legacy W axis row - shown only when the auxcnc stepper is
|
||||||
.dro-row(:class="w.klass + ' ' + w.tklass", v-if="w.enabled",
|
// *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")
|
:title="w.title")
|
||||||
.dro-axis.axis-w W
|
.dro-axis.axis-w W
|
||||||
.dro-pos: unit-value(:value="w.pos", precision=4)
|
.dro-pos: unit-value(:value="w.pos", precision=4)
|
||||||
|
|||||||
@@ -33,11 +33,27 @@ DEFAULTS = {
|
|||||||
'enabled': False,
|
'enabled': False,
|
||||||
'port': '/dev/ttyUSB0',
|
'port': '/dev/ttyUSB0',
|
||||||
'baud': 115200,
|
'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
|
'dir_sign': 1, # +1 or -1: maps logical+ to motor+ steps
|
||||||
'min_w': 0.0, # soft limit min (mm)
|
# Logical axis letter exposed to gplan. The auxcnc ESP stepper
|
||||||
'max_w': 100.0, # soft limit max (mm)
|
# is presented to the planner as this axis (default 'a' = standard
|
||||||
'max_feed_mm_min': 600.0, # informational; rate caps are on the ESP
|
# 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_dir': '-', # which direction is "toward limit" (host's view)
|
||||||
'home_position_mm': 0.0, # mm value to assign at home
|
'home_position_mm': 0.0, # mm value to assign at home
|
||||||
# ESP-side homing rates (steps/sec). Pushed via HOMECFG on connect.
|
# ESP-side homing rates (steps/sec). Pushed via HOMECFG on connect.
|
||||||
@@ -157,6 +173,11 @@ class AuxAxis(object):
|
|||||||
def position_mm(self):
|
def position_mm(self):
|
||||||
return self._steps_to_mm(self._pos_steps)
|
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):
|
def home(self):
|
||||||
"""Run the homing cycle on the ESP. Blocks until done. Raises on
|
"""Run the homing cycle on the ESP. Blocks until done. Raises on
|
||||||
failure. Updates aux_homed and aux_pos.
|
failure. Updates aux_homed and aux_pos.
|
||||||
@@ -531,3 +552,11 @@ class AuxAxis(object):
|
|||||||
except Exception:
|
except Exception:
|
||||||
# During very early startup, state may not be ready.
|
# During very early startup, state may not be ready.
|
||||||
pass
|
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
|
||||||
|
|||||||
@@ -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
|
# History
|
||||||
# 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
|
# v1: rewrote W tokens into (MSG,HOOK:aux:N) lines because the bbctrl
|
||||||
# into a STEPS or HOME command on the ESP.
|
# 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:
|
# What this still does
|
||||||
# - Mixed-axis blocks (W together with XYZABC) are split into two
|
# --------------------
|
||||||
# sequential blocks. By default the W move runs first; configurable.
|
# Maps four user-defined M-codes onto pneumatic-tool-changer events:
|
||||||
# - 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.
|
# M100 DROPTOOL -> (MSG,HOOK:droptool:)
|
||||||
# - G28 W0 / G28.2 W0 -> HOOK:aux_home
|
# M101 GRABTOOL -> (MSG,HOOK:grabtool:)
|
||||||
# - G92 Wx -> HOOK:aux_setzero:<mm>
|
# M102 RELEASE -> (MSG,HOOK:release:)
|
||||||
# - G53 + W not specially handled (W only knows machine coords)
|
# M103 CLAMP -> (MSG,HOOK:clamp:)
|
||||||
# - Lines inside parentheses or after `;` are passed through.
|
#
|
||||||
|
# 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
|
# The preprocessor is intentionally conservative: anything it doesn't
|
||||||
# understand involving W is left alone with a warning, so motion lands in
|
# understand is left alone.
|
||||||
# gplan which will complain loudly rather than silently misbehaving.
|
|
||||||
#
|
#
|
||||||
################################################################################
|
################################################################################
|
||||||
|
|
||||||
@@ -29,129 +37,69 @@ import shutil
|
|||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
|
|
||||||
# Match a word like "W12.5" or "W-3" or "w0". Also matches inside the same
|
# Strip line comments so we don't get fooled by "(M100 not really)".
|
||||||
# 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)".
|
|
||||||
_PAREN_COMMENT_RE = re.compile(r'\([^)]*\)')
|
_PAREN_COMMENT_RE = re.compile(r'\([^)]*\)')
|
||||||
|
|
||||||
# Modal G-code groups we care about.
|
# ATC pneumatics M-codes mapped onto hook events.
|
||||||
_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_M_CODES = {
|
_ATC_M_CODES = {
|
||||||
100: 'droptool',
|
100: 'droptool',
|
||||||
101: 'grabtool',
|
101: 'grabtool',
|
||||||
102: 'release',
|
102: 'release',
|
||||||
103: 'clamp',
|
103: 'clamp',
|
||||||
}
|
}
|
||||||
# A token like 'M100' or 'm103', not preceded/followed by alnum.
|
|
||||||
_ATC_M_RE = re.compile(
|
_ATC_M_RE = re.compile(
|
||||||
r'(?<![A-Za-z_0-9])[Mm]\s*0*(' +
|
r'(?<![A-Za-z_0-9])[Mm]\s*0*(' +
|
||||||
'|'.join(str(n) for n in _ATC_M_CODES) +
|
'|'.join(str(n) for n in _ATC_M_CODES) +
|
||||||
r')(?![\w.])'
|
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):
|
class AuxPreprocessorError(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class AuxPreprocessor(object):
|
class AuxPreprocessor(object):
|
||||||
def __init__(self, log=None, w_first=True):
|
def __init__(self, log=None):
|
||||||
self.log = log
|
self.log = log
|
||||||
# If True, on a mixed-axis line (e.g. G1 X10 W5), emit the W move
|
self._w_warned = False
|
||||||
# first, then the XYZ move. Set False to invert.
|
|
||||||
self.w_first = w_first
|
|
||||||
|
|
||||||
def _info(self, msg):
|
def _info(self, msg):
|
||||||
if self.log:
|
if self.log: self.log.info(msg)
|
||||||
self.log.info(msg)
|
|
||||||
|
|
||||||
def _warn(self, msg):
|
def _warn(self, msg):
|
||||||
if self.log:
|
if self.log: self.log.warning(msg)
|
||||||
self.log.warning(msg)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ scan
|
# ------------------------------------------------------------------ scan
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def file_uses_aux(path):
|
def file_uses_aux(path):
|
||||||
"""Quick check: does this file contain anything the preprocessor
|
"""Quick check: does this file contain anything the preprocessor
|
||||||
would rewrite (W axis tokens or ATC M-codes)? Used to skip
|
would rewrite (currently: just ATC M-codes)?"""
|
||||||
preprocessing entirely for files that don't need it."""
|
|
||||||
try:
|
try:
|
||||||
with open(path, 'r', encoding='utf-8', errors='replace') as f:
|
with open(path, 'r', encoding='utf-8', errors='replace') as f:
|
||||||
for line in f:
|
for line in f:
|
||||||
code = _PAREN_COMMENT_RE.sub('', line)
|
code = _PAREN_COMMENT_RE.sub('', line)
|
||||||
code = code.split(';', 1)[0]
|
code = code.split(';', 1)[0]
|
||||||
if _W_TOKEN_RE.search(code):
|
|
||||||
return True
|
|
||||||
if _ATC_M_RE.search(code):
|
if _ATC_M_RE.search(code):
|
||||||
return True
|
return True
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Backwards-compat alias: external callers used file_uses_w before
|
# Backwards-compat alias.
|
||||||
# the ATC M-codes were added.
|
|
||||||
file_uses_w = file_uses_aux
|
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
|
# ------------------------------------------------------------------ run
|
||||||
|
|
||||||
def process(self, src_path, dst_path):
|
def process(self, src_path, dst_path):
|
||||||
"""Read src_path, write rewritten G-code to dst_path. Returns True
|
"""Read src_path, write rewritten G-code to dst_path. Returns
|
||||||
if any rewrite happened."""
|
True if any rewrite happened."""
|
||||||
modal = {'abs': True, 'inch': False} # G90 G21 are common defaults
|
|
||||||
rewrote_any = False
|
rewrote_any = False
|
||||||
|
|
||||||
with open(src_path, 'r', encoding='utf-8', errors='replace') as fin, \
|
with open(src_path, 'r', encoding='utf-8', errors='replace') as fin, \
|
||||||
@@ -166,16 +114,19 @@ class AuxPreprocessor(object):
|
|||||||
fout.write(raw)
|
fout.write(raw)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Update modal from G-codes on this line first (so absolute
|
# Warn (once) if the file still uses W tokens. The
|
||||||
# vs incremental matches what the planner sees for XYZ).
|
# standard way is now G1 A<value>; old files must be
|
||||||
self._detect_modals(code, modal)
|
# 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
|
# ATC M-codes (M100-M103). Each ATC M-code on the line
|
||||||
# is replaced with its (MSG,HOOK:<event>:) line and
|
# is replaced with its (MSG,HOOK:<event>:) line and
|
||||||
# stripped from the residual. Multiple ATC codes per
|
# stripped from the residual.
|
||||||
# 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.
|
|
||||||
atc_matches = list(_ATC_M_RE.finditer(line))
|
atc_matches = list(_ATC_M_RE.finditer(line))
|
||||||
if atc_matches:
|
if atc_matches:
|
||||||
rewrote_any = True
|
rewrote_any = True
|
||||||
@@ -188,99 +139,32 @@ class AuxPreprocessor(object):
|
|||||||
line = _ATC_M_RE.sub('', line)
|
line = _ATC_M_RE.sub('', line)
|
||||||
code = _PAREN_COMMENT_RE.sub('', line)
|
code = _PAREN_COMMENT_RE.sub('', line)
|
||||||
code = code.split(';', 1)[0]
|
code = code.split(';', 1)[0]
|
||||||
# If nothing else is left on the line, we're done.
|
|
||||||
if not code.strip():
|
if not code.strip():
|
||||||
# Preserve any trailing comment, but skip if the
|
# Nothing meaningful left; preserve any trailing
|
||||||
# whole line is empty after the M-code strip.
|
# comment text but skip empty lines.
|
||||||
rest = line.rstrip()
|
rest = line.rstrip()
|
||||||
if rest:
|
if rest:
|
||||||
fout.write(rest + '\n')
|
fout.write(rest + '\n')
|
||||||
continue
|
continue
|
||||||
|
# Other gcode remains on the line - emit it.
|
||||||
if not _W_TOKEN_RE.search(code):
|
fout.write(line + '\n')
|
||||||
# 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)
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
rewrote_any = True
|
# No rewrite needed.
|
||||||
|
fout.write(raw)
|
||||||
# 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')
|
|
||||||
|
|
||||||
return rewrote_any
|
return rewrote_any
|
||||||
|
|
||||||
# ------------------------------------------------------------ unit conv
|
|
||||||
|
|
||||||
def _w_to_mm(self, w_str, modal, set_pos):
|
def preprocess_file(src_path, log=None, **_unused):
|
||||||
try:
|
"""Convenience: rewrite src_path in place if it contains ATC
|
||||||
v = float(w_str)
|
M-codes. Returns True if the file was rewritten.
|
||||||
except (TypeError, ValueError):
|
|
||||||
raise AuxPreprocessorError('Invalid W value: %r' % w_str)
|
|
||||||
if modal['inch']:
|
|
||||||
v *= 25.4
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
Extra keyword args are accepted for backwards compat (the old
|
||||||
def preprocess_file(src_path, log=None, w_first=True):
|
w_first arg is no longer used)."""
|
||||||
"""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."""
|
|
||||||
if not AuxPreprocessor.file_uses_aux(src_path):
|
if not AuxPreprocessor.file_uses_aux(src_path):
|
||||||
return False
|
return False
|
||||||
pre = AuxPreprocessor(log=log, w_first=w_first)
|
pre = AuxPreprocessor(log=log)
|
||||||
fd, tmp = tempfile.mkstemp(prefix='auxpre_', suffix='.nc',
|
fd, tmp = tempfile.mkstemp(prefix='auxpre_', suffix='.nc',
|
||||||
dir=os.path.dirname(src_path) or None)
|
dir=os.path.dirname(src_path) or None)
|
||||||
os.close(fd)
|
os.close(fd)
|
||||||
|
|||||||
@@ -75,6 +75,19 @@ class Ctrl(object):
|
|||||||
self.hooks = bbctrl.Hooks(self)
|
self.hooks = bbctrl.Hooks(self)
|
||||||
with Trace.span('ctrl.aux'):
|
with Trace.span('ctrl.aux'):
|
||||||
self.aux = bbctrl.AuxAxis(self)
|
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()
|
self._register_aux_hooks()
|
||||||
|
|
||||||
with Trace.span('ctrl.mach.connect'):
|
with Trace.span('ctrl.mach.connect'):
|
||||||
@@ -133,46 +146,39 @@ class Ctrl(object):
|
|||||||
|
|
||||||
|
|
||||||
def _register_aux_hooks(self):
|
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')
|
log = self.log.get('AuxAxis')
|
||||||
|
|
||||||
def _hook_move(ctx):
|
def _hook_aux_home(ctx):
|
||||||
data = (ctx.get('data') or '').strip()
|
# Legacy: route to the standard external-axis homing.
|
||||||
if not data:
|
if self.ext_axis is not None and self.ext_axis.enabled:
|
||||||
raise Exception('aux hook missing target')
|
self.ext_axis.home()
|
||||||
self.aux.move_abs_mm(float(data))
|
else:
|
||||||
|
|
||||||
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):
|
|
||||||
self.aux.home()
|
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_droptool(ctx): self.aux.atc_droptool()
|
||||||
def _hook_grabtool(ctx): self.aux.atc_grabtool()
|
def _hook_grabtool(ctx): self.aux.atc_grabtool()
|
||||||
def _hook_release(ctx): self.aux.atc_release()
|
def _hook_release(ctx): self.aux.atc_release()
|
||||||
def _hook_clamp(ctx): self.aux.atc_clamp()
|
def _hook_clamp(ctx): self.aux.atc_clamp()
|
||||||
|
|
||||||
self.hooks.register_internal('aux', _hook_move,
|
# Legacy alias for older gcode that used aux_home.
|
||||||
block_unpause=True, auto_resume=True)
|
self.hooks.register_internal('aux_home', _hook_aux_home,
|
||||||
self.hooks.register_internal('aux_rel', _hook_move_rel,
|
|
||||||
block_unpause=True, auto_resume=True)
|
|
||||||
self.hooks.register_internal('aux_home', _hook_home,
|
|
||||||
block_unpause=True, auto_resume=True,
|
block_unpause=True, auto_resume=True,
|
||||||
timeout=180)
|
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
|
# ATC pneumatics. block_unpause + auto_resume so a program
|
||||||
# using M6 - implemented as (MSG,HOOK:droptool:) etc - pauses
|
# using M100/M101/M102/M103 pauses at the right point and
|
||||||
# at the right point and resumes once the sequence is done.
|
# resumes once the sequence is done.
|
||||||
self.hooks.register_internal('droptool', _hook_droptool,
|
self.hooks.register_internal('droptool', _hook_droptool,
|
||||||
block_unpause=True, auto_resume=True,
|
block_unpause=True, auto_resume=True,
|
||||||
timeout=60)
|
timeout=60)
|
||||||
@@ -193,5 +199,7 @@ class Ctrl(object):
|
|||||||
self.ioloop.close()
|
self.ioloop.close()
|
||||||
self.avr.close()
|
self.avr.close()
|
||||||
self.mach.planner.close()
|
self.mach.planner.close()
|
||||||
|
try: self.ext_axis.close()
|
||||||
|
except Exception: pass
|
||||||
try: self.aux.close()
|
try: self.aux.close()
|
||||||
except Exception: pass
|
except Exception: pass
|
||||||
|
|||||||
433
src/py/bbctrl/ExternalAxis.py
Normal file
433
src/py/bbctrl/ExternalAxis.py
Normal 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)
|
||||||
@@ -99,18 +99,22 @@ class FileHandler(bbctrl.APIHandler):
|
|||||||
|
|
||||||
del (self.uploadFile)
|
del (self.uploadFile)
|
||||||
|
|
||||||
# If the uploaded G-code uses the virtual W axis, rewrite the
|
# If the uploaded G-code uses ATC M-codes (M100..M103),
|
||||||
# file in place so the planner sees (MSG,HOOK:aux:*) lines
|
# rewrite them into (MSG,HOOK:droptool:) etc so the hook
|
||||||
# instead of W tokens it can't parse.
|
# 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:
|
try:
|
||||||
from bbctrl.AuxPreprocessor import preprocess_file
|
from bbctrl.AuxPreprocessor import preprocess_file
|
||||||
log = self.get_log('AuxPreprocessor')
|
log = self.get_log('AuxPreprocessor')
|
||||||
if preprocess_file(filename.decode('utf8'), log=log):
|
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)
|
self.uploadFilename)
|
||||||
except Exception:
|
except Exception:
|
||||||
self.get_log('AuxPreprocessor').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().preplanner.invalidate(self.uploadFilename)
|
||||||
self.get_ctrl().state.add_file(self.uploadFilename)
|
self.get_ctrl().state.add_file(self.uploadFilename)
|
||||||
|
|||||||
@@ -201,7 +201,15 @@ class Hooks:
|
|||||||
# Cancel any running hook on estop. The hook thread
|
# Cancel any running hook on estop. The hook thread
|
||||||
# cannot be killed from Python, but we can ask the
|
# cannot be killed from Python, but we can ask the
|
||||||
# AuxAxis to send ABORT to the ESP so its in-flight
|
# 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:
|
if self._hook_busy:
|
||||||
self.log.warning('E-stop: cancelling hook "%s"' %
|
self.log.warning('E-stop: cancelling hook "%s"' %
|
||||||
self._hook_busy_event)
|
self._hook_busy_event)
|
||||||
|
|||||||
@@ -256,9 +256,12 @@ class Mach(Comm):
|
|||||||
if cmd[0] == '$': self._query_var(cmd)
|
if cmd[0] == '$': self._query_var(cmd)
|
||||||
elif cmd[0] == '\\': super().queue_command(cmd[1:])
|
elif cmd[0] == '\\': super().queue_command(cmd[1:])
|
||||||
else:
|
else:
|
||||||
# Rewrite W-axis tokens in MDI input the same way the
|
# Rewrite ATC M-codes in MDI input the same way the
|
||||||
# FileHandler rewrites uploaded files.
|
# FileHandler rewrites uploaded files. Motion (X/Y/Z/A)
|
||||||
cmd = self._rewrite_w_mdi(cmd)
|
# 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._begin_cycle('mdi')
|
||||||
self.planner.mdi(cmd, with_limits)
|
self.planner.mdi(cmd, with_limits)
|
||||||
super().resume()
|
super().resume()
|
||||||
@@ -266,13 +269,12 @@ class Mach(Comm):
|
|||||||
self.mlog.info("Exception during MDI: %s" % err)
|
self.mlog.info("Exception during MDI: %s" % err)
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def _rewrite_w_mdi(self, cmd):
|
def _rewrite_aux_mdi(self, cmd):
|
||||||
"""Apply the W-axis preprocessor to a single MDI line. Returns
|
"""Apply the ATC M-code preprocessor to a single MDI line.
|
||||||
possibly-multi-line G-code with HOOK: comments inserted."""
|
Returns possibly-multi-line G-code with HOOK: comments inserted."""
|
||||||
try:
|
try:
|
||||||
from bbctrl.AuxPreprocessor import (
|
from bbctrl.AuxPreprocessor import AuxPreprocessor, _ATC_M_RE
|
||||||
AuxPreprocessor, _W_TOKEN_RE, _ATC_M_RE)
|
if not _ATC_M_RE.search(cmd):
|
||||||
if not _W_TOKEN_RE.search(cmd) and not _ATC_M_RE.search(cmd):
|
|
||||||
return cmd
|
return cmd
|
||||||
import io, tempfile, os
|
import io, tempfile, os
|
||||||
# AuxPreprocessor.process is file-based; route through
|
# AuxPreprocessor.process is file-based; route through
|
||||||
@@ -293,7 +295,7 @@ class Mach(Comm):
|
|||||||
except OSError: pass
|
except OSError: pass
|
||||||
return rewritten
|
return rewritten
|
||||||
except Exception as e:
|
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
|
return cmd
|
||||||
|
|
||||||
def set(self, code, value):
|
def set(self, code, value):
|
||||||
@@ -301,6 +303,17 @@ class Mach(Comm):
|
|||||||
|
|
||||||
|
|
||||||
def jog(self, axes):
|
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._begin_cycle('jogging')
|
||||||
self.planner.position_change()
|
self.planner.position_change()
|
||||||
super().queue_command(Cmd.jog(axes))
|
super().queue_command(Cmd.jog(axes))
|
||||||
@@ -318,6 +331,32 @@ class Mach(Comm):
|
|||||||
enabled = state.is_axis_enabled(axis)
|
enabled = state.is_axis_enabled(axis)
|
||||||
mode = state.axis_homing_mode(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
|
# 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
|
# axis is disabled or in manual homing mode, don't show any
|
||||||
# warnings
|
# warnings
|
||||||
@@ -349,7 +388,21 @@ class Mach(Comm):
|
|||||||
super().resume()
|
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()
|
def estop(self): super().estop()
|
||||||
|
|
||||||
|
|
||||||
@@ -376,6 +429,12 @@ class Mach(Comm):
|
|||||||
def stop(self):
|
def stop(self):
|
||||||
if self._get_state() != 'jogging': self.stopping = True
|
if self._get_state() != 'jogging': self.stopping = True
|
||||||
super().i2c_command(Cmd.STOP)
|
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()
|
def pause(self): super().pause()
|
||||||
|
|
||||||
|
|||||||
@@ -270,6 +270,19 @@ class Planner():
|
|||||||
if type != 'set': self.log.info('Cmd:' + log_json(block))
|
if type != 'set': self.log.info('Cmd:' + log_json(block))
|
||||||
|
|
||||||
if type == 'line':
|
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)
|
self._enqueue_line_time(block)
|
||||||
return Cmd.line(block['target'], block['exit-vel'],
|
return Cmd.line(block['target'], block['exit-vel'],
|
||||||
block['max-accel'], block['max-jerk'],
|
block['max-accel'], block['max-jerk'],
|
||||||
@@ -300,8 +313,17 @@ class Planner():
|
|||||||
|
|
||||||
if name[2:] == '_homed':
|
if name[2:] == '_homed':
|
||||||
motor = self.ctrl.state.find_motor(name[1])
|
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)
|
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
|
return
|
||||||
|
|
||||||
@@ -350,6 +372,57 @@ class Planner():
|
|||||||
self.planner.set_logger(None)
|
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):
|
def reset(self, *args, **kwargs):
|
||||||
stop = kwargs.get('stop', True)
|
stop = kwargs.get('stop', True)
|
||||||
if stop:
|
if stop:
|
||||||
@@ -363,6 +436,16 @@ class Planner():
|
|||||||
self.cmdq.clear()
|
self.cmdq.clear()
|
||||||
self.reset_times()
|
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)
|
resetState = kwargs.get('resetState', True)
|
||||||
if resetState:
|
if resetState:
|
||||||
self.ctrl.state.reset()
|
self.ctrl.state.reset()
|
||||||
@@ -380,16 +463,18 @@ class Planner():
|
|||||||
self.where = path
|
self.where = path
|
||||||
path = self.ctrl.get_path('upload', path)
|
path = self.ctrl.get_path('upload', path)
|
||||||
self.log.info('GCode:' + path)
|
self.log.info('GCode:' + path)
|
||||||
# Make sure W-axis tokens are rewritten before the planner sees
|
# Rewrite ATC M-codes (M100..M103) before gplan sees them.
|
||||||
# the file. preprocess_file is a no-op for files without W and
|
# preprocess_file is a no-op when no rewriting is needed and
|
||||||
# for files already rewritten (no W tokens remain after the
|
# idempotent when run twice on the same file, so this is
|
||||||
# first pass), so this is safe to run on every load.
|
# 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:
|
try:
|
||||||
from bbctrl.AuxPreprocessor import preprocess_file
|
from bbctrl.AuxPreprocessor import preprocess_file
|
||||||
if preprocess_file(path, log = self.log):
|
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:
|
except Exception:
|
||||||
self.log.exception('W-axis preprocess at load failed; '
|
self.log.exception('Aux preprocess at load failed; '
|
||||||
'attempting to load file unchanged')
|
'attempting to load file unchanged')
|
||||||
self._sync_position()
|
self._sync_position()
|
||||||
self.planner.load(path, self.get_config(False, True))
|
self.planner.load(path, self.get_config(False, True))
|
||||||
|
|||||||
@@ -280,8 +280,11 @@ class State(object):
|
|||||||
axis_motors = {axis: self.find_motor(axis) for axis in 'xyzabc'}
|
axis_motors = {axis: self.find_motor(axis) for axis in 'xyzabc'}
|
||||||
axis_vars = {}
|
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():
|
for name, value in vars.items():
|
||||||
if name[0] in '0123':
|
if name[0] in '01234':
|
||||||
motor = int(name[0])
|
motor = int(name[0])
|
||||||
|
|
||||||
for axis in 'xyzabc':
|
for axis in 'xyzabc':
|
||||||
@@ -330,6 +333,9 @@ class State(object):
|
|||||||
def get_axis_vector(self, name, scale = 1):
|
def get_axis_vector(self, name, scale = 1):
|
||||||
v = {}
|
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':
|
for axis in 'xyzabc':
|
||||||
motor = self.find_motor(axis)
|
motor = self.find_motor(axis)
|
||||||
|
|
||||||
@@ -351,7 +357,10 @@ class State(object):
|
|||||||
|
|
||||||
|
|
||||||
def find_motor(self, axis):
|
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
|
if not ('%dan' % motor) in self.vars: continue
|
||||||
motor_axis = 'xyzabc'[self.vars['%dan' % motor]]
|
motor_axis = 'xyzabc'[self.vars['%dan' % motor]]
|
||||||
if motor_axis == axis.lower() and self.vars.get('%dme' % motor, 0):
|
if motor_axis == axis.lower() and self.vars.get('%dme' % motor, 0):
|
||||||
|
|||||||
@@ -812,8 +812,12 @@ class AuxStatusHandler(bbctrl.APIHandler):
|
|||||||
|
|
||||||
class AuxHomeHandler(bbctrl.APIHandler):
|
class AuxHomeHandler(bbctrl.APIHandler):
|
||||||
def put_ok(self):
|
def put_ok(self):
|
||||||
# Run synchronously via the AuxAxis' own RPC; this blocks the
|
# Run synchronously. Route through ExternalAxis so the
|
||||||
# request. Fine because the UI shows a spinner.
|
# 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()
|
self.get_ctrl().aux.home()
|
||||||
|
|
||||||
|
|
||||||
@@ -824,12 +828,22 @@ class AuxAbortHandler(bbctrl.APIHandler):
|
|||||||
|
|
||||||
class AuxJogHandler(bbctrl.APIHandler):
|
class AuxJogHandler(bbctrl.APIHandler):
|
||||||
"""Body: {"mm": 1.5} for relative-mm move,
|
"""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):
|
def put_ok(self):
|
||||||
body = self.json or {}
|
body = self.json or {}
|
||||||
aux = self.get_ctrl().aux
|
aux = self.get_ctrl().aux
|
||||||
|
ext = getattr(self.get_ctrl(), 'ext_axis', None)
|
||||||
if 'mm' in body:
|
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:
|
elif 'steps' in body:
|
||||||
aux.jog_steps(int(body['steps']))
|
aux.jog_steps(int(body['steps']))
|
||||||
else:
|
else:
|
||||||
@@ -842,6 +856,10 @@ class AuxMoveHandler(bbctrl.APIHandler):
|
|||||||
body = self.json or {}
|
body = self.json or {}
|
||||||
if 'mm' not in body:
|
if 'mm' not in body:
|
||||||
raise HTTPError(400, 'mm required')
|
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']))
|
self.get_ctrl().aux.move_abs_mm(float(body['mm']))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ from bbctrl.IOLoop import IOLoop
|
|||||||
from bbctrl.MonitorTemp import MonitorTemp
|
from bbctrl.MonitorTemp import MonitorTemp
|
||||||
from bbctrl.Hooks import Hooks
|
from bbctrl.Hooks import Hooks
|
||||||
from bbctrl.AuxAxis import AuxAxis
|
from bbctrl.AuxAxis import AuxAxis
|
||||||
|
from bbctrl.ExternalAxis import ExternalAxis
|
||||||
import bbctrl.Cmd as Cmd
|
import bbctrl.Cmd as Cmd
|
||||||
import bbctrl.v4l2 as v4l2
|
import bbctrl.v4l2 as v4l2
|
||||||
import bbctrl.Log as log
|
import bbctrl.Log as log
|
||||||
|
|||||||
@@ -13,9 +13,13 @@
|
|||||||
baud: number;
|
baud: number;
|
||||||
steps_per_mm: number;
|
steps_per_mm: number;
|
||||||
dir_sign: number;
|
dir_sign: number;
|
||||||
|
axis_letter: string;
|
||||||
min_w: number;
|
min_w: number;
|
||||||
max_w: number;
|
max_w: number;
|
||||||
max_feed_mm_min: 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_dir: string;
|
||||||
home_position_mm: number;
|
home_position_mm: number;
|
||||||
home_fast_sps: number;
|
home_fast_sps: number;
|
||||||
@@ -130,7 +134,7 @@
|
|||||||
<label for="" class="units">steps/mm</label>
|
<label for="" class="units">steps/mm</label>
|
||||||
</div>
|
</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>
|
<label for="dir_sign">direction sign</label>
|
||||||
<select id="dir_sign" bind:value={cfg.dir_sign}>
|
<select id="dir_sign" bind:value={cfg.dir_sign}>
|
||||||
<option value={1}>+1</option>
|
<option value={1}>+1</option>
|
||||||
@@ -138,19 +142,49 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
<label for="min_w">soft min</label>
|
||||||
<input id="min_w" type="number" bind:value={cfg.min_w} step="any" />
|
<input id="min_w" type="number" bind:value={cfg.min_w} step="any" />
|
||||||
<label for="" class="units">mm</label>
|
<label for="" class="units">mm</label>
|
||||||
</div>
|
</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>
|
<label for="max_w">soft max</label>
|
||||||
<input id="max_w" type="number" bind:value={cfg.max_w} step="any" />
|
<input id="max_w" type="number" bind:value={cfg.max_w} step="any" />
|
||||||
<label for="" class="units">mm</label>
|
<label for="" class="units">mm</label>
|
||||||
</div>
|
</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>
|
<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" />
|
<input id="max_feed_mm_min" type="number" bind:value={cfg.max_feed_mm_min} step="any" />
|
||||||
<label for="" class="units">mm/min</label>
|
<label for="" class="units">mm/min</label>
|
||||||
|
|||||||
Reference in New Issue
Block a user