diff --git a/src/js/axis-vars.js b/src/js/axis-vars.js index 2d39f16..cafd7c5 100644 --- a/src/js/axis-vars.js +++ b/src/js/axis-vars.js @@ -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; }, diff --git a/src/pug/templates/control-view.pug b/src/pug/templates/control-view.pug index 6af83d3..3231d35 100644 --- a/src/pug/templates/control-view.pug +++ b/src/pug/templates/control-view.pug @@ -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) diff --git a/src/py/bbctrl/AuxAxis.py b/src/py/bbctrl/AuxAxis.py index bbcc7bf..e3f8269 100644 --- a/src/py/bbctrl/AuxAxis.py +++ b/src/py/bbctrl/AuxAxis.py @@ -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 diff --git a/src/py/bbctrl/AuxPreprocessor.py b/src/py/bbctrl/AuxPreprocessor.py index 10cd2ab..3fee529 100644 --- a/src/py/bbctrl/AuxPreprocessor.py +++ b/src/py/bbctrl/AuxPreprocessor.py @@ -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: -# - 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'(? 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'(?; 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::) 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) diff --git a/src/py/bbctrl/Ctrl.py b/src/py/bbctrl/Ctrl.py index b2d8862..70ee11c 100644 --- a/src/py/bbctrl/Ctrl.py +++ b/src/py/bbctrl/Ctrl.py @@ -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 diff --git a/src/py/bbctrl/ExternalAxis.py b/src/py/bbctrl/ExternalAxis.py new file mode 100644 index 0000000..4cc615f --- /dev/null +++ b/src/py/bbctrl/ExternalAxis.py @@ -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 `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 `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 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('', 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 + # (__home_position / __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 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) diff --git a/src/py/bbctrl/FileHandler.py b/src/py/bbctrl/FileHandler.py index ca44cda..308958b 100644 --- a/src/py/bbctrl/FileHandler.py +++ b/src/py/bbctrl/FileHandler.py @@ -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) diff --git a/src/py/bbctrl/Hooks.py b/src/py/bbctrl/Hooks.py index 18e6ea9..f17f996 100644 --- a/src/py/bbctrl/Hooks.py +++ b/src/py/bbctrl/Hooks.py @@ -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) diff --git a/src/py/bbctrl/Mach.py b/src/py/bbctrl/Mach.py index 834ddaf..7a611e8 100644 --- a/src/py/bbctrl/Mach.py +++ b/src/py/bbctrl/Mach.py @@ -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() diff --git a/src/py/bbctrl/Planner.py b/src/py/bbctrl/Planner.py index f2369b1..333fd7e 100644 --- a/src/py/bbctrl/Planner.py +++ b/src/py/bbctrl/Planner.py @@ -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_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 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)) diff --git a/src/py/bbctrl/State.py b/src/py/bbctrl/State.py index 7cf2c8a..60be854 100644 --- a/src/py/bbctrl/State.py +++ b/src/py/bbctrl/State.py @@ -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): diff --git a/src/py/bbctrl/Web.py b/src/py/bbctrl/Web.py index b149e8c..a1d37cf 100644 --- a/src/py/bbctrl/Web.py +++ b/src/py/bbctrl/Web.py @@ -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): diff --git a/src/py/bbctrl/__init__.py b/src/py/bbctrl/__init__.py index 3d235cb..099914e 100644 --- a/src/py/bbctrl/__init__.py +++ b/src/py/bbctrl/__init__.py @@ -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 diff --git a/src/svelte-components/src/components/WAxisSettings.svelte b/src/svelte-components/src/components/WAxisSettings.svelte index b8daf5c..6c45795 100644 --- a/src/svelte-components/src/components/WAxisSettings.svelte +++ b/src/svelte-components/src/components/WAxisSettings.svelte @@ -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 @@ -
+
-
+
+ + +
+ +
-
+
+ -
+

Planner Limits

+
+
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +