feat: integrate W axis as virtual A axis through gplan

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

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

## Architecture

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

## New module: ExternalAxis

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

## Planner

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

## Mach

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

## State

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

## AuxAxis

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

## AuxPreprocessor

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

## Hooks

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

## Web.py

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

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

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

## Open follow-ups (validate on hardware)

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

View File

@@ -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