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;
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
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_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_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
|
||||
|
||||
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)
|
||||
|
||||
# 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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -812,9 +812,13 @@ 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.
|
||||
self.get_ctrl().aux.home()
|
||||
# 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()
|
||||
|
||||
|
||||
class AuxAbortHandler(bbctrl.APIHandler):
|
||||
@@ -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,7 +856,11 @@ class AuxMoveHandler(bbctrl.APIHandler):
|
||||
body = self.json or {}
|
||||
if 'mm' not in body:
|
||||
raise HTTPError(400, 'mm required')
|
||||
self.get_ctrl().aux.move_abs_mm(float(body['mm']))
|
||||
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']))
|
||||
|
||||
|
||||
class AuxSetZeroHandler(bbctrl.APIHandler):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user