Compare commits

23 Commits

Author SHA1 Message Date
f8be0a6b6f AuxPreprocessor: precede each HOOK with M0 so atoms block
The ATC M-codes are supposed to behave like proper blocking gcode -
M100 should not return until the ejector pulse has actually finished,
and the next block should not run until M100 has returned. Without
that, the drop macro

  M102            (release)
  M100            (eject pulse 1)
  M100            (eject pulse 2)
  M100            (eject pulse 3)
  M100            (eject pulse 4)
  G53 G0 Z0       (lift Z)
  M103            (clamp)

races: gplan emits all the (MSG,HOOK:...) lines and the Z move in
quick succession, the AVR queues them, and Z lifts while V2 is
still wiggling.

The (MSG,...) transport itself is fire-and-forget by gplan's design.
The Hooks framework already implements proper blocking via the
block_unpause / auto_resume mechanism - but it only takes effect
when the program is actually paused. So precede each hook with M0
(program pause) in the rewritten temp file:

  M0 (MSG,HOOK:release:)
  M0 (MSG,HOOK:eject:)
  ...

Sequence becomes:
  M0       -> machine pauses on the AVR side
  (MSG..)  -> hook fires synchronously in a thread
  hook does ESP RPC, blocks until [eject] done
  hook completes; auto_resume unpauses
  next block streams

This also fixes the consecutive-comment-line collapse problem
naturally: each M0 is its own block, so back-to-back HOOK lines
no longer collide.

The M0 lives only in the tempfile gplan loads; the operator's macro
source still reads as plain M100/M102/M103.
2026-05-03 18:39:33 +02:00
692be42f84 AuxPreprocessor: stop mutating the macro source; use a tempfile
Macros and uploaded jobs now pass through gplan untouched on disk.
The (MSG,HOOK:...) substitution that lets the host react to ATC
M-codes mid-program now lands in a tempfile that gplan loads instead
of the operator-authored source.

Why we still rewrite at all: gplan (camotics planner) treats
M100/M102/M103 as no-ops by spec and doesn't expose a callback for
user-defined M-codes. Its only in-band channel back to Python during
a running program is the (MSG,...) message stream, so we substitute
hook messages for those M-codes purely as transport. That mechanism
is fine; what was broken was that we wrote the substitution back
over the macro source. So:

- The macro editor opened drop.nc and saw (MSG,HOOK:...) blobs
  instead of the M100/M102/M103 sequence.
- Re-running compounded any rewrite quirk (paren-comment handling,
  consecutive HOOK lines collapsing) on every load.
- Editing a macro accidentally re-rewrote its already-rewritten
  form.

Now:

- AuxPreprocessor.preprocess_to_tempfile() returns a path to a
  rewritten temp file; the source is never modified. The old
  preprocess_file() in-place wrapper is kept (deprecated) for
  the upload path, where mutating the saved upload is fine.
- Planner.load() goes through preprocess_to_tempfile and tracks
  the temp path on the Planner instance, deleting the previous
  tempfile on each new load() so /tmp doesn't fill up.
- Each rewritten (MSG,HOOK:...) line gets a tiny G4 P0.001
  dwell prefix so gplan doesn't collapse consecutive comment-
  only lines into a single block (which was eating all but the
  last hook in a sequence). The dwell appears only in the
  tempfile, never in the source.

Macros on the controller (drop.nc, grab.nc, release.nc, clamp.nc)
restored to the human-readable M100/M102/M103 form.
2026-05-03 18:32:12 +02:00
d5ad717f78 AuxPreprocessor: ignore M-codes inside paren comments
Two bugs surfaced when macros got prose like:

    (Composed from atoms: M102 = RELEASE (V1 on), M103 = CLAMP)

1. _ATC_M_RE.finditer was being run against the raw line, so the
   M102/M103 *inside* the comment fired spurious release/clamp
   hooks at file load.
2. The simple _PAREN_COMMENT_RE = re.compile(r'\(\[^)]*\)') is a
   greedy non-nested match, so a header with a nested paren
   (e.g. 'M102 = RELEASE (V1 on)') only stripped the inner
   paren, leaving the trailing 'M103 = CLAMP)' visible to the
   matcher.

Fix:

  - Add _strip_comments() that walks the line tracking paren depth
    and drops the trailing semicolon comment. Handles nested parens
    correctly.
  - Run _ATC_M_RE.finditer against the comment-stripped 'code'
    instead of the raw line, so prose mentions are inert.
  - Drop the original line's comments from the rewritten output;
    keeping them around led to the M-codes being matched twice
    (once stripped, once still in the trailing comment).
  - Use _strip_comments in file_uses_aux too.

