Compare commits

53 Commits

Author SHA1 Message Date
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
3d73e6c59d install.sh: mask sysstat, replace dphys-swapfile with fstab entry
Round-3 cold-boot trims:
- mask sysstat.service (sadc CPU/IO logger; nothing reads it).
- mask dphys-swapfile.service and add /var/swap to /etc/fstab so swap
  is brought up by systemd at local-fs.target instead of by an LSB
  wrapper that re-checks the swap file size on every boot.

Both are reversible: `systemctl unmask <unit>` and remove the fstab
line. Before doing the dphys swap, install.sh verifies /var/swap
exists; on a fresh image where the file hasn't been created yet,
nothing is changed and dphys-swapfile keeps running normally.

Userspace boot 11.5s -> 10.7-11.4s on clean runs; bbctrl listen
unchanged at boot+10.4s (the saving moves to chromium/multi-user).
2026-05-01 10:31:40 +02:00
860ca30aba install.sh: ship cold-boot optimisations with firmware updates
Persist the cold-boot wins (was: only manually deployed via
tmp/20260501_restart_timing/deploy-fast.sh, would silently revert on
the next prod firmware update).

- Install bbserial-rebind.service alongside bbctrl.service and enable
  it. Eliminates the rc.local bbserial reload mid-boot.
- Prefer scripts/rc.local.fast over scripts/rc.local when present.
  Legacy rc.local left as a fallback for old firmware tarballs.
- Mask plymouth-read-write, plymouth-quit-wait, and raspi-config.
  Together these were ~6s of userspace startup that bought nothing
  on a deployed Onefinity Pi.

Cumulative: bbctrl listening at boot+10.6s (was 20.6s), userspace
boot 11.5s (was ~13s), bbctrl.service @2.9s in critical-chain (was
@6.5s after the first optimisation pass).
2026-05-01 10:15:35 +02:00
8e3b7a29e5 Cold-boot: 4 optimisations cutting bbctrl listen by ~8s on the Pi
Measured on onefinity.local (Pi 3, Raspbian Stretch, bbctrl 1.6.7).

Before -> after:
  bbctrl listen           boot+20.6s  ->  boot+12.4s   (-8.2s)
  host -> /api/config/load    28.2s   ->     22.5s    (-5.7s)

The 4 changes (each independently revertable):

1. scripts/bbserial-rebind.service: do the bbserial unbind + reload
   in a dedicated unit ordered Before=bbctrl.service, instead of in
   rc.local AFTER bbctrl is already listening on the serial port.
   Eliminates a full bbctrl restart mid-boot.

2. scripts/bbctrl.service: drop "After=network.target". bbctrl talks
   to the AVR on a local serial port and to the LCD on I2C; it does
   not need DHCP / network-online to come up. Also adds explicit
   ordering after the new bbserial-rebind unit.

3. scripts/rc.local.fast: trimmed rc.local that no longer touches
   bbserial and backgrounds 'startx' so chromium launches in
   parallel with bbctrl rather than after rc.local finishes.

4. src/py/bbctrl/Planner.py: lazy-import camotics.gplan. Costs ~130ms
   on cold cache, deferred from import-time to ctrl.mach init.

5. (bonus) src/py/bbctrl/Log.py: tolerate FileNotFoundError in
   _rotate(). The improved boot path exposed a pre-existing log
   rotator bug that crashed bbctrl on first start when bbctrl.log.16
   was missing.
2026-05-01 10:07:23 +02:00
420caf52be Trace: anchor events to kernel boot, mark first GET /
- Trace reads /proc/stat btime and /proc/uptime at import so every
  event in /api/diag/timing can be expressed as 'seconds since
  power-on' (uptime_at_anchor + ev.t).
- Web.StaticFileHandler.prepare emits 'web.first_root_get' the first
  time chromium hits / or /index.html, so we can see when the kiosk
  browser actually started loading the UI on cold boot.
2026-05-01 09:56:21 +02:00
561d2fd7ea Restart timing: bbctrl.Trace, /api/diag/timing, UI marks
Add a lightweight, self-contained phase tracer for measuring end-to-end
bbctrl restart and Pi boot time. Disabled by setting BBCTRL_TRACE=0.

- src/py/bbctrl/Trace.py: monotonic-anchored event log + sd_notify helper.
- bbctrl/__init__.py: marks for imports, args parsed, ioloop, web init,
  listen, and an sd_notify READY=1 once HTTP is bound.
- bbctrl/Ctrl.py: spans around each subsystem (avr, i2c, lcd, mach,
  preplanner, jog, pwr, hooks, aux, mach.connect).
- bbctrl/Comm.py: avr.firmware_rebooted mark.
- bbctrl/Web.py: TimingHandler (GET /api/diag/timing) and
  UITimingHandler (PUT /api/diag/timing/ui), plus a ws.first_open mark.
- src/js/restart-timing.js + app.js: UI-side performance.now() marks
  (script.load, ws.open, ws.first_msg, ui.first_state, window.load),
  posted once to the controller.
- scripts/bbctrl.service: stdout/stderr -> journal so TRACE lines are
  visible via journalctl -u bbctrl. (Was StandardOutput=null.)

