Match the file-preprocessor behaviour for live operator input. When a
Z-down jog or MDI line would push (A-Z) above the safe band, append
the matching A delta to the same line so the planner runs Z and A
together. Same direction-aware refusal: only error when the operator
explicitly asks A to move *up* (delta > 0) past the bound, or when
the required A would violate A's soft minimum.
Implementation:
* ExternalAxis.coordinate_mdi rewrites a multi-line MDI burst,
tracking G90/G91 modal across lines (jogs always emit
M70/G91/G0/M72; standard MDI defaults to G90). Z and A targets
are computed in machine coords using offset_z and offset_a so
the work-coord A token we emit is consistent with the operator's
frame.
* The 'A0' the jog UI emits for axes that aren't moving is treated
as 'no A intent' (G91 delta of zero) and freely overridden.
* Hooked into Mach.mdi after the existing ATC rewrite. On
ExternalAxisError the burst is dropped with a user message; the
planner check downstream still fires as defense in depth.
* Planner.__encode also catches ExternalAxisError now (vs
bricking on uncaught) - logs to the operator messages list and
halts the cycle cleanly so subsequent jogs work.
* check_coupling itself is now improvement-aware: only refuses
moves that worsen an existing violation. Pure XY jogs and
Z-up/A-down recovery moves pass even when (A-Z) is currently
above the bound.
Tested locally with synthetic MDI: small Z jog within band, Z jog
across the boundary (auto-injects A delta), G90 MDI G0 Z-50
(appends A106), explicit A-lift while Z deep (refuses), pure XY
jog (unchanged), G91 A-down (unchanged), G90 G0 A0 with
offset_a=134 (refuses as lift to home).
The auxiliary A axis carries a tool that hangs below the Z spindle.
Beyond a small Z descent the two physically collide unless A drops
with Z. Enforce in machine coords:
A_machine - Z_machine <= K
K = (A_home_mm - z_home_mm) + couple_z_clearance_mm
With our setup K = (134 - 0) + 22 = 156. At rest A=134 Z=0, A-Z=134
which is fine. Z can descend 22mm before the rule starts forcing A
down with it.
Two complementary layers:
(1) AuxPreprocessor injection (auto-fix uploaded files)
Tracks modal Z, A and distance mode (G90/G91) while scanning the
file. When a line would put A above Z by more than the clearance
we emit a 'G0 A<safe>' BEFORE the line so A is already at the
safe position when Z descends. Endpoint check is sufficient
because Z moves monotonically along a single line.
Errors are raised (not silently auto-fixed) when:
- the line lifts A above the safe band while Z stays put
(would require auto-injecting a Z-up which could swing
through a fixture)
- the line endpoint targets an A above the safe band
G91 disables injection with a one-shot warning; the runtime
check still applies.
(2) Runtime check (ExternalAxis.check_coupling)
Single source of truth for live motion. Hooked into:
* Planner.__encode for every line block (covers MDI and
running programs - gplan emits machine-coord targets)
* ExternalAxis.execute_to_mm/enqueue_target_mm/enqueue_line
for direct A motion (covers UI jog/move and planner-A
dispatch)
Raises ExternalAxisError on violation; gplan and the API both
surface the message. Skipped when coupling is disabled or the
axis isn't homed (mirrors the soft-limit gate).
Continuous Z jog from the AVR is not gated - it's an active
operator action without a pre-known endpoint. Operator-driven
over-travel during continuous jog will be caught by the next
MDI/file-load attempt.
Configuration in aux.json:
couple_z_enabled bool default true (per agreed setup)
couple_z_clearance_mm float default 22.0
z_home_mm float default 0.0
Surfaced in the new Z-A Coupling section of the A Axis settings
page with a description of the rule. Existing aux.json files get
the new keys via the merged-defaults path on read.
Tested locally with synthetic gcode covering Z descent, combined
moves, A lift while Z deep, G92 reset, G91 mode, and combined
Z+A target violations.
Replaces the legacy 'fixed-rate STEPS' path used in Planner.__encode
with a new ExternalAxis.enqueue_line() that hands the ESP the full
7-segment S-curve parameters of every gplan line block (max_accel,
max_jerk, entry_vel, exit_vel, times[7]).
The ESP's new LINE command (auxcnc commit 8acc6f7) integrates the
same SCurve math the AVR uses, so the W axis now physically moves
in lockstep with whatever the planner thinks A is doing. Result:
DRO stays in sync with the actual stepper, no more multi-second lag
between commanded and observed A position.
enqueue_target_mm is kept as a no-frills STEPS path for jog/move UI
endpoints that don't have planner timing context.
AuxAxis._do_line builds the LINE command with mm/min/min^2/min^3
units (matching gplan's internal unit system) and waits for
[line] done|aborted from the ESP. Limit aborts still flag _homed=False.
Three changes that together implement option (b) home semantics:
1. Mach.home for the external axis: replace G28.3 with explicit
AVR position sync (Cmd.set_axis) + planner abs sync
(position_change) + G92 a0 (set user-coord origin to current
physical position, computing offset = home_position_mm).
G28.3 was wrong: it preserves the current user-coord position
and adjusts the offset to bridge to the new abs. After a move
away from home and a re-home, the offset accumulates
(134 -> 268 -> ...). G92 a0 with a freshly-synced abs always
produces offset = home_position_mm regardless of prior state.
2. Planner.__encode: stop stripping the external axis target from
the AVR line. The AVR has no motor mapped to A so it steps no
motor, but exec_move_to_target updates ex.position[A] which
gets reported back as ap. Leaving A in the AVR target keeps
state.ap consistent with gplan's idea of A; stripping it left
ex.position[A] stale and clobbered ExternalAxis's state.ap on
the next status report.
Side benefit: removes the special-case empty-string return for
pure external moves; every line block follows the same path now.
3. ExternalAxis.enqueue_target_mm: stop writing to state.<axis>p
from the planner hot path. The AVR's status reports drive it
instead, which avoids DRO jitter (jump to target then snap back
to intermediate values as the trapezoid runs). _pos_mm internal
mirror is still updated for delta computation.
Re-verified with the integration smoke test in tmp/20260503_option_b/:
home/move-down/move-up/re-home/home-from-bottom all produce the
expected DRO position values (0 at home, -134 at bottom).
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.
The previous fix routed (MSG,HOOK:...) lines through state.messages
and then immediately ack'd them to suppress the user-visible popup.
But state changes are debounced 0.25s before listeners fire, so the
HOOK message was already ack'd (removed from the list) by the time
Hooks._on_state_change saw the update - and the hook never ran.
Add Hooks.dispatch_hook_message() as a direct entry point and call
it from Planner._add_message. HOOK lines are dispatched synchronously
from the planner thread; the user message list is left untouched, so
no popup leaks and no debounce race.
Three fixes for macro/W-axis interaction:
1. run_macro raced the file selection. The frontend mutated
state.selected client-side and immediately fired api.put('start').
Selection on the server is a side effect of GET /api/file/<name>
(FileHandler.get calls state.select_file). The GET request was
often still in flight when start ran, so mach.start() executed
whichever file was selected last - pressing W Down would re-run
W Up. Now run_macro awaits the file fetch before starting.
2. (MSG,HOOK:aux:N) lines, used as the IPC channel between the
W-axis preprocessor and the Hooks system, were leaking to the
user as message popups (because the planner forwards every
(MSG,...) comment to state.messages). Filter HOOK: messages in
Planner._add_message: still pushed through state.messages so
Hooks._on_state_change can dispatch them, but immediately
acked so the UI doesn't render them.
3. AuxPreprocessor only ran at upload time (FileHandler.put_ok and
Mach.mdi). Files written via scp, restored from a config backup,
or hand-edited still contained raw W tokens that the planner
couldn't parse. Run preprocess_file in Planner.load() too. It's
idempotent (no-op when no W tokens remain) so re-loading a
already-rewritten file is free.
Measured on onefinity.local (Pi 3, Raspbian Stretch, bbctrl 1.6.7).
Before -> after:
bbctrl listen boot+20.6s -> boot+12.4s (-8.2s)
host -> /api/config/load 28.2s -> 22.5s (-5.7s)
The 4 changes (each independently revertable):
1. scripts/bbserial-rebind.service: do the bbserial unbind + reload
in a dedicated unit ordered Before=bbctrl.service, instead of in
rc.local AFTER bbctrl is already listening on the serial port.
Eliminates a full bbctrl restart mid-boot.
2. scripts/bbctrl.service: drop "After=network.target". bbctrl talks
to the AVR on a local serial port and to the LCD on I2C; it does
not need DHCP / network-online to come up. Also adds explicit
ordering after the new bbserial-rebind unit.
3. scripts/rc.local.fast: trimmed rc.local that no longer touches
bbserial and backgrounds 'startx' so chromium launches in
parallel with bbctrl rather than after rc.local finishes.
4. src/py/bbctrl/Planner.py: lazy-import camotics.gplan. Costs ~130ms
on cold cache, deferred from import-time to ctrl.mach init.
5. (bonus) src/py/bbctrl/Log.py: tolerate FileNotFoundError in
_rotate(). The improved boot path exposed a pre-existing log
rotator bug that crashed bbctrl on first start when bbctrl.log.16
was missing.