The grab.nc and drop.nc macros on the controller already had the
prose headers; they now preprocess correctly to clean
release / G4 / clamp and release / N x eject / Z0 / clamp
sequences.
2026-05-03 18:20:19 +02:00
130c39fad9 ATC: split tool-change M-codes into composable atoms
Match auxcnc firmware v3, which dropped the monolithic DROPTOOL /
GRABTOOL ESP tasks in favour of three atoms: RELEASE, CLAMP, EJECT.
This lets host macros interleave Z moves between ejector pulses
(the old DROPTOOL ran open->oscillate->clamp in a single ESP task,
so you couldn't lift Z mid-eject).

  AuxAxis: replace atc_droptool() / atc_grabtool() with atc_eject(
    pulse_ms=, dwell_ms=). atc_release() / atc_clamp() are unchanged.

  Ctrl: register internal hooks for release / clamp / eject only.
    The eject hook parses 'pulse=' and 'dwell=' kwargs out of the
    HOOK:eject:<data> payload so macros can emit
    (MSG,HOOK:eject:pulse=400 dwell=300) for tuned wiggles.

  AuxPreprocessor: M100 now maps to eject (was droptool); M101 is
    unmapped (was grabtool, now a pure host-side macro); M102/M103
    are unchanged. Header comment updated.

  docs/AUX_A_AXIS.md: mention the new atom set.

The drop.nc and grab.nc gcode macros on the controller are
correspondingly rewritten on-device as compositions:
  drop = M102 + 4xM100 + G53 G0 Z0 + M103
  grab = M102 + G4 P2 + M103
2026-05-03 18:15:55 +02:00
b59091007c Jog: enforce Z-A coupling on hold-to-jog
Pendant hold-to-jog now picks the more restrictive of the soft
limit and the Z-A coupling bound when computing target_steps for
the ESP. The coupling rule (a - z <= K) caps how high A may go
for the current Z; only the +A direction (toward larger machine
A) is constrained, -A jogs are unaffected.

ExternalAxis already exposes couple_K and _z_machine_now; we
project a_max_mm = z_now + K into step space via the same
_mm_to_steps the rest of AuxAxis uses.

The combined helper _a_combined_target_steps picks whichever of
the two targets is reached first when moving in . The
log line includes target_src so journalctl shows whether a stop
was triggered by softlimit or coupling.

Refusal-on-press logic was extended to use the combined target so
we won't even start a jog when sitting on a coupling-blocked
position.

Limitation: the target is computed once at JOG start. If Z drops
during the jog the bound moves with it; this version doesn't
re-evaluate. Z motion during a manual A jog is rare in practice
(both hands are on the pendant), but a periodic re-check is on
the follow-up list.
2026-05-03 18:08:48 +02:00
5787855f3f Jog: enforce A-axis soft limits during hold-to-jog
Pendant hold-to-jog could drive A past min_mm / max_mm because the
JOG path bypassed the planner-driven soft-limit checks. Wire the
host to compute a step-counter target for whichever soft-limit
boundary lies in the requested direction and pass it through the
new JOG target= parameter.

AuxAxis.jog_start now accepts target_steps; when given it emits
'JOG ... target=<n>'. The ESP picks the decel start point so the
motor ramps to a smooth stop AT the boundary, with no overshoot.

Jog._a_soft_limit_target_steps:
  - Returns None when the axis is not homed -- pre-home setup jogs
    are still allowed (matches the rest of the manual-jog API).
  - Otherwise projects min_mm/max_mm into step space (honoring
    dir_sign) and returns the boundary on the requested side of
    the current position.

Jog._a_start additionally refuses to send the JOG when the
position is already at-or-past the boundary in the requested
direction, so we don't depend on the ESP's wrong-side reject path
for the common 'press button while sitting on the limit' case.

Verified end-to-end on hardware (bare ESP, no gantry):
  JOG dir=+ maxrate=400 target=300 stops at pos=299
  JOG dir=+ target=-50 (wrong side) rejected immediately.
2026-05-03 18:06:29 +02:00
7360c437a9 Preplanner: surface plan failures so the Processing dialog exits
When a plan fails (e.g. AuxPreprocessor Z-A coupling rejection at
planner-load time, or any other gplan error in the plan.py
subprocess), the Plan future was never resolved. PathHandler then
1-second-times-out forever returning {progress:0}, and the JS poll
loop in load_toolpath kept the 'Processing New File' dialog up
indefinitely.

- Preplanner.Plan now records .error and always resolves the future.
- PathHandler returns {progress:1, error:...} when the plan failed.
- load_toolpath closes the dialog and alerts the operator on error,
  and breaks out of the poll loop on api errors instead of looping.
- FileHandler upload-time AuxPreprocessor coupling errors now post a
  visible state message instead of being silently swallowed.
2026-05-03 17:58:31 +02:00
01e39722d3 Jog: detailed event/state logging + dry-run env var
Adds visibility into the gamepad event path so future regressions
can be diagnosed without the gantry attached. AJOG EV logs every
incoming KEY event and any ABS event matching the trigger codes;
AJOG STATE logs every transition; the would-be JOG / JOGSTOP is
also logged.

BBCTRL_AJOG_DRYRUN=1 in the bbctrl env disables actuation while
keeping the logging, so the host-side state machine can be tested
without driving the ESP.

Default is live actuation (dry-run off). Used this to prove the
host side was correct on hardware where the firmware bug was
hiding -- pendant taps produced perfect press/release pairs at
~200 ms while the ESP was the one ignoring JOGSTOP.
2026-05-03 17:44:36 +02:00
b63e5bb55a Jog: drive A-axis hold-to-jog through ESP JOG / JOGSTOP
Previous attempts (small STEPS chunks per 250 ms tick, then a
single-big-STEPS plus ABORT-on-release) both gave a jerky ride: the
chunked path produced staccato accel/decel ladders, and the
ABORT-on-release path raced with task creation, frequently letting
STEPS run to its full target.

The auxcnc ESP gained a continuous-rate JOG / JOGSTOP pair (see
auxcnc commit 'Add JOG / JOGSTOP for smooth hold-to-jog'). On
press we issue JOG dir=+/- maxrate=... accel=... and the ESP ramps
up and cruises until JOGSTOP, which triggers a controlled decel.

AuxAxis additions:
  - jog_start(direction, max_rate_sps=, accel_sps2=, ignore_limits=)
    sends JOG and waits only for the immediate '[jog] started' ack.
  - jog_stop() sends JOGSTOP fire-and-forget (no RPC lock so it can
    interrupt anything in flight, mirroring abort()).
  - _on_line picks up async '[jog] done|aborted ...' lines and
    resyncs _pos_steps so subsequent moves compute the correct
    delta.

Jog.py:
  - On Xbox 360 pad RB (BTN_TR) -> A+ press, RT (ABS_RZ) -> A-
    press; release -> JOGSTOP. Speed buttons (X/A/B/Y) scale max
    rate by 1/128, 1/32, 1/4, 1.0x.
  - safe=0 only when A is unhomed; otherwise the ESP enforces
    limit-toward-home_dir abort.
  - On release, schedules a 200 ms-deferred ext_axis._pos_mm
    refresh so any subsequent gplan-driven A motion sees the new
    position.

Verified end-to-end on hardware: smooth ramp-cruise-ramp on
press/release, no overshoot on quick taps, soft limits respected
when homed.
2026-05-03 16:55:48 +02:00
99b5af56cc Jog: bind right back buttons (R1/R2) to A axis hold-to-jog
The default USB pendant config exposed A only via the right stick
X axis (ABS_RX). Most Onefinity-shipped pendants only have one
usable stick, so A was effectively unreachable.

Map BTN_TR (0x137, upper-right back) to A+ and BTN_TR2 (0x139,
lower-right back) to A- while held. Speed scaling matches the
sticks (1/128, 1/32, 1/4, 1x via X/A/B/Y).

R1 was the vertical-axis-lock toggle; horizontal lock on L1 is
preserved, vertical lock is dropped to free R1.
2026-05-03 16:12:42 +02:00
9d7bc57056 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:14:25 +02:00
90fd8533fe 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 15:14:25 +02:00
ad4815d822 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 15:14:25 +02:00
5150c3e4a8 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 15:14:25 +02:00
4109f9f838 docs: A axis architecture (renamed from W) + README section
- Move docs/AUX_W_AXIS.md to docs/AUX_A_AXIS.md and rebadge W -> A
  throughout, with a header note pointing at ExternalAxis as the
  current implementation.
- README: A-axis fork heading, link to AUX_A_AXIS.md, /api/aux/status
  in verify-flash, small comment in scripts/deploy/local.sh.
2026-05-03 15:14:25 +02:00
ad846b6033 Config: idempotent macro file rename W -> A
The auxiliary stepper used to be exposed as a W axis. After the
gplan integration it is exposed as A. Migrate persisted macro
config on every load:
  w_down.nc -> a_down.nc
  w_up.nc   -> a_up.nc
  'W Down'  -> 'A Down'
  'W Up'    -> 'A Up'

Idempotent so a stale in-memory copy can never reintroduce the old
names.
2026-05-03 15:14:25 +02:00
7a3c2bbb0d UI: A axis surface (DRO row, jog, Home A, settings page)
Front-end side of the gplan-integrated A axis (B3).

- a-axis-view.{js,pug}: dedicated settings page that mounts the
  AAxisSettings Svelte component and lives at #a-axis in the V09
  settings rail.
- AAxisSettings.svelte: aux.json-backed form (axis letter, port,
  homing direction, soft limits, ATC pin map, etc.) with master
  Save integration via 'onefin:save-all'.
- main.ts + SettingsView.svelte: register AAxisSettings in the
  Svelte component map; SettingsView no longer embeds the W axis
  fieldset.
- settings-shell-view: 'A Axis' rail entry; route to a-axis-view.
- app.js: extend settings family to include 'a-axis'; broadcast
  onefin:save-all from the master Save button.
- control-view: Home All button waits for the gantry cycle to
  finish before firing Home A on a non-virtual setup; A jog
  buttons; aux_jog/aux_home/aux_jog_incr methods.
- control-view.pug: A row in the DRO (with set-position + zero +
  home actions), A- / A+ tiles in the jog grid (gated on
  w.enabled || a.enabled), legacy W row kept for installs that
  haven't migrated to the gplan integration.
- style.styl: dro-axis.axis-w color.
2026-05-03 15:14:25 +02:00
da619bd56c ATC: M100..M103 preprocessor + Mach MDI rewrite + hook handlers
ATC pneumatics in g-code (drop tool / grab tool / release clamp /
engage clamp) are expressed as M100..M103. AuxPreprocessor rewrites
those into (MSG,HOOK:droptool:) etc on file upload + on planner
load + on MDI input, so the Hooks layer (B1) can dispatch them via
registered ATC handlers in Ctrl.

- AuxPreprocessor.py: regex-based file rewriter, idempotent.
- FileHandler: invoke preprocessor on every upload.
- Planner.init: also re-preprocess on load (catches files written
  before this version).
- Mach.mdi: same rewrite for ad-hoc MDI input so M101 typed at the
  console produces a HOOK message.
- Ctrl: register the four ATC hooks (droptool/grabtool/release/clamp)
  with block_unpause + auto_resume so programs using them pause at
  the right point and resume cleanly. aux_home retained as a legacy
  alias for older preprocessed files.
2026-05-03 15:14:25 +02:00
800cb04e3b ExternalAxis: virtual A axis through gplan, mirrored on the ESP
ExternalAxis exposes the auxcnc-driven ESP stepper as motor 4 (a
synthetic, host-only motor that gplan sees but the AVR doesn't). The
result is a virtual A axis that is fully integrated with the planner:
G1 A25 F1500 schedules a coordinated S-curve and the ESP runs the
exact same 7-segment trajectory the AVR would have run if A were a
real motor.

- ExternalAxis.py: synthetic-motor state, S-curve LINE block forward
  to the ESP, soft-limit enforcement, option-(b) homing (user A=0
  at the home limit).
- State: walk motors 0..4 in find_motor; clear both homed and h on
  reset; expose synthetic motor vars.
- axis-vars.js: motor-4 guard so the JS computed axis bindings don't
  throw when motor 4 has no entry in config.motors; resolve motor_id
  for the synthetic axis by scanning state['4an'].
- Ctrl: instantiate ExternalAxis after AuxAxis, share the axis_letter
  setting, wire AuxAxis state observer.
- Web: route /api/aux/{home,jog,move} through ExternalAxis when it
  is enabled so the DRO and synthetic-motor flags stay in sync.
2026-05-03 15:14:25 +02:00
d797f1d4fc AuxAxis: ESP32-driven external stepper (auxcnc)
bbctrl.AuxAxis manages a stepper driven by an auxcnc-style ESP32
over /dev/ttyUSB0 (or whichever serial port). Persistent config in
aux.json; UI talks to it via /api/aux/* endpoints.

- AuxAxis: serial framing, position tracking, soft-limit enforcement,
  homing state machine, ATC pneumatic control (M100..M103 wrappers).
- Ctrl: instantiate self.aux alongside the other subsystems and
  close it during shutdown.
- Web: handlers for /api/aux/{config,status,home,abort,jog,move,set-zero}.
2026-05-03 15:14:25 +02:00
80a00978b7 Hooks: ATC IPC layer between gcode preprocessor and runtime
Adds bbctrl.Hooks: a small dispatch layer for HOOK:<event>:<data>
messages embedded in g-code as (MSG,HOOK:droptool:) etc. Hooks can
block the unpause until the registered callback completes and
auto-resume after.

- bbctrl.Hooks: registry, fire, dispatch_hook_message, persistent
  config in hooks.json, REST surface (/api/hooks, /api/hooks/save,
  /api/hooks/status, /api/hooks/fire/<event>).
- Ctrl: instantiate self.hooks alongside the other subsystems.
- Planner._add_message: when a (MSG,...) line is HOOK:<event>:<data>,
  route it through ctrl.hooks instead of state.messages so it never
  surfaces as a UI popup and dispatch is immediate (state.messages
  has a 250ms debounce).
- Web: handlers for the /api/hooks routes.
2026-05-03 15:14:25 +02:00
f0a37828a4 docs: rename esp-a-axis branch to private-mods in AGENTS.md 2026-05-03 15:14:21 +02:00
2b949c4f00 docs: AGENTS.md - branch model and where-does-it-go guide 2026-05-03 15:10:25 +02:00
31 changed files with 4388 additions and 31 deletions

77
AGENTS.md Normal file
View File

@@ -0,0 +1,77 @@
# Onefinity firmware — agent guidelines
## Branch model
This fork lives on **two long-lived branches**:
- **`master`** — public-facing fork. General-use upgrades on top of
upstream OneFinity firmware: V09 UX redesign, Font Awesome 6, faster
cold boot, macOS dev/deploy tooling, build & flash docs, SD-card
backup, `/api/diag/timing`, kiosk/tablet polish, and assorted
bug-fixes. **No A-axis, ATC, hooks, or auxcnc/ESP content.** Aim for
changes that benefit any Onefinity owner.
- **`private-mods`** — bespoke shop branch. Stacks on top of `master`
and adds everything specific to the auxcnc-ESP-driven A axis and
the ATC: `Hooks` (ATC IPC), `AuxAxis` (ESP serial driver),
`ExternalAxis` (virtual A through gplan), `AuxPreprocessor` (M100-M103),
Z-A coupling interlock, the A-axis UI surface, and the
`/api/aux/*` endpoints.
Upstream:
- `upstream``https://github.com/OneFinityCNC/onefinity-firmware.git`
- `origin` → Gitea (`https://gitea.home.muehe.org/muehe/onefinity-firmware.git`)
`origin/pre-split-backup` is a tag preserving the pre-split master
tip. Keep it indefinitely until further notice.
## Where does a change go?
| Change | Branch |
|---|---|
| UI polish, theme, layout that any user benefits from | `master` |
| Build / install / boot performance | `master` |
| Diagnostics, logging, generic Python / Tornado fixes | `master` |
| Anything that touches `AuxAxis`, `ExternalAxis`, `Hooks`, `AuxPreprocessor` | `private-mods` |
| Anything mentioning the auxcnc ESP, `/dev/ttyUSB0`, the M100-M103 ATC pneumatics, or motor index 4 | `private-mods` |
| Z-A coupling interlock, ATC tool change sequencing | `private-mods` |
| A-axis UI (DRO row, jog tile, settings page, A-axis routes) | `private-mods` |
| W → A renames or aux.json migrations | `private-mods` |
When in doubt: ask "would this be useful on a stock Onefinity with no
ESP attached?" If yes → `master`. If no → `private-mods`.
## Workflow
```bash
# Day-to-day shop / hardware work (default)
git checkout private-mods
# … do work, commit …
git push origin private-mods
# Generic improvement to master
git checkout master
# … do work, commit …
git push origin master
# After landing on master, replay private-mods on top
git checkout private-mods
git rebase master
git push --force-with-lease origin private-mods
```
If a change accidentally lands on `master` but is bespoke (touches
the file table above), move it: `git reset --hard <prev>` on master,
cherry-pick onto `private-mods`, force-push master.
## Deploy
- `./deploy.sh local` — UI bundle on `localhost:8770` (tmux session
`onefin-local`). No controller backend; A-axis row stays hidden.
- `./deploy.sh hardware` — rsync to the Pi over SSH, restart
`bbctrl.service`. Use the `private-mods` branch on the shop Pi.
- `./deploy.sh prod` — bundle a release tarball.
See `.pi/BUILD.md` for the full build / flash / cross-compile flow.
## Commit before ending a turn; push after significant changes.

View File

@@ -1,8 +1,9 @@
# OneFinity CNC Controller Firmware (community fork) # OneFinity CNC Controller Firmware (A-axis fork)
This is the OneFinity / Buildbotics bbctrl firmware with a redesigned This is the community-fork firmware (V09 UI, FA6, cold-boot work,
UI (V09), Font Awesome 6, faster cold boot, and a streamlined macOS macOS dev tooling) with a virtual A axis driven by an auxcnc ESP32
dev / deploy workflow. over USB serial. See [docs/AUX_A_AXIS.md](docs/AUX_A_AXIS.md) for the
design and config.
## Layout ## Layout
@@ -16,7 +17,7 @@ src/svelte-components/ Newer Svelte UI for dialogs and settings
src/pug/ Pug templates compiled into build/http/index.html src/pug/ Pug templates compiled into build/http/index.html
src/resources/ Static assets and config templates src/resources/ Static assets and config templates
scripts/ Install / update / RPi build helpers scripts/ Install / update / RPi build helpers
docs/ Architecture, dev setup docs/ Architecture, dev setup, A-axis docs
``` ```
## Build & flash (quick path, macOS or Linux) ## Build & flash (quick path, macOS or Linux)
@@ -101,6 +102,7 @@ bbctrl restarts, then the new UI).
```bash ```bash
curl -s http://onefinity.local/ | grep -c "OneFinity" curl -s http://onefinity.local/ | grep -c "OneFinity"
curl -s http://onefinity.local/api/diag/timing | head curl -s http://onefinity.local/api/diag/timing | head
curl -s http://onefinity.local/api/aux/status # if A axis is enabled
``` ```
## Build & flash (full path, Debian/Linux) ## Build & flash (full path, Debian/Linux)
@@ -108,3 +110,15 @@ curl -s http://onefinity.local/api/diag/timing | head
For AVR + GPlan rebuilds, see [docs/development.md](docs/development.md). For AVR + GPlan rebuilds, see [docs/development.md](docs/development.md).
That path uses qemu + chroot to cross-compile gplan for ARM and needs That path uses qemu + chroot to cross-compile gplan for ARM and needs
the `gcc-avr` / `avr-libc` toolchain. the `gcc-avr` / `avr-libc` toolchain.
## A axis (auxcnc)
This fork adds a virtual A axis. See
[docs/AUX_A_AXIS.md](docs/AUX_A_AXIS.md) for:
- G-code surface (`G28 A0`, `G1 A25`, etc.)
- The G-code preprocessor and hook architecture
- aux.json keys
- REST API (`/api/aux/*`)
- UI surface (jog row in Control, settings panel in Settings)
- Edge cases (ESP reboot mid-job, limit closed at home start, …)

184
docs/AUX_A_AXIS.md Normal file
View File

@@ -0,0 +1,184 @@
# A axis (auxcnc) integration
> **Note:** This document describes the original out-of-band W-axis
> architecture (gcode preprocessor rewriting W tokens into HOOK
> messages dispatched between blocks). The current implementation
> integrates the auxcnc-driven stepper as a *virtual A axis* through
> gplan via a synthetic motor (`bbctrl/ExternalAxis.py`), so A is
> blended with XYZ in the same S-curve plan and the gcode surface
> below applies as plain `A` words.
>
> The HOOK pipeline still exists for ATC pneumatic atoms (M100 EJECT,
> M102 RELEASE, M103 CLAMP) - see `bbctrl/AuxPreprocessor.py`. Macros
> compose drop/grab tool sequences from those atoms.
This adds a virtual `A` axis to the bbctrl controller, driven by the
auxcnc ESP32 over USB serial (`/dev/ttyUSB0`). The ESP owns step-pulse
generation, real-time limit-switch monitoring, and the homing dance.
The Pi owns units (mm), soft limits, sequencing inside G-code jobs, and
a small REST API for jogging / homing from the UI.
## How it works
The bbctrl planner (gplan) only understands `xyzabc`, so adding a true
7th axis would require rebuilding gplan + the AVR firmware. We avoid
that by treating W as a synchronous out-of-band axis: A moves run
*between* G-code blocks, not blended with XYZ.
Pipeline:
1. User uploads a G-code file containing `A` words.
2. `FileHandler` runs `AuxPreprocessor` on the upload, rewriting W
tokens in place into `(MSG,HOOK:aux:<mm>)` etc. The original line
minus the A word continues to drive XYZ.
3. The planner sees only XYZ + message comments. When it reaches a
message line, the message goes through `state.add_message` which
`Hooks._on_state_change` watches for the `HOOK:` prefix.
4. `Hooks._fire('custom', ...)` finds the registered internal handler
for the event name (`aux`, `aux_rel`, `aux_home`, `aux_setzero`).
5. The handler runs in a hook thread, gating `Mach.unpause` until done.
While the handler is busy the machine is in HOLDING - no XYZ motion
can resume until A finishes.
6. The handler talks to the ESP over `/dev/ttyUSB0` via `AuxAxis`,
blocking on a deterministic reply token (`[step] done`, `[home]
done`, etc).
MDI commands containing `A` words are rewritten the same way at the
`Mach.mdi()` boundary so manual jog and macros work too.
## G-code surface
```gcode
G21 G90
G28 A0 ; home A axis
G1 A25 F300 ; move A to 25 mm absolute
G1 X100 W12.5 ; mixed: A moves first, then XYZ (configurable)
G91
G1 A-2.5 ; relative A move
G90
G92 A0 ; set current A as zero (G92-style)
```
Rules:
- `G28` / `G28.2` with W only -> homing hook; the bare `G28` is NOT
emitted to gplan (that would mean home-all).
- `G28.2 X0 Y0 W0` -> emit hook, then keep `G28.2 X0 Y0` for XY homing.
- A line with both W and XYZ axis words is split into two sequential
blocks. Default order: W first, then XYZ. Toggle via the
`w_first` constructor arg.
- Lines inside parens or after `;` are passed through verbatim.
## Configuration
Per-controller config lives at `<ctrl_path>/aux.json` (created on first
save via the API). Keys:
| Key | Default | Notes |
|------------------------|----------------|------------------------------------|
| `enabled` | `false` | Master switch |
| `port` | `/dev/ttyUSB0` | Serial device |
| `baud` | `115200` | |
| `steps_per_mm` | `80.0` | Logical steps per mm |
| `dir_sign` | `1` | +1 or -1: maps logical+ to motor+ |
| `min_w`, `max_w` | `0`, `100` | Soft limits in mm |
| `home_dir` | `'-'` | Direction toward limit switch |
| `home_position_mm` | `0.0` | mm value assigned at home |
| `home_fast_sps` | `4000` | Fast seek rate |
| `home_slow_sps` | `400` | Slow re-seek rate |
| `home_backoff_steps` | `200` | Backoff after touching limit |
| `home_maxtravel_steps` | `200000` | Hard cap on phase 1 seek |
| `step_max_sps` | `4000` | Cruise rate for STEPS |
| `step_accel_sps2` | `16000` | Trapezoidal ramp accel |
| `step_start_sps` | `200` | Ramp floor |
| `limit_low` | `true` | Switch active low (closed = LOW) |
Most of these are pushed to the ESP via `HOMECFG` on connect and
persisted there in NVS.
## REST API
| Verb | Path | Body | Effect |
|------|----------------------------|-----------------------|------------------------|
| GET | `/api/aux/config` | - | Current config |
| PUT | `/api/aux/config/save` | `{key: val, ...}` | Save and re-push |
| GET | `/api/aux/status` | - | `{enabled, present, homed, pos_mm}` |
| PUT | `/api/aux/home` | - | Run home cycle (blocks)|
| PUT | `/api/aux/abort` | - | Cancel running motion |
| PUT | `/api/aux/jog` | `{mm: 1.5}` or `{steps: 200}` | Relative move |
| PUT | `/api/aux/move` | `{mm: 12.5}` | Absolute move (mm) |
| PUT | `/api/aux/set-zero` | `{mm: 0}` | Set current pos to mm |
Steps-mode jog ignores soft limits (use it to inch the axis to the
limit switch when the axis isn't homed yet).
## UI
**Control view**
- A jog row appears under the XYZ jog grid when `aux_enabled` is true,
with three buttons: `A-`, `A+`, and a wide `Home W`. There is
intentionally no separate "set zero" or "W origin" button - homing
lands the axis at `home_position_mm` (0 by default), so home and
zero are the same point.
- The DRO table shows a A axis row with position, status (OFFLINE /
UNHOMED / HOMED), and a single Home button in the actions column
(the cog and map-marker columns are placeholders for layout).
**Settings view**
A "W Axis (auxcnc)" section exposes every aux.json field except
`enabled` (which stays read-only - flipping the A axis on/off requires
editingaux.json on the controller, so a fresh install can't surprise
the user with hardware that isn't there). Saving PUTs the merged
config to `/api/aux/config/save`, which writes aux.json and pushes
`HOMECFG` to the ESP. A status line shows whether the axis is
disabled / offline / connected-unhomed / homed at `<pos> mm`.
## State surface
These are pushed via `state.set` and visible in the websocket stream:
- `aux_enabled` - bool, axis is configured + enabled
- `aux_present` - bool, ESP responding on serial
- `aux_homed` - bool, has been homed since last ESP reset
- `aux_pos` - float, current W in mm (4 decimals)
## Edge cases
- **ESP reboots mid-job**: `[boot] auxcnc v=N` banner -> `aux_homed`
cleared, message added: "A axis controller restarted - re-home
before use". Subsequent A moves still run; if you want a hard fail
instead, that's a one-line change in `_require_present`.
- **Limit switch closed at boot of HOME**: `[home] failed
reason=already_at_limit` -> hook raises -> Mach surfaces error.
- **Pause mid-W-move**: the hook is blocking, so feed-hold takes
effect *after* the A move completes. For an immediate stop hit
estop; the Hooks listener will call `aux.abort()` which sends
`ABORT\n` to the ESP and the step-pulse loop exits.
- **Connection loss**: if `/dev/ttyUSB0` can't be opened at startup,
`aux_present=False` and any G-code with W will fail-fast at the
hook handler with "Aux axis not connected".
- **No home enforcement**: per design, manual jogs and A moves are
allowed even without a successful home. Soft limits still apply
unless you use the raw step jog endpoint.
## Files added/changed
- `src/py/bbctrl/AuxAxis.py` (new): serial worker + RPC layer
- `src/py/bbctrl/AuxPreprocessor.py` (new): G-code rewriter
- `src/py/bbctrl/Hooks.py`: register_internal(), fix the messages
listener so `(MSG,HOOK:...)` actually fires
- `src/py/bbctrl/Ctrl.py`: instantiate AuxAxis, register hooks
- `src/py/bbctrl/Mach.py`: rewrite MDI commands containing W
- `src/py/bbctrl/FileHandler.py`: rewrite uploads in place
- `src/py/bbctrl/Web.py`: REST endpoints
- `src/py/bbctrl/__init__.py`: export AuxAxis
- `src/pug/templates/control-view.pug`: W jog row + DRO row
- `src/js/control-view.js`: aux_home / aux_jog / aux_jog_incr handlers
- `src/js/axis-vars.js`: `_compute_aux_axis` for W state
- `src/svelte-components/src/components/WAxisSettings.svelte`: settings panel
- `src/svelte-components/src/components/SettingsView.svelte`: hosts WAxisSettings
- `auxcnc/src/main.cpp`: new commands HOME, HOMECFG, WPOS, HOMED?,
LIMIT?, ABORT-able STEPS with limit-aware abort, trapezoidal ramps,
NVS-persisted config, `[boot]` banner, deterministic reply tokens

View File

@@ -9,6 +9,9 @@
# * The full V09 chrome (header tabs, settings rail, jog grid, DRO # * The full V09 chrome (header tabs, settings rail, jog grid, DRO
# skeleton, status strip). # skeleton, status strip).
# * A "DISCONNECTED" overlay because there's no controller backend. # * A "DISCONNECTED" overlay because there's no controller backend.
# * The A axis row in jog/DRO is hidden (correct: it appears only when
# the controller reports `aux_enabled = true`). To exercise the A
# axis end-to-end, deploy to the Pi (`./deploy.sh hardware`).
set -euo pipefail set -euo pipefail

20
src/js/a-axis-view.js Normal file
View File

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

View File

@@ -391,6 +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",
"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) {
@@ -626,6 +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",
"a-axis",
]; ];
if (head == "control") { if (head == "control") {
@@ -687,6 +689,13 @@ 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
// own persistence (A axis -> aux.json, etc.) that
// the user just hit the master Save button. They
// listen for `onefin:save-all` and PUT their state.
try {
window.dispatchEvent(new CustomEvent("onefin:save-all"));
} catch (_e) {}
this.modified = false; this.modified = false;
} catch (error) { } catch (error) {
console.error("Save failed:", error); console.error("Save failed:", error);

View File

@@ -32,6 +32,10 @@ module.exports = {
return this._compute_axis("c"); return this._compute_axis("c");
}, },
w: function() {
return this._compute_aux_axis();
},
axes: function() { axes: function() {
return this._compute_axes(); return this._compute_axes();
} }
@@ -52,7 +56,12 @@ module.exports = {
const abs = this.state[`${axis}p`] || 0; const abs = this.state[`${axis}p`] || 0;
const off = this.state[`offset_${axis}`]; const off = this.state[`offset_${axis}`];
const motor_id = this._get_motor_id(axis); const motor_id = this._get_motor_id(axis);
const motor = motor_id == -1 ? {} : this.config.motors[motor_id]; // motor_id may be 4 for the synthetic external-axis motor;
// there is no entry for it in config.motors so guard with
// an empty object to avoid undefined property access.
const motor = (motor_id == -1
? {}
: (this.config.motors[motor_id] || {}));
const enabled = this._check_is_enabled(axis); const enabled = this._check_is_enabled(axis);
const homingMode = motor["homing-mode"]; const homingMode = motor["homing-mode"];
const homed = this.state[`${motor_id}homed`]; const homed = this.state[`${motor_id}homed`];
@@ -185,24 +194,114 @@ module.exports = {
_get_motor_id: function(axis) { _get_motor_id: function(axis) {
for (let i = 0; i < this.config.motors.length; i++) { for (let i = 0; i < this.config.motors.length; i++) {
const motor = this.config.motors[i]; const motor = this.config.motors[i];
if (motor.axis.toLowerCase() == axis) { // motor.axis can be undefined on initial load before
// config has streamed in. Guard so the computed does
// not throw and bubble a Vue warning into the console.
if (motor && typeof motor.axis === "string" &&
motor.axis.toLowerCase() == axis) {
return i; return i;
} }
} }
// Synthetic external motor (index 4) used by ExternalAxis
// to expose the auxcnc ESP stepper as a virtual axis.
// Its `Nan` lives in state, not config.
const axes = { x: 0, y: 1, z: 2, a: 3, b: 4, c: 5 };
const wanted = axes[axis];
const extAn = this.state && this.state["4an"];
if (typeof wanted === "number" && typeof extAn === "number"
&& extAn === wanted) {
return 4;
}
return -1; return -1;
}, },
_check_is_enabled: function(axis){ _check_is_enabled: function(axis){
// Prefer config.motors[i].axis (always present once the
// config has loaded). Fall back to the per-motor state
// `Nan` field, which is what the legacy UI used. This
// avoids hiding axis rows during the brief window after
// config has loaded but before the controller has pushed
// its first state delta.
const axes = { x: 0, y: 1, z: 2, a: 3 }; const axes = { x: 0, y: 1, z: 2, a: 3 };
for(let i = 0; i < this.config.motors.length; i++){ const wanted = axes[axis];
if(this.state[`${i}an`] == axes[axis]){ for (let i = 0; i < this.config.motors.length; i++) {
const motor = this.config.motors[i] || {};
if (typeof motor.axis === "string" &&
motor.axis.toLowerCase() == axis) {
return motor.enabled !== false;
}
// Only use the state Nan fallback for axes we know
// about (x/y/z/a). Otherwise undefined == undefined
// would mistakenly match every axis (b, c, ...).
if (typeof wanted === "number") {
const an = this.state[`${i}an`];
if (typeof an === "number" && an === wanted) {
return true;
}
}
}
// Synthetic external motor (index 4) - the auxcnc ESP
// stepper exposed as A via ExternalAxis.
if (typeof wanted === "number") {
const extAn = this.state["4an"];
const extMe = this.state["4me"];
if (typeof extAn === "number" && extAn === wanted
&& extMe) {
return true; return true;
} }
} }
return false; return false;
}, },
_compute_aux_axis: function() {
// Auxiliary axis driven by the auxcnc ESP32 (typically
// exposed to gplan as A). Position, homed flag and
// presence come from the bbctrl AuxAxis driver via
// state.aux_*. No motor mapping, no soft-limit warnings
// on toolpath bounds (auxcnc enforces its own).
const enabled = !!this.state.aux_enabled;
const present = !!this.state.aux_present;
const homed = !!this.state.aux_homed;
const pos = this.state.aux_pos || 0;
let klass = `${homed ? "homed" : "unhomed"} axis-w`;
let state = present ? "UNHOMED" : "OFFLINE";
let icon = present ? "question-circle" : "plug";
let title = present
? "Click the home button to home the auxiliary axis."
: "Aux controller not connected on /dev/ttyUSB0.";
if (homed) {
state = "HOMED";
icon = "check-circle";
title = "Auxiliary axis successfully homed.";
} else if (!present) {
klass += " error";
}
return {
pos: pos,
abs: pos,
off: 0,
min: 0, max: 0, dim: 0,
pathMin: 0, pathMax: 0, pathDim: 0,
motor: -1,
enabled: enabled,
homingMode: "limit-switch",
homed: homed,
klass: klass,
state: state,
icon: icon,
title: title,
ticon: "check-circle",
tstate: "OK",
toolmsg: "Auxiliary axis is not constrained by tool path bounds.",
tklass: `${homed ? "homed" : "unhomed"} axis-w`,
isAux: true,
};
},
_compute_axes: function() { _compute_axes: function() {
let homed = false; let homed = false;

View File

@@ -249,13 +249,83 @@ module.exports = {
api.put(`home/${axis}/clear`); api.put(`home/${axis}/clear`);
}, },
aux_home: function () {
api.put("aux/home").catch(function (err) {
console.error("Aux home failed:", err);
});
},
// 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
// 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
// homing completes, so we have to watch state.cycle:
// - first wait for it to *leave* 'idle' (cycle began),
// - then wait for it to come *back* to 'idle' (cycle ended).
// Only then do we fire the auxiliary home, so the gantry and the
// 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 {
await api.put("home"); await api.put("home");
} catch (e) { } catch (e) {
console.error("Home all failed:", e); console.error("Home all (XYZ) failed:", e);
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 cycleNow = () => (this.state && this.state.cycle) || "idle";
// Phase 1: wait up to 5s for the homing cycle to actually start.
// If the request was rejected upstream (e.g. estopped) cycle
// never leaves idle and we bail rather than home A in isolation.
const startedAt = Date.now();
while (Date.now() - startedAt < 5000) {
if (cycleNow() != "idle") break;
await wait(100);
}
if (cycleNow() == "idle") {
console.warn("home_all: main homing cycle never started; skipping aux");
return;
}
// Phase 2: wait up to 2 minutes for the gantry to finish.
const settledAt = Date.now();
while (Date.now() - settledAt < 120000) {
if (cycleNow() == "idle") break;
await wait(200);
}
if (cycleNow() != "idle") {
console.warn("home_all: gantry homing did not complete in time");
return;
}
api.put("aux/home").catch(function (err) {
console.error("Aux home failed:", err);
});
},
aux_jog: function (delta_mm) {
api.put("aux/jog", { mm: delta_mm }).catch(function (err) {
console.error("Aux jog failed:", err);
});
},
aux_jog_incr: function (sign) {
const amount = this.jog_incr_amounts[this.display_units][this.jog_incr];
const delta_mm = sign * (this.metric ? amount : amount * 25.4);
this.aux_jog(delta_mm);
}, },
show_set_position: function (axis) { show_set_position: function (axis) {

View File

@@ -232,6 +232,17 @@ module.exports = {
const toolpath = await api.get(`path/${file}`); const toolpath = await api.get(`path/${file}`);
this.toolpath_progress = toolpath.progress; this.toolpath_progress = toolpath.progress;
// Planner failure (e.g. AuxPreprocessor Z-A coupling
// rejection). Close the dialog and surface the message
// instead of polling the same broken plan forever.
if (toolpath.error) {
this.showGcodeMessage = false;
this.toolpath_progress = 0;
console.error("Plan failed:", toolpath.error);
alert("Could not plan G-code:\n\n" + toolpath.error);
return;
}
if (toolpath.progress === 1 || typeof toolpath.progress == "undefined") { if (toolpath.progress === 1 || typeof toolpath.progress == "undefined") {
this.showGcodeMessage = false; this.showGcodeMessage = false;
@@ -248,7 +259,11 @@ module.exports = {
} }
} }
} catch (error) { } catch (error) {
// api.get throws on non-2xx; log and break the loop so the
// dialog doesn't stay up forever.
console.error(error); console.error(error);
this.showGcodeMessage = false;
return;
} }
} }
}, },

View File

@@ -24,6 +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"),
"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 () {
@@ -56,6 +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" },
// Auxiliary axis (auxcnc ESP32 - exposed to gplan as A).
// Mounts the AAxisSettings Svelte component on its own page.
{ 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" },
], ],
@@ -133,6 +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._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.
@@ -155,6 +160,7 @@ module.exports = {
requestAnimationFrame(reset); requestAnimationFrame(reset);
}, 320); }, 320);
} else { } else {
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,8 +92,33 @@ 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 — A axis (rotary) when rotary is enabled. // Row 4 — A axis (the auxcnc-driven external axis) when enabled.
template(v-if="state['2an'] == 3") // A- | A+ | Probe XYZ | Probe Z
// "Home A" lives in the DRO table's actions column on the
// right, so it doesn't need a tile here. The legacy w.enabled
// gate is kept so older installs (where the auxcnc axis still
// 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
span.lbl A
button.jbtn(@click="aux_jog_incr(+1)",
:disabled="!(w.enabled || a.enabled)")
.fa.fa-arrow-up.ico
span.lbl A+
button.jbtn(@click="showProbeDialog('xyz')",
:class="{'load-on': !state['pw']}")
.fa.fa-bullseye.ico
span.lbl Probe XYZ
button.jbtn(@click="showProbeDialog('z')",
:class="{'load-on': !state['pw']}")
.fa.fa-bullseye.ico
span.lbl Probe Z
// Row 4 — A axis (rotary) when no W and rotary is enabled
// (Vue 1 has no v-else-if; we negate w.enabled explicitly.)
template(v-if="!w.enabled && state['2an'] == 3")
button.jbtn.dir(@click="jog_fn(0, 0, 0, -1)") button.jbtn.dir(@click="jog_fn(0, 0, 0, -1)")
.fa.fa-rotate-left.ico .fa.fa-rotate-left.ico
span.lbl A span.lbl A
@@ -109,7 +134,7 @@ script#control-view-template(type="text/x-template")
span.lbl Probe span.lbl Probe
// Row 4 — fallback probe / zero / home shortcuts // Row 4 — fallback probe / zero / home shortcuts
template(v-if="state['2an'] != 3") template(v-if="!w.enabled && state['2an'] != 3")
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
@@ -193,7 +218,8 @@ script#control-view-template(type="text/x-template")
.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). // enabled axis (legacy Onefinity behavior). Auto-includes
// 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
@@ -223,6 +249,28 @@ script#control-view-template(type="text/x-template")
@click=`home('${axis}')`) @click=`home('${axis}')`)
.fa.fa-home .fa.fa-home
// Legacy auxiliary-axis row - shown only when the auxcnc stepper is
// *not* exposed as a virtual A axis. After v2 the standard
// A row above renders this axis natively (with full offset
// + set-position support); this row only appears on legacy
// installs that haven't migrated yet.
.dro-row(:class="w.klass + ' ' + w.tklass",
v-if="w.enabled && !a.enabled",
:title="w.title")
.dro-axis.axis-w W
.dro-pos: unit-value(:value="w.pos", precision=4)
.dro-sec: unit-value(:value="w.abs", precision=3)
.dro-sec —
.actions-cell
button.icon-btn(disabled, style="visibility:hidden")
.fa.fa-gear
button.icon-btn(disabled, style="visibility:hidden")
.fa.fa-location-dot
button.icon-btn(:class="w.homed ? 'state-green' : 'state-amber'",
:disabled="!w.enabled",
title="Home auxiliary axis.", @click="aux_home()")
.fa.fa-home
// ----- Status strip ----- // ----- Status strip -----
.status-strip .status-strip
.stat-card .stat-card

View File

@@ -46,6 +46,8 @@ 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")
a-axis-view(v-if="sub === 'a-axis' && config_ready",
: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")
help-view(v-if="sub === 'help' && config_ready", help-view(v-if="sub === 'help' && config_ready",

798
src/py/bbctrl/AuxAxis.py Normal file
View File

@@ -0,0 +1,798 @@
################################################################################
#
# AuxAxis - W-axis serial driver for the auxcnc ESP32 controller
#
# Owns /dev/ttyUSB0 (or whatever serial.port is configured to). Provides
# blocking RPCs for use from a hook thread. Maintains:
#
# - aux_present : True if serial is open and we've seen a boot banner
# - aux_homed : True if we've successfully run HOME since last reset
# - aux_pos : current logical position in mm (from ESP step counter
# * (1 / steps_per_mm * dir_sign))
#
# Real-time decisions (limit switch monitoring, step pulse generation) live
# on the ESP. The host is responsible for units, soft limits, and tracking
# whether we've ever boot-cycled the ESP since last home.
#
################################################################################
import os
import json
import time
import threading
import traceback
try:
import serial
except ImportError:
serial = None
# Default config; overridden by ./aux.json or ctrl.config.
DEFAULTS = {
'enabled': False,
'port': '/dev/ttyUSB0',
'baud': 115200,
'steps_per_mm': 80.0, # logical steps per mm of axis travel
'dir_sign': 1, # +1 or -1: maps logical+ to motor+ steps
# Logical axis letter exposed to gplan. The auxcnc ESP stepper
# is presented to the planner as this axis (default 'a' = standard
# 4th axis). gcode uses A for moves; the host ExternalAxis layer
# forks A motion to the ESP transparently.
'axis_letter': 'a',
'min_mm': 0.0, # soft limit min (mm), exposed as 4tn
'max_mm': 100.0, # soft limit max (mm), exposed as 4tm
# Per-axis kinematic limits used to populate the planner's config.
# Units match the bbctrl/onefinity per-motor convention so the
# values are directly comparable to motors 0-3:
# max_velocity_m_per_min m/min (planner sees * 1000 = mm/min)
# max_accel_km_per_min2 km/min2 (planner sees * 1e6 = mm/min2)
# max_jerk_km_per_min3 km/min3 (planner sees * 1e6 = mm/min3)
'max_velocity_m_per_min': 6.0,
'max_accel_km_per_min2': 100.0,
'max_jerk_km_per_min3': 500.0,
# Informational only - rate caps that actually clamp the move
# are on the ESP via step_max_sps below.
'max_feed_mm_min': 600.0,
'home_dir': '-', # which direction is "toward limit" (host's view)
'home_position_mm': 0.0, # mm value to assign at home
# ESP-side homing rates (steps/sec). Pushed via HOMECFG on connect.
# Speeds tuned for a typical 25 steps/mm aux drive (so 1 step =
# 0.04 mm). With the limit-aware ESP firmware these values give
# a brisk seek (100 mm/s), enough backoff to clear the switch
# hysteresis (16 mm), and a slow re-engage (10 mm/s) that's
# accurate without being painfully slow on a longer axis.
'home_fast_sps': 2500, # ≈ 100 mm/s @ 25 steps/mm
'home_slow_sps': 250, # ≈ 10 mm/s
'home_backoff_steps': 400, # ≈ 16 mm
'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_accel_sps2': 12000,
'step_start_sps': 200,
'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
}
class AuxAxisError(Exception):
pass
class AuxAxis(object):
def __init__(self, ctrl):
self.ctrl = ctrl
self.log = ctrl.log.get('AuxAxis')
self._cfg = dict(DEFAULTS)
self._load_config()
self._sp = None
self._sp_lock = threading.Lock() # serial write/RPC serialization
self._rx_lock = threading.Lock() # read-line buffer access
self._reader_thread = None
self._stop = threading.Event()
# Pending replies waiting for a [topic] line. Single-slot since we
# serialize RPCs via _sp_lock.
self._pending_topics = []
self._pending_replies = []
self._pending_cv = threading.Condition()
# Async lines that aren't replies (e.g. logs) are simply logged.
self._present = False
self._homed = False
self._pos_steps = 0 # ESP step counter mirror
# Publish initial state
self._publish_state()
if not self._cfg['enabled']:
self.log.info('Aux axis disabled in config')
return
if serial is None:
self.log.error('pyserial not available; aux axis disabled')
return
self._open()
# ------------------------------------------------------------------ config
def _config_path(self):
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):
path = self._config_path()
if os.path.exists(path):
try:
with open(path) as f:
user = json.load(f)
migrated = self._migrate_legacy_fields(user)
# Be permissive; ignore unknown keys.
for k, v in user.items():
if k in self._cfg:
self._cfg[k] = v
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:
self.log.error('Failed to read aux.json: %s'
% traceback.format_exc())
def save_config(self, cfg):
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():
if k in DEFAULTS:
merged[k] = v
path = self._config_path()
with open(path, 'w') as f:
json.dump(merged, f, indent=2)
self._cfg = merged
self.log.info('Saved aux config')
# Push the relevant pieces to the ESP if connected.
if self._present:
try:
self._push_homecfg()
except Exception as e:
self.log.warning('Could not push HOMECFG after save: %s' % e)
def get_config(self):
return dict(self._cfg)
# ------------------------------------------------------------------ public
@property
def enabled(self):
return bool(self._cfg.get('enabled', False))
@property
def present(self):
return self._present
@property
def homed(self):
return self._homed
@property
def position_mm(self):
return self._steps_to_mm(self._pos_steps)
def set_state_observer(self, fn):
"""Register a callback invoked after every _publish_state.
Used by ExternalAxis to mirror the homed flag into State."""
self._state_observer = fn
def home(self):
"""Run the homing cycle on the ESP. Blocks until done. Raises on
failure. Updates aux_homed and aux_pos.
The ESP's home_zero is pre-loaded via HOMECFG so when the cycle
completes the step counter already corresponds to home_position_mm.
That way the homed-state survives a bbctrl restart correctly
(we don't need a post-home WPOS write, which would clear HOMED)."""
self._require_present()
# Make sure home_zero on the ESP matches our current
# home_position_mm in case the user just edited config.
self._push_homecfg()
line = self._rpc('HOME', topic='home', timeout=120.0)
# line is the body after '[home] '. Only terminal lines use
# the [home] topic now (done / failed); progress is [home_log].
if line.startswith('done'):
self._pos_steps = self._parse_kv_int(line, 'pos', 0)
self._homed = True
self._publish_state()
return
# failure
reason = line.split('reason=', 1)[1] if 'reason=' in line else line
raise AuxAxisError('Homing failed: %s' % reason)
def move_abs_mm(self, target_mm):
"""Move to absolute logical W position (mm). Blocks until done."""
self._require_present()
self._check_limits(target_mm)
target_steps = self._mm_to_steps(target_mm)
delta = target_steps - self._pos_steps
if delta == 0:
return
self._do_steps(delta)
def move_rel_mm(self, delta_mm):
"""Move by delta mm relative to current position. Blocks until done."""
self._require_present()
target_mm = self.position_mm + delta_mm
self._check_limits(target_mm)
target_steps = self._mm_to_steps(target_mm)
delta = target_steps - self._pos_steps
if delta == 0:
return
self._do_steps(delta)
def set_position_mm(self, mm):
"""Set current W to <mm> without moving (G92-style for W)."""
self._require_present()
steps = self._mm_to_steps(mm)
self._rpc('WPOS %d' % steps, topic='ok', timeout=2.0)
self._pos_steps = steps
# WPOS clears homed on the ESP; mirror it.
self._homed = False
self._publish_state()
def jog_steps(self, steps):
"""Raw step move bypassing mm conversion and soft limits.
Used by manual jog UI when axis isn't homed yet."""
self._require_present()
if steps == 0:
return
self._do_steps(int(steps), ignore_limits=True)
# ----------------------------------------------- continuous-rate jog
#
# Hold-to-jog support for the gamepad pendant. JOG / JOGSTOP on
# the ESP give a smooth ramp-up, cruise-until-released, ramp-down
# profile - much better than streaming small STEPS chunks.
#
# `jog_start` returns immediately after the ESP acknowledges with
# `[jog] started ...`. The terminal `[jog] done count=<n>
# pos=<p>` arrives later; our reader picks it up and resyncs
# _pos_steps via the same path as STEPS.
def jog_start(self, direction, max_rate_sps=None,
accel_sps2=None, ignore_limits=False,
target_steps=None):
"""Begin a continuous-rate jog. `direction` is +1 or -1.
Returns once the ESP has accepted the JOG command.
target_steps (optional): a signed step-counter value. The
ESP picks the deceleration start point so the motor ramps
smoothly from the current cruise rate to step_start_rate
and stops AT this counter value. Used to enforce host-side
soft limits without overshoot. The target must be on the
side of the current g_pos that matches `direction`; the
ESP rejects a wrong-side target with reason=softlimit."""
self._require_present()
if direction not in (-1, +1):
raise AuxAxisError('jog_start direction must be +/-1')
sign = '+' if direction > 0 else '-'
rate = (int(max_rate_sps) if max_rate_sps is not None
else int(self._cfg['step_max_sps']))
accel = (int(accel_sps2) if accel_sps2 is not None
else int(self._cfg['step_accel_sps2']))
if rate < 1: rate = 1
if accel < 1: accel = 1
cmd = 'JOG dir=%s maxrate=%d accel=%d safe=%d' % (
sign, rate, accel, 0 if ignore_limits else 1)
if target_steps is not None:
cmd += ' target=%d' % int(target_steps)
# Capture both the immediate ack AND the eventual terminal
# line in a single _rpc call would block; instead fire the
# ack-only RPC here and let _on_line handle the terminal
# `[jog] done` async (it falls through to the info log path,
# but we hook _on_line to update _pos_steps).
line = self._rpc(cmd, topic='jog', timeout=2.0)
if line.startswith('error'):
raise AuxAxisError('JOG rejected: %s' % line)
if not line.startswith('started'):
# Could be "done count=0 pos=..." if a near-instant abort
# raced; treat as completed.
self._pos_steps = self._parse_kv_int(
line, 'pos', self._pos_steps)
self._publish_state()
# else: cruising, terminal [jog] reply will arrive later.
def jog_stop(self):
"""Request the running JOG to ramp down to a stop. Returns
immediately; the terminal `[jog] done` arrives async and is
picked up by `_on_line` to resync _pos_steps.
Like abort(), this does NOT take the RPC lock - JOGSTOP is
the on-release path of a hold-to-jog UI and must not block
on whatever else is in flight."""
if not self._present:
return
try:
self.log.info('aux >> JOGSTOP')
self._send_raw('JOGSTOP')
except Exception as e:
self.log.warning('JOGSTOP send failed: %s' % e)
def abort(self):
"""Cancel any running ESP motion immediately."""
if not self._present:
return
try:
# Don't take the RPC lock; ABORT must be able to interrupt.
self._send_raw('ABORT')
except Exception as e:
self.log.warning('ABORT send failed: %s' % e)
# ---------------------------------------------------------- ATC atoms
#
# The auxcnc firmware drives an AMB 1050 FME-W DI tool changer via
# two pneumatic valves on relays 1-2:
# V1 (clamp, 3/2 valve) - relay 2: ON = collet open, OFF = vent + spring closes
# V2 (ejector) - relay 1: ON = ejector cylinder extends
#
# The host exposes three composable atoms - RELEASE, CLAMP, EJECT -
# and composes drop/grab sequences from G-code macros that call
# them in order. (Older firmware exposed monolithic DROPTOOL /
# GRABTOOL verbs; protocol v3 dropped them in favour of these
# atoms so callers can interleave Z moves between ejector pulses.)
def atc_release(self, timeout=5.0):
"""Open the collet (V1 on). Instant. Idempotent. Pairs with
atc_clamp() to bracket a sequence of host-side moves and/or
ejector pulses with the collet held open."""
self._require_present()
line = self._rpc('RELEASE', topic='release', timeout=timeout)
if line.startswith('done'):
return
reason = line.split('reason=', 1)[1] if 'reason=' in line else line
raise AuxAxisError('RELEASE failed: %s' % reason)
def atc_clamp(self, timeout=10.0):
"""Close the collet: V1 off, then dwell for the line to vent
and the spring to re-engage. Idempotent."""
self._require_present()
line = self._rpc('CLAMP', topic='clamp', timeout=timeout)
if line.startswith('done'):
return
reason = line.split('reason=', 1)[1] if 'reason=' in line else line
raise AuxAxisError('CLAMP failed: %s' % reason)
def atc_eject(self, pulse_ms=None, dwell_ms=None, timeout=10.0):
"""One ejector wiggle: V2 on for pulse_ms, then off for
dwell_ms. The collet (V1) is left in whatever state the caller
set it to via atc_release/atc_clamp - typically RELEASE first
so the holder can actually drop.
Repeatedly calling atc_eject gives the wiggle that the old
monolithic DROPTOOL did internally, but as discrete blocking
calls so a macro can interleave Z moves between pulses.
pulse_ms / dwell_ms default to the ESP-side defaults
(currently 500 / 500). Pass explicit values to override."""
self._require_present()
parts = ['EJECT']
if pulse_ms is not None: parts.append('pulse=%d' % int(pulse_ms))
if dwell_ms is not None: parts.append('dwell=%d' % int(dwell_ms))
cmd = ' '.join(parts)
line = self._rpc(cmd, topic='eject', timeout=timeout)
if line.startswith('done'):
return
reason = line.split('reason=', 1)[1] if 'reason=' in line else line
raise AuxAxisError('EJECT failed: %s' % reason)
def close(self):
self._stop.set()
try:
if self._sp is not None:
self._sp.close()
except Exception:
pass
# ------------------------------------------------------------------ guts
def _require_present(self):
if not self.enabled:
raise AuxAxisError('Aux axis disabled')
if not self._present:
raise AuxAxisError('Aux axis not connected')
def _check_limits(self, target_mm):
lo = float(self._cfg['min_mm'])
hi = float(self._cfg['max_mm'])
if hi <= lo:
return # no limits
if target_mm < lo - 1e-6 or target_mm > hi + 1e-6:
raise AuxAxisError(
'A=%.3f out of soft limits [%.3f, %.3f]' % (target_mm, lo, hi))
def _mm_to_steps(self, mm):
spm = float(self._cfg['steps_per_mm'])
sign = 1 if int(self._cfg.get('dir_sign', 1)) >= 0 else -1
return int(round(mm * spm * sign))
def _steps_to_mm(self, steps):
spm = float(self._cfg['steps_per_mm']) or 1.0
sign = 1 if int(self._cfg.get('dir_sign', 1)) >= 0 else -1
return (steps / spm) * sign
def _do_steps(self, signed_count, ignore_limits=False):
max_rate = int(self._cfg['step_max_sps'])
accel = int(self._cfg['step_accel_sps2'])
safe_flag = 0 if ignore_limits else 1
cmd = 'STEPS %d maxrate=%d accel=%d safe=%d' % (
signed_count, max_rate, accel, safe_flag)
line = self._rpc(cmd, topic='step', timeout=300.0)
# line: "done count=N pos=P limit=L" or "aborted count=N pos=P [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)
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
def _open(self):
port = self._cfg['port']
baud = int(self._cfg['baud'])
try:
self._sp = serial.Serial(port, baud, timeout=0.2)
except Exception as e:
self.log.error('Could not open %s: %s' % (port, e))
self._sp = None
return
self.log.info('Opened %s @ %d' % (port, baud))
self._reader_thread = threading.Thread(
target=self._reader_loop, name='AuxAxis-rx', daemon=True)
self._reader_thread.start()
# Give the ESP a moment to settle, then push HOMECFG and query state.
# This runs in a background thread to avoid blocking startup.
threading.Thread(target=self._on_connect, daemon=True).start()
def _on_connect(self):
time.sleep(0.5)
try:
self._push_homecfg()
self._refresh_state()
except Exception as e:
self.log.warning('Aux post-connect setup failed: %s' % e)
def _push_homecfg(self):
c = self._cfg
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 '
'zero=%d accel=%d step_max=%d step_start=%d limit_low=%d '
'preclear=%d') % (
c['home_dir'],
int(c['home_fast_sps']),
int(c['home_slow_sps']),
int(c['home_backoff_steps']),
int(c['home_maxtravel_steps']),
int(zero_steps),
int(c['step_accel_sps2']),
int(c['step_max_sps']),
int(c['step_start_sps']),
1 if c['limit_low'] else 0,
preclear_steps,
)
self._rpc(cmd, topic='homecfg', timeout=3.0)
def _refresh_state(self):
try:
r = self._rpc('WPOS?', topic='wpos', timeout=2.0)
self._pos_steps = int(r.strip())
except Exception:
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:
self._rpc('UNHOME', topic='ok', timeout=2.0)
self._homed = False
except Exception:
# 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()
def _reader_loop(self):
buf = b''
while not self._stop.is_set():
sp = self._sp
if sp is None:
time.sleep(0.5)
continue
try:
chunk = sp.read(256)
except Exception as e:
self.log.warning('Aux serial read error: %s' % e)
time.sleep(0.5)
continue
if not chunk:
continue
buf += chunk
while True:
nl = buf.find(b'\n')
if nl < 0:
break
line = buf[:nl].rstrip(b'\r').decode('utf-8', errors='replace')
buf = buf[nl+1:]
self._on_line(line)
def _on_line(self, line):
if not line:
return
# Boot banner -> reset homed flag.
if line.startswith('[boot]'):
self.log.warning('Aux ESP booted: %s' % line)
self._homed = False
self._present = True
self._publish_state()
self.ctrl.state.add_message(
'Auxiliary axis controller restarted - re-home before use')
return
# Topic dispatch: "[topic] body..."
if line.startswith('[') and ']' in line:
rb = line.index(']')
topic = line[1:rb]
body = line[rb+1:].lstrip()
# Mark present on first known topic.
if not self._present:
self._present = True
self._publish_state()
# Match against the head of the pending queue.
with self._pending_cv:
if (self._pending_topics
and topic in self._pending_topics[0]):
# Pop and deliver
self._pending_topics.pop(0)
self._pending_replies.append(body)
self._pending_cv.notify_all()
return
# Async informational line.
#
# The terminal [jog] done|aborted line for a continuous
# JOG arrives long after the JOG _rpc returned (the JOG
# _rpc only waits for the immediate `[jog] started`
# ack). Use this async path to keep _pos_steps in sync
# so subsequent moves compute the correct delta.
if topic == 'jog' and ('pos=' in body):
try:
self._pos_steps = self._parse_kv_int(
body, 'pos', self._pos_steps)
if 'reason=limit' in body:
self._homed = False
self._publish_state()
except Exception:
pass
self.log.info('aux: %s' % line)
else:
self.log.info('aux: %s' % line)
def _send_raw(self, cmd):
sp = self._sp
if sp is None:
raise AuxAxisError('Serial not open')
if not cmd.endswith('\n'):
cmd = cmd + '\n'
sp.write(cmd.encode('utf-8'))
sp.flush()
def _rpc(self, cmd, topic, timeout=5.0):
"""Send `cmd`, wait for a reply line whose topic is in `topic`.
topic may be a single string or a tuple/list of acceptable topics
(e.g. ('home', 'err'))."""
if isinstance(topic, str):
topics = (topic, 'err')
else:
topics = tuple(topic) + ('err',)
with self._sp_lock:
with self._pending_cv:
self._pending_topics.append(topics)
self._pending_replies = [] # reset
self.log.info('aux >> %s' % cmd.strip())
self._send_raw(cmd)
deadline = time.time() + timeout
with self._pending_cv:
while not self._pending_replies:
remaining = deadline - time.time()
if remaining <= 0:
# Drop the pending slot so we don't capture a
# late reply meant for the next caller.
try:
self._pending_topics.remove(topics)
except ValueError:
pass
raise AuxAxisError(
'Timeout waiting for %s reply to "%s"'
% (topics, cmd.strip()))
self._pending_cv.wait(timeout=remaining)
reply = self._pending_replies.pop(0)
self.log.info('aux << %s' % reply)
if reply.startswith('err') or reply.startswith('error'):
raise AuxAxisError('ESP error: %s' % reply)
return reply
@staticmethod
def _parse_kv_int(line, key, default=0):
# Parse "key=N" (signed integer) out of a line.
for tok in line.split():
if tok.startswith(key + '='):
try:
return int(tok.split('=', 1)[1])
except ValueError:
return default
return default
@staticmethod
def _parse_kv_str(line, key, default=''):
for tok in line.split():
if tok.startswith(key + '='):
return tok.split('=', 1)[1]
return default
# ------------------------------------------------------------ state push
def _publish_state(self):
st = self.ctrl.state
try:
st.set('aux_present', bool(self._present))
st.set('aux_homed', bool(self._homed))
st.set('aux_pos', round(self.position_mm, 4))
st.set('aux_enabled', bool(self.enabled))
except Exception:
# During very early startup, state may not be ready.
pass
# Notify the external-axis layer so it can mirror state
# (e.g. homed flag) into the synthetic motor vars.
observer = getattr(self, '_state_observer', None)
if observer is not None:
try:
observer()
except Exception:
pass

View File

@@ -0,0 +1,507 @@
################################################################################
#
# AuxPreprocessor - rewrite ATC M-codes into hook calls
#
# History
# -------
# v1: rewrote W tokens into (MSG,HOOK:aux:N) lines because the bbctrl
# planner only understood XYZABC and the W axis was driven via a
# side-channel.
# v2: W is now exposed to gplan as a virtual A axis (see ExternalAxis),
# so gplan handles W motion natively. The preprocessor no longer
# touches W tokens. ATC pneumatics still go through the hook
# channel because they're events, not motion.
# v3: ATC primitives split into atoms. The composite DROPTOOL /
# GRABTOOL hooks are gone; macros now compose tool changes from
# RELEASE / CLAMP / EJECT.
#
# What this still does
# --------------------
# Maps three user-defined M-codes onto pneumatic-tool-changer atoms:
#
# M100 EJECT -> (MSG,HOOK:eject:) one V2 ejector pulse
# M102 RELEASE -> (MSG,HOOK:release:) open collet (V1 on)
# M103 CLAMP -> (MSG,HOOK:clamp:) close collet (V1 off + vent)
#
# M101 (formerly GRABTOOL) is intentionally unmapped - it's now a
# pure host-side macro composed from RELEASE / dwell / CLAMP. If a
# legacy file still emits M101 the preprocessor leaves it alone and
# the planner ignores it (M101 is in the user-defined range, so it
# won't error - it just won't do anything).
#
# M100-M103 are in LinuxCNC/Buildbotics' user-defined range, so the
# planner won't error if the codes leak through unrewritten - it just
# won't *do* anything. We strip the recognized ones out and emit the
# matching hook line in their place.
#
# The preprocessor is intentionally conservative: anything it doesn't
# understand is left alone.
#
################################################################################
import os
import re
import shutil
import tempfile
# Strip line comments so we don't get fooled by "(M100 not really)".
# Note this is a simple regex and doesn't handle nested parentheses
# - which actually occur in real macro headers like
# `(Composed from atoms: M102 = RELEASE (V1 on), M103 = CLAMP)`.
# Use _strip_comments() below for a parser that does handle them.
_PAREN_COMMENT_RE = re.compile(r'\([^)]*\)')
def _strip_comments(line):
"""Return `line` with paren comments and the trailing semicolon
comment removed. Handles arbitrarily nested parentheses (RS274
technically forbids them but real-world gcode comments often
contain prose with parens, e.g. `(M102 = RELEASE (V1 on))`).
Returns just the executable code, with the original whitespace
preserved between tokens."""
out = []
depth = 0
i = 0
n = len(line)
while i < n:
c = line[i]
if c == ';' and depth == 0:
break
if c == '(':
depth += 1
i += 1
continue
if c == ')':
if depth > 0: depth -= 1
i += 1
continue
if depth == 0:
out.append(c)
i += 1
return ''.join(out)
# ATC pneumatics M-codes mapped onto hook events. M101 is
# deliberately unassigned (see header).
_ATC_M_CODES = {
100: 'eject',
102: 'release',
103: 'clamp',
}
_ATC_M_RE = re.compile(
r'(?<![A-Za-z_0-9])[Mm]\s*0*(' +
'|'.join(str(n) for n in _ATC_M_CODES) +
r')(?![\w.])'
)
# Detect a W axis token. We no longer rewrite W to A automatically;
# instead we warn so the user knows their old gcode needs migration.
# (The W support was removed when the axis was integrated as a real
# A axis through gplan.)
_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):
pass
class AuxPreprocessor(object):
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._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):
if self.log: self.log.info(msg)
def _warn(self, msg):
if self.log: self.log.warning(msg)
# ------------------------------------------------------------------ scan
@staticmethod
def file_uses_aux(path, coupling=None):
"""Quick check: does this file contain anything the preprocessor
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:
with open(path, 'r', encoding='utf-8', errors='replace') as f:
for line in f:
code = _strip_comments(line)
if _ATC_M_RE.search(code):
return True
if couple_active:
if _AXIS_TOKEN_RES['z'].search(code) or \
_AXIS_TOKEN_RES['a'].search(code):
return True
except Exception:
pass
return False
# Backwards-compat alias.
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
def process(self, src_path, dst_path):
"""Read src_path, write rewritten G-code to dst_path. Returns
True if any rewrite happened."""
rewrote_any = False
with open(src_path, 'r', encoding='utf-8', errors='replace') as fin, \
open(dst_path, 'w', encoding='utf-8') as fout:
for raw in fin:
line = raw.rstrip('\n')
# Comment-only or blank lines pass through verbatim.
code = _strip_comments(line)
if not code.strip():
fout.write(raw)
continue
# Warn (once) if the file still uses W tokens. The
# standard way is now G1 A<value>; old files must be
# migrated by hand.
if (not self._w_warned) and _W_TOKEN_RE.search(code):
self._warn('Found W axis token in gcode; W is no '
'longer recognized by bbctrl. Use A '
'instead. (warning suppressed for '
'subsequent W tokens in this file)')
self._w_warned = True
# Z-A coupling injection BEFORE the line is emitted.
if self._maybe_inject_a_down(code, fout):
rewrote_any = True
# ATC M-codes (M100/M102/M103). Match against the
# comment-stripped `code` so prose mentions like
# `(M102 = RELEASE)` inside a comment don't spuriously
# fire hooks. Each match emits a (MSG,HOOK:<event>:)
# line; the M-code is stripped from the executable
# residual but the original line's comments are kept
# for log readability.
atc_matches = list(_ATC_M_RE.finditer(code))
if atc_matches:
rewrote_any = True
for m in atc_matches:
try: num = int(m.group(1))
except ValueError: continue
event = _ATC_M_CODES.get(num)
if event:
# We need two things here that aren't
# naturally provided by the (MSG,...)
# transport:
#
# (1) Synchronization. (MSG,HOOK:...) is
# fire-and-forget from gplan's view -
# gplan emits the message and keeps
# streaming subsequent blocks (Z
# moves, the next eject, etc.) to the
# AVR. Meanwhile the hook handler
# runs the actual ESP RPC in a
# thread, and Z lifts while V2 is
# still wiggling. To make M-codes
# behave like proper blocking gcode,
# we precede each HOOK with M0
# (program pause). The Hooks layer
# registers the atom as block_unpause
# + auto_resume, so:
# M0 -> machine pauses
# (MSG,HOOK:event:) fires hook
# hook thread runs ESP RPC
# hook completes, auto-unpauses
# next block streams
# End result: M100/M102/M103 block
# until the ESP says done, just like
# a G-code dwell.
#
# (2) Block separation. gplan collapses
# consecutive comment-only lines
# into a single block, so back-to-
# back HOOK lines used to drop all
# but the last. M0 is its own block
# so this falls out automatically -
# the (MSG,...) attaches cleanly to
# each M0.
fout.write('M0 (MSG,HOOK:%s:)\n' % event)
code_stripped = _ATC_M_RE.sub('', code).strip()
if code_stripped:
# Mixed line: keep the residual executable
# gcode. Drop the comments to keep the
# rewritten file tidy (the original line's
# text already appears once as the input).
fout.write(code_stripped + '\n')
continue
# No rewrite needed.
fout.write(raw)
return rewrote_any
def preprocess_to_tempfile(src_path, log=None, coupling=None):
"""Run the preprocessor on `src_path` and return the path to a
rewritten temp file (or None if no rewriting was needed). Caller
owns the temp file and must os.unlink() it when done.
The original source file is never modified - this is the
intentional design: the macro / job file the operator authored
is what they see in the macro editor and the file viewer; the
rewriting happens only on the in-memory copy that gplan loads.
Why we rewrite at all: gplan (the camotics planner) treats the
user-defined M-codes M100/M102/M103 as no-ops. The only callback
channel it exposes during a running program is the (MSG,...)
message stream, so the only way for the host to react to those
M-codes mid-program is to substitute (MSG,HOOK:<event>:) lines
in their place. This rewriting is an implementation detail the
operator should never have to know about - hence the tempfile.
"""
if not AuxPreprocessor.file_uses_aux(src_path, coupling=coupling):
return None
pre = AuxPreprocessor(log=log, coupling=coupling)
fd, tmp = tempfile.mkstemp(prefix='auxpre_', suffix='.nc',
dir=os.path.dirname(src_path) or None)
os.close(fd)
try:
rewrote = pre.process(src_path, tmp)
if rewrote:
return tmp
os.unlink(tmp)
return None
except Exception:
try:
os.unlink(tmp)
except OSError:
pass
raise
def preprocess_file(src_path, log=None, coupling=None, **_unused):
"""DEPRECATED in-place version of the preprocessor. Kept for
callers that still rewrite their input on disk (chiefly the
upload path, where mutating the file is fine because there's no
operator-authored source to preserve).
Returns True if the file was rewritten, False otherwise.
For new callers prefer preprocess_to_tempfile() which never
touches the source."""
tmp = preprocess_to_tempfile(src_path, log=log, coupling=coupling)
if tmp is None:
return False
try:
shutil.move(tmp, src_path)
except Exception:
try:
os.unlink(tmp)
except OSError:
pass
raise
return True

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

@@ -71,6 +71,24 @@ class Ctrl(object):
self.jog = bbctrl.Jog(self) self.jog = bbctrl.Jog(self)
with Trace.span('ctrl.pwr'): with Trace.span('ctrl.pwr'):
self.pwr = bbctrl.Pwr(self) self.pwr = bbctrl.Pwr(self)
with Trace.span('ctrl.hooks'):
self.hooks = bbctrl.Hooks(self)
with Trace.span('ctrl.aux'):
self.aux = bbctrl.AuxAxis(self)
with Trace.span('ctrl.ext_axis'):
# ExternalAxis exposes the auxcnc ESP stepper as a
# virtual A axis that gplan handles natively. Created
# unconditionally so State sees the synthetic motor
# vars even when aux is disabled (kept inert in that
# case via ext_axis.enabled).
axis_letter = self.aux._cfg.get('axis_letter', 'a')
self.ext_axis = bbctrl.ExternalAxis(
self, self.aux, axis_letter=axis_letter)
# Hook AuxAxis post-publish callback so homed flag
# mirrors into State after homing.
self.aux.set_state_observer(
self.ext_axis.refresh_homed)
self._register_aux_hooks()
with Trace.span('ctrl.mach.connect'): with Trace.span('ctrl.mach.connect'):
self.mach.connect() self.mach.connect()
@@ -127,8 +145,76 @@ class Ctrl(object):
self.preplanner.start() self.preplanner.start()
def _register_aux_hooks(self):
"""Wire up auxcnc HOOK: events to AuxAxis methods.
v2: motion hooks (aux/aux_rel/aux_home/aux_setzero) are
retired now that the W axis is integrated through gplan as
a virtual A axis (see ExternalAxis). Only the ATC pneumatic
hooks remain - those are events, not motion.
For backwards compatibility with files that still contain
(MSG,HOOK:aux_home:) (e.g. older preprocessed gcode), keep
an aux_home alias that routes to the standard ext_axis homing
path."""
log = self.log.get('AuxAxis')
def _hook_aux_home(ctx):
# Legacy: route to the standard external-axis homing.
if self.ext_axis is not None and self.ext_axis.enabled:
self.ext_axis.home()
else:
self.aux.home()
def _hook_release(ctx): self.aux.atc_release()
def _hook_clamp(ctx): self.aux.atc_clamp()
def _hook_eject(ctx):
# ctx['data'] is the payload after HOOK:eject:. Allow
# operators to override pulse / dwell from gcode via
# (MSG,HOOK:eject:pulse=400 dwell=300). Empty data ->
# ESP defaults.
data = (ctx.get('data') or '').strip()
kw = {}
for tok in data.split():
if '=' not in tok: continue
k, v = tok.split('=', 1)
k = k.strip().lower()
if k in ('pulse', 'pulse_ms'):
try: kw['pulse_ms'] = int(v)
except ValueError: pass
elif k in ('dwell', 'dwell_ms'):
try: kw['dwell_ms'] = int(v)
except ValueError: pass
self.aux.atc_eject(**kw)
# Legacy alias for older gcode that used aux_home.
self.hooks.register_internal('aux_home', _hook_aux_home,
block_unpause=True, auto_resume=True,
timeout=180)
# ATC pneumatic atoms. block_unpause + auto_resume so a
# program using M100/M102/M103 pauses at the right point and
# resumes once each atom finishes. Macros compose drop/grab
# sequences from these primitives.
self.hooks.register_internal('release', _hook_release,
block_unpause=True, auto_resume=True,
timeout=10)
self.hooks.register_internal('clamp', _hook_clamp,
block_unpause=True, auto_resume=True,
timeout=15)
self.hooks.register_internal('eject', _hook_eject,
block_unpause=True, auto_resume=True,
timeout=15)
log.info('Aux hooks registered')
def close(self): def close(self):
self.log.get('Ctrl').info('Closing %s' % self.id) self.log.get('Ctrl').info('Closing %s' % self.id)
self.ioloop.close() self.ioloop.close()
self.avr.close() self.avr.close()
self.mach.planner.close() self.mach.planner.close()
try: self.ext_axis.close()
except Exception: pass
try: self.aux.close()
except Exception: pass

View File

@@ -0,0 +1,677 @@
################################################################################
#
# ExternalAxis - bridges a logical motorless axis to step generation on
# the auxcnc ESP, so the Buildbotics planner can drive a stepper that
# isn't on the AVR.
#
# Architecture
# ------------
# The bbctrl planner (camotics gplan) handles parsing, units, modal
# state, soft limits, accel ramping and S-curve timing for axes
# X, Y, Z, A, B, C. The AVR has 4 motor channels (0-3) and only
# generates step pulses for axes that have a motor mapped to them.
# An axis with no mapped motor is fully accepted by the AVR - it
# updates its internal `ex.position[axis]` and reports `<axis>p` to
# the host, but no stepper turns.
#
# We exploit that: the W stepper is exposed to gplan as A, but no
# AVR motor maps to A. The planner does all the gcode-level work
# correctly (G90/G91, soft limits, accel, units, modal feed rate);
# we intercept the resulting `Cmd.line` blocks in `Planner.__encode`,
# strip A out, and forward the A delta to the auxcnc ESP as STEPS.
#
# To make gplan and State *believe* A is enabled we register a
# synthetic motor (index 4) into State.vars, populated from
# aux.json, with `4an=3` (axis A), `4me=1` (enabled), and the
# usual velocity/accel/jerk/soft-limit vars. State.find_motor and
# the snapshot projection are extended to walk index 4. Motor-4
# vars never leave the host (they're not in the AVR's schema) so
# the AVR is undisturbed.
#
# v1 coupling: serialize. If a line has any A delta we wait for
# the ESP to finish before letting subsequent commands flow. This
# matches the behaviour of the previous hook-based approach (no
# XYZ+A blending) but with all the planner's correctness guarantees.
#
# v2 could match ESP move duration to the gplan trapezoid time and
# allow concurrent motion; out of scope for v1.
#
################################################################################
import threading
try:
from queue import Queue
except ImportError:
from Queue import Queue # py2 just in case
# Synthetic motor index used to expose the external axis to State.
# The AVR has motors 0..3; we use 4 as a host-only sentinel.
EXTERNAL_MOTOR_INDEX = 4
# Axis letters in their canonical order; 'a' is index 3.
_AXIS_LETTERS = 'xyzabc'
class ExternalAxisError(Exception):
pass
class ExternalAxis(object):
"""Bridge between Planner line blocks and AuxAxis serial RPCs.
Owns no thread; runs RPC calls inline on whatever thread invokes
execute_to_mm / home / abort. The Planner runs `__encode` on its
own thread which is allowed to block on planner I/O, so blocking
inside the interceptor is fine.
Position tracking: gplan emits absolute targets in mm; the ESP
counts steps relative to home_zero. We mirror the last commanded
mm position so subsequent line blocks compute the correct delta.
`_pos_mm` is also published as `<axis>p` so DRO updates."""
def __init__(self, ctrl, aux, axis_letter='a'):
self.ctrl = ctrl
self.aux = aux
self.log = ctrl.log.get('ExternalAxis')
self.axis_letter = (axis_letter or 'a').lower()[:1]
if self.axis_letter not in _AXIS_LETTERS:
raise ExternalAxisError(
'Invalid external axis letter: %r' % axis_letter)
# Index in 'xyzabc' (0..5)
self.axis_index = _AXIS_LETTERS.index(self.axis_letter)
self._busy = threading.Event()
# Last absolute mm we committed; None until first move /
# homing event syncs us up.
self._pos_mm = None
# Single-slot worker queue: __encode posts (target_mm,) tuples
# here; the worker thread runs the ESP RPC. Capacity is
# intentionally bounded - if it fills it means motion is
# outpacing the ESP and we should backpressure the planner.
self._work_q = Queue(maxsize=64)
self._stop = threading.Event()
self._worker = threading.Thread(
target=self._worker_loop,
name='ExternalAxis-worker', daemon=True)
self._worker.start()
# Push synthetic motor vars into State so the planner sees
# this axis as enabled with proper limits/velocity/accel.
self._publish_synthetic_motor()
# Also seed <axis>p so the DRO has something to render.
self.ctrl.state.set(self.axis_letter + 'p', 0.0)
# -------------------------------------------------------------- enabled
@property
def enabled(self):
try:
return bool(self.aux is not None
and self.aux.enabled
and self.aux.present)
except Exception:
return False
# -------------------------------------------------------- configuration
@property
def steps_per_mm(self):
try:
return float(self.aux._cfg.get('steps_per_mm', 25.0))
except Exception:
return 25.0
@property
def dir_sign(self):
try:
v = int(self.aux._cfg.get('dir_sign', 1))
return -1 if v < 0 else 1
except Exception:
return 1
@property
def home_position_mm(self):
try:
return float(self.aux._cfg.get('home_position_mm', 0.0))
except Exception:
return 0.0
# ------------------------------------------------------- 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
def mm_to_steps_delta(self, delta_mm):
return int(round(float(delta_mm) * self.steps_per_mm * self.dir_sign))
def steps_to_mm(self, steps):
return (float(steps) / self.steps_per_mm) * self.dir_sign
# ---------------------------------------------------- synthetic motor
def _publish_synthetic_motor(self):
"""Write motor-4 vars into State so find_motor('a') and
get_axis_vector('vm') see A as a real axis. The AVR never
sees these (motor index 4 is not in its var schema)."""
cfg = self.aux._cfg if self.aux is not None else {}
st = self.ctrl.state
i = str(EXTERNAL_MOTOR_INDEX)
# Axis assignment: 'an' is the 0-based axis index in xyzabc.
st.set(i + 'an', self.axis_index)
# Motor enabled.
st.set(i + 'me', 1 if (self.aux and self.aux.enabled) else 0)
# Homed flag - cleared until aux reports homed.
try:
homed = bool(self.aux._homed)
except Exception:
homed = False
st.set(i + 'h', 1 if homed else 0)
# Velocity / accel / jerk: the planner reads these via
# state.get_axis_vector('<code>', SCALE) which multiplies the
# stored raw value by SCALE. The bbctrl convention (matching
# what motors 0-3 store) is:
# vm: stored in m/min, planner expects mm/min (scale 1000)
# am: stored in km/min^2, planner expects mm/min^2 (scale 1e6)
# jm: stored in km/min^3, planner expects mm/min^3 (scale 1e6)
# Onefinity defaults for XY are vm=10, am=750, jm=1000. We
# follow the same convention; aux.json exposes the values in
# those user-facing units so they're directly comparable.
st.set(i + 'vm', float(cfg.get('max_velocity_m_per_min', 6.0)))
st.set(i + 'am', float(cfg.get('max_accel_km_per_min2', 100.0)))
st.set(i + 'jm', float(cfg.get('max_jerk_km_per_min3', 500.0)))
# Soft limits in machine units (mm). State.get_soft_limit_vector
# returns these directly, no scaling.
st.set(i + 'tn', float(cfg.get('min_mm', 0.0)))
st.set(i + 'tm', float(cfg.get('max_mm', 0.0)))
# home_position / home_travel are exposed as callbacks for
# motors 0..3 (see State.__init__). Register the same lazy
# callbacks for motor 4 so gplan's resolver lookup
# (_<axis>_home_position / _<axis>_home_travel) returns the
# right values for the external axis.
st.set_callback(
i + 'home_position', lambda name: self.home_position_mm)
st.set_callback(
i + 'home_travel',
lambda name: float(self.aux._cfg.get('max_mm', 0.0))
- self.home_position_mm)
# Misc fields that other code paths might query. Defaults
# mirror what the AVR pushes for motors 0-3.
st.set(i + 'sa', 1.8)
st.set(i + 'mi', 16)
st.set(i + 'tr', 4.0)
st.set(i + 'sp', 200)
st.set(i + 'ic', 0.0)
st.set(i + 'dc', 0.0)
st.set(i + 'rv', False)
st.set(i + 'tc', 1)
st.set(i + 'lb', 5)
st.set(i + 'ho', 0)
st.set(i + 'os', 0)
st.set(i + 'oa', False)
st.set(i + 'lm', 8)
st.set(i + 'lv', 0.1)
st.set(i + 'sv', 1.688)
st.set(i + 'tv', 1.997)
st.set(i + 'lw', 2) # min-switch
st.set(i + 'xw', 2) # max-switch
st.set(i + 'ls', 0)
st.set(i + 'xs', 0)
st.set(i + 'df', 0)
def refresh_homed(self):
"""Called when AuxAxis updates its homed flag. Mirrors into
State so is_axis_homed('a') returns the right answer.
Updates several places at once because different layers read
the homed state via different keys:
- synthetic motor flag: 4h (used by snapshot -> a_h)
- axis-level flag: a_homed (used by State.is_axis_homed
and gplan _a_homed resolver)"""
try:
homed = bool(self.aux._homed)
except Exception:
homed = False
st = self.ctrl.state
st.set(str(EXTERNAL_MOTOR_INDEX) + 'h', 1 if homed else 0)
st.set(self.axis_letter + '_homed', bool(homed))
# ----------------------------------------------------------- line split
def split_target(self, target):
"""Pop the external axis out of a target dict and return
(target_without_ext, ext_mm_or_None). Both case variants
accepted defensively."""
if not target:
return target, None
ax = self.axis_letter
new_target = dict(target)
ext_mm = new_target.pop(ax, None)
if ext_mm is None:
ext_mm = new_target.pop(ax.upper(), None)
return new_target, ext_mm
# -------------------------------------------------------- execution API
def is_busy(self):
return self._busy.is_set()
def execute_to_mm(self, ext_mm):
"""Synchronously run an external move. Blocks until the ESP
reports done. Used by the legacy /api/aux/move and /api/aux/jog
endpoints which may want to wait. Most planner-driven motion
goes through enqueue_target_mm instead, which is non-blocking.
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:
raise ExternalAxisError(
'External axis %r not available (aux disabled or '
'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)
if steps == 0:
self._pos_mm = abs_mm
self.ctrl.state.set(self.axis_letter + 'p', self._pos_mm)
return
self._busy.set()
try:
self.aux._do_steps(steps, ignore_limits=True)
self._pos_mm = abs_mm
self.ctrl.state.set(self.axis_letter + 'p', self._pos_mm)
finally:
self._busy.clear()
def enqueue_target_mm(self, ext_mm):
"""Legacy non-blocking variant: post a fixed-rate STEPS move
to the worker queue. No longer used by Planner.__encode (which
uses enqueue_line for full S-curve mirroring), but kept for
UI jog endpoints that don't have planner timing data.
Soft limits are enforced here (defense in depth on top of
gplan)."""
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)
# Internal mirror only - drives subsequent delta computation.
# state.<axis>p is left to the AVR's status reports.
self._pos_mm = abs_mm
if steps == 0:
return
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):
"""Return (signed_steps, absolute_mm) for a target in mm.
Caches first-time position from the ESP."""
if self._pos_mm is None:
self._pos_mm = self._read_esp_position_mm()
delta_mm = float(ext_mm) - self._pos_mm
return self.mm_to_steps_delta(delta_mm), float(ext_mm)
def _worker_loop(self):
"""Background thread that drains the work queue. RPCs to the
ESP are slow (multi-second moves) and must not run on the
ioloop thread. We serialize ESP commands here so multiple
line-block enqueues for the external axis are processed in
the order the planner emitted them."""
while not self._stop.is_set():
try:
op = self._work_q.get(timeout=0.5)
except Exception:
continue
if op is None:
continue
kind = op[0]
try:
self._busy.set()
if kind == 'move':
steps = op[1]
self.aux._do_steps(steps, ignore_limits=True)
elif kind == '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':
self.aux.home()
# _pos_mm and DRO updated by the caller's enqueue.
except Exception as e:
self.log.error('External axis worker failed on %s: %s'
% (kind, e))
finally:
self._busy.clear()
self._work_q.task_done()
def wait_idle(self, timeout=None):
"""Block until the worker queue is empty. Used by callers
that need post-motion state to be settled (e.g. homing,
stop/abort handlers)."""
try:
# Queue.join blocks until task_done has been called for
# every item put. It does not honour a timeout, so we
# poll instead when one is requested.
if timeout is None:
self._work_q.join()
return True
import time
deadline = time.time() + float(timeout)
while time.time() < deadline:
if self._work_q.unfinished_tasks == 0:
return True
time.sleep(0.05)
return False
except Exception:
return False
def close(self):
self._stop.set()
try:
self._work_q.put(None, block=False)
except Exception:
pass
def home(self):
"""Run the ESP homing cycle and sync our recorded position
to the configured home_position_mm. Blocks; called from
Mach.home (which already runs synchronously per axis)."""
if not self.enabled:
raise ExternalAxisError(
'External axis %r not available' % self.axis_letter)
# Drain pending moves so we don't home into stale work.
self.wait_idle(timeout=30.0)
self._busy.set()
try:
self.aux.home()
self._pos_mm = self.home_position_mm
self.ctrl.state.set(self.axis_letter + 'p', self._pos_mm)
self.refresh_homed()
finally:
self._busy.clear()
def abort(self):
"""Cancel the ESP move and drop pending queued work.
Caller (estop / stop handler) is responsible for the
planner-side cleanup."""
try:
if self.aux is not None:
self.aux.abort()
finally:
self._busy.clear()
# Drain any pending ops so resume after an abort doesn't
# replay stale targets.
try:
while True:
self._work_q.get_nowait()
self._work_q.task_done()
except Exception:
pass
# ------------------------------------------------------- ESP introspection
def _read_esp_position_mm(self):
"""Convert AuxAxis._pos_steps mirror to mm. Falls back to 0."""
try:
steps = int(self.aux._pos_steps)
except Exception:
steps = 0
return self.steps_to_mm(steps)
# ---------------------------------------------------------- DRO update
def sync_dro(self):
"""Push the current position to State as <axis>p so the DRO
reflects what we believe gplan/ESP agreed on. Called after
moves; also safe to call from external code."""
if self._pos_mm is None:
return
self.ctrl.state.set(self.axis_letter + 'p', self._pos_mm)

View File

@@ -99,6 +99,39 @@ class FileHandler(bbctrl.APIHandler):
del (self.uploadFile) del (self.uploadFile)
# If the uploaded G-code uses ATC M-codes (M100..M103),
# rewrite them into (MSG,HOOK:droptool:) etc so the hook
# layer can dispatch them at runtime. The planner accepts
# M100-M103 in user-defined range but doesn't *do* anything
# with them. Motion in A goes through gplan unchanged - the
# auxcnc stepper is exposed as a virtual A axis (see
# ExternalAxis).
try:
from bbctrl.AuxPreprocessor import (
preprocess_file, AuxPreprocessorError)
log = self.get_log('AuxPreprocessor')
ext = getattr(self.get_ctrl(), 'ext_axis', None)
coupling = (ext.coupling_for_preprocessor()
if ext is not None else None)
try:
if preprocess_file(filename.decode('utf8'),
log=log, coupling=coupling):
log.info('Rewrote upload (ATC / Z-A coupling) in %s'
% self.uploadFilename)
except AuxPreprocessorError as e:
# Surface coupling-violation errors to the operator
# via the message stream so the upload doesn't go
# silently un-rewritten and then trip the runtime
# check (which can hang the planner dialog).
log.warning('Aux preprocess refused upload: %s' % e)
try:
self.get_ctrl().state.add_message(
'Z-A coupling: ' + str(e))
except Exception: pass
except Exception:
self.get_log('AuxPreprocessor').exception(
'Aux preprocess failed; uploading unchanged')
self.get_ctrl().preplanner.invalidate(self.uploadFilename) self.get_ctrl().preplanner.invalidate(self.uploadFilename)
self.get_ctrl().state.add_file(self.uploadFilename) self.get_ctrl().state.add_file(self.uploadFilename)

454
src/py/bbctrl/Hooks.py Normal file
View File

@@ -0,0 +1,454 @@
################################################################################
#
# Hooks - External event triggers during G-code execution
#
# Integrates with the controller's pause/unpause cycle to run external
# actions (webhooks, scripts) at specific points during G-code execution.
#
# ## How tool-change hooks work (the important one):
#
# G-code: T5 M6
#
# 1. Planner replaces M6 with tool-change override G-code (configurable).
# Default: "M0 M6 (MSG, Change tool)"
#
# 2. Planner emits: set(tool,5), pause(program), message("Change tool")
# These are sent to the AVR as serial commands.
#
# 3. AVR finishes current move, enters HOLDING state.
# Reports back: xx=HOLDING, pr="Program pause"
#
# 4. Pi: Mach._update() sees HOLDING, flushes CommandQueue.
# CommandQueue executes callbacks: state.set('tool', 5) fires.
#
# 5. Hooks._on_state_change() sees tool changed.
# Sets self._hook_busy = True, runs the hook in a thread.
# While _hook_busy, Mach.unpause() is blocked via can_unpause().
#
# 6. Machine sits in HOLDING. UI shows "Change tool" message.
# User cannot resume yet (unpause is gated).
#
# 7. Hook thread finishes (toolchanger done). Sets _hook_busy = False.
# If auto_resume is set, calls unpause automatically.
# Otherwise user clicks Continue in UI.
#
# 8. Mach.unpause() → planner.restart() → AVR UNPAUSE → motion resumes.
#
# ## Configuration (hooks.json):
#
# {
# "tool-change": {
# "type": "webhook",
# "url": "http://toolchanger.local/api/change",
# "method": "POST",
# "timeout": 120,
# "block_unpause": true,
# "auto_resume": true
# },
# "program-start": {
# "type": "script",
# "command": "/usr/local/bin/dust-collector on",
# "block_unpause": false
# }
# }
#
# block_unpause: if true, unpause is blocked until hook completes
# auto_resume: if true AND block_unpause, auto-unpause after hook done
#
################################################################################
import os
import json
import subprocess
import threading
import traceback
from urllib.request import Request, urlopen
from urllib.error import URLError
# Events that can be hooked
HOOK_EVENTS = [
'tool-change', # M6 - tool change requested
'program-start', # Program begins running
'program-end', # M2/M30 - program ends
'pause', # M0/M1 - program pause
'estop', # Emergency stop triggered
'homing-start', # Homing cycle begins
'homing-end', # Homing cycle completes
'custom', # Triggered by (MSG,HOOK:name:data) comments
]
class Hooks:
def __init__(self, ctrl):
self.ctrl = ctrl
self.log = ctrl.log.get('Hooks')
self.hooks = {}
# Hook execution state
self._hook_busy = False # True while a blocking hook runs
self._hook_busy_event = None # Which event is blocking
self._hook_error = None # Error from last hook, if any
self._hook_thread = None
# In-process hook handlers registered by Python modules. Keyed by
# event name (matches what the G-code emits as HOOK:<event>).
# Take precedence over hooks.json entries with the same name.
self._internal = {}
# Track state for edge detection — must be set before add_listener
# because add_listener fires immediately with current state
self._last_cycle = ctrl.state.get('cycle', 'idle')
self._last_state = ctrl.state.get('xx', '')
self._last_tool = ctrl.state.get('tool', 0)
self._last_pause_reason = ctrl.state.get('pr', '')
# Highest message id we've already inspected for HOOK: lines.
self._last_msg_id = -1
self._initialized = False
self._load_config()
# Listen for state changes
ctrl.state.add_listener(self._on_state_change)
self._initialized = True
# -- Config management --
def _get_config_path(self):
return self.ctrl.get_path(filename='hooks.json')
def _load_config(self):
path = self._get_config_path()
if os.path.exists(path):
try:
with open(path) as f:
self.hooks = json.load(f)
self.log.info('Loaded %d hook(s) from %s' %
(len(self.hooks), path))
except Exception:
self.log.error('Failed to load hooks.json: %s' %
traceback.format_exc())
else:
self.log.info('No hooks.json found, hooks disabled')
def save_config(self, config):
"""Save hook configuration (called from API)."""
path = self._get_config_path()
with open(path, 'w') as f:
json.dump(config, f, indent=2)
self.hooks = config
self.log.info('Saved %d hook(s)' % len(config))
def get_config(self):
return self.hooks
# -- Unpause gating (called from Mach) --
def can_unpause(self):
"""Returns True if no blocking hook is running.
Called by Mach.unpause() to gate resume."""
if self._hook_busy:
self.log.info('Unpause blocked: hook "%s" still running' %
self._hook_busy_event)
return False
return True
def get_status(self):
"""Return current hook execution status for the UI."""
return {
'busy': self._hook_busy,
'event': self._hook_busy_event,
'error': self._hook_error,
}
# -- State change listener --
def _on_state_change(self, update):
"""Called on every state update from the controller."""
if not self._initialized:
return
state = self.ctrl.state
# Detect tool change (tool number changed while HOLDING)
if 'tool' in update:
new_tool = update['tool']
if new_tool != self._last_tool:
self._fire('tool-change', {
'old_tool': self._last_tool,
'new_tool': new_tool,
})
self._last_tool = new_tool
# Detect cycle changes
if 'cycle' in update:
new_cycle = update['cycle']
if new_cycle != self._last_cycle:
if new_cycle == 'running' and self._last_cycle == 'idle':
self._fire('program-start', {})
elif new_cycle == 'idle' and self._last_cycle == 'running':
self._fire('program-end', {})
elif new_cycle == 'homing':
self._fire('homing-start', {})
elif self._last_cycle == 'homing' and new_cycle == 'idle':
self._fire('homing-end', {})
self._last_cycle = new_cycle
# Detect AVR state changes
if 'xc' in update or 'xx' in update:
new_state = state.get('xx', '')
if new_state != self._last_state:
if new_state == 'ESTOPPED':
# Cancel any running hook on estop. The hook thread
# cannot be killed from Python, but we can ask the
# AuxAxis to send ABORT to the ESP so its in-flight
# motion stops. Also drain the external-axis
# worker queue so resume after clear doesn't replay
# stale moves.
try:
ext = getattr(self.ctrl, 'ext_axis', None)
if ext is not None:
ext.abort()
except Exception:
pass
if self._hook_busy:
self.log.warning('E-stop: cancelling hook "%s"' %
self._hook_busy_event)
try:
aux = getattr(self.ctrl, 'aux', None)
if aux is not None:
aux.abort()
except Exception:
pass
self._hook_busy = False
self._hook_busy_event = None
self._fire('estop', {})
self._last_state = new_state
# Detect pause
if 'pr' in update:
pr = update['pr']
if pr and pr != self._last_pause_reason:
self._fire('pause', {'reason': pr})
self._last_pause_reason = pr
# Detect custom hook messages emitted via (MSG,HOOK:event_name:data)
# gcode comments. State stores them as a list under 'messages'
# ([{'id': N, 'text': '...'}, ...]); fire only on new ids.
if 'messages' in update:
msgs = update['messages']
if isinstance(msgs, list):
for m in msgs:
try:
mid = m.get('id', -1)
text = m.get('text', '')
except AttributeError:
continue
if mid <= self._last_msg_id:
continue
self._last_msg_id = mid
if isinstance(text, str) and text.startswith('HOOK:'):
parts = text[5:].split(':', 1)
event = parts[0]
data = parts[1] if len(parts) > 1 else ''
self._fire('custom', {
'event': event,
'data': data,
}, custom_name=event)
# -- Hook execution --
def dispatch_hook_message(self, text):
"""Direct entry point for HOOK:<event>:<data> messages emitted
by the planner via (MSG,HOOK:...) comments. Bypasses the
state.messages list (which the UI also reads), so callers can
suppress popup display without losing the hook dispatch.
Returns True if the text matched a HOOK: line and was
dispatched, False otherwise."""
if not isinstance(text, str) or not text.startswith('HOOK:'):
return False
parts = text[5:].split(':', 1)
event = parts[0]
data = parts[1] if len(parts) > 1 else ''
self._fire('custom', {'event': event, 'data': data},
custom_name=event)
return True
def register_internal(self, name, fn, block_unpause=True,
auto_resume=True, timeout=120):
"""Register an in-process handler for HOOK:<name> events.
fn(context) -> None. May raise. Runs synchronously in the hook
thread; while it runs and block_unpause=True, Mach.unpause is
gated."""
self._internal[name] = {
'type': 'internal',
'fn': fn,
'block_unpause': block_unpause,
'auto_resume': auto_resume,
'timeout': timeout,
}
self.log.info('Registered internal hook: %s' % name)
def _fire(self, event, context, custom_name=None):
"""Fire a hook event."""
# Internal handlers win over hooks.json entries.
hook = None
if custom_name:
hook = self._internal.get(custom_name)
if not hook:
hook = self._internal.get(event)
if not hook:
hook = self.hooks.get(event)
if custom_name and not hook:
hook = self.hooks.get(custom_name)
if not hook:
return
self.log.info('Hook firing: %s %s' % (event, json.dumps(context)))
# Add standard context
state = self.ctrl.state
context.update({
'event': event,
'position': (state.get_position()
if hasattr(state, 'get_position') else {}),
'state': state.get('xx', ''),
'cycle': state.get('cycle', 'idle'),
})
block_unpause = hook.get('block_unpause', event == 'tool-change')
auto_resume = hook.get('auto_resume', False)
if block_unpause:
# Run in thread, block unpause until done
self._hook_busy = True
self._hook_busy_event = event
self._hook_error = None
# Update UI state so frontend knows we're busy
self.ctrl.state.set('hook_busy', True)
self.ctrl.state.set('hook_event', event)
self._hook_thread = threading.Thread(
target=self._run_hook_blocking,
args=(hook, event, context, auto_resume),
daemon=True
)
self._hook_thread.start()
else:
# Fire and forget (non-blocking)
self._execute_hook(hook, context)
def _run_hook_blocking(self, hook, event, context, auto_resume):
"""Runs in a background thread. Blocks unpause until complete."""
try:
self._execute_hook(hook, context)
self.log.info('Hook "%s" completed successfully' % event)
except Exception as e:
self._hook_error = str(e)
self.log.error('Hook "%s" failed: %s' % (event, e))
finally:
self._hook_busy = False
self._hook_busy_event = None
# Schedule UI update on the ioloop thread
self.ctrl.ioloop.call_later(0, self._hook_finished, auto_resume)
def _hook_finished(self, auto_resume):
"""Called on the ioloop after a blocking hook completes."""
self.ctrl.state.set('hook_busy', False)
self.ctrl.state.set('hook_event', '')
if self._hook_error:
self.ctrl.state.set('hook_error', self._hook_error)
self.log.error('Hook error: %s' % self._hook_error)
else:
self.ctrl.state.set('hook_error', '')
if auto_resume and not self._hook_error:
self.log.info('Hook done, auto-resuming')
try:
self.ctrl.mach.unpause()
except Exception as e:
self.log.error('Auto-resume failed: %s' % e)
def _execute_hook(self, hook, context):
"""Execute a single hook (webhook, script, or internal). May block."""
hook_type = hook.get('type', 'webhook')
if hook_type == 'webhook':
self._fire_webhook(hook, context)
elif hook_type == 'script':
self._fire_script(hook, context)
elif hook_type == 'internal':
fn = hook.get('fn')
if fn is None:
raise Exception('Internal hook missing fn')
fn(context)
else:
raise Exception('Unknown hook type: %s' % hook_type)
def _fire_webhook(self, hook, context):
"""Fire a webhook HTTP request."""
url = hook.get('url')
if not url:
raise Exception('Webhook missing url')
method = hook.get('method', 'POST').upper()
timeout = hook.get('timeout', 30)
headers = dict(hook.get('headers', {}))
body = dict(hook.get('body', {}))
# Merge context into body
body['_context'] = context
data = json.dumps(body).encode('utf-8')
headers['Content-Type'] = 'application/json'
req = Request(url, data=data, headers=headers, method=method)
self.log.info('Webhook %s %s' % (method, url))
resp = urlopen(req, timeout=timeout)
self.log.info('Webhook response: %d' % resp.status)
if resp.status >= 400:
raise Exception('Webhook returned %d' % resp.status)
def _fire_script(self, hook, context):
"""Fire a local script/command. Blocks until complete."""
command = hook.get('command')
if not command:
raise Exception('Script hook missing command')
timeout = hook.get('timeout', 120)
# Pass context as environment variables
env = os.environ.copy()
env['HOOK_EVENT'] = context.get('event', '')
env['HOOK_STATE'] = context.get('state', '')
env['HOOK_CYCLE'] = context.get('cycle', '')
env['HOOK_DATA'] = json.dumps(context)
if 'old_tool' in context:
env['HOOK_OLD_TOOL'] = str(context['old_tool'])
if 'new_tool' in context:
env['HOOK_NEW_TOOL'] = str(context['new_tool'])
self.log.info('Script: %s' % command)
result = subprocess.run(
command, shell=True, env=env,
timeout=timeout,
stdout=subprocess.PIPE, stderr=subprocess.PIPE
)
stdout = result.stdout.decode('utf-8', errors='replace').strip()
stderr = result.stderr.decode('utf-8', errors='replace').strip()
if stdout:
self.log.info('Script stdout: %s' % stdout)
if result.returncode != 0:
raise Exception('Script failed (%d): %s' %
(result.returncode, stderr or 'non-zero exit'))

View File

@@ -25,10 +25,21 @@
# # # #
################################################################################ ################################################################################
import os
import threading
import time
import inevent import inevent
from inevent.Constants import * from inevent.Constants import *
# Set to True (or BBCTRL_AJOG_DRYRUN=1 in env) to log press/release
# events and would-be ESP commands without actually sending JOG /
# JOGSTOP. Useful for debugging the gamepad event path without
# touching the gantry. Defaults to live actuation.
A_DRY_RUN = os.environ.get('BBCTRL_AJOG_DRYRUN', '') == '1'
# Listen for input events # Listen for input events
class Jog(inevent.JogHandler): class Jog(inevent.JogHandler):
def __init__(self, ctrl): def __init__(self, ctrl):
@@ -51,12 +62,23 @@ class Jog(inevent.JogHandler):
"dir": [1, -1, -1, 1], "dir": [1, -1, -1, 1],
"arrows": [ABS_HAT0X, ABS_HAT0Y], "arrows": [ABS_HAT0X, ABS_HAT0Y],
"speed": [0x133, 0x130, 0x131, 0x134], "speed": [0x133, 0x130, 0x131, 0x134],
"lock": [0x136, 0x137], "lock": [0x136], # L1 = horiz-lock; RB/RT now A axis
# Right back controls drive the A axis while held.
# Verified on Xbox 360 pad (Vendor=045e Product=028e):
# RB (upper-right bumper) -> BTN_TR (0x137) digital -> A+
# RT (lower-right trigger) -> ABS_RZ analog 0..255 -> A-
# Some pads expose RT as BTN_TR2 (0x139) instead -- that
# works too via a_neg_btn.
"a_pos_btn": 0x137,
"a_neg_btn": 0x139,
"a_neg_abs": ABS_RZ,
"a_abs_thresh": 32, # 0..255 trigger press threshold
} }
} }
super().__init__(config) super().__init__(config)
self.a_button = 0 # -1, 0, +1 from RB / RT hold state
self.v = [0.0] * 4 self.v = [0.0] * 4
self.lastV = self.v self.lastV = self.v
self.callback() self.callback()
@@ -64,6 +86,276 @@ class Jog(inevent.JogHandler):
self.processor = inevent.InEvent(ctrl.ioloop, self, types = ['js']) self.processor = inevent.InEvent(ctrl.ioloop, self, types = ['js'])
# -------- A-axis (external, ESP-driven) hold-to-jog ---------------
#
# The Mach jog path only knows about AVR axes; the A axis is
# handled by ExternalAxis on the auxcnc ESP, which has a proper
# JOG / JOGSTOP protocol added for hold-to-jog: ramp up on press,
# cruise while held, ramp down on release.
#
# Speed buttons (X/A/B/Y) scale the cruise rate (1/128, 1/32,
# 1/4, 1.0x of the configured step_max_sps).
def _a_speed_scale(self):
if self.speed == 1: return 1.0 / 128.0
if self.speed == 2: return 1.0 / 32.0
if self.speed == 3: return 1.0 / 4.0
return 1.0
def _a_stop(self):
ext = getattr(self.ctrl, 'ext_axis', None)
ext_state = ('present' if (ext is not None and ext.enabled)
else 'unavailable')
if A_DRY_RUN:
self.log.info('AJOG DRYRUN _a_stop ext=%s (would send JOGSTOP)',
ext_state)
return
if ext is None or not ext.enabled:
return
try:
ext.aux.jog_stop()
except Exception as e:
self.log.warning('A-axis jog_stop failed: %s', e)
def _a_soft_limit_target_steps(self, aux, direction):
"""Return a step-counter target for the configured soft
limit (`min_mm` / `max_mm`) on the `direction` side of the
current position, or None when no limit applies (axis
unhomed or limits not configured)."""
try:
if not bool(aux._homed):
return None
cfg = aux._cfg
lo_mm = float(cfg.get('min_mm', 0.0))
hi_mm = float(cfg.get('max_mm', 0.0))
if hi_mm <= lo_mm:
return None
lo_steps = aux._mm_to_steps(lo_mm)
hi_steps = aux._mm_to_steps(hi_mm)
# _mm_to_steps applies dir_sign; sort so we know which
# is "more positive in g_pos".
top_steps = max(lo_steps, hi_steps)
bottom_steps = min(lo_steps, hi_steps)
return top_steps if direction > 0 else bottom_steps
except Exception:
return None
def _a_coupling_target_steps(self, ext, direction):
"""Return a step-counter target that prevents the Z-A
coupling rule (a - z <= K) from being violated by this jog.
Returns None when coupling is disabled or doesn't constrain
motion in `direction`.
The constraint is on machine-mm: the rule limits how far A
may go *up* (toward larger machine A) for the current Z. So
only the +A jog direction can ever violate it; -A jogs are
unconstrained by coupling and we return None for them.
Note: 'direction' here refers to the gamepad axis sign, not
machine-mm. dir_sign in aux config maps gamepad+ to
machine+ steps. We translate via the existing
ext._a_machine_now / aux._mm_to_steps so the result is in
the same g_pos space as _a_soft_limit_target_steps."""
try:
if not ext.couple_z_enabled:
return None
if not bool(ext.aux._homed):
return None
K = ext.couple_K
if K is None:
return None
z_now = ext._z_machine_now()
if z_now is None:
return None
# Max permitted A in machine-mm: a_max = z_now + K.
a_max_mm = float(z_now) + float(K)
a_max_steps = ext.aux._mm_to_steps(a_max_mm)
# The coupling only caps the *upper* side (more-positive
# machine A). With dir_sign=+1 that's g_pos+; with
# dir_sign=-1 it's g_pos-. Jogs in the opposite gamepad
# direction don't approach the coupling bound, return
# None so the soft-limit target alone applies.
dir_sign = 1 if int(ext.aux._cfg.get('dir_sign', 1)) >= 0 else -1
# Gamepad+ moves toward larger machine-mm when dir_sign>0.
machine_dir = direction * dir_sign
if machine_dir <= 0:
return None
return a_max_steps
except Exception:
return None
def _a_combined_target_steps(self, ext, direction):
"""Pick the more restrictive of soft-limit and coupling
targets. Returns (target_steps, source_label) where
target_steps is None when neither rule applies."""
soft = self._a_soft_limit_target_steps(ext.aux, direction)
couple = self._a_coupling_target_steps(ext, direction)
if soft is None and couple is None:
return None, 'none'
if soft is None: return couple, 'coupling'
if couple is None: return soft, 'softlimit'
# Both present: pick whichever is reached first when moving
# in `direction` from the current g_pos.
try:
cur = int(ext.aux._pos_steps)
except Exception:
cur = 0
if direction > 0:
return ((soft, 'softlimit') if soft <= couple
else (couple, 'coupling'))
else:
return ((soft, 'softlimit') if soft >= couple
else (couple, 'coupling'))
def _a_start(self, direction):
ext = getattr(self.ctrl, 'ext_axis', None)
ext_state = ('present' if (ext is not None and ext.enabled)
else 'unavailable')
scale = self._a_speed_scale()
target_steps = None
target_src = 'none'
cur_steps = None
if ext is not None and ext.enabled:
target_steps, target_src = self._a_combined_target_steps(
ext, direction)
try: cur_steps = int(ext.aux._pos_steps)
except Exception: cur_steps = None
if A_DRY_RUN:
try:
step_max = (int(ext.aux._cfg['step_max_sps'])
if ext is not None and ext.enabled else -1)
accel = (int(ext.aux._cfg['step_accel_sps2'])
if ext is not None and ext.enabled else -1)
except Exception:
step_max, accel = -1, -1
self.log.info(
'AJOG DRYRUN _a_start dir=%+d ext=%s speed=%d scale=%.4f '
'step_max=%d accel=%d cur_steps=%s target_steps=%s '
'target_src=%s (would send JOG)',
direction, ext_state, self.speed, scale, step_max, accel,
cur_steps, target_steps, target_src)
return
if ext is None or not ext.enabled or direction == 0:
return
try:
aux = ext.aux
max_rate = max(1, int(int(aux._cfg['step_max_sps']) * scale))
accel = int(aux._cfg['step_accel_sps2'])
# If the axis is already at-or-past the more-restrictive
# boundary (soft limit OR Z-A coupling) in the requested
# direction, refuse the jog rather than sending a
# wrong-side target the ESP would reject.
if target_steps is not None and cur_steps is not None:
at_limit = ((direction > 0 and cur_steps >= target_steps)
or (direction < 0 and cur_steps <= target_steps))
if at_limit:
self.log.info(
'A-axis jog refused: at %s limit '
'(cur=%d target=%d dir=%+d)',
target_src, cur_steps, target_steps, direction)
return
# ignore_limits=True (safe=0) when the axis is unhomed:
# pendant jog is allowed before homing for setup. When
# homed, soft limits AND Z-A coupling are enforced via
# target_steps and the ESP's hardware-limit abort still
# applies unconditionally (movingTowardLimit in
# jogTask).
ignore = not bool(aux._homed)
aux.jog_start(direction,
max_rate_sps=max_rate,
accel_sps2=accel,
ignore_limits=ignore,
target_steps=target_steps)
if target_steps is not None:
self.log.info(
'A-axis jog_start dir=%+d cur=%d target=%d (%s)',
direction, cur_steps, target_steps, target_src)
except Exception as e:
self.log.warning('A-axis jog_start failed: %s', e)
def _a_apply(self, new_dir, old_dir):
if new_dir == old_dir:
return
# On any state change we stop the current jog and (if the
# new direction is non-zero) start a fresh one. JOG / JOGSTOP
# are non-blocking on the host side.
if old_dir != 0:
self._a_stop()
if new_dir != 0:
self._a_start(new_dir)
def _a_resync_pos(self):
"""Pull the ESP step counter back into ExternalAxis after a
JOG ends, so subsequent gplan-driven A motion computes the
right delta. Called opportunistically on state changes; the
AuxAxis reader also updates _pos_steps from the terminal
[jog] done line."""
ext = getattr(self.ctrl, 'ext_axis', None)
if ext is None or not ext.enabled:
return
try:
ext._pos_mm = ext.aux.position_mm
self.ctrl.state.set(ext.axis_letter + 'p', ext._pos_mm)
except Exception:
pass
def event(self, event, state, dev_name):
cfg = self.get_config(dev_name)
old = self.a_button
# DEBUG: log EVERY incoming gamepad event so we can see
# exactly what the pendant is producing on press/release.
# Skip noisy stick / report-syn events to keep the journal
# readable but log all KEY events and any ABS event whose
# code matches one we care about.
try:
tname = ev_type_name.get(event.type, '?')
except Exception:
tname = '?'
if event.type == EV_KEY:
self.log.info(
'AJOG EV dev=%r type=%s(%d) code=0x%x val=%d '
'cfg.a_pos_btn=0x%x cfg.a_neg_btn=0x%x',
dev_name, tname, event.type, event.code, event.value,
cfg.get('a_pos_btn', 0), cfg.get('a_neg_btn', 0))
elif event.type == EV_ABS and event.code in (
cfg.get('a_neg_abs', -1),
cfg.get('a_pos_abs', -1)):
self.log.info(
'AJOG EV dev=%r type=%s(%d) code=0x%x val=%d (trigger ABS)',
dev_name, tname, event.type, event.code, event.value)
if event.type == EV_KEY:
if event.code == cfg.get('a_pos_btn'):
if event.value: self.a_button = 1
elif self.a_button == 1: self.a_button = 0
elif event.code == cfg.get('a_neg_btn'):
if event.value: self.a_button = -1
elif self.a_button == -1: self.a_button = 0
elif event.type == EV_ABS:
thresh = cfg.get('a_abs_thresh', 32)
if event.code == cfg.get('a_neg_abs'):
if event.value >= thresh: self.a_button = -1
elif self.a_button == -1: self.a_button = 0
if self.a_button != old:
self.log.info(
'AJOG STATE %+d -> %+d (t=%.3f dry_run=%s)',
old, self.a_button, time.monotonic(), A_DRY_RUN)
self._a_apply(self.a_button, old)
# On every release pull a fresh position mirror in case
# the user does a gplan-driven A move next. The terminal
# [jog] done line itself already updates aux._pos_steps;
# this propagates that into ExternalAxis._pos_mm.
if self.a_button == 0 and not A_DRY_RUN:
# Wait briefly so the [jog] done line has time to
# arrive before we read aux.position_mm.
self.ctrl.ioloop.call_later(0.2, self._a_resync_pos)
super().event(event, state, dev_name)
def up(self): self.ctrl.lcd.page_up() def up(self): self.ctrl.lcd.page_up()
def down(self): self.ctrl.lcd.page_down() def down(self): self.ctrl.lcd.page_down()
def left(self): self.ctrl.lcd.page_left() def left(self): self.ctrl.lcd.page_left()
@@ -90,4 +382,7 @@ class Jog(inevent.JogHandler):
if self.speed == 2: scale = 1.0 / 32.0 if self.speed == 2: scale = 1.0 / 32.0
if self.speed == 3: scale = 1.0 / 4.0 if self.speed == 3: scale = 1.0 / 4.0
# axes[3] is left untouched by RB/RT -- the A axis is the
# ESP-driven external axis on this branch and is jogged via
# discrete relative moves through ExternalAxis (see _a_pump).
self.v = [x * scale for x in self.axes] self.v = [x * scale for x in self.axes]

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')
@@ -256,6 +260,12 @@ class Mach(Comm):
if cmd[0] == '$': self._query_var(cmd) if cmd[0] == '$': self._query_var(cmd)
elif cmd[0] == '\\': super().queue_command(cmd[1:]) elif cmd[0] == '\\': super().queue_command(cmd[1:])
else: else:
# Rewrite ATC M-codes in MDI input the same way the
# FileHandler rewrites uploaded files. Motion (X/Y/Z/A)
# is left unchanged: the planner handles it natively
# now that the auxcnc stepper is exposed as a virtual
# A axis (see ExternalAxis).
cmd = self._rewrite_aux_mdi(cmd)
self._begin_cycle('mdi') self._begin_cycle('mdi')
self.planner.mdi(cmd, with_limits) self.planner.mdi(cmd, with_limits)
super().resume() super().resume()
@@ -263,11 +273,51 @@ class Mach(Comm):
self.mlog.info("Exception during MDI: %s" % err) self.mlog.info("Exception during MDI: %s" % err)
pass pass
def _rewrite_aux_mdi(self, cmd):
"""Apply the ATC M-code preprocessor to a single MDI line.
Returns possibly-multi-line G-code with HOOK: comments inserted."""
try:
from bbctrl.AuxPreprocessor import AuxPreprocessor, _ATC_M_RE
if not _ATC_M_RE.search(cmd):
return cmd
import io, tempfile, os
# AuxPreprocessor.process is file-based; route through
# tempfiles so we don't fork the regex/state logic.
pre = AuxPreprocessor(log=self.mlog)
with tempfile.NamedTemporaryFile('w', suffix='.nc',
delete=False) as fi:
fi.write(cmd if cmd.endswith('\n') else cmd + '\n')
ipath = fi.name
opath = ipath + '.out'
try:
pre.process(ipath, opath)
rewritten = open(opath).read()
finally:
try: os.unlink(ipath)
except OSError: pass
try: os.unlink(opath)
except OSError: pass
return rewritten
except Exception as e:
self.mlog.warning('Aux MDI rewrite failed: %s' % e)
return cmd
def set(self, code, value): def set(self, code, value):
super().queue_command('${}={}'.format(code, value)) super().queue_command('${}={}'.format(code, value))
def jog(self, axes): def jog(self, axes):
# Strip the external axis from the jog request before sending
# to the AVR. v1 doesn't support continuous-rate jogging on
# the ESP-driven axis - users jog A via /api/aux/jog (relative
# mm steps) instead. Sending A to the AVR is harmless (no
# motor maps to it) but cleaner to strip.
ext = getattr(self.ctrl, 'ext_axis', None)
if ext is not None and isinstance(axes, dict):
axes = {k: v for k, v in axes.items()
if k.lower() != ext.axis_letter}
if not axes:
return
self._begin_cycle('jogging') self._begin_cycle('jogging')
self.planner.position_change() self.planner.position_change()
super().queue_command(Cmd.jog(axes)) super().queue_command(Cmd.jog(axes))
@@ -281,10 +331,52 @@ 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)
# External axes (e.g. the auxcnc-driven A axis) home via
# their own ESP-side homing routine; the standard
# G28.2 / G38.6 / latch sequence doesn't apply.
#
# 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)
if ext is not None and ext.enabled \
and ext.axis_letter == axis.lower():
if 1 < len(axes) and not enabled:
continue
# Defer until AVR axes are done. We capture the axis
# letter and ext reference; the actual homing runs
# in _run_external_homing below.
external_pending.append((axis, ext))
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
# axis is disabled or in manual homing mode, don't show any # axis is disabled or in manual homing mode, don't show any
# warnings # warnings
@@ -315,8 +407,138 @@ 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 unhome(self, axis): self.mdi('G28.2 %c0' % axis) 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):
# External axes don't have AVR-side homed state to clear; the
# ESP holds its own homed flag. We don't have an explicit
# "unhome" verb on the ESP, but a stale homed flag is harmless
# because the next absolute move will fail-soft via
# ExternalAxis._pos_mm sync. Still mirror the cleared flag
# into State for the UI.
ext = getattr(self.ctrl, 'ext_axis', None)
if ext is not None and ext.enabled \
and chr(axis).lower() == ext.axis_letter:
from bbctrl.ExternalAxis import EXTERNAL_MOTOR_INDEX
self.ctrl.state.set('%dh' % EXTERNAL_MOTOR_INDEX, 0)
self.ctrl.state.set(ext.axis_letter + '_homed', False)
return
self.mdi('G28.2 %c0' % axis)
def estop(self): super().estop() def estop(self): super().estop()
@@ -343,12 +565,22 @@ class Mach(Comm):
def stop(self): def stop(self):
if self._get_state() != 'jogging': self.stopping = True if self._get_state() != 'jogging': self.stopping = True
super().i2c_command(Cmd.STOP) super().i2c_command(Cmd.STOP)
# Drain the external-axis worker queue so post-stop resumption
# doesn't replay queued moves that the user wanted cancelled.
ext = getattr(self.ctrl, 'ext_axis', None)
if ext is not None:
try: ext.abort()
except Exception: pass
def pause(self): super().pause() def pause(self): super().pause()
def unpause(self): def unpause(self):
if self._is_paused(): if self._is_paused():
# Gate unpause on hook completion
if hasattr(self.ctrl, 'hooks') and \
not self.ctrl.hooks.can_unpause():
return
self.ctrl.state.set('optional_pause', False) self.ctrl.state.set('optional_pause', False)
self._unpause() self._unpause()

View File

@@ -27,6 +27,7 @@
import json import json
import math import math
import os
import re import re
import time import time
from collections import deque from collections import deque
@@ -76,6 +77,10 @@ class Planner():
self.planner = None self.planner = None
self._position_dirty = False self._position_dirty = False
self.where = '' self.where = ''
# Tracks the rewritten temp file (if any) returned by the
# AuxPreprocessor for the currently-loaded program. We delete
# it on the next load() so it doesn't pile up under /tmp.
self._aux_tempfile = None
ctrl.state.add_listener(self._update) ctrl.state.add_listener(self._update)
@@ -196,12 +201,23 @@ class Planner():
def _add_message(self, text): def _add_message(self, text):
self.ctrl.state.add_message(text)
line = self.ctrl.state.get('line', 0) line = self.ctrl.state.get('line', 0)
if 0 <= line: where = '%s:%d' % (self.where, line) if 0 <= line: where = '%s:%d' % (self.where, line)
else: where = self.where else: where = self.where
# HOOK:<event>:<data> messages are an internal IPC channel
# between the gcode preprocessor and Hooks; bypass the user
# message list so they don't surface as popups, and dispatch
# the hook directly. Routing through state.messages would
# only deliver it after the 0.25s state-change debounce, by
# which point we'd have to keep it visible to ensure Hooks
# could see it.
hooks = getattr(self.ctrl, 'hooks', None)
if hooks is not None and hooks.dispatch_hook_message(text):
self.log.info('HOOK msg: %s' % text, where = where)
return
self.ctrl.state.add_message(text)
self.log.message(text, where = where) self.log.message(text, where = where)
@@ -259,6 +275,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)
if ext is not None:
# Side effect: enqueue the ESP move on the external-
# axis worker. The AVR still receives the full target
# (including A) so ex.position[A] tracks gplan; no
# motor steps for A because no motor maps to it.
self._dispatch_external_line(block, ext)
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'],
@@ -289,8 +353,17 @@ class Planner():
if name[2:] == '_homed': if name[2:] == '_homed':
motor = self.ctrl.state.find_motor(name[1]) motor = self.ctrl.state.find_motor(name[1])
if motor is not None: # Synthetic external motor (index 4) doesn't exist
# on the AVR; mirror the homed flag in State only.
from bbctrl.ExternalAxis import EXTERNAL_MOTOR_INDEX
if motor is not None and motor < EXTERNAL_MOTOR_INDEX:
return Cmd.set_sync('%dh' % motor, value) return Cmd.set_sync('%dh' % motor, value)
if motor == EXTERNAL_MOTOR_INDEX:
# Update synthetic motor flag and the<axis>_homed
# projection consumed by the DRO.
self.cmdq.enqueue(
id, self.ctrl.state.set,
'%dh' % EXTERNAL_MOTOR_INDEX, value)
return return
@@ -339,6 +412,68 @@ class Planner():
self.planner.set_logger(None) self.planner.set_logger(None)
# ----------------------------------------------- external-axis routing
#
# When an axis is exposed to gplan via a synthetic motor (no AVR
# channel), we need to fork its motion off to the ESP at line
# encode time and let the rest of the line proceed to the AVR.
# The split is done here rather than in gplan because gplan
# treats all six axes uniformly and just emits target dicts; we
# don't want to teach it about the ESP.
def _external_axis_for_line(self, block):
"""Return the ExternalAxis instance for whichever axis in
block['target'] is external, or None."""
ext = getattr(self.ctrl, 'ext_axis', None)
if ext is None or not ext.enabled:
return None
target = block.get('target') or {}
if ext.axis_letter in target or ext.axis_letter.upper() in target:
return ext
return None
def _dispatch_external_line(self, block, ext):
"""Side-effect: enqueue the ESP move on the external-axis
worker thread (non-blocking). Returns the block (possibly
unchanged) for the AVR.
We do NOT strip the external axis target from the AVR line.
The AVR's exec_move_to_target updates ex.position[axis] for
every axis in the target dict regardless of motor mapping,
and reports it back via the `p` indexed var. Leaving A in
the target keeps state.ap in sync with gplan's idea of A
(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:
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:
self.log.error('External axis enqueue failed: %s' % e)
raise
return block
def reset(self, *args, **kwargs): def reset(self, *args, **kwargs):
stop = kwargs.get('stop', True) stop = kwargs.get('stop', True)
if stop: if stop:
@@ -352,6 +487,16 @@ class Planner():
self.cmdq.clear() self.cmdq.clear()
self.reset_times() self.reset_times()
# Drain the external-axis worker queue and force the next
# move to re-sync position from the ESP (since State.reset
# below will zero <axis>p which makes ext._pos_mm stale).
ext = getattr(self.ctrl, 'ext_axis', None)
if ext is not None:
try: ext.abort()
except Exception: pass
try: ext._pos_mm = None
except Exception: pass
resetState = kwargs.get('resetState', True) resetState = kwargs.get('resetState', True)
if resetState: if resetState:
self.ctrl.state.reset() self.ctrl.state.reset()
@@ -367,12 +512,57 @@ class Planner():
def load(self, path): def load(self, path):
self.where = path self.where = path
path = self.ctrl.get_path('upload', path) src_path = self.ctrl.get_path('upload', path)
self.log.info('GCode:' + path) self.log.info('GCode:' + src_path)
# Clean up any leftover temp file from a previous load.
self._cleanup_aux_tempfile()
# Rewrite ATC M-codes (M100/M102/M103) and inject Z-A
# coupling moves before gplan sees them. The rewriting goes
# to a temp file -- the operator's macro / job source is
# never modified. This matters because:
#
# 1. The macro editor reads back the source. If we
# rewrote in place, the operator would open `drop.nc`
# and see (MSG,HOOK:...) blobs instead of the M-code
# sequence they wrote.
# 2. Re-running a rewritten file would re-rewrite it; any
# bug in the regex (e.g. with paren comments) would
# compound on every load.
#
# Why we rewrite at all: gplan treats M100..M103 as no-ops
# by spec and exposes no callback for user M-codes. Its only
# in-band channel back to Python during a running program is
# the (MSG,...) message stream, so we substitute hook
# messages for the M-codes purely as transport.
load_path = src_path
try:
from bbctrl.AuxPreprocessor import preprocess_to_tempfile
ext = getattr(self.ctrl, 'ext_axis', None)
coupling = (ext.coupling_for_preprocessor()
if ext is not None else None)
tmp = preprocess_to_tempfile(
src_path, log=self.log, coupling=coupling)
if tmp is not None:
self._aux_tempfile = tmp
load_path = tmp
self.log.info(
'Rewrote (ATC / Z-A coupling) for gplan: %s -> %s'
% (src_path, tmp))
except Exception:
self.log.exception('Aux preprocess at load failed; '
'attempting to load file unchanged')
self._sync_position() self._sync_position()
self.planner.load(path, self.get_config(False, True)) self.planner.load(load_path, self.get_config(False, True))
self.reset_times() self.reset_times()
def _cleanup_aux_tempfile(self):
if self._aux_tempfile and os.path.exists(self._aux_tempfile):
try: os.unlink(self._aux_tempfile)
except OSError: pass
self._aux_tempfile = None
def stop(self): def stop(self):
try: try:

View File

@@ -74,6 +74,7 @@ class Plan(object):
self.progress = 0 self.progress = 0
self.cancel = False self.cancel = False
self.pid = None self.pid = None
self.error = None
root = ctrl.get_path() root = ctrl.get_path()
self.gcode = '%s/upload/%s' % (root, filename) self.gcode = '%s/upload/%s' % (root, filename)
@@ -202,8 +203,16 @@ class Plan(object):
if not self._exists(): yield self._exec() if not self._exists(): yield self._exec()
self.future.set_result(self._read()) self.future.set_result(self._read())
except: except Exception as e:
self.preplanner.log.exception("Failed to load file - doesn't appear to be GCode.") # Record the error and ALWAYS resolve the future, otherwise
# PathHandler.get keeps timing out at 1s forever and the UI
# gets stuck on the "Processing New File" dialog.
self.preplanner.log.exception(
"Failed to plan file: " + str(e))
self.error = str(e) or 'Plan failed'
self.progress = 1
if not self.future.done():
self.future.set_result(None)
class Preplanner(object): class Preplanner(object):
@@ -268,3 +277,6 @@ class Preplanner(object):
def get_plan_progress(self, filename): def get_plan_progress(self, filename):
return self.plans[filename].progress if filename in self.plans else 0 return self.plans[filename].progress if filename in self.plans else 0
def get_plan_error(self, filename):
return self.plans[filename].error if filename in self.plans else None

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':
@@ -280,8 +286,11 @@ class State(object):
axis_motors = {axis: self.find_motor(axis) for axis in 'xyzabc'} axis_motors = {axis: self.find_motor(axis) for axis in 'xyzabc'}
axis_vars = {} axis_vars = {}
# NOTE: motor index '4' is a host-only synthetic motor used
# by ExternalAxis to expose the auxcnc ESP-driven stepper as
# an additional axis. Real AVR motors are 0..3.
for name, value in vars.items(): for name, value in vars.items():
if name[0] in '0123': if name[0] in '01234':
motor = int(name[0]) motor = int(name[0])
for axis in 'xyzabc': for axis in 'xyzabc':
@@ -330,6 +339,9 @@ class State(object):
def get_axis_vector(self, name, scale = 1): def get_axis_vector(self, name, scale = 1):
v = {} v = {}
# 0..3 are AVR motor channels. 4 is the host-side synthetic
# motor used by ExternalAxis. find_motor returns the right
# index regardless of whether the axis is physical or external.
for axis in 'xyzabc': for axis in 'xyzabc':
motor = self.find_motor(axis) motor = self.find_motor(axis)
@@ -351,7 +363,10 @@ class State(object):
def find_motor(self, axis): def find_motor(self, axis):
for motor in range(4): # Walk 0..4: 0..3 are real AVR motors, 4 is the synthetic
# host-side motor used to expose the auxcnc ESP stepper as
# an external axis.
for motor in range(5):
if not ('%dan' % motor) in self.vars: continue if not ('%dan' % motor) in self.vars: continue
motor_axis = 'xyzabc'[self.vars['%dan' % motor]] motor_axis = 'xyzabc'[self.vars['%dan' % motor]]
if motor_axis == axis.lower() and self.vars.get('%dme' % motor, 0): if motor_axis == axis.lower() and self.vars.get('%dme' % motor, 0):

View File

@@ -411,11 +411,22 @@ class PathHandler(bbctrl.APIHandler):
except gen.TimeoutError: except gen.TimeoutError:
progress = preplanner.get_plan_progress(filename) progress = preplanner.get_plan_progress(filename)
self.write_json(dict(progress = progress)) err = preplanner.get_plan_error(filename)
resp = dict(progress = progress)
if err: resp['error'] = err
self.write_json(resp)
return return
try: try:
if data is None: return # Plan finished but produced no data (planner subprocess
# failed, e.g. AuxPreprocessor coupling rejection at
# planner-load time). Surface the error so the UI can
# close the "Processing New File" dialog instead of
# polling forever.
if data is None:
err = preplanner.get_plan_error(filename) or 'Plan failed'
self.write_json(dict(progress = 1, error = err))
return
meta, positions, speeds = data meta, positions, speeds = data
if dataType == '/positions': data = positions if dataType == '/positions': data = positions
@@ -766,6 +777,111 @@ class RotaryHandler(bbctrl.APIHandler):
log.error('Unexpected error: {}'.format(e)) log.error('Unexpected error: {}'.format(e))
class HooksGetHandler(bbctrl.APIHandler):
def get(self):
self.write_json(self.get_ctrl().hooks.get_config())
class HooksSaveHandler(bbctrl.APIHandler):
def put_ok(self):
self.get_ctrl().hooks.save_config(self.json)
class HooksStatusHandler(bbctrl.APIHandler):
def get(self):
self.write_json(self.get_ctrl().hooks.get_status())
class HooksFireHandler(bbctrl.APIHandler):
def put_ok(self, event):
data = self.json if hasattr(self, 'json') and self.json else {}
self.get_ctrl().hooks._fire(event, data)
# ----- W axis (auxcnc) endpoints --------------------------------------------
class AuxConfigGetHandler(bbctrl.APIHandler):
def get(self):
self.write_json(self.get_ctrl().aux.get_config())
class AuxConfigSaveHandler(bbctrl.APIHandler):
def put_ok(self):
self.get_ctrl().aux.save_config(self.json or {})
class AuxStatusHandler(bbctrl.APIHandler):
def get(self):
aux = self.get_ctrl().aux
self.write_json({
'enabled': aux.enabled,
'present': aux.present,
'homed': aux.homed,
'pos_mm': aux.position_mm,
})
class AuxHomeHandler(bbctrl.APIHandler):
def put_ok(self):
# Run synchronously. Route through ExternalAxis so the
# synthetic motor's homed flag and DRO update.
ext = getattr(self.get_ctrl(), 'ext_axis', None)
if ext is not None and ext.enabled:
ext.home()
else:
self.get_ctrl().aux.home()
class AuxAbortHandler(bbctrl.APIHandler):
def put_ok(self):
self.get_ctrl().aux.abort()
class AuxJogHandler(bbctrl.APIHandler):
"""Body: {"mm": 1.5} for relative-mm move,
{"steps": 200} for raw step move (bypasses soft limits).
Note: with the gplan-integrated W axis, jog-by-mm goes through
ExternalAxis so the DRO updates and gplan's idea of A's position
stays in sync. jog-by-steps still bypasses everything for the
homing/setup workflow where the axis isn't homed yet."""
def put_ok(self):
body = self.json or {}
aux = self.get_ctrl().aux
ext = getattr(self.get_ctrl(), 'ext_axis', None)
if 'mm' in body:
delta_mm = float(body['mm'])
if ext is not None and ext.enabled and ext._pos_mm is not None:
ext.execute_to_mm(ext._pos_mm + delta_mm)
else:
aux.move_rel_mm(delta_mm)
elif 'steps' in body:
aux.jog_steps(int(body['steps']))
else:
raise HTTPError(400, 'mm or steps required')
class AuxMoveHandler(bbctrl.APIHandler):
"""Body: {"mm": 12.5} absolute move in mm."""
def put_ok(self):
body = self.json or {}
if 'mm' not in body:
raise HTTPError(400, 'mm required')
ext = getattr(self.get_ctrl(), 'ext_axis', None)
if ext is not None and ext.enabled:
ext.execute_to_mm(float(body['mm']))
else:
self.get_ctrl().aux.move_abs_mm(float(body['mm']))
class AuxSetZeroHandler(bbctrl.APIHandler):
"""Body: {"mm": 0} set current position to <mm>."""
def put_ok(self):
body = self.json or {}
mm = float(body.get('mm', 0.0))
self.get_ctrl().aux.set_position_mm(mm)
class RemoteDiagnosticsHandler(bbctrl.APIHandler): class RemoteDiagnosticsHandler(bbctrl.APIHandler):
def get(self): def get(self):
@@ -798,7 +914,6 @@ class RemoteDiagnosticsHandler(bbctrl.APIHandler):
'message': e.reason or "Unknown" 'message': e.reason or "Unknown"
}) })
class TimingHandler(bbctrl.APIHandler): class TimingHandler(bbctrl.APIHandler):
"""Return the bbctrl process startup timeline as JSON. """Return the bbctrl process startup timeline as JSON.
@@ -992,6 +1107,18 @@ class Web(tornado.web.Application):
(r'/api/time', TimeHandler), (r'/api/time', TimeHandler),
(r'/api/rotary', RotaryHandler), (r'/api/rotary', RotaryHandler),
(r'/api/remote-diagnostics', RemoteDiagnosticsHandler), (r'/api/remote-diagnostics', RemoteDiagnosticsHandler),
(r'/api/hooks', HooksGetHandler),
(r'/api/hooks/save', HooksSaveHandler),
(r'/api/hooks/status', HooksStatusHandler),
(r'/api/hooks/fire/([\w-]+)', HooksFireHandler),
(r'/api/aux/config', AuxConfigGetHandler),
(r'/api/aux/config/save', AuxConfigSaveHandler),
(r'/api/aux/status', AuxStatusHandler),
(r'/api/aux/home', AuxHomeHandler),
(r'/api/aux/abort', AuxAbortHandler),
(r'/api/aux/jog', AuxJogHandler),
(r'/api/aux/move', AuxMoveHandler),
(r'/api/aux/set-zero', AuxSetZeroHandler),
(r'/(.*)', StaticFileHandler, (r'/(.*)', StaticFileHandler,
{'path': bbctrl.get_resource('http/'), {'path': bbctrl.get_resource('http/'),
'default_filename': 'index.html'}), 'default_filename': 'index.html'}),

View File

@@ -66,6 +66,9 @@ from bbctrl.AVR import AVR
from bbctrl.AVREmu import AVREmu from bbctrl.AVREmu import AVREmu
from bbctrl.IOLoop import IOLoop from bbctrl.IOLoop import IOLoop
from bbctrl.MonitorTemp import MonitorTemp from bbctrl.MonitorTemp import MonitorTemp
from bbctrl.Hooks import Hooks
from bbctrl.AuxAxis import AuxAxis
from bbctrl.ExternalAxis import ExternalAxis
import bbctrl.Cmd as Cmd import bbctrl.Cmd as Cmd
import bbctrl.v4l2 as v4l2 import bbctrl.v4l2 as v4l2
import bbctrl.Log as log import bbctrl.Log as log

View File

@@ -1457,6 +1457,9 @@ tt.save
.dro-axis.axis-c .dro-axis.axis-c
color #d946ef color #d946ef
.dro-axis.axis-w
color #7c3aed
.dro-pos .dro-pos
font-family 'JetBrains Mono', monospace font-family 'JetBrains Mono', monospace
font-size 36px font-size 36px

View File

@@ -0,0 +1,336 @@
<script lang="ts">
import { onMount } from "svelte";
import Button, { Label } from "@smui/button";
import * as api from "$lib/api";
// Mirrors the DEFAULTS in src/py/bbctrl/AuxAxis.py. The "enabled"
// flag is read-only here; toggling the auxiliary A axis on/off
// is done via aux.json on disk, so adding/removing the hardware
// 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 = {
enabled: boolean;
port: string;
baud: number;
steps_per_mm: number;
dir_sign: number;
axis_letter: string;
min_mm: number;
max_mm: number;
max_feed_mm_min: number;
max_velocity_m_per_min: number;
max_accel_km_per_min2: number;
max_jerk_km_per_min3: number;
home_dir: string;
home_position_mm: number;
home_fast_sps: number;
home_slow_sps: number;
home_backoff_steps: number;
home_maxtravel_steps: number;
step_max_sps: number;
step_accel_sps2: number;
step_start_sps: number;
limit_low: boolean;
couple_z_enabled: boolean;
couple_z_clearance_mm: number;
z_home_mm: number;
};
let cfg: AuxConfig | null = null;
let status: { enabled: boolean; present: boolean; homed: boolean; pos_mm: number } | null = null;
let busy = false;
// Listen for the global "save-all" event the Vue root dispatches
// when the user clicks the master Save button. We persist our
// current cfg the same way the in-form button used to. This way
// the user only ever needs one Save button.
function onGlobalSave() {
save().catch(e => console.error("aux save failed:", e));
}
onMount(async () => {
await refresh();
window.addEventListener("onefin:save-all", onGlobalSave);
return () => window.removeEventListener("onefin:save-all", onGlobalSave);
});
async function refresh() {
try {
cfg = await api.GET("aux/config");
status = await api.GET("aux/status");
} catch (e) {
console.error("Failed to load aux config/status:", e);
}
}
async function save() {
if (!cfg) return;
busy = true;
try {
await api.PUT("aux/config/save", cfg);
await refresh();
} catch (e) {
console.error("Failed to save aux config:", e);
throw e;
} finally {
busy = false;
}
}
// Mark the root config as modified whenever an auxiliary axis
// field is edited, so the master Save button highlights and
// the user knows there are unsaved changes.
function markDirty() {
try {
const root = (window as any).$root || (window as any).Vue?.root;
if (root && "modified" in root) root.modified = true;
} catch (_e) {}
// Also dispatch a generic event the Vue root listens for.
window.dispatchEvent(new CustomEvent("onefin:dirty"));
}
</script>
<div class="a-axis-settings">
{#if !cfg}
<p class="tip">Loading A axis configuration...</p>
{:else}
<div class="status">
{#if status}
<span>
Status:
{#if !status.enabled}
disabled
{:else if !status.present}
offline
{:else if status.homed}
homed at {status.pos_mm.toFixed(3)} mm
{:else}
connected, unhomed
{/if}
</span>
{/if}
</div>
<div class="pure-form pure-form-aligned" on:input={markDirty} on:change={markDirty}>
<fieldset>
<div class="pure-control-group" title="Enable the auxiliary axis (auxcnc-driven A). Edit aux.json to toggle.">
<label for="enabled">enabled</label>
<input id="enabled" type="checkbox" checked={cfg.enabled} disabled />
<label for="" class="units">(edit aux.json)</label>
</div>
<div class="pure-control-group" title="Serial port for the auxcnc ESP32.">
<label for="port">serial port</label>
<input id="port" type="text" bind:value={cfg.port} />
</div>
<div class="pure-control-group" title="Serial baud rate.">
<label for="baud">baud</label>
<input id="baud" type="number" bind:value={cfg.baud} min={1200} step={1} />
</div>
</fieldset>
<h3>Mechanics</h3>
<fieldset>
<div class="pure-control-group" title="Logical steps per mm of axis travel.">
<label for="steps_per_mm">steps per mm</label>
<input id="steps_per_mm" type="number" bind:value={cfg.steps_per_mm} step="any" />
<label for="" class="units">steps/mm</label>
</div>
<div class="pure-control-group" title="Direction sign: +1 or -1. Flip if A+ moves the wrong way.">
<label for="dir_sign">direction sign</label>
<select id="dir_sign" bind:value={cfg.dir_sign}>
<option value={1}>+1</option>
<option value={-1}>-1</option>
</select>
</div>
<div class="pure-control-group" title="gcode axis letter exposed to the planner. Default 'a' (the standard 4th axis).">
<label for="axis_letter">axis letter</label>
<select id="axis_letter" bind:value={cfg.axis_letter}>
<option value="a">A</option>
<option value="b">B</option>
<option value="c">C</option>
</select>
</div>
<div class="pure-control-group" title="Soft-limit minimum in mm.">
<label for="min_mm">soft min</label>
<input id="min_mm" type="number" bind:value={cfg.min_mm} step="any" />
<label for="" class="units">mm</label>
</div>
<div class="pure-control-group" title="Soft-limit maximum in mm.">
<label for="max_mm">soft max</label>
<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>
</div>
</fieldset>
<h3>Planner Limits</h3>
<fieldset>
<div class="pure-control-group" title="Maximum velocity used by gplan trajectory planning.">
<label for="max_velocity_m_per_min">max velocity</label>
<input id="max_velocity_m_per_min" type="number" bind:value={cfg.max_velocity_m_per_min} step="any" />
<label for="" class="units">m/min</label>
</div>
<div class="pure-control-group" title="Maximum acceleration used by gplan trajectory planning.">
<label for="max_accel_km_per_min2">max acceleration</label>
<input id="max_accel_km_per_min2" type="number" bind:value={cfg.max_accel_km_per_min2} step="any" />
<label for="" class="units">km/min²</label>
</div>
<div class="pure-control-group" title="Maximum jerk used by gplan trajectory planning.">
<label for="max_jerk_km_per_min3">max jerk</label>
<input id="max_jerk_km_per_min3" type="number" bind:value={cfg.max_jerk_km_per_min3} step="any" />
<label for="" class="units">km/min³</label>
</div>
<div class="pure-control-group" title="Informational max feed; rate caps live on the ESP via step_max_sps.">
<label for="max_feed_mm_min">max feed</label>
<input id="max_feed_mm_min" type="number" bind:value={cfg.max_feed_mm_min} step="any" />
<label for="" class="units">mm/min</label>
</div>
</fieldset>
<h3>Homing</h3>
<fieldset>
<div class="pure-control-group" title="Direction the axis moves when looking for the home limit switch.">
<label for="home_dir">home direction</label>
<select id="home_dir" bind:value={cfg.home_dir}>
<option value="-">- (toward A-)</option>
<option value="+">+ (toward A+)</option>
</select>
</div>
<div class="pure-control-group" title="Axis position assigned when homing completes.">
<label for="home_position_mm">home position</label>
<input id="home_position_mm" type="number" bind:value={cfg.home_position_mm} step="any" />
<label for="" class="units">mm</label>
</div>
<div class="pure-control-group" title="Fast seek rate during homing search.">
<label for="home_fast_sps">fast seek</label>
<input id="home_fast_sps" type="number" bind:value={cfg.home_fast_sps} step={1} min={1} />
<label for="" class="units">steps/s</label>
</div>
<div class="pure-control-group" title="Slow seek rate during homing latch.">
<label for="home_slow_sps">slow seek</label>
<input id="home_slow_sps" type="number" bind:value={cfg.home_slow_sps} step={1} min={1} />
<label for="" class="units">steps/s</label>
</div>
<div class="pure-control-group" title="Backoff after the limit triggers, before the slow seek.">
<label for="home_backoff_steps">backoff</label>
<input id="home_backoff_steps" type="number" bind:value={cfg.home_backoff_steps} step={1} min={0} />
<label for="" class="units">steps</label>
</div>
<div class="pure-control-group" title="Maximum travel before homing aborts as a runaway.">
<label for="home_maxtravel_steps">max travel</label>
<input id="home_maxtravel_steps" type="number" bind:value={cfg.home_maxtravel_steps} step={1} min={1} />
<label for="" class="units">steps</label>
</div>
<div class="pure-control-group" title="Limit switch active-low? Off = active-high.">
<label for="limit_low">limit active low</label>
<input id="limit_low" type="checkbox" bind:checked={cfg.limit_low} />
</div>
</fieldset>
<h3>Step Profile</h3>
<fieldset>
<div class="pure-control-group" title="Maximum step rate during normal moves.">
<label for="step_max_sps">max rate</label>
<input id="step_max_sps" type="number" bind:value={cfg.step_max_sps} step={1} min={1} />
<label for="" class="units">steps/s</label>
</div>
<div class="pure-control-group" title="Acceleration in steps per second squared.">
<label for="step_accel_sps2">acceleration</label>
<input id="step_accel_sps2" type="number" bind:value={cfg.step_accel_sps2} step={1} min={1} />
<label for="" class="units">steps/s²</label>
</div>
<div class="pure-control-group" title="Initial step rate at the start of a move.">
<label for="step_start_sps">start rate</label>
<input id="step_start_sps" type="number" bind:value={cfg.step_start_sps} step={1} min={1} />
<label for="" class="units">steps/s</label>
</div>
</fieldset>
<div class="tip">
Changes are written to aux.json when you click the
master <strong>Save</strong> button at the bottom of the
settings rail. Homing rates and the limit polarity are
pushed to the ESP immediately; any running motion is
unaffected. Re-home the auxiliary axis after changing direction,
sign, or step settings.
</div>
</div>
{/if}
</div>
<style lang="scss">
.a-axis-settings {
.status {
margin-bottom: 1em;
font-size: 90%;
opacity: 0.8;
}
.actions {
margin-left: 210px;
margin-top: 1em;
display: flex;
align-items: center;
gap: 1em;
}
.save-msg {
font-style: italic;
}
.tip {
margin-left: 210px;
margin-top: 1em;
margin-bottom: 15px;
font-style: italic;
font-size: 90%;
line-height: 1.5;
}
}
</style>

View File

@@ -2,6 +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";
// AAxisSettings is mounted directly by the V09 settings shell at
// #a-axis instead of being embedded here — see
// src/pug/templates/a-axis-view.pug.
// 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";
@@ -94,6 +98,10 @@
{/each} {/each}
</fieldset> </fieldset>
<!-- W Axis (auxcnc) is now its own routed page in the V09
settings shell (#a-axis). Keep the SettingsView free of
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>
<fieldset data-sec="gcode"> <fieldset data-sec="gcode">
<ConfigTemplatedInput key={`settings.max-deviation`} /> <ConfigTemplatedInput key={`settings.max-deviation`} />

View File

@@ -6,6 +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 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";
@@ -22,6 +23,9 @@ export function createComponent(component: string, target: HTMLElement, props: R
case "HelpView": case "HelpView":
return new HelpView({ target, props }); return new HelpView({ target, props });
case "AAxisSettings":
return new AAxisSettings({ target, props });
case "DialogHost": case "DialogHost":
return new DialogHost({ target, props }); return new DialogHost({ target, props });