Compare commits

34 Commits

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

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

Operator workflow: jog freely down to the safe band; if you need to
go deeper, lower A first (aux jog mm) or use a step-jog MDI like
'G91 G0 Z-10 A-10' that includes the A delta. Programs do the right
thing on their own.
2026-05-03 15:03:01 +02:00
39e308d9ae AuxAxis: push home_preclear_mm via HOMECFG
Tells the auxcnc ESP how far (in steps) to back off if HOME is
invoked while the limit switch is already tripped. The ESP now
hard-fails instead of zeroing blindly when the switch stays active
after the preclear move. Default 10 mm; set home_preclear_mm=0 to
disable the preclear and revert to immediate failure.
2026-05-03 14:54:56 +02:00
8e6e72a8b9 Z-A coupling: auto-coordinate A on jogs and MDI
Match the file-preprocessor behaviour for live operator input. When a
Z-down jog or MDI line would push (A-Z) above the safe band, append
the matching A delta to the same line so the planner runs Z and A
together. Same direction-aware refusal: only error when the operator
explicitly asks A to move *up* (delta > 0) past the bound, or when
the required A would violate A's soft minimum.

Implementation:
  * ExternalAxis.coordinate_mdi rewrites a multi-line MDI burst,
    tracking G90/G91 modal across lines (jogs always emit
    M70/G91/G0/M72; standard MDI defaults to G90). Z and A targets
    are computed in machine coords using offset_z and offset_a so
    the work-coord A token we emit is consistent with the operator's
    frame.
  * The 'A0' the jog UI emits for axes that aren't moving is treated
    as 'no A intent' (G91 delta of zero) and freely overridden.
  * Hooked into Mach.mdi after the existing ATC rewrite. On
    ExternalAxisError the burst is dropped with a user message; the
    planner check downstream still fires as defense in depth.
  * Planner.__encode also catches ExternalAxisError now (vs
    bricking on uncaught) - logs to the operator messages list and
    halts the cycle cleanly so subsequent jogs work.
  * check_coupling itself is now improvement-aware: only refuses
    moves that worsen an existing violation. Pure XY jogs and
    Z-up/A-down recovery moves pass even when (A-Z) is currently
    above the bound.

Tested locally with synthetic MDI: small Z jog within band, Z jog
across the boundary (auto-injects A delta), G90 MDI G0 Z-50
(appends A106), explicit A-lift while Z deep (refuses), pure XY
jog (unchanged), G91 A-down (unchanged), G90 G0 A0 with
offset_a=134 (refuses as lift to home).
2026-05-03 14:47:44 +02:00
226b44053c Z-A coupling interlock: prevent collision between Z and A tools
The auxiliary A axis carries a tool that hangs below the Z spindle.
Beyond a small Z descent the two physically collide unless A drops
with Z. Enforce in machine coords:

    A_machine - Z_machine <= K
    K = (A_home_mm - z_home_mm) + couple_z_clearance_mm

With our setup K = (134 - 0) + 22 = 156. At rest A=134 Z=0, A-Z=134
which is fine. Z can descend 22mm before the rule starts forcing A
down with it.

Two complementary layers:

(1) AuxPreprocessor injection (auto-fix uploaded files)
    Tracks modal Z, A and distance mode (G90/G91) while scanning the
    file. When a line would put A above Z by more than the clearance
    we emit a 'G0 A<safe>' BEFORE the line so A is already at the
    safe position when Z descends. Endpoint check is sufficient
    because Z moves monotonically along a single line.

    Errors are raised (not silently auto-fixed) when:
      - the line lifts A above the safe band while Z stays put
        (would require auto-injecting a Z-up which could swing
        through a fixture)
      - the line endpoint targets an A above the safe band

    G91 disables injection with a one-shot warning; the runtime
    check still applies.

(2) Runtime check (ExternalAxis.check_coupling)
    Single source of truth for live motion. Hooked into:
      * Planner.__encode for every line block (covers MDI and
        running programs - gplan emits machine-coord targets)
      * ExternalAxis.execute_to_mm/enqueue_target_mm/enqueue_line
        for direct A motion (covers UI jog/move and planner-A
        dispatch)
    Raises ExternalAxisError on violation; gplan and the API both
    surface the message. Skipped when coupling is disabled or the
    axis isn't homed (mirrors the soft-limit gate).

    Continuous Z jog from the AVR is not gated - it's an active
    operator action without a pre-known endpoint. Operator-driven
    over-travel during continuous jog will be caught by the next
    MDI/file-load attempt.

Configuration in aux.json:
    couple_z_enabled        bool   default true (per agreed setup)
    couple_z_clearance_mm   float  default 22.0
    z_home_mm               float  default 0.0

Surfaced in the new Z-A Coupling section of the A Axis settings
page with a description of the rule. Existing aux.json files get
the new keys via the merged-defaults path on read.

Tested locally with synthetic gcode covering Z descent, combined
moves, A lift while Z deep, G92 reset, G91 mode, and combined
Z+A target violations.
2026-05-03 14:28:57 +02:00
0493a4ddc7 Rename W axis -> A axis everywhere (with migration)
Auxiliary axis is the auxcnc-driven stepper exposed to gplan as A,
not W. Half the stack already used A (gcode, DRO row, soft limits,
homing); the other half (settings tab, macros, internal field
names) still said W which was confusing.

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

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

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

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

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

Split into two phases:

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

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

Wrap the external-homing block in try/except and force the cycle
back to idle on any failure. The success path is unchanged - it
still relies on the AVR's queued set_axis + G92 acks to bring the
state back to READY.
2026-05-03 13:13:29 +02:00
983e06b53d Wire ExternalAxis to send LINE blocks (full S-curve mirror to ESP)
Replaces the legacy 'fixed-rate STEPS' path used in Planner.__encode
with a new ExternalAxis.enqueue_line() that hands the ESP the full
7-segment S-curve parameters of every gplan line block (max_accel,
max_jerk, entry_vel, exit_vel, times[7]).

The ESP's new LINE command (auxcnc commit 8acc6f7) integrates the
same SCurve math the AVR uses, so the W axis now physically moves
in lockstep with whatever the planner thinks A is doing. Result:
DRO stays in sync with the actual stepper, no more multi-second lag
between commanded and observed A position.

enqueue_target_mm is kept as a no-frills STEPS path for jog/move UI
endpoints that don't have planner timing context.

AuxAxis._do_line builds the LINE command with mm/min/min^2/min^3
units (matching gplan's internal unit system) and waits for
[line] done|aborted from the ESP. Limit aborts still flag _homed=False.
2026-05-03 12:29:43 +02:00
53b65dc30e AuxAxis: force unhome on every connect to keep host/ESP state consistent
The ESP's homed flag survives bbctrl restarts (since the ESP itself
stays powered). Host state, on the other hand, gets reset to zero on
boot - State.reset zeros ap and offset_a. Trusting the ESP's homed
flag in that situation made gplan think A was homed at machine-coord 0
while physically the axis was at 134, which then rejected any move
to the bottom (G1 A-134) as 'less than minimum soft limit 0'.

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

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

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

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

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

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

1. Mach.home for the external axis: replace G28.3 with explicit
   AVR position sync (Cmd.set_axis) + planner abs sync
   (position_change) + G92 a0 (set user-coord origin to current
   physical position, computing offset = home_position_mm).

   G28.3 was wrong: it preserves the current user-coord position
   and adjusts the offset to bridge to the new abs. After a move
   away from home and a re-home, the offset accumulates
   (134 -> 268 -> ...). G92 a0 with a freshly-synced abs always
   produces offset = home_position_mm regardless of prior state.

2. Planner.__encode: stop stripping the external axis target from
   the AVR line. The AVR has no motor mapped to A so it steps no
   motor, but exec_move_to_target updates ex.position[A] which
   gets reported back as ap. Leaving A in the AVR target keeps
   state.ap consistent with gplan's idea of A; stripping it left
   ex.position[A] stale and clobbered ExternalAxis's state.ap on
   the next status report.

   Side benefit: removes the special-case empty-string return for
   pure external moves; every line block follows the same path now.

3. ExternalAxis.enqueue_target_mm: stop writing to state.<axis>p
   from the planner hot path. The AVR's status reports drive it
   instead, which avoids DRO jitter (jump to target then snap back
   to intermediate values as the trapezoid runs). _pos_mm internal
   mirror is still updated for delta computation.

Re-verified with the integration smoke test in tmp/20260503_option_b/:
home/move-down/move-up/re-home/home-from-bottom all produce the
expected DRO position values (0 at home, -134 at bottom).
2026-05-03 10:46:47 +02:00
7cdab010b3 axis-vars: guard motor lookup for synthetic motor 4
_compute_axis indexed config.motors[motor_id] directly without
checking the array length. For motor_id=4 (the synthetic external-
axis motor used by ExternalAxis) there is no entry in config.motors,
so motor was undefined and motor["homing-mode"] threw - which
made the entire 'a' computed prop return undefined and the A row
never rendered.

Default to {} when the index is out of bounds.
2026-05-03 10:26:36 +02:00
53f26b0be8 feat: integrate W axis as virtual A axis through gplan
Big-bang refactor of the W-axis integration. The auxcnc ESP stepper
is now exposed to the bbctrl planner (camotics gplan) as a virtual
A axis with no AVR motor mapping. gplan parses gcode for A natively,
applies soft limits, units, accel ramping and S-curve trajectories.
Line blocks with A motion are intercepted in Planner.__encode and
forked to the ESP via ExternalAxis on a worker thread; the residual
XYZ motion goes to the AVR as before.

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

## Architecture

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

## New module: ExternalAxis

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

## Planner

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

## Mach

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

## State

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

## AuxAxis

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

## AuxPreprocessor

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

## Hooks

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

## Web.py

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

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

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

## Open follow-ups (validate on hardware)

  - Q1: gplan soft-limit enforcement for A with min/max set.
    Easy smoke test: max_w=50, MDI G1 A100, expect rejection.
  - Q2: AVR behaviour with a target dict containing A values for
    a motorless axis. Read of exec.c suggests it's safe; needs a
    smoke test (no motor faults, no unexpected step counts).
  - Q3: pause/resume mid-A-move semantics. ESP doesn't honour
    bbctrl pauses; ext.abort drains the queue but a move-in-flight
    runs to completion. Acceptable for v1; v2 could add a synced
    pause.
2026-05-02 17:17:20 +02:00
3614a2bcd4 AuxPreprocessor: canonical M100-M103 for ATC pneumatics
Map four user-defined M-codes to the existing ATC hooks:

  M100  DROPTOOL  -> (MSG,HOOK:droptool:)
  M101  GRABTOOL  -> (MSG,HOOK:grabtool:)
  M102  RELEASE   -> (MSG,HOOK:release:)
  M103  CLAMP     -> (MSG,HOOK🗜️)

M100-M103 are in LinuxCNC/Buildbotics user-defined range so the
planner won't error on the raw codes if the preprocessor is bypassed.
Stripped from the residual line and replaced with the hook line.
Order is left-to-right; multiple ATC codes per line and ATC+W on
the same line both work (M100 W10 -> drop then move to W=10).

The file scanner (file_uses_aux, formerly file_uses_w) now wakes
up for either W tokens or ATC M-codes; backwards-compat alias kept.
MDI rewrite (Mach._rewrite_w_mdi) updated likewise.

Tested locally with mixed ATC/W gcode in tmp/20260501_atc_mcodes.
2026-05-01 18:49:44 +02:00
06f0e6517e AuxAxis: wire DROPTOOL/GRABTOOL/RELEASE/CLAMP as gcode hooks
Adds atc_droptool/atc_grabtool/atc_release/atc_clamp wrappers in
AuxAxis (each just an RPC waiting on the matching terminal reply
line from the firmware), and registers them as internal hook
handlers in Ctrl. Macros and gcode programs can now invoke the
tool changer with:

  (MSG,HOOK:droptool:)
  (MSG,HOOK:grabtool:)
  (MSG,HOOK:release:)
  (MSG,HOOK🗜️)

block_unpause + auto_resume mirrors the W-axis hooks: the program
pauses while the ESP runs the pneumatic sequence and resumes when
done. Soft timeouts match the worst-case ESP sequence durations.
2026-05-01 16:35:45 +02:00
1a6f926181 Hooks: dispatch HOOK messages directly, bypassing state.messages
The previous fix routed (MSG,HOOK:...) lines through state.messages
and then immediately ack'd them to suppress the user-visible popup.
But state changes are debounced 0.25s before listeners fire, so the
HOOK message was already ack'd (removed from the list) by the time
Hooks._on_state_change saw the update - and the hook never ran.

Add Hooks.dispatch_hook_message() as a direct entry point and call
it from Planner._add_message. HOOK lines are dispatched synchronously
from the planner thread; the user message list is left untouched, so
no popup leaks and no debounce race.
2026-05-01 16:32:02 +02:00
b0712a5bf0 run_macro: use fetch instead of api.get for /api/file
api.get assumes JSON responses, but /api/file/<name> returns raw
gcode text. The await threw on response.json() and start_pause()
never fired. Use fetch directly and await response.text() to make
sure FileHandler.get's select_file side effect has been processed
before mach.start() runs.
2026-05-01 16:29:25 +02:00
1c69c0a157 Macros: fix wrong-file race, suppress HOOK message popups, preprocess at load
Three fixes for macro/W-axis interaction:

1. run_macro raced the file selection. The frontend mutated
   state.selected client-side and immediately fired api.put('start').
   Selection on the server is a side effect of GET /api/file/<name>
   (FileHandler.get calls state.select_file). The GET request was
   often still in flight when start ran, so mach.start() executed
   whichever file was selected last - pressing W Down would re-run
   W Up. Now run_macro awaits the file fetch before starting.

2. (MSG,HOOK:aux:N) lines, used as the IPC channel between the
   W-axis preprocessor and the Hooks system, were leaking to the
   user as message popups (because the planner forwards every
   (MSG,...) comment to state.messages). Filter HOOK: messages in
   Planner._add_message: still pushed through state.messages so
   Hooks._on_state_change can dispatch them, but immediately
   acked so the UI doesn't render them.

3. AuxPreprocessor only ran at upload time (FileHandler.put_ok and
   Mach.mdi). Files written via scp, restored from a config backup,
   or hand-edited still contained raw W tokens that the planner
   couldn't parse. Run preprocess_file in Planner.load() too. It's
   idempotent (no-op when no W tokens remain) so re-loading a
   already-rewritten file is free.