Revert: git revert this commit. To disable at runtime without
reverting, set BBCTRL_TRACE=0 in the bbctrl service environment.
2026-05-01 09:48:10 +02:00
73c6a4f160 Macros: suppress placeholder color stripes (#dedede, #fff, ...)
The bbctrl controller seeds new macros with placeholder colors like
'#dedede' and '#ffffff'. Treating those as 'configured' lit up the
asymmetric 6 px left stripe on every default macro, which looked
lopsided.

Add control-view.has_macro_color() that filters out a fixed set of
default placeholders plus anything within the near-white band
(R+G+B > 690). The .has-color class and the inline border-left-color
style are gated on that helper, so unconfigured macros render as
clean symmetric slate tiles.

Tested live on http://10.1.10.55/#control with the existing macro
config (#dedede): button now renders without the gray stripe.
2026-05-01 08:18:28 +02:00
5926316a25 Fix: real-hardware bring-up issues found at 1920x1080 on the Pi
After testing the V09 redesign live on the Pi at onefinity.local
(1920x1080, Chrome fullscreen) several real bugs surfaced. This
commit fixes all of them.

Layout fits at 1920x1080
- Cap .app-shell at 100vh height with overflow:hidden so child
  flex containers actually constrain to one screen.
- Make .control-page / .program-page / .console-page use
  flex 1 1 auto + min-height 0 + overflow hidden so the page total
  no longer grows to ~36 000 px when the gcode-viewer is mounted.
- Override clusterize.css default max-height: 200px on the
  .clusterize-scroll element with max-height: none + flex 1 1 0 +
  height 100% so the gcode listing fills the available column.

E-Stop in the header
- The legacy estop.pug SVG had width=130 height=130 but no
  viewBox, so CSS-only sizing did nothing and the SVG content
  spilled ~26 px off the right edge of the screen and ~70 px
  below the header. Add viewBox="0 0 130 130" plus
  preserveAspectRatio so CSS sizing actually shrinks the inner
  geometry. Drop the octagonal clip-path (the SVG already
  carries its own yellow safety ring + EMERGENCY/STOP text).

3D toolpath preview (path-viewer)
- The legacy .path-viewer.small CSS clamped the canvas to
  340 x 150 floated into the corner. In the new program-body
  grid we want it to fill the 600 px right column. Override
  with width 100%, height auto, float none, !important.
- Make orbit.js wheel/touchstart/touchmove listeners
  {passive: false} so OrbitControls.preventDefault() actually
  works and the page no longer scrolls while panning the 3D
  view on a touch screen.

Vue 1 template + reactivity bugs exposed by the live data
- Replace v-else-if (Vue 1 has no v-else-if) in
  control-view.pug with three sibling v-if templates that
  mutually exclude on w.enabled and state['2an'] == 3.
- axis-vars._get_motor_id: guard motor.axis.toLowerCase()
  against undefined motors (initial config is [{}, {}, ...]).
- axis-vars._check_is_enabled: prefer config.motors[i].axis
  when present, fall back to state[N + 'an'] only for
  recognised axes (x/y/z/a) so undefined == undefined
  doesn't mistakenly enable b/c rows.
- program-mixin: tolerate state.files / state.gcode_list
  being undefined right after connect.

App-shell race conditions
- Skip the early parse_hash() in app.js ready() when the
  initial hash is in the settings family. Those Svelte
  components read settings.units / settings.probing-prompts /
  motion.* etc. and crash on first paint with the empty
  placeholder config. Stay on loading-view until update()
  completes and routes us in itself.

Misc
- src/static/js/ui.js: null-guard the legacy burger menu code
  (#menuLink no longer exists). Was throwing 'Cannot set
  properties of null (setting onclick)'.
- src/static/css/Audiowide.css: switch the gstatic font URL
  from http:// to https:// so it isn't blocked as mixed
  content under the home.muehe.org HTTPS proxy.
- Macro buttons: drop the default 6 px yellow border-left.
  The stripe now only appears via .has-color when
  state.macros[i].color is actually configured. Removes the
  asymmetric/lopsided look from the screenshot.

Tested live on http://10.1.10.55/ and via the HTTPS proxy at
https://onefinity.home.muehe.org/.
2026-04-30 22:24:55 +02:00
ea23f94b87 Settings rail: add W Axis entry; deploy scripts (local/hardware/prod)
UX
- The V09 redesign already exposed the W axis in the Control jog grid
  (row 4 when w.enabled) and as a row in the DRO table. The Settings
  shell now also surfaces a dedicated 'W Axis' rail entry that smooth-
  scrolls to the W Axis (auxcnc) section of the main settings page.
  The rail item is marked active only while the user is on Display &
  Units AND the W Axis link was the most recent click.
- The W Axis section in src/svelte-components/src/components/Settings
  View.svelte gets an id="w-axis" anchor so the scroll lands cleanly.

Tested live against onefinity.local. Aux status reports
{enabled: true, present: true, pos_mm: 43.96, homed: false}; the W
axis row appears in the DRO with the right purple styling, and the
jog row 4 shows W- / Home W / W+ / Probe.

Deploy scripts
- deploy.sh dispatches to scripts/deploy/{local,hardware,prod}.sh
  with shorthand wrappers (deploy-local.sh / deploy-hardware.sh /
  deploy-prod.sh).
- local: builds the UI bundle and serves build/http/ via
  python3 -m http.server 8770 in a tmux session 'onefin-local'.
  Useful for visual iteration on macOS — chrome only, no controller.
- hardware: rsyncs the freshly built build/http/ tree onto the Pi at
  onefinity.local and restarts bbctrl. Stages to /tmp on the Pi and
  uses sudo to install into the running egg's bbctrl/http directory,
  so iteration time is ~5 seconds.
- prod: requires a clean working tree, then runs 'make pkg' followed
  by 'make update HOST=onefinity.local PASSWORD=onefinity'.

Defaults can be overridden with environment variables (HOST, PASSWORD,
REMOTE_USER for the hardware path).
2026-04-30 21:45:17 +02:00
b8c4f53bb1 Merge branch 'hooks' into master
Brings in:
- W axis (auxcnc) integration via ESP32 over /dev/ttyUSB0, including
  the W axis settings panel, DRO row, jog row aligned with X/Y/Z, and
  collapsed home-only W controls.
- README + W axis docs covering macOS build/flash and the new UI.
- Build & flash docs for the Pi firmware (BUILD.md), including the
  cached gplan.so build via Docker (~30 min first time, 3 sec after).
- Hooks v2: external triggers during G-code execution that block
  unpause until the hook completes.
- V09 full UX redesign mock + implementation plan + mock variations.
- V09 implementation: new app shell with underline-ribbon tabs,
  Program / Console / Settings shells, V09 jog/macro palette, slim
  status pill replacing the old chip soup, and an octagonal STOP that
  wraps the existing <estop> SVG.
- Vue.config.async = false to fix sticky :class bindings under hash
  navigation.

# Conflicts:
#	.gitignore
2026-04-30 21:33:48 +02:00
32f3aca368 UX redesign V09: replace shell, split Program/Console/Settings
Implements the V09 mock end-to-end (per plans/2026-04-30_ux_redesign.md):

Top shell
- index.pug rebuilt around .app-shell with a slim 96px header.
- Underline-ribbon tab bar (Control / Program / Console / Settings)
  replaces the old side menu and the inline #tab1..#tab4 system.
- Single 'All systems' pill collapses the legacy WiFi/Camera/Rotary/
  IP/Version chip-soup into one popover (sys-popover) anchored to the
  header; rotary toggle, camera feed and shutdown live there.
- Octagonal 88x88px STOP button wraps the existing <estop> SVG; STATE
  pill with pulse-dot honors prefers-reduced-motion.

Routing
- app.js parse_hash maps every existing hash:
    #control                       -> Control
    #program / #program:auto       -> Program
    #console / #console:mdi|messages|indicators -> Console
    #settings, #admin-general,
    #admin-network, #motor:N, #tool, #io, #macros, #help,
    #cheat-sheet                  -> Settings (rail picks inner)
- All deep links are preserved.

Control panel (control-view.pug + .js)
- 720px jog grid + 4-axis DRO + 4 KPI cards + 8-macro row.
- Jog tiles use V09 flat slate (#3f4b63) with diagonal helpers and
  a ghost row for XY/Z origin shortcuts.
- Per-axis Settings/Set-zero/Home buttons grow to 72x72px.
- Status strip cards: State / Velocity-Feed / Spindle / Job. Tapping
  the Spindle card opens the new override-drawer with feed + spindle
  range inputs (resolved decision in plans/...).
- Macro row binds to state.macros.slice(0, 8); >8 lives in Settings.
- Drops the old <table> control-buttons, .info, .override and .tabs
  blocks entirely.

Program panel (program-view.pug + .js)
- Extracts the Auto bar, file selectors, gcode-viewer and path-viewer
  out of control-view.
- Action buttons (RUN/STOP/UPLOAD-FOLDER/UPLOAD-FILE/DOWNLOAD-FILE/
  DELETE) at 84px with explicit color affordances.
- Reuses control-view's existing methods via the new program-mixin.

Console panel (console-view.pug + .js)
- Three sub-tabs: MDI / Messages / Indicators. Sub-tab persists in the
  URL fragment (#console:messages etc.).
- MDI: terminal-style prompt + SEND, plus an 8-wide on-screen keypad
  (G0/G1/G2/G3/G28/G92/M3/M5 + axis letters + CLEAR/SEND).
- Messages: pulls from .messages_log (mirrored from
  state.messages); badge in the header tab counts unread.
- Indicators: mounts the existing <indicators> component.

Settings shell (settings-shell.pug + .js)
- New left rail navigator listing Display, Network, General/Firmware,
  Spindle&Tool, IO, Motors 0..3, Macros, Cheat Sheet, Help.
- Inner area mounts the existing settings family templates via an
  explicit v-if cascade (avoiding a Vue 1 :is reactivity quirk).
- Shutdown / Save buttons relocated from the dropped side menu.

JS plumbing
- main.js: Vue.config.async = false to keep dependent watchers in
  sync when reactive data is mutated outside Vue's normal event loop
  (e.g. from a hashchange listener).
- program-mixin.js extracted so control-view.js no longer carries the
  file/macro/gcode methods that are now Program-only.
- control-view.js trimmed to jog/DRO/probe/home logic.
- console-view.js / settings-shell-view.js use a hashchange listener
  + local data props because Vue 1 cannot reliably observe
  .sub_tab from a child component.

Stylus rewrite
- Removes the old .header (140px), .nav-header, .brand subtree, #menu,
  #main, .control-view block, .info, .override, .toolbar, .macros-div,
  .macros-button, the .tabs > input radio-tab system and the .control-
  view #control media-query overrides. None of these are referenced
  any more.
- Adds V09 tokens (jog/macro palette + accent + line/card colors) at
  the top, the new shell rules, .ktab / .sys-btn / .state-badge /
  .estop chrome, the .control-page grid, status strip + override
  drawer, .program-page action / file bars and program body,
  .console-page MDI keypad / messages / indicators panes, and the
  .settings-shell rail.
- Adds a 1820px breakpoint that stacks the right column under the jog
  on smaller portable monitors.

Hard cut: no config.ui.layout flag, the old shell is removed in this
single commit. side-menu.css is no longer included from index.pug.

Tested locally with agent-browser (1920x1080) on every top tab and
every settings sub-route; routing, active tab highlighting and inner
view selection all work without a controller connection.
2026-04-30 21:27:00 +02:00
081209decf Plan: resolve open questions (hard cut, macros slice, override drawer, defer pin)
Replaces the 'Open questions' section with 'Resolved decisions' and
propagates the four decisions into the relevant phases:

- Hard cut: no config.ui.layout flag. Phase 6 now includes the
  removal of .nav-header, side-menu.css and the #tab1..#tab4 block
  with a git grep verification step.
- Macros: Control row binds to config.macros.slice(0, 8); Settings
  -> Macros owns the master list and reordering.
- Pin to Control: deferred, status strip stays at State / V&F /
  Spindle / Job for this iteration.
- Feed/spindle override: bottom drawer triggered by the Spindle
  KPI tile, reusing override_feed / override_speed.

Goals (s.1) and Phase 6 testing checklist updated to match.
2026-04-30 20:43:29 +02:00
ef4658aaf6 Plan: V09 full UX redesign mock + implementation plan
- docs/mocks/v09_full_ux.html — high-fidelity 1920x1080 mock
  showing the proposed Control / Program / Console / Settings tab
  layout with the V09 flat slate jog/macro palette and an underline
  ribbon header tab style.
- plans/2026-04-30_ux_redesign.md — phased implementation plan to
  port index.pug + control-view.pug to the new shell while keeping
  hash routing and existing settings/admin views intact.
2026-04-30 20:00:03 +02:00
ef78f20eaa Docs: README + W axis docs cover macOS build/flash and new UI
- README.md (was a one-liner): describe the layout, the macOS quick
  path including the esbuild platform-pin gotcha, and how to flash
  with curl or 'make update'.
- docs/AUX_W_AXIS.md: document the new Control jog row layout, the
  Settings 'W Axis (auxcnc)' section, and list the additional UI
  files touched by this fork.
2026-04-30 19:54:30 +02:00
36829020a5 Settings: add W axis (auxcnc) panel
Expose the aux.json fields under a new 'W Axis (auxcnc)' section in
Settings: serial port/baud, mechanics (steps/mm, dir sign, soft limits,
max feed), homing (direction, position, fast/slow seek, backoff, max
travel, limit polarity) and the step profile (max/start rate, accel).

The 'enabled' flag stays read-only in the UI; flipping the W axis
on/off is still done via aux.json so a fresh install can't surprise the
user with hardware that isn't there. Live status (offline / unhomed /
homed at <pos> mm) is shown above the form.

Saving PUTs the merged config to /api/aux/config/save, which writes
aux.json and pushes the homing/step config to the ESP.
2026-04-30 19:10:24 +02:00
2413fc49ab UI: collapse W axis to home-only (drop set-zero / W-origin)
The W axis homing already drives toward the configured limit (home_dir
in aux.json, default '-') and lands at home_position_mm = 0, so
'home' and 'zero' are the same point. Remove the now-redundant 'W
Origin' (move-to-zero) and 'Set W to 0' map-marker buttons; keep just
W-, W+, and a single Home W button. Also drop the unused
aux_move_zero / aux_set_zero JS handlers.
2026-04-30 19:07:17 +02:00
Claude
7d5949f5fc UI: add W jog row (W- / W Origin / W+ / Home W) under the XYZ jog grid
Mirrors the 4-column rotary A row that appears when 2an==3, so the same
fine/small/medium/large increment selector that drives XYZ jogging now
also drives W jogging. New control-view methods:

- aux_jog_incr(sign) - PUTs aux/jog with the current jog_incr amount
  converted to mm (handles imperial display units)
- aux_move_zero() - PUTs aux/move {mm:0}, the absolute counterpart to
  aux_set_zero (which redefines the current pos as zero without moving)

Row is hidden when w.enabled is false, so users without the auxcnc
controller see no change.
2026-04-30 17:37:49 +02:00
Claude
23f22105a8 UI: align W axis marker/home buttons with the X/Y/Z columns
The xyzabc rows have three actions (set-position cog, zero marker, home),
W only has two. Without a placeholder the W buttons render in the left two
slots of the actions cell, leaving the home button unaligned with the home
column above. Added a hidden disabled cog button so the marker and home
buttons sit under the same columns as the rest.
2026-04-30 17:23:55 +02:00
Claude
4f74e75d44 UI: render W (aux) axis row in the main DRO
Adds the auxcnc W axis to the front-page Position table:

- axis-vars.js exposes a 'w' computed property fed by state.aux_pos /
  aux_enabled / aux_homed / aux_present (set by AuxAxis on the host).
  No motor mapping, no soft-limit warnings - the aux controller does
  its own bounds.
- control-view.pug adds a W row after the xyzabc loop. The Set/Zero
  button calls /api/aux/set-zero {mm:0} and the Home button calls
  /api/aux/home, which hit the new endpoints exposed by Web.py.
- control-view.js: aux_home(), aux_set_zero(), and aux_jog() helpers.

When aux_enabled is false (no aux.json or aux.json has enabled=false)
the row stays hidden, matching the existing axis-row behavior.
2026-04-30 17:10:00 +02:00
Claude
c7cf9483b3 Add W axis integration via auxcnc ESP32 over /dev/ttyUSB0
Rather than rebuild gplan + the AVR firmware to add a true 7th axis,
we treat W as a synchronous out-of-band axis that moves between G-code
blocks. The pipeline:

  upload -> AuxPreprocessor rewrites W tokens into (MSG,HOOK:aux:N)
  comments -> planner sees only XYZ + messages -> Hooks fires the
  registered internal handler -> AuxAxis sends STEPS/HOME over serial
  to the ESP and blocks the planner until done.

New files:
  src/py/bbctrl/AuxAxis.py       serial worker + RPC layer
  src/py/bbctrl/AuxPreprocessor.py  G-code rewriter
  docs/AUX_W_AXIS.md             design + ops notes

Changed:
  Hooks.py        register_internal(); fix the (MSG,HOOK:...) listener
                  to read the 'messages' state list (was broken before)
  Ctrl.py         instantiate AuxAxis, register aux/aux_rel/aux_home/
                  aux_setzero hooks
  FileHandler.py  rewrite uploads in place when they use W
  Mach.py         rewrite W tokens in MDI input the same way
  Web.py          REST endpoints under /api/aux/*

The ESP firmware in ../auxcnc was extended in lockstep: HOME, HOMECFG
(NVS-persisted), WPOS, HOMED?, LIMIT?, abortable STEPS with
limit-aware abort, trapezoidal ramps, deterministic [topic] reply
tokens, [boot] banner.

Real-time decisions (limit switch, step pulses) live on the ESP. The
host owns mm units, soft limits, and aux_homed bookkeeping. ESP
reboot mid-job clears aux_homed and surfaces a message; per design
manual jogs are still allowed without homing.
2026-04-30 16:51:24 +02:00
54a15f9d12 Rewrite BUILD.md: clean up, add quick start, remove dead weight
- Quick start section at the top (3 commands)
- Removed inline Bullseye build recipe (moved to 'why not' appendix)
- Added build time estimates
- Cleaner table formatting
- gplan.so contents documented (cbang + camotics)
2026-04-30 16:39:57 +02:00
704bc8d35c gplan.so: build from source using Raspbian Stretch Docker
Use balenalib/raspberry-pi-debian:stretch with legacy.raspbian.org repos.
Exact match: GCC 6.3, Python 3.5, GLIBC 2.24 — identical to the Pi.
First build ~25min (QEMU), subsequent builds ~1sec (cached image).

Replaces the broken Bullseye approach that had GLIBC/GLIBCXX mismatches.
2026-04-30 16:33:20 +02:00
4d2d5fd88c Update BUILD.md: gplan.so can't be built from source on Bullseye
Document GLIBC/GLIBCXX version constraints and Python 3.5 compat notes.
Recommend using official release gplan.so instead.
2026-04-30 15:57:31 +02:00
eab204b7be Fix Python 3.5 compat: capture_output and text= not available
Use stdout=PIPE/stderr=PIPE and manual .decode() instead.
Use official 1.6.6 gplan.so (built with Stretch-era GCC, no GLIBC_2.29 dep).
2026-04-30 15:56:42 +02:00
e3c059eb9b Add cached gplan.so build: 30min first time, 3sec after
- Dockerfile.gplan: pre-built armv7 image with cbang + camotics objects
- build-gplan.sh: relinks against Python 3.5m in ~3sec
- Pi Python 3.5 headers cached in .pi/pi-python35.tar.gz (gitignored)
2026-04-30 14:43:05 +02:00
7306464440 Document gplan.so build-from-source procedure
Build in armv7 QEMU Docker, compile with Python 3.9 SCons,
relink final .so against Python 3.5m from the Pi.
2026-04-30 13:52:58 +02:00
1625b768d8 Add build/flash/backup documentation for Pi firmware 2026-04-30 12:09:12 +02:00
5be7515a92 Fix gplan.so: use armv7 binary from official 1.6.6 release
The gplan.so (CAMotics G-code planner) must be a 32-bit ARM binary
matching the Pi's Python 3.5. Source it from the official release
package rather than cross-compiling (SCons ignores CC/CXX overrides).

Also revert install.sh gplan.so preservation logic — simpler to just
ship the correct binary in the package.
2026-04-30 11:36:09 +02:00
b10a6d537e Add SD card backup/restore script
Streams raw dd from Pi over SSH, compresses locally with gzip.
Supports backup, restore (local SD card or remote Pi), and verify.
2026-04-30 11:17:03 +02:00
7d0755c55b Hooks v2: block unpause until hook completes
- Blocking hooks (block_unpause: true, default for tool-change) run
  in a background thread and gate Mach.unpause() via can_unpause()
- Machine stays in HOLDING state while hook runs — AVR steppers idle,
  spindle state preserved, position locked
- auto_resume option to unpause automatically after hook completes
- E-stop cancels any running hook immediately
- Hook status pushed to frontend via state (hook_busy, hook_event)
- GET /api/hooks/status endpoint for polling
- Non-blocking hooks (program-start, program-end, etc.) fire-and-forget
2026-04-21 08:10:07 +02:00
7f8fd23615 Add hooks system for external triggers during G-code execution
- New Hooks module (src/py/bbctrl/Hooks.py) that watches controller state
  and fires webhooks or scripts on events:
  - tool-change (M6), program-start, program-end, pause, estop,
    homing-start, homing-end, custom (via MSG comments)
- API endpoints:
  - GET /api/hooks - get current hook config
  - PUT /api/hooks/save - save hook config
  - PUT /api/hooks/fire/<event> - manually fire a hook (for testing)
- Hook config stored in hooks.json with two types:
  - webhook: HTTP POST/PUT to external URL with JSON context
  - script: run local command with env vars (HOOK_OLD_TOOL, etc.)
- Fix tornado.web.asynchronous deprecation in Camera.py
- Wired into Ctrl initialization and state listener system
2026-04-20 17:43:02 +02:00
33 changed files with 1362 additions and 1846 deletions

View File

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

View File

@@ -1,67 +1,6 @@
OneFinity CNC Controller Firmware Changelog
===========================================
## Unreleased (community fork)
General-use additions on top of upstream OneFinity firmware.
### UI
- V09 redesign: 4-tab top header (Control / Program / Console /
Settings) replaces the legacy side menu.
- Control: redesigned DRO with per-axis offset + zero + home
actions, jog grid with consistent button sizing across kiosk
and tablet, status strip with live state / velocity / spindle.
- Program: dedicated tab for run / pause / stop, file browser,
toolpath preview.
- Console: MDI shell, message log, indicators.
- Settings: rail-driven inner pages so each section is its own
focused panel rather than one long scroll.
- Tablet mode (`?tablet=1`) pins the UI to 1920x1080 and scales
it to fit the actual viewport.
- Kiosk mode (`?kiosk=1`, auto on localhost): tighter layout for
the controller's onboard 1366x768 screen.
- Font Awesome 6 throughout (replaces FA4).
- Fix: stop clobbering motor settings while the user is editing
them.
- Fix: keep jog grid visible during jog/home/probe/MDI activity.
- Fix: opaque dark canvas for path-viewer (no flash through page
background).
- Fix: OrbitControls now uses non-passive wheel/touch listeners so
it can suppress page panning while interacting with the 3D
viewer.
- Fix: macros tab no longer renders placeholder color stripes for
`#dedede`/`#fff`-only macros.
- Fix: hide the X cursor in kiosk mode (touchscreen).
- Fix: chromium 72 mime + flex-gap fallbacks (some kiosk Pis ship
with that older browser build).
- Fix: Vue 1 async batching disabled so reactive writes from
`hashchange` listeners propagate synchronously.
### Boot / install
- Cold-boot optimisations cutting bbctrl listen latency by ~8s on
the Pi (mask sysstat, replace dphys-swapfile with an fstab swap
entry, lazy-load `camotics.gplan`, `bbserial-rebind.service`
with explicit `Before=bbctrl.service`).
- `install.sh` now ships these with firmware updates.
- `bbctrl.Trace` + `/api/diag/timing` for measuring startup, with
a UI-side `restart-timing.js` client that POSTs browser marks.
- `Camera.py` switched from deprecated `@web.asynchronous` to
`async def` so the streaming endpoint works on newer Tornado.
- `Log.py` tolerates missing rotated log files on startup
(concurrent logrotate runs from `/etc/cron.reboot` no longer
crash bbctrl).
### Build / tooling
- `.pi/BUILD.md`: end-to-end macOS dev workflow, deploy paths,
troubleshooting.
- `.pi/Dockerfile.gplan` + `build-gplan.sh`: rebuild `gplan.so`
from source on Raspbian Stretch (Bullseye is too new).
- `deploy.sh` dispatcher with `local`, `hardware`, `prod` modes.
- `backup/onefinity-backup.sh`: dd-based whole-card backup/restore
with shrink/expand support.
- `Makefile`: ensure trailing newlines between concatenated pug
templates so Pug doesn't glue file boundaries together.
## v1.0.8
- Fixed chatter and lost steps issues (most commonly seen by Fusion users), re-enabled support for G61, G61.1, G64.
- Fixed 3d preview on Safari-based web browsers (MacOS & iOS)

View File

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

View File

@@ -1,18 +1,6 @@
# A axis (auxcnc) integration
# W axis (auxcnc) integration
> **Note:** This document describes the original out-of-band W-axis
> architecture (gcode preprocessor rewriting W tokens into HOOK
> messages dispatched between blocks). The current implementation
> integrates the auxcnc-driven stepper as a *virtual A axis* through
> gplan via a synthetic motor (`bbctrl/ExternalAxis.py`), so A is
> blended with XYZ in the same S-curve plan and the gcode surface
> below applies as plain `A` words.
>
> The HOOK pipeline still exists for ATC pneumatic atoms (M100 EJECT,
> M102 RELEASE, M103 CLAMP) - see `bbctrl/AuxPreprocessor.py`. Macros
> compose drop/grab tool sequences from those atoms.
This adds a virtual `A` axis to the bbctrl controller, driven by the
This adds a virtual `W` axis to the bbctrl controller, driven by the
auxcnc ESP32 over USB serial (`/dev/ttyUSB0`). The ESP owns step-pulse
generation, real-time limit-switch monitoring, and the homing dance.
The Pi owns units (mm), soft limits, sequencing inside G-code jobs, and
@@ -22,15 +10,15 @@ a small REST API for jogging / homing from the UI.
The bbctrl planner (gplan) only understands `xyzabc`, so adding a true
7th axis would require rebuilding gplan + the AVR firmware. We avoid
that by treating W as a synchronous out-of-band axis: A moves run
that by treating W as a synchronous out-of-band axis: W moves run
*between* G-code blocks, not blended with XYZ.
Pipeline:
1. User uploads a G-code file containing `A` words.
1. User uploads a G-code file containing `W` words.
2. `FileHandler` runs `AuxPreprocessor` on the upload, rewriting W
tokens in place into `(MSG,HOOK:aux:<mm>)` etc. The original line
minus the A word continues to drive XYZ.
minus the W word continues to drive XYZ.
3. The planner sees only XYZ + message comments. When it reaches a
message line, the message goes through `state.add_message` which
`Hooks._on_state_change` watches for the `HOOK:` prefix.
@@ -38,25 +26,25 @@ Pipeline:
for the event name (`aux`, `aux_rel`, `aux_home`, `aux_setzero`).
5. The handler runs in a hook thread, gating `Mach.unpause` until done.
While the handler is busy the machine is in HOLDING - no XYZ motion
can resume until A finishes.
can resume until W finishes.
6. The handler talks to the ESP over `/dev/ttyUSB0` via `AuxAxis`,
blocking on a deterministic reply token (`[step] done`, `[home]
done`, etc).
MDI commands containing `A` words are rewritten the same way at the
MDI commands containing `W` words are rewritten the same way at the
`Mach.mdi()` boundary so manual jog and macros work too.
## G-code surface
```gcode
G21 G90
G28 A0 ; home A axis
G1 A25 F300 ; move A to 25 mm absolute
G1 X100 W12.5 ; mixed: A moves first, then XYZ (configurable)
G28 W0 ; home W axis
G1 W25 F300 ; move W to 25 mm absolute
G1 X100 W12.5 ; mixed: W moves first, then XYZ (configurable)
G91
G1 A-2.5 ; relative A move
G1 W-2.5 ; relative W move
G90
G92 A0 ; set current A as zero (G92-style)
G92 W0 ; set current W as zero (G92-style)
```
Rules:
@@ -117,18 +105,18 @@ limit switch when the axis isn't homed yet).
**Control view**
- A jog row appears under the XYZ jog grid when `aux_enabled` is true,
with three buttons: `A-`, `A+`, and a wide `Home W`. There is
with three buttons: `W-`, `W+`, and a wide `Home W`. There is
intentionally no separate "set zero" or "W origin" button - homing
lands the axis at `home_position_mm` (0 by default), so home and
zero are the same point.
- The DRO table shows a A axis row with position, status (OFFLINE /
- The DRO table shows a W axis row with position, status (OFFLINE /
UNHOMED / HOMED), and a single Home button in the actions column
(the cog and map-marker columns are placeholders for layout).
**Settings view**
A "W Axis (auxcnc)" section exposes every aux.json field except
`enabled` (which stays read-only - flipping the A axis on/off requires
`enabled` (which stays read-only - flipping the W axis on/off requires
editingaux.json on the controller, so a fresh install can't surprise
the user with hardware that isn't there). Saving PUTs the merged
config to `/api/aux/config/save`, which writes aux.json and pushes
@@ -147,19 +135,19 @@ These are pushed via `state.set` and visible in the websocket stream:
## Edge cases
- **ESP reboots mid-job**: `[boot] auxcnc v=N` banner -> `aux_homed`
cleared, message added: "A axis controller restarted - re-home
before use". Subsequent A moves still run; if you want a hard fail
cleared, message added: "W axis controller restarted - re-home
before use". Subsequent W moves still run; if you want a hard fail
instead, that's a one-line change in `_require_present`.
- **Limit switch closed at boot of HOME**: `[home] failed
reason=already_at_limit` -> hook raises -> Mach surfaces error.
- **Pause mid-W-move**: the hook is blocking, so feed-hold takes
effect *after* the A move completes. For an immediate stop hit
effect *after* the W move completes. For an immediate stop hit
estop; the Hooks listener will call `aux.abort()` which sends
`ABORT\n` to the ESP and the step-pulse loop exits.
- **Connection loss**: if `/dev/ttyUSB0` can't be opened at startup,
`aux_present=False` and any G-code with W will fail-fast at the
hook handler with "Aux axis not connected".
- **No home enforcement**: per design, manual jogs and A moves are
- **No home enforcement**: per design, manual jogs and W moves are
allowed even without a successful home. Soft limits still apply
unless you use the raw step jog endpoint.

900
docs/mocks/v09_full_ux.html Normal file
View File

@@ -0,0 +1,900 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Onefinity · V09 · Full UX</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@6.5.2/css/all.min.css" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@500;700&display=swap" rel="stylesheet">
<style>
*{box-sizing:border-box}
html,body{margin:0;font-family:'Inter',system-ui,sans-serif;background:#0f172a;color:#e5e7eb}
.mono{font-family:'JetBrains Mono',monospace}
/* ---------- HOST CHROME ---------- */
.host{min-height:100vh;display:flex;flex-direction:column;background:radial-gradient(circle at 30% 0%,#374151,#0f172a 60%);}
.topbar{display:flex;align-items:center;gap:.6rem;flex-wrap:wrap;padding:.7rem 1rem;background:rgba(255,255,255,.04);border-bottom:1px solid rgba(255,255,255,.08);position:sticky;top:0;z-index:50;backdrop-filter:blur(10px);}
.topbar .brand{display:flex;align-items:center;gap:.5rem;font-weight:800;color:#fff}
.stripe-logo-sm{background:repeating-linear-gradient(135deg,#a7c7a3 0 6px,transparent 6px 14px);width:26px;height:26px;border-radius:6px}
.pill{padding:.3rem .65rem;border-radius:9999px;font-size:.75rem;font-weight:700;background:rgba(255,255,255,.08);color:#cbd5e1}
.seg-host{display:inline-flex;background:rgba(255,255,255,.05);border-radius:9999px;padding:3px;gap:3px}
.seg-host button{padding:.4rem .85rem;border-radius:9999px;font-size:.78rem;font-weight:700;color:#cbd5e1}
.seg-host button.on{background:#fde047;color:#0f172a}
.toggle{display:inline-flex;align-items:center;gap:.4rem;padding:.4rem .7rem;border-radius:8px;background:rgba(255,255,255,.08);font-size:.75rem;font-weight:600;color:#e5e7eb;cursor:pointer}
.toggle.on{background:#22c55e;color:#0b1220}
.stage{flex:1;display:flex;align-items:flex-start;justify-content:center;padding:1rem;overflow:auto}
.scaler-viewport{position:relative;flex:0 0 auto}
.scaler{position:absolute;top:0;left:0;width:1920px;height:auto;transform-origin:top left;transition:transform .2s}
/* ---------- KIOSK (1920x1080) ---------- */
.kiosk{
width:1920px;height:1080px;overflow:hidden;border-radius:14px;position:relative;
box-shadow:0 30px 60px rgba(0,0,0,.5);
display:flex;flex-direction:column;
background:#ffffff;color:#0f172a;
}
/* Header */
.head{
flex:0 0 96px;height:96px;
display:flex;align-items:center;gap:18px;
padding:0 24px;background:#ffffff;border-bottom:1px solid #e5e7eb;
}
.brand-blk{display:flex;align-items:center;gap:14px}
.menu-btn{width:54px;height:54px;border-radius:12px;background:#f1f5f9;border:1px solid #e2e8f0;color:#0f172a;display:inline-flex;align-items:center;justify-content:center;font-size:1.1rem}
.menu-btn:hover{background:#e2e8f0}
.brand-logo{width:42px;height:42px;border-radius:8px;background:repeating-linear-gradient(135deg,#a7c7a3 0 6px,transparent 6px 14px)}
.brand-name{font-weight:900;font-size:22px;letter-spacing:-.01em}
/* Underline-ribbon tab style (V02) */
.kiosk-tabs{display:inline-flex;gap:0;margin-right:auto;padding-left:18px;align-items:stretch;height:96px}
.ktab{
position:relative;
height:96px;padding:0 26px;
background:transparent;border:none;border-radius:0;
color:#475569;font-size:1.05rem;font-weight:700;
display:inline-flex;align-items:center;gap:.55rem;cursor:pointer;
transition:color .15s;
}
.ktab i{font-size:1.1rem;color:#94a3b8;transition:color .15s}
.ktab:hover{color:#0f172a}
.ktab:hover i{color:#475569}
.ktab.active{color:#0f172a}
.ktab.active i{color:#0f172a}
.ktab.active::after{
content:"";position:absolute;left:14px;right:14px;bottom:0;
height:5px;background:#fde047;border-radius:5px 5px 0 0;
}
.ktab .ktab-badge{background:#fee2e2;color:#991b1b;font-size:.7rem;padding:3px 8px;border-radius:9999px;font-weight:800;line-height:1}
.ktab.active .ktab-badge{background:#fde047;color:#0f172a}
.sys-btn{display:inline-flex;align-items:center;gap:.55rem;height:54px;padding:0 1.1rem;border-radius:14px;background:#f1f5f9;border:1px solid #e2e8f0;color:#0f172a;font-size:.9rem;font-weight:600}
.sys-btn .pip{width:9px;height:9px;border-radius:9999px;background:#22c55e}
.state-badge{display:inline-flex;align-items:center;gap:.6rem;height:54px;padding:0 1.1rem;border-radius:14px;background:#dcfce7;color:#166534;font-weight:800;font-size:1rem;letter-spacing:.04em}
.state-badge .dot{width:10px;height:10px;border-radius:9999px;background:currentColor;position:relative}
.state-badge .dot::after{content:"";position:absolute;inset:-3px;border-radius:9999px;border:2px solid currentColor;opacity:.5;animation:pls 1.6s ease-out infinite}
@keyframes pls{0%{transform:scale(.7);opacity:.6}100%{transform:scale(2.2);opacity:0}}
.estop{
width:88px;height:88px;background:#dc2626;color:#fff;font-weight:900;
clip-path:polygon(30% 0,70% 0,100% 30%,100% 70%,70% 100%,30% 100%,0 70%,0 30%);
display:flex;align-items:center;justify-content:center;
border:3px solid #fff;box-shadow:0 0 0 3px #b91c1c, 0 8px 20px rgba(220,38,38,.35);font-size:1rem;letter-spacing:.05em
}
/* Body */
.body{flex:1;display:flex;flex-direction:column;background:#f1f5f9;min-height:0}
.panel{display:none;flex:1;min-height:0;flex-direction:column;padding:18px;gap:14px}
.panel.active{display:flex}
/* ----------------------- V09 jog/macro palette ----------------------- */
/* Flat soft slate, no shadow */
:root{
--jog-bg:#3f4b63;
--jog-hover:#4a5777;
--jog-dir-bg:#5b6885;
--jog-dir-hover:#6a779a;
--jog-ghost-bg:#8c97ad;
--jog-ghost-hover:#9ba6bb;
--jog-ink:#fff;
--jog-ghost-ink:#0f172a;
}
/* JOG */
.jog-card{background:#fff;border:1px solid #e5e7eb;border-radius:18px;display:flex;flex-direction:column;padding:18px;min-height:0}
.jog-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:14px}
.jog-title{font-size:18px;font-weight:700;color:#0f172a}
.jog-title .step{color:#0ea5e9;font-family:'JetBrains Mono',monospace}
.step-seg{display:inline-flex;background:#f1f5f9;border:1px solid #e2e8f0;border-radius:14px;padding:4px}
.step-seg button{height:48px;min-width:64px;padding:0 1rem;border-radius:11px;font-size:1rem;font-weight:800;color:#475569;cursor:pointer}
.step-seg button.active{background:#0f172a;color:#fde047}
.jog-grid{display:grid;grid-template-columns:repeat(4,1fr);grid-template-rows:repeat(4,1fr);gap:10px;flex:1;min-height:0}
.jbtn{
border-radius:16px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:4px;
user-select:none;-webkit-tap-highlight-color:transparent;cursor:pointer;
font-weight:700;font-size:1.05rem;border:none;
transition:transform .06s, background .15s;
background:var(--jog-bg);color:var(--jog-ink);
}
.jbtn:hover{background:var(--jog-hover)}
.jbtn:active{transform:scale(.97)}
.jbtn .ico{font-size:1.6rem}
.jbtn .lbl{font-size:.8rem;color:inherit;opacity:.85;font-weight:600}
.jbtn.dir{background:var(--jog-dir-bg)} .jbtn.dir:hover{background:var(--jog-dir-hover)}
.jbtn.ghost{background:var(--jog-ghost-bg);color:var(--jog-ghost-ink)} .jbtn.ghost:hover{background:var(--jog-ghost-hover)}
/* DRO + STATUS */
.control-grid{display:grid;grid-template-columns:720px 1fr;gap:18px;flex:1;min-height:0}
.right-col{display:grid;grid-template-rows:1fr 158px;gap:18px;min-height:0}
.dro-card{background:#fff;border:1px solid #e5e7eb;border-radius:18px;overflow:hidden;display:flex;flex-direction:column}
.dro-head{display:grid;grid-template-columns:84px 1.4fr 1fr 1fr 170px 170px 280px;column-gap:.75rem;align-items:center;padding:14px 22px;background:#f8fafc;border-bottom:1px solid #e5e7eb;font-size:.78rem;font-weight:800;text-transform:uppercase;letter-spacing:.1em;color:#94a3b8}
.dro-row{display:grid;grid-template-columns:84px 1.4fr 1fr 1fr 170px 170px 280px;column-gap:.75rem;align-items:center;padding:14px 22px;border-bottom:1px solid #f1f5f9;flex:1;min-height:0}
.dro-row:last-child{border-bottom:none}
.dro-axis{font-weight:900;font-size:46px;line-height:1}
.dro-pos{font-family:'JetBrains Mono',monospace;font-size:36px;font-weight:800}
.dro-pos .u{font-size:14px;color:#94a3b8;font-weight:500;margin-left:6px}
.dro-sec{font-family:'JetBrains Mono',monospace;font-size:18px;color:#64748b;font-weight:600}
.axis-x{color:#dc2626} .axis-y{color:#16a34a} .axis-z{color:#2563eb} .axis-w{color:#7c3aed}
.chip{display:inline-flex;align-items:center;gap:.4rem;padding:.4rem .7rem;border-radius:9999px;font-size:.78rem;font-weight:700}
.chip-green{background:#dcfce7;color:#166534}
.chip-amber{background:#fef3c7;color:#92400e}
.chip-red{background:#fee2e2;color:#991b1b}
.chip-slate{background:#e2e8f0;color:#334155}
.chip-blue{background:#dbeafe;color:#1e40af}
.icon-btn{
width:72px;height:72px;border-radius:14px;cursor:pointer;
display:inline-flex;align-items:center;justify-content:center;
color:#334155;background:#f1f5f9;border:1px solid #e2e8f0;
font-size:1.45rem
}
.icon-btn:hover{background:#e2e8f0}
.actions-cell{display:flex;justify-content:flex-end;gap:10px}
.z-highlight{background:rgba(254,243,199,.4)}
.status-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:18px;min-height:0}
.stat-card{background:#fff;border:1px solid #e5e7eb;border-radius:18px;padding:18px 22px;display:flex;flex-direction:column;justify-content:center}
.stat-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.14em;color:#94a3b8}
.stat-val{font-family:'JetBrains Mono',monospace;font-size:30px;font-weight:800;margin-top:6px}
.stat-val.ok{color:#166534}
.stat-sub{font-size:13px;color:#64748b;margin-top:2px}
/* MACROS */
.macro-row{display:grid;grid-template-columns:repeat(8,1fr);gap:12px;flex:0 0 auto}
.macro-btn{
height:84px;border-radius:14px;border:none;cursor:pointer;
color:#fff;background:#3f4b63;
font-weight:800;font-size:1rem;
display:flex;align-items:center;justify-content:center;gap:.6rem;
transition:transform .06s, background .15s
}
.macro-btn:hover{background:#4a5777}
.macro-btn:active{transform:translateY(2px)}
.macro-btn .mnum{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:8px;background:#fde047;color:#0f172a;font-size:.85rem;font-weight:900}
.macro-btn .micon{font-size:1.1rem;opacity:.75}
/* =============================================================
PROGRAM PANEL
============================================================= */
.program-card{background:#fff;border:1px solid #e5e7eb;border-radius:18px;display:flex;flex-direction:column;flex:1;min-height:0;overflow:hidden}
.ptab-bar{display:flex;align-items:center;gap:6px;border-bottom:1px solid #e5e7eb;flex:0 0 auto;background:#fff;padding:0 18px}
.ptab{height:60px;padding:0 22px;font-weight:700;color:#64748b;border-bottom:3px solid transparent;font-size:1rem;display:inline-flex;align-items:center;gap:.5rem;cursor:pointer}
.ptab:hover{color:#0f172a}
.ptab.active{color:#0f172a;border-bottom-color:#0f172a}
.ptab .ptab-badge{background:#fde047;color:#0f172a;font-size:.7rem;padding:2px 7px;border-radius:9999px;font-weight:900}
.action-bar{display:flex;align-items:center;gap:12px;padding:18px;flex-wrap:wrap;border-bottom:1px solid #f1f5f9}
.action-btn{height:84px;padding:0 24px;border-radius:14px;background:#3f4b63;color:#fff;border:none;cursor:pointer;display:inline-flex;flex-direction:column;align-items:center;justify-content:center;gap:4px;font-weight:800;font-size:.9rem;letter-spacing:.04em;transition:background .15s}
.action-btn:hover{background:#4a5777}
.action-btn .ico{font-size:1.4rem}
.action-btn.run{background:#16a34a}
.action-btn.run:hover{background:#15803d}
.action-btn.stop{background:#0f172a}
.action-btn.stop:hover{background:#1e293b}
.action-btn.danger{background:#fee2e2;color:#7f1d1d}
.action-btn.danger:hover{background:#fecaca}
.action-btn.danger .ico{color:#dc2626}
.file-bar{display:flex;align-items:center;gap:10px;padding:14px 18px;flex-wrap:wrap;border-bottom:1px solid #f1f5f9}
.file-btn{height:54px;padding:0 18px;border-radius:12px;background:#f1f5f9;border:1px solid #e2e8f0;font-weight:700;color:#0f172a;font-size:.9rem;display:inline-flex;align-items:center;gap:.5rem;cursor:pointer}
.file-btn:hover{background:#e2e8f0}
.file-select{height:54px;padding:0 16px;border-radius:12px;background:#fff;border:1px solid #e2e8f0;font-weight:600;color:#0f172a;font-size:.9rem;display:inline-flex;align-items:center;gap:.5rem;cursor:pointer}
.file-select .caret{color:#94a3b8;margin-left:.5rem}
.file-select.primary{background:#fff;border:2px solid #0ea5e9;flex:1;min-width:300px}
.program-body{flex:1;display:grid;grid-template-columns:1fr 600px;min-height:0}
.gcode{font-family:'JetBrains Mono',monospace;font-size:14px;line-height:1.6;background:#fafafa;border-right:1px solid #f1f5f9;padding:14px 0;overflow:auto;color:#1e293b}
.gline{display:grid;grid-template-columns:60px 1fr;gap:14px;padding:1px 18px 1px 0}
.gline:nth-child(odd){background:#f4f4f5}
.gline .gn{color:#f59e0b;text-align:right;font-weight:700}
.gline.cur{background:#dbeafe !important}
.gline.cur .gn{color:#1e40af}
.gcomment{color:#64748b}
.gword{color:#0f172a}
.gnum{color:#16a34a}
.viewer{display:flex;flex-direction:column;min-height:0}
.viewer-3d{flex:1;background:#0b1220;position:relative;overflow:hidden;display:flex;align-items:center;justify-content:center}
.viewer-tools{display:flex;gap:8px;padding:14px;border-top:1px solid #f1f5f9;background:#fff;flex-wrap:wrap}
.vtool{height:60px;width:60px;border-radius:12px;background:#f1f5f9;border:1px solid #e2e8f0;color:#475569;display:inline-flex;align-items:center;justify-content:center;font-size:1.2rem;cursor:pointer}
.vtool:hover{background:#e2e8f0}
.vtool.on{background:#0f172a;color:#fff;border-color:#0f172a}
.vinfo{padding:14px 18px;background:#fff;font-size:13px;color:#64748b;border-top:1px solid #f1f5f9;display:flex;justify-content:space-between;align-items:center}
.vinfo .ext{color:#0f172a;font-weight:600}
/* =============================================================
MESSAGES PANEL
============================================================= */
.messages{display:none;flex-direction:column;flex:1;min-height:0;padding:18px;gap:12px;overflow:auto}
.messages.active{display:flex}
.msg{background:#fff;border:1px solid #e5e7eb;border-radius:14px;padding:18px 22px;display:grid;grid-template-columns:54px 1fr auto;gap:18px;align-items:flex-start}
.msg .mi{width:54px;height:54px;border-radius:12px;display:inline-flex;align-items:center;justify-content:center;font-size:1.4rem}
.msg.error{border-left:6px solid #dc2626}
.msg.error .mi{background:#fee2e2;color:#991b1b}
.msg.warn{border-left:6px solid #f59e0b}
.msg.warn .mi{background:#fef3c7;color:#92400e}
.msg.info{border-left:6px solid #0ea5e9}
.msg.info .mi{background:#dbeafe;color:#1e40af}
.msg.ok{border-left:6px solid #16a34a}
.msg.ok .mi{background:#dcfce7;color:#166534}
.msg .mtitle{font-weight:800;font-size:1.05rem;color:#0f172a}
.msg .mtime{font-size:.8rem;color:#94a3b8;margin-top:2px}
.msg .mbody{margin-top:6px;color:#475569;font-size:.95rem;line-height:1.5}
.msg .mbody .mono{background:#f1f5f9;padding:2px 6px;border-radius:4px;font-size:.85rem}
.msg .mactions{display:flex;gap:8px}
.mbtn{height:48px;padding:0 16px;border-radius:10px;background:#f1f5f9;border:1px solid #e2e8f0;font-weight:700;color:#0f172a;font-size:.85rem;cursor:pointer}
.mbtn:hover{background:#e2e8f0}
.mbtn.primary{background:#0f172a;color:#fff;border-color:#0f172a}
.mbtn.primary:hover{background:#1e293b}
/* =============================================================
INDICATORS PANEL
============================================================= */
.indicators{display:none;flex:1;min-height:0;padding:18px;gap:14px;overflow:auto;grid-template-columns:repeat(4,1fr);grid-auto-rows:min-content}
.indicators.active{display:grid}
.ind{background:#fff;border:1px solid #e5e7eb;border-radius:14px;padding:16px 18px;display:flex;flex-direction:column;gap:6px}
.ind-label{font-size:.8rem;font-weight:800;text-transform:uppercase;letter-spacing:.1em;color:#94a3b8}
.ind-val{font-family:'JetBrains Mono',monospace;font-size:1.6rem;font-weight:800;color:#0f172a}
.ind-state{display:inline-flex;align-items:center;gap:.4rem;font-size:.8rem;font-weight:700;color:#475569}
.ind-state .dot{width:10px;height:10px;border-radius:9999px}
.ind .progress{height:8px;background:#f1f5f9;border-radius:9999px;overflow:hidden;margin-top:4px}
.ind .progress > div{height:100%;background:#0ea5e9}
.ind.full{grid-column:span 2}
/* =============================================================
MDI PANEL
============================================================= */
.mdi{display:none;flex-direction:column;flex:1;min-height:0;padding:18px;gap:14px}
.mdi.active{display:flex}
.mdi-input{
background:#0b1220;color:#86efac;border:1px solid #1e293b;border-radius:14px;
padding:22px 24px;font-family:'JetBrains Mono',monospace;font-size:1.4rem;font-weight:600;
display:flex;align-items:center;gap:.6rem;
}
.mdi-input .prompt{color:#475569}
.mdi-input .cursor{display:inline-block;width:14px;height:1.4rem;background:#86efac;animation:blink 1s steps(2,end) infinite;vertical-align:middle}
@keyframes blink{50%{opacity:0}}
.mdi-keys{display:grid;grid-template-columns:repeat(8,1fr);gap:8px;flex:0 0 auto}
.mkey{height:64px;border-radius:12px;background:#fff;border:1px solid #e2e8f0;font-weight:800;font-size:1.05rem;color:#0f172a;cursor:pointer;font-family:'JetBrains Mono',monospace}
.mkey:hover{background:#f1f5f9}
.mkey.send{background:#16a34a;color:#fff;border-color:#15803d;grid-column:span 2;font-family:'Inter',sans-serif;font-size:.95rem;letter-spacing:.04em}
.mkey.send:hover{background:#15803d}
.mkey.clear{background:#fee2e2;color:#7f1d1d;border-color:#fca5a5;font-family:'Inter',sans-serif;font-size:.95rem;letter-spacing:.04em}
.mdi-history{flex:1;background:#fff;border:1px solid #e5e7eb;border-radius:14px;padding:14px 18px;overflow:auto;font-family:'JetBrains Mono',monospace;font-size:.95rem}
.mdi-history .h-row{display:grid;grid-template-columns:80px 1fr auto;gap:14px;padding:6px 0;border-bottom:1px solid #f1f5f9;align-items:center}
.mdi-history .h-time{color:#94a3b8;font-size:.8rem}
.mdi-history .h-cmd{color:#0f172a;font-weight:700}
.mdi-history .h-status{color:#16a34a;font-weight:700;font-size:.8rem}
.mdi-history .h-status.err{color:#dc2626}
/* =============================================================
SETTINGS PANEL
============================================================= */
.settings{display:none;flex:1;min-height:0;padding:18px;gap:14px;overflow:auto;grid-template-columns:280px 1fr}
.settings.active{display:grid}
.set-side{background:#fff;border:1px solid #e5e7eb;border-radius:14px;padding:10px;display:flex;flex-direction:column;gap:4px;height:fit-content}
.set-item{height:56px;padding:0 16px;border-radius:10px;display:flex;align-items:center;gap:.6rem;color:#475569;font-weight:700;cursor:pointer}
.set-item:hover{background:#f1f5f9}
.set-item.active{background:#0f172a;color:#fff}
.set-content{display:flex;flex-direction:column;gap:14px}
.set-card{background:#fff;border:1px solid #e5e7eb;border-radius:14px;padding:22px}
.set-title{font-weight:800;font-size:1.1rem;color:#0f172a;margin-bottom:14px}
.set-row{display:grid;grid-template-columns:280px 1fr auto;gap:14px;align-items:center;padding:14px 0;border-bottom:1px solid #f1f5f9}
.set-row:last-child{border-bottom:none}
.set-row .label{font-weight:700;color:#0f172a;font-size:.95rem}
.set-row .desc{color:#64748b;font-size:.85rem;margin-top:2px}
.set-row .val{font-family:'JetBrains Mono',monospace;color:#475569}
.set-input{height:48px;padding:0 14px;border-radius:10px;border:1px solid #e2e8f0;background:#fff;font-family:'JetBrains Mono',monospace;font-size:.95rem;color:#0f172a;min-width:200px}
.set-toggle{width:54px;height:30px;border-radius:9999px;background:#cbd5e1;position:relative;cursor:pointer;transition:background .15s}
.set-toggle::after{content:"";position:absolute;left:3px;top:3px;width:24px;height:24px;border-radius:9999px;background:#fff;transition:transform .15s}
.set-toggle.on{background:#16a34a}
.set-toggle.on::after{transform:translateX(24px)}
</style>
</head>
<body>
<div class="host">
<div class="topbar">
<div class="brand">
<div class="stripe-logo-sm"></div>
ONEFINITY · V09 · Full UX preview
</div>
<span class="pill">Click the inner tabs to navigate</span>
<div style="margin-left:auto"></div>
<button id="oneToOne" class="toggle">1:1</button>
<button id="fitBtn" class="toggle on">Fit</button>
<span id="scaleInfo" class="pill mono">100%</span>
</div>
<div class="stage" id="stage">
<div class="scaler-viewport" id="viewport">
<div class="scaler" id="scaler">
<!-- ============= KIOSK ============= -->
<div class="kiosk">
<header class="head">
<div class="brand-blk">
<div class="brand-logo"></div>
<div class="brand-name">ONEFINITY</div>
</div>
<div class="kiosk-tabs">
<button class="ktab active" data-target="control"><i class="fa-solid fa-gamepad"></i> Control</button>
<button class="ktab" data-target="program"><i class="fa-solid fa-list-ol"></i> Program</button>
<button class="ktab" data-target="console"><i class="fa-solid fa-terminal"></i> Console <span class="ktab-badge">2</span></button>
<button class="ktab" data-target="settings"><i class="fa-solid fa-sliders"></i> Settings</button>
</div>
<button class="sys-btn"><span class="pip"></span> All systems · view <i class="fa-solid fa-chevron-down" style="font-size:10px;opacity:.6"></i></button>
<span class="state-badge"><span class="dot"></span> READY</span>
<button class="estop">STOP</button>
</header>
<div class="body">
<!-- ============= CONTROL ============= -->
<div class="panel active" data-panel="control">
<div class="control-grid">
<!-- jog -->
<div class="jog-card">
<div class="jog-head">
<div class="jog-title">Jog · step <span class="step">10mm</span></div>
<div class="step-seg">
<button>0.1</button><button>1</button><button class="active">10</button><button>100</button>
</div>
</div>
<div class="jog-grid">
<button class="jbtn dir"><i class="fa-solid fa-arrow-up ico" style="transform:rotate(-45deg)"></i></button>
<button class="jbtn">Y+</button>
<button class="jbtn dir"><i class="fa-solid fa-arrow-up ico" style="transform:rotate(45deg)"></i></button>
<button class="jbtn">Z+</button>
<button class="jbtn">X</button>
<button class="jbtn ghost"><span class="lbl">XY</span><span style="font-size:1rem;font-weight:700">Origin</span></button>
<button class="jbtn">X+</button>
<button class="jbtn ghost"><span class="lbl">Z</span><span style="font-size:1rem;font-weight:700">Origin</span></button>
<button class="jbtn dir"><i class="fa-solid fa-arrow-down ico" style="transform:rotate(45deg)"></i></button>
<button class="jbtn">Y</button>
<button class="jbtn dir"><i class="fa-solid fa-arrow-down ico" style="transform:rotate(-45deg)"></i></button>
<button class="jbtn">Z</button>
<button class="jbtn"><i class="fa-solid fa-arrow-down ico"></i><span class="lbl">W</span></button>
<button class="jbtn ghost"><span class="lbl">W</span><span style="font-size:1rem;font-weight:700">Origin</span></button>
<button class="jbtn"><i class="fa-solid fa-arrow-up ico"></i><span class="lbl">W+</span></button>
<button class="jbtn"><i class="fa-solid fa-house ico"></i><span class="lbl">Home</span></button>
</div>
</div>
<!-- DRO + status -->
<div class="right-col">
<div class="dro-card">
<div class="dro-head">
<div>Axis</div><div>Position</div><div>Absolute</div><div>Offset</div><div>State</div><div>Toolpath</div><div style="text-align:right">Actions</div>
</div>
<div class="dro-row">
<div class="dro-axis axis-x">X</div>
<div class="dro-pos">0.000<span class="u">mm</span></div>
<div class="dro-sec">0.000</div>
<div class="dro-sec">0.000</div>
<div><span class="chip chip-amber"><i class="fa-solid fa-question"></i> Unhomed</span></div>
<div><span class="chip chip-green"><i class="fa-solid fa-check"></i> OK</span></div>
<div class="actions-cell">
<button class="icon-btn"><i class="fa-solid fa-gear"></i></button>
<button class="icon-btn"><i class="fa-solid fa-location-dot"></i></button>
<button class="icon-btn"><i class="fa-solid fa-house"></i></button>
</div>
</div>
<div class="dro-row">
<div class="dro-axis axis-y">Y</div>
<div class="dro-pos">0.000<span class="u">mm</span></div>
<div class="dro-sec">0.000</div>
<div class="dro-sec">0.000</div>
<div><span class="chip chip-amber"><i class="fa-solid fa-question"></i> Unhomed</span></div>
<div><span class="chip chip-green"><i class="fa-solid fa-check"></i> OK</span></div>
<div class="actions-cell">
<button class="icon-btn"><i class="fa-solid fa-gear"></i></button>
<button class="icon-btn"><i class="fa-solid fa-location-dot"></i></button>
<button class="icon-btn"><i class="fa-solid fa-house"></i></button>
</div>
</div>
<div class="dro-row z-highlight">
<div class="dro-axis axis-z">Z</div>
<div class="dro-pos">0.000<span class="u">mm</span></div>
<div class="dro-sec">0.000</div>
<div class="dro-sec">0.000</div>
<div><span class="chip chip-amber"><i class="fa-solid fa-question"></i> Unhomed</span></div>
<div><span class="chip chip-amber"><i class="fa-solid fa-triangle-exclamation"></i> Over</span></div>
<div class="actions-cell">
<button class="icon-btn"><i class="fa-solid fa-gear"></i></button>
<button class="icon-btn"><i class="fa-solid fa-location-dot"></i></button>
<button class="icon-btn"><i class="fa-solid fa-house"></i></button>
</div>
</div>
<div class="dro-row">
<div class="dro-axis axis-w">W</div>
<div class="dro-pos">0.000<span class="u">mm</span></div>
<div class="dro-sec">0.000</div>
<div class="dro-sec" style="opacity:.4"></div>
<div><span class="chip chip-amber"><i class="fa-solid fa-question"></i> Unhomed</span></div>
<div><span class="chip chip-green"><i class="fa-solid fa-check"></i> OK</span></div>
<div class="actions-cell">
<button class="icon-btn"><i class="fa-solid fa-location-dot"></i></button>
<button class="icon-btn"><i class="fa-solid fa-house"></i></button>
</div>
</div>
</div>
<div class="status-strip">
<div class="stat-card"><div class="stat-label">State</div><div class="stat-val ok">READY</div><div class="stat-sub">No alerts</div></div>
<div class="stat-card"><div class="stat-label">Velocity / Feed</div><div class="stat-val">0 · 0</div><div class="stat-sub">m/min · mm/min</div></div>
<div class="stat-card"><div class="stat-label">Spindle</div><div class="stat-val">0 (0)</div><div class="stat-sub">RPM (commanded / actual)</div></div>
<div class="stat-card"><div class="stat-label">Job</div><div class="stat-val">0 / 1,785</div><div class="stat-sub">Line · 19:07 remaining</div></div>
</div>
</div>
</div>
<!-- macros -->
<div class="macro-row">
<button class="macro-btn"><span class="mnum">1</span><i class="fa-solid fa-circle-play micon"></i> Macro 1</button>
<button class="macro-btn"><span class="mnum">2</span><i class="fa-solid fa-circle-play micon"></i> Macro 2</button>
<button class="macro-btn"><span class="mnum">3</span><i class="fa-solid fa-circle-play micon"></i> Macro 3</button>
<button class="macro-btn"><span class="mnum">4</span><i class="fa-solid fa-circle-play micon"></i> Macro 4</button>
<button class="macro-btn"><span class="mnum">5</span><i class="fa-solid fa-circle-play micon"></i> Macro 5</button>
<button class="macro-btn"><span class="mnum">6</span><i class="fa-solid fa-circle-play micon"></i> Macro 6</button>
<button class="macro-btn"><span class="mnum">7</span><i class="fa-solid fa-circle-play micon"></i> Macro 7</button>
<button class="macro-btn"><span class="mnum">8</span><i class="fa-solid fa-circle-play micon"></i> Macro 8</button>
</div>
</div>
<!-- ============= PROGRAM ============= -->
<div class="panel" data-panel="program" style="padding:0;gap:0">
<div class="program-card" style="margin:18px;border-radius:18px">
<!-- Auto sub-panel -->
<div class="auto-sub" data-sub="auto" style="display:flex;flex-direction:column;flex:1;min-height:0">
<div class="action-bar">
<button class="action-btn run"><i class="fa-solid fa-play ico"></i><span>RUN</span></button>
<button class="action-btn stop"><i class="fa-solid fa-stop ico"></i><span>STOP</span></button>
<button class="action-btn"><i class="fa-solid fa-folder-arrow-up ico"></i><span>UPLOAD FOLDER</span></button>
<button class="action-btn"><i class="fa-solid fa-file-arrow-up ico"></i><span>UPLOAD FILE</span></button>
<button class="action-btn"><i class="fa-solid fa-file-arrow-down ico"></i><span>DOWNLOAD FILE</span></button>
<button class="action-btn danger"><i class="fa-solid fa-trash ico"></i><span>DELETE</span></button>
</div>
<div class="file-bar">
<button class="file-btn"><i class="fa-solid fa-folder-plus"></i> Create Folder</button>
<button class="file-btn"><i class="fa-solid fa-folder-minus"></i> Delete Folder</button>
<span class="file-select"><i class="fa-solid fa-folder-open" style="color:#64748b"></i> Default folder <i class="fa-solid fa-chevron-down caret"></i></span>
<span class="file-select primary"><i class="fa-solid fa-file-code" style="color:#0ea5e9"></i> thin-rough.nc <i class="fa-solid fa-chevron-down caret" style="margin-left:auto"></i></span>
<span class="file-select"><i class="fa-solid fa-arrow-down-wide-short" style="color:#64748b"></i> By Upload Date <i class="fa-solid fa-chevron-down caret"></i></span>
</div>
<div class="program-body">
<div class="gcode" id="gcode-list"></div>
<div class="viewer">
<div class="viewer-3d">
<svg viewBox="0 0 400 220" style="width:100%;height:100%">
<defs>
<pattern id="gridv" width="20" height="20" patternUnits="userSpaceOnUse">
<path d="M 20 0 L 0 0 0 20" fill="none" stroke="#1e293b" stroke-width="1"/>
</pattern>
</defs>
<rect width="400" height="220" fill="url(#gridv)"/>
<rect x="40" y="80" width="320" height="60" stroke="#475569" stroke-width="1" fill="none" stroke-dasharray="3 3"/>
<text x="40" y="74" fill="#64748b" font-size="9" font-family="monospace">Stock: 250 × 25 × 16 mm</text>
<!-- toolpath -->
<path d="M40,110 L360,110 M40,100 L360,100 M40,120 L360,120 M40,90 L360,90 M40,130 L360,130" stroke="#22c55e" stroke-width="1.4" fill="none" opacity=".8"/>
<path d="M40,110 L40,80 L60,80 L60,110 M80,110 L80,80 L100,80 L100,110 M120,110 L120,80 L140,80 L140,110" stroke="#ef4444" stroke-width="1.4" fill="none" opacity=".8"/>
<circle cx="40" cy="110" r="3" fill="#22c55e"/>
<circle cx="360" cy="110" r="3" fill="#ef4444"/>
<text x="46" y="108" fill="#22c55e" font-size="8" font-family="monospace">START</text>
<text x="332" y="108" fill="#ef4444" font-size="8" font-family="monospace">END</text>
<!-- axes gizmo -->
<g transform="translate(28,196)">
<line x1="0" y1="0" x2="22" y2="0" stroke="#ef4444" stroke-width="2"/>
<line x1="0" y1="0" x2="0" y2="-22" stroke="#3b82f6" stroke-width="2"/>
<line x1="0" y1="0" x2="-12" y2="12" stroke="#22c55e" stroke-width="2"/>
<text x="24" y="4" fill="#ef4444" font-size="9" font-family="monospace">X</text>
<text x="-4" y="-26" fill="#3b82f6" font-size="9" font-family="monospace">Z</text>
<text x="-22" y="22" fill="#22c55e" font-size="9" font-family="monospace">Y</text>
</g>
</svg>
</div>
<div class="viewer-tools">
<button class="vtool" title="Fit"><i class="fa-solid fa-expand"></i></button>
<button class="vtool on" title="Tool"><i class="fa-solid fa-screwdriver-wrench"></i></button>
<button class="vtool" title="Stock"><i class="fa-solid fa-cube"></i></button>
<button class="vtool" title="Origin"><i class="fa-solid fa-up-right-and-down-left-from-center"></i></button>
<button class="vtool" title="Top"><i class="fa-solid fa-square"></i></button>
<button class="vtool" title="Front"><i class="fa-solid fa-square-full"></i></button>
<button class="vtool" title="Iso"><i class="fa-solid fa-cubes"></i></button>
</div>
<div class="vinfo">
<span><span class="ext">thin-rough.nc</span> · 1,785 lines · 12.4 KB</span>
<span class="mono">est. 19:07</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ============= CONSOLE ============= -->
<div class="panel" data-panel="console" style="padding:0;gap:0">
<div class="program-card" style="margin:18px;border-radius:18px">
<div class="ptab-bar">
<button class="ptab active" data-ptab="mdi"><i class="fa-solid fa-keyboard"></i> MDI</button>
<button class="ptab" data-ptab="messages"><i class="fa-solid fa-comment-dots"></i> Messages <span class="ptab-badge">2</span></button>
<button class="ptab" data-ptab="indicators"><i class="fa-solid fa-bell"></i> Indicators</button>
</div>
<!-- MDI sub-panel -->
<div class="mdi active" data-sub="mdi">
<div class="mdi-input">
<span class="prompt">G&gt;</span>
<span class="mono">G0 X100 Y50 F2000</span>
<span class="cursor"></span>
</div>
<div class="mdi-keys">
<button class="mkey">G0</button>
<button class="mkey">G1</button>
<button class="mkey">G2</button>
<button class="mkey">G3</button>
<button class="mkey">G28</button>
<button class="mkey">G92</button>
<button class="mkey">M3</button>
<button class="mkey">M5</button>
<button class="mkey">X</button>
<button class="mkey">Y</button>
<button class="mkey">Z</button>
<button class="mkey">W</button>
<button class="mkey">F</button>
<button class="mkey">S</button>
<button class="mkey clear">CLEAR</button>
<button class="mkey send">SEND ↵</button>
</div>
<div class="mdi-history">
<div class="h-row"><span class="h-time">19:42:11</span><span class="h-cmd">G21</span><span class="h-status">✓ ok</span></div>
<div class="h-row"><span class="h-time">19:42:14</span><span class="h-cmd">G90</span><span class="h-status">✓ ok</span></div>
<div class="h-row"><span class="h-time">19:43:02</span><span class="h-cmd">G0 Y12.800</span><span class="h-status">✓ ok</span></div>
<div class="h-row"><span class="h-time">19:43:08</span><span class="h-cmd">G0 Z19.040</span><span class="h-status">✓ ok</span></div>
<div class="h-row"><span class="h-time">19:43:30</span><span class="h-cmd">G1 Z-20 F800</span><span class="h-status err">✗ blocked: Z over travel</span></div>
<div class="h-row"><span class="h-time">19:44:01</span><span class="h-cmd">G0 Z5</span><span class="h-status">✓ ok</span></div>
</div>
</div>
<!-- Messages sub-panel -->
<div class="messages" data-sub="messages">
<div class="msg warn">
<div class="mi"><i class="fa-solid fa-triangle-exclamation"></i></div>
<div>
<div style="display:flex;align-items:baseline;gap:.6rem">
<div class="mtitle">Z toolpath exceeds soft-limit</div>
<div class="mtime">2 min ago · sticky</div>
</div>
<div class="mbody">Loaded program reaches <span class="mono">Z = -16.500</span>. Configured soft-limit is <span class="mono">Z = -15.000</span>. Adjust the Z origin or set a deeper soft-limit before running.</div>
</div>
<div class="mactions">
<button class="mbtn">Open settings</button>
<button class="mbtn primary">Acknowledge</button>
</div>
</div>
<div class="msg info">
<div class="mi"><i class="fa-solid fa-circle-info"></i></div>
<div>
<div style="display:flex;align-items:baseline;gap:.6rem">
<div class="mtitle">Camera offline</div>
<div class="mtime">12 min ago</div>
</div>
<div class="mbody">Camera at <span class="mono">10.1.10.55:8554</span> did not respond on last poll. Live preview disabled.</div>
</div>
<div class="mactions">
<button class="mbtn">Retry</button>
<button class="mbtn">Dismiss</button>
</div>
</div>
<div class="msg ok">
<div class="mi"><i class="fa-solid fa-check"></i></div>
<div>
<div style="display:flex;align-items:baseline;gap:.6rem">
<div class="mtitle">File uploaded · thin-rough.nc</div>
<div class="mtime">21 min ago</div>
</div>
<div class="mbody">1,785 lines · 12.4 KB · checksum verified.</div>
</div>
<div class="mactions">
<button class="mbtn">Open</button>
</div>
</div>
<div class="msg error">
<div class="mi"><i class="fa-solid fa-circle-xmark"></i></div>
<div>
<div style="display:flex;align-items:baseline;gap:.6rem">
<div class="mtitle">WiFi: not connected</div>
<div class="mtime">1 h ago</div>
</div>
<div class="mbody">Falling back to wired ethernet. SSID <span class="mono">workshop-2g</span> last seen 53 min ago.</div>
</div>
<div class="mactions">
<button class="mbtn">Network…</button>
<button class="mbtn">Mute</button>
</div>
</div>
</div>
<!-- Indicators sub-panel -->
<div class="indicators" data-sub="indicators">
<div class="ind">
<div class="ind-label">Spindle Load</div>
<div class="ind-val">0 %</div>
<div class="ind-state"><span class="dot" style="background:#16a34a"></span> idle</div>
<div class="progress"><div style="width:0%"></div></div>
</div>
<div class="ind">
<div class="ind-label">Spindle Temp</div>
<div class="ind-val">24 °C</div>
<div class="ind-state"><span class="dot" style="background:#16a34a"></span> nominal</div>
<div class="progress"><div style="width:24%"></div></div>
</div>
<div class="ind">
<div class="ind-label">Driver Voltage</div>
<div class="ind-val">48.1 V</div>
<div class="ind-state"><span class="dot" style="background:#16a34a"></span> ok</div>
</div>
<div class="ind">
<div class="ind-label">Coolant</div>
<div class="ind-val">OFF</div>
<div class="ind-state"><span class="dot" style="background:#94a3b8"></span> standby</div>
</div>
<div class="ind">
<div class="ind-label">Limit X</div>
<div class="ind-val" style="color:#16a34a">CLEAR</div>
<div class="ind-state"><span class="dot" style="background:#16a34a"></span> ok</div>
</div>
<div class="ind">
<div class="ind-label">Limit Y</div>
<div class="ind-val" style="color:#16a34a">CLEAR</div>
<div class="ind-state"><span class="dot" style="background:#16a34a"></span> ok</div>
</div>
<div class="ind">
<div class="ind-label">Limit Z</div>
<div class="ind-val" style="color:#dc2626">BLOCKED</div>
<div class="ind-state"><span class="dot" style="background:#dc2626"></span> over-travel</div>
</div>
<div class="ind">
<div class="ind-label">Probe</div>
<div class="ind-val">OPEN</div>
<div class="ind-state"><span class="dot" style="background:#94a3b8"></span> not contacted</div>
</div>
<div class="ind">
<div class="ind-label">E-Stop</div>
<div class="ind-val" style="color:#16a34a">RELEASED</div>
<div class="ind-state"><span class="dot" style="background:#16a34a"></span> safe</div>
</div>
<div class="ind">
<div class="ind-label">Door</div>
<div class="ind-val">CLOSED</div>
<div class="ind-state"><span class="dot" style="background:#16a34a"></span> ok</div>
</div>
<div class="ind">
<div class="ind-label">Air Pressure</div>
<div class="ind-val">6.2 bar</div>
<div class="ind-state"><span class="dot" style="background:#16a34a"></span> ok</div>
<div class="progress"><div style="width:62%"></div></div>
</div>
<div class="ind">
<div class="ind-label">Vacuum</div>
<div class="ind-val">0.81 bar</div>
<div class="ind-state"><span class="dot" style="background:#16a34a"></span> hold</div>
<div class="progress"><div style="width:81%"></div></div>
</div>
</div>
</div>
</div>
<!-- ============= SETTINGS ============= -->
<div class="panel" data-panel="settings" style="padding:0;gap:0">
<div class="settings active" style="padding:18px">
<div class="set-side">
<div class="set-item active"><i class="fa-solid fa-display"></i> Display & Units</div>
<div class="set-item"><i class="fa-solid fa-arrows-up-down-left-right"></i> Motion</div>
<div class="set-item"><i class="fa-solid fa-bolt"></i> Spindle</div>
<div class="set-item"><i class="fa-solid fa-shield-halved"></i> Safety / Soft-limits</div>
<div class="set-item"><i class="fa-solid fa-network-wired"></i> Network</div>
<div class="set-item"><i class="fa-solid fa-video"></i> Camera</div>
<div class="set-item"><i class="fa-solid fa-keyboard"></i> Macros</div>
<div class="set-item"><i class="fa-solid fa-circle-info"></i> About</div>
</div>
<div class="set-content">
<div class="set-card">
<div class="set-title">Display & Units</div>
<div class="set-row">
<div>
<div class="label">Display Units</div>
<div class="desc">Position, feed and dimensions throughout the UI.</div>
</div>
<div><div class="step-seg" style="display:inline-flex"><button class="active">METRIC</button><button>IMPERIAL</button></div></div>
<div></div>
</div>
<div class="set-row">
<div>
<div class="label">Decimal places</div>
<div class="desc">Position readout precision.</div>
</div>
<div><input class="set-input" value="3" /></div>
<div class="val">04</div>
</div>
<div class="set-row">
<div>
<div class="label">Pulse-dot animation</div>
<div class="desc">Animate status badges (ready, idle, alarm).</div>
</div>
<div><div class="set-toggle on"></div></div>
<div></div>
</div>
<div class="set-row">
<div>
<div class="label">Theme</div>
<div class="desc">Pick a tile finish.</div>
</div>
<div><span class="file-select"><i class="fa-solid fa-palette" style="color:#64748b"></i> V09 · Flat soft slate <i class="fa-solid fa-chevron-down caret"></i></span></div>
<div></div>
</div>
</div>
<div class="set-card">
<div class="set-title">Network</div>
<div class="set-row">
<div>
<div class="label">IP Address</div>
<div class="desc">Wired ethernet, DHCP.</div>
</div>
<div><span class="mono" style="font-size:1.05rem;font-weight:700">10.1.10.55</span></div>
<div><button class="mbtn">Edit</button></div>
</div>
<div class="set-row">
<div>
<div class="label">WiFi</div>
<div class="desc">Wireless network connection.</div>
</div>
<div><span class="chip chip-red"><i class="fa-solid fa-wifi"></i> Not connected</span></div>
<div><button class="mbtn primary">Configure</button></div>
</div>
<div class="set-row">
<div>
<div class="label">Hostname</div>
<div class="desc">Used in mDNS / Bonjour discovery.</div>
</div>
<div><input class="set-input" value="onefinity-shop.local" style="width:300px" /></div>
<div></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
// ----- Build G-code list -----
const gcodeLines = [
[1,'G21','word'],[2,'; X = along blank, Z = tool entry from top, Y fixed','c'],
[3,'; Y fixed to blank center: 12.800','c'],[4,'; nominal rapid: 3200.0 mm/min','c'],
[5,'; stock top Z: -0.960','c'],[6,'; deepest allowed cut Z: -16.500','c'],
[7,'G21','word'],[8,'G90','word'],[9,'G0 Y12.800','word'],
[10,'G0 Z19.040','word'],[11,'; rough pass 1 radius=18.540','c'],
[12,'G0 X0.000','word'],[13,'G1 Z-0.710 F800.000','word cur'],
[14,'G1 Z-0.960 F200.000','word'],[15,'G4 P0.250','word'],
[16,'G1 X249.500 F200.000','word'],[17,'G4 P0.250','word'],
[18,'G0 Z19.040','word'],[19,'; rough pass 2 radius=17.540','c'],
[20,'G0 X0.000','word'],[21,'G1 Z-1.710 F800.000','word'],
[22,'G1 Z-1.960 F200.000','word'],[23,'G4 P0.250','word'],
[24,'G1 X249.500 F200.000','word'],[25,'G4 P0.250','word'],
[26,'G0 Z19.040','word'],[27,'; rough pass 3 radius=16.540','c'],
[28,'G0 X0.000','word'],[29,'G1 Z-2.710 F800.000','word'],
[30,'G1 Z-2.960 F200.000','word'],[31,'G4 P0.250','word'],
[32,'G1 X249.500 F200.000','word'],[33,'G4 P0.250','word'],
[34,'G0 Z19.040','word'],[35,'; rough pass 4 radius=15.540','c'],
];
document.getElementById('gcode-list').innerHTML = gcodeLines.map(([n,t,cls])=>{
const isComment = cls.includes('c');
const isCur = cls.includes('cur');
const cls2 = 'gline' + (isCur?' cur':'');
const inner = isComment ? `<span class="gcomment">${t}</span>` : `<span class="gword">${t}</span>`;
return `<div class="${cls2}"><span class="gn">${n}</span><span>${inner}</span></div>`;
}).join('');
// ----- Top tab switching (Control / Program / Settings) -----
document.querySelectorAll('.ktab').forEach(b=>{
b.addEventListener('click', ()=>{
const target = b.dataset.target;
document.querySelectorAll('.ktab').forEach(x=>x.classList.remove('active'));
b.classList.add('active');
document.querySelectorAll('.panel').forEach(p=>p.classList.remove('active'));
document.querySelector(`.panel[data-panel="${target}"]`).classList.add('active');
applyScale();
});
});
// ----- Console sub-tab switching (MDI / Messages / Indicators) -----
function showSub(name){
document.querySelectorAll('.ptab').forEach(x=>x.classList.toggle('active', x.dataset.ptab===name));
document.querySelectorAll('[data-sub]').forEach(s=>{
const on = s.dataset.sub===name;
if(s.classList.contains('messages') || s.classList.contains('indicators') || s.classList.contains('mdi')){
s.classList.toggle('active', on);
}
});
}
document.querySelectorAll('.ptab').forEach(b=>{
b.addEventListener('click', ()=>{ showSub(b.dataset.ptab); });
});
// Default Console sub: MDI active
document.querySelectorAll('.messages[data-sub], .indicators[data-sub]').forEach(s=>s.classList.remove('active'));
// ----- Scaling -----
const stage = document.getElementById('stage');
const scaler = document.getElementById('scaler');
const viewport = document.getElementById('viewport');
const fitBtn = document.getElementById('fitBtn');
const oneToOne = document.getElementById('oneToOne');
const scaleInfo = document.getElementById('scaleInfo');
let mode = 'fit';
function activeKioskHeight(){
const m = document.querySelector('.kiosk');
return m ? Math.max(1080, m.offsetHeight) : 1080;
}
function applyScale(){
let s;
if(mode==='1:1'){
s = 1; scaleInfo.textContent = '100% · 1920px wide';
} else {
const sw = stage.clientWidth - 32;
s = Math.min(sw/1920, 1);
scaleInfo.textContent = Math.round(s*100) + '% · 1920px wide';
}
const h = activeKioskHeight();
scaler.style.transform = `scale(${s})`;
viewport.style.width = (1920 * s) + 'px';
viewport.style.height = (h * s) + 'px';
}
window.addEventListener('resize', applyScale);
fitBtn.addEventListener('click', ()=>{ mode='fit'; fitBtn.classList.add('on'); oneToOne.classList.remove('on'); applyScale(); });
oneToOne.addEventListener('click', ()=>{ mode='1:1'; oneToOne.classList.add('on'); fitBtn.classList.remove('on'); applyScale(); });
applyScale();
</script>
</body>
</html>

View File

@@ -0,0 +1,169 @@
# UX Redesign — Implementation Plan
Reference mock: `docs/mocks/v09_full_ux.html`
Target hardware: 10.8" portable monitor, 1920×1080, capacitive touch, Chrome fullscreen.
## 1. Goals
The redesign keeps every existing feature but reorganizes the page into a single-screen control surface for finger-touch use:
- A slim 96 px header replaces the 140 px nav-header. Only logo + ONEFINITY wordmark + tab bar + system pill + READY badge + octagonal STOP.
- 4 top-level sections accessed via underline-ribbon tabs in the header:
1. **Control** — jog pad, DRO table, status strip, macro row.
2. **Program** — Auto run controls, file actions, G-code listing, 3D viewer.
3. **Console** — MDI, Messages, Indicators (sub-tabs).
4. **Settings** — paged settings (replaces the Pure left rail).
- Touch targets ≥ 64 px (jog tiles 72 px, axis action icons 72 px, macro buttons 84 px).
- All action chip-soup (WiFi/Camera/Rotary/IP/Version) collapses into one "All systems · view" pill that opens a popover. Burger menu removed (Settings tab supersedes it).
- V09 jog/macro palette: flat soft slate (#3f4b63), no drop shadow; yellow (#fde047) accent for active states (step seg, tab underline, macro number badge).
- Spindle override / feed override sliders live in a bottom-edge drawer triggered by tapping the Spindle KPI tile (no permanent screen real estate).
- Hard cut: no `config.ui.layout` flag; the new shell replaces the old in a single release.
## 2. Scope of code change
The build is Pug + Stylus + Browserify Vue (Vue 1.x). `index.pug` defines the chrome; `src/pug/templates/*.pug` defines each view; `src/js/*.js` mirrors them as Vue components routed by `currentView` from the URL hash.
Files we will touch:
- `src/pug/index.pug` — replace `#layout / #menu / #main / .nav-header` with the new header + tab bar + body. Drop the burger and the side-menu include.
- `src/pug/templates/control-view.pug` — restructure into the new Control panel (jog grid + DRO table + status strip + macro row). MDI/Messages/Indicators move out.
- New `src/pug/templates/program-view.pug` — Auto sub-panel content (action bar, file bar, gcode-viewer, path-viewer).
- New `src/pug/templates/console-view.pug` — MDI / Messages / Indicators sub-tabs hosting existing `console.pug` and `indicators.pug` partials.
- `src/js/app.js` — extend `parse_hash` so `#program`, `#console`, `#settings` resolve; expose tab state for the header to highlight.
- `src/js/control-view.js` — keep jog/DRO logic, drop the Auto/MDI/Messages/Indicators internal `tab` state and template hooks.
- New `src/js/program-view.js`, `src/js/console-view.js` — extracted Vue components.
- `src/stylus/style.styl` — add `.app-shell`, `.head`, `.tabs-host`, `.ktab`, panel styles, V09 jog tokens. Keep legacy classes alive until templates fully migrated.
- `src/static/css/side-menu.css` — stop including in `index.pug`.
- Settings: keep `settings-view.pug`, `admin-general-view.pug`, `admin-network-view.pug`, `motor-view.pug`, `tool-view.pug`, `io-view.pug`, etc., and surface them through a left-rail navigator inside the Settings panel rather than the sidebar.
- Settings → Macros owns the full macro list (1…N). Control's macro row is a slice of the first 8; reordering happens in Settings.
## 3. Routing model
We keep the existing URL hash routing because everything in `src/js/app.js#parse_hash` and the deep-linked menu items (`#motor:0`, `#admin-network`, etc.) depend on it.
| URL hash | Top tab | Notes |
|-------------------------|------------|-------------------------------------------------------|
| `#control` | Control | Default |
| `#program` / `#program:auto` | Program | Auto sub-view (only sub-view for now) |
| `#console` / `#console:mdi` | Console | MDI default, also `:messages` and `:indicators` |
| `#settings` | Settings | Settings home (Display & Units) |
| `#admin-general`, `#admin-network`, `#motor:N`, `#tool`, `#io`, `#help`, `#cheat-sheet` | Settings | Existing routes remain, surfaced in the Settings left rail |
The header tab bar maps URL prefix → active tab. A tiny helper `topTabFromHash(hash)` lives in `app.js` and is reused by the header template.
## 4. Step-by-step
### Phase 1 — Mock parity (12 days)
1. Add `docs/mocks/v09_full_ux.html` (done) so anyone can preview the target.
2. Move the V09 palette into Stylus tokens at the top of `style.styl`:
```styl
$jog-bg = #3f4b63
$jog-hover = #4a5777
$jog-dir = #5b6885
$jog-ghost = #8c97ad
$accent = #fde047
$accent-ink = #0f172a
```
3. Build the header in `index.pug`:
```pug
.app-shell
header.head
.brand-blk
.brand-logo
.brand-name ONEFINITY
nav.tabs-host(role="tablist")
a.ktab(:class="{active: topTab === 'control'}", href="#control")
.fa.fa-gamepad
| Control
a.ktab(:class="{active: topTab === 'program'}", href="#program") …
a.ktab(:class="{active: topTab === 'console'}", href="#console") …
a.ktab(:class="{active: topTab === 'settings'}", href="#settings") …
button.sys-btn(@click="toggle_sys_popover") …
span.state-badge(:class="state_class")
estop(@click="estop")
```
4. Style the header tabs as **underline ribbon** (V02): transparent fills, slate-gray text, dark text + 5 px yellow underline on active. CSS already proven in the mock.
5. Move the rotary toggle and pi-temp warning into the system pill popover.
### Phase 2 — Control panel (2 days)
1. Rewrite the outer markup of `control-view.pug` to a CSS grid:
```
.control-grid → 720px jog-card | 1fr right-col(dro-card + status-strip)
```
Drop the `<table>`-based outer layout (axes table stays — it's a real data table).
2. Replace the legacy `<button>` elements in the jog table with `.jbtn` markup that pulls colors from `$jog-*` tokens. Keep the `@click="jog_fn(...)"` bindings unchanged.
3. Build the new `.step-seg` with the existing `jog_incr` model. The four buttons stay wired to `jog_incr = 'fine' | 'small' | 'medium' | 'large'`.
4. Build `.dro-card` from the existing `table.axes` markup. Each row gets the new 7-column grid; axis cells just need `.dro-axis`, `.dro-pos`, `.dro-sec` classes.
5. Move the four KPI tiles (`State / Velocity-Feed / Spindle / Job`) into `.status-strip`. Existing `state.v`, `state.feed`, `state.s`, `state.line` bindings are unchanged.
6. Move `.macros-div` into a `.macro-row` 8-column grid. The row binds to `config.macros.slice(0, 8)`; macros 9…N are editable and runnable only from Settings → Macros (no drawer in Control). Reordering in Settings changes which macros appear in the visible 8.
7. Drop the legacy `.tabs / #tab1` block from `control-view.pug` entirely.
### Phase 3 — Program panel (1.5 days)
1. New file `src/pug/templates/program-view.pug` with `.program-card` and the action / file bars.
2. Move the Auto bar (RUN, STOP, UPLOAD FOLDER, UPLOAD FILE, DOWNLOAD FILE, DELETE) and the file-select strip (Create Folder, Delete Folder, folder picker, file picker, sort) out of `control-view.pug` into here. Use the V09 button styles (`.action-btn`, `.action-btn.run`, `.action-btn.danger`, `.file-btn`, `.file-select`).
3. Embed `path-viewer` and `gcode-viewer` in `.program-body { 1fr 600px }`. Both Vue components render unchanged.
4. New `src/js/program-view.js` exporting the same data model the existing `Auto` tab uses (`gcode_files`, `state.selected`, `start_pause`, etc.). The fastest path: move the relevant computed/methods into a mixin `gcode-program-mixin.js` consumed by both old and new components during the migration.
5. Wire `<component :is="currentView + '-view'">` in `index.pug` to pick up `program-view`.
### Phase 4 — Console panel (1 day)
1. New `src/pug/templates/console-view.pug` with the inner `.ptab-bar` (MDI / Messages / Indicators) and `data-sub` panels.
2. The MDI panel reuses the existing `<input v-model="mdi" @keyup.enter="submit_mdi">` plus the on-screen keypad (G0/G1/G2/G3/G28/G92/M3/M5 + axis letters + CLEAR/SEND).
3. The Messages panel pulls from the existing `popupMessages` array + a new `messages_log` state we will accumulate from `app.js`'s `error` and `popupMessages` channels (no protocol change).
4. The Indicators panel mounts the existing `<indicators :state="state" :template="template">` component.
5. Sub-tab state is local Vue state (`activeSub: 'mdi' | 'messages' | 'indicators'`) plus URL fragment after `:` so deep links keep working.
### Phase 5 — Settings panel (1 day)
1. New `src/pug/templates/settings-view.pug` with a left rail and a content slot.
2. The left rail is data-driven from a list of existing settings views: General, Network, Motion (settings-view), Spindle (tool-view), Safety (admin-general subset), Camera, Macros (settings-view subset), I/O, Motors, Help, About.
3. The content slot uses `<component :is="settingsSub + '-view'">` so each existing pug template renders unchanged (`admin-general-view.pug`, `admin-network-view.pug`, `motor-view.pug`, `tool-view.pug`, `io-view.pug`, `settings-view.pug`, `help-view.pug`, `cheat-sheet-view.pug`).
4. Existing routes (`#admin-network`, `#motor:0`, …) resolve to Settings + the matching left-rail item. We lose nothing.
5. Decommission the side menu in `index.pug` and stop including `side-menu.css`.
### Phase 6 — Polish & rollout (0.5 days)
1. Pulse-dot animation for the READY badge (CSS keyframes already in the mock).
2. System pill popover content: WiFi state + button, Camera state + retry, Rotary toggle, IP address, firmware version, "Open Settings".
3. Disabled states: jog buttons + macro buttons honor `is_ready` like before; gray them out instead of hiding.
4. Decimal-places setting from the existing `display_units` plumbing — wire to a new `precision` config the DRO reads.
5. Build the **Spindle override drawer**: clicking the `.stat-card` for Spindle toggles `.override-drawer.open` anchored to the bottom edge of the body. The drawer hosts the two existing `<input type="range">` controls for `feed_override` and `speed_override` plus `Reset` buttons. Bind to the existing `override_feed` / `override_speed` methods.
6. **Hard cut cleanup:** delete the legacy `.nav-header`, side-menu markup, and the inline `.tabs / #tab1#tab4` block from `control-view.pug`. Remove `src/static/css/side-menu.css` from `index.pug` includes. Sweep `style.styl` for orphan rules (`.nav-header`, `.brand`, `.menu-link`, `.pure-menu*` overrides, `.tabs > input` selectors) and delete them in the same commit so we don't ship dead CSS.
## 5. Migration risks & mitigations
| Risk | Mitigation |
|----------------------------------------------|---------------------------------------------------------------------------------------------|
| Existing deep links from PDFs / forum posts (`#admin-network`) break | Keep the same hashes; only their visual shell changes. `parse_hash` resolves them. |
| Vue 1.x doesn't support modern slot syntax we used in the mock | The mock is plain HTML for visual review; production code uses the existing Vue 1 patterns. No new Vue features required. |
| Touch monitor with HDMI vs USB-C may report different DPI | The new layout is fluid inside 1920 × 1080 only when fullscreen Chrome. Provide a CSS `@media (max-width: 1820px)` fallback that scales the macro row to 4 columns and stacks the right column under the jog. |
| Existing customers rely on muscle memory of the side menu | Settings tab opens directly to the same left-rail navigator. First-launch toast: "Side menu moved to Settings." |
| `path-viewer` / `gcode-viewer` are heavy three.js components | They live in the Program tab now; we lazy-mount with `v-if="currentView === 'program'"` so Control stays light. |
| MDI input could lose focus when the inner `.ptab` is switched | Keep the input mounted, just hide non-active subs with `display:none`. |
## 6. Testing checklist
- Chrome on the 10.8" 1920 × 1080 monitor, fullscreen — every panel fits without scrolling at 100 %.
- Chrome at 1366 × 768 — fallback layout works (Control collapses jog above DRO).
- Touch hit-tests: every interactive target ≥ 48 px on its shortest side, primary jog tiles ≥ 72 px.
- Existing flows still work end-to-end: home all axes, run a small program, MDI a `G0 X10`, switch to Imperial, upload a folder, delete a file.
- Hash routing: hand-type `#motor:1` and confirm Settings tab activates with Motor 1 selected.
- Spindle override drawer: tap Spindle KPI tile, sliders move feed/speed override, `Reset` returns both to 100 %, tile tap closes drawer.
- Macro row shows macros 18 only; reordering in Settings → Macros changes which 8 appear on Control.
- Pulse-dot animation respects `prefers-reduced-motion`.
- Hard-cut cleanup verified: `git grep` finds no references to the old `.nav-header`, `side-menu.css`, or the `#tab1#tab4` selectors after the rename.
## 7. Estimated effort
About 67 working days for one developer:
1. Mock parity & header — 1.5 days
2. Control panel (incl. macro slice + DRO grid) — 2 days
3. Program panel — 1.5 days
4. Console panel — 1 day
5. Settings shell — 1 day
6. Override drawer, polish, hard-cut cleanup, regression tests — 0.51 day
## 8. Resolved decisions
- **Rollout: hard cut.** No `config.ui.layout` feature flag, no parallel legacy shell. The new `index.pug` tree replaces the old one in a single release; the old `.nav-header`, side menu, and embedded `.tabs` block are deleted (not gated). One pre-release internal QA pass on real hardware before tagging.
- **Macros above 8: Settings owns the master list; Control surfaces the first 8 (configurable).** The Control macro row reads from `config.macros[0..7]`; everything beyond index 7 is editable / runnable only from Settings → Macros. Users can reorder which macros land in the visible 8 there.
- **"Pin to Control" indicator slot: defer.** Not in this redesign. Tracked as a follow-up; current status strip stays fixed at State / Velocity·Feed / Spindle / Job.
- **Feed & spindle override: drawer triggered by the Spindle KPI tile.** The Spindle card in the status strip becomes tappable. Tap opens a bottom-edge drawer (≈ 220 px tall) containing the two existing range inputs (`feed_override`, `speed_override`) at touch-friendly size with `Reset to 100 %` buttons. Closes by tapping the tile again or the drawer chevron. No protocol change; reuses the existing `override_feed` / `override_speed` handlers.

View File

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

View File

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

View File

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

View File

@@ -251,24 +251,19 @@ module.exports = {
aux_home: function () {
api.put("aux/home").catch(function (err) {
console.error("Aux home failed:", err);
console.error("W 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.
// 2. W axis via /api/aux/home on the ESP
// /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.
// Only then do we fire the W home, so the gantry and the auxcnc
// ESP never move at the same time.
home_all: async function () {
this.ask_home = false;
try {
@@ -279,24 +274,19 @@ module.exports = {
}
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.
// never leaves idle and we bail rather than home W 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");
console.warn("home_all: main homing cycle never started; skipping W");
return;
}
@@ -312,13 +302,13 @@ module.exports = {
}
api.put("aux/home").catch(function (err) {
console.error("Aux home failed:", err);
console.error("W home failed:", err);
});
},
aux_jog: function (delta_mm) {
api.put("aux/jog", { mm: delta_mm }).catch(function (err) {
console.error("Aux jog failed:", err);
console.error("W jog failed:", err);
});
},

View File

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

View File

@@ -24,7 +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"),
"w-axis-view": require("./w-axis-view"),
"cheat-sheet-view": {
template: "#cheat-sheet-view-template",
data: function () {
@@ -57,9 +57,9 @@ module.exports = {
{ sub: "motor", motor: 1, href: "#motor:1", icon: "fa-arrows-up-down-left-right", label: "Motor 1" },
{ sub: "motor", motor: 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" },
// 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" },
// W axis is auxiliary (auxcnc ESP32). It mounts the existing
// WAxisSettings Svelte component on its own page.
{ sub: "w-axis", href: "#w-axis", icon: "fa-arrows-up-down", label: "W Axis" },
{ section: " " },
{ sub: "help", href: "#help", icon: "fa-circle-question", label: "Help" },
],
@@ -137,7 +137,7 @@ module.exports = {
// layout, which under tablet mode pulls the fixed header out
// of view.
if (location.hash !== item.href) location.hash = item.href;
this._a_axis_focus = (item.sub === "a-axis");
this._w_axis_focus = (item.sub === "w-axis");
const reset = () => {
// Force any inadvertent ancestor scroll back to 0 before
// we move .settings-content explicitly.
@@ -160,7 +160,7 @@ module.exports = {
requestAnimationFrame(reset);
}, 320);
} else {
this._a_axis_focus = false;
this._w_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");

View File

@@ -1,16 +1,16 @@
"use strict";
// V09 A-axis page mounts the AAxisSettings Svelte component
// V09 W-axis page \u2014 mounts the existing WAxisSettings 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",
template: "#w-axis-view-template",
attached: function () {
this.svelteComponent = SvelteComponents.createComponent(
"AAxisSettings",
document.getElementById("a-axis-mount")
"WAxisSettings",
document.getElementById("w-axis-mount")
);
},

View File

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

View File

@@ -215,11 +215,13 @@ script#control-view-template(type="text/x-template")
div Position
div Absolute
div Offset
div State
div Toolpath
.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.
// the W axis when it is enabled.
button.icon-btn(:disabled="!is_idle",
title="Home all axes.", @click="home_all()")
.fa.fa-house-chimney
@@ -228,32 +230,39 @@ script#control-view-template(type="text/x-template")
each axis in 'xyzabc'
.dro-row(:class=`${axis}.klass + ' ' + ${axis}.tklass`,
v-if=`${axis}.enabled`,
:title=`${axis}.toolmsg ? (${axis}.title + ' — ' + ${axis}.toolmsg) : ${axis}.title`)
:title=`${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-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.')`,
button.icon-btn(:disabled="!can_set_axis",
:title=`'Zero ${axis.toUpperCase()} axis offset.'`,
@click=`zero('${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`,
button.icon-btn(:disabled="!is_idle",
:title=`'Home ${axis.toUpperCase()} axis.'`,
@click=`home('${axis}')`)
.fa.fa-home
// Legacy auxiliary-axis row - shown only when the auxcnc stepper is
// Legacy W 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.
// + set-position support); the W row stays for backwards
// compatibility with installs that haven't migrated.
.dro-row(:class="w.klass + ' ' + w.tklass",
v-if="w.enabled && !a.enabled",
:title="w.title")
@@ -261,14 +270,21 @@ script#control-view-template(type="text/x-template")
.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-gear
button.icon-btn(disabled, style="visibility:hidden")
.fa.fa-location-dot
button.icon-btn(:class="w.homed ? 'state-green' : 'state-amber'",
:disabled="!w.enabled",
title="Home auxiliary axis.", @click="aux_home()")
button.icon-btn(:disabled="!w.enabled",
title="Home W axis.", @click="aux_home()")
.fa.fa-home
// ----- Status strip -----

View File

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

View File

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

View File

@@ -40,8 +40,8 @@ DEFAULTS = {
# 4th axis). gcode uses A for moves; the host ExternalAxis layer
# 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
'min_w': 0.0, # soft limit min (mm), exposed as 4tn
'max_w': 100.0, # soft limit max (mm), exposed as 4tm
# Per-axis kinematic limits used to populate the planner's config.
# Units match the bbctrl/onefinity per-motor convention so the
# values are directly comparable to motors 0-3:
@@ -66,34 +66,10 @@ DEFAULTS = {
'home_slow_sps': 250, # ≈ 10 mm/s
'home_backoff_steps': 400, # ≈ 16 mm
'home_maxtravel_steps': 200000,
# If HOME starts with the limit switch already tripped the ESP
# first moves this many steps away from the limit and then
# rechecks. If the switch is still active afterward, HOME hard-
# fails (refuses to set zero blindly when we may already be past
# the home position). Default ≈ 10 mm @ 25 steps/mm. Set to 0 to
# disable the preclear move (HOME then fails immediately if the
# switch reads active at start, matching the original behaviour).
'home_preclear_mm': 10.0,
'step_max_sps': 4000, # ≈ 160 mm/s normal-move cap
'step_accel_sps2': 12000,
'step_start_sps': 200,
'limit_low': True,
# ------------------------------------------------------------------
# Z-A coupling interlock
# ------------------------------------------------------------------
# The auxiliary A axis carries a tool that physically hangs below
# the Z-axis spindle nose. Beyond a certain Z descent the two
# collide unless A drops with Z. The constraint, in machine coords,
# is:
# A_machine - Z_machine <= K
# where K = (A_home_mm - z_home_mm) + couple_z_clearance_mm.
# When enabled this is enforced everywhere motion can be
# initiated (planner, MDI, jog, file load) and the AuxPreprocessor
# injects pre-position A moves before Z descends past the safe
# band.
'couple_z_enabled': True,
'couple_z_clearance_mm': 22.0, # Z drop allowed before A must follow
'z_home_mm': 0.0, # Z's machine position when homed
}
@@ -144,61 +120,23 @@ 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
@@ -303,75 +241,6 @@ class AuxAxis(object):
return
self._do_steps(int(steps), ignore_limits=True)
# ----------------------------------------------- continuous-rate jog
#
# Hold-to-jog support for the gamepad pendant. JOG / JOGSTOP on
# the ESP give a smooth ramp-up, cruise-until-released, ramp-down
# profile - much better than streaming small STEPS chunks.
#
# `jog_start` returns immediately after the ESP acknowledges with
# `[jog] started ...`. The terminal `[jog] done count=<n>
# pos=<p>` arrives later; our reader picks it up and resyncs
# _pos_steps via the same path as STEPS.
def jog_start(self, direction, max_rate_sps=None,
accel_sps2=None, ignore_limits=False,
target_steps=None):
"""Begin a continuous-rate jog. `direction` is +1 or -1.
Returns once the ESP has accepted the JOG command.
target_steps (optional): a signed step-counter value. The
ESP picks the deceleration start point so the motor ramps
smoothly from the current cruise rate to step_start_rate
and stops AT this counter value. Used to enforce host-side
soft limits without overshoot. The target must be on the
side of the current g_pos that matches `direction`; the
ESP rejects a wrong-side target with reason=softlimit."""
self._require_present()
if direction not in (-1, +1):
raise AuxAxisError('jog_start direction must be +/-1')
sign = '+' if direction > 0 else '-'
rate = (int(max_rate_sps) if max_rate_sps is not None
else int(self._cfg['step_max_sps']))
accel = (int(accel_sps2) if accel_sps2 is not None
else int(self._cfg['step_accel_sps2']))
if rate < 1: rate = 1
if accel < 1: accel = 1
cmd = 'JOG dir=%s maxrate=%d accel=%d safe=%d' % (
sign, rate, accel, 0 if ignore_limits else 1)
if target_steps is not None:
cmd += ' target=%d' % int(target_steps)
# Capture both the immediate ack AND the eventual terminal
# line in a single _rpc call would block; instead fire the
# ack-only RPC here and let _on_line handle the terminal
# `[jog] done` async (it falls through to the info log path,
# but we hook _on_line to update _pos_steps).
line = self._rpc(cmd, topic='jog', timeout=2.0)
if line.startswith('error'):
raise AuxAxisError('JOG rejected: %s' % line)
if not line.startswith('started'):
# Could be "done count=0 pos=..." if a near-instant abort
# raced; treat as completed.
self._pos_steps = self._parse_kv_int(
line, 'pos', self._pos_steps)
self._publish_state()
# else: cruising, terminal [jog] reply will arrive later.
def jog_stop(self):
"""Request the running JOG to ramp down to a stop. Returns
immediately; the terminal `[jog] done` arrives async and is
picked up by `_on_line` to resync _pos_steps.
Like abort(), this does NOT take the RPC lock - JOGSTOP is
the on-release path of a hold-to-jog UI and must not block
on whatever else is in flight."""
if not self._present:
return
try:
self.log.info('aux >> JOGSTOP')
self._send_raw('JOGSTOP')
except Exception as e:
self.log.warning('JOGSTOP send failed: %s' % e)
def abort(self):
"""Cancel any running ESP motion immediately."""
if not self._present:
@@ -382,23 +251,38 @@ class AuxAxis(object):
except Exception as e:
self.log.warning('ABORT send failed: %s' % e)
# ---------------------------------------------------------- ATC atoms
# ---------------------------------------------------------- ATC commands
#
# The auxcnc firmware drives an AMB 1050 FME-W DI tool changer via
# two pneumatic valves on relays 1-2:
# V1 (clamp, 3/2 valve) - relay 2: ON = collet open, OFF = vent + spring closes
# V2 (ejector) - relay 1: ON = ejector cylinder extends
#
# The host exposes three composable atoms - RELEASE, CLAMP, EJECT -
# and composes drop/grab sequences from G-code macros that call
# them in order. (Older firmware exposed monolithic DROPTOOL /
# GRABTOOL verbs; protocol v3 dropped them in favour of these
# atoms so callers can interleave Z moves between ejector pulses.)
# 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):
"""Open the collet (V1 on). Instant. Idempotent. Pairs with
atc_clamp() to bracket a sequence of host-side moves and/or
ejector pulses with the collet held open."""
"""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'):
@@ -407,8 +291,8 @@ class AuxAxis(object):
raise AuxAxisError('RELEASE failed: %s' % reason)
def atc_clamp(self, timeout=10.0):
"""Close the collet: V1 off, then dwell for the line to vent
and the spring to re-engage. Idempotent."""
"""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'):
@@ -416,29 +300,6 @@ class AuxAxis(object):
reason = line.split('reason=', 1)[1] if 'reason=' in line else line
raise AuxAxisError('CLAMP failed: %s' % reason)
def atc_eject(self, pulse_ms=None, dwell_ms=None, timeout=10.0):
"""One ejector wiggle: V2 on for pulse_ms, then off for
dwell_ms. The collet (V1) is left in whatever state the caller
set it to via atc_release/atc_clamp - typically RELEASE first
so the holder can actually drop.
Repeatedly calling atc_eject gives the wiggle that the old
monolithic DROPTOOL did internally, but as discrete blocking
calls so a macro can interleave Z moves between pulses.
pulse_ms / dwell_ms default to the ESP-side defaults
(currently 500 / 500). Pass explicit values to override."""
self._require_present()
parts = ['EJECT']
if pulse_ms is not None: parts.append('pulse=%d' % int(pulse_ms))
if dwell_ms is not None: parts.append('dwell=%d' % int(dwell_ms))
cmd = ' '.join(parts)
line = self._rpc(cmd, topic='eject', timeout=timeout)
if line.startswith('done'):
return
reason = line.split('reason=', 1)[1] if 'reason=' in line else line
raise AuxAxisError('EJECT failed: %s' % reason)
def close(self):
self._stop.set()
try:
@@ -456,13 +317,13 @@ class AuxAxis(object):
raise AuxAxisError('Aux axis not connected')
def _check_limits(self, target_mm):
lo = float(self._cfg['min_mm'])
hi = float(self._cfg['max_mm'])
lo = float(self._cfg['min_w'])
hi = float(self._cfg['max_w'])
if hi <= lo:
return # no limits
if target_mm < lo - 1e-6 or target_mm > hi + 1e-6:
raise AuxAxisError(
'A=%.3f out of soft limits [%.3f, %.3f]' % (target_mm, lo, hi))
'W=%.3f out of soft limits [%.3f, %.3f]' % (target_mm, lo, hi))
def _mm_to_steps(self, mm):
spm = float(self._cfg['steps_per_mm'])
@@ -495,64 +356,6 @@ 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):
@@ -585,14 +388,8 @@ 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=%d accel=%d step_max=%d step_start=%d limit_low=%d '
'preclear=%d') % (
'zero=%d accel=%d step_max=%d step_start=%d limit_low=%d') % (
c['home_dir'],
int(c['home_fast_sps']),
int(c['home_slow_sps']),
@@ -603,7 +400,6 @@ class AuxAxis(object):
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)
@@ -613,28 +409,11 @@ 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:
self._rpc('UNHOME', topic='ok', timeout=2.0)
self._homed = False
r = self._rpc('HOMED?', topic='homed', timeout=2.0)
self._homed = (r.strip() == '1')
except Exception:
# Fall back to whatever HOMED? says - but treat any
# missing UNHOME support as "trust ESP's flag" so we
# don't break older firmware.
try:
r = self._rpc('HOMED?', topic='homed', timeout=2.0)
self._homed = (r.strip() == '1')
except Exception:
pass
pass
self._publish_state()
def _reader_loop(self):
@@ -671,7 +450,7 @@ class AuxAxis(object):
self._present = True
self._publish_state()
self.ctrl.state.add_message(
'Auxiliary axis controller restarted - re-home before use')
'W axis controller restarted - re-home before use')
return
# Topic dispatch: "[topic] body..."
@@ -692,22 +471,7 @@ class AuxAxis(object):
self._pending_replies.append(body)
self._pending_cv.notify_all()
return
# Async informational line.
#
# The terminal [jog] done|aborted line for a continuous
# JOG arrives long after the JOG _rpc returned (the JOG
# _rpc only waits for the immediate `[jog] started`
# ack). Use this async path to keep _pos_steps in sync
# so subsequent moves compute the correct delta.
if topic == 'jog' and ('pos=' in body):
try:
self._pos_steps = self._parse_kv_int(
body, 'pos', self._pos_steps)
if 'reason=limit' in body:
self._homed = False
self._publish_state()
except Exception:
pass
# Async informational line; just log.
self.log.info('aux: %s' % line)
else:
self.log.info('aux: %s' % line)

View File

@@ -11,28 +11,20 @@
# so gplan handles W motion natively. The preprocessor no longer
# touches W tokens. ATC pneumatics still go through the hook
# channel because they're events, not motion.
# v3: ATC primitives split into atoms. The composite DROPTOOL /
# GRABTOOL hooks are gone; macros now compose tool changes from
# RELEASE / CLAMP / EJECT.
#
# What this still does
# --------------------
# Maps three user-defined M-codes onto pneumatic-tool-changer atoms:
# Maps four user-defined M-codes onto pneumatic-tool-changer events:
#
# M100 EJECT -> (MSG,HOOK:eject:) one V2 ejector pulse
# M102 RELEASE -> (MSG,HOOK:release:) open collet (V1 on)
# M103 CLAMP -> (MSG,HOOK:clamp:) close collet (V1 off + vent)
#
# M101 (formerly GRABTOOL) is intentionally unmapped - it's now a
# pure host-side macro composed from RELEASE / dwell / CLAMP. If a
# legacy file still emits M101 the preprocessor leaves it alone and
# the planner ignores it (M101 is in the user-defined range, so it
# won't error - it just won't do anything).
# M100 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 the recognized ones out and emit the
# matching hook line in their place.
# 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 is left alone.
@@ -46,46 +38,12 @@ import tempfile
# Strip line comments so we don't get fooled by "(M100 not really)".
# Note this is a simple regex and doesn't handle nested parentheses
# - which actually occur in real macro headers like
# `(Composed from atoms: M102 = RELEASE (V1 on), M103 = CLAMP)`.
# Use _strip_comments() below for a parser that does handle them.
_PAREN_COMMENT_RE = re.compile(r'\([^)]*\)')
def _strip_comments(line):
"""Return `line` with paren comments and the trailing semicolon
comment removed. Handles arbitrarily nested parentheses (RS274
technically forbids them but real-world gcode comments often
contain prose with parens, e.g. `(M102 = RELEASE (V1 on))`).
Returns just the executable code, with the original whitespace
preserved between tokens."""
out = []
depth = 0
i = 0
n = len(line)
while i < n:
c = line[i]
if c == ';' and depth == 0:
break
if c == '(':
depth += 1
i += 1
continue
if c == ')':
if depth > 0: depth -= 1
i += 1
continue
if depth == 0:
out.append(c)
i += 1
return ''.join(out)
# ATC pneumatics M-codes mapped onto hook events. M101 is
# deliberately unassigned (see header).
# ATC pneumatics M-codes mapped onto hook events.
_ATC_M_CODES = {
100: 'eject',
100: 'droptool',
101: 'grabtool',
102: 'release',
103: 'clamp',
}
@@ -101,55 +59,15 @@ _ATC_M_RE = re.compile(
# A axis through gplan.)
_W_TOKEN_RE = re.compile(r'(?<![A-Za-z_0-9])[Ww]\s*[-+]?\d*\.?\d+')
# Match a single axis word (letter + optional whitespace + signed decimal)
# for Z, A, X, Y. Used to extract modal targets while preserving the
# original line for emission. We deliberately ignore I/J/K/R (arc params)
# because they're not endpoints.
_AXIS_TOKEN_RES = {
'z': re.compile(r'(?<![A-Za-z_0-9])[Zz]\s*([-+]?\d*\.?\d+)'),
'a': re.compile(r'(?<![A-Za-z_0-9])[Aa]\s*([-+]?\d*\.?\d+)'),
'x': re.compile(r'(?<![A-Za-z_0-9])[Xx]\s*([-+]?\d*\.?\d+)'),
'y': re.compile(r'(?<![A-Za-z_0-9])[Yy]\s*([-+]?\d*\.?\d+)'),
}
_G_CODE_RE = re.compile(r'(?<![A-Za-z_0-9])[Gg]\s*0*(\d+(?:\.\d+)?)')
class AuxPreprocessorError(Exception):
pass
class AuxPreprocessor(object):
def __init__(self, log=None, coupling=None):
"""`coupling`, when supplied, enables Z-A coupling injection.
Expected shape:
{
'enabled': bool,
'clearance_mm': float, # max (A_wc - Z_wc)
'a_initial_wc': float, # A's work-coord position at
# file start (typically 0 if
# operator zeroed at home)
'z_initial_wc': float, # Z's work-coord position at
# file start (typically 0)
}
Pass None to disable injection (preprocessor still rewrites
ATC M-codes)."""
def __init__(self, log=None):
self.log = log
self._w_warned = False
self._coupling = coupling if (coupling and
coupling.get('enabled')) else None
# Modal state used while scanning the file.
if self._coupling is not None:
self._a_wc = float(coupling.get('a_initial_wc', 0.0))
self._z_wc = float(coupling.get('z_initial_wc', 0.0))
self._K = float(coupling.get('clearance_mm', 0.0))
else:
self._a_wc = 0.0
self._z_wc = 0.0
self._K = 0.0
self._g91_warned = False
# Distance mode: True for absolute (G90), False for incremental
# (G91). Per RS274 the modal default at start is G90.
self._g90 = True
def _info(self, msg):
if self.log: self.log.info(msg)
@@ -160,22 +78,16 @@ class AuxPreprocessor(object):
# ------------------------------------------------------------------ scan
@staticmethod
def file_uses_aux(path, coupling=None):
def file_uses_aux(path):
"""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'))
would rewrite (currently: just ATC M-codes)?"""
try:
with open(path, 'r', encoding='utf-8', errors='replace') as f:
for line in f:
code = _strip_comments(line)
code = _PAREN_COMMENT_RE.sub('', line)
code = code.split(';', 1)[0]
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
@@ -183,170 +95,6 @@ class AuxPreprocessor(object):
# Backwards-compat alias.
file_uses_w = file_uses_aux
# ------------------------------------------------------------------ Z-A coupling
#
# Track modal Z and A targets across the file. Whenever a line
# would put A above Z by more than `clearance_mm` (i.e. A_wc -
# Z_wc > K), we inject `G0 A<safe>` immediately before it so A is
# already at the safe position when Z descends. The injected move
# uses G0 (rapid) so it's quick.
#
# Endpoint-only check: gplan plans line endpoints. As long as
# (target_A_wc - target_Z_wc) <= K, the trajectory stays safe
# because Z's *minimum* during a single line is its endpoint (Z
# moves monotonically along a single line block in absolute
# mode) and A is held at the pre-positioned value during the move.
def _extract_g_codes(self, code):
"""Return the set of G-codes referenced on `code`. Numeric
only, e.g. {0, 1, 90, 17}. Used to track modal state."""
out = set()
for m in _G_CODE_RE.finditer(code):
try:
out.add(int(float(m.group(1))))
except Exception:
pass
return out
def _extract_axis(self, axis, code):
"""Return the last value of `axis` token on `code`, or None."""
rx = _AXIS_TOKEN_RES.get(axis)
if rx is None:
return None
last = None
for m in rx.finditer(code):
try:
last = float(m.group(1))
except Exception:
pass
return last
def _maybe_inject_a_down(self, code, fout):
"""Inspect `code` (with comments stripped) for an upcoming Z
descent; emit a `G0 A<safe>` line on `fout` if needed and
update self._a_wc accordingly. Returns True if anything was
injected.
On a violation that cannot be fixed by lowering A (e.g. the
operator wrote `G0 A0` while Z is too deep), raise
AuxPreprocessorError so the file load surfaces the problem -
per the rule we agreed: error, don't silently insert a Z-up.
"""
if self._coupling is None:
return False
# Distance mode tracking.
gs = self._extract_g_codes(code)
if 90 in gs: self._g90 = True
if 91 in gs:
if self._g90 and not self._g91_warned:
self._warn(
'AuxPreprocessor: G91 (incremental mode) detected; '
'Z-A coupling injection is disabled for the rest of '
'the file. The runtime check still applies.')
self._g91_warned = True
self._g90 = False
# G92 sets coordinate offsets. The new modal value of an
# axis is whatever value follows on the same word (e.g.
# G92 A0 sets A_wc = 0). Apply that and skip injection.
if 92 in gs:
new_a = self._extract_axis('a', code)
new_z = self._extract_axis('z', code)
if new_a is not None: self._a_wc = new_a
if new_z is not None: self._z_wc = new_z
return False
# In incremental mode we can still track approximately, but
# the user has been warned; skip injection.
if not self._g90:
return False
new_z_target = self._extract_axis('z', code)
new_a_target = self._extract_axis('a', code)
if new_z_target is None and new_a_target is None:
return False
# Modal values after the line executes.
a_after = new_a_target if new_a_target is not None else self._a_wc
z_after = new_z_target if new_z_target is not None else self._z_wc
eps = 1e-4
if a_after - z_after <= self._K + eps:
# Move is safe as authored. Update modal state.
self._a_wc = a_after
self._z_wc = z_after
return False
# Violation. Two cases:
#
# (a) The line lowers Z (z_after < self._z_wc) and A is
# held or moved upward, so A needs to drop to keep up.
# We can fix this by pre-positioning A at z_after + K
# BEFORE the line - at which point gplan's plan for the
# line is safe at every point along it.
#
# (b) The line raises A above the safe band while Z is
# held (z_after >= self._z_wc) - i.e. the operator
# wrote `G0 A0` while Z is parked deep. Auto-injecting
# a Z-up here is unsafe (Z could swing into a fixture
# or the part) so we error out and let the operator
# author the lift.
safe_a = z_after + self._K
# If the line itself targets an A above the safe band, the
# endpoint violates the rule no matter what we pre-position.
# Refuse rather than emit something that runs the gantry into
# the tool.
if new_a_target is not None and new_a_target > safe_a + eps:
raise AuxPreprocessorError(
'Z-A coupling violation: line targets A=%.3f at '
'Z=%.3f, but max A allowed is %.3f (clearance %.3f). '
'Lower the A target or add a Z-up move first.' % (
new_a_target, z_after, safe_a, self._K))
# If the line raises A above the current safe band but Z
# isn't dropping with it (no Z target on the line, or Z stays
# put), the violation is the operator's A-up, not a Z-down.
# Refuse rather than insert a Z-up (which could swing through
# a fixture or part).
if (new_a_target is not None and
new_a_target > self._a_wc + eps and
new_z_target is None):
raise AuxPreprocessorError(
'Z-A coupling violation at line raising A to %.3f '
'while Z is at %.3f (max A allowed is %.3f given '
'clearance %.3f). Add a Z-up move first.' % (
new_a_target, z_after, safe_a, self._K))
# Case (a): pre-position A.
# Don't move A *up* as part of pre-position - if the safe
# value is above where A already is, we'd lift A into a
# potential collision elsewhere. In practice safe_a < a_wc
# whenever we get here (otherwise no violation), but assert
# to be sure.
if safe_a > self._a_wc + eps:
raise AuxPreprocessorError(
'Z-A coupling: cannot fix line by lowering A '
'(safe A = %.3f > current A = %.3f).' % (
safe_a, self._a_wc))
fout.write('(injected by AuxPreprocessor: Z-A coupling)\n')
fout.write('G0 A%.4f\n' % safe_a)
self._a_wc = safe_a
# Don't update z_wc yet - the original line will do that
# when it runs. But our modal copy must reflect the post-line
# value so subsequent injections compute correctly.
self._z_wc = z_after
# If the original line also moved A, our pre-positioning
# supersedes it (we overwrite a_wc above with safe_a then
# the original line's A target may push it back up). Update
# a_wc to the line's authored A value so further checks see
# the post-line state.
if new_a_target is not None:
self._a_wc = new_a_target
return True
# ------------------------------------------------------------------ run
def process(self, src_path, dst_path):
@@ -360,7 +108,8 @@ class AuxPreprocessor(object):
line = raw.rstrip('\n')
# Comment-only or blank lines pass through verbatim.
code = _strip_comments(line)
code = _PAREN_COMMENT_RE.sub('', line)
code = code.split(';', 1)[0]
if not code.strip():
fout.write(raw)
continue
@@ -375,18 +124,10 @@ class AuxPreprocessor(object):
'subsequent W tokens in this file)')
self._w_warned = True
# Z-A coupling injection BEFORE the line is emitted.
if self._maybe_inject_a_down(code, fout):
rewrote_any = True
# ATC M-codes (M100/M102/M103). Match against the
# comment-stripped `code` so prose mentions like
# `(M102 = RELEASE)` inside a comment don't spuriously
# fire hooks. Each match emits a (MSG,HOOK:<event>:)
# line; the M-code is stripped from the executable
# residual but the original line's comments are kept
# for log readability.
atc_matches = list(_ATC_M_RE.finditer(code))
# 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:
@@ -394,49 +135,19 @@ class AuxPreprocessor(object):
except ValueError: continue
event = _ATC_M_CODES.get(num)
if event:
# We need two things here that aren't
# naturally provided by the (MSG,...)
# transport:
#
# (1) Synchronization. (MSG,HOOK:...) is
# fire-and-forget from gplan's view -
# gplan emits the message and keeps
# streaming subsequent blocks (Z
# moves, the next eject, etc.) to the
# AVR. Meanwhile the hook handler
# runs the actual ESP RPC in a
# thread, and Z lifts while V2 is
# still wiggling. To make M-codes
# behave like proper blocking gcode,
# we precede each HOOK with M0
# (program pause). The Hooks layer
# registers the atom as block_unpause
# + auto_resume, so:
# M0 -> machine pauses
# (MSG,HOOK:event:) fires hook
# hook thread runs ESP RPC
# hook completes, auto-unpauses
# next block streams
# End result: M100/M102/M103 block
# until the ESP says done, just like
# a G-code dwell.
#
# (2) Block separation. gplan collapses
# consecutive comment-only lines
# into a single block, so back-to-
# back HOOK lines used to drop all
# but the last. M0 is its own block
# so this falls out automatically -
# the (MSG,...) attaches cleanly to
# each M0.
fout.write('M0 (MSG,HOOK:%s:)\n' % event)
code_stripped = _ATC_M_RE.sub('', code).strip()
if code_stripped:
# Mixed line: keep the residual executable
# gcode. Drop the comments to keep the
# rewritten file tidy (the original line's
# text already appears once as the input).
fout.write(code_stripped + '\n')
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
# No rewrite needed.
@@ -445,63 +156,28 @@ class AuxPreprocessor(object):
return rewrote_any
def preprocess_to_tempfile(src_path, log=None, coupling=None):
"""Run the preprocessor on `src_path` and return the path to a
rewritten temp file (or None if no rewriting was needed). Caller
owns the temp file and must os.unlink() it when done.
def preprocess_file(src_path, log=None, **_unused):
"""Convenience: rewrite src_path in place if it contains ATC
M-codes. Returns True if the file was rewritten.
The original source file is never modified - this is the
intentional design: the macro / job file the operator authored
is what they see in the macro editor and the file viewer; the
rewriting happens only on the in-memory copy that gplan loads.
Why we rewrite at all: gplan (the camotics planner) treats the
user-defined M-codes M100/M102/M103 as no-ops. The only callback
channel it exposes during a running program is the (MSG,...)
message stream, so the only way for the host to react to those
M-codes mid-program is to substitute (MSG,HOOK:<event>:) lines
in their place. This rewriting is an implementation detail the
operator should never have to know about - hence the tempfile.
"""
if not AuxPreprocessor.file_uses_aux(src_path, coupling=coupling):
return None
pre = AuxPreprocessor(log=log, coupling=coupling)
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):
return False
pre = AuxPreprocessor(log=log)
fd, tmp = tempfile.mkstemp(prefix='auxpre_', suffix='.nc',
dir=os.path.dirname(src_path) or None)
os.close(fd)
try:
rewrote = pre.process(src_path, tmp)
if rewrote:
return tmp
shutil.move(tmp, src_path)
return True
os.unlink(tmp)
return None
except Exception:
try:
os.unlink(tmp)
except OSError:
pass
raise
def preprocess_file(src_path, log=None, coupling=None, **_unused):
"""DEPRECATED in-place version of the preprocessor. Kept for
callers that still rewrite their input on disk (chiefly the
upload path, where mutating the file is fine because there's no
operator-authored source to preserve).
Returns True if the file was rewritten, False otherwise.
For new callers prefer preprocess_to_tempfile() which never
touches the source."""
tmp = preprocess_to_tempfile(src_path, log=log, coupling=coupling)
if tmp is None:
return False
try:
shutil.move(tmp, src_path)
except Exception:
try:
os.unlink(tmp)
except OSError:
pass
raise
return True

View File

@@ -216,32 +216,6 @@ 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

@@ -166,46 +166,31 @@ class Ctrl(object):
else:
self.aux.home()
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_eject(ctx):
# ctx['data'] is the payload after HOOK:eject:. Allow
# operators to override pulse / dwell from gcode via
# (MSG,HOOK:eject:pulse=400 dwell=300). Empty data ->
# ESP defaults.
data = (ctx.get('data') or '').strip()
kw = {}
for tok in data.split():
if '=' not in tok: continue
k, v = tok.split('=', 1)
k = k.strip().lower()
if k in ('pulse', 'pulse_ms'):
try: kw['pulse_ms'] = int(v)
except ValueError: pass
elif k in ('dwell', 'dwell_ms'):
try: kw['dwell_ms'] = int(v)
except ValueError: pass
self.aux.atc_eject(**kw)
# Legacy alias for older gcode that used aux_home.
self.hooks.register_internal('aux_home', _hook_aux_home,
block_unpause=True, auto_resume=True,
timeout=180)
# ATC pneumatic atoms. block_unpause + auto_resume so a
# program using M100/M102/M103 pauses at the right point and
# resumes once each atom finishes. Macros compose drop/grab
# sequences from these primitives.
# 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)
self.hooks.register_internal('eject', _hook_eject,
block_unpause=True, auto_resume=True,
timeout=15)
log.info('Aux hooks registered')

View File

@@ -146,8 +146,8 @@ class ExternalAxis(object):
"""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))
lo = float(self.aux._cfg.get('min_w', 0.0))
hi = float(self.aux._cfg.get('max_w', 0.0))
except Exception:
return (None, None)
if hi <= lo:
@@ -185,147 +185,6 @@ class ExternalAxis(object):
'[%.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):
@@ -371,8 +230,8 @@ class ExternalAxis(object):
# 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)))
st.set(i + 'tn', float(cfg.get('min_w', 0.0)))
st.set(i + 'tm', float(cfg.get('max_w', 0.0)))
# home_position / home_travel are exposed as callbacks for
# motors 0..3 (see State.__init__). Register the same lazy
@@ -383,7 +242,7 @@ class ExternalAxis(object):
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))
lambda name: float(self.aux._cfg.get('max_w', 0.0))
- self.home_position_mm)
# Misc fields that other code paths might query. Defaults
@@ -465,9 +324,6 @@ class ExternalAxis(object):
'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
@@ -483,18 +339,29 @@ class ExternalAxis(object):
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.
"""Non-blocking variant: post a target to the worker queue.
Used by Planner.__encode in the hot path.
We deliberately do NOT mirror the new target into state.<axis>p
here. The AVR also receives the line block (we don't strip
the external axis from the AVR target) and its trapezoid
progressively updates ex.position[A] -> reports back as ap.
If we set state.ap here ahead of the AVR, the DRO would jump
to the target and then snap back to intermediate values as
AVR reports stream in. Instead we let the AVR drive state.ap.
We still mirror the new abs into our internal _pos_mm so
the next line block computes the correct delta.
The Planner.__encode hook calls this so the AVR comm thread
is never blocked by serial RPCs to the ESP.
Soft limits are enforced here (defense in depth on top of
gplan)."""
gplan). Raising stops the planner via Planner.next's
exception handler."""
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.
@@ -503,49 +370,6 @@ class ExternalAxis(object):
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."""
@@ -573,15 +397,6 @@ class ExternalAxis(object):
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.

View File

@@ -107,27 +107,11 @@ class FileHandler(bbctrl.APIHandler):
# auxcnc stepper is exposed as a virtual A axis (see
# ExternalAxis).
try:
from bbctrl.AuxPreprocessor import (
preprocess_file, AuxPreprocessorError)
from bbctrl.AuxPreprocessor import preprocess_file
log = self.get_log('AuxPreprocessor')
ext = getattr(self.get_ctrl(), 'ext_axis', None)
coupling = (ext.coupling_for_preprocessor()
if ext is not None else None)
try:
if preprocess_file(filename.decode('utf8'),
log=log, coupling=coupling):
log.info('Rewrote upload (ATC / Z-A coupling) in %s'
% self.uploadFilename)
except AuxPreprocessorError as e:
# Surface coupling-violation errors to the operator
# via the message stream so the upload doesn't go
# silently un-rewritten and then trip the runtime
# check (which can hang the planner dialog).
log.warning('Aux preprocess refused upload: %s' % e)
try:
self.get_ctrl().state.add_message(
'Z-A coupling: ' + str(e))
except Exception: pass
if preprocess_file(filename.decode('utf8'), log=log):
log.info('Rewrote ATC M-codes in %s' %
self.uploadFilename)
except Exception:
self.get_log('AuxPreprocessor').exception(
'Aux preprocess failed; uploading unchanged')

View File

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

View File

@@ -95,10 +95,6 @@ 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')
@@ -331,16 +327,6 @@ 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)
@@ -371,10 +357,26 @@ class Mach(Comm):
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))
self.mlog.info('Homing external %s axis via auxcnc' %
axis.upper())
try:
self._begin_cycle('homing')
ext.home()
home_mm = ext.home_position_mm
# 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 as e:
self.mlog.error(
'External axis homing failed: %s' % e)
continue
# If this is not a request to home a specific axis and the
@@ -407,122 +409,6 @@ 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 _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

View File

@@ -27,7 +27,6 @@
import json
import math
import os
import re
import time
from collections import deque
@@ -77,10 +76,6 @@ class Planner():
self.planner = None
self._position_dirty = False
self.where = ''
# Tracks the rewritten temp file (if any) returned by the
# AuxPreprocessor for the currently-loaded program. We delete
# it on the next load() so it doesn't pile up under /tmp.
self._aux_tempfile = None
ctrl.state.add_listener(self._update)
@@ -275,47 +270,6 @@ 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-
@@ -450,10 +404,11 @@ class Planner():
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."""
For mixed XYZ + external moves the AVR runs XYZ at the
gplan-computed rate while the ESP runs the external delta
in parallel. Final positions agree; intermediate ap reports
from the AVR may briefly disagree with state.ap until the
block completes."""
target = block.get('target') or {}
# Read the external target (case-insensitive) without modifying
# the dict so the AVR still sees A.
@@ -461,14 +416,7 @@ class Planner():
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),
)
ext.enqueue_target_mm(ext_mm)
except Exception as e:
self.log.error('External axis enqueue failed: %s' % e)
raise
@@ -512,57 +460,25 @@ class Planner():
def load(self, path):
self.where = path
src_path = self.ctrl.get_path('upload', path)
self.log.info('GCode:' + src_path)
# Clean up any leftover temp file from a previous load.
self._cleanup_aux_tempfile()
# Rewrite ATC M-codes (M100/M102/M103) and inject Z-A
# coupling moves before gplan sees them. The rewriting goes
# to a temp file -- the operator's macro / job source is
# never modified. This matters because:
#
# 1. The macro editor reads back the source. If we
# rewrote in place, the operator would open `drop.nc`
# and see (MSG,HOOK:...) blobs instead of the M-code
# sequence they wrote.
# 2. Re-running a rewritten file would re-rewrite it; any
# bug in the regex (e.g. with paren comments) would
# compound on every load.
#
# Why we rewrite at all: gplan treats M100..M103 as no-ops
# by spec and exposes no callback for user M-codes. Its only
# in-band channel back to Python during a running program is
# the (MSG,...) message stream, so we substitute hook
# messages for the M-codes purely as transport.
load_path = src_path
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_to_tempfile
ext = getattr(self.ctrl, 'ext_axis', None)
coupling = (ext.coupling_for_preprocessor()
if ext is not None else None)
tmp = preprocess_to_tempfile(
src_path, log=self.log, coupling=coupling)
if tmp is not None:
self._aux_tempfile = tmp
load_path = tmp
self.log.info(
'Rewrote (ATC / Z-A coupling) for gplan: %s -> %s'
% (src_path, tmp))
from bbctrl.AuxPreprocessor import preprocess_file
if preprocess_file(path, log = self.log):
self.log.info('Rewrote ATC M-codes in %s' % path)
except Exception:
self.log.exception('Aux preprocess at load failed; '
'attempting to load file unchanged')
self._sync_position()
self.planner.load(load_path, self.get_config(False, True))
self.planner.load(path, self.get_config(False, True))
self.reset_times()
def _cleanup_aux_tempfile(self):
if self._aux_tempfile and os.path.exists(self._aux_tempfile):
try: os.unlink(self._aux_tempfile)
except OSError: pass
self._aux_tempfile = None
def stop(self):
try:

View File

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

View File

@@ -107,14 +107,8 @@ class State(object):
def reset(self):
# 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)
# Unhome all motors
for i in range(4): self.set('%dhomed' % i, False)
# Zero offsets and positions
for axis in 'xyzabc':

View File

@@ -411,22 +411,11 @@ class PathHandler(bbctrl.APIHandler):
except gen.TimeoutError:
progress = preplanner.get_plan_progress(filename)
err = preplanner.get_plan_error(filename)
resp = dict(progress = progress)
if err: resp['error'] = err
self.write_json(resp)
self.write_json(dict(progress = progress))
return
try:
# Plan finished but produced no data (planner subprocess
# failed, e.g. AuxPreprocessor coupling rejection at
# planner-load time). Surface the error so the UI can
# close the "Processing New File" dialog instead of
# polling forever.
if data is None:
err = preplanner.get_plan_error(filename) or 'Plan failed'
self.write_json(dict(progress = 1, error = err))
return
if data is None: return
meta, positions, speeds = data
if dataType == '/positions': data = positions

View File

@@ -1396,7 +1396,7 @@ tt.save
.control-page .dro-head, .control-page .dro-row
display grid
grid-template-columns 84px 1.4fr 1fr 1fr 280px
grid-template-columns 84px 1.4fr 1fr 1fr 170px 170px 280px
column-gap 0.75rem
align-items center
padding 14px 22px
@@ -1529,39 +1529,6 @@ 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
@@ -2413,7 +2380,7 @@ html.kiosk-mode
gap 6px
.control-page .dro-head, .control-page .dro-row
grid-template-columns 56px 1fr 0.85fr 0.85fr 1fr
grid-template-columns 56px 1fr 0.85fr 0.85fr 90px 90px 1fr
column-gap 0.4rem
padding 6px 10px

View File

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

View File

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

View File

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