Compare commits

14 Commits

Author SHA1 Message Date
4470fcee0a Z-A coupling: drop active jog/MDI auto-coordination, keep refuse-only check
The active rewriter for jogs/MDI didn't help anyway because the
continuous-jog buttons send rate-based /api/jog commands to the AVR
and bypass the planner+MDI path entirely. Rather than build out
continuous-jog coupling on the ESP firmware or fake it with browser
ticks, simplify back to:

  * Runtime check (Planner.__encode + ExternalAxis motion entry
    points) refuses any move that would worsen the Z-A gap. Already
    improvement-aware so X/Y jogs and Z-up/A-down recoveries pass.
  * File preprocessor (AuxPreprocessor) injects pre-position A
    moves into uploaded gcode so well-formed programs run without
    operator intervention.

Operator workflow: jog freely down to the safe band; if you need to
go deeper, lower A first (aux jog mm) or use a step-jog MDI like
'G91 G0 Z-10 A-10' that includes the A delta. Programs do the right
thing on their own.
2026-05-03 15:03:01 +02:00
39e308d9ae AuxAxis: push home_preclear_mm via HOMECFG
Tells the auxcnc ESP how far (in steps) to back off if HOME is
invoked while the limit switch is already tripped. The ESP now
hard-fails instead of zeroing blindly when the switch stays active
after the preclear move. Default 10 mm; set home_preclear_mm=0 to
disable the preclear and revert to immediate failure.
2026-05-03 14:54:56 +02:00
8e6e72a8b9 Z-A coupling: auto-coordinate A on jogs and MDI
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).
2026-05-03 14:47:44 +02:00
226b44053c Z-A coupling interlock: prevent collision between Z and A tools
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.
2026-05-03 14:28:57 +02:00
0493a4ddc7 Rename W axis -> A axis everywhere (with migration)
Auxiliary axis is the auxcnc-driven stepper exposed to gplan as A,
not W. Half the stack already used A (gcode, DRO row, soft limits,
homing); the other half (settings tab, macros, internal field
names) still said W which was confusing.

Renames:
  - aux.json fields:       min_w/max_w        -> min_mm/max_mm
  - svelte component:      WAxisSettings      -> AAxisSettings
  - settings tab slug:     #w-axis            -> #a-axis
  - js view module:        w-axis-view.js     -> a-axis-view.js
  - pug template:          w-axis-view.pug    -> a-axis-view.pug
  - macros:                w_down.nc/w_up.nc  -> a_down.nc/a_up.nc
                           'W Down'/'W Up'    -> 'A Down'/'A Up'
  - css class & ids:       .w-axis-settings   -> .a-axis-settings,
                           min_w/max_w form ids match field names
  - internal js identifiers and comments

Migration:
  - AuxAxis._migrate_legacy_fields() promotes min_w/max_w in aux.json
    on every load and persists the upgraded form, so existing
    installs come out clean on first restart.
  - Config._upgrade() renames macro file_name and display name in
    every config.json load, so a stale in-memory copy can't
    reintroduce the W names. Ships with a save right after.

The auxcnc ESP wire protocol verbs (WPOS/HOMED) are unchanged - they
are wire-format identifiers, not user-facing labels.
2026-05-03 13:37:16 +02:00
c4c20c6d0a Rename user-facing 'W axis' references to 'A axis' / 'auxiliary axis'
The auxcnc-driven stepper has been integrated into gplan as a
virtual A axis since the option-b migration. User-facing labels
that still said 'W axis' are now confusing because the user types
G-code with A, sees 'A' in the DRO, but config tabs and tooltips
still talked about W.

Cleaned up:
  - Settings tab label W Axis -> A Axis (route slug stays #w-axis
    for back-compat with bookmarks)
  - WAxisSettings.svelte tooltips and tip text say 'auxiliary' or
    just describe the field generically
  - control-view.pug DRO row tooltips, comments, labels
  - control-view.js console.error messages and route-comments
  - axis-vars.js _compute_aux_axis tooltips
  - AuxAxis.py boot-banner message

Internal identifiers (filenames WAxisSettings.svelte, route slug
#w-axis, aux.json fields min_w/max_w, internal comments referring
to the historical W-as-aux design) are kept where they are tied to
on-disk state or wire formats - those rename moves are not
worthwhile and would force users to migrate their aux.json files.
2026-05-03 13:29:34 +02:00
4a494a101d Mach.home: defer external A homing until AVR axes finish
Previously the homing loop iterated zxyabc and processed each axis
in turn, but the AVR axes (Z/X/Y) just queue G-code to the planner
and return immediately - the gantry keeps moving in the background.
The external A homing was then driven synchronously on the same
loop iteration, which meant the W stepper started its limit-seek
*while the gantry was still actively homing Z/X/Y*. Visually
confusing and unsafe.

Split into two phases:

  1. The HTTP handler thread queues every AVR axis (no change) and
     collects external axes into a deferred list.
  2. A background thread polls cycle until it returns to 'idle'
     (signalling the AVR finished its queued homing). It then runs
     each external axis home in order, blocking on the ESP serial
     RPC. Post-home bookkeeping (set_axis to AVR, planner G92, cycle
     reset) is scheduled back onto the IOLoop via add_callback so
     gplan and the AVR command queue are only touched from one
     thread.

A guard prevents overlapping threads if Home is clicked again while
the previous deferred run is still waiting.
2026-05-03 13:24:54 +02:00
ca7e30aa05 Mach.home: end the homing cycle when external homing fails
When ext.home() raised, Mach.home logged the error but never reset
the cycle from 'homing' back to 'idle'. The AVR had not moved (the
homing was external), so its state stayed at READY without a
transition - meaning _update's normal 'state changed to READY ->
exit cycle' path never fired and the UI was permanently locked out
of every action that requires is_idle (jog buttons, the home button
itself, run, etc).

Wrap the external-homing block in try/except and force the cycle
back to idle on any failure. The success path is unchanged - it
still relies on the AVR's queued set_axis + G92 acks to bring the
state back to READY.
2026-05-03 13:13:29 +02:00
983e06b53d Wire ExternalAxis to send LINE blocks (full S-curve mirror to ESP)
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.
2026-05-03 12:29:43 +02:00
53b65dc30e AuxAxis: force unhome on every connect to keep host/ESP state consistent
The ESP's homed flag survives bbctrl restarts (since the ESP itself
stays powered). Host state, on the other hand, gets reset to zero on
boot - State.reset zeros ap and offset_a. Trusting the ESP's homed
flag in that situation made gplan think A was homed at machine-coord 0
while physically the axis was at 134, which then rejected any move
to the bottom (G1 A-134) as 'less than minimum soft limit 0'.

Send UNHOME (new auxcnc verb that just clears g_homed without
moving) on every host connect. The user has to re-home explicitly,
which goes through the proper Mach.home path that sets up the
offset and gplan position consistently.

Falls back to HOMED? if the firmware doesn't know UNHOME, so older
auxcnc builds keep their previous behaviour.

State.reset extended to also clear motor 4's homed flags
(<motor>homed and <motor>h) so the synthetic external-axis motor
gets reset alongside the real AVR motors.
2026-05-03 12:04:12 +02:00
f545438fa8 control: skip redundant aux/home in Home All when W is mapped to A
When the auxcnc axis is integrated as a virtual machine axis via
ExternalAxis (synthetic motor 4 enabled), Mach.home(None) already
homes the external axis as it iterates xyzabc. The legacy 'home W
last' path in home_all() then fired PUT /api/aux/home a second time,
causing the A axis to home twice on Home All.

Skip the trailing aux/home when state['4me'] is set; keep the
fallback for setups where aux is enabled but not integrated as a
virtual axis.
2026-05-03 11:58:08 +02:00
3b622d3d17 ExternalAxis: enforce soft limits in execute_to_mm and enqueue_target_mm
Soft limits in machine coords (min_w/max_w from aux.json) were only
checked by gplan. UI jog/move endpoints went through ExternalAxis
directly without any check, so the W+ button at home would happily
push past max_w into a physical crash.

Add _check_soft_limit(target_abs_mm) called by both motion paths:
the synchronous execute_to_mm (UI) and the non-blocking
enqueue_target_mm (planner). Boundaries inclusive within a 1e-4
epsilon for floating-point round-trip stability. Skipped when the
axis isn't homed, matching the standard bbctrl convention that
soft limits are gated by homing state. Skipped when max <= min
(disabled).

Tested locally:
- pre-home: 200mm allowed (jog-out-of-trouble path)
- post-home: 0 and 134 (boundaries) accepted
- post-home: 135 and -1 rejected with clear error
- 134.00005 accepted (within epsilon), 134.001 rejected
- enqueue path also rejects, propagating up through Planner.next()
- max==min config skips check
2026-05-03 11:50:49 +02:00
aa747dcc85 Hide X cursor on kiosk (touchscreen)
Pass -nocursor to startx so the mouse pointer never appears on the\nOnefinity touchscreen. Patched in all three boot paths: rc.local.fast\n(active), legacy rc.local, and the setup_rpi.sh bootstrap.
2026-05-03 11:47:05 +02:00
56c3406f25 ExternalAxis: option (b) homing - user A=0 at home, deterministic on re-home
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).
2026-05-03 10:46:47 +02:00
24 changed files with 1093 additions and 199 deletions

View File

@@ -28,4 +28,4 @@ plymouth quit
# Start X in /home/pi # Start X in /home/pi
cd /home/pi cd /home/pi
sudo -u pi startx sudo -u pi startx -- -nocursor

View File

@@ -34,7 +34,9 @@ plymouth quit 2>/dev/null || true
# late-boot units (bbctrl logrotate, etc.) don't block on it. Output # late-boot units (bbctrl logrotate, etc.) don't block on it. Output
# is redirected so the journal doesn't fill up with X warnings. # is redirected so the journal doesn't fill up with X warnings.
cd /home/pi cd /home/pi
nohup sudo -u pi startx >/var/log/onefin-x.log 2>&1 & # `-- -nocursor` hides the X pointer; this is a touchscreen kiosk and
# the mouse cursor only gets in the way.
nohup sudo -u pi startx -- -nocursor >/var/log/onefin-x.log 2>&1 &
disown disown
exit 0 exit 0

View File

@@ -75,7 +75,7 @@ sed -i 's/^PARTUUID=.*\//\/dev\/mmcblk0p2 \//' /etc/fstab
# Enable browser in xorg # Enable browser in xorg
sed -i 's/allowed_users=console/allowed_users=anybody/' /etc/X11/Xwrapper.config sed -i 's/allowed_users=console/allowed_users=anybody/' /etc/X11/Xwrapper.config
echo "sudo -u pi startx" >> /etc/rc.local echo "sudo -u pi startx -- -nocursor" >> /etc/rc.local
cp /mnt/host/xinitrc /home/pi/.xinitrc cp /mnt/host/xinitrc /home/pi/.xinitrc
cp /mnt/host/ratpoisonrc /home/pi/.ratpoisonrc cp /mnt/host/ratpoisonrc /home/pi/.ratpoisonrc
cp /mnt/host/xorg.conf /etc/X11/ cp /mnt/host/xorg.conf /etc/X11/

View File