2026-05-01 16:27:14 +02:00
muehe
748f092795 control: properly wait for gantry homing before W
api.put('home') returns immediately when queued, not when homing
finishes, so the previous polling loop saw cycle=='idle' (homing
hadn't started yet) and fired W right away. Now we first wait up
to 5s for the cycle to *leave* idle, then up to 2min for it to
return, before kicking off the auxcnc W home.
2026-05-01 16:18:23 +02:00
muehe
cfc14643d2 control: home W last in 'Home All'
Previously XYZ and W homing dispatched in parallel. Wait for the
main AVR homing cycle to return to idle before kicking off the W
auxcnc home so they never run simultaneously.
2026-05-01 16:16:51 +02:00
muehe
b0f38619ba control: keep jog grid visible during jog/home/probe/mdi
is_program_executing was checking only state.xx (RUNNING/HOLDING/
STOPPING) which is also true during jogging, homing, probing and
MDI cycles. The Now Running panel therefore took over the Control
view whenever the user jogged. Add a state.cycle check so only the
'running' cycle (a loaded program executing) triggers the swap.
2026-05-01 16:08:39 +02:00
549b69c234 motor-view: stop clobbering user edits with controller state
The legacy Vue 1 motor settings page had nine `current_xxx` computed
props mirroring controller state vars (`<idx>vm`, `<idx>am`,
`<idx>jm`, `<idx>sa`, `<idx>tr`, `<idx>mi`, `<idx>tm`,
`<idx>tn`, `<idx>an`) paired with watchers that copied the state
value back into `config.motors[index]`, plus an `attached()` hook
running the same sync on mount.

The controller streams those vars continuously over the websocket.
Whenever a user typed into a field, the next state tick reverted it
to the controller's pre-edit value, so the form felt racy and edits
disappeared before Save. The same path also nuked unsaved edits when
navigating to another settings page and back.

The watcher logic was added in 749d63e to handle the case where
toggling rotary mode (PUT /api/rotary) rewrites motor 1+2 in
config.json on the server. Move that fix to the right place: refetch
config after the rotary PUT in app.js. The form now edits config
directly, Save PUTs it, and incoming controller state never overwrites
the user's in-progress edits.

Also drop the unused `syncStateToConfig` method.
2026-05-01 16:08:31 +02:00
muehe
5376d23f8b control: shrink Home All button in DRO header
Smaller than the per-axis home buttons so it reads as a header-level
shortcut rather than competing visually with each row's action.
2026-05-01 15:57:31 +02:00
muehe
ecf3191fcc control: restore Home All button in DRO header
Legacy Onefinity exposed a master Home All in the DRO header. Our
V09 redesign only kept it inside the no-W fallback row, so machines
with the W axis enabled (which is most users now) had no master
home button. Add it back to the DRO header's Actions column.

home_all() fires /api/home for X/Y/Z/A and /api/aux/home for W in
parallel \u2014 the AVR and the auxcnc ESP run independent homing cycles
so the user sees one click homing everything.
2026-05-01 15:54:52 +02:00
muehe
3baa67360c control: align XY/Z origin tile color with other axis tiles
Drop the .ghost class from the XY Origin and Z Origin tiles so they
use the same flat slate color as the X-/X+/Z+ neighbors. The lighter
ghost tone made them look like a different category of action when
they are just origin shortcuts.
2026-05-01 14:50:22 +02:00
muehe
b7bd7a1c9c AuxAxis: faster sane defaults for homing and stepping
Old defaults (4000 fast / 400 slow / 200 backoff / 16000 accel /
200 start) were never aggressive in practice because the user-
saved config drifted to even lower values (600/80/110). Re-tune
the DEFAULTS dict to values that are sensible at 25 steps/mm:

  home_fast_sps     2500   ~100 mm/s seek
  home_slow_sps      250    ~10 mm/s re-engage
  home_backoff_steps 400     ~16 mm clear hysteresis
  step_max_sps      4000   ~160 mm/s normal-move cap
  step_accel_sps2  12000

These only affect machines without an existing aux.json. The Pi
at 10.1.10.55 was patched manually.
2026-05-01 14:47:11 +02:00
muehe
6fe2e79bff settings: unify W axis save into master Save button
- Drop the in-form 'Save W Axis Settings' button. The Svelte
  WAxisSettings component now listens for a global onefin:save-all
  event and PUTs aux/config/save when fired.
- Vue root's save() dispatches that event after saving config.json,
  so a single click of the master Save button persists both the
  controller config and aux.json atomically.
- Editing any W axis field triggers onefin:dirty, which the Vue
  root catches to set modified=true so the master Save lights up
  with the unsaved-changes indicator.
2026-05-01 14:28:15 +02:00
muehe
19e6cc6c93 ui: unify jog button sizing across tablet and kiosk
Big jog labels were only set inside the kiosk-mode override block,
which made the 1920x1080 tablet preview look small and inconsistent
with the Pi kiosk. Move the larger sizes to the base .jbtn rule
(font 1.6rem, ico 2.4rem, lbl 1.5rem) and drop the kiosk-mode
.jbtn override so both viewports use the same single source of
truth.
2026-05-01 14:24:33 +02:00
muehe
50839718e2 kiosk: chromium 72 mime + flex-gap fixes
Pi's onboard chromium is 72 (Jan 2019). Two issues:

1. Python 3.5's mimetypes doesn't know woff/woff2/ttf, so Tornado
   serves them as application/octet-stream which chromium 72 refuses
   to use as web fonts -> all FA6 icons render as empty boxes. Add
   scripts/deploy/patch_font_mime.py that monkey-patches bbctrl
   Web.py's StaticFileHandler with correct content types. Run
   automatically by deploy-hardware.sh (idempotent).

2. flex-gap landed in chromium 84. Add '> * + *' margin fallbacks
   for the flex containers that show up on Program tab (action-bar,
   action-btn, file-bar, file-btn) and tighten the kiosk-mode
   settings rail so all 14 items fit in 768px height.
2026-05-01 11:16:28 +02:00
68a92bb297 AuxAxis: pre-load home_zero via HOMECFG, drop post-home WPOS
home() previously matched the wrong [home] line (firmware-side bug,
fixed in auxcnc) and even when it would have matched, it tried to
shift the step counter by writing 'WPOS <n>' after homing. The ESP's
WPOS handler clears HOMED, so a bbctrl restart would forget the home
state.

Push the desired step counter via HOMECFG zero= (firmware writes it
into the counter at the end of a successful HOME, leaving HOMED set).
home() now only reads the terminal [home] line; no post-home counter
fixup.
2026-05-01 11:08:51 +02:00
muehe
41d720c1d0 kiosk: pi-friendly compact mode + chromium 72 fallbacks
- Detect kiosk mode (localhost / ?kiosk=1) and add html.kiosk-mode
- Suppress 3D path-viewer in kiosk mode (Pi 3B too slow)
- Compact 1366x768 layout: 56px header, smaller jog grid, 4-col macros
  2-col status, 540px jog column
- Flex-gap fallbacks for Chromium 72 (header tabs, sys-btn, state-badge,
  ktab, sp-row, etc.) using "> * + *" margin-* rules
- Path-viewer: opaque WebGL canvas, ResizeObserver-gated render loop,
  no first-frame size flash
- Path-viewer renderer cleared properly on component teardown
- W axis row: W- | W+ | Probe XYZ | Probe Z (was W-|HomeW|W+|Probe)
- Running panel only for actual program execution (not jogging)
- Settings sectioned (Display+Units / Probing / G-code+Motion)
- Routed component now keep-alive across tab swaps
- FA4 -> FA6 webfonts
2026-05-01 11:05:39 +02:00
54 changed files with 3332 additions and 608 deletions

View File

@@ -68,7 +68,11 @@ update: pkg
build/templates.pug: $(TEMPLS)
mkdir -p build
cat $(TEMPLS) >$@
# Use awk to ensure each template is followed by a newline so the
# next file's first line never gets glued onto the previous file's
# last line (some templates ship without a trailing newline, which
# would produce subtle Pug parse failures).
awk 'FNR==1 && NR>1 {print ""} {print} END{print ""}' $(TEMPLS) >$@
node_modules: package.json
npm install && touch node_modules

View File

@@ -8,7 +8,7 @@
#
# Defaults:
# HOST=onefinity.local
# USER=bbmc
# REMOTE_USER=bbmc
# PASSWORD=onefinity (used for sudo on the Pi)
#
# Override:
@@ -20,46 +20,65 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$SCRIPT_DIR"
HOST="${HOST:-onefinity.local}"
# REMOTE_USER (not USER, which the shell pre-populates with the local
# logged-in account).
REMOTE_USER="${REMOTE_USER:-bbmc}"
PASSWORD="${PASSWORD:-onefinity}"
echo "🛠 Building UI bundle..."
echo "Building UI bundle (HTML + resources)..."
make build/http/index.html >/dev/null
# Copy src/resources/* into build/http/. The Makefile's "all" target
# also does this, but pulls in cross-compiled subprojects (avr/boot/
# pwr/jig) we don't have toolchains for on macOS. This rsync mirrors
# only the resource tree.
rsync -a src/resources/ build/http/
# Discover the on-Pi http path; the bbctrl egg version may change.
echo "🔍 Locating bbctrl http/ directory on $HOST..."
echo "Locating bbctrl http/ directory on $HOST..."
REMOTE_HTTP_DIR="$(ssh -o ConnectTimeout=5 "${REMOTE_USER}@${HOST}" \
"ls -d /usr/local/lib/python*/dist-packages/bbctrl-*-py*.egg/bbctrl/http 2>/dev/null | head -1")"
if [[ -z "$REMOTE_HTTP_DIR" ]]; then
echo "❌ Could not find bbctrl http/ directory on $HOST"
echo "ERROR: could not find bbctrl http/ directory on $HOST"
exit 1
fi
echo " $REMOTE_HTTP_DIR"
echo " $REMOTE_HTTP_DIR"
echo "🚚 Rsyncing build/http/ $HOST:$REMOTE_HTTP_DIR/"
# Stage to a tmp dir owned by $REMOTE_USER, then sudo-mv into place.
# This avoids needing root over rsync.
echo "Rsyncing build/http/ -> $HOST:$REMOTE_HTTP_DIR/"
# Stage to a tmp dir owned by $REMOTE_USER, then sudo-rsync into
# place. This avoids needing root over rsync. We do NOT use --delete
# anywhere -- the Pi's egg ships extra runtime files (config-template
# .json, default machine JSON, buildbotics.nc, etc.) that come with
# the bbctrl package and are not in this repo's src/resources. If
# they were deleted the controller's API would 500 because Python
# imports fail.
REMOTE_TMP="/tmp/onefin_ui_$$"
ssh -o ConnectTimeout=5 "${REMOTE_USER}@${HOST}" "mkdir -p '${REMOTE_TMP}'"
rsync -avz --delete \
rsync -avz \
--exclude='hostinfo.txt' \
-e "ssh -o ConnectTimeout=5" \
build/http/ "${REMOTE_USER}@${HOST}:${REMOTE_TMP}/"
echo "📦 Installing into ${REMOTE_HTTP_DIR}/ (sudo)..."
echo "Installing into ${REMOTE_HTTP_DIR}/ (sudo)..."
ssh -o ConnectTimeout=5 "${REMOTE_USER}@${HOST}" \
"echo '${PASSWORD}' | sudo -S bash -c '
rsync -a --delete --exclude=hostinfo.txt \"${REMOTE_TMP}/\" \"${REMOTE_HTTP_DIR}/\" \
rsync -a --exclude=hostinfo.txt \"${REMOTE_TMP}/\" \"${REMOTE_HTTP_DIR}/\" \
&& rm -rf \"${REMOTE_TMP}\"
'" 2>&1 | tail -3
echo "🔁 Restarting bbctrl service..."
# Patch bbctrl Web.py so font files get the correct MIME type. The
# Pi ships Python 3.5, whose `mimetypes` module doesn't know about
# woff/woff2/ttf, so Tornado serves them as application/octet-stream
# which Chromium 72 (the Pi's onboard browser) refuses to use as a
# web font, leading to all FontAwesome icons rendering as empty
# boxes in the kiosk UI. The patch is idempotent.
echo "Patching bbctrl font MIME types (idempotent)..."
scp -o ConnectTimeout=5 "$SCRIPT_DIR/scripts/deploy/patch_font_mime.py" \
"${REMOTE_USER}@${HOST}:/tmp/patch_font_mime.py" >/dev/null
ssh -o ConnectTimeout=5 "${REMOTE_USER}@${HOST}" \
"echo '${PASSWORD}' | sudo -S python3 /tmp/patch_font_mime.py" 2>&1 | tail -3
echo "Restarting bbctrl service..."
ssh -o ConnectTimeout=5 "${REMOTE_USER}@${HOST}" \
"echo '${PASSWORD}' | sudo -S systemctl restart bbctrl" 2>&1 | tail -3
echo ""
echo "Deployed to http://${HOST}/"
echo " Logs: ssh ${REMOTE_USER}@${HOST} 'journalctl -u bbctrl -f'"
echo " Open: open -a 'Google Chrome' http://${HOST}/"
echo "Deployed to http://${HOST}/"
echo " Logs: ssh ${REMOTE_USER}@${HOST} 'journalctl -u bbctrl -f'"
echo " Open: open -a 'Google Chrome' http://${HOST}/"

View File

@@ -0,0 +1,102 @@
#!/usr/bin/env python3
"""
Patch bbctrl Web.py so font files get the correct MIME type.
Background
----------
The Onefinity controller (Pi 3B running Raspbian stretch) ships Python
3.5, whose ``mimetypes`` module does not recognize ``.woff``, ``.woff2``
or ``.ttf``. Tornado's ``StaticFileHandler`` therefore falls back to
``application/octet-stream`` for those, and Chromium 72 (the Pi's
onboard kiosk browser) refuses to use such payloads as web fonts. The
result is that every FontAwesome icon renders as an empty box on the
kiosk display.
This patch monkey-patches ``StaticFileHandler.get_content_type`` to
emit the right MIME types. It is idempotent: running it twice is a
no-op. Run with ``sudo`` so it can rewrite the egg's Web.py.
Used by:
scripts/deploy/hardware.sh
"""
from __future__ import print_function
import os
import sys
def find_web_py():
"""Return the absolute path to the bbctrl Web.py shipped in the egg."""
base = "/usr/local/lib"
for entry in os.listdir(base):
if not entry.startswith("python"):
continue
candidate_dir = os.path.join(base, entry, "dist-packages")
if not os.path.isdir(candidate_dir):
continue
for sub in os.listdir(candidate_dir):
if sub.startswith("bbctrl-") and sub.endswith(".egg"):
p = os.path.join(candidate_dir, sub, "bbctrl", "Web.py")
if os.path.isfile(p):
return p
return None
OLD_BLOCK = (
"class StaticFileHandler(tornado.web.StaticFileHandler):\n"
" def set_extra_headers(self, path):\n"
" self.set_header('Cache-Control',\n"
" 'no-store, no-cache, must-revalidate, max-age=0')"
)
NEW_BLOCK = (
"class StaticFileHandler(tornado.web.StaticFileHandler):\n"
" # FONT_MIME_FIX: Python 3.5's mimetypes module does not know\n"
" # woff/woff2/ttf, so Tornado serves them as application/octet-\n"
" # stream which Chromium 72 (the Pi's onboard kiosk browser)\n"
" # refuses to use as web fonts. Set explicit types so the FA6\n"
" # icon set actually renders on the kiosk display.\n"
" def get_content_type(self):\n"
" path = self.absolute_path or ''\n"
" if path.endswith('.woff2'): return 'font/woff2'\n"
" if path.endswith('.woff'): return 'font/woff'\n"
" if path.endswith('.ttf'): return 'font/ttf'\n"
" if path.endswith('.otf'): return 'font/otf'\n"
" if path.endswith('.eot'): return 'application/vnd.ms-fontobject'\n"
" return super().get_content_type()\n"
"\n"
" def set_extra_headers(self, path):\n"
" self.set_header('Cache-Control',\n"
" 'no-store, no-cache, must-revalidate, max-age=0')"
)
def main():
target = find_web_py()
if target is None:
print("ERROR: could not locate bbctrl Web.py under /usr/local/lib",
file=sys.stderr)
return 1
with open(target) as f:
src = f.read()
if "FONT_MIME_FIX" in src:
print("font mime: already patched ({})".format(target))
return 0
if OLD_BLOCK not in src:
print("font mime: expected block not found in {} -- skipping".format(target),
file=sys.stderr)
# Don't fail the deploy; just log and continue.
return 0
new_src = src.replace(OLD_BLOCK, NEW_BLOCK, 1)
with open(target, "w") as f:
f.write(new_src)
print("font mime: patched {}".format(target))
return 0
if __name__ == "__main__":
sys.exit(main())

View File

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

View File

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

View File

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

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

@@ -251,6 +251,19 @@ module.exports = new Vue({
},
computed: {
// True when the UI is in kiosk mode — i.e. running on the
// controller's own onboard browser (Pi 3B at 1366x768) or
// explicitly forced via ?kiosk=1. Source-of-truth is the
// `kiosk-mode` class added to <html> by the inline script
// in index.pug, which already honors hostname + URL param +
// localStorage. The Pi's VideoCore IV is too slow for the
// three.js toolpath preview, so we suppress that panel in
// kiosk mode and let the gcode listing take the full width.
is_kiosk: function() {
return typeof document !== "undefined"
&& document.documentElement.classList.contains("kiosk-mode");
},
popupMessages: function() {
const msgs = [];
@@ -356,6 +369,15 @@ module.exports = new Vue({
ready: function() {
window.onhashchange = () => this.parse_hash();
// Embedded Svelte subviews (A axis settings, etc.) signal
// unsaved changes via this event. The master Save button
// highlights when modified is true.
window.addEventListener("onefin:dirty", () => {
this.modified = true;
});
// Resolve the initial route before the websocket connects so
// the shell shows the right view even on a slow / offline
// controller. update() will call parse_hash() again once the
@@ -365,9 +387,11 @@ module.exports = new Vue({
// motion.*, etc.) and would throw on first paint with the
// empty placeholder config.
const settingsFamily = [
"settings", "admin-general", "admin-network",
"settings", "probing", "gcode",
"admin-general", "admin-network",
"motor", "tool", "io", "macros",
"help", "cheat-sheet",
"a-axis",
];
const initialHead = (location.hash || "").replace(/^#/, "").split(":")[0];
if (settingsFamily.indexOf(initialHead) === -1) {
@@ -464,6 +488,12 @@ module.exports = new Vue({
toggle_rotary: async function(isActive) {
try {
await api.put("rotary", {status: isActive});
// The /api/rotary endpoint rewrites motors[1]/[2]
// in config.json on the server. Refetch so the UI
// reflects the new motor config (otherwise the
// motor settings page keeps showing pre-toggle
// values until the next page reload).
await this.update();
} catch (error) {
console.error(error);
alert("Error occured");
@@ -593,9 +623,11 @@ module.exports = new Vue({
// Settings tab while keeping their existing top-level
// hash. This preserves all existing deep links.
const settingsViews = [
"settings", "admin-general", "admin-network",
"settings", "probing", "gcode",
"admin-general", "admin-network",
"motor", "tool", "io", "macros",
"help", "cheat-sheet",
"a-axis",
];
if (head == "control") {
@@ -654,9 +686,16 @@ module.exports = new Vue({
this.config["selected-tool-settings"][selected_tool] = settings;
this.display_units = this.config.settings["units"];
try {
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;
} catch (error) {
console.error("Save failed:", error);

View File

@@ -56,7 +56,12 @@ module.exports = {
const abs = this.state[`${axis}p`] || 0;
const off = this.state[`offset_${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 homingMode = motor["homing-mode"];
const homed = this.state[`${motor_id}homed`];
@@ -198,6 +203,17 @@ module.exports = {
}
}
// 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;
},
@@ -226,14 +242,25 @@ module.exports = {
}
}
}
// 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 false;
},
_compute_aux_axis: function() {
// Virtual W axis driven by the auxcnc ESP32. 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).
// 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;
@@ -243,12 +270,12 @@ module.exports = {
let state = present ? "UNHOMED" : "OFFLINE";
let icon = present ? "question-circle" : "plug";
let title = present
? "Click the home button to home W axis."
? "Click the home button to home the auxiliary axis."
: "Aux controller not connected on /dev/ttyUSB0.";
if (homed) {
state = "HOMED";
icon = "check-circle";
title = "W axis successfully homed.";
title = "Auxiliary axis successfully homed.";
} else if (!present) {
klass += " error";
}
@@ -269,7 +296,7 @@ module.exports = {
title: title,
ticon: "check-circle",
tstate: "OK",
toolmsg: "W axis is not constrained by tool path bounds.",
toolmsg: "Auxiliary axis is not constrained by tool path bounds.",
tklass: `${homed ? "homed" : "unhomed"} axis-w`,
isAux: true,
};

View File

@@ -251,13 +251,74 @@ module.exports = {
aux_home: function () {
api.put("aux/home").catch(function (err) {
console.error("W home failed:", err);
console.error("Aux home failed:", err);
});
},
// Home every enabled axis (legacy Onefinity "Home All"). Sequence:
// 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 () {
this.ask_home = false;
try {
await api.put("home");
} catch (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("W jog failed:", err);
console.error("Aux jog failed:", err);
});
},

View File

@@ -49,14 +49,17 @@ module.exports = {
methods: {
get_io_state_class: function(active, state) {
if (typeof active == "undefined" || typeof state == "undefined") {
return "fa-exclamation-triangle warn";
return "fa-triangle-exclamation warn";
}
// Tristated: render as the regular (outline) circle to
// distinguish from active/inactive solid circles. Adding
// `far` switches to the FA6 regular family.
if (state == 2) {
return "fa-circle-o";
return "far fa-circle";
}
const icon = state ? "fa-plus-circle" : "fa-minus-circle";
const icon = state ? "fa-circle-plus" : "fa-circle-minus";
return `${icon} ${active ? "active" : "inactive"}`;
},

View File

@@ -87,100 +87,16 @@ module.exports = {
return this.stallRPM * this.stepsPerRev * ustep / 60;
},
current_axis: function() {
return this.state[this.index + 'an'];
},
current_max_velocity: function() {
return this.state[this.index + 'vm'];
},
current_max_soft_limit: function() {
return this.state[this.index + 'tm'];
},
current_min_soft_limit: function() {
return this.state[this.index + 'tn'];
},
current_max_accel: function() {
return this.state[this.index + 'am'];
},
current_max_jerk: function() {
return this.state[this.index + 'jm'];
},
current_step_angle: function() {
return this.state[this.index + 'sa'];
},
current_travel_per_rev: function() {
return this.state[this.index + 'tr'];
},
current_microsteps: function() {
return this.state[this.index + 'mi'];
}
},
attached: function() {
// Sync all state values with motor config when component is ready
// This ensures UI shows correct values when component is first loaded
console.log("Syncing state to motor config for motor index ",this.index);
this.syncStateToConfig();
},
watch: {
current_axis(new_value) {
const motor_axes = ["X", "Y", "Z", "A", "B", "C"]
if(motor_axes[new_value] != this.motor['axis']){
this.motor['axis'] = motor_axes[new_value];
}
},
current_max_velocity(new_value) {
if(new_value != this.motor['max-velocity']) {
this.motor['max-velocity'] = new_value;
}
},
current_max_soft_limit(new_value) {
if(new_value != this.motor['max-soft-limit']) {
this.motor['max-soft-limit'] = new_value;
}
},
current_min_soft_limit(new_value) {
if(new_value != this.motor['min-soft-limit']) {
this.motor['min-soft-limit'] = new_value;
}
},
current_max_accel(new_value) {
if(new_value != this.motor['max-accel']) {
this.motor['max-accel'] = new_value;
}
},
current_max_jerk(new_value) {
if(new_value != this.motor['max-jerk']) {
this.motor['max-jerk'] = new_value;
}
},
current_step_angle(new_value) {
if(new_value != this.motor['step-angle']) {
this.motor['step-angle'] = new_value;
}
},
current_travel_per_rev(new_value) {
if(new_value != this.motor['travel-per-rev']) {
this.motor['travel-per-rev'] = new_value;
}
},
current_microsteps(new_value) {
if(new_value != this.motor['microsteps']) {
this.motor['microsteps'] = new_value;
}
}
// NOTE: do not add `current_xxx` computed props that mirror
// controller state vars (`<idx>vm`, `<idx>am`, …) and pair
// them with watchers that copy state -> motor config. The
// controller streams those vars continuously over the WS;
// any watcher that writes them back into
// `config.motors[index]` will clobber whatever the user is
// typing into the form between websocket ticks. The form
// edits config directly; Save (app.js) PUTs it to the
// server. The server-side rotary toggle is handled by
// refetching config after the PUT, not by watching state.
},
events: {
@@ -210,45 +126,6 @@ module.exports = {
}
return templ.hmodes.indexOf(this.motor["homing-mode"]) != -1;
},
syncStateToConfig: function() {
// Force sync all state values to motor config
// This ensures the UI reflects the current state even if changes happened while component was unmounted
if(this.state == undefined) {
console.log("State is undefined");
return;
}
if (this.state[this.index + 'an'] != this.motor['axis']) {
const motor_axes = ["X", "Y", "Z", "A", "B", "C"];
this.$set('motor["axis"]', motor_axes[this.state[this.index + 'an']]);
}
if (this.state[this.index + 'vm'] != this.motor['max-velocity']) {
this.$set('motor["max-velocity"]', this.state[this.index + 'vm']);
}
if (this.state[this.index + 'tm'] != this.motor['max-soft-limit']) {
this.$set('motor["max-soft-limit"]', this.state[this.index + 'tm']);
}
if (this.state[this.index + 'tn'] != this.motor['min-soft-limit']) {
this.$set('motor["min-soft-limit"]', this.state[this.index + 'tn']);
}
if (this.state[this.index + 'am'] != this.motor['max-accel']) {
this.$set('motor["max-accel"]', this.state[this.index + 'am']);
}
if (this.state[this.index + 'jm'] != this.motor['max-jerk']) {
this.$set('motor["max-jerk"]', this.state[this.index + 'jm']);
}
if (this.state[this.index + 'sa'] != this.motor['step-angle']) {
this.$set('motor["step-angle"]', this.state[this.index + 'sa']);
}
if (this.state[this.index + 'tr'] != this.motor['travel-per-rev']) {
this.$set('motor["travel-per-rev"]', this.state[this.index + 'tr']);
}
if (this.state[this.index + 'mi'] != this.motor['microsteps']) {
this.$set('motor["microsteps"]', this.state[this.index + 'mi']);
}
}
}
};

View File

@@ -101,6 +101,13 @@ module.exports = {
Vue.nextTick(this.update);
},
beforeDestroy: function() {
if (this._sizeWatcher) {
this._sizeWatcher.disconnect();
this._sizeWatcher = null;
}
},
methods: {
update: async function() {
if (!this.webglAvailable) {
@@ -201,6 +208,12 @@ module.exports = {
}
const dims = this.get_dims();
// Skip layouts where the target has no measurable size.
// The render loop guard below will not draw frames until
// a real size has been observed at least once.
if (!(dims.width > 0 && dims.height > 0)) {
return;
}
this.camera.aspect = dims.width / dims.height;
this.camera.updateProjectionMatrix();
@@ -274,12 +287,23 @@ module.exports = {
}
try {
// Renderer
this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
// Renderer. Use an opaque canvas with a clear color
// that matches the page-side gradient so the moment
// the canvas is appended (and before the first 3D
// frame is drawn) the user does not see a flash from
// the page background through transparency.
this.renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: false,
});
this.renderer.setPixelRatio(window.devicePixelRatio);
this.renderer.setClearColor(0, 0);
this.renderer.setClearColor(0x222222, 1);
// Same color on the DOM element itself so the very
// first paint (before the WebGL context has cleared)
// is dark too.
this.renderer.domElement.style.background = "#222222";
this.renderer.domElement.style.display = "block";
this.target.appendChild(this.renderer.domElement);
} catch (e) {
console.log("WebGL not supported: ", e);
return;
@@ -333,8 +357,46 @@ module.exports = {
// Events
window.addEventListener("resize", this.update_view, false);
// Start it
this.render();
// Start the render loop only after the target has a real,
// stable size. Without this, the first frame paints into
// a 0×0 / collapsed-flex canvas and a second frame paints
// again at the right size — visible as a flash on the
// very first mount of the Program tab.
const startRendering = () => {
if (this._rendering) return;
this._rendering = true;
this.update_view();
this.render();
};
const dims = this.get_dims();
if (dims.width > 0 && dims.height > 0) {
startRendering();
} else if (typeof ResizeObserver !== "undefined") {
this._sizeWatcher = new ResizeObserver(entries => {
for (const entry of entries) {
const r = entry.contentRect;
if (r.width > 0 && r.height > 0) {
this._sizeWatcher.disconnect();
this._sizeWatcher = null;
startRendering();
return;
}
}
});
this._sizeWatcher.observe(this.target);
} else {
// Old browser fallback: poll for a non-zero size.
const tick = () => {
const d = this.get_dims();
if (d.width > 0 && d.height > 0) {
startRendering();
} else {
requestAnimationFrame(tick);
}
};
requestAnimationFrame(tick);
}
},
create_surface_material: function() {
@@ -646,6 +708,14 @@ module.exports = {
return;
}
// Don't paint frames while the target has no size; this
// prevents an initial single-frame clear from painting
// before the layout has settled (visible as a dark flash).
const dims = this.get_dims();
if (!(dims.width > 0 && dims.height > 0)) {
return;
}
if (this.controls.update() || this.dirty) {
this.dirty = false;
this.renderer.render(this.scene, this.camera);

View File

@@ -77,6 +77,32 @@ module.exports = {
return this.state.cycle == "idle";
},
// True only while a loaded G-code program is actually being
// executed (running, paused/holding, or stopping). Excludes
// jogging, homing, probing, MDI commands and other one-off
// motion that also leave state.xx == "RUNNING" but must not
// swap the jog grid out for the "Now Running" panel.
//
// Distinguishing signal is state.cycle:
// - "idle" : nothing happening
// - "jogging" : user-initiated jog
// - "homing" : home cycle
// - "probing" : probe cycle
// - "mdi" : single MDI command
// - "running" : an actual loaded program is being run
// Only "running" (combined with a selected file) is what we want.
is_program_executing: function () {
if (!this.state) return false;
const xx = this.state.xx;
const cycle = this.state.cycle;
const isExecState = xx == "RUNNING" || xx == "HOLDING" || xx == "STOPPING";
if (!isExecState) return false;
// The cycle string narrows it to a real program run; anything
// else (jogging / homing / probing / mdi) is a one-off.
if (cycle && cycle != "running" && cycle != "idle") return false;
return !!this.state.selected;
},
is_paused: function () {
return this.is_holding && (this.pause_reason == "User pause" || this.pause_reason == "Program pause");
},
@@ -538,23 +564,43 @@ module.exports = {
override_feed: function () { api.put(`override/feed/${this.feed_override}`); },
override_speed: function () { api.put(`override/speed/${this.speed_override}`); },
run_macro: function (id) {
run_macro: async function (id) {
if (this.state.macros[id].file_name == "default") {
this.showNoGcodeMessage = true;
} else {
if (this.state.macros[id].file_name != this.state.selected) {
this.state.selected = this.state.macros[id].file_name;
}
try {
this.load();
if (this.state.macros[id].alert == true) {
this.macrosLoading = true;
} else {
setImmediate(() => this.start_pause());
return;
}
const file_name = this.state.macros[id].file_name;
try {
// Selecting a file on the server is a side effect of
// GET /api/file/<name>. The macro button used to mutate
// state.selected client-side and immediately call start, which
// raced the file fetch: if the server hadn't seen the new
// selection yet, mach.start() ran whichever file was selected
// last. Do it explicitly and await so start always sees the
// right file.
if (file_name != this.state.selected) {
this.state.selected = file_name;
// GET /api/file/<name> returns gcode text (not JSON), so use
// fetch directly. The server's FileHandler.get sets
// state.selected as a side effect; we await the response
// before starting so mach.start() reads the right file.
const resp = await fetch(
`/api/file/${encodeURIComponent(file_name)}`,
{ cache: "no-cache" }
);
if (!resp.ok) {
throw new Error(`file fetch failed: ${resp.status}`);
}
} catch (error) {
console.warn("Error running program: ", error);
await resp.text();
}
this.load();
if (this.state.macros[id].alert == true) {
this.macrosLoading = true;
} else {
await this.start_pause();
}
} catch (error) {
console.warn("Error running macro: ", error);
}
},
},

View File

@@ -26,6 +26,8 @@ module.exports = {
},
computed: {
is_kiosk: function () { return !!this.$root.is_kiosk; },
display_units: {
cache: false,
get: function () { return this.$root.display_units; },

View File

@@ -24,6 +24,7 @@ module.exports = {
"io-view": require("./io-view"),
"macros-view": require("./macros"),
"help-view": require("./help-view"),
"a-axis-view": require("./a-axis-view"),
"cheat-sheet-view": {
template: "#cheat-sheet-view-template",
data: function () {
@@ -36,8 +37,17 @@ module.exports = {
return {
sub: this.$root.sub_tab || "settings",
ridx: this.$root.index, // local copy of the motor index
// Whether the controller config has streamed in. The Svelte
// settings views crash on first paint with the placeholder
// config (settings.units / settings.easy-adapter / motion.*
// are all undefined). Gate the inner mount on this flag.
config_ready: false,
rail_items: [
{ sub: "settings", href: "#settings", icon: "fa-display", label: "Display & Units" },
{ sub: "probing", href: "#probing", icon: "fa-bullseye", label: "Probing" },
{ sub: "gcode", href: "#gcode", icon: "fa-code", label: "G-code & Motion" },
{ sub: "macros", href: "#macros", icon: "fa-keyboard", label: "Macros" },
{ sub: "cheat-sheet", href: "#cheat-sheet", icon: "fa-book", label: "G-code Cheat Sheet" },
{ sub: "admin-network", href: "#admin-network", icon: "fa-network-wired", label: "Network" },
{ sub: "admin-general", href: "#admin-general", icon: "fa-shield-halved", label: "General / Firmware" },
{ sub: "tool", href: "#tool", icon: "fa-bolt", label: "Spindle & Tool" },
@@ -47,13 +57,10 @@ module.exports = {
{ 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: 3, href: "#motor:3", icon: "fa-arrows-up-down-left-right", label: "Motor 3" },
// W axis is auxiliary (auxcnc ESP32). Its config lives inside
// the main Settings page; we route to #settings and scroll to
// the #w-axis anchor on click.
{ sub: "w-axis", href: "#settings", anchor: "w-axis", icon: "fa-arrows-up-down", label: "W Axis" },
// 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: " " },
{ sub: "macros", href: "#macros", icon: "fa-keyboard", label: "Macros" },
{ sub: "cheat-sheet", href: "#cheat-sheet", icon: "fa-book", label: "G-code Cheat Sheet" },
{ sub: "help", href: "#help", icon: "fa-circle-question", label: "Help" },
],
};
@@ -63,6 +70,12 @@ module.exports = {
this._onHash = () => this.refresh_from_hash();
window.addEventListener("hashchange", this._onHash);
this.refresh_from_hash();
this._configPoll = setInterval(() => {
const c = this.$root && this.$root.config;
const ready = !!(c && c.full_version && c.full_version !== "<loading>"
&& c.settings && typeof c.settings === "object");
if (ready !== this.config_ready) this.config_ready = ready;
}, 200);
},
attached: function () {
@@ -87,6 +100,7 @@ module.exports = {
if (this._onHash) {
window.removeEventListener("hashchange", this._onHash);
}
if (this._configPoll) clearInterval(this._configPoll);
},
methods: {
@@ -99,12 +113,6 @@ module.exports = {
is_active: function (item) {
if (!item || item.section) return false;
// The W Axis rail item is a soft-link into #settings; we mark
// it active only when the user is on Display & Units AND the
// last clicked rail item was W Axis.
if (item.sub === "w-axis") {
return this.sub === "settings" && this._w_axis_focus === true;
}
if (item.sub !== this.sub) return false;
if (item.sub === "motor") {
return "" + item.motor === "" + this.ridx;
@@ -113,19 +121,50 @@ module.exports = {
},
on_rail_click: function (item, ev) {
// Soft-link rail items use an anchor and a scrollIntoView call.
if (item && item.anchor) {
ev.preventDefault();
// Navigate to settings if not already there, then scroll.
if (!item) return;
// Always preventDefault on rail clicks. Letting the browser
// anchor-scroll to <div id="settings"> etc. inside .app-body
// can pull the .app-head out of view; we drive navigation
// ourselves through location.hash and our hashchange handler.
if (ev && ev.preventDefault) ev.preventDefault();
if (item.anchor) {
// Soft-link rail items use a #settings hash plus an in-page
// anchor scroll once the Svelte page has mounted. We scroll
// ONLY the .settings-content overflow container by setting
// its scrollTop directly — element.scrollIntoView() walks all
// ancestor scroll containers and can tug the .app-body / html
// layout, which under tablet mode pulls the fixed header out
// of view.
if (location.hash !== item.href) location.hash = item.href;
this._w_axis_focus = (item.sub === "w-axis");
// Defer the scroll so Vue mounts the inner Svelte page first.
this._a_axis_focus = (item.sub === "a-axis");
const reset = () => {
// Force any inadvertent ancestor scroll back to 0 before
// we move .settings-content explicitly.
window.scrollTo(0, 0);
const body = document.querySelector(".app-body");
if (body) body.scrollTop = 0;
document.documentElement.scrollTop = 0;
};
setTimeout(() => {
reset();
const el = document.getElementById(item.anchor);
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
}, 250);
const scroller = document.querySelector(".settings-content");
if (el && scroller) {
const elTop = el.getBoundingClientRect().top;
const scTop = scroller.getBoundingClientRect().top;
scroller.scrollTop = scroller.scrollTop + (elTop - scTop) - 12;
}
// Re-assert ancestor scroll = 0 in case the assignment above
// moved things.
requestAnimationFrame(reset);
}, 320);
} else {
this._w_axis_focus = false;
this._a_axis_focus = false;
if (location.hash !== item.href) location.hash = item.href;
// Reset .app-body scroll so each route starts at the top.
const body = document.querySelector(".app-body");
if (body) body.scrollTop = 0;
}
},

View File

@@ -1,14 +1,60 @@
// V09 wraps the legacy Svelte SettingsView and filters its big page
// down to a single rail section so each rail item shows only the
// relevant controls. The Svelte component is left untouched (it is
// shared with the legacy UI) — we just hide the `<h2>` and `<fieldset>`
// elements whose `data-sec` does not match the active section.
module.exports = {
template: "#settings-view-template",
attached: function() {
props: {
// "display" | "probing" | "gcode". Default is "display" which
// keeps the rail's "Display & Units" item working unchanged.
section: { default: "display" },
},
attached: function () {
this.svelteComponent = SvelteComponents.createComponent(
"SettingsView",
document.getElementById("settings")
);
// Defer one tick so Svelte has rendered the section markup.
setTimeout(() => this.apply_section_filter(), 0);
},
detached: function() {
this.svelteComponent.$destroy();
}
detached: function () {
if (this.svelteComponent) this.svelteComponent.$destroy();
},
watch: {
section: function () {
this.apply_section_filter();
},
},
methods: {
apply_section_filter: function () {
const root = document.getElementById("settings");
if (!root) return;
const want = this.section || "display";
// Hide every section block that does not match.
root.querySelectorAll("[data-sec]").forEach(el => {
el.style.display = el.dataset.sec === want ? "" : "none";
});
// Hide the global <h1>Settings</h1> on subsections so the
// page reads as a focused panel.
const h1 = root.querySelector(".settings-view > h1");
if (h1) {
if (want === "display") {
h1.textContent = "Display & Units";
} else if (want === "probing") {
h1.textContent = "Probing";
} else if (want === "gcode") {
h1.textContent = "G-code & Motion";
} else {
h1.textContent = "Settings";
}
}
},
},
};

View File

@@ -9,7 +9,7 @@ html(lang="en")
style: include ../static/css/pure-min.css
style: include ../static/css/font-awesome.min.css
style: include ../static/css/fa6.min.css
style: include ../static/css/Audiowide.css
style: include ../static/css/clusterize.css
style: include ../svelte-components/node_modules/svelte-material-ui/bare.css
@@ -18,6 +18,51 @@ html(lang="en")
style: include:stylus ../stylus/style.styl
body(v-cloak)
// Tablet (kiosk) mode — pins the .app-shell to 1920x1080 and
// scales it to fit the actual viewport so the UI always looks
// exactly like the 10.8" 1920x1080 portable monitor.
//
// Toggle: ?tablet=1 to enable
// ?tablet=0 to disable
// Sticky in localStorage; once set, no querystring is needed.
script.
(function () {
try {
var p = new URLSearchParams(location.search);
if (p.has("tablet")) {
var on = p.get("tablet") !== "0" && p.get("tablet") !== "false";
localStorage.setItem("ui-tablet-mode", on ? "1" : "0");
}
if (localStorage.getItem("ui-tablet-mode") === "1") {
document.documentElement.classList.add("tablet-mode");
}
function fit() {
if (!document.documentElement.classList.contains("tablet-mode")) return;
var s = Math.min(window.innerWidth / 1920, window.innerHeight / 1080);
document.documentElement.style.setProperty("--tablet-scale", s);
}
fit();
window.addEventListener("resize", fit);
// Kiosk mode: when the UI is loaded by the controller's
// own onboard browser (Chromium pointing at localhost on
// the Pi 3B at 1366x768), apply a tighter layout that
// packs the V09 UI into the smaller, slower display.
// Override with ?kiosk=0 to force the desktop layout.
if (p.has("kiosk")) {
var k = p.get("kiosk") !== "0" && p.get("kiosk") !== "false";
localStorage.setItem("ui-kiosk-mode", k ? "1" : "0");
}
var stored = localStorage.getItem("ui-kiosk-mode");
var auto = location.hostname === "localhost"
|| location.hostname === "127.0.0.1"
|| location.hostname === "::1";
if (stored === "1" || (stored !== "0" && auto)) {
document.documentElement.classList.add("kiosk-mode");
}
} catch (_e) {}
})();
#svelte-dialog-host
#overlay(v-if="status != 'connected'")
@@ -57,7 +102,7 @@ html(lang="en")
.pi-temp-warning(v-if="80 <= state.rpi_temp",
title="Raspberry Pi temperature too high.")
.fa.fa-thermometer-full
.fa.fa-temperature-full
span.state-badge(:class="state_class", :title="mach_state_full")
span.dot
@@ -75,7 +120,7 @@ html(lang="en")
.sp-val v{{config.full_version}}
a.sp-act(v-if="show_upgrade()", href="#admin-general")
| Upgrade to v{{latestVersion}}
.fa.fa-exclamation-circle.upgrade-attention
.fa.fa-circle-exclamation.upgrade-attention
.sp-row
.sp-icon: .fa.fa-network-wired
.sp-text
@@ -122,13 +167,22 @@ html(lang="en")
.fa.fa-save
| &nbsp;Save{{modified ? '*' : ''}}
// Routed view (no keep-alive: Vue 1 has issues re-evaluating
// dynamic :class / v-if bindings on cached components when the
// route changes within the same kept-alive tree)
// Routed view. We keep instances alive across tab swaps so:
// - The Program tab's WebGL <path-viewer> canvas does not
// get destroyed and recreated each time (which caused a
// dark flash as the GL context cleared the new canvas
// before its first frame).
// - The Program tab's clusterize.js gcode list does not
// re-virtualize from scratch on every visit.
// - The Settings shell's child Svelte components stay
// mounted, preserving any in-flight form state.
// The settings-shell handles its own inner v-if cascade so
// the Vue 1 reactivity quirk that motivated removing
// keep-alive earlier no longer applies here.
.app-body
component(:is="currentView + '-view'", :index="index",
:config="config", :template="template", :state="state",
:sub-tab="sub_tab")
:sub-tab="sub_tab", keep-alive)
message.error-message(:show.sync="errorShow")
div(slot="header")

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

@@ -52,7 +52,7 @@ script#console-view-template(type="text/x-template")
// ----- Messages -----
.messages-pane(v-show="sub === 'messages'")
.msg-empty(v-if="!$root.messages_log.length")
.fa.fa-check-circle
.fa.fa-circle-check
| &nbsp;No messages.
.msg(v-for="m in $root.messages_log",
:class="m.level === 'warning' ? 'warn' : 'info'", track-by="$index")

View File

@@ -35,8 +35,9 @@ script#control-view-template(type="text/x-template")
button.pure-button.button-error(@click="GCodeNotFound=false") OK
message(:show.sync="show_probe_dialog")
h3(slot="header") Probe Rotary
h3(slot="header") Choose probe type
div(slot="body")
p Pick which probe routine to run.
button.pure-button(:class="state['pw'] ? '' : 'load-on'", @click="showProbeDialog('xyz')") Probe XYZ
button.pure-button(:class="state['pw'] ? '' : 'load-on'", @click="showProbeDialog('z')") Probe Z
div(slot="footer")
@@ -46,7 +47,9 @@ script#control-view-template(type="text/x-template")
.control-grid
// ===== JOG =====
.jog-card
// Hidden only while a G-code program is running / paused /
// stopping. Jogging / homing / MDI moves do not hide it.
.jog-card(v-if="!is_program_executing")
.jog-head
.jog-title
| Jog
@@ -73,11 +76,11 @@ script#control-view-template(type="text/x-template")
// Row 2
button.jbtn(@click="jog_fn(-1, 0, 0, 0)") X
button.jbtn.ghost(@click="showMoveToZeroDialog('xy')")
button.jbtn(@click="showMoveToZeroDialog('xy')")
span.lbl XY
span Origin
button.jbtn(@click="jog_fn(1, 0, 0, 0)") X+
button.jbtn.ghost(@click="showMoveToZeroDialog('z')")
button.jbtn(@click="showMoveToZeroDialog('z')")
span.lbl Z
span Origin
@@ -89,21 +92,29 @@ script#control-view-template(type="text/x-template")
.fa.fa-arrow-down.ico(style="transform: rotate(-45deg)")
button.jbtn(@click="jog_fn(0, 0, -1, 0)") Z
// Row 4 — W axis (auxcnc) when enabled
template(v-if="w.enabled")
button.jbtn(@click="aux_jog_incr(-1)", :disabled="!w.enabled")
// Row 4 — A axis (the auxcnc-driven external axis) when enabled.
// 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 W
button.jbtn.ghost(@click="aux_home()", :disabled="!w.enabled")
span.lbl Home
span W
button.jbtn(@click="aux_jog_incr(+1)", :disabled="!w.enabled")
span.lbl A
button.jbtn(@click="aux_jog_incr(+1)",
:disabled="!(w.enabled || a.enabled)")
.fa.fa-arrow-up.ico
span.lbl W+
button.jbtn(@click="show_probe_dialog=true",
span.lbl A+
button.jbtn(@click="showProbeDialog('xyz')",
:class="{'load-on': !state['pw']}")
.fa.fa-bullseye.ico
span.lbl Probe
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.)
@@ -129,16 +140,72 @@ script#control-view-template(type="text/x-template")
.fa.fa-bullseye.ico
span.lbl Probe XYZ
button.jbtn.ghost(@click="zero()", :disabled="!can_set_axis")
.fa.fa-map-marker.ico
.fa.fa-location-dot.ico
span.lbl Zero all
button.jbtn(@click="showProbeDialog('z')",
:class="{'load-on': !state['pw']}")
.fa.fa-bullseye.ico
span.lbl Probe Z
button.jbtn.ghost(@click="home()", :disabled="!is_idle")
button.jbtn.ghost(@click="home()")
.fa.fa-home.ico
span.lbl Home all
// ===== NOW RUNNING (replaces jog grid only while a G-code
// program is actually executing). Jogging is excluded.
.running-panel(v-if="is_program_executing")
.running-top
div
.running-file
.fa.fa-file-code
span(v-if="state.selected") &nbsp;{{state.selected}}
span(v-else) &nbsp;{{(mach_state || 'BUSY').toLowerCase()}}
.running-meta
span(v-if="is_running") {{ (mach_state || 'RUNNING').toLowerCase() }}
span(v-if="is_holding") paused
span(v-if="is_holding && pause_reason") · {{pause_reason}}
span(v-if="is_stopping") stopping
span(v-if="toolpath.lines") · line {{state.line || 0 | number}} / {{toolpath.lines | number}}
span(v-if="plan_time_remaining") · ETA {{plan_time_remaining | time}}
.running-pct
| {{((progress || 0) * 100) | fixed 0}}
span %
.running-progress
div(:style="'width:' + ((progress || 0) * 100) + '%'")
.running-stats
.running-stat
.lbl Velocity
.val
unit-value(:value="state.v", precision="2", unit="", iunit="", scale="0.0254")
| &nbsp;{{metric ? 'm/min' : 'IPM'}}
.running-stat
.lbl Feed
.val
unit-value(:value="state.feed", precision="0", unit="", iunit="")
| &nbsp;{{metric ? 'mm/min' : 'IPM'}}
.running-stat
.lbl Spindle
.val
| {{(state.speed || 0) | fixed 0}}
span(v-if="state.s != null && !isNaN(state.s)") ({{state.s | fixed 0}})
| &nbsp;RPM
.running-stat
.lbl Tool
.val T{{state.tool || 0}}
.running-row
// While RUNNING the primary action is Pause; while HOLDING / STOPPING it's Resume.
button.tx-btn.pause(v-if="is_running", @click="pause()")
.fa.fa-pause
span.lbl PAUSE
button.tx-btn.run(v-if="is_holding || is_stopping", @click="unpause()")
.fa.fa-play
span.lbl RESUME
button.tx-btn.stop(@click="stop()")
.fa.fa-stop
span.lbl STOP
button.tx-btn.step(v-if="is_holding", @click="step()")
.fa.fa-forward-step
span.lbl STEP
// ===== DRO + status strip =====
.right-col
@@ -148,64 +215,60 @@ script#control-view-template(type="text/x-template")
div Position
div Absolute
div Offset
div State
div Toolpath
div(style="text-align:right") Actions
.actions-cell
// Master Home All. Each row's Actions cell has a per-axis
// home button; this header-level button homes every
// enabled axis (legacy Onefinity behavior). Auto-includes
// the auxiliary A axis when it is enabled.
button.icon-btn(:disabled="!is_idle",
title="Home all axes.", @click="home_all()")
.fa.fa-house-chimney
// Per-axis rows — keep unit-value + bindings from axis-vars
each axis in 'xyzabc'
.dro-row(:class=`${axis}.klass + ' ' + ${axis}.tklass`,
v-if=`${axis}.enabled`,
:title=`${axis}.title`)
:title=`${axis}.toolmsg ? (${axis}.title + ' — ' + ${axis}.toolmsg) : ${axis}.title`)
.dro-axis(:class=`'axis-' + '${axis}'`)= axis.toUpperCase()
.dro-pos: unit-value(:value=`${axis}.pos`, precision=4)
.dro-sec: unit-value(:value=`${axis}.abs`, precision=3)
.dro-sec: unit-value(:value=`${axis}.off`, precision=3)
.dro-state
span.chip(:class=`${axis}.tklass.indexOf('error') !== -1 ? 'chip-red' : (${axis}.homed ? 'chip-green' : 'chip-amber')`)
.fa(:class=`'fa-' + ${axis}.icon`)
| &nbsp;{{#{axis}.state}}
.dro-toolpath
span.chip(:class=`${axis}.tklass.indexOf('error') !== -1 ? 'chip-red' : (${axis}.tklass.indexOf('warn') !== -1 ? 'chip-amber' : 'chip-green')`,
@click=`showToolpathMessageDialog('${axis}')`)
.fa(:class=`'fa-' + ${axis}.ticon`)
| &nbsp;{{#{axis}.tstate}}
.actions-cell
button.icon-btn(:disabled="!can_set_axis",
:title=`'Set ${axis.toUpperCase()} axis position.'`,
@click=`show_set_position('${axis}')`)
.fa.fa-cog
button.icon-btn(:disabled="!can_set_axis",
:title=`'Zero ${axis.toUpperCase()} axis offset.'`,
.fa.fa-gear
button.icon-btn(:class=`${axis}.tklass.indexOf('error') !== -1 ? 'state-red' : (${axis}.tklass.indexOf('warn') !== -1 ? 'state-amber' : 'state-green')`,
:disabled="!can_set_axis",
:title=`${axis}.toolmsg || ('Zero ${axis.toUpperCase()} axis offset.')`,
@click=`zero('${axis}')`)
.fa.fa-map-marker
button.icon-btn(:disabled="!is_idle",
:title=`'Home ${axis.toUpperCase()} axis.'`,
.fa.fa-location-dot
button.icon-btn(:class=`${axis}.klass.indexOf('error') !== -1 ? 'state-red' : (${axis}.homed ? 'state-green' : 'state-amber')`,
:disabled="!is_idle",
:title=`${axis}.title`,
@click=`home('${axis}')`)
.fa.fa-home
// W axis (auxiliary) — no offset, no set-zero / no set-position
.dro-row(:class="w.klass + ' ' + w.tklass", v-if="w.enabled",
// 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 —
.dro-state
span.chip(:class="w.homed ? 'chip-green' : 'chip-amber'")
.fa(:class="'fa-' + w.icon")
| &nbsp;{{w.state}}
.dro-toolpath
span.chip.chip-green
.fa(:class="'fa-' + w.ticon")
| &nbsp;{{w.tstate}}
.actions-cell
button.icon-btn(disabled, style="visibility:hidden")
.fa.fa-cog
.fa.fa-gear
button.icon-btn(disabled, style="visibility:hidden")
.fa.fa-map-marker
button.icon-btn(:disabled="!w.enabled",
title="Home W axis.", @click="aux_home()")
.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 -----

View File

@@ -6,11 +6,11 @@ script#indicators-template(type="text/x-template")
tr
td
.fa.fa-plus-circle.io
.fa.fa-circle-plus.io
th Hi/+3.3v
th.separator
td
.fa.fa-minus-circle.io
.fa.fa-circle-minus.io
th Lo/Gnd
th.separator
td
@@ -22,7 +22,7 @@ script#indicators-template(type="text/x-template")
th Inactive
th.separator
td
.fa.fa-circle-o.io
.far.fa-circle.io
th Tristated/Disabled
table.inputs
@@ -169,14 +169,14 @@ script#indicators-template(type="text/x-template")
tr
th Motor
th(title="Overtemperature fault"): .fa.fa-thermometer-full
th(title="Overtemperature fault"): .fa.fa-temperature-full
th(title="Overcurrent motor channel A") A #[.fa.fa-bolt]
th(title="Predriver fault motor channel A")
| A #[.fa.fa-exclamation-triangle]
| A #[.fa.fa-triangle-exclamation]
th(title="Overcurrent motor channel B") B #[.fa.fa-bolt]
th(title="Predriver fault motor channel B")
| B #[.fa.fa-exclamation-triangle]
th(title="Driver communication failure"): .fa.fa-handshake-o
| B #[.fa.fa-triangle-exclamation]
th(title="Driver communication failure"): .fa.fa-handshake
th(title="Reset all motor flags")
.fa.fa-eraser(@click="motor_reset()")

View File

@@ -3,7 +3,7 @@ script#path-viewer-template(type="text/x-template")
.path-viewer-toolbar
.tool-button(title="Toggle path view size.",
@click="small = !small", :class="{active: !small}")
.fa.fa-arrows-alt
.fa.fa-up-down-left-right
.tool-button(@click="showTool = !showTool", :class="{active: showTool}",
title="Show/hide tool.")

View File

@@ -82,7 +82,7 @@ script#program-view-template(type="text/x-template")
span STOP
button.action-btn(@click="open_folder", :disabled="!is_ready",
title="Upload a new GCode folder.")
.fa.fa-folder-arrow-up.ico
.fa.fa-folder-plus.ico
span UPLOAD FOLDER
form.gcode-folder-input.file-upload
input#folderInput(type="file", @change="upload_folder",
@@ -126,10 +126,15 @@ script#program-view-template(type="text/x-template")
.fa.fa-arrow-down-wide-short
| &nbsp;{{files_sortby}}
// Body: gcode listing on the left, 3D viewer on the right
.program-body
// Body: gcode listing on the left, 3D viewer on the right.
// The 3D path-viewer is suppressed when the UI is loaded by
// the Pi's onboard kiosk browser — the VideoCore IV cannot
// run three.js at a usable frame rate. Off-Pi clients still
// see the full split.
.program-body(:class="{'no-preview': is_kiosk}")
gcode-viewer
path-viewer(:toolpath="toolpath", :state="state", :config="config")
path-viewer(v-if="!is_kiosk", :toolpath="toolpath",
:state="state", :config="config")
.progress-bar(v-if="toolpath_progress && toolpath_progress < 1",
title="Simulating GCode to check for errors, calculate ETA and generate 3D view.")

View File

@@ -24,21 +24,35 @@ script#settings-shell-view-template(type="text/x-template")
// Explicit v-if cascade so the inner template swaps reactively
// when sub changes (Vue 1's `<component :is>` does not always
// re-evaluate dynamic strings inside a kept-alive parent).
settings-view-inner(v-if="sub === 'settings'",
// The Svelte settings views read many config keys eagerly on
// attach (settings.units, settings.easy-adapter, motion.*),
// so we gate the inner mount on config_ready.
settings-view-inner(v-if="sub === 'settings' && config_ready",
section="display",
:index="index", :config="config", :template="template", :state="state")
admin-general-view(v-if="sub === 'admin-general'",
settings-view-inner(v-if="sub === 'probing' && config_ready",
section="probing",
:index="index", :config="config", :template="template", :state="state")
admin-network-view(v-if="sub === 'admin-network'",
settings-view-inner(v-if="sub === 'gcode' && config_ready",
section="gcode",
:index="index", :config="config", :template="template", :state="state")
motor-view(v-if="sub === 'motor'",
admin-general-view(v-if="sub === 'admin-general' && config_ready",
:index="index", :config="config", :template="template", :state="state")
tool-view(v-if="sub === 'tool'",
admin-network-view(v-if="sub === 'admin-network' && config_ready",
:index="index", :config="config", :template="template", :state="state")
io-view(v-if="sub === 'io'",
motor-view(v-if="sub === 'motor' && config_ready",
:index="index", :config="config", :template="template", :state="state")
macros-view(v-if="sub === 'macros'",
tool-view(v-if="sub === 'tool' && config_ready",
:index="index", :config="config", :template="template", :state="state")
help-view(v-if="sub === 'help'",
io-view(v-if="sub === 'io' && config_ready",
:index="index", :config="config", :template="template", :state="state")
cheat-sheet-view(v-if="sub === 'cheat-sheet'",
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",
:index="index", :config="config", :template="template", :state="state")
help-view(v-if="sub === 'help' && config_ready",
:index="index", :config="config", :template="template", :state="state")
cheat-sheet-view(v-if="sub === 'cheat-sheet' && config_ready",
:index="index", :config="config", :template="template", :state="state")
.settings-loading(v-if="!config_ready")
| Loading configuration…

View File

@@ -433,4 +433,4 @@ script#tool-view-template(type="text/x-template")
| Other settings according to the
|
a(href="https://buildbotics.com/upload/vfd/stepperonline-v70.pdf",
target="_blank") Stepper Online V70 VFD manual
target="_blank") Stepper Online V70 VFD manual

View File

@@ -33,22 +33,67 @@ DEFAULTS = {
'enabled': False,
'port': '/dev/ttyUSB0',
'baud': 115200,
'steps_per_mm': 80.0, # logical steps per mm of W travel
'steps_per_mm': 80.0, # logical steps per mm of axis travel
'dir_sign': 1, # +1 or -1: maps logical+ to motor+ steps
'min_w': 0.0, # soft limit min (mm)
'max_w': 100.0, # soft limit max (mm)
'max_feed_mm_min': 600.0, # informational; rate caps are on the ESP
# Logical axis letter exposed to gplan. The auxcnc ESP stepper
# is presented to the planner as this axis (default 'a' = standard
# 4th axis). gcode uses A for moves; the host ExternalAxis layer
# forks A motion to the ESP transparently.
'axis_letter': 'a',
'min_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.
'home_fast_sps': 4000,
'home_slow_sps': 400,
'home_backoff_steps': 200,
# 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,
'step_max_sps': 4000,
'step_accel_sps2': 16000,
# 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
}
@@ -99,23 +144,61 @@ class AuxAxis(object):
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
@@ -152,24 +235,29 @@ class AuxAxis(object):
def position_mm(self):
return self._steps_to_mm(self._pos_steps)
def set_state_observer(self, fn):
"""Register a callback invoked after every _publish_state.
Used by ExternalAxis to mirror the homed flag into State."""
self._state_observer = fn
def home(self):
"""Run the homing cycle on the ESP. Blocks until done. Raises on
failure. Updates aux_homed and aux_pos."""
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] '
# line is the body after '[home] '. Only terminal lines use
# the [home] topic now (done / failed); progress is [home_log].
if line.startswith('done'):
# ESP set its counter to home_zero; mirror that.
new_pos = self._parse_kv_int(line, 'pos', 0)
self._pos_steps = new_pos
self._pos_steps = self._parse_kv_int(line, 'pos', 0)
self._homed = True
# Translate to home_position_mm. Conceptually the host says
# "after homing, W is here in mm". We achieve that by setting
# the ESP counter (WPOS) so the mm conversion works out.
target_pos = self._mm_to_steps(self._cfg['home_position_mm'])
if target_pos != new_pos:
self._rpc('WPOS %d' % target_pos, topic='ok', timeout=2.0)
self._pos_steps = target_pos
self._publish_state()
return
# failure
@@ -225,6 +313,55 @@ class AuxAxis(object):
except Exception as e:
self.log.warning('ABORT send failed: %s' % e)
# ---------------------------------------------------------- ATC commands
#
# The auxcnc firmware drives an AMB 1050 FME-W DI tool changer via
# three pneumatic valves on relays 1-3. The ESP runs the timed
# sequences itself; the host just kicks them off and waits for the
# terminal reply.
def atc_droptool(self, timeout=30.0):
"""Eject the current tool. Opens the collet (V1), oscillates the
ejector (V2), then re-clamps with a bleed cycle. Blocks until
the ESP reports done. Raises on failure."""
self._require_present()
line = self._rpc('DROPTOOL', topic='droptool', timeout=timeout)
if line.startswith('done'):
return
reason = line.split('reason=', 1)[1] if 'reason=' in line else line
raise AuxAxisError('DROPTOOL failed: %s' % reason)
def atc_grabtool(self, timeout=30.0):
"""Pick up a tool that's already been seated by the operator.
Opens V1 (releases the collet), waits for the operator to insert
the holder, then re-clamps with a bleed cycle. Blocks."""
self._require_present()
line = self._rpc('GRABTOOL', topic='grabtool', timeout=timeout)
if line.startswith('done'):
return
reason = line.split('reason=', 1)[1] if 'reason=' in line else line
raise AuxAxisError('GRABTOOL failed: %s' % reason)
def atc_release(self, timeout=5.0):
"""Manually open the collet (release-only, no clamp). Use
atc_clamp() afterwards once the new holder is in place."""
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):
"""Manually clamp the collet (run a full bleed cycle). Pairs
with atc_release() for two-step manual tool changes."""
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 close(self):
self._stop.set()
try:
@@ -242,13 +379,13 @@ class AuxAxis(object):
raise AuxAxisError('Aux axis not connected')
def _check_limits(self, target_mm):
lo = float(self._cfg['min_w'])
hi = float(self._cfg['max_w'])
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(
'W=%.3f out of soft limits [%.3f, %.3f]' % (target_mm, lo, hi))
'A=%.3f out of soft limits [%.3f, %.3f]' % (target_mm, lo, hi))
def _mm_to_steps(self, mm):
spm = float(self._cfg['steps_per_mm'])
@@ -281,6 +418,64 @@ class AuxAxis(object):
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):
@@ -312,17 +507,26 @@ class AuxAxis(object):
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=0 accel=%d step_max=%d step_start=%d limit_low=%d') % (
'zero=%d accel=%d step_max=%d step_start=%d limit_low=%d '
'preclear=%d') % (
c['home_dir'],
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)
@@ -332,11 +536,28 @@ class AuxAxis(object):
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:
r = self._rpc('HOMED?', topic='homed', timeout=2.0)
self._homed = (r.strip() == '1')
self._rpc('UNHOME', topic='ok', timeout=2.0)
self._homed = False
except Exception:
pass
# Fall back to whatever HOMED? says - but treat any
# missing UNHOME support as "trust ESP's flag" so we
# don't break older firmware.
try:
r = self._rpc('HOMED?', topic='homed', timeout=2.0)
self._homed = (r.strip() == '1')
except Exception:
pass
self._publish_state()
def _reader_loop(self):
@@ -373,7 +594,7 @@ class AuxAxis(object):
self._present = True
self._publish_state()
self.ctrl.state.add_message(
'W axis controller restarted - re-home before use')
'Auxiliary axis controller restarted - re-home before use')
return
# Topic dispatch: "[topic] body..."
@@ -475,3 +696,11 @@ class AuxAxis(object):
except Exception:
# During very early startup, state may not be ready.
pass
# Notify the external-axis layer so it can mirror state
# (e.g. homed flag) into the synthetic motor vars.
observer = getattr(self, '_state_observer', None)
if observer is not None:
try:
observer()
except Exception:
pass

View File

@@ -1,25 +1,33 @@
################################################################################
#
# AuxPreprocessor - rewrite W-axis G-code into hook calls
# AuxPreprocessor - rewrite ATC M-codes into hook calls
#
# The bbctrl planner only understands xyzabc. We expose a virtual W axis by
# rewriting the G-code file *before* it is fed to gplan, replacing each W
# move with a (MSG,HOOK:aux:...) line that the host's hook handler turns
# into a STEPS or HOME command on the ESP.
# 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.
#
# Rules:
# - Mixed-axis blocks (W together with XYZABC) are split into two
# sequential blocks. By default the W move runs first; configurable.
# - G90/G91/G20/G21 modal state is tracked so we can convert relative-W
# and inch-W into the absolute mm value the hook handler expects.
# - G28 W0 / G28.2 W0 -> HOOK:aux_home
# - G92 Wx -> HOOK:aux_setzero:<mm>
# - G53 + W not specially handled (W only knows machine coords)
# - Lines inside parentheses or after `;` are passed through.
# What this still does
# --------------------
# Maps four user-defined M-codes onto pneumatic-tool-changer events:
#
# M100 DROPTOOL -> (MSG,HOOK:droptool:)
# M101 GRABTOOL -> (MSG,HOOK:grabtool:)
# M102 RELEASE -> (MSG,HOOK:release:)
# M103 CLAMP -> (MSG,HOOK:clamp:)
#
# 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 them out and emit the matching hook
# line in their place.
#
# The preprocessor is intentionally conservative: anything it doesn't
# understand involving W is left alone with a warning, so motion lands in
# gplan which will complain loudly rather than silently misbehaving.
# understand is left alone.
#
################################################################################
@@ -29,18 +37,39 @@ import shutil
import tempfile
# Match a word like "W12.5" or "W-3" or "w0". Also matches inside the same
# line as XYZ words. We pull W out specifically.
_W_TOKEN_RE = re.compile(r'(?<![A-Za-z_0-9])[Ww]\s*([-+]?\d*\.?\d+)')
# Detect any axis-bearing word (so we can tell mixed-axis lines apart).
_AXIS_WORD_RE = re.compile(r'(?<![A-Za-z_0-9])[XYZABCxyzabc]\s*[-+]?\d*\.?\d+')
# Strip line comments so we don't get fooled by "(W axis)".
# Strip line comments so we don't get fooled by "(M100 not really)".
_PAREN_COMMENT_RE = re.compile(r'\([^)]*\)')
# Modal G-code groups we care about.
_MODAL_RE = re.compile(r'(?<![A-Za-z_0-9])[Gg]\s*0*(\d+(?:\.\d+)?)')
# ATC pneumatics M-codes mapped onto hook events.
_ATC_M_CODES = {
100: 'droptool',
101: 'grabtool',
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):
@@ -48,79 +77,240 @@ class AuxPreprocessorError(Exception):
class AuxPreprocessor(object):
def __init__(self, log=None, w_first=True):
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
# If True, on a mixed-axis line (e.g. G1 X10 W5), emit the W move
# first, then the XYZ move. Set False to invert.
self.w_first = w_first
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)
if self.log: self.log.info(msg)
def _warn(self, msg):
if self.log:
self.log.warning(msg)
if self.log: self.log.warning(msg)
# ------------------------------------------------------------------ scan
@staticmethod
def file_uses_w(path):
"""Quick check: does this file contain any W-axis word? Used to skip
preprocessing entirely for files that don't care about W."""
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 = _PAREN_COMMENT_RE.sub('', line)
code = code.split(';', 1)[0]
if _W_TOKEN_RE.search(code):
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
# ------------------------------------------------------------------ core
# Backwards-compat alias.
file_uses_w = file_uses_aux
def _strip_w(self, line):
"""Return (line_without_w, w_value_str_or_None). Only first W kept."""
m = _W_TOKEN_RE.search(line)
if m is None:
return line, None
# Remove just the matched W<num> token, preserving surrounding spaces.
rewritten = line[:m.start()] + line[m.end():]
return rewritten, m.group(1)
# ------------------------------------------------------------------ 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 _has_other_axis(self, code_no_w):
return _AXIS_WORD_RE.search(code_no_w) is not None
def _detect_modals(self, code, modal):
"""Update modal dict in-place from G-codes on this line."""
for mm in _MODAL_RE.finditer(code):
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:
g = float(mm.group(1))
except ValueError:
continue
if g == 90: modal['abs'] = True
elif g == 91: modal['abs'] = False
elif g == 20: modal['inch'] = True
elif g == 21: modal['inch'] = False
# G28 / G28.2 / G92 are detected case-by-case below.
out.add(int(float(m.group(1))))
except Exception:
pass
return out
@staticmethod
def _is_g28_like(code):
# Match G28 or G28.2 (homing).
return bool(re.search(r'(?<![A-Za-z_0-9])[Gg]\s*0*28(?:\.2)?(?![\w.])', code))
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
@staticmethod
def _is_g92(code):
return bool(re.search(r'(?<![A-Za-z_0-9])[Gg]\s*0*92(?![\w.])', code))
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."""
modal = {'abs': True, 'inch': False} # G90 G21 are common defaults
"""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, \
@@ -135,90 +325,63 @@ class AuxPreprocessor(object):
fout.write(raw)
continue
# Update modal from G-codes on this line first (so absolute
# vs incremental matches what the planner sees for XYZ).
self._detect_modals(code, modal)
# 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
if not _W_TOKEN_RE.search(code):
fout.write(raw)
# Z-A coupling injection BEFORE the line is emitted.
if self._maybe_inject_a_down(code, fout):
rewrote_any = True
# ATC M-codes (M100-M103). Each ATC M-code on the line
# is replaced with its (MSG,HOOK:<event>:) line and
# stripped from the residual.
atc_matches = list(_ATC_M_RE.finditer(line))
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:
fout.write('(MSG,HOOK:%s:)\n' % event)
line = _ATC_M_RE.sub('', line)
code = _PAREN_COMMENT_RE.sub('', line)
code = code.split(';', 1)[0]
if not code.strip():
# Nothing meaningful left; preserve any trailing
# comment text but skip empty lines.
rest = line.rstrip()
if rest:
fout.write(rest + '\n')
continue
# Other gcode remains on the line - emit it.
fout.write(line + '\n')
continue
rewrote_any = True
# G28[.2] W... -> aux_home (W value is ignored except as
# a flag that W is being homed).
if self._is_g28_like(code):
code_no_w, _ = self._strip_w(line)
fout.write('(MSG,HOOK:aux_home:)\n')
# Only keep the residual line if other axes were also
# present (e.g. G28.2 X0 Y0 W0 still homes X+Y). A bare
# "G28" without axis args means "home all" in gcode
# which we explicitly DON'T want to trigger from a
# W-only home command.
rest_code = _PAREN_COMMENT_RE.sub('', code_no_w)
rest_code = rest_code.split(';', 1)[0]
if self._has_other_axis(rest_code):
fout.write(code_no_w + '\n')
continue
# G92 W... -> set W zero (or other value) without motion.
if self._is_g92(code):
line_no_w, w_val = self._strip_w(line)
target_mm = self._w_to_mm(w_val, modal, set_pos=True)
fout.write('(MSG,HOOK:aux_setzero:%g)\n' % target_mm)
rest_code = _PAREN_COMMENT_RE.sub('', line_no_w)
rest_code = rest_code.split(';', 1)[0]
if self._has_other_axis(rest_code):
fout.write(line_no_w + '\n')
continue
# Plain motion: G0/G1 etc with W word.
line_no_w, w_val = self._strip_w(line)
target_mm = self._w_to_mm(w_val, modal, set_pos=False)
# Distinguish absolute vs relative: encode both, the hook
# handler will pick the right operation.
if modal['abs']:
hook_line = '(MSG,HOOK:aux:%g)' % target_mm
else:
hook_line = '(MSG,HOOK:aux_rel:%g)' % target_mm
rest_code = _PAREN_COMMENT_RE.sub('', line_no_w)
rest_code = rest_code.split(';', 1)[0]
has_xyz = self._has_other_axis(rest_code)
if not has_xyz:
# Pure W move; drop the (now-empty) original line.
fout.write(hook_line + '\n')
continue
# Mixed-axis: split. Default order is W first.
if self.w_first:
fout.write(hook_line + '\n')
fout.write(line_no_w + '\n')
else:
fout.write(line_no_w + '\n')
fout.write(hook_line + '\n')
# No rewrite needed.
fout.write(raw)
return rewrote_any
# ------------------------------------------------------------ unit conv
def _w_to_mm(self, w_str, modal, set_pos):
try:
v = float(w_str)
except (TypeError, ValueError):
raise AuxPreprocessorError('Invalid W value: %r' % w_str)
if modal['inch']:
v *= 25.4
return v
def preprocess_file(src_path, log=None, coupling=None, **_unused):
"""Convenience: rewrite src_path in place if it contains ATC
M-codes or needs Z-A coupling injection. Returns True if the
file was rewritten.
def preprocess_file(src_path, log=None, w_first=True):
"""Convenience: rewrite src_path in place if it uses W.
Returns True if the file was rewritten."""
if not AuxPreprocessor.file_uses_w(src_path):
`coupling` is an optional dict (see AuxPreprocessor.__init__).
Extra keyword args are accepted for backwards compat (the old
w_first arg is no longer used)."""
if not AuxPreprocessor.file_uses_aux(src_path, coupling=coupling):
return False
pre = AuxPreprocessor(log=log, w_first=w_first)
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)

View File

@@ -216,6 +216,32 @@ class Config(object):
defaults = json.load(f)
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['full_version'] = self.version

View File

@@ -75,6 +75,19 @@ class Ctrl(object):
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'):
@@ -133,38 +146,51 @@ class Ctrl(object):
def _register_aux_hooks(self):
"""Wire up the auxcnc HOOK: events to AuxAxis methods."""
"""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_move(ctx):
data = (ctx.get('data') or '').strip()
if not data:
raise Exception('aux hook missing target')
self.aux.move_abs_mm(float(data))
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_move_rel(ctx):
data = (ctx.get('data') or '').strip()
if not data:
raise Exception('aux_rel hook missing delta')
self.aux.move_rel_mm(float(data))
def _hook_droptool(ctx): self.aux.atc_droptool()
def _hook_grabtool(ctx): self.aux.atc_grabtool()
def _hook_release(ctx): self.aux.atc_release()
def _hook_clamp(ctx): self.aux.atc_clamp()
def _hook_home(ctx):
self.aux.home()
def _hook_setzero(ctx):
data = (ctx.get('data') or '').strip()
mm = float(data) if data else 0.0
self.aux.set_position_mm(mm)
self.hooks.register_internal('aux', _hook_move,
block_unpause=True, auto_resume=True)
self.hooks.register_internal('aux_rel', _hook_move_rel,
block_unpause=True, auto_resume=True)
self.hooks.register_internal('aux_home', _hook_home,
# 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)
self.hooks.register_internal('aux_setzero', _hook_setzero,
block_unpause=True, auto_resume=True)
# ATC pneumatics. block_unpause + auto_resume so a program
# using M100/M101/M102/M103 pauses at the right point and
# resumes once the sequence is done.
self.hooks.register_internal('droptool', _hook_droptool,
block_unpause=True, auto_resume=True,
timeout=60)
self.hooks.register_internal('grabtool', _hook_grabtool,
block_unpause=True, auto_resume=True,
timeout=60)
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)
log.info('Aux hooks registered')
@@ -173,5 +199,7 @@ class Ctrl(object):
self.ioloop.close()
self.avr.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,18 +99,26 @@ class FileHandler(bbctrl.APIHandler):
del (self.uploadFile)
# If the uploaded G-code uses the virtual W axis, rewrite the
# file in place so the planner sees (MSG,HOOK:aux:*) lines
# instead of W tokens it can't parse.
# 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
log = self.get_log('AuxPreprocessor')
if preprocess_file(filename.decode('utf8'), log=log):
log.info('Rewrote W-axis tokens in %s' %
self.uploadFilename)
ext = getattr(self.get_ctrl(), 'ext_axis', None)
coupling = (ext.coupling_for_preprocessor()
if ext is not None else None)
if preprocess_file(filename.decode('utf8'),
log=log, coupling=coupling):
log.info('Rewrote upload (ATC / Z-A coupling) in %s'
% self.uploadFilename)
except Exception:
self.get_log('AuxPreprocessor').exception(
'W-axis preprocess failed; uploading unchanged')
'Aux preprocess failed; uploading unchanged')
self.get_ctrl().preplanner.invalidate(self.uploadFilename)
self.get_ctrl().state.add_file(self.uploadFilename)

View File

@@ -201,7 +201,15 @@ class Hooks:
# 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.
# 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)
@@ -249,6 +257,23 @@ class Hooks:
# -- 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.

View File

@@ -95,6 +95,10 @@ class Mach(Comm):
self.planner = bbctrl.Planner(ctrl)
self.unpausing = 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')
@@ -256,9 +260,12 @@ class Mach(Comm):
if cmd[0] == '$': self._query_var(cmd)
elif cmd[0] == '\\': super().queue_command(cmd[1:])
else:
# Rewrite W-axis tokens in MDI input the same way the
# FileHandler rewrites uploaded files.
cmd = self._rewrite_w_mdi(cmd)
# 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.planner.mdi(cmd, with_limits)
super().resume()
@@ -266,12 +273,12 @@ class Mach(Comm):
self.mlog.info("Exception during MDI: %s" % err)
pass
def _rewrite_w_mdi(self, cmd):
"""Apply the W-axis preprocessor to a single MDI line. Returns
possibly-multi-line G-code with HOOK: comments inserted."""
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, _W_TOKEN_RE
if not _W_TOKEN_RE.search(cmd):
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
@@ -292,7 +299,7 @@ class Mach(Comm):
except OSError: pass
return rewritten
except Exception as e:
self.mlog.warning('W-axis MDI rewrite failed: %s' % e)
self.mlog.warning('Aux MDI rewrite failed: %s' % e)
return cmd
def set(self, code, value):
@@ -300,6 +307,17 @@ class Mach(Comm):
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.planner.position_change()
super().queue_command(Cmd.jog(axes))
@@ -313,10 +331,52 @@ class Mach(Comm):
axes = 'zxybc' if is_rotary_active else 'zxyabc' # TODO This should be configurable
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:
enabled = state.is_axis_enabled(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
# axis is disabled or in manual homing mode, don't show any
# warnings
@@ -347,8 +407,138 @@ class Mach(Comm):
self.planner.mdi(gcode, False)
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()
@@ -375,6 +565,12 @@ class Mach(Comm):
def stop(self):
if self._get_state() != 'jogging': self.stopping = True
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()

View File

@@ -196,12 +196,23 @@ class Planner():
def _add_message(self, text):
self.ctrl.state.add_message(text)
line = self.ctrl.state.get('line', 0)
if 0 <= line: where = '%s:%d' % (self.where, line)
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)
@@ -259,6 +270,54 @@ class Planner():
if type != 'set': self.log.info('Cmd:' + log_json(block))
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)
return Cmd.line(block['target'], block['exit-vel'],
block['max-accel'], block['max-jerk'],
@@ -289,8 +348,17 @@ class Planner():
if name[2:] == '_homed':
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)
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
@@ -339,6 +407,68 @@ class Planner():
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):
stop = kwargs.get('stop', True)
if stop:
@@ -352,6 +482,16 @@ class Planner():
self.cmdq.clear()
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)
if resetState:
self.ctrl.state.reset()
@@ -369,6 +509,22 @@ class Planner():
self.where = path
path = self.ctrl.get_path('upload', path)
self.log.info('GCode:' + path)
# Rewrite ATC M-codes (M100..M103) before gplan sees them.
# preprocess_file is a no-op when no rewriting is needed and
# idempotent when run twice on the same file, so this is
# safe on every load. W tokens are no longer rewritten - the
# auxcnc stepper is now exposed as a virtual A axis and gcode
# should use A directly.
try:
from bbctrl.AuxPreprocessor import preprocess_file
ext = getattr(self.ctrl, 'ext_axis', None)
coupling = (ext.coupling_for_preprocessor()
if ext is not None else None)
if preprocess_file(path, log=self.log, coupling=coupling):
self.log.info('Rewrote (ATC / Z-A coupling) in %s' % path)
except Exception:
self.log.exception('Aux preprocess at load failed; '
'attempting to load file unchanged')
self._sync_position()
self.planner.load(path, self.get_config(False, True))
self.reset_times()

View File

@@ -107,8 +107,14 @@ class State(object):
def reset(self):
# Unhome all motors
for i in range(4): self.set('%dhomed' % i, False)
# Unhome all motors (real AVR motors 0..3 and the synthetic
# 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
for axis in 'xyzabc':
@@ -280,8 +286,11 @@ class State(object):
axis_motors = {axis: self.find_motor(axis) for axis in 'xyzabc'}
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():
if name[0] in '0123':
if name[0] in '01234':
motor = int(name[0])
for axis in 'xyzabc':
@@ -330,6 +339,9 @@ class State(object):
def get_axis_vector(self, name, scale = 1):
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':
motor = self.find_motor(axis)
@@ -351,7 +363,10 @@ class State(object):
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
motor_axis = 'xyzabc'[self.vars['%dan' % motor]]
if motor_axis == axis.lower() and self.vars.get('%dme' % motor, 0):

View File

@@ -812,9 +812,13 @@ class AuxStatusHandler(bbctrl.APIHandler):
class AuxHomeHandler(bbctrl.APIHandler):
def put_ok(self):
# Run synchronously via the AuxAxis' own RPC; this blocks the
# request. Fine because the UI shows a spinner.
self.get_ctrl().aux.home()
# 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):
@@ -824,12 +828,22 @@ class AuxAbortHandler(bbctrl.APIHandler):
class AuxJogHandler(bbctrl.APIHandler):
"""Body: {"mm": 1.5} for relative-mm move,
{"steps": 200} for raw step move (bypasses soft limits)."""
{"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:
aux.move_rel_mm(float(body['mm']))
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:
@@ -842,7 +856,11 @@ class AuxMoveHandler(bbctrl.APIHandler):
body = self.json or {}
if 'mm' not in body:
raise HTTPError(400, 'mm required')
self.get_ctrl().aux.move_abs_mm(float(body['mm']))
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):

View File

@@ -68,6 +68,7 @@ from bbctrl.IOLoop import IOLoop
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.v4l2 as v4l2
import bbctrl.Log as log

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

9
src/static/css/fa6.min.css vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -30,6 +30,16 @@ $jog-ghost-hov = #9ba6bb
$jog-ink = #fff
$jog-ghost-ink = $ink
// Lock html + body so nothing other than the explicit .app-body or
// inner scroll containers can scroll. Without this, autofocus inside
// nested Svelte components (Settings, Admin Network, etc.) can call
// scrollIntoView() on the html element and push our fixed header off
// the top of the viewport.
html, body
height 100%
overflow hidden
overscroll-behavior none
body
margin 0
font-family 'Inter', system-ui, -apple-system, sans-serif
@@ -78,6 +88,66 @@ tt
width 100%
overflow hidden
background $body-bg
// Hint to the browser that this layer is a stable composited
// surface so tab swaps inside it don't invalidate the whole page.
contain layout paint
isolation isolate
// Program tab pre-warmer. Mounts the program-view at app start so
// the WebGL canvas is already initialized when the user first
// clicks the Program tab avoids the first-time dark flash that
// happens when WebGL is created on demand. We render the component
// at full size off-screen with the same width as the live tab so
// the canvas dims match.
.program-warmer
position absolute
left -10000px
top 0
width 1920px
height 980px
pointer-events none
visibility hidden
// =====================================================================
// Tablet / kiosk mode
//
// When <html class="tablet-mode"> is set (via ?tablet=1, sticky in
// localStorage), pin the app shell to a fixed 1920 x 1080 box and
// scale it to fit the real viewport. The scale ratio is published as
// the --tablet-scale CSS variable by the inline script in index.pug.
//
// On the actual 10.8" 1920x1080 portable monitor the scale is 1; on
// a desktop browser at e.g. 1440x900 you get a faithfully shrunk
// preview of exactly what the kiosk renders.
// =====================================================================
html.tablet-mode
background #0f172a
overflow hidden
html.tablet-mode body
margin 0
width 100vw
height 100vh
overflow hidden
display flex
align-items center
justify-content center
html.tablet-mode .app-shell
width 1920px
height 1080px
flex 0 0 auto
transform scale(var(--tablet-scale, 1))
transform-origin center center
box-shadow 0 30px 60px rgba(0, 0, 0, 0.45)
border-radius 14px
// Keep dialog hosts above the scaled shell.
html.tablet-mode #svelte-dialog-host
position fixed
top 0
left 0
z-index 10000
.app-body
flex 1
@@ -100,7 +170,10 @@ tt
padding 0 24px
background $bg
border-bottom 1px solid $line
position relative
// sticky so the header stays visible even if a nested scroll
// container manages to move under it.
position sticky
top 0
z-index 30
.brand-blk
@@ -644,8 +717,12 @@ span.unit
text-transform capitalize
.path-viewer-content
background-color #333
background linear-gradient(to bottom, #666 0%, #222 100%);
// Solid dark background matching the WebGL renderer's clear
// colour. We used to use a gradient (#666 -> #222) but the
// visible-during-mount color difference between the gradient
// top and the GL clear bottom caused a brief dark flash when
// the canvas was reattached on every tab switch.
background-color #222
margin-bottom 0.5em
&.small
@@ -1073,6 +1150,150 @@ tt.save
font-size 0.85em
margin-left 2px
// =====================================================================
// NOW RUNNING panel
// Replaces the jog grid (left column) while the machine is not idle.
// Same outer dimensions as .jog-card so the rest of the layout doesn't
// reflow.
// =====================================================================
.control-page .running-panel
flex 1 1 auto
min-height 0
display flex
flex-direction column
gap 18px
padding 24px
border-radius 18px
color #fff
background linear-gradient(160deg, #0f172a 0%, #1e293b 100%)
.running-top
display flex
align-items flex-start
justify-content space-between
gap 18px
.running-file
font-size 1.6rem
font-weight 900
line-height 1.1
display flex
align-items center
gap 4px
.fa
color $accent
.running-meta
font-size 0.9rem
color #cbd5e1
font-weight 600
margin-top 6px
text-transform capitalize
.running-pct
font-family 'JetBrains Mono', monospace
font-size 3.6rem
font-weight 900
line-height 1
color #fff
span
font-size 2rem
color $accent
font-weight 800
margin-left 4px
.running-progress
height 14px
background rgba(255, 255, 255, 0.18)
border-radius 9999px
overflow hidden
width 100%
> div
height 100%
background $accent
transition width 0.4s ease-out
.running-stats
display grid
grid-template-columns repeat(4, 1fr)
gap 14px
.running-stat
background rgba(255, 255, 255, 0.06)
border-radius 14px
padding 14px 16px
.lbl
font-size 0.7rem
letter-spacing 0.14em
text-transform uppercase
color #cbd5e1
font-weight 700
.val
font-family 'JetBrains Mono', monospace
font-weight 900
font-size 1.5rem
margin-top 4px
color #fff
.running-row
display grid
grid-template-columns repeat(auto-fit, minmax(140px, 1fr))
gap 14px
margin-top auto
.running-row .tx-btn
display inline-flex
flex-direction column
align-items center
justify-content center
gap 6px
height 120px
border-radius 18px
border none
font-weight 800
letter-spacing 0.05em
cursor pointer
background #1e293b
color #fff
transition transform 0.06s, background 0.15s
.running-row .tx-btn .fa
font-size 2.4rem
.running-row .tx-btn .lbl
font-size 0.85rem
opacity 0.9
.running-row .tx-btn:hover
background #334155
.running-row .tx-btn:active
transform translateY(2px)
.running-row .tx-btn.pause
background #f59e0b
color #0f172a
.running-row .tx-btn.pause:hover
background #d97706
.running-row .tx-btn.run
background #16a34a
.running-row .tx-btn.run:hover
background #15803d
.running-row .tx-btn.stop
background #dc2626
.running-row .tx-btn.stop:hover
background #b91c1c
// Step segmented control
.step-seg
display inline-flex
@@ -1112,12 +1333,16 @@ tt.save
flex-direction column
align-items center
justify-content center
gap 4px
gap 6px
user-select none
-webkit-tap-highlight-color transparent
cursor pointer
font-weight 700
font-size 1.05rem
// Single sizing used by both the 1920x1080 portable touchscreen and
// the Pi 1366x768 kiosk large enough to be readable at arm's
// length on the smaller display, still proportionate on the bigger
// one. No mode-specific override.
font-size 1.6rem
border none
background $jog-bg
color $jog-ink
@@ -1126,13 +1351,13 @@ tt.save
min-width 0
.ico
font-size 1.6rem
font-size 2.4rem
.lbl
font-size 0.8rem
font-size 1.5rem
color inherit
opacity 0.85
font-weight 600
opacity 0.95
font-weight 700
&:hover:not([disabled])
background $jog-hover
@@ -1171,7 +1396,7 @@ tt.save
.control-page .dro-head, .control-page .dro-row
display grid
grid-template-columns 84px 1.4fr 1fr 1fr 170px 170px 280px
grid-template-columns 84px 1.4fr 1fr 1fr 280px
column-gap 0.75rem
align-items center
padding 14px 22px
@@ -1185,6 +1410,15 @@ tt.save
letter-spacing 0.1em
color $muted-2
// Master Home All sits in the header's Actions cell. Make it
// visually subordinate to the per-axis home buttons in the rows
// below same family, smaller scale.
.icon-btn
width 44px
height 44px
font-size 0.95rem
border-radius 9px
.control-page .dro-row
border-bottom 1px solid $line-soft
flex 1
@@ -1295,6 +1529,39 @@ tt.save
opacity 0.45
cursor not-allowed
// State-tinted variants used on home + zero buttons in DRO rows
// to communicate per-axis homing / toolpath fit at a glance,
// replacing the explicit HOMED / OK chips that used to live in
// their own columns.
&.state-green
background #dcfce7
border-color #86efac
color #166534
&:hover:not([disabled])
background #bbf7d0
&.state-amber
background #fef3c7
border-color #fcd34d
color #92400e
&:hover:not([disabled])
background #fde68a
&.state-red
background #fee2e2
border-color #fca5a5
color #991b1b
&:hover:not([disabled])
background #fecaca
&[disabled].state-green,
&[disabled].state-amber,
&[disabled].state-red
opacity 0.7
.actions-cell
display flex
justify-content flex-end
@@ -1627,6 +1894,15 @@ tt.save
min-height 0
overflow hidden
// On the Pi's onboard kiosk browser the 3D toolpath preview is
// suppressed (Pi 3B's VideoCore IV can't run three.js fast
// enough), so the gcode listing claims the full width.
&.no-preview
grid-template-columns 1fr
> .gcode
border-right none
> .gcode
border-right 1px solid $line-soft
background #fafafa
@@ -1643,22 +1919,28 @@ tt.save
> .path-viewer
overflow hidden
min-height 0
height 100%
background #222 // matches the WebGL clear colour so any
// first-frame mismatch reads as the same dark
// panel rather than a flash.
display flex
flex-direction column
.path-viewer-content
flex 1 1 auto
width 100% !important
height auto !important
height 100% !important
min-height 0
float none !important
margin 0 !important
background #222
&.small .path-viewer-content
width 100% !important
height auto !important
height 100% !important
float none !important
margin 0 !important
background #222
.progress-bar
height 28px
@@ -2054,3 +2336,321 @@ tt.save
h1, h2, h3
margin-top 0
.settings-loading
color $muted
font-style italic
padding 24px
// =====================================================================
// KIOSK MODE compact layout for the controller's own onboard browser
// (Pi 3B at 1366x768). Activated by `html.kiosk-mode` (auto-applied
// when location.hostname is localhost). All overrides target the V09
// shell so the desktop / portable touchscreen layout is unaffected.
// =====================================================================
html.kiosk-mode
font-size 13px
.app-head
flex 0 0 56px
height 56px
padding 0 12px
gap 10px
.brand-blk
display none
.estop
transform scale(0.6)
transform-origin right center
.tabs-host
height 56px
padding-left 0
.ktab
height 56px
padding 0 14px
font-size 14px
gap 6px
.fa
font-size 16px
.ktab .ktab-underline,
.ktab.active::after
bottom 0
.app-body
padding 8px
gap 8px
// Control page: tighten everything
.control-page
gap 8px
// Keep two columns at 1366x768 vertical space is the constraint.
// Shrink the jog column from 720px to 540px so the DRO has more
// breathing room.
.control-page .control-grid
grid-template-columns 540px 1fr
gap 8px
.control-page .right-col
grid-template-rows 1fr 110px
gap 8px
.control-page .jog-card
padding 10px
.control-page .jog-head
margin-bottom 8px
.control-page .jog-title
font-size 14px
.control-page .jog-grid
gap 6px
.control-page .dro-head, .control-page .dro-row
grid-template-columns 56px 1fr 0.85fr 0.85fr 1fr
column-gap 0.4rem
padding 6px 10px
.control-page .dro-head
font-size 0.65rem
.control-page .dro-row
font-size 0.95rem
// Axis-action buttons in DRO rows (settings/zero/home).
.control-page .dro-row .icon-btn
width 56px
height 56px
font-size 1.25rem
border-radius 11px
.control-page .status-strip
grid-template-columns repeat(2, 1fr)
gap 8px
.control-page .stat-card
padding 8px 12px
.stat-label
font-size 9px
.stat-val
font-size 18px
margin-top 2px
.stat-sub
font-size 11px
margin-top 0
// Macros: 8 -> 4 column grid; shorter buttons.
.control-page .macro-row
grid-template-columns repeat(4, 1fr)
gap 6px
.macro-btn
height 56px
font-size 0.85rem
border-radius 10px
// Now-running panel: tighten paddings, smaller percent text.
.control-page .running-panel
padding 12px
gap 10px
.running-file
font-size 1.15rem
.running-pct
font-size 2.2rem
span
font-size 1.2rem
.running-stats
grid-template-columns repeat(2, 1fr)
gap 6px
.running-stat
padding 8px 10px
.val
font-size 1rem
// Program page: gcode listing fills (path-viewer is hidden via JS).
.program-page
gap 8px
// Settings shell: tighter rail.
.settings-shell .rail
padding 8px
.settings-shell .rail .rail-item
padding 8px 10px
font-size 0.9rem
.settings-content
padding 10px
// System pill / sidebar headers smaller.
.system-pill, .sidebar-pill
font-size 0.8rem
// Inside system-pill, the icon + text need explicit spacing.
.system-pill > * + *,
.sidebar-pill > * + *
margin-left 6px
// Modal dialogs scaled down for the smaller viewport.
.modal-bg
font-size 13px
// =====================================================================
// LEGACY-CHROMIUM FLEX-GAP FALLBACK
// =====================================================================
// Chromium 72 (which ships on the controller's Pi 3B) does not support
// `gap` on flexbox containers it landed in Chrome 84 (2020-05). Grid
// `gap` IS supported (Chrome 57+) so anything `display: grid` is fine.
//
// We can't use @supports not (gap: 1px) because Chromium 72 reports
// it supports `gap` (the spec considers it a grid-only property in
// older snapshots). Instead, we add explicit margin-based spacing for
// every known flex container in the V09 shell that visibly breaks on
// the Pi kiosk. The modern CSS gap rule still applies in newer Chrome
// these rules are inert (margin-left:auto rules elsewhere keep
// their meaning) because the gap pushes children apart anyway.
// App header brand block, tabs, system pill, estop
.app-head > * + *
margin-left 18px
.app-head .brand-blk > * + *
margin-left 14px
// Tabs ribbon the tab button itself uses flex+gap to space its
// icon and label. Without flex-gap support those collapse.
.ktab > * + *
margin-left 0.55rem
// Header system pill (sys-btn) and machine state badge
.sys-btn > * + *
margin-left 0.55rem
.state-badge > * + *
margin-left 0.6rem
// Jog card title row
.control-page .jog-head > * + *
margin-left 12px
// Now-running panel top, file/meta, stats, row, transport
.control-page .running-panel > * + *
margin-top 18px
.running-top > * + *
margin-left 18px
.running-row > * + *
margin-left 14px
.transport-row > * + *
margin-left 14px
// Console card tabs
.console-card .ptab-bar > * + *
margin-left 6px
// Settings shell rail and content
.settings-shell > * + *
margin-left 18px
// Macro buttons (.macro-btn icon + label)
.macro-btn > * + *
margin-left 0.6rem
// Jog buttons (.jbtn ico + lbl) column flex, vertical gap
.jbtn > * + *
margin-top 4px
// DRO actions cell already uses gap; emulate via margin
.actions-cell > * + *
margin-left 10px
// Generic ".header"-style flex rows in older subpages
.app-body .pure-form > * + *
margin-top 4px
// =====================================================================
// KIOSK-MODE-SPECIFIC LEGACY FALLBACKS
// =====================================================================
html.kiosk-mode
.app-head > * + *
margin-left 10px
.control-page
> * + *
margin-top 8px
.control-page .control-grid
// grid-gap works on Chromium 72 so nothing here.
.control-page .right-col
// grid-gap works.
grid-template-rows 1fr 158px // tighter than the desktop 158px
.running-row > * + *
margin-left 6px
.control-page .jog-head > * + *
margin-left 8px
// Settings rail must be scrollable in kiosk mode \u2014 the 14+
// item list overflows the 768px viewport at default heights.
.settings-shell
grid-template-columns 220px 1fr
gap 10px
.settings-rail
position static
align-self stretch
max-height 100%
overflow-y auto
.settings-rail .set-item
height 36px
font-size 0.85rem
padding 0 10px
.fa
width 14px
font-size 0.9rem
.settings-rail .set-section
margin 6px 4px 2px
font-size 0.62rem
.settings-rail .set-rail-foot
margin-top 4px
padding-top 6px
.sp-shutdown, .sp-save
height 32px
font-size 0.85rem
// Program tab flex-gap fallbacks for Chromium 72.
// Action bar (RUN/STOP/UPLOAD/.../DELETE) and the action buttons
// themselves (icon stacked over label).
.action-bar > * + *
margin-left 12px
.action-btn > * + *
margin-top 4px
// File bar (Create Folder / folder select / file select / sort).
.file-bar > * + *
margin-left 10px
.file-btn > * + *
margin-left 0.4rem

View File

@@ -4,18 +4,24 @@
import * as api from "$lib/api";
// Mirrors the DEFAULTS in src/py/bbctrl/AuxAxis.py. The "enabled"
// flag is read-only here; toggling the W axis on/off is done via
// aux.json on disk, so adding/removing the hardware doesn't have a
// surprise UI that bricks bring-up.
// 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;
min_w: number;
max_w: 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;
@@ -26,15 +32,27 @@
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;
let saveMessage = "";
// 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() {
@@ -49,24 +67,33 @@
async function save() {
if (!cfg) return;
busy = true;
saveMessage = "";
try {
await api.PUT("aux/config/save", cfg);
saveMessage = "Saved.";
await refresh();
setTimeout(() => (saveMessage = ""), 3000);
} catch (e) {
console.error("Failed to save aux config:", e);
saveMessage = "Save failed - see console.";
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="w-axis-settings">
<div class="a-axis-settings">
{#if !cfg}
<p class="tip">Loading W axis configuration...</p>
<p class="tip">Loading A axis configuration...</p>
{:else}
<div class="status">
{#if status}
@@ -85,9 +112,9 @@
{/if}
</div>
<div class="pure-form pure-form-aligned">
<div class="pure-form pure-form-aligned" on:input={markDirty} on:change={markDirty}>
<fieldset>
<div class="pure-control-group" title="Enable the auxcnc W axis. Edit aux.json to toggle.">
<div class="pure-control-group" title="Enable the auxiliary axis (auxcnc-driven A). Edit aux.json to toggle.">
<label for="enabled">enabled</label>
<input id="enabled" type="checkbox" checked={cfg.enabled} disabled />
<label for="" class="units">(edit aux.json)</label>
@@ -106,13 +133,13 @@
<h3>Mechanics</h3>
<fieldset>
<div class="pure-control-group" title="Logical steps per mm of W travel.">
<div class="pure-control-group" title="Logical steps per mm of axis travel.">
<label for="steps_per_mm">steps per mm</label>
<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 W+ moves the wrong way.">
<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>
@@ -120,19 +147,78 @@
</select>
</div>
<div class="pure-control-group" title="Soft-limit minimum W in mm.">
<label for="min_w">soft min</label>
<input id="min_w" type="number" bind:value={cfg.min_w} step="any" />
<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 W in mm.">
<label for="max_w">soft max</label>
<input id="max_w" type="number" bind:value={cfg.max_w} step="any" />
<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="Informational max feed; rate caps live on the ESP.">
<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>
@@ -144,12 +230,12 @@
<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 W-)</option>
<option value="+">+ (toward W+)</option>
<option value="-">- (toward A-)</option>
<option value="+">+ (toward A+)</option>
</select>
</div>
<div class="pure-control-group" title="W position assigned when homing completes.">
<div class="pure-control-group" title="Axis position assigned when homing completes.">
<label for="home_position_mm">home position</label>
<input id="home_position_mm" type="number" bind:value={cfg.home_position_mm} step="any" />
<label for="" class="units">mm</label>
@@ -206,32 +292,20 @@
</div>
</fieldset>
<div class="actions">
<Button
touch
variant="raised"
on:click={save}
disabled={busy}
>
<Label>Save W Axis Settings</Label>
</Button>
{#if saveMessage}
<span class="save-msg">{saveMessage}</span>
{/if}
</div>
<div class="tip">
Changes are written to aux.json. Homing rates and the
limit polarity are pushed to the ESP immediately; any
running motion is unaffected. Re-home the W axis after
changing direction, sign, or step settings.
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">
.w-axis-settings {
.a-axis-settings {
.status {
margin-bottom: 1em;
font-size: 90%;

View File

@@ -2,7 +2,10 @@
import configTemplate from "../../../resources/config-template.json";
import ScreenRotationDialog from "$dialogs/ScreenRotationDialog.svelte";
import ConfigTemplatedInput from "./ConfigTemplatedInput.svelte";
import WAxisSettings from "./WAxisSettings.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 Button, { Label } from "@smui/button";
@@ -19,8 +22,8 @@
<h1>Settings</h1>
<div class="pure-form pure-form-aligned">
<h2>User Interface</h2>
<fieldset>
<h2 id="sec-display" data-sec="display">User Interface</h2>
<fieldset data-sec="display">
<div class="pure-control-group">
<label for="screen-rotation" />
<Button
@@ -46,8 +49,8 @@
</div> -->
</fieldset>
<h2>Units</h2>
<fieldset>
<h2 id="sec-units" data-sec="display">Units</h2>
<fieldset data-sec="display">
<ConfigTemplatedInput key={`settings.units`} />
<div class="tip">
Note, units sets both the machine default units and the units used in motor configuration. GCode program-start,
@@ -55,13 +58,13 @@
</div>
</fieldset>
<h2>Easy Adapter</h2>
<fieldset>
<h2 id="sec-easy-adapter" data-sec="display">Easy Adapter</h2>
<fieldset data-sec="display">
<ConfigTemplatedInput key={`settings.easy-adapter`} />
</fieldset>
<h2>Probing</h2>
<fieldset>
<h2 id="sec-probing" data-sec="probing">Probing</h2>
<fieldset data-sec="probing">
<ConfigTemplatedInput key={`settings.probing-prompts`} />
<div class="tip">
Onefinity highly recommends that you keep the safety prompts
@@ -88,20 +91,19 @@
{/each}
</fieldset>
<fieldset>
<h2>GCode</h2>
<fieldset data-sec="gcode">
<h2 id="sec-gcode" data-sec="gcode">GCode</h2>
{#each Object.keys(configTemplate.gcode) as key}
<ConfigTemplatedInput key={`gcode.${key}`} />
{/each}
</fieldset>
<h2 id="w-axis">W Axis (auxcnc)</h2>
<fieldset>
<WAxisSettings />
</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>Path Accuracy</h2>
<fieldset>
<h2 id="sec-path-accuracy" data-sec="gcode">Path Accuracy</h2>
<fieldset data-sec="gcode">
<ConfigTemplatedInput key={`settings.max-deviation`} />
<div class="tip">
@@ -124,8 +126,8 @@
</div>
</fieldset>
<h2>Cornering Speed (Advanced)</h2>
<fieldset>
<h2 id="sec-cornering" data-sec="gcode">Cornering Speed (Advanced)</h2>
<fieldset data-sec="gcode">
<ConfigTemplatedInput key={`settings.junction-accel`} />
<div class="tip">
Junction acceleration limits the cornering speed the planner

View File

@@ -51,7 +51,7 @@
>
<div slot="trailingIcon">
{#if valid}
<Icon class="fa fa-check-circle-o" style="color: green;" />
<Icon class="fa fa-circle-check" style="color: green;" />
{/if}
</div>
<HelperText persistent slot="helper">{helperText}</HelperText>

View File

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