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:
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)
|
||||
Reference in New Issue
Block a user