@@ -1,16 +1,16 @@
"use strict"; "use strict";
// V09 W-axis page \u2014 mounts the existing WAxisSettings Svelte component // V09 A-axis page mounts the AAxisSettings Svelte component
// inside the settings shell so it gets a real top-level rail entry // inside the settings shell so it gets a real top-level rail entry
// instead of being a soft-link anchor inside Display & Units. // instead of being a soft-link anchor inside Display & Units.
module.exports = { module.exports = {
template: "#w-axis-view-template", template: "#a-axis-view-template",
attached: function () { attached: function () {
this.svelteComponent = SvelteComponents.createComponent( this.svelteComponent = SvelteComponents.createComponent(
"WAxisSettings", "AAxisSettings",
document.getElementById("w-axis-mount") document.getElementById("a-axis-mount")
); );
}, },

View File

@@ -369,7 +369,7 @@ module.exports = new Vue({
ready: function() { ready: function() {
window.onhashchange = () => this.parse_hash(); window.onhashchange = () => this.parse_hash();
// Embedded Svelte subviews (W axis settings, etc.) signal // Embedded Svelte subviews (A axis settings, etc.) signal
// unsaved changes via this event. The master Save button // unsaved changes via this event. The master Save button
// highlights when modified is true. // highlights when modified is true.
window.addEventListener("onefin:dirty", () => { window.addEventListener("onefin:dirty", () => {
@@ -391,7 +391,7 @@ module.exports = new Vue({
"admin-general", "admin-network", "admin-general", "admin-network",
"motor", "tool", "io", "macros", "motor", "tool", "io", "macros",
"help", "cheat-sheet", "help", "cheat-sheet",
"w-axis", "a-axis",
]; ];
const initialHead = (location.hash || "").replace(/^#/, "").split(":")[0]; const initialHead = (location.hash || "").replace(/^#/, "").split(":")[0];
if (settingsFamily.indexOf(initialHead) === -1) { if (settingsFamily.indexOf(initialHead) === -1) {
@@ -627,7 +627,7 @@ module.exports = new Vue({
"admin-general", "admin-network", "admin-general", "admin-network",
"motor", "tool", "io", "macros", "motor", "tool", "io", "macros",
"help", "cheat-sheet", "help", "cheat-sheet",
"w-axis", "a-axis",
]; ];
if (head == "control") { if (head == "control") {
@@ -690,7 +690,7 @@ module.exports = new Vue({
try { try {
await api.put("config/save", this.config); await api.put("config/save", this.config);
// Notify any embedded Svelte subviews that own their // Notify any embedded Svelte subviews that own their
// own persistence (W axis -> aux.json, etc.) that // own persistence (A axis -> aux.json, etc.) that
// the user just hit the master Save button. They // the user just hit the master Save button. They
// listen for `onefin:save-all` and PUT their state. // listen for `onefin:save-all` and PUT their state.
try { try {

View File

@@ -256,10 +256,11 @@ module.exports = {
}, },
_compute_aux_axis: function() { _compute_aux_axis: function() {
// Virtual W axis driven by the auxcnc ESP32. Position, homed // Auxiliary axis driven by the auxcnc ESP32 (typically
// flag and presence come from the bbctrl AuxAxis driver via // exposed to gplan as A). Position, homed flag and
// state.aux_*. No motor mapping, no soft-limit warnings on // presence come from the bbctrl AuxAxis driver via
// toolpath bounds (auxcnc enforces its own). // state.aux_*. No motor mapping, no soft-limit warnings
// on toolpath bounds (auxcnc enforces its own).
const enabled = !!this.state.aux_enabled; const enabled = !!this.state.aux_enabled;
const present = !!this.state.aux_present; const present = !!this.state.aux_present;
const homed = !!this.state.aux_homed; const homed = !!this.state.aux_homed;
@@ -269,12 +270,12 @@ module.exports = {
let state = present ? "UNHOMED" : "OFFLINE"; let state = present ? "UNHOMED" : "OFFLINE";
let icon = present ? "question-circle" : "plug"; let icon = present ? "question-circle" : "plug";
let title = present let title = present
? "Click the home button to home W axis." ? "Click the home button to home the auxiliary axis."
: "Aux controller not connected on /dev/ttyUSB0."; : "Aux controller not connected on /dev/ttyUSB0.";
if (homed) { if (homed) {
state = "HOMED"; state = "HOMED";
icon = "check-circle"; icon = "check-circle";
title = "W axis successfully homed."; title = "Auxiliary axis successfully homed.";
} else if (!present) { } else if (!present) {
klass += " error"; klass += " error";
} }
@@ -295,7 +296,7 @@ module.exports = {
title: title, title: title,
ticon: "check-circle", ticon: "check-circle",
tstate: "OK", tstate: "OK",
toolmsg: "W axis is not constrained by tool path bounds.", toolmsg: "Auxiliary axis is not constrained by tool path bounds.",
tklass: `${homed ? "homed" : "unhomed"} axis-w`, tklass: `${homed ? "homed" : "unhomed"} axis-w`,
isAux: true, isAux: true,
}; };

View File

@@ -251,19 +251,24 @@ module.exports = {
aux_home: function () { aux_home: function () {
api.put("aux/home").catch(function (err) { api.put("aux/home").catch(function (err) {
console.error("W home failed:", err); console.error("Aux home failed:", err);
}); });
}, },
// Home every enabled axis (legacy Onefinity "Home All"). Sequence: // Home every enabled axis (legacy Onefinity "Home All"). Sequence:
// 1. Z, X, Y (and A/B/C if enabled) via /api/home on the AVR // 1. Z, X, Y (and A/B/C if enabled) via /api/home on the AVR
// 2. W axis via /api/aux/home on the ESP // 2. Auxiliary axis via /api/aux/home on the ESP
// ONLY when the auxcnc axis is not integrated as a virtual
// machine axis. With the gplan A-axis integration (synthetic
// motor 4 enabled), Mach.home() already homes the external
// axis as part of the xyzabc pass - calling aux/home
// afterwards would home it a second time.
// /api/home returns as soon as the request is queued, not when // /api/home returns as soon as the request is queued, not when
// homing completes, so we have to watch state.cycle: // homing completes, so we have to watch state.cycle:
// - first wait for it to *leave* 'idle' (cycle began), // - first wait for it to *leave* 'idle' (cycle began),
// - then wait for it to come *back* to 'idle' (cycle ended). // - then wait for it to come *back* to 'idle' (cycle ended).
// Only then do we fire the W home, so the gantry and the auxcnc // Only then do we fire the auxiliary home, so the gantry and the
// ESP never move at the same time. // auxcnc ESP never move at the same time.
home_all: async function () { home_all: async function () {
this.ask_home = false; this.ask_home = false;
try { try {
@@ -274,19 +279,24 @@ module.exports = {
} }
if (!this.w || !this.w.enabled) return; if (!this.w || !this.w.enabled) return;
// When the synthetic external motor (index 4) is enabled,
// the auxcnc axis is mapped onto a real machine axis letter
// (e.g. A) and was already homed by /api/home above.
if (this.state && this.state["4me"]) return;
const wait = (ms) => new Promise(r => setTimeout(r, ms)); const wait = (ms) => new Promise(r => setTimeout(r, ms));
const cycleNow = () => (this.state && this.state.cycle) || "idle"; const cycleNow = () => (this.state && this.state.cycle) || "idle";
// Phase 1: wait up to 5s for the homing cycle to actually start. // Phase 1: wait up to 5s for the homing cycle to actually start.
// If the request was rejected upstream (e.g. estopped) cycle // If the request was rejected upstream (e.g. estopped) cycle
// never leaves idle and we bail rather than home W in isolation. // never leaves idle and we bail rather than home A in isolation.
const startedAt = Date.now(); const startedAt = Date.now();
while (Date.now() - startedAt < 5000) { while (Date.now() - startedAt < 5000) {
if (cycleNow() != "idle") break; if (cycleNow() != "idle") break;
await wait(100); await wait(100);
} }
if (cycleNow() == "idle") { if (cycleNow() == "idle") {
console.warn("home_all: main homing cycle never started; skipping W"); console.warn("home_all: main homing cycle never started; skipping aux");
return; return;
} }
@@ -302,13 +312,13 @@ module.exports = {
} }
api.put("aux/home").catch(function (err) { api.put("aux/home").catch(function (err) {
console.error("W home failed:", err); console.error("Aux home failed:", err);
}); });
}, },
aux_jog: function (delta_mm) { aux_jog: function (delta_mm) {
api.put("aux/jog", { mm: delta_mm }).catch(function (err) { api.put("aux/jog", { mm: delta_mm }).catch(function (err) {
console.error("W jog failed:", err); console.error("Aux jog failed:", err);
}); });
}, },

View File

@@ -24,7 +24,7 @@ module.exports = {
"io-view": require("./io-view"), "io-view": require("./io-view"),
"macros-view": require("./macros"), "macros-view": require("./macros"),
"help-view": require("./help-view"), "help-view": require("./help-view"),
"w-axis-view": require("./w-axis-view"), "a-axis-view": require("./a-axis-view"),
"cheat-sheet-view": { "cheat-sheet-view": {
template: "#cheat-sheet-view-template", template: "#cheat-sheet-view-template",
data: function () { data: function () {
@@ -57,9 +57,9 @@ module.exports = {
{ sub: "motor", motor: 1, href: "#motor:1", icon: "fa-arrows-up-down-left-right", label: "Motor 1" }, { sub: "motor", motor: 1, href: "#motor:1", icon: "fa-arrows-up-down-left-right", label: "Motor 1" },
{ sub: "motor", motor: 2, href: "#motor:2", icon: "fa-arrows-up-down-left-right", label: "Motor 2" }, { sub: "motor", motor: 2, href: "#motor:2", icon: "fa-arrows-up-down-left-right", label: "Motor 2" },
{ sub: "motor", motor: 3, href: "#motor:3", icon: "fa-arrows-up-down-left-right", label: "Motor 3" }, { sub: "motor", motor: 3, href: "#motor:3", icon: "fa-arrows-up-down-left-right", label: "Motor 3" },
// W axis is auxiliary (auxcnc ESP32). It mounts the existing // Auxiliary axis (auxcnc ESP32 - exposed to gplan as A).
// WAxisSettings Svelte component on its own page. // Mounts the AAxisSettings Svelte component on its own page.
{ sub: "w-axis", href: "#w-axis", icon: "fa-arrows-up-down", label: "W Axis" }, { sub: "a-axis", href: "#a-axis", icon: "fa-arrows-up-down", label: "A Axis" },
{ section: " " }, { section: " " },
{ sub: "help", href: "#help", icon: "fa-circle-question", label: "Help" }, { sub: "help", href: "#help", icon: "fa-circle-question", label: "Help" },
], ],
@@ -137,7 +137,7 @@ module.exports = {
// layout, which under tablet mode pulls the fixed header out // layout, which under tablet mode pulls the fixed header out
// of view. // of view.
if (location.hash !== item.href) location.hash = item.href; if (location.hash !== item.href) location.hash = item.href;
this._w_axis_focus = (item.sub === "w-axis"); this._a_axis_focus = (item.sub === "a-axis");
const reset = () => { const reset = () => {
// Force any inadvertent ancestor scroll back to 0 before // Force any inadvertent ancestor scroll back to 0 before
// we move .settings-content explicitly. // we move .settings-content explicitly.
@@ -160,7 +160,7 @@ module.exports = {
requestAnimationFrame(reset); requestAnimationFrame(reset);
}, 320); }, 320);
} else { } else {
this._w_axis_focus = false; this._a_axis_focus = false;
if (location.hash !== item.href) location.hash = item.href; if (location.hash !== item.href) location.hash = item.href;
// Reset .app-body scroll so each route starts at the top. // Reset .app-body scroll so each route starts at the top.
const body = document.querySelector(".app-body"); const body = document.querySelector(".app-body");

View File

@@ -0,0 +1,4 @@
script#a-axis-view-template(type="text/x-template")
#a-axis-page
h1 A Axis (auxcnc)
#a-axis-mount

View File

@@ -92,17 +92,21 @@ script#control-view-template(type="text/x-template")
.fa.fa-arrow-down.ico(style="transform: rotate(-45deg)") .fa.fa-arrow-down.ico(style="transform: rotate(-45deg)")
button.jbtn(@click="jog_fn(0, 0, -1, 0)") Z button.jbtn(@click="jog_fn(0, 0, -1, 0)") Z
// Row 4 — W axis (auxcnc) when enabled. // Row 4 — A axis (the auxcnc-driven external axis) when enabled.
// W- | W+ | Probe XYZ | Probe Z // A- | A+ | Probe XYZ | Probe Z
// "Home W" lives in the DRO table's actions column on the // "Home A" lives in the DRO table's actions column on the
// right, so it doesn't need a tile here. // right, so it doesn't need a tile here. The legacy w.enabled
template(v-if="w.enabled") // gate is kept so older installs (where the auxcnc axis still
button.jbtn(@click="aux_jog_incr(-1)", :disabled="!w.enabled") // appears as W via the side-channel) keep working.
template(v-if="w.enabled || a.enabled")
button.jbtn(@click="aux_jog_incr(-1)",
:disabled="!(w.enabled || a.enabled)")
.fa.fa-arrow-down.ico .fa.fa-arrow-down.ico
span.lbl W span.lbl A
button.jbtn(@click="aux_jog_incr(+1)", :disabled="!w.enabled") button.jbtn(@click="aux_jog_incr(+1)",
:disabled="!(w.enabled || a.enabled)")
.fa.fa-arrow-up.ico .fa.fa-arrow-up.ico
span.lbl W+ span.lbl A+
button.jbtn(@click="showProbeDialog('xyz')", button.jbtn(@click="showProbeDialog('xyz')",
:class="{'load-on': !state['pw']}") :class="{'load-on': !state['pw']}")
.fa.fa-bullseye.ico .fa.fa-bullseye.ico
@@ -211,13 +215,11 @@ script#control-view-template(type="text/x-template")
div Position div Position
div Absolute div Absolute
div Offset div Offset
div State
div Toolpath
.actions-cell .actions-cell
// Master Home All. Each row's Actions cell has a per-axis // Master Home All. Each row's Actions cell has a per-axis
// home button; this header-level button homes every // home button; this header-level button homes every
// enabled axis (legacy Onefinity behavior). Auto-includes // enabled axis (legacy Onefinity behavior). Auto-includes
// the W axis when it is enabled. // the auxiliary A axis when it is enabled.
button.icon-btn(:disabled="!is_idle", button.icon-btn(:disabled="!is_idle",
title="Home all axes.", @click="home_all()") title="Home all axes.", @click="home_all()")
.fa.fa-house-chimney .fa.fa-house-chimney
@@ -226,39 +228,32 @@ script#control-view-template(type="text/x-template")
each axis in 'xyzabc' each axis in 'xyzabc'
.dro-row(:class=`${axis}.klass + ' ' + ${axis}.tklass`, .dro-row(:class=`${axis}.klass + ' ' + ${axis}.tklass`,
v-if=`${axis}.enabled`, v-if=`${axis}.enabled`,
:title=`${axis}.title`) :title=`${axis}.toolmsg ? (${axis}.title + ' — ' + ${axis}.toolmsg) : ${axis}.title`)
.dro-axis(:class=`'axis-' + '${axis}'`)= axis.toUpperCase() .dro-axis(:class=`'axis-' + '${axis}'`)= axis.toUpperCase()
.dro-pos: unit-value(:value=`${axis}.pos`, precision=4) .dro-pos: unit-value(:value=`${axis}.pos`, precision=4)
.dro-sec: unit-value(:value=`${axis}.abs`, precision=3) .dro-sec: unit-value(:value=`${axis}.abs`, precision=3)
.dro-sec: unit-value(:value=`${axis}.off`, precision=3) .dro-sec: unit-value(:value=`${axis}.off`, precision=3)
.dro-state
span.chip(:class=`${axis}.tklass.indexOf('error') !== -1 ? 'chip-red' : (${axis}.homed ? 'chip-green' : 'chip-amber')`)
.fa(:class=`'fa-' + ${axis}.icon`)
| &nbsp;{{#{axis}.state}}
.dro-toolpath
span.chip(:class=`${axis}.tklass.indexOf('error') !== -1 ? 'chip-red' : (${axis}.tklass.indexOf('warn') !== -1 ? 'chip-amber' : 'chip-green')`,
@click=`showToolpathMessageDialog('${axis}')`)
.fa(:class=`'fa-' + ${axis}.ticon`)
| &nbsp;{{#{axis}.tstate}}
.actions-cell .actions-cell
button.icon-btn(:disabled="!can_set_axis", button.icon-btn(:disabled="!can_set_axis",
:title=`'Set ${axis.toUpperCase()} axis position.'`, :title=`'Set ${axis.toUpperCase()} axis position.'`,
@click=`show_set_position('${axis}')`) @click=`show_set_position('${axis}')`)
.fa.fa-gear .fa.fa-gear
button.icon-btn(:disabled="!can_set_axis", button.icon-btn(:class=`${axis}.tklass.indexOf('error') !== -1 ? 'state-red' : (${axis}.tklass.indexOf('warn') !== -1 ? 'state-amber' : 'state-green')`,
:title=`'Zero ${axis.toUpperCase()} axis offset.'`, :disabled="!can_set_axis",
:title=`${axis}.toolmsg || ('Zero ${axis.toUpperCase()} axis offset.')`,
@click=`zero('${axis}')`) @click=`zero('${axis}')`)
.fa.fa-location-dot .fa.fa-location-dot
button.icon-btn(:disabled="!is_idle", button.icon-btn(:class=`${axis}.klass.indexOf('error') !== -1 ? 'state-red' : (${axis}.homed ? 'state-green' : 'state-amber')`,
:title=`'Home ${axis.toUpperCase()} axis.'`, :disabled="!is_idle",
:title=`${axis}.title`,
@click=`home('${axis}')`) @click=`home('${axis}')`)
.fa.fa-home .fa.fa-home
// Legacy W axis row - shown only when the auxcnc stepper is // Legacy auxiliary-axis row - shown only when the auxcnc stepper is
// *not* exposed as a virtual A axis. After v2 the standard // *not* exposed as a virtual A axis. After v2 the standard
// A row above renders this axis natively (with full offset // A row above renders this axis natively (with full offset
// + set-position support); the W row stays for backwards // + set-position support); this row only appears on legacy
// compatibility with installs that haven't migrated. // installs that haven't migrated yet.
.dro-row(:class="w.klass + ' ' + w.tklass", .dro-row(:class="w.klass + ' ' + w.tklass",
v-if="w.enabled && !a.enabled", v-if="w.enabled && !a.enabled",
:title="w.title") :title="w.title")
@@ -266,21 +261,14 @@ script#control-view-template(type="text/x-template")
.dro-pos: unit-value(:value="w.pos", precision=4) .dro-pos: unit-value(:value="w.pos", precision=4)
.dro-sec: unit-value(:value="w.abs", precision=3) .dro-sec: unit-value(:value="w.abs", precision=3)
.dro-sec — .dro-sec —
.dro-state
span.chip(:class="w.homed ? 'chip-green' : 'chip-amber'")
.fa(:class="'fa-' + w.icon")
| &nbsp;{{w.state}}
.dro-toolpath
span.chip.chip-green
.fa(:class="'fa-' + w.ticon")
| &nbsp;{{w.tstate}}
.actions-cell .actions-cell
button.icon-btn(disabled, style="visibility:hidden") button.icon-btn(disabled, style="visibility:hidden")
.fa.fa-gear .fa.fa-gear
button.icon-btn(disabled, style="visibility:hidden") button.icon-btn(disabled, style="visibility:hidden")
.fa.fa-location-dot .fa.fa-location-dot
button.icon-btn(:disabled="!w.enabled", button.icon-btn(:class="w.homed ? 'state-green' : 'state-amber'",
title="Home W axis.", @click="aux_home()") :disabled="!w.enabled",
title="Home auxiliary axis.", @click="aux_home()")
.fa.fa-home .fa.fa-home
// ----- Status strip ----- // ----- Status strip -----

View File

@@ -46,7 +46,7 @@ script#settings-shell-view-template(type="text/x-template")
:index="index", :config="config", :template="template", :state="state") :index="index", :config="config", :template="template", :state="state")
io-view(v-if="sub === 'io' && config_ready", io-view(v-if="sub === 'io' && config_ready",
:index="index", :config="config", :template="template", :state="state") :index="index", :config="config", :template="template", :state="state")
w-axis-view(v-if="sub === 'w-axis' && config_ready", a-axis-view(v-if="sub === 'a-axis' && config_ready",
:index="index", :config="config", :template="template", :state="state") :index="index", :config="config", :template="template", :state="state")
macros-view(v-if="sub === 'macros' && config_ready", macros-view(v-if="sub === 'macros' && config_ready",
:index="index", :config="config", :template="template", :state="state") :index="index", :config="config", :template="template", :state="state")

View File

@@ -1,4 +0,0 @@
script#w-axis-view-template(type="text/x-template")
#w-axis-page
h1 W Axis (auxcnc)
#w-axis-mount

View File

@@ -40,8 +40,8 @@ DEFAULTS = {
# 4th axis). gcode uses A for moves; the host ExternalAxis layer # 4th axis). gcode uses A for moves; the host ExternalAxis layer
# forks A motion to the ESP transparently. # forks A motion to the ESP transparently.
'axis_letter': 'a', 'axis_letter': 'a',
'min_w': 0.0, # soft limit min (mm), exposed as 4tn 'min_mm': 0.0, # soft limit min (mm), exposed as 4tn
'max_w': 100.0, # soft limit max (mm), exposed as 4tm 'max_mm': 100.0, # soft limit max (mm), exposed as 4tm
# Per-axis kinematic limits used to populate the planner's config. # Per-axis kinematic limits used to populate the planner's config.
# Units match the bbctrl/onefinity per-motor convention so the # Units match the bbctrl/onefinity per-motor convention so the
# values are directly comparable to motors 0-3: # values are directly comparable to motors 0-3:
@@ -66,10 +66,34 @@ DEFAULTS = {
'home_slow_sps': 250, # ≈ 10 mm/s 'home_slow_sps': 250, # ≈ 10 mm/s
'home_backoff_steps': 400, # ≈ 16 mm 'home_backoff_steps': 400, # ≈ 16 mm
'home_maxtravel_steps': 200000, 'home_maxtravel_steps': 200000,
# If HOME starts with the limit switch already tripped the ESP
# first moves this many steps away from the limit and then
# rechecks. If the switch is still active afterward, HOME hard-
# fails (refuses to set zero blindly when we may already be past
# the home position). Default ≈ 10 mm @ 25 steps/mm. Set to 0 to
# disable the preclear move (HOME then fails immediately if the
# switch reads active at start, matching the original behaviour).
'home_preclear_mm': 10.0,
'step_max_sps': 4000, # ≈ 160 mm/s normal-move cap 'step_max_sps': 4000, # ≈ 160 mm/s normal-move cap
'step_accel_sps2': 12000, 'step_accel_sps2': 12000,
'step_start_sps': 200, 'step_start_sps': 200,
'limit_low': True, 'limit_low': True,
# ------------------------------------------------------------------
# Z-A coupling interlock
# ------------------------------------------------------------------
# The auxiliary A axis carries a tool that physically hangs below
# the Z-axis spindle nose. Beyond a certain Z descent the two
# collide unless A drops with Z. The constraint, in machine coords,
# is:
# A_machine - Z_machine <= K
# where K = (A_home_mm - z_home_mm) + couple_z_clearance_mm.
# When enabled this is enforced everywhere motion can be
# initiated (planner, MDI, jog, file load) and the AuxPreprocessor
# injects pre-position A moves before Z descends past the safe
# band.
'couple_z_enabled': True,
'couple_z_clearance_mm': 22.0, # Z drop allowed before A must follow
'z_home_mm': 0.0, # Z's machine position when homed
} }
@@ -120,23 +144,61 @@ class AuxAxis(object):
def _config_path(self): def _config_path(self):
return self.ctrl.get_path(filename='aux.json') return self.ctrl.get_path(filename='aux.json')
# Legacy aux.json fields that have been renamed for clarity.
# Loaded values are migrated up on every load/save so existing
# installs keep working without operator intervention.
_LEGACY_FIELD_MAP = {
'min_w': 'min_mm',
'max_w': 'max_mm',
}
def _migrate_legacy_fields(self, cfg):
"""In-place rename of legacy keys in `cfg` (dict). Returns
True if anything was migrated, so callers can decide whether
to persist the upgraded form.
"""
migrated = False
for old, new in self._LEGACY_FIELD_MAP.items():
if old in cfg:
if new not in cfg:
cfg[new] = cfg[old]
del cfg[old]
migrated = True
return migrated
def _load_config(self): def _load_config(self):
path = self._config_path() path = self._config_path()
if os.path.exists(path): if os.path.exists(path):
try: try:
with open(path) as f: with open(path) as f:
user = json.load(f) user = json.load(f)
migrated = self._migrate_legacy_fields(user)
# Be permissive; ignore unknown keys. # Be permissive; ignore unknown keys.
for k, v in user.items(): for k, v in user.items():
if k in self._cfg: if k in self._cfg:
self._cfg[k] = v self._cfg[k] = v
self.log.info('Loaded aux config from %s' % path) self.log.info('Loaded aux config from %s' % path)
if migrated:
# Persist the upgraded form so future restarts
# see the new field names directly.
try:
self.save_config(self._cfg)
self.log.info(
'Migrated aux.json legacy fields '
'(min_w/max_w -> min_mm/max_mm)')
except Exception:
self.log.warning(
'Could not persist aux.json migration')
except Exception: except Exception:
self.log.error('Failed to read aux.json: %s' self.log.error('Failed to read aux.json: %s'
% traceback.format_exc()) % traceback.format_exc())
def save_config(self, cfg): def save_config(self, cfg):
merged = dict(DEFAULTS) merged = dict(DEFAULTS)
# Accept legacy keys from callers that may still send the
# old names (older UI bundles, hand-edited POSTs).
cfg = dict(cfg)
self._migrate_legacy_fields(cfg)
for k, v in cfg.items(): for k, v in cfg.items():
if k in DEFAULTS: if k in DEFAULTS:
merged[k] = v merged[k] = v
@@ -317,13 +379,13 @@ class AuxAxis(object):
raise AuxAxisError('Aux axis not connected') raise AuxAxisError('Aux axis not connected')
def _check_limits(self, target_mm): def _check_limits(self, target_mm):
lo = float(self._cfg['min_w']) lo = float(self._cfg['min_mm'])
hi = float(self._cfg['max_w']) hi = float(self._cfg['max_mm'])
if hi <= lo: if hi <= lo:
return # no limits return # no limits
if target_mm < lo - 1e-6 or target_mm > hi + 1e-6: if target_mm < lo - 1e-6 or target_mm > hi + 1e-6:
raise AuxAxisError( raise AuxAxisError(
'W=%.3f out of soft limits [%.3f, %.3f]' % (target_mm, lo, hi)) 'A=%.3f out of soft limits [%.3f, %.3f]' % (target_mm, lo, hi))
def _mm_to_steps(self, mm): def _mm_to_steps(self, mm):
spm = float(self._cfg['steps_per_mm']) spm = float(self._cfg['steps_per_mm'])
@@ -356,6 +418,64 @@ class AuxAxis(object):
raise AuxAxisError('W move aborted by limit switch') raise AuxAxisError('W move aborted by limit switch')
raise AuxAxisError('W move aborted: %s' % line) raise AuxAxisError('W move aborted: %s' % line)
def _do_line(self, signed_steps, length_mm,
max_accel_mm_min2, max_jerk_mm_min3,
entry_vel_mm_min, exit_vel_mm_min,
times_min, ignore_limits=False, timeout=300.0):
"""Run a 7-segment jerk-limited S-curve on the ESP that mirrors
gplan/buildbotics' planner output exactly.
Parameters are in the same units the AVR/gplan use:
- length_mm: absolute travel in mm (>= 0)
- max_accel: mm/min^2
- max_jerk: mm/min^3
- entry/exit_vel: mm/min
- times_min: 7-tuple of section durations in minutes
ignore_limits sets safe=0 on the ESP - used for jog/move
endpoints that may run before homing.
Blocks until the ESP reports done or aborted. Updates the
position mirror and re-publishes state on every reply.
"""
if signed_steps == 0 or length_mm <= 0:
return
if not any(times_min):
raise AuxAxisError('LINE rejected: all section times are zero')
# Build the LINE command. Float formatting matches the AVR's
# printf precision (6 sig figs) - that's well above what the
# ESP needs given it integrates into a few thousand 4 ms
# segments per move.
parts = [
'LINE',
'steps=%d' % int(signed_steps),
'length=%.6f' % float(length_mm),
'max_accel=%.6f' % float(max_accel_mm_min2),
'max_jerk=%.6f' % float(max_jerk_mm_min3),
'entry_vel=%.6f' % float(entry_vel_mm_min),
'exit_vel=%.6f' % float(exit_vel_mm_min),
]
for i, t in enumerate(times_min):
if t and t > 0:
parts.append('t%d=%.9f' % (i, float(t)))
if ignore_limits:
parts.append('safe=0')
cmd = ' '.join(parts)
line = self._rpc(cmd, topic='line', timeout=timeout)
# line: "done pos=P emitted=N" or "aborted pos=P emitted=N reason=..."
if line.startswith('done'):
self._pos_steps = self._parse_kv_int(line, 'pos', self._pos_steps)
self._publish_state()
return
# aborted
self._pos_steps = self._parse_kv_int(line, 'pos', self._pos_steps)
self._publish_state()
reason = self._parse_kv_str(line, 'reason')
if reason == 'limit':
self._homed = False
raise AuxAxisError('W move aborted by limit switch')
raise AuxAxisError('W move aborted: %s' % line)
# ------------------------------------------------------------ serial I/O # ------------------------------------------------------------ serial I/O
def _open(self): def _open(self):
@@ -388,8 +508,14 @@ class AuxAxis(object):
def _push_homecfg(self): def _push_homecfg(self):
c = self._cfg c = self._cfg
zero_steps = self._mm_to_steps(c['home_position_mm']) zero_steps = self._mm_to_steps(c['home_position_mm'])
# preclear: how far (in steps) the ESP backs off if HOME is
# invoked while the limit switch is already tripped. Computed
# from home_preclear_mm so the operator configures it in mm.
spm = float(c.get('steps_per_mm', 1.0)) or 1.0
preclear_steps = int(round(abs(float(c['home_preclear_mm'])) * spm))
cmd = ('HOMECFG dir=%s fast=%d slow=%d backoff=%d maxtravel=%d ' cmd = ('HOMECFG dir=%s fast=%d slow=%d backoff=%d maxtravel=%d '
'zero=%d accel=%d step_max=%d step_start=%d limit_low=%d') % ( 'zero=%d accel=%d step_max=%d step_start=%d limit_low=%d '
'preclear=%d') % (
c['home_dir'], c['home_dir'],
int(c['home_fast_sps']), int(c['home_fast_sps']),
int(c['home_slow_sps']), int(c['home_slow_sps']),
@@ -400,6 +526,7 @@ class AuxAxis(object):
int(c['step_max_sps']), int(c['step_max_sps']),
int(c['step_start_sps']), int(c['step_start_sps']),
1 if c['limit_low'] else 0, 1 if c['limit_low'] else 0,
preclear_steps,
) )
self._rpc(cmd, topic='homecfg', timeout=3.0) self._rpc(cmd, topic='homecfg', timeout=3.0)
@@ -409,11 +536,28 @@ class AuxAxis(object):
self._pos_steps = int(r.strip()) self._pos_steps = int(r.strip())
except Exception: except Exception:
pass pass
# Force the host to start unhomed regardless of what the ESP
# remembers from a prior session. The ESP's homed flag survives
# bbctrl restarts (since the ESP itself wasn't power-cycled),
# but the host's planner offsets and DRO position get reset to
# zero on bbctrl boot. Trusting the ESP's homed flag would mean
# the user thinks A is homed at the wrong work-coord origin
# (offset_a=0 but ESP physically at home_position_mm). Sending
# UNHOME forces the user to re-home explicitly, which sets up
# the offset and gplan state correctly via the homing path in
# Mach.home.
try: try:
r = self._rpc('HOMED?', topic='homed', timeout=2.0) self._rpc('UNHOME', topic='ok', timeout=2.0)
self._homed = (r.strip() == '1') self._homed = False
except Exception: except Exception:
pass # Fall back to whatever HOMED? says - but treat any
# missing UNHOME support as "trust ESP's flag" so we
# don't break older firmware.
try:
r = self._rpc('HOMED?', topic='homed', timeout=2.0)
self._homed = (r.strip() == '1')
except Exception:
pass
self._publish_state() self._publish_state()
def _reader_loop(self): def _reader_loop(self):
@@ -450,7 +594,7 @@ class AuxAxis(object):
self._present = True self._present = True
self._publish_state() self._publish_state()
self.ctrl.state.add_message( self.ctrl.state.add_message(
'W axis controller restarted - re-home before use') 'Auxiliary axis controller restarted - re-home before use')
return return
# Topic dispatch: "[topic] body..." # Topic dispatch: "[topic] body..."

View File

@@ -59,15 +59,55 @@ _ATC_M_RE = re.compile(
# A axis through gplan.) # A axis through gplan.)
_W_TOKEN_RE = re.compile(r'(?<![A-Za-z_0-9])[Ww]\s*[-+]?\d*\.?\d+') _W_TOKEN_RE = re.compile(r'(?<![A-Za-z_0-9])[Ww]\s*[-+]?\d*\.?\d+')
# Match a single axis word (letter + optional whitespace + signed decimal)
# for Z, A, X, Y. Used to extract modal targets while preserving the
# original line for emission. We deliberately ignore I/J/K/R (arc params)
# because they're not endpoints.
_AXIS_TOKEN_RES = {
'z': re.compile(r'(?<![A-Za-z_0-9])[Zz]\s*([-+]?\d*\.?\d+)'),
'a': re.compile(r'(?<![A-Za-z_0-9])[Aa]\s*([-+]?\d*\.?\d+)'),
'x': re.compile(r'(?<![A-Za-z_0-9])[Xx]\s*([-+]?\d*\.?\d+)'),
'y': re.compile(r'(?<![A-Za-z_0-9])[Yy]\s*([-+]?\d*\.?\d+)'),
}
_G_CODE_RE = re.compile(r'(?<![A-Za-z_0-9])[Gg]\s*0*(\d+(?:\.\d+)?)')
class AuxPreprocessorError(Exception): class AuxPreprocessorError(Exception):
pass pass
class AuxPreprocessor(object): class AuxPreprocessor(object):
def __init__(self, log=None): def __init__(self, log=None, coupling=None):
"""`coupling`, when supplied, enables Z-A coupling injection.
Expected shape:
{
'enabled': bool,
'clearance_mm': float, # max (A_wc - Z_wc)
'a_initial_wc': float, # A's work-coord position at
# file start (typically 0 if
# operator zeroed at home)
'z_initial_wc': float, # Z's work-coord position at
# file start (typically 0)
}
Pass None to disable injection (preprocessor still rewrites
ATC M-codes)."""
self.log = log self.log = log
self._w_warned = False self._w_warned = False
self._coupling = coupling if (coupling and
coupling.get('enabled')) else None
# Modal state used while scanning the file.
if self._coupling is not None:
self._a_wc = float(coupling.get('a_initial_wc', 0.0))
self._z_wc = float(coupling.get('z_initial_wc', 0.0))
self._K = float(coupling.get('clearance_mm', 0.0))
else:
self._a_wc = 0.0
self._z_wc = 0.0
self._K = 0.0
self._g91_warned = False
# Distance mode: True for absolute (G90), False for incremental
# (G91). Per RS274 the modal default at start is G90.
self._g90 = True
def _info(self, msg): def _info(self, msg):
if self.log: self.log.info(msg) if self.log: self.log.info(msg)
@@ -78,9 +118,12 @@ class AuxPreprocessor(object):
# ------------------------------------------------------------------ scan # ------------------------------------------------------------------ scan
@staticmethod @staticmethod
def file_uses_aux(path): def file_uses_aux(path, coupling=None):
"""Quick check: does this file contain anything the preprocessor """Quick check: does this file contain anything the preprocessor
would rewrite (currently: just ATC M-codes)?""" would rewrite? Returns True for ATC M-codes always, and for
any Z/A move if coupling is enabled (we have to scan to know
whether injection is needed, so any motion file qualifies)."""
couple_active = bool(coupling and coupling.get('enabled'))
try: try:
with open(path, 'r', encoding='utf-8', errors='replace') as f: with open(path, 'r', encoding='utf-8', errors='replace') as f:
for line in f: for line in f:
@@ -88,6 +131,10 @@ class AuxPreprocessor(object):
code = code.split(';', 1)[0] code = code.split(';', 1)[0]
if _ATC_M_RE.search(code): if _ATC_M_RE.search(code):
return True return True
if couple_active:
if _AXIS_TOKEN_RES['z'].search(code) or \
_AXIS_TOKEN_RES['a'].search(code):
return True
except Exception: except Exception:
pass pass
return False return False
@@ -95,6 +142,170 @@ class AuxPreprocessor(object):
# Backwards-compat alias. # Backwards-compat alias.
file_uses_w = file_uses_aux file_uses_w = file_uses_aux
# ------------------------------------------------------------------ Z-A coupling
#
# Track modal Z and A targets across the file. Whenever a line
# would put A above Z by more than `clearance_mm` (i.e. A_wc -
# Z_wc > K), we inject `G0 A<safe>` immediately before it so A is
# already at the safe position when Z descends. The injected move
# uses G0 (rapid) so it's quick.
#
# Endpoint-only check: gplan plans line endpoints. As long as
# (target_A_wc - target_Z_wc) <= K, the trajectory stays safe
# because Z's *minimum* during a single line is its endpoint (Z
# moves monotonically along a single line block in absolute
# mode) and A is held at the pre-positioned value during the move.
def _extract_g_codes(self, code):
"""Return the set of G-codes referenced on `code`. Numeric
only, e.g. {0, 1, 90, 17}. Used to track modal state."""
out = set()
for m in _G_CODE_RE.finditer(code):
try:
out.add(int(float(m.group(1))))
except Exception:
pass
return out
def _extract_axis(self, axis, code):
"""Return the last value of `axis` token on `code`, or None."""
rx = _AXIS_TOKEN_RES.get(axis)
if rx is None:
return None
last = None
for m in rx.finditer(code):
try:
last = float(m.group(1))
except Exception:
pass
return last
def _maybe_inject_a_down(self, code, fout):
"""Inspect `code` (with comments stripped) for an upcoming Z
descent; emit a `G0 A<safe>` line on `fout` if needed and
update self._a_wc accordingly. Returns True if anything was
injected.
On a violation that cannot be fixed by lowering A (e.g. the
operator wrote `G0 A0` while Z is too deep), raise
AuxPreprocessorError so the file load surfaces the problem -
per the rule we agreed: error, don't silently insert a Z-up.
"""
if self._coupling is None:
return False
# Distance mode tracking.
gs = self._extract_g_codes(code)
if 90 in gs: self._g90 = True
if 91 in gs:
if self._g90 and not self._g91_warned:
self._warn(
'AuxPreprocessor: G91 (incremental mode) detected; '
'Z-A coupling injection is disabled for the rest of '
'the file. The runtime check still applies.')
self._g91_warned = True
self._g90 = False
# G92 sets coordinate offsets. The new modal value of an
# axis is whatever value follows on the same word (e.g.
# G92 A0 sets A_wc = 0). Apply that and skip injection.
if 92 in gs:
new_a = self._extract_axis('a', code)
new_z = self._extract_axis('z', code)
if new_a is not None: self._a_wc = new_a
if new_z is not None: self._z_wc = new_z
return False
# In incremental mode we can still track approximately, but
# the user has been warned; skip injection.
if not self._g90:
return False
new_z_target = self._extract_axis('z', code)
new_a_target = self._extract_axis('a', code)
if new_z_target is None and new_a_target is None:
return False
# Modal values after the line executes.
a_after = new_a_target if new_a_target is not None else self._a_wc
z_after = new_z_target if new_z_target is not None else self._z_wc
eps = 1e-4
if a_after - z_after <= self._K + eps:
# Move is safe as authored. Update modal state.
self._a_wc = a_after
self._z_wc = z_after
return False
# Violation. Two cases:
#
# (a) The line lowers Z (z_after < self._z_wc) and A is
# held or moved upward, so A needs to drop to keep up.
# We can fix this by pre-positioning A at z_after + K
# BEFORE the line - at which point gplan's plan for the
# line is safe at every point along it.
#
# (b) The line raises A above the safe band while Z is
# held (z_after >= self._z_wc) - i.e. the operator
# wrote `G0 A0` while Z is parked deep. Auto-injecting
# a Z-up here is unsafe (Z could swing into a fixture
# or the part) so we error out and let the operator
# author the lift.
safe_a = z_after + self._K
# If the line itself targets an A above the safe band, the
# endpoint violates the rule no matter what we pre-position.
# Refuse rather than emit something that runs the gantry into
# the tool.
if new_a_target is not None and new_a_target > safe_a + eps:
raise AuxPreprocessorError(
'Z-A coupling violation: line targets A=%.3f at '
'Z=%.3f, but max A allowed is %.3f (clearance %.3f). '
'Lower the A target or add a Z-up move first.' % (
new_a_target, z_after, safe_a, self._K))
# If the line raises A above the current safe band but Z
# isn't dropping with it (no Z target on the line, or Z stays
# put), the violation is the operator's A-up, not a Z-down.
# Refuse rather than insert a Z-up (which could swing through
# a fixture or part).
if (new_a_target is not None and
new_a_target > self._a_wc + eps and
new_z_target is None):
raise AuxPreprocessorError(
'Z-A coupling violation at line raising A to %.3f '
'while Z is at %.3f (max A allowed is %.3f given '
'clearance %.3f). Add a Z-up move first.' % (
new_a_target, z_after, safe_a, self._K))
# Case (a): pre-position A.
# Don't move A *up* as part of pre-position - if the safe
# value is above where A already is, we'd lift A into a
# potential collision elsewhere. In practice safe_a < a_wc
# whenever we get here (otherwise no violation), but assert
# to be sure.
if safe_a > self._a_wc + eps:
raise AuxPreprocessorError(
'Z-A coupling: cannot fix line by lowering A '
'(safe A = %.3f > current A = %.3f).' % (
safe_a, self._a_wc))
fout.write('(injected by AuxPreprocessor: Z-A coupling)\n')
fout.write('G0 A%.4f\n' % safe_a)
self._a_wc = safe_a
# Don't update z_wc yet - the original line will do that
# when it runs. But our modal copy must reflect the post-line
# value so subsequent injections compute correctly.
self._z_wc = z_after
# If the original line also moved A, our pre-positioning
# supersedes it (we overwrite a_wc above with safe_a then
# the original line's A target may push it back up). Update
# a_wc to the line's authored A value so further checks see
# the post-line state.
if new_a_target is not None:
self._a_wc = new_a_target
return True
# ------------------------------------------------------------------ run # ------------------------------------------------------------------ run
def process(self, src_path, dst_path): def process(self, src_path, dst_path):
@@ -124,6 +335,10 @@ class AuxPreprocessor(object):
'subsequent W tokens in this file)') 'subsequent W tokens in this file)')
self._w_warned = True self._w_warned = True
# Z-A coupling injection BEFORE the line is emitted.
if self._maybe_inject_a_down(code, fout):
rewrote_any = True
# ATC M-codes (M100-M103). Each ATC M-code on the line # ATC M-codes (M100-M103). Each ATC M-code on the line
# is replaced with its (MSG,HOOK:<event>:) line and # is replaced with its (MSG,HOOK:<event>:) line and
# stripped from the residual. # stripped from the residual.
@@ -156,15 +371,17 @@ class AuxPreprocessor(object):
return rewrote_any return rewrote_any
def preprocess_file(src_path, log=None, **_unused): def preprocess_file(src_path, log=None, coupling=None, **_unused):
"""Convenience: rewrite src_path in place if it contains ATC """Convenience: rewrite src_path in place if it contains ATC
M-codes. Returns True if the file was rewritten. M-codes or needs Z-A coupling injection. Returns True if the
file was rewritten.
`coupling` is an optional dict (see AuxPreprocessor.__init__).
Extra keyword args are accepted for backwards compat (the old Extra keyword args are accepted for backwards compat (the old
w_first arg is no longer used).""" w_first arg is no longer used)."""
if not AuxPreprocessor.file_uses_aux(src_path): if not AuxPreprocessor.file_uses_aux(src_path, coupling=coupling):
return False return False
pre = AuxPreprocessor(log=log) pre = AuxPreprocessor(log=log, coupling=coupling)
fd, tmp = tempfile.mkstemp(prefix='auxpre_', suffix='.nc', fd, tmp = tempfile.mkstemp(prefix='auxpre_', suffix='.nc',
dir=os.path.dirname(src_path) or None) dir=os.path.dirname(src_path) or None)
os.close(fd) os.close(fd)

View File

@@ -216,6 +216,32 @@ class Config(object):
defaults = json.load(f) defaults = json.load(f)
config['selected-tool-settings'] = defaults['selected-tool-settings']; config['selected-tool-settings'] = defaults['selected-tool-settings'];
# Auxiliary axis nomenclature: rename W -> A in macro names and
# filenames. The auxcnc-driven stepper has been integrated into
# gplan as A since the option-b migration; old configs may
# still carry W Down/W Up macro entries pointing at
# w_down.nc/w_up.nc which were renamed on disk to a_down.nc /
# a_up.nc. Migrate idempotently on every load so a stale
# in-memory copy can never reintroduce the old names.
macros = config.get('macros') if isinstance(config, dict) else None
if isinstance(macros, list):
renames = {
'w_down.nc': 'a_down.nc',
'w_up.nc': 'a_up.nc',
}
display_renames = {
'W Down': 'A Down',
'W Up': 'A Up',
}
for m in macros:
if not isinstance(m, dict): continue
fn = m.get('file_name')
if isinstance(fn, str) and fn in renames:
m['file_name'] = renames[fn]
nm = m.get('name')
if isinstance(nm, str) and nm in display_renames:
m['name'] = display_renames[nm]
config['version'] = self.version.split('b')[0] config['version'] = self.version.split('b')[0]
config['full_version'] = self.version config['full_version'] = self.version

View File

@@ -140,6 +140,192 @@ class ExternalAxis(object):
except Exception: except Exception:
return 0.0 return 0.0
# ------------------------------------------------------- soft limits
def _soft_limits(self):
"""Return (min_mm, max_mm) in machine coords, or (None, None)
if soft limits are disabled (max <= min)."""
try:
lo = float(self.aux._cfg.get('min_mm', 0.0))
hi = float(self.aux._cfg.get('max_mm', 0.0))
except Exception:
return (None, None)
if hi <= lo:
return (None, None)
return (lo, hi)
def _check_soft_limit(self, target_abs_mm):
"""Raise ExternalAxisError if target_abs_mm is outside the
configured soft limits. Skips the check when the axis isn't
homed (matching the standard bbctrl convention that soft
limits are gated by homing state) - that lets the user jog
away from a stuck position before homing without false
rejections.
Called by both planner-driven motion (enqueue_target_mm) and
UI motion (execute_to_mm), so this is the single source of
truth regardless of which path triggered the move."""
# Honour the homing gate.
try:
homed = bool(self.aux._homed)
except Exception:
homed = False
if not homed:
return
lo, hi = self._soft_limits()
if lo is None:
return
# Use a tiny epsilon so floating-point round-trip targets
# right at the boundary aren't rejected.
eps = 1e-4
target = float(target_abs_mm)
if target < lo - eps or target > hi + eps:
raise ExternalAxisError(
'%s axis target %.4f mm is outside soft limits '
'[%.3f, %.3f] mm' % (
self.axis_letter.upper(), target, lo, hi))
# ----------------------------------------------- Z-A coupling
#
# The auxiliary tool hangs below the Z spindle. Beyond a small
# Z descent the two collide unless A drops with Z. The
# constraint, in machine coords, is
#
# A_machine - Z_machine <= K
# K = (A_home_mm - z_home_mm) + couple_z_clearance_mm
#
# Enforced before any motion (planner blocks, MDI, jogs). The
# AuxPreprocessor injects pre-position A moves into uploaded
# files so well-formed gcode runs without having to think about
# this. Disabled when couple_z_enabled is false.
@property
def couple_z_enabled(self):
try:
return bool(self.aux._cfg.get('couple_z_enabled', False))
except Exception:
return False
@property
def couple_K(self):
"""Limit constant K (machine-coord units): the maximum value
of (A_machine - Z_machine) before the tool collides. Returns
None if the rule isn't applicable (coupling disabled or
config missing)."""
try:
cfg = self.aux._cfg
clearance = float(cfg.get('couple_z_clearance_mm', 0.0))
a_home = float(cfg.get('home_position_mm', 0.0))
z_home = float(cfg.get('z_home_mm', 0.0))
return (a_home - z_home) + clearance
except Exception:
return None
@property
def couple_clearance_mm(self):
"""Raw clearance from config: how far Z may travel below its
home before A has to start dropping with it. Used by the
AuxPreprocessor to inject pre-position A moves into uploaded
gcode."""
try:
return float(self.aux._cfg.get('couple_z_clearance_mm', 0.0))
except Exception:
return 0.0
def _z_machine_now(self):
"""Read Z's current machine position from State, or None if
Z isn't homed/reported yet. The AVR reports absolute machine
positions in <axis>p; the work-coord display is computed by
the UI as zp - offset_z, but here we want machine directly."""
try:
st = self.ctrl.state
zp = st.get('zp', None)
if zp is None:
return None
return float(zp)
except Exception:
return None
def _a_machine_now(self):
"""A's current machine position. ExternalAxis tracks this
directly in self._pos_mm (mm in machine coords - we don't
apply G92 to A internally; offset_a is informational)."""
try:
if self._pos_mm is not None:
return float(self._pos_mm)
# Fall back to whatever the ESP last reported.
return float(self.aux.position_mm)
except Exception:
return None
def coupling_for_preprocessor(self):
"""Return the dict the AuxPreprocessor wants for in-file
injection, or None when coupling is off. We assume the
operator authors gcode in a frame where the at-home position
is A_wc=0, Z_wc=0 - which matches our home-zeroed setup.
Files that use a different convention will fall through to
the runtime check."""
if not self.couple_z_enabled:
return None
return {
'enabled': True,
'clearance_mm': self.couple_clearance_mm,
'a_initial_wc': 0.0,
'z_initial_wc': 0.0,
}
def check_coupling(self, target_a_machine=None, target_z_machine=None):
"""Validate that a proposed motion respects the Z-A coupling.
Each argument is a target *machine* mm position; pass None to
keep the current value of that axis.
Improvement-aware: a move is rejected only when it *worsens*
an already-violating state (or moves a healthy state into
violation). Pure XY jogs that touch neither Z nor A are not
passed through here; jogs that hold Z or A at their current
value (gplan emits the unchanged value in `target`) pass
because (a-z) doesn't change. Z-up moves while in violation
also pass because they reduce (a-z) toward the bound.
Raises ExternalAxisError on violation. Skipped when coupling
is disabled, the aux axis isn't homed, or current positions
aren't yet known.
"""
if not self.couple_z_enabled:
return
try:
homed = bool(self.aux._homed)
except Exception:
homed = False
if not homed:
return
K = self.couple_K
if K is None:
return
a_now = self._a_machine_now()
z_now = self._z_machine_now()
if a_now is None or z_now is None:
return
a_after = (float(target_a_machine)
if target_a_machine is not None else a_now)
z_after = (float(target_z_machine)
if target_z_machine is not None else z_now)
eps = 1e-4
gap_after = a_after - z_after
gap_before = a_now - z_now
# Only refuse when (a) the resulting state would violate the
# constraint AND (b) the move makes things at least as bad
# as the current state. This lets the operator escape an
# already-violating state by moving in the right direction
# (Z up, A down).
if gap_after > K + eps and gap_after > gap_before - eps:
raise ExternalAxisError(
'Z-A coupling violation: A=%.3f mm and Z=%.3f mm '
'(machine) would put A above Z by %.3f mm; max '
'allowed is %.3f mm. Drop A or raise Z first.' % (
a_after, z_after, gap_after, K))
# ----------------------------------------------------------- conversion # ----------------------------------------------------------- conversion
def mm_to_steps_delta(self, delta_mm): def mm_to_steps_delta(self, delta_mm):
@@ -185,8 +371,8 @@ class ExternalAxis(object):
# Soft limits in machine units (mm). State.get_soft_limit_vector # Soft limits in machine units (mm). State.get_soft_limit_vector
# returns these directly, no scaling. # returns these directly, no scaling.
st.set(i + 'tn', float(cfg.get('min_w', 0.0))) st.set(i + 'tn', float(cfg.get('min_mm', 0.0)))
st.set(i + 'tm', float(cfg.get('max_w', 0.0))) st.set(i + 'tm', float(cfg.get('max_mm', 0.0)))
# home_position / home_travel are exposed as callbacks for # home_position / home_travel are exposed as callbacks for
# motors 0..3 (see State.__init__). Register the same lazy # motors 0..3 (see State.__init__). Register the same lazy
@@ -197,7 +383,7 @@ class ExternalAxis(object):
i + 'home_position', lambda name: self.home_position_mm) i + 'home_position', lambda name: self.home_position_mm)
st.set_callback( st.set_callback(
i + 'home_travel', i + 'home_travel',
lambda name: float(self.aux._cfg.get('max_w', 0.0)) lambda name: float(self.aux._cfg.get('max_mm', 0.0))
- self.home_position_mm) - self.home_position_mm)
# Misc fields that other code paths might query. Defaults # Misc fields that other code paths might query. Defaults
@@ -265,12 +451,23 @@ class ExternalAxis(object):
"""Synchronously run an external move. Blocks until the ESP """Synchronously run an external move. Blocks until the ESP
reports done. Used by the legacy /api/aux/move and /api/aux/jog reports done. Used by the legacy /api/aux/move and /api/aux/jog
endpoints which may want to wait. Most planner-driven motion endpoints which may want to wait. Most planner-driven motion
goes through enqueue_target_mm instead, which is non-blocking.""" goes through enqueue_target_mm instead, which is non-blocking.
Soft limits are enforced here (not just in gplan) because the
UI jog/move endpoints don't go through the planner.
Updates state.<axis>p immediately on completion. For the
planner-driven path that goes through enqueue_target_mm, the
AVR's own ap reports drive state.<axis>p instead."""
if not self.enabled: if not self.enabled:
raise ExternalAxisError( raise ExternalAxisError(
'External axis %r not available (aux disabled or ' 'External axis %r not available (aux disabled or '
'not connected)' % self.axis_letter) 'not connected)' % self.axis_letter)
self._check_soft_limit(ext_mm)
# Coupling: A is in machine coords directly (we don't apply
# a G92 offset to A), so target_a_machine == ext_mm.
self.check_coupling(target_a_machine=ext_mm)
steps, abs_mm = self._compute_move(ext_mm) steps, abs_mm = self._compute_move(ext_mm)
if steps == 0: if steps == 0:
self._pos_mm = abs_mm self._pos_mm = abs_mm
@@ -286,31 +483,69 @@ class ExternalAxis(object):
self._busy.clear() self._busy.clear()
def enqueue_target_mm(self, ext_mm): def enqueue_target_mm(self, ext_mm):
"""Non-blocking variant: post a target to the worker queue """Legacy non-blocking variant: post a fixed-rate STEPS move
and update the host's notion of the axis position immediately to the worker queue. No longer used by Planner.__encode (which
so subsequent line splits compute correct deltas. uses enqueue_line for full S-curve mirroring), but kept for
UI jog endpoints that don't have planner timing data.
The Planner.__encode hook calls this so the AVR comm thread Soft limits are enforced here (defense in depth on top of
is never blocked by serial RPCs to the ESP. v1 accepts that gplan)."""
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: if not self.enabled:
raise ExternalAxisError( raise ExternalAxisError(
'External axis %r not available' % self.axis_letter) 'External axis %r not available' % self.axis_letter)
self._check_soft_limit(ext_mm)
self.check_coupling(target_a_machine=ext_mm)
steps, abs_mm = self._compute_move(ext_mm) steps, abs_mm = self._compute_move(ext_mm)
# Update host position immediately so the next line block # Internal mirror only - drives subsequent delta computation.
# sees the new absolute target as the starting point. # state.<axis>p is left to the AVR's status reports.
self._pos_mm = abs_mm self._pos_mm = abs_mm
self.ctrl.state.set(self.axis_letter + 'p', self._pos_mm)
if steps == 0: if steps == 0:
return 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)) self._work_q.put(('move', steps))
def enqueue_line(self, ext_mm, max_accel_mm_min2, max_jerk_mm_min3,
entry_vel_mm_min, exit_vel_mm_min, times_ms):
"""Post a full S-curve LINE block to the ESP worker. Mirrors
gplan's planned trajectory exactly (same 7-segment math, same
unit system) so the ESP's move duration matches what the AVR
would have produced for an A motor.
Called by Planner.__encode for every line block that touches
the external axis.
Parameters:
ext_mm: absolute target in mm (gplan target['a'])
max_accel_mm_min2:from block['max-accel']
max_jerk_mm_min3: from block['max-jerk']
entry_vel_mm_min: from block['entry-vel'] (typically 0 for
the first block, exit_vel of the prior
block otherwise)
exit_vel_mm_min: from block['exit-vel']
times_ms: 7-tuple of section durations in ms
(block['times'] - the same units gplan uses)
"""
if not self.enabled:
raise ExternalAxisError(
'External axis %r not available' % self.axis_letter)
self._check_soft_limit(ext_mm)
self.check_coupling(target_a_machine=ext_mm)
steps, abs_mm = self._compute_move(ext_mm)
delta_mm = abs(abs_mm - (self._pos_mm if self._pos_mm is not None
else 0.0))
# Update internal mirror; AVR drives state.<axis>p.
self._pos_mm = abs_mm
if steps == 0 or delta_mm <= 0:
return
# ms -> minutes (the unit gplan/AVR/ESP use internally for
# SCurve math).
times_min = tuple((t / 60000.0) if t else 0.0 for t in times_ms)
self._work_q.put(('line', steps, delta_mm,
float(max_accel_mm_min2),
float(max_jerk_mm_min3),
float(entry_vel_mm_min),
float(exit_vel_mm_min),
times_min))
def _compute_move(self, ext_mm): def _compute_move(self, ext_mm):
"""Return (signed_steps, absolute_mm) for a target in mm. """Return (signed_steps, absolute_mm) for a target in mm.
Caches first-time position from the ESP.""" Caches first-time position from the ESP."""
@@ -338,6 +573,15 @@ class ExternalAxis(object):
if kind == 'move': if kind == 'move':
steps = op[1] steps = op[1]
self.aux._do_steps(steps, ignore_limits=True) self.aux._do_steps(steps, ignore_limits=True)
elif kind == 'line':
(_, steps, length_mm,
max_accel, max_jerk,
entry_vel, exit_vel,
times_min) = op
self.aux._do_line(
steps, length_mm, max_accel, max_jerk,
entry_vel, exit_vel, times_min,
ignore_limits=True)
elif kind == 'home': elif kind == 'home':
self.aux.home() self.aux.home()
# _pos_mm and DRO updated by the caller's enqueue. # _pos_mm and DRO updated by the caller's enqueue.

View File

@@ -109,9 +109,13 @@ class FileHandler(bbctrl.APIHandler):
try: try:
from bbctrl.AuxPreprocessor import preprocess_file from bbctrl.AuxPreprocessor import preprocess_file
log = self.get_log('AuxPreprocessor') log = self.get_log('AuxPreprocessor')
if preprocess_file(filename.decode('utf8'), log=log): ext = getattr(self.get_ctrl(), 'ext_axis', None)
log.info('Rewrote ATC M-codes in %s' % coupling = (ext.coupling_for_preprocessor()
self.uploadFilename) if ext is not None else None)
if preprocess_file(filename.decode('utf8'),
log=log, coupling=coupling):
log.info('Rewrote upload (ATC / Z-A coupling) in %s'
% self.uploadFilename)
except Exception: except Exception:
self.get_log('AuxPreprocessor').exception( self.get_log('AuxPreprocessor').exception(
'Aux preprocess failed; uploading unchanged') 'Aux preprocess failed; uploading unchanged')

View File

@@ -95,6 +95,10 @@ class Mach(Comm):
self.planner = bbctrl.Planner(ctrl) self.planner = bbctrl.Planner(ctrl)
self.unpausing = False self.unpausing = False
self.stopping = False self.stopping = False
# Guard against overlapping deferred-external-homing threads
# if the user clicks Home (All) again while the previous run
# is still waiting for the AVR cycle to finish.
self._ext_home_thread = None
ctrl.state.set('cycle', 'idle') ctrl.state.set('cycle', 'idle')
@@ -327,6 +331,16 @@ class Mach(Comm):
axes = 'zxybc' if is_rotary_active else 'zxyabc' # TODO This should be configurable axes = 'zxybc' if is_rotary_active else 'zxyabc' # TODO This should be configurable
else: axes = '%c' % axis else: axes = '%c' % axis
# Collect external axes here and process them *after* every
# AVR axis above has finished its homing cycle. Without this,
# the AVR is still running Z/X/Y homing G-code in the
# planner queue while ext.home() synchronously drives the ESP
# to home A in parallel - which is unsafe (the gantry and W
# axis can move at the same time) and visually confusing.
# We defer external homing to a background thread that
# polls cycle until the AVR cycle completes.
external_pending = []
for axis in axes: for axis in axes:
enabled = state.is_axis_enabled(axis) enabled = state.is_axis_enabled(axis)
mode = state.axis_homing_mode(axis) mode = state.axis_homing_mode(axis)
@@ -334,27 +348,33 @@ class Mach(Comm):
# External axes (e.g. the auxcnc-driven A axis) home via # External axes (e.g. the auxcnc-driven A axis) home via
# their own ESP-side homing routine; the standard # their own ESP-side homing routine; the standard
# G28.2 / G38.6 / latch sequence doesn't apply. # G28.2 / G38.6 / latch sequence doesn't apply.
#
# After homing we want a deterministic outcome regardless
# of where the user was before:
# physical position = home_position_mm (e.g. 134 mm)
# work-coord origin = home position (user A = 0)
# work offset = home_position_mm (so abs - off = 0)
#
# ext.home() blocks on the ESP and updates state.ap to
# home_position_mm. We then need to tell the AVR (so its
# ex.position[A] matches physical reality) and gplan
# (so trajectory planning sees abs at home).
#
# We deliberately avoid G28.3 here: gplan's G28.3 keeps the
# current user-coord position fixed and adjusts the offset
# to match the new abs, which means re-homing after a move
# accumulates offset (134 -> 268 -> ...). Using G92 a0
# *after* syncing abs gives the desired "user A = 0 here"
# outcome with offset = home_position every time.
ext = getattr(self.ctrl, 'ext_axis', None) ext = getattr(self.ctrl, 'ext_axis', None)
if ext is not None and ext.enabled \ if ext is not None and ext.enabled \
and ext.axis_letter == axis.lower(): and ext.axis_letter == axis.lower():
if 1 < len(axes) and not enabled: if 1 < len(axes) and not enabled:
continue continue
self.mlog.info('Homing external %s axis via auxcnc' % # Defer until AVR axes are done. We capture the axis
axis.upper()) # letter and ext reference; the actual homing runs
# ext.home() blocks on the ESP. Use the lower-level # in _run_external_homing below.
# planner.mdi (not Mach.mdi) so we don't try to external_pending.append((axis, ext))
# _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 continue
# If this is not a request to home a specific axis and the # If this is not a request to home a specific axis and the
@@ -387,6 +407,122 @@ class Mach(Comm):
self.planner.mdi(gcode, False) self.planner.mdi(gcode, False)
super().resume() super().resume()
# Kick off the deferred external-axis homing on a background
# thread so we don't block the HTTP handler (which is on the
# IOLoop) waiting for the AVR cycle to finish.
if external_pending:
prev = self._ext_home_thread
if prev is not None and prev.is_alive():
self.mlog.info(
'External homing already in progress; ignoring '
'duplicate request')
else:
import threading
t = threading.Thread(
target=self._run_external_homing,
args=(list(external_pending),),
name='ext-home-deferred',
daemon=True)
self._ext_home_thread = t
t.start()
def _run_external_homing(self, pending):
"""Background worker: wait for the AVR cycle to drop to idle
(meaning all queued AVR-side homing is done), then run each
deferred external-axis home in order.
We split the work between two threads:
- this background thread blocks on the ESP serial RPC
(ext.home(), which can take 5-10 seconds while the
carriage seeks the limit and backs off twice);
- small bookkeeping operations that touch gplan, the AVR
command queue, or shared State are scheduled back onto
the IOLoop via ctrl.ioloop.add_callback() so we don't
race with the rest of the controller.
"""
import time
# Wait up to 5 minutes for the AVR cycle to leave 'homing'.
# Long enough for any reasonable Onefinity full-travel home
# (Y axis at slow rate covers ~800 mm).
deadline = time.time() + 300.0
while time.time() < deadline:
cycle = self._get_cycle()
# 'homing' is the AVR's homing cycle; we wait for it to
# return to idle. If the user estopped or the cycle was
# aborted, cycle goes to idle too - we still proceed and
# the external home will fail-soft if conditions are wrong.
if cycle == 'idle':
break
time.sleep(0.1)
else:
self.mlog.error(
'External axis homing aborted: AVR cycle did not '
'return to idle within timeout')
return
for axis, ext in pending:
self.mlog.info('Homing external %s axis via auxcnc' %
axis.upper())
# Begin the cycle on the IOLoop so cycle-state writes go
# through the same thread that all other state writes do.
self.ctrl.ioloop.add_callback(self._begin_cycle, 'homing')
try:
# ext.home() runs on this background thread - it
# blocks on serial I/O and is fully thread-safe (the
# AuxAxis driver has its own RPC lock).
ext.home()
home_mm = ext.home_position_mm
# All of the post-home bookkeeping touches gplan and
# the AVR command queue, both of which run on the
# IOLoop. Schedule it there in a single callback so
# the steps run in order without intervening events.
self.ctrl.ioloop.add_callback(
self._finish_external_home, axis, home_mm)
except Exception as e:
self.mlog.error(
'External axis homing failed: %s' % e)
# Cycle reset must also happen on the IOLoop. Without
# this the UI stays locked at 'homing' since the AVR
# never moved (no state change to drive _update's
# cycle-end path).
self.ctrl.ioloop.add_callback(
self._abort_external_home_cycle)
def _finish_external_home(self, axis, home_mm):
"""IOLoop-side completion of an external axis home.
Synchronizes AVR position, refreshes the planner, and emits
a G92 to set the user-coord origin at the home position.
"""
try:
# 1) Update AVR: no motor steps, just position sync.
super().queue_command(Cmd.set_axis(axis, home_mm))
# 2) Force planner to resync abs from State on the next
# planner call (which is the MDI below).
self.planner.position_change()
# 3) G92 <axis>0: with abs already at home_mm, sets
# user-coord A = 0 and offset = home_mm. Use
# planner.mdi (not Mach.mdi) so we don't flip cycle
# to 'mdi' inside the 'homing' cycle.
self.planner.mdi('G92 %c0' % axis, False)
super().resume()
except Exception:
self.mlog.exception(
'Post-home bookkeeping failed for external axis')
self._abort_external_home_cycle()
def _abort_external_home_cycle(self):
"""Reset cycle to idle from the IOLoop after a failed
external axis home. The AVR never moved so _update's normal
cycle-end path won't fire; do it explicitly here.
"""
if self._get_cycle() == 'homing':
try:
self._set_cycle('idle')
except Exception:
self.mlog.exception(
'Failed to reset cycle to idle after external '
'homing error')
def unhome(self, axis): def unhome(self, axis):
# External axes don't have AVR-side homed state to clear; the # External axes don't have AVR-side homed state to clear; the

View File

@@ -270,19 +270,54 @@ class Planner():
if type != 'set': self.log.info('Cmd:' + log_json(block)) if type != 'set': self.log.info('Cmd:' + log_json(block))
if type == 'line': if type == 'line':
# Z-A coupling check: every line block that touches Z (or
# A) is validated against the projected (A,Z) machine
# pair. The ExternalAxis check is improvement-aware: it
# only refuses moves that worsen an existing violation
# or push a healthy state into one. So pure-XY jogs and
# recovery moves (Z up, A down) are not rejected even
# when (A-Z) is currently above the bound.
ext_check = getattr(self.ctrl, 'ext_axis', None)
if ext_check is not None:
from bbctrl.ExternalAxis import ExternalAxisError
target = block.get('target') or {}
z_target = target.get('z')
if z_target is None: z_target = target.get('Z')
a_letter = ext_check.axis_letter
a_target = target.get(a_letter)
if a_target is None:
a_target = target.get(a_letter.upper())
if z_target is not None or a_target is not None:
try:
ext_check.check_coupling(
target_a_machine=a_target,
target_z_machine=z_target)
except ExternalAxisError as e:
# Convert the raw error into a clean abort:
# surface the message to the operator, stop
# the cycle, and skip this block. Returning
# None drops the block from the AVR queue;
# mach.stop() halts further planner output
# so the rest of an offending program can't
# leak through. The planner stays usable
# for new MDI / jog commands.
self.log.warning('Z-A coupling refused: %s' % e)
try:
self.ctrl.state.add_message(
'Z-A coupling refused move: ' + str(e))
except Exception: pass
try:
self.ctrl.mach.stop()
except Exception: pass
return None
ext = self._external_axis_for_line(block) ext = self._external_axis_for_line(block)
if ext is not None: if ext is not None:
# Side-effects: run the ESP move synchronously, # Side effect: enqueue the ESP move on the external-
# split the line into ESP (already done) + AVR (rest). # axis worker. The AVR still receives the full target
avr_block = self._dispatch_external_line(block, ext) # (including A) so ex.position[A] tracks gplan; no
if avr_block is None: # motor steps for A because no motor maps to it.
# Pure external move - no AVR work to issue but self._dispatch_external_line(block, ext)
# 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) self._enqueue_line_time(block)
return Cmd.line(block['target'], block['exit-vel'], return Cmd.line(block['target'], block['exit-vel'],
block['max-accel'], block['max-jerk'], block['max-accel'], block['max-jerk'],
@@ -394,34 +429,45 @@ class Planner():
def _dispatch_external_line(self, block, ext): def _dispatch_external_line(self, block, ext):
"""Side-effect: enqueue the ESP move on the external-axis """Side-effect: enqueue the ESP move on the external-axis
worker thread (non-blocking). Return a new block dict with worker thread (non-blocking). Returns the block (possibly
the external axis stripped from `target`, or None if the unchanged) for the AVR.
line had no other axes.
For mixed XYZ + external moves the AVR runs XYZ at the We do NOT strip the external axis target from the AVR line.
gplan-computed rate while the ESP runs the external delta in The AVR's exec_move_to_target updates ex.position[axis] for
parallel. Pure external moves return None so __encode emits every axis in the target dict regardless of motor mapping,
only the id-sync to keep planner ids advancing.""" and reports it back via the `p` indexed var. Leaving A in
target = dict(block['target']) the target keeps state.ap in sync with gplan's idea of A
new_target, ext_mm = ext.split_target(target) (otherwise the AVR's stale ex.position[A] would clobber
ExternalAxis's state.ap=N update on the next status report).
The AVR doesn't step any motor for the external axis (no
motor maps to it) - so leaving A in the target is
physically a no-op for the steppers, while keeping the
host-side state coherent.
We pass the full S-curve parameters to the ESP so its move
duration matches the AVR's exactly. The ESP runs the same
7-segment jerk-limited trajectory the AVR would have run
if A had been a real motor."""
target = block.get('target') or {}
# Read the external target (case-insensitive) without modifying
# the dict so the AVR still sees A.
ext_mm = target.get(ext.axis_letter)
if ext_mm is None:
ext_mm = target.get(ext.axis_letter.upper())
try: try:
ext.enqueue_target_mm(ext_mm) ext.enqueue_line(
ext_mm,
block.get('max-accel', 0.0),
block.get('max-jerk', 0.0),
block.get('entry-vel', 0.0),
block.get('exit-vel', 0.0),
block.get('times', [0]*7),
)
except Exception as e: 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) self.log.error('External axis enqueue failed: %s' % e)
raise raise
return block
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): def reset(self, *args, **kwargs):
stop = kwargs.get('stop', True) stop = kwargs.get('stop', True)
@@ -471,8 +517,11 @@ class Planner():
# should use A directly. # should use A directly.
try: try:
from bbctrl.AuxPreprocessor import preprocess_file from bbctrl.AuxPreprocessor import preprocess_file
if preprocess_file(path, log = self.log): ext = getattr(self.ctrl, 'ext_axis', None)
self.log.info('Rewrote ATC M-codes in %s' % path) coupling = (ext.coupling_for_preprocessor()
if ext is not None else None)
if preprocess_file(path, log=self.log, coupling=coupling):
self.log.info('Rewrote (ATC / Z-A coupling) in %s' % path)
except Exception: except Exception:
self.log.exception('Aux preprocess at load failed; ' self.log.exception('Aux preprocess at load failed; '
'attempting to load file unchanged') 'attempting to load file unchanged')

View File

@@ -107,8 +107,14 @@ class State(object):
def reset(self): def reset(self):
# Unhome all motors # Unhome all motors (real AVR motors 0..3 and the synthetic
for i in range(4): self.set('%dhomed' % i, False) # external-axis motor at index 4 used by ExternalAxis).
# Both <motor>homed and <motor>h are cleared - they're set
# by different code paths (gplan emits homed via _<axis>_homed
# set blocks, AVR reports h directly).
for i in range(5):
self.set('%dhomed' % i, False)
self.set('%dh' % i, 0)
# Zero offsets and positions # Zero offsets and positions
for axis in 'xyzabc': for axis in 'xyzabc':

View File

@@ -1396,7 +1396,7 @@ tt.save
.control-page .dro-head, .control-page .dro-row .control-page .dro-head, .control-page .dro-row
display grid display grid
grid-template-columns 84px 1.4fr 1fr 1fr 170px 170px 280px grid-template-columns 84px 1.4fr 1fr 1fr 280px
column-gap 0.75rem column-gap 0.75rem
align-items center align-items center
padding 14px 22px padding 14px 22px
@@ -1529,6 +1529,39 @@ tt.save
opacity 0.45 opacity 0.45
cursor not-allowed cursor not-allowed
// State-tinted variants used on home + zero buttons in DRO rows
// to communicate per-axis homing / toolpath fit at a glance,
// replacing the explicit HOMED / OK chips that used to live in
// their own columns.
&.state-green
background #dcfce7
border-color #86efac
color #166534
&:hover:not([disabled])
background #bbf7d0
&.state-amber
background #fef3c7
border-color #fcd34d
color #92400e
&:hover:not([disabled])
background #fde68a
&.state-red
background #fee2e2
border-color #fca5a5
color #991b1b
&:hover:not([disabled])
background #fecaca
&[disabled].state-green,
&[disabled].state-amber,
&[disabled].state-red
opacity 0.7
.actions-cell .actions-cell
display flex display flex
justify-content flex-end justify-content flex-end
@@ -2380,7 +2413,7 @@ html.kiosk-mode
gap 6px gap 6px
.control-page .dro-head, .control-page .dro-row .control-page .dro-head, .control-page .dro-row
grid-template-columns 56px 1fr 0.85fr 0.85fr 90px 90px 1fr grid-template-columns 56px 1fr 0.85fr 0.85fr 1fr
column-gap 0.4rem column-gap 0.4rem
padding 6px 10px padding 6px 10px

View File

@@ -4,9 +4,11 @@
import * as api from "$lib/api"; import * as api from "$lib/api";
// Mirrors the DEFAULTS in src/py/bbctrl/AuxAxis.py. The "enabled" // Mirrors the DEFAULTS in src/py/bbctrl/AuxAxis.py. The "enabled"
// flag is read-only here; toggling the W axis on/off is done via // flag is read-only here; toggling the auxiliary A axis on/off
// aux.json on disk, so adding/removing the hardware doesn't have a // is done via aux.json on disk, so adding/removing the hardware
// surprise UI that bricks bring-up. // doesn't have a surprise UI that bricks bring-up. Legacy aux.json
// files using min_w/max_w are migrated up to min_mm/max_mm by
// AuxAxis._migrate_legacy_fields on load.
type AuxConfig = { type AuxConfig = {
enabled: boolean; enabled: boolean;
port: string; port: string;
@@ -14,8 +16,8 @@
steps_per_mm: number; steps_per_mm: number;
dir_sign: number; dir_sign: number;
axis_letter: string; axis_letter: string;
min_w: number; min_mm: number;
max_w: number; max_mm: number;
max_feed_mm_min: number; max_feed_mm_min: number;
max_velocity_m_per_min: number; max_velocity_m_per_min: number;
max_accel_km_per_min2: number; max_accel_km_per_min2: number;
@@ -30,6 +32,9 @@
step_accel_sps2: number; step_accel_sps2: number;
step_start_sps: number; step_start_sps: number;
limit_low: boolean; limit_low: boolean;
couple_z_enabled: boolean;
couple_z_clearance_mm: number;
z_home_mm: number;
}; };
let cfg: AuxConfig | null = null; let cfg: AuxConfig | null = null;
@@ -73,9 +78,9 @@
} }
} }
// Mark the root config as modified whenever a W axis field is // Mark the root config as modified whenever an auxiliary axis
// edited, so the master Save button highlights and the user knows // field is edited, so the master Save button highlights and
// there are unsaved changes. // the user knows there are unsaved changes.
function markDirty() { function markDirty() {
try { try {
const root = (window as any).$root || (window as any).Vue?.root; const root = (window as any).$root || (window as any).Vue?.root;
@@ -86,9 +91,9 @@
} }
</script> </script>
<div class="w-axis-settings"> <div class="a-axis-settings">
{#if !cfg} {#if !cfg}
<p class="tip">Loading W axis configuration...</p> <p class="tip">Loading A axis configuration...</p>
{:else} {:else}
<div class="status"> <div class="status">
{#if status} {#if status}
@@ -109,7 +114,7 @@
<div class="pure-form pure-form-aligned" on:input={markDirty} on:change={markDirty}> <div class="pure-form pure-form-aligned" on:input={markDirty} on:change={markDirty}>
<fieldset> <fieldset>
<div class="pure-control-group" title="Enable the auxcnc W axis. Edit aux.json to toggle."> <div class="pure-control-group" title="Enable the auxiliary axis (auxcnc-driven A). Edit aux.json to toggle.">
<label for="enabled">enabled</label> <label for="enabled">enabled</label>
<input id="enabled" type="checkbox" checked={cfg.enabled} disabled /> <input id="enabled" type="checkbox" checked={cfg.enabled} disabled />
<label for="" class="units">(edit aux.json)</label> <label for="" class="units">(edit aux.json)</label>
@@ -128,7 +133,7 @@
<h3>Mechanics</h3> <h3>Mechanics</h3>
<fieldset> <fieldset>
<div class="pure-control-group" title="Logical steps per mm of W travel."> <div class="pure-control-group" title="Logical steps per mm of axis travel.">
<label for="steps_per_mm">steps per mm</label> <label for="steps_per_mm">steps per mm</label>
<input id="steps_per_mm" type="number" bind:value={cfg.steps_per_mm} step="any" /> <input id="steps_per_mm" type="number" bind:value={cfg.steps_per_mm} step="any" />
<label for="" class="units">steps/mm</label> <label for="" class="units">steps/mm</label>
@@ -152,14 +157,43 @@
</div> </div>
<div class="pure-control-group" title="Soft-limit minimum in mm."> <div class="pure-control-group" title="Soft-limit minimum in mm.">
<label for="min_w">soft min</label> <label for="min_mm">soft min</label>
<input id="min_w" type="number" bind:value={cfg.min_w} step="any" /> <input id="min_mm" type="number" bind:value={cfg.min_mm} step="any" />
<label for="" class="units">mm</label> <label for="" class="units">mm</label>
</div> </div>
<div class="pure-control-group" title="Soft-limit maximum in mm."> <div class="pure-control-group" title="Soft-limit maximum in mm.">
<label for="max_w">soft max</label> <label for="max_mm">soft max</label>
<input id="max_w" type="number" bind:value={cfg.max_w} step="any" /> <input id="max_mm" type="number" bind:value={cfg.max_mm} step="any" />
<label for="" class="units">mm</label>
</div>
</fieldset>
<h3>Z-A Coupling</h3>
<p class="tip">
The auxiliary tool hangs below the Z spindle. Beyond a small
Z descent the two collide unless A drops with Z. The rule
in machine coordinates is
<code>A &minus; Z &le; (A_home &minus; Z_home) + clearance</code>.
When enabled, the planner refuses moves that would violate
it and the gcode preprocessor injects pre-position A moves
into uploaded files.
</p>
<fieldset>
<div class="pure-control-group" title="Master switch for the Z-A interlock. When off, no checks are performed.">
<label for="couple_z_enabled">enable coupling</label>
<input id="couple_z_enabled" type="checkbox" bind:checked={cfg.couple_z_enabled} />
</div>
<div class="pure-control-group" title="How far Z may descend below its home position before A must move with it.">
<label for="couple_z_clearance_mm">Z clearance</label>
<input id="couple_z_clearance_mm" type="number" bind:value={cfg.couple_z_clearance_mm} step="any" />
<label for="" class="units">mm</label>
</div>
<div class="pure-control-group" title="Z's machine position when homed. Almost always 0.">
<label for="z_home_mm">Z home position</label>
<input id="z_home_mm" type="number" bind:value={cfg.z_home_mm} step="any" />
<label for="" class="units">mm</label> <label for="" class="units">mm</label>
</div> </div>
</fieldset> </fieldset>
@@ -196,12 +230,12 @@
<div class="pure-control-group" title="Direction the axis moves when looking for the home limit switch."> <div class="pure-control-group" title="Direction the axis moves when looking for the home limit switch.">
<label for="home_dir">home direction</label> <label for="home_dir">home direction</label>
<select id="home_dir" bind:value={cfg.home_dir}> <select id="home_dir" bind:value={cfg.home_dir}>
<option value="-">- (toward W-)</option> <option value="-">- (toward A-)</option>
<option value="+">+ (toward W+)</option> <option value="+">+ (toward A+)</option>
</select> </select>
</div> </div>
<div class="pure-control-group" title="W position assigned when homing completes."> <div class="pure-control-group" title="Axis position assigned when homing completes.">
<label for="home_position_mm">home position</label> <label for="home_position_mm">home position</label>
<input id="home_position_mm" type="number" bind:value={cfg.home_position_mm} step="any" /> <input id="home_position_mm" type="number" bind:value={cfg.home_position_mm} step="any" />
<label for="" class="units">mm</label> <label for="" class="units">mm</label>
@@ -263,7 +297,7 @@
master <strong>Save</strong> button at the bottom of the master <strong>Save</strong> button at the bottom of the
settings rail. Homing rates and the limit polarity are settings rail. Homing rates and the limit polarity are
pushed to the ESP immediately; any running motion is pushed to the ESP immediately; any running motion is
unaffected. Re-home the W axis after changing direction, unaffected. Re-home the auxiliary axis after changing direction,
sign, or step settings. sign, or step settings.
</div> </div>
</div> </div>
@@ -271,7 +305,7 @@
</div> </div>
<style lang="scss"> <style lang="scss">
.w-axis-settings { .a-axis-settings {
.status { .status {
margin-bottom: 1em; margin-bottom: 1em;
font-size: 90%; font-size: 90%;

View File

@@ -2,10 +2,10 @@
import configTemplate from "../../../resources/config-template.json"; import configTemplate from "../../../resources/config-template.json";
import ScreenRotationDialog from "$dialogs/ScreenRotationDialog.svelte"; import ScreenRotationDialog from "$dialogs/ScreenRotationDialog.svelte";
import ConfigTemplatedInput from "./ConfigTemplatedInput.svelte"; import ConfigTemplatedInput from "./ConfigTemplatedInput.svelte";
// WAxisSettings is mounted directly by the V09 settings shell at // AAxisSettings is mounted directly by the V09 settings shell at
// #w-axis instead of being embedded here — see // #a-axis instead of being embedded here — see
// src/pug/templates/w-axis-view.pug. // src/pug/templates/a-axis-view.pug.
// import WAxisSettings from "./WAxisSettings.svelte"; // import AAxisSettings from "./AAxisSettings.svelte";
import SetTimeDialog from "$dialogs/SetTimeDialog.svelte"; import SetTimeDialog from "$dialogs/SetTimeDialog.svelte";
import Button, { Label } from "@smui/button"; import Button, { Label } from "@smui/button";
@@ -99,7 +99,7 @@
</fieldset> </fieldset>
<!-- W Axis (auxcnc) is now its own routed page in the V09 <!-- W Axis (auxcnc) is now its own routed page in the V09
settings shell (#w-axis). Keep the SettingsView free of settings shell (#a-axis). Keep the SettingsView free of
that section so we don't render it twice. --> that section so we don't render it twice. -->
<h2 id="sec-path-accuracy" data-sec="gcode">Path Accuracy</h2> <h2 id="sec-path-accuracy" data-sec="gcode">Path Accuracy</h2>

View File

@@ -6,7 +6,7 @@ matchAll.shim();
import AdminNetworkView from "$components/AdminNetworkView.svelte"; import AdminNetworkView from "$components/AdminNetworkView.svelte";
import SettingsView from "$components/SettingsView.svelte"; import SettingsView from "$components/SettingsView.svelte";
import HelpView from "$components/HelpView.svelte"; import HelpView from "$components/HelpView.svelte";
import WAxisSettings from "$components/WAxisSettings.svelte"; import AAxisSettings from "$components/AAxisSettings.svelte";
import DialogHost, { showDialog } from "$dialogs/DialogHost.svelte"; import DialogHost, { showDialog } from "$dialogs/DialogHost.svelte";
import { handleConfigUpdate, setDisplayUnits } from "$lib/ConfigStore"; import { handleConfigUpdate, setDisplayUnits } from "$lib/ConfigStore";
import { handleControllerStateUpdate } from "$lib/ControllerState"; import { handleControllerStateUpdate } from "$lib/ControllerState";
@@ -23,8 +23,8 @@ export function createComponent(component: string, target: HTMLElement, props: R
case "HelpView": case "HelpView":
return new HelpView({ target, props }); return new HelpView({ target, props });
case "WAxisSettings": case "AAxisSettings":
return new WAxisSettings({ target, props }); return new AAxisSettings({ target, props });
case "DialogHost": case "DialogHost":
return new DialogHost({ target, props }); return new DialogHost({ target, props });