Compare commits

30 Commits

Author SHA1 Message Date
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
56 changed files with 7669 additions and 1898 deletions

0
.devcontainer/install_tools.sh Normal file → Executable file
View File

16
.gitignore vendored
View File

@@ -27,3 +27,19 @@ __pycache__
*.elf
*.hex
.idea/deployment.xml
backup/*.img.gz
backup/*.partial
# Demo mode artifacts
bbctrl.log*
hooks.json
/*/bbctrl.log*
src/py/camotics/__init__.py
src/py/camotics/gplan.so
src/avr/emu/bbemu
src/avr/emu/build/
.pi/pi-python35.tar.gz
src/py/camotics/gplan.so.built
tmp/
backup/

249
.pi/BUILD.md Normal file
View File

@@ -0,0 +1,249 @@
# Onefinity CNC Firmware — Build, Flash & Backup
## Quick Start
```bash
# 1. Build gplan.so (first time ~25min, then ~1sec)
.pi/build-gplan.sh
# 2. Build firmware package (frontend + AVR + Python, ~1min)
docker run --rm -v "$(pwd):/workspace" -w /workspace onefin-dev \
bash -c 'make all && python3 ./setup.py sdist'
# 3. Flash to controller
curl -X PUT -F "firmware=@dist/bbctrl-1.6.7.tar.bz2" \
-F "password=onefinity" http://10.1.10.55/api/firmware/update
```
## Architecture Overview
The controller is a **Raspberry Pi 2/3** (armv7l, Raspbian Stretch, Python 3.5)
connected to an **ATxmega192a3u** AVR over serial. The Pi runs a Tornado web
server that serves the UI, parses G-code, and plans motion. The AVR executes
realtime step/direction pulses.
```
Browser ←WebSocket→ Pi (Tornado/Python) → GCode Planner → Serial → AVR → Stepper drivers
```
The firmware package (`bbctrl-X.Y.Z.tar.bz2`) contains:
| Component | Source | Description |
|---|---|---|
| Python backend | `src/py/bbctrl/` | Tornado web server, state machine, planner bridge |
| Web frontend | `build/http/` | Pug + Stylus + Svelte → static HTML/JS/CSS |
| AVR firmware | `src/avr/bbctrl-avr-firmware.hex` | Realtime motion controller |
| gplan.so | `src/py/camotics/gplan.so` | CAMotics G-code planner (native ARM C++ extension) |
| Install scripts | `scripts/install.sh` | AVR flash, Python install, service restart |
## Prerequisites
- **Docker** with QEMU binfmt support (default on Docker Desktop for Mac)
- **devcontainer image**: `docker build -t onefin-dev -f .devcontainer/Dockerfile .devcontainer/`
- **SSH access**: `ssh bbmc@10.1.10.55` (password: `onefinity`)
## Building
### Step 1: gplan.so
`gplan.so` is the CAMotics G-code planner — a C++ Python extension that must
be a **32-bit ARM binary linked against Python 3.5**. It cannot be built in the
devcontainer (wrong arch + wrong Python + wrong glibc).
**Build from source** (recommended):
```bash
.pi/build-gplan.sh
```
This uses a Raspbian Stretch Docker image (`balenalib/raspberry-pi-debian:stretch`)
with the Pi's exact toolchain: GCC 6.3, Python 3.5, GLIBC 2.24. The image is
built once (~25min under QEMU), then cached — subsequent runs take ~1sec.
The image pre-compiles two C++ dependencies:
- [cbang](https://github.com/CauldronDevelopmentLLC/cbang) @ `18f1e96` — C++ utility library
- [camotics](https://github.com/CauldronDevelopmentLLC/camotics) @ `ec876c8` — G-code planner with S-curve motion planning
To force a full rebuild: `docker rmi onefin-gplan && .pi/build-gplan.sh`
**Alternatives** (if Docker build fails):
```bash
# From official release
curl -sL https://github.com/OneFinityCNC/onefinity-firmware/releases/download/v1.6.6/onefinity-1.6.6.tar.bz2 \
| tar xjf - --include='*/gplan.so' -O > src/py/camotics/gplan.so
# From the running Pi
scp bbmc@10.1.10.55:/usr/local/lib/python3.5/dist-packages/bbctrl-*.egg/camotics/gplan.so src/py/camotics/
```
**Verify** — must show `ELF 32-bit ... ARM ... libpython3.5m`:
```bash
file src/py/camotics/gplan.so
readelf -d src/py/camotics/gplan.so | grep python
```
### Step 2: Firmware package
```bash
docker run --rm -v "$(pwd):/workspace" -w /workspace onefin-dev \
bash -c 'make all && python3 ./setup.py sdist'
```
This builds inside the devcontainer (arm64 Bullseye — fine for frontend/AVR/Python):
| Component | Tool | Time |
|---|---|---|
| Node modules | `npm install` | ~30sec |
| Svelte components | `vite build` | ~5sec |
| Pug/Stylus → HTML | `pug-cli`, `stylus` | ~2sec |
| AVR firmware | `avr-g++` (ATxmega192a3u) | ~10sec |
| Boot/Power/Jig MCUs | `avr-gcc` | ~5sec |
| Python sdist | `setup.py sdist` | ~2sec |
Produces: `dist/bbctrl-X.Y.Z.tar.bz2` (~3-4MB)
### bbserial.ko (kernel module — usually skip)
Cross-compiles against the Pi's kernel headers (4.9.59-v7+). The Pi already has
a working `bbserial.ko` installed. `install.sh` skips it gracefully if missing.
## Flashing
### Via web API (machine running)
```bash
curl -X PUT -F "firmware=@dist/bbctrl-1.6.7.tar.bz2" \
-F "password=onefinity" http://10.1.10.55/api/firmware/update
```
Or: `make update HOST=10.1.10.55`
### Via SSH (web UI down or crash-looping)
```bash
scp dist/bbctrl-1.6.7.tar.bz2 bbmc@10.1.10.55:/tmp/
ssh bbmc@10.1.10.55 'echo onefinity | sudo -S bash -c "
systemctl stop bbctrl
mkdir -p /var/lib/bbctrl/firmware
cp /tmp/bbctrl-1.6.7.tar.bz2 /var/lib/bbctrl/firmware/update.tar.bz2
/usr/local/bin/update-bbctrl
"'
```
### What happens during flash
1. `update-bbctrl` stops bbctrl, extracts tarball to `/tmp/update/`
2. `install.sh` runs:
- Flashes AVR via `scripts/avr109-flash.py` (serial bootloader protocol)
- `setup.py install --force` — installs Python package + frontend + gplan.so
- Restarts `bbctrl` systemd service
- May reboot if boot config or kernel module changed
### Recovery from bad flash
SSH still works even when bbctrl is crash-looping:
1. Check the error: `sudo python3 /usr/local/bin/bbctrl 2>&1 | head -20`
2. Common cause: wrong gplan.so architecture → replace with correct one (see above)
3. Nuclear option: restore SD card from backup
## Running Locally (demo mode)
Full stack in Docker with AVR emulator — no Pi needed:
```bash
# Build bbemu (AVR emulator, native in devcontainer)
docker run --rm -v "$(pwd):/workspace" -w /workspace/src/avr/emu onefin-dev make
# Run demo (needs arm64 gplan.so for the container, not armhf)
docker run --rm -d --name onefin-demo \
-v "$(pwd):/workspace" -w /workspace -p 8765:80 \
onefin-dev bash -c '
pip3 install -q tornado sockjs-tornado pyserial watchdog
cp src/avr/emu/bbemu /usr/local/bin/
pip3 install -q -e .
exec bbctrl --demo --port 80 --addr 0.0.0.0 --disable-camera
'
```
Open http://localhost:8765 — full UI with emulated controller.
Note: demo mode needs a **container-arch** gplan.so (arm64 + Python 3.9), not the
Pi one. The devcontainer build from the Makefile's `gplan` target produces this,
or it can be built following the procedure in `scripts/gplan-build.sh`.
## SD Card Backup & Restore
```bash
# Backup (~50 min, streams raw dd from Pi, compresses locally)
./backup/onefinity-backup.sh backup
# Verify
./backup/onefinity-backup.sh verify backup/onefinity-20260430.img.gz
# Restore to local SD card
./backup/onefinity-backup.sh restore backup/onefinity-20260430.img.gz /dev/diskN
# Restore back to Pi over SSH
./backup/onefinity-backup.sh restore backup/onefinity-20260430.img.gz
```
Environment: `ONEFINITY_HOST` (default 10.1.10.55), `ONEFINITY_USER` (bbmc),
`ONEFINITY_PASS` (onefinity).
## Python 3.5 Compatibility
The Pi runs Python 3.5.3. Avoid features added in later versions:
| Avoid | Use instead |
|---|---|
| `f"hello {name}"` | `"hello %s" % name` or `"hello {}".format(name)` |
| `subprocess.run(capture_output=True)` | `stdout=subprocess.PIPE, stderr=subprocess.PIPE` |
| `subprocess.run(text=True)` | `.stdout.decode('utf-8')` |
| `dataclasses` | plain classes with `__init__` |
| `:=` walrus operator | separate assignment |
| `asyncio.run()` | `loop.run_until_complete()` |
| `dict[str, int]` | `Dict[str, int]` from `typing` |
## Pi Details
| | |
|---|---|
| Host | `10.1.10.55` |
| SSH | `bbmc` / `onefinity` |
| OS | Raspbian Stretch (Debian 9) |
| Kernel | 4.9.59-v7+ |
| Python | 3.5.3 |
| GCC | 6.3.0 |
| GLIBC | 2.24 (max symbol: GLIBC_2.24) |
| GLIBCXX | 3.4.22 |
| Arch | armv7l (32-bit ARM, EABI5) |
| SD card | 30GB (~2.8GB used) |
| Service | `systemctl {start,stop,restart,status} bbctrl` |
| Log | `/var/log/bbctrl.log` or `journalctl -u bbctrl` |
| Config | `/var/lib/bbctrl/config.json` |
| Uploads | `/var/lib/bbctrl/upload/` |
| Web root | `/usr/local/lib/python3.5/dist-packages/bbctrl-*.egg/bbctrl/http/` |
| AVR serial | `/dev/ttyAMA0` at 230400 baud |
## Why Not Build gplan.so on Bullseye?
Documented for reference — we tried two approaches that don't work:
**1. devcontainer (arm64 Bullseye):** Wrong ELF class (64-bit vs 32-bit) and wrong
Python (3.9 vs 3.5). Cross-compiling with `CXX=arm-linux-gnueabihf-g++` fails
because SCons ignores CC/CXX environment variables.
**2. Bullseye armhf container:** Correct architecture, but GCC 10 / glibc 2.31
produce objects requiring GLIBC_2.29+ and GLIBCXX_3.4.26+ symbols. The Pi's
Stretch only has GLIBC_2.24 / GLIBCXX_3.4.22. Even `-static-libstdc++
-static-libgcc` doesn't help — glibc symbols leak through the object files.
Relinking against Python 3.5m works but the GLIBC mismatch remains.
**3. Plain `debian:stretch` armhf:** The archived repos have broken package
metadata — `apt-get install build-essential` fails with unresolvable version
conflicts.
**Solution:** `balenalib/raspberry-pi-debian:stretch` with `legacy.raspbian.org`
repos. See `.pi/Dockerfile.gplan`.

48
.pi/Dockerfile.gplan Normal file
View File

@@ -0,0 +1,48 @@
# Raspbian Stretch armhf build environment for gplan.so
# Matches the Pi exactly: GCC 6.3, Python 3.5, GLIBC 2.24
#
# Build image: docker build -t onefin-gplan -f .pi/Dockerfile.gplan .pi/
# Build gplan: .pi/build-gplan.sh
FROM balenalib/raspberry-pi-debian:stretch
# Fix repos to use archived Raspbian mirrors
RUN echo "deb http://legacy.raspbian.org/raspbian/ stretch main contrib non-free rpi" \
> /etc/apt/sources.list && \
rm -f /etc/apt/sources.list.d/*.list
RUN apt-get -o Acquire::Check-Valid-Until=false \
-o Acquire::AllowInsecureRepositories=true update && \
apt-get -o Acquire::Check-Valid-Until=false --allow-unauthenticated \
install -y --no-install-recommends \
build-essential python3-dev scons git ca-certificates \
libssl-dev libexpat1-dev libbz2-dev liblz4-dev zlib1g-dev perl file && \
rm -rf /var/lib/apt/lists/*
# Clone and build cbang
RUN mkdir -p /opt/cbang && cd /opt/cbang && git init -q && \
git remote add origin https://github.com/CauldronDevelopmentLLC/cbang && \
git fetch --depth 1 -q origin 18f1e963107ef26abe750c023355a5c40dd07853 && \
git reset --hard FETCH_HEAD -q && \
scons -j2 disable_local="re2 libevent" && \
rm -rf .git build/dep
# Clone, patch, and build camotics/gplan
RUN mkdir -p /opt/camotics && cd /opt/camotics && git init -q && \
git remote add origin https://github.com/CauldronDevelopmentLLC/camotics && \
git fetch --depth 1 -q origin ec876c80d20fc19837133087cef0c447df5a939d && \
git reset --hard FETCH_HEAD -q && \
mkdir -p build && touch build/version.txt && \
P="src/gcode/plan" && \
for F in LineCommand.cpp LinePlanner.cpp; do \
for V in maxVel maxJerk maxAccel; do \
perl -i -0pe "s/(fabs\((config\.$V\[axis\]) \/ unit\[axis\]\));/std::min(\2, \1);/gm" $P/$F; \
done; \
done && \
rm -rf .git
ENV CBANG_HOME=/opt/cbang
# Pre-compile everything including gplan.so
RUN cd /opt/camotics && scons -j2 gplan.so with_gui=0 with_tpl=0
WORKDIR /opt/camotics

30
.pi/build-gplan.sh Executable file
View File

@@ -0,0 +1,30 @@
#!/usr/bin/env bash
set -euo pipefail
# Build gplan.so for the Onefinity Pi (armv7l, Python 3.5, GCC 6.3)
#
# Uses a Raspbian Stretch Docker image that exactly matches the Pi's
# toolchain. No cross-compile, no relink hacks, no GLIBC mismatches.
#
# First run: ~30min (builds Docker image with cbang + camotics)
# After that: ~1sec (copies pre-built gplan.so from image)
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
IMAGE="onefin-gplan"
OUTPUT="$PROJECT_DIR/src/py/camotics/gplan.so"
# Build image if needed (one-time)
if ! docker image inspect "$IMAGE" >/dev/null 2>&1; then
echo "Building $IMAGE Docker image (one-time, ~30min under QEMU)..."
docker build -t "$IMAGE" -f "$SCRIPT_DIR/Dockerfile.gplan" "$SCRIPT_DIR"
fi
# Copy gplan.so out of the image
echo "Extracting gplan.so..."
docker run --rm -v "$PROJECT_DIR:/workspace" "$IMAGE" \
bash -c 'cp /opt/camotics/gplan.so /workspace/src/py/camotics/gplan.so && \
file /workspace/src/py/camotics/gplan.so && \
readelf -d /workspace/src/py/camotics/gplan.so | grep -E "NEEDED|python"'
echo "✓ Built: $OUTPUT"

123
README.md
View File

@@ -1 +1,122 @@
#OneFinity CNC Controller Firmware
# OneFinity CNC Controller Firmware (W-axis fork)
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
```
src/avr/ AVR firmware (motion controller, AtxMega)
src/boot/ AVR bootloader
src/bbserial/ Linux kernel module for the bbserial driver
src/py/bbctrl/ Python control daemon (Tornado + websockets)
src/js/ Vue.js UI (legacy)
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, W-axis docs
```
## Build & flash (quick path, macOS or Linux)
The full build (`make`) requires `avr-gcc`, but the controller and UI
only depend on the Python + web parts. If you're shipping a UI/Python
change you don't need the AVR toolchain.
### Prerequisites
- Node.js (any recent LTS) with npm
- Python 3 with setuptools
- `npm install` once at the project root (this is wired into the
`node_modules` Make target, but on a fresh checkout it's clearer to
do it explicitly)
```bash
npm install
(cd src/svelte-components && npm install)
```
#### macOS gotcha: esbuild platform pin
The Pi build leaves `node_modules/esbuild` pinned to
`linux-arm64`, which won't run on Darwin. If `npm run build` inside
`src/svelte-components` complains about esbuild, reinstall it for the
host:
```bash
cd src/svelte-components
rm -rf node_modules/esbuild
npm install esbuild@0.14.49 --no-save
```
(Use the version that matches `package-lock.json`.)
### Build the web UI + Python sdist
```bash
# Build the Svelte components
(cd src/svelte-components && npm run build)
# Render pug templates and copy assets into build/http
make all # AVR step will fail without avr-gcc; safe to ignore
# if you didn't change anything under src/avr or src/boot
# Package
./setup.py sdist
ls dist/bbctrl-*.tar.bz2
```
`make pkg` is the canonical target but it tries to build AVR first. On
hosts without avr-gcc, run the steps above directly.
If `bbctrl-*.tar.bz2` is missing `src/bbserial/bbserial.ko`, copy the
prebuilt `.ko` from a previous official release into `src/bbserial/`
before running `setup.py sdist` (the install script on the controller
just installs the existing module if a newer one isn't shipped).
### Flash to a controller
```bash
curl -X PUT -H "Content-Type: multipart/form-data" \
-F "firmware=@dist/bbctrl-1.6.7.tar.bz2" \
-F "password=onefinity" \
http://onefinity.local/api/firmware/update
```
…or use the Make target:
```bash
make update HOST=onefinity.local PASSWORD=onefinity
```
The controller stops bbctrl, untars the package, runs
`scripts/install.sh`, and brings the service back up. Total downtime
is ~30-45s. Watch progress at `http://<host>/` (you'll get 404s while
bbctrl restarts, then the new UI).
### Verify the flash
```bash
curl -s http://onefinity.local/ | grep -c "OneFinity"
curl -s http://onefinity.local/api/aux/status # if W axis is enabled
```
## Build & flash (full path, Debian/Linux)
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.
## W axis (auxcnc)
This fork adds a virtual W axis. See
[docs/AUX_W_AXIS.md](docs/AUX_W_AXIS.md) for:
- G-code surface (`G28 W0`, `G1 W25`, etc.)
- The G-code preprocessor and hook architecture
- aux.json keys
- REST API (`/api/aux/*`)
- UI surface (jog row in Control, settings panel in Settings)
- Edge cases (ESP reboot mid-job, limit closed at home start, …)

278
backup/onefinity-backup.sh Executable file
View File

@@ -0,0 +1,278 @@
#!/usr/bin/env bash
set -euo pipefail
# Onefinity CNC Controller - SD Card Backup & Restore
#
# Backs up the Raspberry Pi's SD card over SSH as a compressed image.
# Compression runs on the local machine (fast), raw bytes stream from the Pi.
#
# Usage:
# ./onefinity-backup.sh backup # backup with defaults
# ./onefinity-backup.sh backup -o myfile.gz # custom output file
# ./onefinity-backup.sh restore image.gz # restore to SD card
# ./onefinity-backup.sh verify image.gz # verify image integrity
#
# Environment:
# ONEFINITY_HOST - Pi IP/hostname (default: 10.1.10.55)
# ONEFINITY_USER - SSH user (default: bbmc)
# ONEFINITY_PASS - sudo password (default: onefinity)
HOST="${ONEFINITY_HOST:-10.1.10.55}"
USER="${ONEFINITY_USER:-bbmc}"
PASS="${ONEFINITY_PASS:-onefinity}"
BACKUP_DIR="$(cd "$(dirname "$0")" && pwd)"
DEVICE="/dev/mmcblk0"
ssh_cmd() {
ssh -o ConnectTimeout=10 -o StrictHostKeyChecking=no -o LogLevel=ERROR "$USER@$HOST" "$@"
}
sudo_ssh() {
ssh_cmd "echo '$PASS' | sudo -S bash -c '$1' 2>/dev/null"
}
die() { echo "ERROR: $*" >&2; exit 1; }
# ── Backup ──────────────────────────────────────────────────────────────────
do_backup() {
local outfile=""
while [[ $# -gt 0 ]]; do
case "$1" in
-o|--output) outfile="$2"; shift 2 ;;
*) die "Unknown option: $1" ;;
esac
done
if [[ -z "$outfile" ]]; then
outfile="$BACKUP_DIR/onefinity-$(date +%Y%m%d-%H%M).img.gz"
fi
echo "╔══════════════════════════════════════════════════════╗"
echo "║ Onefinity CNC Controller - SD Card Backup ║"
echo "╚══════════════════════════════════════════════════════╝"
echo ""
echo " Host: $USER@$HOST"
echo " Device: $DEVICE"
echo " Output: $outfile"
echo ""
# Check connectivity
echo "→ Checking SSH connection..."
ssh_cmd 'hostname' >/dev/null 2>&1 || die "Cannot SSH to $USER@$HOST"
# Get card size
local card_bytes
card_bytes=$(sudo_ssh "blockdev --getsize64 $DEVICE")
local card_gb=$(echo "scale=1; $card_bytes / 1073741824" | bc)
echo " SD card: ${card_gb}GB ($card_bytes bytes)"
echo ""
# Check for enough local disk space (compressed is ~4% of raw)
local avail_bytes
avail_bytes=$(df -P "$(dirname "$outfile")" | tail -1 | awk '{print $4 * 1024}')
local need_bytes=$((card_bytes / 10)) # conservative: assume 10% compressed
if (( avail_bytes < need_bytes )); then
die "Not enough local disk space. Need ~$(echo "scale=1; $need_bytes/1073741824" | bc)GB, have $(echo "scale=1; $avail_bytes/1073741824" | bc)GB"
fi
# Stream raw dd from Pi, compress locally with gzip
# The Pi's SD card reads at ~20MB/s which is the bottleneck.
# Compressing locally on a fast machine is much better than on the ARM.
echo "→ Streaming SD card image (this takes ~20-50 minutes)..."
echo " Pi: dd → SSH → local gzip → $outfile"
echo ""
local start_time=$SECONDS
local tmpfile="${outfile}.partial"
ssh_cmd "echo '$PASS' | sudo -S dd if=$DEVICE bs=4M 2>/dev/null" 2>/dev/null \
| gzip -1 > "$tmpfile" &
local pid=$!
# Progress monitor
while kill -0 $pid 2>/dev/null; do
sleep 15
if [[ -f "$tmpfile" ]]; then
local size_h
size_h=$(ls -lh "$tmpfile" 2>/dev/null | awk '{print $5}')
local elapsed=$(( SECONDS - start_time ))
local min=$(( elapsed / 60 ))
local sec=$(( elapsed % 60 ))
printf "\r %dm%02ds elapsed — %s compressed" "$min" "$sec" "$size_h"
fi
done
wait $pid
local exit_code=$?
echo ""
if [[ $exit_code -ne 0 ]]; then
rm -f "$tmpfile"
die "Backup failed (exit code $exit_code)"
fi
mv "$tmpfile" "$outfile"
local elapsed=$(( SECONDS - start_time ))
local final_size
final_size=$(ls -lh "$outfile" | awk '{print $5}')
echo ""
echo "→ Verifying image integrity..."
if gzip -t "$outfile" 2>/dev/null; then
echo " ✓ gzip integrity OK"
else
die "Image file is corrupt!"
fi
# Verify full size by counting decompressed bytes
local actual_bytes
actual_bytes=$(gzip -d -c "$outfile" | wc -c | tr -d ' ')
if [[ "$actual_bytes" -eq "$card_bytes" ]]; then
echo " ✓ Size matches: $actual_bytes bytes (full ${card_gb}GB card)"
else
echo " ⚠ Size mismatch: expected $card_bytes, got $actual_bytes"
fi
echo ""
echo "╔══════════════════════════════════════════════════════╗"
echo " ✓ Backup complete"
echo " File: $outfile"
echo " Size: $final_size compressed (${card_gb}GB raw)"
echo " Time: $(( elapsed / 60 ))m $(( elapsed % 60 ))s"
echo "╚══════════════════════════════════════════════════════╝"
}
# ── Restore ─────────────────────────────────────────────────────────────────
do_restore() {
local imgfile="$1"
local target="${2:-}"
[[ -f "$imgfile" ]] || die "Image file not found: $imgfile"
echo "╔══════════════════════════════════════════════════════╗"
echo "║ Onefinity CNC Controller - SD Card Restore ║"
echo "╚══════════════════════════════════════════════════════╝"
echo ""
if [[ -n "$target" ]]; then
# ── Local restore: write to a local SD card device ──
[[ -b "$target" ]] || die "$target is not a block device"
local target_bytes
target_bytes=$(diskutil info -plist "$target" 2>/dev/null \
| plutil -extract TotalSize raw - 2>/dev/null \
|| blockdev --getsize64 "$target" 2>/dev/null \
|| echo 0)
echo " Image: $imgfile"
echo " Target: $target ($(echo "scale=1; $target_bytes/1073741824" | bc)GB)"
echo ""
echo " ⚠ THIS WILL ERASE ALL DATA ON $target"
echo ""
read -rp " Type YES to continue: " confirm
[[ "$confirm" == "YES" ]] || die "Aborted"
echo ""
echo "→ Unmounting target..."
diskutil unmountDisk "$target" 2>/dev/null || true
echo "→ Writing image to $target..."
local raw_target
raw_target=$(echo "$target" | sed 's|/dev/disk|/dev/rdisk|')
gzip -d -c "$imgfile" | sudo dd of="$raw_target" bs=4M status=progress
sync
echo ""
echo " ✓ Restore complete. Safe to eject $target."
else
# ── Remote restore: write back to Pi over SSH ──
echo " Image: $imgfile"
echo " Target: $USER@$HOST:$DEVICE"
echo ""
echo " ⚠ THIS WILL ERASE THE PI'S SD CARD"
echo " ⚠ The Pi must be booted from USB/network, not the SD card"
echo ""
read -rp " Type YES to continue: " confirm
[[ "$confirm" == "YES" ]] || die "Aborted"
echo ""
echo "→ Writing image to $HOST:$DEVICE..."
gzip -d -c "$imgfile" \
| ssh_cmd "echo '$PASS' | sudo -S dd of=$DEVICE bs=4M 2>/dev/null"
echo ""
echo " ✓ Remote restore complete."
fi
}
# ── Verify ──────────────────────────────────────────────────────────────────
do_verify() {
local imgfile="$1"
[[ -f "$imgfile" ]] || die "Image file not found: $imgfile"
echo "Verifying: $imgfile"
echo ""
local compressed_size
compressed_size=$(ls -lh "$imgfile" | awk '{print $5}')
echo " Compressed size: $compressed_size"
echo " Checking gzip integrity..."
if gzip -t "$imgfile" 2>/dev/null; then
echo " ✓ gzip OK"
else
die "gzip integrity check FAILED"
fi
echo " Counting uncompressed bytes..."
local raw_bytes
raw_bytes=$(gzip -d -c "$imgfile" | wc -c | tr -d ' ')
local raw_gb=$(echo "scale=1; $raw_bytes / 1073741824" | bc)
echo " ✓ Uncompressed size: ${raw_gb}GB ($raw_bytes bytes)"
echo " Checking partition table..."
gzip -d -c "$imgfile" 2>/dev/null | head -c 512 | xxd | head -4 || true
echo ""
echo " ✓ Image looks valid"
}
# ── Main ────────────────────────────────────────────────────────────────────
usage() {
cat <<EOF
Usage: $(basename "$0") <command> [options]
Commands:
backup [-o file.img.gz] Backup SD card from Pi over SSH
restore <image.gz> [/dev/diskN] Restore image to local SD card or remote Pi
verify <image.gz> Verify image integrity
Environment variables:
ONEFINITY_HOST Pi address (default: 10.1.10.55)
ONEFINITY_USER SSH user (default: bbmc)
ONEFINITY_PASS sudo password (default: onefinity)
Examples:
$(basename "$0") backup
$(basename "$0") backup -o /tmp/mybackup.img.gz
$(basename "$0") restore backup/onefinity-20260430.img.gz /dev/disk4
$(basename "$0") verify backup/onefinity-20260430.img.gz
EOF
exit 1
}
[[ $# -ge 1 ]] || usage
case "$1" in
backup) shift; do_backup "$@" ;;
restore) shift; [[ $# -ge 1 ]] || usage; do_restore "$@" ;;
verify) shift; [[ $# -ge 1 ]] || usage; do_verify "$@" ;;
*) usage ;;
esac

4
deploy-hardware.sh Executable file
View File

@@ -0,0 +1,4 @@
#!/bin/bash
# Shorthand for ./deploy.sh hardware
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec "$SCRIPT_DIR/deploy.sh" hardware "$@"

4
deploy-local.sh Executable file
View File

@@ -0,0 +1,4 @@
#!/bin/bash
# Shorthand for ./deploy.sh local
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec "$SCRIPT_DIR/deploy.sh" local "$@"

4
deploy-prod.sh Executable file
View File

@@ -0,0 +1,4 @@
#!/bin/bash
# Shorthand for ./deploy.sh prod
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec "$SCRIPT_DIR/deploy.sh" prod "$@"

52
deploy.sh Executable file
View File

@@ -0,0 +1,52 @@
#!/bin/bash
# Onefinity firmware deploy script.
#
# ./deploy.sh local — build & static-serve the UI on macOS
# (chrome only; no controller, shows
# DISCONNECTED overlay)
# ./deploy.sh hardware — fast iteration: rsync build/http/
# contents to the running Pi at
# onefinity.local, then restart bbctrl
# ./deploy.sh prod — full firmware update via the Pi's
# /api/firmware/update endpoint
# (equivalent to `make update`)
#
# Notes:
# * On macOS we cannot run the Python `bbctrl` controller directly
# because it imports the ARM-only camotics gplan.so. For full UI
# testing with live data, deploy to the Pi (hardware or prod).
# * `prod` requires a clean working tree.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
CMD="${1:-}"
case "$CMD" in
local) exec "$SCRIPT_DIR/scripts/deploy/local.sh" "$@" ;;
hardware) exec "$SCRIPT_DIR/scripts/deploy/hardware.sh" "$@" ;;
prod) exec "$SCRIPT_DIR/scripts/deploy/prod.sh" "$@" ;;
*)
cat <<USAGE
usage: $0 {local | hardware | prod}
local Build the UI and static-serve build/http/ in a tmux session
on macOS. Useful for iterating on the V09 chrome and routing.
URL: http://localhost:8770/
tmux: tmux attach -t onefin-local
hardware Fast iteration on the actual controller: rsync the freshly
built build/http/ tree onto onefinity.local, then restart
the bbctrl service. Requires SSH access as bbmc@onefinity.local.
Defaults: HOST=onefinity.local PASSWORD=onefinity
prod Build a full firmware package (.tar.bz2) and PUT it through
/api/firmware/update on the Pi. Equivalent to:
make update HOST=onefinity.local PASSWORD=onefinity
Requires a clean working tree.
USAGE
exit 1
;;
esac

172
docs/AUX_W_AXIS.md Normal file
View File

@@ -0,0 +1,172 @@
# W axis (auxcnc) integration
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
a small REST API for jogging / homing from the UI.
## How it works
The bbctrl planner (gplan) only understands `xyzabc`, so adding a true
7th axis would require rebuilding gplan + the AVR firmware. We avoid
that by treating W as a synchronous out-of-band axis: W moves run
*between* G-code blocks, not blended with XYZ.
Pipeline:
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 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.
4. `Hooks._fire('custom', ...)` finds the registered internal handler
for the event name (`aux`, `aux_rel`, `aux_home`, `aux_setzero`).
5. The handler runs in a hook thread, gating `Mach.unpause` until done.
While the handler is busy the machine is in HOLDING - no XYZ motion
can resume until 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 `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 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 W-2.5 ; relative W move
G90
G92 W0 ; set current W as zero (G92-style)
```
Rules:
- `G28` / `G28.2` with W only -> homing hook; the bare `G28` is NOT
emitted to gplan (that would mean home-all).
- `G28.2 X0 Y0 W0` -> emit hook, then keep `G28.2 X0 Y0` for XY homing.
- A line with both W and XYZ axis words is split into two sequential
blocks. Default order: W first, then XYZ. Toggle via the
`w_first` constructor arg.
- Lines inside parens or after `;` are passed through verbatim.
## Configuration
Per-controller config lives at `<ctrl_path>/aux.json` (created on first
save via the API). Keys:
| Key | Default | Notes |
|------------------------|----------------|------------------------------------|
| `enabled` | `false` | Master switch |
| `port` | `/dev/ttyUSB0` | Serial device |
| `baud` | `115200` | |
| `steps_per_mm` | `80.0` | Logical steps per mm |
| `dir_sign` | `1` | +1 or -1: maps logical+ to motor+ |
| `min_w`, `max_w` | `0`, `100` | Soft limits in mm |
| `home_dir` | `'-'` | Direction toward limit switch |
| `home_position_mm` | `0.0` | mm value assigned at home |
| `home_fast_sps` | `4000` | Fast seek rate |
| `home_slow_sps` | `400` | Slow re-seek rate |
| `home_backoff_steps` | `200` | Backoff after touching limit |
| `home_maxtravel_steps` | `200000` | Hard cap on phase 1 seek |
| `step_max_sps` | `4000` | Cruise rate for STEPS |
| `step_accel_sps2` | `16000` | Trapezoidal ramp accel |
| `step_start_sps` | `200` | Ramp floor |
| `limit_low` | `true` | Switch active low (closed = LOW) |
Most of these are pushed to the ESP via `HOMECFG` on connect and
persisted there in NVS.
## REST API
| Verb | Path | Body | Effect |
|------|----------------------------|-----------------------|------------------------|
| GET | `/api/aux/config` | - | Current config |
| PUT | `/api/aux/config/save` | `{key: val, ...}` | Save and re-push |
| GET | `/api/aux/status` | - | `{enabled, present, homed, pos_mm}` |
| PUT | `/api/aux/home` | - | Run home cycle (blocks)|
| PUT | `/api/aux/abort` | - | Cancel running motion |
| PUT | `/api/aux/jog` | `{mm: 1.5}` or `{steps: 200}` | Relative move |
| PUT | `/api/aux/move` | `{mm: 12.5}` | Absolute move (mm) |
| PUT | `/api/aux/set-zero` | `{mm: 0}` | Set current pos to mm |
Steps-mode jog ignores soft limits (use it to inch the axis to the
limit switch when the axis isn't homed yet).
## UI
**Control view**
- A jog row appears under the XYZ jog grid when `aux_enabled` is true,
with three buttons: `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 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 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
`HOMECFG` to the ESP. A status line shows whether the axis is
disabled / offline / connected-unhomed / homed at `<pos> mm`.
## State surface
These are pushed via `state.set` and visible in the websocket stream:
- `aux_enabled` - bool, axis is configured + enabled
- `aux_present` - bool, ESP responding on serial
- `aux_homed` - bool, has been homed since last ESP reset
- `aux_pos` - float, current W in mm (4 decimals)
## Edge cases
- **ESP reboots mid-job**: `[boot] auxcnc v=N` banner -> `aux_homed`
cleared, message added: "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 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 W moves are
allowed even without a successful home. Soft limits still apply
unless you use the raw step jog endpoint.
## Files added/changed
- `src/py/bbctrl/AuxAxis.py` (new): serial worker + RPC layer
- `src/py/bbctrl/AuxPreprocessor.py` (new): G-code rewriter
- `src/py/bbctrl/Hooks.py`: register_internal(), fix the messages
listener so `(MSG,HOOK:...)` actually fires
- `src/py/bbctrl/Ctrl.py`: instantiate AuxAxis, register hooks
- `src/py/bbctrl/Mach.py`: rewrite MDI commands containing W
- `src/py/bbctrl/FileHandler.py`: rewrite uploads in place
- `src/py/bbctrl/Web.py`: REST endpoints
- `src/py/bbctrl/__init__.py`: export AuxAxis
- `src/pug/templates/control-view.pug`: W jog row + DRO row
- `src/js/control-view.js`: aux_home / aux_jog / aux_jog_incr handlers
- `src/js/axis-vars.js`: `_compute_aux_axis` for W state
- `src/svelte-components/src/components/WAxisSettings.svelte`: settings panel
- `src/svelte-components/src/components/SettingsView.svelte`: hosts WAxisSettings
- `auxcnc/src/main.cpp`: new commands HOME, HOMECFG, WPOS, HOMED?,
LIMIT?, ABORT-able STEPS with limit-aware abort, trapezoidal ramps,
NVS-persisted config, `[boot]` banner, deterministic reply tokens

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

@@ -1,13 +1,24 @@
[Unit]
Description=Buildbotics Controller
After=network.target
# Note: bbctrl previously had `After=network.target`. That delays
# start by ~5s on this Pi while dhcpcd brings up wlan0/eth0, but
# bbctrl does not actually require network connectivity to come up
# (the AVR is on a local serial port, the LCD on I2C). Dropping it
# means the Pi shows the UI faster on cold boot. The wifi config UI
# still works because it queries iw/dhcpcd lazily on demand.
After=local-fs.target bbserial-rebind.service
Wants=bbserial-rebind.service
[Service]
User=root
ExecStart=/usr/local/bin/bbctrl -l /var/log/bbctrl.log
WorkingDirectory=/var/lib/bbctrl
Restart=always
StandardOutput=null
# StandardOutput was 'null'. Set to 'journal' so TRACE lines emitted by
# bbctrl.Trace are visible via `journalctl -u bbctrl`. Bbctrl still
# writes its own log via -l above; this only affects stdout/stderr.
StandardOutput=journal
StandardError=journal
Nice=-10
KillMode=process

View File

@@ -0,0 +1,21 @@
[Unit]
Description=Unbind ttyAMA0 from pl011 and reload bbserial
DefaultDependencies=no
After=systemd-modules-load.service local-fs.target
Before=bbctrl.service
ConditionPathExists=/sys/bus/amba/drivers/uart-pl011
[Service]
Type=oneshot
RemainAfterExit=yes
# Tolerate the device already being bound elsewhere or the module
# already being loaded — the goal is the end state (bbserial owns
# ttyAMA0), not running the steps.
ExecStart=/bin/sh -c '\
echo 3f201000.serial > /sys/bus/amba/drivers/uart-pl011/unbind 2>/dev/null || true; \
/sbin/modprobe -r bbserial 2>/dev/null || true; \
/sbin/modprobe bbserial \
'
[Install]
WantedBy=multi-user.target

65
scripts/deploy/hardware.sh Executable file
View File

@@ -0,0 +1,65 @@
#!/bin/bash
# --- Hardware iteration (live Pi at onefinity.local) ---
#
# Rsyncs the freshly built static UI tree (build/http/) onto the Pi's
# bbctrl egg directory and restarts bbctrl. This is much faster than
# a full firmware update and is the fastest way to iterate on the V09
# UI changes against real machine state (W axis, jog feedback, etc).
#
# Defaults:
# HOST=onefinity.local
# USER=bbmc
# PASSWORD=onefinity (used for sudo on the Pi)
#
# Override:
# HOST=10.1.10.55 ./deploy.sh hardware
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$SCRIPT_DIR"
HOST="${HOST:-onefinity.local}"
# REMOTE_USER (not USER, which the shell pre-populates with the local
# logged-in account).
REMOTE_USER="${REMOTE_USER:-bbmc}"
PASSWORD="${PASSWORD:-onefinity}"
echo "🛠 Building UI bundle..."
make build/http/index.html >/dev/null
# Discover the on-Pi http path; the bbctrl egg version may change.
echo "🔍 Locating bbctrl http/ directory on $HOST..."
REMOTE_HTTP_DIR="$(ssh -o ConnectTimeout=5 "${REMOTE_USER}@${HOST}" \
"ls -d /usr/local/lib/python*/dist-packages/bbctrl-*-py*.egg/bbctrl/http 2>/dev/null | head -1")"
if [[ -z "$REMOTE_HTTP_DIR" ]]; then
echo "❌ Could not find bbctrl http/ directory on $HOST"
exit 1
fi
echo " $REMOTE_HTTP_DIR"
echo "🚚 Rsyncing build/http/ → $HOST:$REMOTE_HTTP_DIR/"
# Stage to a tmp dir owned by $REMOTE_USER, then sudo-mv into place.
# This avoids needing root over rsync.
REMOTE_TMP="/tmp/onefin_ui_$$"
ssh -o ConnectTimeout=5 "${REMOTE_USER}@${HOST}" "mkdir -p '${REMOTE_TMP}'"
rsync -avz --delete \
--exclude='hostinfo.txt' \
-e "ssh -o ConnectTimeout=5" \
build/http/ "${REMOTE_USER}@${HOST}:${REMOTE_TMP}/"
echo "📦 Installing into ${REMOTE_HTTP_DIR}/ (sudo)..."
ssh -o ConnectTimeout=5 "${REMOTE_USER}@${HOST}" \
"echo '${PASSWORD}' | sudo -S bash -c '
rsync -a --delete --exclude=hostinfo.txt \"${REMOTE_TMP}/\" \"${REMOTE_HTTP_DIR}/\" \
&& rm -rf \"${REMOTE_TMP}\"
'" 2>&1 | tail -3
echo "🔁 Restarting bbctrl service..."
ssh -o ConnectTimeout=5 "${REMOTE_USER}@${HOST}" \
"echo '${PASSWORD}' | sudo -S systemctl restart bbctrl" 2>&1 | tail -3
echo ""
echo "✅ Deployed to http://${HOST}/"
echo " Logs: ssh ${REMOTE_USER}@${HOST} 'journalctl -u bbctrl -f'"
echo " Open: open -a 'Google Chrome' http://${HOST}/"

75
scripts/deploy/local.sh Executable file
View File

@@ -0,0 +1,75 @@
#!/bin/bash
# --- Local development (macOS) ---
#
# Builds the UI bundle and static-serves it on http://localhost:8770/.
# Runs in a named tmux session so we can iterate (re-running this script
# rebuilds and restarts the server in-place, you keep your browser tab).
#
# What you'll see:
# * The full V09 chrome (header tabs, settings rail, jog grid, DRO
# skeleton, status strip).
# * A "DISCONNECTED" overlay because there's no controller backend.
# * 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
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$SCRIPT_DIR"
echo "🛠 Building UI bundle..."
make build/http/index.html >/dev/null
PORT="${PORT:-8770}"
SESSION="onefin-local"
ensure_tmux_window() {
local session="$1"
local window="${2:-}"
local target="${session}${window:+:$window}"
if tmux has-session -t "$session" 2>/dev/null; then
if tmux send-keys -t "$target" "" 2>/dev/null; then
echo "🔁 Reusing tmux session '$session'..."
tmux send-keys -t "$target" C-c
sleep 1
return
fi
echo "⚠️ Dead pane in '$session', recreating..."
tmux kill-session -t "$session" 2>/dev/null
fi
echo "🆕 Creating tmux session '$session'..."
tmux new-session -d -s "$session"
}
ensure_tmux_window "$SESSION"
# Free the port if a previous run is still listening.
if lsof -iTCP:"$PORT" -sTCP:LISTEN >/dev/null 2>&1; then
echo "⚠️ Port $PORT is busy; killing previous server..."
lsof -tiTCP:"$PORT" -sTCP:LISTEN | xargs -r kill 2>/dev/null || true
sleep 1
fi
tmux send-keys -t "$SESSION" \
"cd '$SCRIPT_DIR' && python3 -m http.server --directory build/http $PORT" \
C-m
echo ""
echo "✅ Static UI server started on http://localhost:$PORT/"
echo ""
echo " Routes to try:"
echo " http://localhost:$PORT/#control"
echo " http://localhost:$PORT/#program"
echo " http://localhost:$PORT/#console"
echo " http://localhost:$PORT/#settings (Display & Units)"
echo " http://localhost:$PORT/#admin-network (WiFi / IP)"
echo " http://localhost:$PORT/#motor:0 (Motor 0 settings)"
echo ""
echo " tmux: tmux attach -t $SESSION"
echo " stop: tmux kill-session -t $SESSION"
echo ""
echo " No controller is running, so the page shows DISCONNECTED and"
echo " axis values stay empty. For live data + W axis, run:"
echo " ./deploy.sh hardware (fast: rsync build/http -> Pi)"
echo " ./deploy.sh prod (full firmware update)"

42
scripts/deploy/prod.sh Executable file
View File

@@ -0,0 +1,42 @@
#!/bin/bash
# --- Production firmware update (Pi at onefinity.local) ---
#
# Builds a full firmware package (.tar.bz2) and PUTs it through the Pi's
# /api/firmware/update endpoint. This is the canonical OTA flow and goes
# through the bbctrl Tornado server's update handler.
#
# Defaults:
# HOST=onefinity.local
# PASSWORD=onefinity
#
# Override:
# HOST=10.1.10.55 PASSWORD=secret ./deploy.sh prod
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$SCRIPT_DIR"
HOST="${HOST:-onefinity.local}"
PASSWORD="${PASSWORD:-onefinity}"
# Require a clean working tree.
echo "🔍 Checking git state..."
if ! git diff --quiet || ! git diff --cached --quiet \
|| [[ -n "$(git ls-files --others --exclude-standard)" ]]; then
echo "❌ Refusing to deploy: working tree has uncommitted changes."
git status --short
exit 1
fi
echo "✅ Working tree is clean."
echo "🛠 Building firmware package..."
make pkg
echo "🚚 Uploading to http://${HOST}/api/firmware/update..."
make update HOST="$HOST" PASSWORD="$PASSWORD"
echo ""
echo "✅ Firmware update PUT to ${HOST}."
echo " The Pi will reboot itself after applying the update."
echo " Once it comes back, open: http://${HOST}/"

View File

@@ -19,8 +19,17 @@ if $UPDATE_PY; then
# Update service
rm -f /etc/init.d/bbctrl
cp scripts/bbctrl.service /etc/systemd/system/
# Cold-boot fast path:
# - bbserial-rebind.service replaces the bbserial unbind/reload
# that used to live in rc.local AFTER bbctrl was already
# listening on /dev/ttyAMA0. Doing it as a unit ordered
# Before=bbctrl.service eliminates a full bbctrl restart
# mid-boot (~5s saved).
cp scripts/bbserial-rebind.service /etc/systemd/system/
systemctl daemon-reload
systemctl enable bbctrl
systemctl enable bbserial-rebind.service
fi
if $UPDATE_AVR; then
@@ -118,8 +127,50 @@ if [ $? -ne 0 ]; then
REBOOT=true
fi
# Install rc.local
cp scripts/rc.local /etc/
# Install rc.local. Use the slimmed "fast" variant if it exists in this
# checkout (preferred); fall back to the legacy rc.local for older
# firmware tarballs that don't ship rc.local.fast yet.
if [ -f scripts/rc.local.fast ]; then
cp scripts/rc.local.fast /etc/rc.local
else
cp scripts/rc.local /etc/rc.local
fi
chmod +x /etc/rc.local
# Cold-boot: mask units that contribute to userspace startup time but
# do not benefit a deployed Onefinity Pi. Each is reversible with
# `systemctl unmask <unit>`.
# plymouth-read-write : 4s of work for a splash that rc.local kills
# immediately with `plymouth quit`.
# plymouth-quit-wait : holds graphical.target until the splash is
# fully gone; redundant once the splash is
# masked.
# raspi-config : one-shot first-boot config; on a deployed
# image it's a 2s no-op.
# sysstat : sadc CPU/IO stats logger; not used.
# Use --now so the change also applies to the running system; harmless
# on a fresh install where the units are inactive.
for unit in \
plymouth-read-write.service \
plymouth-quit-wait.service \
raspi-config.service \
sysstat.service; do
systemctl mask --now "$unit" 2>/dev/null || true
done
# Cold-boot: switch swap activation from dphys-swapfile (~4.3s LSB
# wrapper that re-checks the swap file size on every boot) to a plain
# fstab entry. The swap file itself is already created at
# /var/swap by the previous boot; we only need to make sure it gets
# `swapon`'d at local-fs.target instead.
SWAPFILE=/var/swap
if [ -f "$SWAPFILE" ]; then
if ! grep -qE "^[^#]*${SWAPFILE//\//\\/}[[:space:]]+swap" /etc/fstab; then
echo "$SWAPFILE none swap sw 0 0" >> /etc/fstab
fi
systemctl mask --now dphys-swapfile.service 2>/dev/null || true
swapon -a 2>/dev/null || true
fi
# Ensure that the watchdog python library is installed
pip3 list --format=columns | grep watchdog >/dev/null

40
scripts/rc.local.fast Normal file
View File

@@ -0,0 +1,40 @@
#!/bin/bash
# rc.local for the OneFinity Pi, "fast" variant.
#
# What changed vs. scripts/rc.local:
# - bbserial unbind/rebind moved to bbserial-rebind.service (runs
# once, before bbctrl, instead of after bbctrl is already
# listening on the serial port).
# - startx moved to kiosk.service so chromium starts in parallel
# with bbctrl rather than blocking on rc.local.
# - rc.local no longer keeps the Pi in 'starting' state forever,
# which fixes systemd-analyze.
set -e
# Mount /boot read only
mount -o remount,ro /boot 2>/dev/null || true
# Set SPI GPIO mode
gpio mode 27 alt3 || true
# Create browser memory limited cgroup
if [ -d /sys/fs/cgroup/memory ]; then
CGROUP=/sys/fs/cgroup/memory/chrome
[ -d "$CGROUP" ] || mkdir -p "$CGROUP"
chown -R pi:pi "$CGROUP"
echo 650000000 > "$CGROUP/memory.soft_limit_in_bytes"
echo 750000000 > "$CGROUP/memory.limit_in_bytes"
fi
# Stop boot splash; harmless if plymouth already gone.
plymouth quit 2>/dev/null || true
# Start X (chromium kiosk) in the background so rc.local can exit and
# late-boot units (bbctrl logrotate, etc.) don't block on it. Output
# is redirected so the journal doesn't fill up with X warnings.
cd /home/pi
nohup sudo -u pi startx >/var/log/onefin-x.log 2>&1 &
disown
exit 0

View File

@@ -17,7 +17,7 @@ setup(
license = pkg['license'],
url = pkg['homepage'],
package_dir = {'': 'src/py'},
packages = ['bbctrl', 'inevent', 'lcd', 'camotics','iw_parse'],
packages = ['bbctrl', 'inevent', 'lcd', 'camotics', 'iw_parse'],
include_package_data = True,
entry_points = {
'console_scripts': [

View File

@@ -4,6 +4,7 @@ const api = require("./api");
const cookie = require("./cookie")("bbctrl-");
const Sock = require("./sock");
const semverLt = require("semver/functions/lt");
const restartTiming = require("./restart-timing");
if (document.getElementById("svelte-dialog-host") != undefined) {
SvelteComponents.createComponent(
@@ -103,12 +104,23 @@ module.exports = new Vue({
return {
status: "connecting",
currentView: "loading",
// Top-level shell tab. Mapped from the URL hash by parse_hash().
// One of: control | program | console | settings
top_tab: "control",
// Sub-route when a tab has internal pages (e.g. console:mdi,
// settings:admin-network, settings:motor:0). The settings sub
// also drives which inner view is mounted.
sub_tab: "",
sys_open: false,
has_camera: true,
messages_log: [],
messages_seen: 0,
display_units: localStorage.getItem("display_units") || "METRIC",
index: -1,
modified: false,
template: require("../resources/config-template.json"),
config: {
settings: {
settings: {
units: "METRIC",
"easy-adapter": false
},
@@ -143,22 +155,15 @@ module.exports = new Vue({
estop: { template: "#estop-template" },
"loading-view": { template: "<h1>Loading...</h1>" },
"control-view": require("./control-view"),
"settings-view": require("./settings-view"),
"motor-view": require("./motor-view"),
"tool-view": require("./tool-view"),
"io-view": require("./io-view"),
"admin-general-view": require("./admin-general-view"),
"admin-network-view": require("./admin-network-view"),
"macros-view": require('./macros'),
"help-view": require("./help-view"),
"cheat-sheet-view": {
template: "#cheat-sheet-view-template",
data: function() {
return {
showUnimplemented: false
};
},
},
"program-view": require("./program-view"),
"console-view": require("./console-view"),
// The settings-shell renders the rail + an inner routed view.
// All settings-family hashes (settings, admin-general,
// admin-network, motor:N, tool, io, macros, help, cheat-sheet)
// resolve to this same shell; parse_hash() sets sub_tab so the
// shell knows which inner template to mount.
"settings-shell-view": require("./settings-shell-view"),
},
watch: {
@@ -166,6 +171,25 @@ module.exports = new Vue({
localStorage.setItem("display_units", value);
SvelteComponents.setDisplayUnits(value);
},
// Mirror controller messages into a console log used by the
// Console > Messages tab and the header badge counter.
"state.messages": {
handler: function(messages) {
if (!Array.isArray(messages)) return;
this.messages_log = messages.map(m => ({
text: m.text,
id: m.id,
level: /^#/.test(m.text || "") ? "info" : "warning",
ts: m.ts || Date.now(),
}));
if (this.top_tab === "console" && this.sub_tab === "messages") {
this.messages_seen = this.messages_log.length;
}
},
deep: true,
immediate: true,
},
},
events: {
@@ -252,18 +276,120 @@ module.exports = new Vue({
enable_rotary: function() {
if(this.state["2an"] == 1 || this.state["2an"] == 3) return true;
return false;
}
},
// ---------------- header chrome helpers ----------------
// Underlying machine state from the controller. Mirrors
// control-view's `mach_state` so the header has access without
// depending on the routed component.
mach_state: function() {
const cycle = this.state.cycle;
const xx = this.state.xx;
if (xx != "ESTOPPED" && (cycle == "jogging" || cycle == "homing")) {
return cycle.toUpperCase();
}
return xx || "";
},
// Short text for the READY pill in the header.
state_label: function() {
const s = this.mach_state;
if (!s) return "--";
return s;
},
// Class added to the READY pill (.state-badge) so styling can
// reflect ready / running / holding / fault / estop.
state_class: function() {
const s = this.mach_state;
if (s == "ESTOPPED" || s == "FAULT" || s == "SHUTDOWN") return "bad";
if (s == "HOLDING" || s == "STOPPING") return "warn";
if (s == "RUNNING" || s == "HOMING" || s == "JOGGING") return "busy";
if (s == "READY") return "ok";
return "unknown";
},
mach_state_full: function() {
const s = this.mach_state;
if (s == "ESTOPPED") return "E-Stopped \u2014 release to clear";
if (s == "HOLDING") return "Feed hold (" + (this.state.pr || "paused") + ")";
if (s == "RUNNING") return "Running program";
if (s == "HOMING") return "Homing axes";
if (s == "JOGGING") return "Jogging";
if (s == "READY") return "Ready";
return s;
},
// Pip color for the unified system pill.
sys_class: function() {
const wifi_off = !this.config.wifiName || this.config.wifiName == "not connected";
const cam_off = !this.has_camera;
const hot = this.state && 80 <= this.state.rpi_temp;
if (hot) return "red";
if (wifi_off || cam_off) return "amber";
return "green";
},
// Compact summary for the system pill.
sys_summary: function() {
const issues = [];
if (!this.config.wifiName || this.config.wifiName == "not connected") {
issues.push("WiFi off");
}
if (!this.has_camera) issues.push("Camera offline");
if (this.state && 80 <= this.state.rpi_temp) issues.push("Pi hot");
if (this.is_rotary_active) issues.push("Rotary");
if (issues.length === 0) return "All systems";
if (issues.length === 1) return issues[0];
return issues.length + " notes";
},
// Number of unread Console > Messages entries.
messages_count: function() {
return Math.max(0, this.messages_log.length - this.messages_seen);
},
},
ready: function() {
window.onhashchange = () => this.parse_hash();
// Resolve the initial route before the websocket connects so
// the shell shows the right view even on a slow / offline
// controller. update() will call parse_hash() again once the
// first config is in. Skip routing into the Svelte settings
// family before config has loaded — those components read
// many config keys (settings.units, settings.probing-prompts,
// motion.*, etc.) and would throw on first paint with the
// empty placeholder config.
const settingsFamily = [
"settings", "admin-general", "admin-network",
"motor", "tool", "io", "macros",
"help", "cheat-sheet",
];
const initialHead = (location.hash || "").replace(/^#/, "").split(":")[0];
if (settingsFamily.indexOf(initialHead) === -1) {
this.parse_hash();
}
// else: stay on "loading" until update() completes and calls
// parse_hash() itself.
this.connect();
// Close the system popover when clicking anywhere else.
document.addEventListener("click", () => {
if (this.sys_open) this.sys_open = false;
});
SvelteComponents.registerControllerMethods({
dispatch: (...args) => this.$dispatch(...args)
});
},
methods: {
block_error_dialog: function() {
this.errorTimeoutStart = Date.now();
@@ -372,11 +498,19 @@ module.exports = new Vue({
connect: function() {
this.sock = new Sock(`//${location.host}/sockjs`);
let _gotFirstMsg = false;
let _gotFirstState = false;
this.sock.onmessage = (e) => {
if (typeof e.data != "object") {
return;
}
if (!_gotFirstMsg) {
_gotFirstMsg = true;
restartTiming.onWsFirstMessage();
}
if (e.data.log && e.data.log.msg !== "Switch not found") {
this.$broadcast("log", e.data.log);
@@ -386,6 +520,11 @@ module.exports = new Vue({
}
}
if (!_gotFirstState) {
_gotFirstState = true;
restartTiming.onFirstState();
}
// Check for session ID change on controller
if ("sid" in e.data) {
if (typeof this.sid == "undefined") {
@@ -410,6 +549,7 @@ module.exports = new Vue({
this.sock.onopen = () => {
this.status = "connected";
restartTiming.onWsOpen();
this.$emit(this.status);
this.$broadcast(this.status);
};
@@ -421,6 +561,21 @@ module.exports = new Vue({
};
},
// Maps a URL hash to (currentView, top_tab, sub_tab, index).
// Hash layouts supported (all kept for backward compat):
// #control -> control tab
// #program[:auto] -> program tab
// #console[:mdi|messages|indicators]
// -> console tab
// #settings -> settings tab home
// #admin-general -> settings tab, admin-general inside
// #admin-network -> settings tab, admin-network inside
// #motor:0..3 -> settings tab, motor 0..3
// #tool -> settings tab, tool view
// #io -> settings tab, io view
// #macros -> settings tab, macros view
// #help -> settings tab, help view
// #cheat-sheet -> settings tab, cheat sheet view
parse_hash: function() {
const hash = location.hash.substr(1);
@@ -430,12 +585,56 @@ module.exports = new Vue({
}
const parts = hash.split(":");
const head = parts[0];
if (parts.length == 2) {
this.index = parts[1];
this.index = parts.length > 1 ? parts[1] : -1;
// Legacy / settings-managed views resolve under the
// Settings tab while keeping their existing top-level
// hash. This preserves all existing deep links.
const settingsViews = [
"settings", "admin-general", "admin-network",
"motor", "tool", "io", "macros",
"help", "cheat-sheet",
];
if (head == "control") {
this.top_tab = "control";
this.sub_tab = "";
this.currentView = "control";
} else if (head == "program") {
this.top_tab = "program";
this.sub_tab = parts[1] || "auto";
this.currentView = "program";
} else if (head == "console") {
this.top_tab = "console";
this.sub_tab = parts[1] || "mdi";
this.currentView = "console";
} else if (settingsViews.indexOf(head) !== -1) {
this.top_tab = "settings";
this.sub_tab = head;
// All settings-family routes mount the same shell;
// shell picks inner view from sub_tab. Vary the
// currentView token so Vue 1 fully remounts the
// shell on every navigation — this avoids stale :class
// bindings against the local `sub` data prop.
this.currentView = "settings-shell";
} else {
// Unknown hash: route to settings shell anyway so we
// never end up rendering a bare loading screen.
this.top_tab = "settings";
this.sub_tab = head;
this.currentView = "settings-shell";
}
this.currentView = parts[0];
// Mark Console messages as seen when we enter that tab.
if (this.top_tab == "console" && this.sub_tab == "messages") {
this.messages_seen = this.messages_log.length;
}
},
toggle_sys_popover: function() {
this.sys_open = !this.sys_open;
},
save: async function() {

View File

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

125
src/js/console-view.js Normal file
View File

@@ -0,0 +1,125 @@
"use strict";
const api = require("./api");
// Console tab — MDI command input, message log and live indicators.
// Sub-tab state syncs with the URL hash (#console:mdi |
// #console:messages | #console:indicators) so deep links work.
module.exports = {
template: "#console-view-template",
props: ["config", "template", "state"],
data: function () {
return {
mdi: "",
history: [],
sub: "mdi",
// Local mirror of $root.messages_count so Vue 1 reactivity works.
unread_messages_local: 0,
};
},
watch: {
sub: function () {
// Switching to messages marks them as seen so the header badge
// clears.
if (this.sub === "messages") {
this.$root.messages_seen = this.$root.messages_log.length;
this.unread_messages_local = 0;
}
},
},
computed: {
unread_messages: function () {
return this.unread_messages_local;
},
mach_state: function () {
const cycle = this.state.cycle;
const xx = this.state.xx;
if (xx != "ESTOPPED" && (cycle == "jogging" || cycle == "homing")) {
return cycle.toUpperCase();
}
return xx || "";
},
is_idle: function () { return this.state.cycle == "idle"; },
can_mdi: function () {
return this.is_idle || this.state.cycle == "mdi";
},
mach_units: function () {
return this.$root.display_units;
},
},
ready: function () {
this._onHash = () => this.refresh_from_hash();
window.addEventListener("hashchange", this._onHash);
this.refresh_from_hash();
this._poll = setInterval(() => {
// Cheap re-poll for unread message count; Vue 1 cannot observe
// `$root.messages_count` directly so we mirror it here.
const c = this.$root && this.$root.messages_count;
if (typeof c === "number" && c !== this.unread_messages_local) {
this.unread_messages_local = c;
}
}, 500);
},
beforeDestroy: function () {
if (this._onHash) window.removeEventListener("hashchange", this._onHash);
if (this._poll) clearInterval(this._poll);
},
methods: {
refresh_from_hash: function () {
const hash = location.hash.substr(1);
const parts = hash.split(":");
const sub = parts[0] === "console" ? (parts[1] || "mdi") : "mdi";
this.sub = sub;
if (sub === "messages" && this.$root) {
this.$root.messages_seen = this.$root.messages_log.length;
this.unread_messages_local = 0;
}
},
select_sub: function (name) {
this.sub = name;
// Update URL hash for deep links / back-button.
const h = "#console" + (name && name !== "mdi" ? ":" + name : "");
if (location.hash !== h) {
history.replaceState(null, "", h);
}
if (name === "messages") {
this.$root.messages_seen = this.$root.messages_log.length;
this.unread_messages_local = 0;
}
},
prepend: function (token) {
this.mdi = token + this.mdi.trimStart();
},
append: function (token) {
const tail = this.mdi.endsWith(" ") || !this.mdi ? "" : " ";
this.mdi = this.mdi + tail + token;
},
submit_mdi: function () {
if (!this.mdi) return;
this.$dispatch("send", this.mdi);
if (!this.history.length || this.history[0] != this.mdi) {
this.history.unshift(this.mdi);
}
this.mdi = "";
},
load_history: function (index) {
this.mdi = this.history[index];
},
},
};

View File

@@ -1,7 +1,6 @@
"use strict";
const api = require("./api");
const utils = require("./utils");
const cookie = require("./cookie")("bbctrl-");
module.exports = {
@@ -12,15 +11,7 @@ module.exports = {
return {
current_time: "",
mach_units: this.$root.state.metric ? "METRIC" : "IMPERIAL",
mdi: "",
last_file: undefined,
last_file_time: undefined,
toolpath: {},
toolpath_progress: 0,
axes: "xyzabc",
history: [],
speed_override: 1,
feed_override: 1,
jog_incr_amounts: {
METRIC: {
fine: 0.1,
@@ -38,34 +29,14 @@ module.exports = {
jog_incr: localStorage.getItem("jog_incr") || "small",
jog_step: cookie.get_bool("jog-step"),
jog_adjust: parseInt(cookie.get("jog-adjust", 2)),
deleteGCode: false,
tab: "auto",
ask_home: true,
folder_name: "",
edited: false,
uploading_files: false,
confirmDelete: false,
create_folder: false,
showGcodeMessage: false,
showNoGcodeMessage: false,
macrosLoading: false,
show_gcodes: false,
GCodeNotFound: false,
show_probe_dialog: false,
filesUploaded: 0,
totalFiles: 0,
files_sortby: "By Upload Date",
selected_items_to_delete: [],
search_query: "",
filtered_files: [],
selected_folder_index: null,
overrides_open: false,
};
},
components: {
"axis-control": require("./axis-control"),
"path-viewer": require("./path-viewer"),
"gcode-viewer": require("./gcode-viewer"),
},
watch: {
@@ -80,16 +51,6 @@ module.exports = {
immediate: true,
},
"state.line": function () {
if (this.mach_state != "HOMING") {
this.$broadcast("gcode-line", this.state.line);
}
},
"state.selected_time": function () {
this.load();
},
jog_step: function () {
cookie.set_bool("jog-step", this.jog_step);
},
@@ -127,43 +88,16 @@ module.exports = {
return state || "";
},
pause_reason: function () {
return this.state.pr;
},
is_running: function () {
return this.mach_state == "RUNNING" || this.mach_state == "HOMING";
},
is_stopping: function () {
return this.mach_state == "STOPPING";
},
is_holding: function () {
return this.mach_state == "HOLDING";
},
is_ready: function () {
return this.mach_state == "READY";
can_set_axis: function () {
return this.state.cycle == "idle";
},
is_idle: function () {
return this.state.cycle == "idle";
},
is_paused: function () {
return this.is_holding && (this.pause_reason == "User pause" || this.pause_reason == "Program pause");
},
can_mdi: function () {
return this.is_idle || this.state.cycle == "mdi";
},
can_set_axis: function () {
return this.is_idle;
// TODO allow setting axis position during pause
// return this.is_idle || this.is_paused;
is_ready: function () {
return this.mach_state == "READY";
},
message: function () {
@@ -191,57 +125,21 @@ module.exports = {
},
plan_time_remaining: function () {
if (!(this.is_stopping || this.is_running || this.is_holding)) {
return 0;
}
return this.toolpath.time - this.plan_time;
const stopping = this.mach_state == "STOPPING";
const running = this.mach_state == "RUNNING" || this.mach_state == "HOMING";
const holding = this.mach_state == "HOLDING";
if (!(stopping || running || holding)) return 0;
const tp = this.$root && this.$root.toolpath ? this.$root.toolpath.time : 0;
return (tp || 0) - this.plan_time;
},
eta: function () {
if (this.mach_state != "RUNNING") {
return "";
}
const remaining = this.plan_time_remaining;
const d = new Date();
d.setSeconds(d.getSeconds() + remaining);
return d.toLocaleString();
},
progress: function () {
if (!this.toolpath.time || this.is_ready) {
return 0;
}
const p = this.plan_time / this.toolpath.time;
return Math.min(1, p);
},
gcode_files: function () {
if (!this.state.folder) {
return [];
}
const folder = this.state.gcode_list.find(item => item.name == this.state.folder);
if (!folder) {
return [];
}
const files = folder.files.filter(item => this.state.files.includes(item.file_name)).map(item => item.file_name);
if (this.files_sortby == "A-Z") {
return files.sort();
} else if (this.files_sortby == "Z-A") {
return files.sort().reverse();
} else {
return files;
}
},
gcode_filtered_files: function () {
return this.filtered_files.filter(file => file.toLowerCase().includes(this.search_query.toLowerCase()));
},
gcode_folders: function () {
return this.state.gcode_list
.map(item => item.name)
.filter(element => element !== "default")
.sort();
state_kpi_class: function () {
const s = this.mach_state;
if (s == "ESTOPPED" || s == "FAULT" || s == "SHUTDOWN") return "bad";
if (s == "HOLDING" || s == "STOPPING") return "warn";
if (s == "RUNNING" || s == "HOMING" || s == "JOGGING") return "busy";
if (s == "READY") return "ok";
return "";
},
},
@@ -264,14 +162,9 @@ module.exports = {
M72
`);
},
folder_name_edited: function () {
this.edited = true;
},
},
ready: function () {
this.load();
setInterval(() => {
this.current_time = new Date().toLocaleTimeString();
}, 1000);
@@ -287,28 +180,39 @@ module.exports = {
},
methods: {
save_config: async function (config) {
try {
await api.put("config/save", config);
this.$dispatch("update");
} catch (error) {
console.error("Restore Failed: ", error);
alert("Restore failed");
}
},
populateFiles(index) {
this.selected_folder_index = index;
this.filtered_files = this.state.gcode_list[index].files.map(item => item.file_name);
},
getJogIncrStyle(value) {
const weight = `font-weight:${this.jog_incr === value ? "bold" : "normal"}`;
const color = this.jog_incr === value ? "color:#0078e7" : "";
return [weight, color].join(";");
},
// Should the macro row render a colored left stripe for this
// macro? Only when the user has explicitly picked a color. The
// controller seeds new macros with default placeholders like
// "#ffffff" or "#dedede"; treat anything that close to white as
// "no color".
has_macro_color(macros) {
if (!macros || typeof macros.color !== "string") return false;
const c = macros.color.trim().toLowerCase();
if (!c) return false;
const defaults = [
"#fff", "#ffffff", "#fefefe", "#fdfdfd", "#fcfcfc",
"#dedede", "#dddddd", "#cccccc",
];
if (defaults.indexOf(c) !== -1) return false;
// Fallback: if the color is very close to white (sum of RGB
// > 690), suppress the stripe.
const m = c.match(/^#([0-9a-f]{6})$/);
if (m) {
const v = parseInt(m[1], 16);
const r = (v >> 16) & 0xff;
const g = (v >> 8) & 0xff;
const b = v & 0xff;
if (r + g + b > 690) return false;
}
return true;
},
jog_fn: function (x_jog, y_jog, z_jog, a_jog) {
const amount = this.jog_incr_amounts[this.display_units][this.jog_incr];
@@ -324,426 +228,6 @@ module.exports = {
`);
},
send: function (msg) {
this.$dispatch("send", msg);
},
toggle_sorting: function () {
if (this.files_sortby === "By Upload Date") {
this.files_sortby = "A-Z";
} else if (this.files_sortby === "A-Z") {
this.files_sortby = "Z-A";
} else if (this.files_sortby === "Z-A") {
this.files_sortby = "By Upload Date";
}
},
load: function () {
const file_time = this.state.selected_time;
const file = this.state.selected;
if (this.last_file == file && this.last_file_time == file_time) {
return;
}
if (this.state.selected && !this.state.files.includes(this.state.selected)) {
this.GCodeNotFound = true;
return;
}
this.last_file = file;
this.last_file_time = file_time;
this.$broadcast("gcode-load", file);
this.$broadcast("gcode-line", this.state.line);
this.toolpath_progress = 0;
this.load_toolpath(file, file_time);
},
load_toolpath: async function (file, file_time) {
this.toolpath = {};
if (!file || this.last_file_time != file_time) {
return;
}
this.showGcodeMessage = true;
while (this.showGcodeMessage) {
try {
const toolpath = await api.get(`path/${file}`);
this.toolpath_progress = toolpath.progress;
if (toolpath.progress === 1 || typeof toolpath.progress == "undefined") {
this.showGcodeMessage = false;
if (toolpath.bounds) {
toolpath.filename = file;
this.toolpath_progress = 1;
this.toolpath = toolpath;
const state = this.$root.state;
for (const axis of "xyzabc") {
Vue.set(state, `path_min_${axis}`, toolpath.bounds.min[axis]);
Vue.set(state, `path_max_${axis}`, toolpath.bounds.max[axis]);
}
}
}
} catch (error) {
console.error(error);
}
}
},
submit_mdi: function () {
this.send(this.mdi);
if (!this.history.length || this.history[0] != this.mdi) {
this.history.unshift(this.mdi);
}
this.mdi = "";
},
mdi_start_pause: function () {
if (this.state.xx == "RUNNING") {
this.pause();
} else if (this.state.xx == "STOPPING" || this.state.xx == "HOLDING") {
this.unpause();
} else {
this.submit_mdi();
}
},
load_history: function (index) {
this.mdi = this.history[index];
},
open_file: function () {
utils.clickFileInput("gcode-file-input");
},
open_folder: function () {
utils.clickFileInput("gcode-folder-input");
},
edited_folder_name: function (event) {
if (event.target.value.trim() != "") {
this.$dispatch("folder_name_edited");
}
},
update_config: function () {
this.config.gcode_list = [...this.state.gcode_list];
this.config.non_macros_list = [...this.state.non_macros_list];
this.config.macros_list = [...this.state.macros_list];
this.config.macros = [...this.state.macros];
},
reset_gcode: function () {
this.state.selected = "";
this.last_file = "";
this.$broadcast("gcode-load", "");
},
upload_gcode: async function (filename, file) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
this.filesUploaded++;
if (this.filesUploaded == this.totalFiles) {
this.uploading_files = false;
}
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve("file uploaded");
} else {
console.error("File upload failed:", xhr.statusText);
reject("upload failed");
}
};
xhr.onerror = () => {
alert("Upload failed.");
reject("upload failed");
};
xhr.open("PUT", `/api/file/${encodeURIComponent(filename)}`, true);
xhr.send(file);
});
},
readFile: function (file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
resolve(reader.result);
};
reader.onerror = error => {
reject(error);
};
reader.readAsText(file, "utf-8");
});
},
validateFiles: async function (files) {
const validFiles = [];
for (const file of files) {
const extension = file.name.split(".").pop().toLowerCase();
const validExtensions = ["nc", "ngc", "gcode", "gc"];
if (validExtensions.includes(extension)) {
validFiles.push(file);
} else {
alert(`Unsupported file : ${file.name}`);
this.filesUploaded++;
if (this.filesUploaded == this.totalFiles) {
this.uploadFiles = false;
}
}
}
return validFiles;
},
uploadValidFiles: async function (files, folderName) {
const updatedConfig = { ...this.config };
for (const file of files) {
try {
const gcode = await this.readFile(file);
await this.upload_gcode(file.name, gcode);
const isAlreadyPresent = updatedConfig.non_macros_list.some(element => element.file_name === file.name);
if (!isAlreadyPresent) {
updatedConfig.non_macros_list.push({ file_name: file.name });
}
if (folderName) {
const folder = updatedConfig.gcode_list.find(item => item.type == "folder" && item.name == folderName);
if (folder) {
if (!folder.files.map(item => item.file_name).includes(file.name)) {
folder.files.push({ file_name: file.name });
}
} else {
updatedConfig.gcode_list.push({
name: folderName,
type: "folder",
files: [
{
file_name: file.name,
},
],
});
}
} else {
var folder_to_add = updatedConfig.gcode_list.find(
item => item.type == "folder" && item.name == this.state.folder,
);
if (!folder_to_add) {
folder_to_add = updatedConfig.gcode_list.unshift({
name: this.state.folder,
type: "folder",
files: [
{
file_name: file.name,
},
],
});
folder_to_add = updatedConfig.gcode_list[0];
}
if (!folder_to_add.files.find(item => item.file_name == file.name)) {
folder_to_add.files.push({ file_name: file.name });
}
}
} catch (error) {
console.warn(`error uploading file : `, error);
}
}
return updatedConfig;
},
upload_files: async function (files, folderName) {
this.update_config();
const validFiles = await this.validateFiles(files);
const updatedConfig = await this.uploadValidFiles(validFiles, folderName);
await this.save_config(updatedConfig);
},
upload_file: async function (e) {
this.uploading_files = true;
this.filesUploaded = 0;
const files = e.target.files || e.dataTransfer.files;
if (!files.length) {
return;
}
this.totalFiles = files.length;
await this.upload_files(files);
},
create_new_folder: async function () {
const folder_name = this.folder_name.trim();
if (folder_name != "") {
if (this.state.gcode_list.find(item => item.type == "folder" && item.name == folder_name)) {
alert("Folder with the same name already exists!");
return;
} else {
this.update_config();
this.config.gcode_list.push({
name: folder_name,
type: "folder",
files: [],
});
}
this.state.folder = folder_name;
this.edited = false;
this.create_folder = false;
this.folder_name = "";
this.save_config(this.config);
}
},
cancel_new_folder: function () {
this.create_folder = false;
this.folder_name = "";
},
upload_folder: async function (e) {
this.uploading_files = true;
this.filesUploaded = 0;
const files = e.target.files || e.dataTransfer.files;
if (!files.length) {
return;
}
this.totalFiles = files.length;
const folderName = files[0].webkitRelativePath.split("/")[0];
this.upload_files(files, folderName);
},
delete_current: async function () {
if (!this.state.selected) {
this.deleteGCode = false;
return;
}
this.update_config();
this.config.non_macros_list = this.config.non_macros_list.filter(
item => !this.selected_items_to_delete.includes(item.file_name),
);
const folder_to_update = this.config.gcode_list.find(
item => item.name == this.config.gcode_list[this.selected_folder_index].name && item.type == "folder",
);
folder_to_update.files = folder_to_update.files.filter(
item => !this.selected_items_to_delete.includes(item.file_name),
);
const exception_list = this.state.macros_list.map(item => item.file_name);
let files_to_delete = this.selected_items_to_delete.filter(item => !exception_list.includes(item));
await api.delete(`file/DINCAIQABiDARixAxiABDIHCAMQABiABDIHCAQQABiABDIH${files_to_delete.toString()}`);
this.save_config(this.config);
this.filtered_files = [];
this.search_query = "";
this.selected_folder_index = null;
this.selected_items_to_delete = [];
this.deleteGCode = false;
},
cancel_delete: function () {
this.filtered_files = [];
this.search_query = "";
this.selected_folder_index = null;
this.selected_items_to_delete = [];
this.deleteGCode = false;
},
delete_all: function () {
api.delete("file");
this.deleteGCode = false;
},
delete_all_except_macros: async function () {
this.update_config();
const macrosList = this.state.macros_list.map(item => item.file_name).toString();
api.delete(`file/EgZjaHJvbWUqCggBEAAYsQMYgAQyBggAEEUYOTIKCAE${macrosList}`);
this.config.non_macros_list = [];
this.config.gcode_list = [
{
name: "default",
type: "folder",
files: [],
},
];
this.save_config(this.config);
this.state.folder = "default";
this.state.selected = "";
this.selected_items_to_delete = [];
this.deleteGCode = false;
},
delete_folder: async function () {
this.update_config();
if (this.state.folder && this.state.folder != "default") {
const files_to_move = this.config.gcode_list.find(
item => item.type == "folder" && item.name == this.state.folder,
);
if (files_to_move) {
const default_folder = this.config.gcode_list.find(item => item.name == "default");
default_folder.files = [...default_folder.files, ...files_to_move.files].sort();
this.config.gcode_list = this.config.gcode_list.filter(item => item.name != this.state.folder);
this.save_config(this.config);
}
}
this.state.folder = "default";
this.confirmDelete = false;
},
delete_folder_and_files: async function () {
if (!this.state.folder) {
this.confirmDelete = false;
return;
}
this.update_config();
const selected_folder = this.config.gcode_list.find(
item => item.type == "folder" && item.name == this.state.folder,
);
if (!selected_folder) {
return;
}
const macrosList = this.state.macros_list.map(item => item.file_name);
var files_to_delete = selected_folder.files
.map(item => item.file_name)
.filter(item => !macrosList.includes(item));
if (selected_folder.name != "default") {
this.config.gcode_list = this.config.gcode_list.filter(item => item.name != this.state.folder);
} else {
selected_folder.files = [];
}
await api.delete(`file/DINCAIQABiDARixAxiABDIHCAMQABiABDIHCAQQABiABDIH${files_to_delete.toString()}`);
this.config.non_macros_list = this.config.non_macros_list.filter(
item => !files_to_delete.includes(item.file_name),
);
this.save_config(this.config);
this.state.folder = "default";
this.confirmDelete = false;
},
home: function (axis) {
this.ask_home = false;
@@ -765,6 +249,24 @@ module.exports = {
api.put(`home/${axis}/clear`);
},
aux_home: function () {
api.put("aux/home").catch(function (err) {
console.error("W home failed:", err);
});
},
aux_jog: function (delta_mm) {
api.put("aux/jog", { mm: delta_mm }).catch(function (err) {
console.error("W jog failed:", err);
});
},
aux_jog_incr: function (sign) {
const amount = this.jog_incr_amounts[this.display_units][this.jog_incr];
const delta_mm = sign * (this.metric ? amount : amount * 25.4);
this.aux_jog(delta_mm);
},
show_set_position: function (axis) {
SvelteComponents.showDialog("SetAxisPosition", { axis });
},
@@ -790,93 +292,20 @@ module.exports = {
},
zero: function (axis) {
if (typeof axis == "undefined") {
this.zero_all();
} else {
this.set_position(axis, 0);
}
},
start_pause: function () {
this.macrosLoading = false;
if (this.state.xx == "RUNNING") {
this.pause();
} else if (this.state.xx == "STOPPING" || this.state.xx == "HOLDING") {
this.unpause();
} else {
this.start();
}
},
start: function () {
api.put("start");
},
pause: function () {
api.put("pause");
},
unpause: function () {
api.put("unpause");
},
optional_pause: function () {
api.put("pause/optional");
},
stop: function () {
api.put("stop");
},
step: function () {
api.put("step");
},
override_feed: function () {
api.put(`override/feed/${this.feed_override}`);
},
override_speed: function () {
api.put(`override/speed/${this.speed_override}`);
},
current: function (axis, value) {
const x = value / 32.0;
if (this.state[`${axis}pl`] == x) {
return;
}
const data = {};
data[`${axis}pl`] = x;
this.send(JSON.stringify(data));
if (typeof axis == "undefined") this.zero_all();
else this.set_position(axis, 0);
},
showProbeDialog: function (probeType) {
if(this.show_probe_dialog){
if (this.show_probe_dialog) {
this.show_probe_dialog = false;
}
SvelteComponents.showDialog("Probe", { probeType, isRotaryActive: this.state["2an"] == 3 });
},
run_macro: function (id) {
if (this.state.macros[id].file_name == "default") {
this.showNoGcodeMessage = true;
} else {
if (this.state.macros[id].file_name != this.state.selected) {
this.state.selected = this.state.macros[id].file_name;
}
try {
this.load();
if (this.state.macros[id].alert == true) {
this.macrosLoading = true;
} else {
setImmediate(() => this.start_pause());
}
} catch (error) {
console.warn("Error running program: ", error);
}
}
SvelteComponents.showDialog("Probe", {
probeType,
isRotaryActive: this.state["2an"] == 3,
});
},
},
mixins: [require("./axis-vars")],
mixins: [require("./program-mixin"), require("./axis-vars")],
};

View File

@@ -44,6 +44,16 @@ window.onload = function() {
cookie_set("client-id", uuid(), 10000);
}
// Vue 1's async queue can drop dependent watcher updates when
// data props are mutated outside the normal event flow (e.g. from
// a `hashchange` listener that fires before Vue's tick scheduler
// has caught up). Disable async batching so every reactive write
// synchronously re-evaluates dependents — this matches Vue 1's
// older default and is what the legacy UI implicitly relied on.
if (Vue.config) {
Vue.config.async = false;
}
// Register global components
Vue.component("templated-input", require("./templated-input"));
Vue.component("message", require("./message"));

View File

@@ -683,12 +683,16 @@ const OrbitControls = function(object, domElement) {
event.preventDefault();
}
// Chrome treats touch/wheel listeners as passive by default,
// which prevents OrbitControls.preventDefault() from suppressing
// page panning while interacting with the 3D viewer. Pass
// {passive: false} on the events that need to call preventDefault.
scope.domElement.addEventListener("contextmenu", onContextMenu, false);
scope.domElement.addEventListener("mousedown", onMouseDown, false);
scope.domElement.addEventListener("wheel", onMouseWheel, false);
scope.domElement.addEventListener("touchstart", onTouchStart, false);
scope.domElement.addEventListener("wheel", onMouseWheel, { passive: false });
scope.domElement.addEventListener("touchstart", onTouchStart, { passive: false });
scope.domElement.addEventListener("touchend", onTouchEnd, false);
scope.domElement.addEventListener("touchmove", onTouchMove, false);
scope.domElement.addEventListener("touchmove", onTouchMove, { passive: false });
window.addEventListener("keydown", onKeyDown, false);
this.update(); // force an update at start

561
src/js/program-mixin.js Normal file
View File

@@ -0,0 +1,561 @@
"use strict";
// Shared data, computed properties and methods that are used by both
// the Control view (for things like start/stop, run-macro, axis state)
// and the Program view (RUN/STOP/Upload/Download/Delete + file picker
// + gcode/path viewers). Splitting these out lets us mount the same
// behaviour under two top-level routes without duplicating code.
//
// The mixin intentionally does *not* require axis-vars; control-view
// keeps that one to itself.
const api = require("./api");
const utils = require("./utils");
module.exports = {
data: function () {
return {
mdi: "",
last_file: undefined,
last_file_time: undefined,
toolpath: {},
toolpath_progress: 0,
history: [],
speed_override: 1,
feed_override: 1,
deleteGCode: false,
folder_name: "",
edited: false,
uploading_files: false,
confirmDelete: false,
create_folder: false,
showGcodeMessage: false,
showNoGcodeMessage: false,
macrosLoading: false,
show_gcodes: false,
GCodeNotFound: false,
filesUploaded: 0,
totalFiles: 0,
files_sortby: "By Upload Date",
selected_items_to_delete: [],
search_query: "",
filtered_files: [],
selected_folder_index: null,
};
},
watch: {
"state.line": function () {
if (this.mach_state != "HOMING") {
this.$broadcast("gcode-line", this.state.line);
}
},
"state.selected_time": function () {
this.load();
},
},
computed: {
is_running: function () {
return this.mach_state == "RUNNING" || this.mach_state == "HOMING";
},
is_stopping: function () {
return this.mach_state == "STOPPING";
},
is_holding: function () {
return this.mach_state == "HOLDING";
},
is_ready: function () {
return this.mach_state == "READY";
},
is_idle: function () {
return this.state.cycle == "idle";
},
is_paused: function () {
return this.is_holding && (this.pause_reason == "User pause" || this.pause_reason == "Program pause");
},
can_mdi: function () {
return this.is_idle || this.state.cycle == "mdi";
},
pause_reason: function () {
return this.state.pr;
},
plan_time: function () {
return this.state.plan_time;
},
plan_time_remaining: function () {
if (!(this.is_stopping || this.is_running || this.is_holding)) {
return 0;
}
return this.toolpath.time - this.plan_time;
},
eta: function () {
if (this.mach_state != "RUNNING") {
return "";
}
const remaining = this.plan_time_remaining;
const d = new Date();
d.setSeconds(d.getSeconds() + remaining);
return d.toLocaleString();
},
progress: function () {
if (!this.toolpath.time || this.is_ready) {
return 0;
}
const p = this.plan_time / this.toolpath.time;
return Math.min(1, p);
},
gcode_files: function () {
if (!this.state.folder) return [];
const list = Array.isArray(this.state.gcode_list) ? this.state.gcode_list : [];
const folder = list.find(item => item.name == this.state.folder);
if (!folder) return [];
const stateFiles = Array.isArray(this.state.files) ? this.state.files : [];
const files = (folder.files || [])
.filter(item => stateFiles.includes(item.file_name))
.map(item => item.file_name);
if (this.files_sortby == "A-Z") return files.sort();
if (this.files_sortby == "Z-A") return files.sort().reverse();
return files;
},
gcode_filtered_files: function () {
return this.filtered_files.filter(file =>
file.toLowerCase().includes(this.search_query.toLowerCase()));
},
gcode_folders: function () {
const list = Array.isArray(this.state.gcode_list) ? this.state.gcode_list : [];
return list
.map(item => item.name)
.filter(element => element !== "default")
.sort();
},
},
methods: {
save_config: async function (config) {
try {
await api.put("config/save", config);
this.$dispatch("update");
} catch (error) {
console.error("Restore Failed: ", error);
alert("Restore failed");
}
},
populateFiles(index) {
this.selected_folder_index = index;
this.filtered_files = this.state.gcode_list[index].files.map(item => item.file_name);
},
send: function (msg) {
this.$dispatch("send", msg);
},
toggle_sorting: function () {
if (this.files_sortby === "By Upload Date") this.files_sortby = "A-Z";
else if (this.files_sortby === "A-Z") this.files_sortby = "Z-A";
else if (this.files_sortby === "Z-A") this.files_sortby = "By Upload Date";
},
load: function () {
const file_time = this.state.selected_time;
const file = this.state.selected;
if (this.last_file == file && this.last_file_time == file_time) return;
// state.files can be undefined briefly after connect, before the
// controller has pushed its file list. Skip the existence check
// until we have a list to consult.
const files = Array.isArray(this.state.files) ? this.state.files : null;
if (this.state.selected && files && !files.includes(this.state.selected)) {
this.GCodeNotFound = true;
return;
}
this.last_file = file;
this.last_file_time = file_time;
this.$broadcast("gcode-load", file);
this.$broadcast("gcode-line", this.state.line);
this.toolpath_progress = 0;
this.load_toolpath(file, file_time);
},
load_toolpath: async function (file, file_time) {
this.toolpath = {};
if (!file || this.last_file_time != file_time) return;
this.showGcodeMessage = true;
while (this.showGcodeMessage) {
try {
const toolpath = await api.get(`path/${file}`);
this.toolpath_progress = toolpath.progress;
if (toolpath.progress === 1 || typeof toolpath.progress == "undefined") {
this.showGcodeMessage = false;
if (toolpath.bounds) {
toolpath.filename = file;
this.toolpath_progress = 1;
this.toolpath = toolpath;
const state = this.$root.state;
for (const axis of "xyzabc") {
Vue.set(state, `path_min_${axis}`, toolpath.bounds.min[axis]);
Vue.set(state, `path_max_${axis}`, toolpath.bounds.max[axis]);
}
}
}
} catch (error) {
console.error(error);
}
}
},
submit_mdi: function () {
this.send(this.mdi);
if (!this.history.length || this.history[0] != this.mdi) {
this.history.unshift(this.mdi);
}
this.mdi = "";
},
mdi_start_pause: function () {
if (this.state.xx == "RUNNING") this.pause();
else if (this.state.xx == "STOPPING" || this.state.xx == "HOLDING") this.unpause();
else this.submit_mdi();
},
load_history: function (index) {
this.mdi = this.history[index];
},
open_file: function () {
utils.clickFileInput("gcode-file-input");
},
open_folder: function () {
utils.clickFileInput("gcode-folder-input");
},
edited_folder_name: function (event) {
if (event.target.value.trim() != "") {
this.$dispatch("folder_name_edited");
}
},
update_config: function () {
this.config.gcode_list = [...this.state.gcode_list];
this.config.non_macros_list = [...this.state.non_macros_list];
this.config.macros_list = [...this.state.macros_list];
this.config.macros = [...this.state.macros];
},
reset_gcode: function () {
this.state.selected = "";
this.last_file = "";
this.$broadcast("gcode-load", "");
},
upload_gcode: async function (filename, file) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
this.filesUploaded++;
if (this.filesUploaded == this.totalFiles) {
this.uploading_files = false;
}
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) resolve("file uploaded");
else { console.error("File upload failed:", xhr.statusText); reject("upload failed"); }
};
xhr.onerror = () => { alert("Upload failed."); reject("upload failed"); };
xhr.open("PUT", `/api/file/${encodeURIComponent(filename)}`, true);
xhr.send(file);
});
},
readFile: function (file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = error => reject(error);
reader.readAsText(file, "utf-8");
});
},
validateFiles: async function (files) {
const validFiles = [];
for (const file of files) {
const extension = file.name.split(".").pop().toLowerCase();
const validExtensions = ["nc", "ngc", "gcode", "gc"];
if (validExtensions.includes(extension)) {
validFiles.push(file);
} else {
alert(`Unsupported file : ${file.name}`);
this.filesUploaded++;
if (this.filesUploaded == this.totalFiles) {
this.uploadFiles = false;
}
}
}
return validFiles;
},
uploadValidFiles: async function (files, folderName) {
const updatedConfig = { ...this.config };
for (const file of files) {
try {
const gcode = await this.readFile(file);
await this.upload_gcode(file.name, gcode);
const isAlreadyPresent = updatedConfig.non_macros_list.some(element => element.file_name === file.name);
if (!isAlreadyPresent) {
updatedConfig.non_macros_list.push({ file_name: file.name });
}
if (folderName) {
const folder = updatedConfig.gcode_list.find(item => item.type == "folder" && item.name == folderName);
if (folder) {
if (!folder.files.map(item => item.file_name).includes(file.name)) {
folder.files.push({ file_name: file.name });
}
} else {
updatedConfig.gcode_list.push({
name: folderName,
type: "folder",
files: [{ file_name: file.name }],
});
}
} else {
var folder_to_add = updatedConfig.gcode_list.find(
item => item.type == "folder" && item.name == this.state.folder,
);
if (!folder_to_add) {
folder_to_add = updatedConfig.gcode_list.unshift({
name: this.state.folder,
type: "folder",
files: [{ file_name: file.name }],
});
folder_to_add = updatedConfig.gcode_list[0];
}
if (!folder_to_add.files.find(item => item.file_name == file.name)) {
folder_to_add.files.push({ file_name: file.name });
}
}
} catch (error) {
console.warn(`error uploading file : `, error);
}
}
return updatedConfig;
},
upload_files: async function (files, folderName) {
this.update_config();
const validFiles = await this.validateFiles(files);
const updatedConfig = await this.uploadValidFiles(validFiles, folderName);
await this.save_config(updatedConfig);
},
upload_file: async function (e) {
this.uploading_files = true;
this.filesUploaded = 0;
const files = e.target.files || e.dataTransfer.files;
if (!files.length) return;
this.totalFiles = files.length;
await this.upload_files(files);
},
create_new_folder: async function () {
const folder_name = this.folder_name.trim();
if (folder_name != "") {
if (this.state.gcode_list.find(item => item.type == "folder" && item.name == folder_name)) {
alert("Folder with the same name already exists!");
return;
}
this.update_config();
this.config.gcode_list.push({
name: folder_name,
type: "folder",
files: [],
});
this.state.folder = folder_name;
this.edited = false;
this.create_folder = false;
this.folder_name = "";
this.save_config(this.config);
}
},
cancel_new_folder: function () {
this.create_folder = false;
this.folder_name = "";
},
upload_folder: async function (e) {
this.uploading_files = true;
this.filesUploaded = 0;
const files = e.target.files || e.dataTransfer.files;
if (!files.length) return;
this.totalFiles = files.length;
const folderName = files[0].webkitRelativePath.split("/")[0];
this.upload_files(files, folderName);
},
delete_current: async function () {
if (!this.state.selected) {
this.deleteGCode = false;
return;
}
this.update_config();
this.config.non_macros_list = this.config.non_macros_list.filter(
item => !this.selected_items_to_delete.includes(item.file_name),
);
const folder_to_update = this.config.gcode_list.find(
item => item.name == this.config.gcode_list[this.selected_folder_index].name && item.type == "folder",
);
folder_to_update.files = folder_to_update.files.filter(
item => !this.selected_items_to_delete.includes(item.file_name),
);
const exception_list = this.state.macros_list.map(item => item.file_name);
let files_to_delete = this.selected_items_to_delete.filter(item => !exception_list.includes(item));
await api.delete(`file/DINCAIQABiDARixAxiABDIHCAMQABiABDIHCAQQABiABDIH${files_to_delete.toString()}`);
this.save_config(this.config);
this.filtered_files = [];
this.search_query = "";
this.selected_folder_index = null;
this.selected_items_to_delete = [];
this.deleteGCode = false;
},
cancel_delete: function () {
this.filtered_files = [];
this.search_query = "";
this.selected_folder_index = null;
this.selected_items_to_delete = [];
this.deleteGCode = false;
},
delete_all: function () {
api.delete("file");
this.deleteGCode = false;
},
delete_all_except_macros: async function () {
this.update_config();
const macrosList = this.state.macros_list.map(item => item.file_name).toString();
api.delete(`file/EgZjaHJvbWUqCggBEAAYsQMYgAQyBggAEEUYOTIKCAE${macrosList}`);
this.config.non_macros_list = [];
this.config.gcode_list = [{ name: "default", type: "folder", files: [] }];
this.save_config(this.config);
this.state.folder = "default";
this.state.selected = "";
this.selected_items_to_delete = [];
this.deleteGCode = false;
},
delete_folder: async function () {
this.update_config();
if (this.state.folder && this.state.folder != "default") {
const files_to_move = this.config.gcode_list.find(
item => item.type == "folder" && item.name == this.state.folder,
);
if (files_to_move) {
const default_folder = this.config.gcode_list.find(item => item.name == "default");
default_folder.files = [...default_folder.files, ...files_to_move.files].sort();
this.config.gcode_list = this.config.gcode_list.filter(item => item.name != this.state.folder);
this.save_config(this.config);
}
}
this.state.folder = "default";
this.confirmDelete = false;
},
delete_folder_and_files: async function () {
if (!this.state.folder) {
this.confirmDelete = false;
return;
}
this.update_config();
const selected_folder = this.config.gcode_list.find(
item => item.type == "folder" && item.name == this.state.folder,
);
if (!selected_folder) return;
const macrosList = this.state.macros_list.map(item => item.file_name);
var files_to_delete = selected_folder.files
.map(item => item.file_name)
.filter(item => !macrosList.includes(item));
if (selected_folder.name != "default") {
this.config.gcode_list = this.config.gcode_list.filter(item => item.name != this.state.folder);
} else {
selected_folder.files = [];
}
await api.delete(`file/DINCAIQABiDARixAxiABDIHCAMQABiABDIHCAQQABiABDIH${files_to_delete.toString()}`);
this.config.non_macros_list = this.config.non_macros_list.filter(
item => !files_to_delete.includes(item.file_name),
);
this.save_config(this.config);
this.state.folder = "default";
this.confirmDelete = false;
},
start_pause: function () {
this.macrosLoading = false;
if (this.state.xx == "RUNNING") this.pause();
else if (this.state.xx == "STOPPING" || this.state.xx == "HOLDING") this.unpause();
else this.start();
},
start: function () { api.put("start"); },
pause: function () { api.put("pause"); },
unpause: function () { api.put("unpause"); },
optional_pause: function () { api.put("pause/optional"); },
stop: function () { api.put("stop"); },
step: function () { api.put("step"); },
override_feed: function () { api.put(`override/feed/${this.feed_override}`); },
override_speed: function () { api.put(`override/speed/${this.speed_override}`); },
run_macro: function (id) {
if (this.state.macros[id].file_name == "default") {
this.showNoGcodeMessage = true;
} else {
if (this.state.macros[id].file_name != this.state.selected) {
this.state.selected = this.state.macros[id].file_name;
}
try {
this.load();
if (this.state.macros[id].alert == true) {
this.macrosLoading = true;
} else {
setImmediate(() => this.start_pause());
}
} catch (error) {
console.warn("Error running program: ", error);
}
}
},
},
};

60
src/js/program-view.js Normal file
View File

@@ -0,0 +1,60 @@
"use strict";
// Program tab — file management, run/stop, gcode listing and 3D
// toolpath preview. Reuses the shared mixin (program-mixin) that also
// powers the legacy bits of control-view; this view does not host the
// jog grid or the DRO.
module.exports = {
template: "#program-view-template",
props: ["config", "template", "state"],
components: {
"path-viewer": require("./path-viewer"),
"gcode-viewer": require("./gcode-viewer"),
},
data: function () {
return {};
},
watch: {
"state.metric": {
handler: function () {},
immediate: true,
},
},
computed: {
display_units: {
cache: false,
get: function () { return this.$root.display_units; },
set: function (value) {
this.config.settings.units = value;
this.$root.display_units = value;
this.$dispatch("config-changed");
},
},
metric: function () {
return this.display_units === "METRIC";
},
mach_state: function () {
const cycle = this.state.cycle;
const xx = this.state.xx;
if (xx != "ESTOPPED" && (cycle == "jogging" || cycle == "homing")) {
return cycle.toUpperCase();
}
return xx || "";
},
can_set_axis: function () { return this.state.cycle == "idle"; },
},
ready: function () {
this.load();
},
mixins: [require("./program-mixin")],
};

80
src/js/restart-timing.js Normal file
View File

@@ -0,0 +1,80 @@
// Lightweight UI-side restart/cold-load timing.
//
// Records a few key marks using performance.now(), then POSTs them to
// /api/diag/timing/ui once 'ui.first_state' has fired. Disabled by
// setting window.BBCTRL_TRACE = false before this module is loaded.
//
// Marks collected:
// script.load -- this module evaluated
// ws.open -- websocket onopen
// ws.first_msg -- first message from controller
// ui.first_state -- first message that contained controller state
// window.load -- window 'load' event
//
// Aligning these with /api/diag/timing on the server gives the full
// picture from systemd start -> bbctrl up -> WS open -> UI rendered.
"use strict";
const _enabled = typeof window !== "undefined" && window.BBCTRL_TRACE !== false;
const _t0 = (typeof performance !== "undefined" && performance.now)
? performance.now()
: Date.now();
const _navStart = (typeof performance !== "undefined" && performance.timeOrigin)
? performance.timeOrigin
: Date.now();
const marks = [];
let posted = false;
function _now() {
return (typeof performance !== "undefined" && performance.now)
? performance.now() - _t0
: Date.now() - _t0;
}
function mark(name, fields) {
if (!_enabled) return;
marks.push(Object.assign({ n: name, t: Math.round(_now()) }, fields || {}));
}
function _post() {
if (!_enabled || posted) return;
posted = true;
const body = JSON.stringify({
navStart: _navStart,
t0_perf: _t0,
href: typeof location !== "undefined" ? location.href : "",
ua: typeof navigator !== "undefined" ? navigator.userAgent : "",
marks: marks,
});
try {
if (typeof fetch === "function") {
fetch("/api/diag/timing/ui", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: body,
keepalive: true,
}).catch(() => {});
}
} catch (e) { /* swallow */ }
}
// Record window load too; doesn't block posting.
if (_enabled && typeof window !== "undefined") {
window.addEventListener("load", () => mark("window.load"));
}
mark("script.load");
module.exports = {
enabled: _enabled,
mark: mark,
onWsOpen: () => mark("ws.open"),
onWsFirstMessage: () => mark("ws.first_msg"),
onFirstState: () => {
mark("ui.first_state");
// Defer slightly so any synchronous render finishes first.
setTimeout(_post, 100);
},
flush: _post,
};

View File

@@ -0,0 +1,136 @@
"use strict";
// Wrapper that adds a left-rail navigator around the settings family
// of views (Settings, Admin General, Admin Network, Tool, IO, Motor,
// Macros, Help, Cheat Sheet). The inner view is selected by the URL
// hash (parsed in app.js) and exposed as $root.sub_tab.
// Vue 1 has trouble making child components reactive to `$root.sub_tab`
// changes (whether via computed, watch, or prop binding through
// `<component :is>`). The shell instead listens to `hashchange`
// directly and parses the hash itself, mirroring app.js's logic, then
// keeps a local data prop `sub` that the template binds to. This is
// the only path that updates the rail's `:class` reactively.
module.exports = {
template: "#settings-shell-view-template",
props: ["config", "template", "state", "index"],
components: {
"settings-view-inner": require("./settings-view"),
"admin-general-view": require("./admin-general-view"),
"admin-network-view": require("./admin-network-view"),
"motor-view": require("./motor-view"),
"tool-view": require("./tool-view"),
"io-view": require("./io-view"),
"macros-view": require("./macros"),
"help-view": require("./help-view"),
"cheat-sheet-view": {
template: "#cheat-sheet-view-template",
data: function () {
return { showUnimplemented: false };
},
},
},
data: function () {
return {
sub: this.$root.sub_tab || "settings",
ridx: this.$root.index, // local copy of the motor index
rail_items: [
{ sub: "settings", href: "#settings", icon: "fa-display", label: "Display & Units" },
{ sub: "admin-network", href: "#admin-network", icon: "fa-network-wired", label: "Network" },
{ sub: "admin-general", href: "#admin-general", icon: "fa-shield-halved", label: "General / Firmware" },
{ sub: "tool", href: "#tool", icon: "fa-bolt", label: "Spindle & Tool" },
{ sub: "io", href: "#io", icon: "fa-plug", label: "I/O" },
{ section: "Motors" },
{ sub: "motor", motor: 0, href: "#motor:0", icon: "fa-arrows-up-down-left-right", label: "Motor 0" },
{ sub: "motor", motor: 1, href: "#motor:1", icon: "fa-arrows-up-down-left-right", label: "Motor 1" },
{ sub: "motor", motor: 2, href: "#motor:2", icon: "fa-arrows-up-down-left-right", label: "Motor 2" },
{ sub: "motor", motor: 3, href: "#motor:3", icon: "fa-arrows-up-down-left-right", label: "Motor 3" },
// W axis is auxiliary (auxcnc ESP32). Its config lives inside
// the main Settings page; we route to #settings and scroll to
// the #w-axis anchor on click.
{ sub: "w-axis", href: "#settings", anchor: "w-axis", icon: "fa-arrows-up-down", label: "W Axis" },
{ section: " " },
{ sub: "macros", href: "#macros", icon: "fa-keyboard", label: "Macros" },
{ sub: "cheat-sheet", href: "#cheat-sheet", icon: "fa-book", label: "G-code Cheat Sheet" },
{ sub: "help", href: "#help", icon: "fa-circle-question", label: "Help" },
],
};
},
ready: function () {
this._onHash = () => this.refresh_from_hash();
window.addEventListener("hashchange", this._onHash);
this.refresh_from_hash();
},
attached: function () {
// Vue 1 fires `attached` whenever the component is inserted into
// the DOM (which happens on every route change because the outer
// <component :is> recreates the instance). Re-bind the listener
// here so it works even after detach/attach cycles.
if (!this._onHash) {
this._onHash = () => this.refresh_from_hash();
}
window.addEventListener("hashchange", this._onHash);
this.refresh_from_hash();
},
detached: function () {
if (this._onHash) {
window.removeEventListener("hashchange", this._onHash);
}
},
beforeDestroy: function () {
if (this._onHash) {
window.removeEventListener("hashchange", this._onHash);
}
},
methods: {
refresh_from_hash: function () {
const hash = location.hash.substr(1) || "settings";
const parts = hash.split(":");
this.sub = parts[0] || "settings";
this.ridx = parts[1] !== undefined ? parts[1] : -1;
},
is_active: function (item) {
if (!item || item.section) return false;
// The W Axis rail item is a soft-link into #settings; we mark
// it active only when the user is on Display & Units AND the
// last clicked rail item was W Axis.
if (item.sub === "w-axis") {
return this.sub === "settings" && this._w_axis_focus === true;
}
if (item.sub !== this.sub) return false;
if (item.sub === "motor") {
return "" + item.motor === "" + this.ridx;
}
return true;
},
on_rail_click: function (item, ev) {
// Soft-link rail items use an anchor and a scrollIntoView call.
if (item && item.anchor) {
ev.preventDefault();
// Navigate to settings if not already there, then scroll.
if (location.hash !== item.href) location.hash = item.href;
this._w_axis_focus = (item.sub === "w-axis");
// Defer the scroll so Vue mounts the inner Svelte page first.
setTimeout(() => {
const el = document.getElementById(item.anchor);
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
}, 250);
} else {
this._w_axis_focus = false;
}
},
showShutdownDialog: function () {
SvelteComponents.showDialog("Shutdown");
},
},
};

View File

@@ -8,7 +8,6 @@ html(lang="en")
style: include ../static/css/pure-min.css
style: include ../static/css/side-menu.css
style: include ../static/css/font-awesome.min.css
style: include ../static/css/Audiowide.css
@@ -23,99 +22,113 @@ html(lang="en")
#overlay(v-if="status != 'connected'")
span {{status}}
#layout
a#menuLink.menu-link(href="#menu"): span
#menu
button.save.pure-button.button-success(:disabled="!modified",
@click="save") Save
.app-shell
header.app-head
.brand-blk
.brand-logo
.brand-name ONEFINITY
.pure-menu
ul.pure-menu-list
li.pure-menu-heading
a.pure-menu-link(href="#control") Control
nav.tabs-host(role="tablist")
a.ktab(:class="{active: top_tab === 'control'}", href="#control",
title="Jog, DRO, macros")
.fa.fa-gamepad
span Control
a.ktab(:class="{active: top_tab === 'program'}", href="#program",
title="Run programs, files, toolpath preview")
.fa.fa-list-ol
span Program
a.ktab(:class="{active: top_tab === 'console'}", href="#console",
title="MDI, messages, indicators")
.fa.fa-terminal
span Console
span.ktab-badge(v-if="messages_count") {{messages_count}}
a.ktab(:class="{active: top_tab === 'settings'}", href="#settings",
title="Configuration, network, macros")
.fa.fa-sliders
span Settings
li.pure-menu-heading
a.pure-menu-link(href="#macros") Macros
.head-spacer
li.pure-menu-heading
a.pure-menu-link(href="#settings") Settings
.sys-btn(@click.stop="toggle_sys_popover", :class="{open: sys_open}")
span.pip(:class="sys_class")
span.sys-text {{sys_summary}}
.fa.fa-chevron-down
li.pure-menu-heading
a.pure-menu-link(href="#motor:0") Motors
.pi-temp-warning(v-if="80 <= state.rpi_temp",
title="Raspberry Pi temperature too high.")
.fa.fa-thermometer-full
li.pure-menu-item(v-for="motor in config.motors")
a.pure-menu-link(:href="'#motor:' + $index") Motor {{$index}}
span.state-badge(:class="state_class", :title="mach_state_full")
span.dot
span {{state_label}}
li.pure-menu-heading
a.pure-menu-link(href="#tool") Tool
.estop(:class="{active: state.es}")
estop(@click="estop")
li.pure-menu-heading
a.pure-menu-link(href="#io") I/O
li.pure-menu-heading
a.pure-menu-link(href="#admin-general") Admin
li.pure-menu-item
a.pure-menu-link(href="#admin-general") General
li.pure-menu-item
a.pure-menu-link(href="#admin-network") Network
li.pure-menu-heading
a.pure-menu-link(href="#cheat-sheet") Cheat Sheet
li.pure-menu-heading
a.pure-menu-link(href="#help") Help
button.pure-button.pure-button-primary(@click="showShutdownDialog", style="width: 100%")
.fa.fa-power-off
#main
.nav-header
.brand
img(src="/images/onefinity_logo.png")
.version
div Version: v{{config.full_version}}
div IP Address: {{config.ip}}
div WiFi: {{config.wifiName}}
a.upgrade-link(v-if="show_upgrade()", href="#admin-general")
| Upgrade to v{{latestVersion}}
.fa.fa-exclamation-circle.upgrade-attention(v-if="show_upgrade()")
.pi-temp-warning
.fa.fa-thermometer-full(class="error",
v-if="80 <= state.rpi_temp",
title="Raspberry Pi temperature too high.")
.easy-adapter(v-if="is_easy_adapter_active")
.round-dot
div.easy-adapter-text Easy Adapter
.whitespace
div
button.rotary-button(:disabled="!enable_rotary", :class="is_rotary_active && 'active'", @click="showSwitchRotaryModeDialog")
img(src="/images/rotary.svg", alt="rotary", :style="is_rotary_active ? 'width:90%;' : 'width:85%;'")
div.rotary-text Rotary
.video(title="Plug camera into USB.\n" +
"Left click to toggle video size.\n" +
"Right click to toggle crosshair.", @click="toggle_video",
@contextmenu="toggle_crosshair", :class="video_size")
// System popover (chip-soup destination)
.sys-popover(v-if="sys_open", @click.stop="")
.sp-row
.sp-icon: .fa.fa-microchip
.sp-text
.sp-label Firmware
.sp-val v{{config.full_version}}
a.sp-act(v-if="show_upgrade()", href="#admin-general")
| Upgrade to v{{latestVersion}}
.fa.fa-exclamation-circle.upgrade-attention
.sp-row
.sp-icon: .fa.fa-network-wired
.sp-text
.sp-label IP Address
.sp-val {{config.ip}}
.sp-row
.sp-icon: .fa.fa-wifi(:class="{'sp-warn': config.wifiName === 'not connected'}")
.sp-text
.sp-label WiFi
.sp-val {{config.wifiName}}
a.sp-act(href="#admin-network", @click="sys_open=false") Configure
.sp-row(v-if="enable_rotary")
.sp-icon: img(src="/images/rotary.svg", alt="rotary")
.sp-text
.sp-label Rotary
.sp-val {{is_rotary_active ? 'Active' : 'Inactive'}}
button.sp-act(@click="showSwitchRotaryModeDialog")
| {{is_rotary_active ? 'Disable' : 'Enable'}}
.sp-row(v-if="is_easy_adapter_active")
.sp-icon: .fa.fa-puzzle-piece
.sp-text
.sp-label Easy Adapter
.sp-val Active
.sp-row.video-row
.sp-icon: .fa.fa-video
.sp-text
.sp-label Camera
.sp-val {{has_camera ? 'Live' : 'Plug camera into USB'}}
.sp-act(v-if="has_camera", @click="toggle_video")
| {{video_size === 'small' ? 'Enlarge' : 'Shrink'}}
.video(v-if="sys_open && has_camera", title="Camera feed",
@click="toggle_video", @contextmenu="toggle_crosshair",
:class="video_size")
.crosshair(v-if="crosshair")
.vertical
.horizontal
.box
img(src="/api/video")
img(src="/api/video", @error="has_camera=false")
.sp-foot
button.sp-shutdown(@click="showShutdownDialog")
.fa.fa-power-off
| &nbsp;Shutdown
button.sp-save(:disabled="!modified", @click="save")
.fa.fa-save
| &nbsp;Save{{modified ? '*' : ''}}
.estop(:class="{active: state.es}")
estop(@click="estop")
.content(class="{{currentView}}-view")
component(:is="currentView + '-view'", :index="index",
:config="config", :template="template", :state="state", keep-alive)
// Routed view (no keep-alive: Vue 1 has issues re-evaluating
// dynamic :class / v-if bindings on cached components when the
// route changes within the same kept-alive tree)
.app-body
component(:is="currentView + '-view'", :index="index",
:config="config", :template="template", :state="state",
:sub-tab="sub_tab")
message.error-message(:show.sync="errorShow")
div(slot="header")

View File

@@ -0,0 +1,67 @@
script#console-view-template(type="text/x-template")
.console-page
.console-card
.ptab-bar
button.ptab(:class="{active: sub === 'mdi'}", @click="select_sub('mdi')")
.fa.fa-keyboard
| &nbsp;MDI
button.ptab(:class="{active: sub === 'messages'}", @click="select_sub('messages')")
.fa.fa-comment-dots
| &nbsp;Messages
span.ptab-badge(v-if="unread_messages") {{unread_messages}}
button.ptab(:class="{active: sub === 'indicators'}", @click="select_sub('indicators')")
.fa.fa-bell
| &nbsp;Indicators
// ----- MDI -----
.mdi-pane(v-show="sub === 'mdi'")
.mdi-input
span.prompt G&gt;
input(type="text", v-model="mdi", :disabled="!can_mdi",
@keyup.enter="submit_mdi", placeholder="enter a G-code command…")
button.mdi-send(:disabled="!can_mdi || !mdi", @click="submit_mdi")
.fa.fa-paper-plane
| &nbsp;SEND
.mdi-keys
button.mkey(@click="prepend('G0 ')") G0
button.mkey(@click="prepend('G1 ')") G1
button.mkey(@click="prepend('G2 ')") G2
button.mkey(@click="prepend('G3 ')") G3
button.mkey(@click="prepend('G28 ')") G28
button.mkey(@click="prepend('G92 ')") G92
button.mkey(@click="prepend('M3 ')") M3
button.mkey(@click="prepend('M5 ')") M5
button.mkey(@click="append('X')") X
button.mkey(@click="append('Y')") Y
button.mkey(@click="append('Z')") Z
button.mkey(@click="append('W')") W
button.mkey(@click="append('F')") F
button.mkey(@click="append('S')") S
button.mkey.clear(@click="mdi = ''") CLEAR
button.mkey.send(:disabled="!can_mdi || !mdi", @click="submit_mdi") SEND ↵
em Machine units: #[strong {{mach_units}}]. G20/G21 to switch.
.mdi-history(:class="{placeholder: !history.length}")
span.mdi-empty(v-if="!history.length") MDI history will display here.
.h-row(v-for="item in history", @click="load_history($index)",
track-by="$index")
span.h-cmd {{item}}
span.h-status ↻
// ----- Messages -----
.messages-pane(v-show="sub === 'messages'")
.msg-empty(v-if="!$root.messages_log.length")
.fa.fa-check-circle
| &nbsp;No messages.
.msg(v-for="m in $root.messages_log",
:class="m.level === 'warning' ? 'warn' : 'info'", track-by="$index")
.mi
.fa(:class="m.level === 'warning' ? 'fa-triangle-exclamation' : 'fa-circle-info'")
div
.mtitle {{m.text}}
.mtime ID {{m.id}}
// ----- Indicators -----
.indicators-pane(v-show="sub === 'indicators'")
indicators(:state="state", :template="template")

View File

@@ -1,43 +1,39 @@
script#control-view-template(type="text/x-template")
#control
.control-page
// ----- Modal dialogs (kept verbatim from legacy) -----
message(:show.sync="showGcodeMessage")
h3(slot="header") Processing New File
div(slot="body")
h3 Please wait..
p Simulating GCode to check for errors, calculate ETA and generate 3D view.
div(slot="footer")
label Simulating {{(toolpath_progress || 0) | percent}}
message(:show.sync="showNoGcodeMessage")
h3(slot="header") GCode Not Set
div(slot="body")
p Configure the GCode for the selected macro to use it
div(slot="footer")
button.pure-button(@click="showNoGcodeMessage=false") OK
h3(slot="header") GCode Not Set
div(slot="body")
p Configure the GCode for the selected macro to use it
div(slot="footer")
button.pure-button(@click="showNoGcodeMessage=false") OK
message(:show.sync="macrosLoading")
h3(slot="header") Run Macro?
div(slot="body")
p
| The macro file
strong {{state.selected}}
| is being loaded.
div(slot="footer")
button.pure-button(@click="macrosLoading=false") Cancel
button.pure-button.pure-button-primary(@click="start_pause") Run
h3(slot="header") Run Macro?
div(slot="body")
p
| The macro file
strong {{state.selected}}
| is being loaded.
div(slot="footer")
button.pure-button(@click="macrosLoading=false") Cancel
button.pure-button.pure-button-primary(@click="start_pause") Run
message(:show.sync="GCodeNotFound")
h3(slot="header") File not found
div(slot="body")
p It seems like the file you selected cannot be found. Try uploading again.
p It seems like the file you selected cannot be found. Try uploading again.
div(slot="footer")
button.pure-button.button-error(@click="GCodeNotFound=false")
| OK
button.pure-button.button-error(@click="GCodeNotFound=false") OK
message(:show.sync="show_probe_dialog")
h3(slot="header") Probe Rotary
div(slot="body")
@@ -46,430 +42,240 @@ script#control-view-template(type="text/x-template")
div(slot="footer")
button.pure-button(@click="show_probe_dialog=false") Cancel
// ----- Main grid: jog | (DRO + status strip) -----
.control-grid
table(style="table-layout: fixed; width: 100%;")
tr(style="height: fit-content;")
td(style="white-space: nowrap; width: 410px;", rowspan="2")
table.control-buttons(table-layout="fixed")
colgroup
col(style="width:100px")
col(style="width:100px")
col(style="width:100px")
col(style="width:100px")
tr
td(style="height:100px",align="center")
button(@click="jog_fn(-1,1,0,0)")
.fa.fa-arrow-right(style="transform: rotate(-135deg);")
td(style="height:100px",align="center")
button(@click="jog_fn(0,1,0,0)") Y+
td(style="height:100px",align="center")
button(@click="jog_fn(1,1,0,0)")
.fa.fa-arrow-right(style="transform: rotate(-45deg);")
td(style="height:100px",align="center")
button(,@click="jog_fn(0,0,1,0)") Z+
tr
td(style="height:100px",align="center")
button(@click="jog_fn(-1,0,0,0)") X-
td(style="height:100px",align="center")
button(@click="showMoveToZeroDialog('xy')")
| XY
br
| Origin
td(style="height:100px",align="center")
button(@click="jog_fn(1,0,0,0)") X+
td(style="height:100px",align="center")
button(@click="showMoveToZeroDialog('z')")
| Z
br
| Origin
tr
td(style="height:100px",align="center")
button(@click="jog_fn(-1,-1,0,0)")
.fa.fa-arrow-right(style="transform: rotate(135deg);")
td(style="height:100px",align="center")
button(@click="jog_fn(0,-1,0,0)") Y-
td(style="height:100px",align="center")
button(@click="jog_fn(1,-1,0,0)")
.fa.fa-arrow-right(style="transform: rotate(45deg);")
td(style="height:100px",align="center")
button(@click="jog_fn(0,0,-1,0)") Z-
tr
td(style="height:100px",align="center")
button(:style="getJogIncrStyle('fine')", @click="jog_incr = 'fine'")
span {{jog_incr_amounts[display_units].fine}}#[span.jog-units {{metric ? 'mm' : 'in'}}]
td(style="height:100px",align="center")
button(:style="getJogIncrStyle('small')", @click="jog_incr = 'small'")
span {{jog_incr_amounts[display_units].small}}#[span.jog-units {{metric ? 'mm' : 'in'}}]
td(style="height:100px",align="center")
button(:style="getJogIncrStyle('medium')", @click="jog_incr = 'medium'")
span {{jog_incr_amounts[display_units].medium}}#[span.jog-units {{metric ? 'mm' : 'in'}}]
td(style="height:100px",align="center")
button(:style="getJogIncrStyle('large')", @click="jog_incr = 'large'")
span {{jog_incr_amounts[display_units].large}}#[span.jog-units {{metric ? 'mm' : 'in'}}]
// ===== JOG =====
.jog-card
.jog-head
.jog-title
| Jog
span.step-pre · step
span.step {{jog_incr_amounts[display_units][jog_incr]}}#[span.unit {{metric ? 'mm' : 'in'}}]
.step-seg
button(:class="{active: jog_incr === 'fine'}", @click="jog_incr = 'fine'")
| {{jog_incr_amounts[display_units].fine}}
button(:class="{active: jog_incr === 'small'}", @click="jog_incr = 'small'")
| {{jog_incr_amounts[display_units].small}}
button(:class="{active: jog_incr === 'medium'}", @click="jog_incr = 'medium'")
| {{jog_incr_amounts[display_units].medium}}
button(:class="{active: jog_incr === 'large'}", @click="jog_incr = 'large'")
| {{jog_incr_amounts[display_units].large}}
tr(v-if="state['2an'] == 3")
td(style="height:100px", align="center", colspan="1")
button(@click="show_probe_dialog=true")
| Probe
br
| Rotary
.jog-grid
// Row 1
button.jbtn.dir(@click="jog_fn(-1, 1, 0, 0)", title="X- Y+")
.fa.fa-arrow-up.ico(style="transform: rotate(-45deg)")
button.jbtn(@click="jog_fn(0, 1, 0, 0)") Y+
button.jbtn.dir(@click="jog_fn(1, 1, 0, 0)", title="X+ Y+")
.fa.fa-arrow-up.ico(style="transform: rotate(45deg)")
button.jbtn(@click="jog_fn(0, 0, 1, 0)") Z+
td(style="height:100px", align="center", colspan="1")
button(@click="jog_fn(0,0,0,-1)", style="display: grid;justify-content: center;align-items: center;padding: 14px;")
| A-
.fa.fa-rotate-left
td(style="height:100px", align="center", colspan="1")
button(@click="showMoveToZeroDialog('a')")
| A
br
| Origin
// Row 2
button.jbtn(@click="jog_fn(-1, 0, 0, 0)") X
button.jbtn.ghost(@click="showMoveToZeroDialog('xy')")
span.lbl XY
span Origin
button.jbtn(@click="jog_fn(1, 0, 0, 0)") X+
button.jbtn.ghost(@click="showMoveToZeroDialog('z')")
span.lbl Z
span Origin
td(style="height:100px", align="center", colspan="1")
button(@click="jog_fn(0,0,0,1)", style="display: grid;justify-content: center;align-items: center;padding: 14px;")
| A+
.fa.fa-rotate-right
// Row 3
button.jbtn.dir(@click="jog_fn(-1, -1, 0, 0)", title="X- Y-")
.fa.fa-arrow-down.ico(style="transform: rotate(45deg)")
button.jbtn(@click="jog_fn(0, -1, 0, 0)") Y
button.jbtn.dir(@click="jog_fn(1, -1, 0, 0)", title="X+ Y-")
.fa.fa-arrow-down.ico(style="transform: rotate(-45deg)")
button.jbtn(@click="jog_fn(0, 0, -1, 0)") Z
tr(v-else)
td(style="height:100px", align="center", colspan="2")
button(:class="state['pw'] ? '' : 'load-on'",
style="height:100px;width:200px",
@click="showProbeDialog('xyz')")
| Probe XYZ
// Row 4 — W axis (auxcnc) when enabled
template(v-if="w.enabled")
button.jbtn(@click="aux_jog_incr(-1)", :disabled="!w.enabled")
.fa.fa-arrow-down.ico
span.lbl W
button.jbtn.ghost(@click="aux_home()", :disabled="!w.enabled")
span.lbl Home
span W
button.jbtn(@click="aux_jog_incr(+1)", :disabled="!w.enabled")
.fa.fa-arrow-up.ico
span.lbl W+
button.jbtn(@click="show_probe_dialog=true",
:class="{'load-on': !state['pw']}")
.fa.fa-bullseye.ico
span.lbl Probe
td(style="height:100px", align="center", colspan="2")
button(:class="state['pw'] ? '' : 'load-on'",
style="height:100px;width:200px",
@click="showProbeDialog('z')")
| Probe Z
// Row 4 — A axis (rotary) when no W and rotary is enabled
// (Vue 1 has no v-else-if; we negate w.enabled explicitly.)
template(v-if="!w.enabled && state['2an'] == 3")
button.jbtn.dir(@click="jog_fn(0, 0, 0, -1)")
.fa.fa-rotate-left.ico
span.lbl A
button.jbtn.ghost(@click="showMoveToZeroDialog('a')")
span.lbl A
span Origin
button.jbtn.dir(@click="jog_fn(0, 0, 0, 1)")
.fa.fa-rotate-right.ico
span.lbl A+
button.jbtn(@click="show_probe_dialog=true",
:class="{'load-on': !state['pw']}")
.fa.fa-bullseye.ico
span.lbl Probe
td(style="vertical-align: top;")
table.axes
tr(:class="axes.klass")
th.name Axis
th.position Position
th.absolute Absolute
th.offset Offset
th.state State
th.tstate Toolpath
th.actions
button.pure-button(disabled, style="height:60px;width:60px;display:none;")
// Row 4 — fallback probe / zero / home shortcuts
template(v-if="!w.enabled && state['2an'] != 3")
button.jbtn(@click="showProbeDialog('xyz')",
:class="{'load-on': !state['pw']}")
.fa.fa-bullseye.ico
span.lbl Probe XYZ
button.jbtn.ghost(@click="zero()", :disabled="!can_set_axis")
.fa.fa-map-marker.ico
span.lbl Zero all
button.jbtn(@click="showProbeDialog('z')",
:class="{'load-on': !state['pw']}")
.fa.fa-bullseye.ico
span.lbl Probe Z
button.jbtn.ghost(@click="home()", :disabled="!is_idle")
.fa.fa-home.ico
span.lbl Home all
button.pure-button(:disabled="!can_set_axis",
title="Zero all axis offsets.", @click="zero()",style="height:60px;width:60px")
.fa.fa-map-marker
// ===== DRO + status strip =====
.right-col
button.pure-button(title="Home all axes.", @click="home()",
:disabled="!is_idle",style="height:60px;width:60px")
.fa.fa-home
each axis in 'xyzabc'
tr.axis(:class=`${axis}.klass`, v-if=`${axis}.enabled`,
:title=`${axis}.title`)
th.name= axis
td.position: unit-value(:value=`${axis}.pos`, precision=4)
td.absolute: unit-value(:value=`${axis}.abs`, precision=3)
td.offset: unit-value(:value=`${axis}.off`, precision=3)
td.state
.dro-card
.dro-head
div Axis
div Position
div Absolute
div Offset
div State
div Toolpath
div(style="text-align:right") Actions
// Per-axis rows — keep unit-value + bindings from axis-vars
each axis in 'xyzabc'
.dro-row(:class=`${axis}.klass + ' ' + ${axis}.tklass`,
v-if=`${axis}.enabled`,
:title=`${axis}.title`)
.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`)
| {{#{axis}.state}}
td.tstate(:class=`${axis}.tklass`, :title=`${axis}.toolmsg`, @click=`showToolpathMessageDialog('${axis}')`)
| &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`)
| {{#{axis}.tstate}}
| &nbsp;{{#{axis}.tstate}}
.actions-cell
button.icon-btn(:disabled="!can_set_axis",
:title=`'Set ${axis.toUpperCase()} axis position.'`,
@click=`show_set_position('${axis}')`)
.fa.fa-cog
button.icon-btn(:disabled="!can_set_axis",
:title=`'Zero ${axis.toUpperCase()} axis offset.'`,
@click=`zero('${axis}')`)
.fa.fa-map-marker
button.icon-btn(:disabled="!is_idle",
:title=`'Home ${axis.toUpperCase()} axis.'`,
@click=`home('${axis}')`)
.fa.fa-home
th.actions
button.pure-button(:disabled="!can_set_axis",
title=`Set {{'${axis}' | upper}} axis position.`,
@click=`show_set_position('${axis}')`, style="height:60px;width:60px")
.fa.fa-cog
// W axis (auxiliary) — no offset, no set-zero / no set-position
.dro-row(:class="w.klass + ' ' + w.tklass", v-if="w.enabled",
:title="w.title")
.dro-axis.axis-w W
.dro-pos: unit-value(:value="w.pos", precision=4)
.dro-sec: unit-value(:value="w.abs", precision=3)
.dro-sec —
.dro-state
span.chip(:class="w.homed ? 'chip-green' : 'chip-amber'")
.fa(:class="'fa-' + w.icon")
| &nbsp;{{w.state}}
.dro-toolpath
span.chip.chip-green
.fa(:class="'fa-' + w.ticon")
| &nbsp;{{w.tstate}}
.actions-cell
button.icon-btn(disabled, style="visibility:hidden")
.fa.fa-cog
button.icon-btn(disabled, style="visibility:hidden")
.fa.fa-map-marker
button.icon-btn(:disabled="!w.enabled",
title="Home W axis.", @click="aux_home()")
.fa.fa-home
button.pure-button(:disabled="!can_set_axis",
title=`Zero {{'${axis}' | upper}} axis offset.`,
@click=`zero('${axis}')`, style="height:60px;width:60px")
.fa.fa-map-marker
// ----- Status strip -----
.status-strip
.stat-card
.stat-label State
.stat-val(:class="state_kpi_class") {{mach_state || '--'}}
.stat-sub(v-if="message") {{message.replace(/^#/, '')}}
.stat-sub(v-else) No alerts
button.pure-button(:disabled="!is_idle", @click=`home('${axis}')`,
title=`Home {{'${axis}' | upper}} axis.`, style="height:60px;width:60px")
.fa.fa-home
tr(style="vertical-align: top;")
td
table(width="100%")
tr
td(style="text-align:center")
table.info
tr
th State
td(:class="{attention: highlight_state}") {{mach_state}}
.stat-card
.stat-label Velocity / Feed
.stat-val
unit-value(:value="state.v", precision="2", unit="", iunit="",
scale="0.0254")
| ·&nbsp;
unit-value(:value="state.feed", precision="0", unit="", iunit="")
.stat-sub {{metric ? 'm/min · mm/min' : 'IPM · IPM'}}
tr
th Message
td.message(:class="{attention: highlight_state}")
| {{message.replace(/^#/, '')}}
.stat-card.stat-tappable(@click="overrides_open = !overrides_open",
:class="{open: overrides_open}", title="Tap to adjust feed/spindle override")
.stat-label Spindle
.stat-val
| {{(state.speed || 0) | fixed 0}}
span(v-if="state.s != null && !isNaN(state.s)") ({{state.s | fixed 0}})
.stat-sub
| RPM (commanded / actual)
.fa.fa-sliders.tap-hint(title="Open override drawer")
tr
th Display Units
td.units
select(v-model="display_units")
option(value="METRIC") METRIC
option(value="IMPERIAL") IMPERIAL
.stat-card
.stat-label Job
.stat-val
| {{0 <= state.line ? state.line : 0 | number}}
span(v-if="toolpath.lines")
| / {{toolpath.lines | number}}
.stat-sub(v-if="plan_time_remaining || toolpath.time")
| Line · {{plan_time_remaining ? (plan_time_remaining | time) : (toolpath.time | time)}} remaining
.stat-sub(v-else) Line · ETA --
tr(title="Active tool")
th Tool
td {{state.tool || 0}}
// ----- Macro row (slice 0..7); full list lives in Settings → Macros -----
// The colored left stripe (.has-color) is suppressed for white,
// near-white and other default placeholder colors so unconfigured
// macros render as clean slate tiles instead of looking lopsided.
.macro-row(v-if="state.macros && state.macros.length")
button.macro-btn(v-for="(index, macros) in state.macros.slice(0, 8)",
title="Click to run macro",
@click="run_macro(index)",
:disabled="!is_ready",
:class="{'has-color': has_macro_color(macros)}",
:style="has_macro_color(macros) ? {borderLeftColor: macros.color} : {}")
span.mnum {{index + 1}}
span.mname {{macros.name || ('Macro ' + (index + 1))}}
td
table.info
tr(
title="Current velocity in {{metric ? 'meters' : 'inches'}} per minute")
th Velocity
td
unit-value(:value="state.v", precision="2", unit="", iunit="",
scale="0.0254")
| {{metric ? ' m/min' : ' IPM'}}
tr(title="Programmed feed rate.")
th Feed
td
unit-value(:value="state.feed", precision="2", unit="", iunit="")
| {{metric ? ' mm/min' : ' IPM'}}
tr(title="Programed and actual speed.")
th Speed
td
| {{state.speed || 0 | fixed 0}}
span(v-if="!isNaN(state.s)") &nbsp;({{state.s | fixed 0}})
= ' RPM'
tr(title="Load switch states.")
th Loads
td
span(:class="state['1oa'] ? 'load-on' : ''")
| 1:{{state['1oa'] ? 'On' : 'Off'}}
| &nbsp;
span(:class="state['2oa'] ? 'load-on' : ''")
| 2:{{state['2oa'] ? 'On' : 'Off'}}
td
table.info
tr
th Remaining
td(title="Total run time (days:hours:mins:secs)").
#[span(v-if="plan_time_remaining") {{plan_time_remaining | time}} of]
{{toolpath.time | time}}
tr
th ETA
td.eta {{eta}}
tr
th Line
td
| {{0 <= state.line ? state.line : 0 | number}}
span(v-if="toolpath.lines")
| &nbsp;of {{toolpath.lines | number}}
tr
th Progress
td.progress
label {{(progress || 0) | percent}}
.bar(:style="'width:' + (progress || 0) * 100 + '%'")
.macros-div(class="present")
button.macros-button(title="Click to run Macros",v-for="(index,macros) in state.macros",
@click="run_macro(index)",:disabled="!is_ready",v-bind:style="{ backgroundColor: macros.color }") {{macros.name}}
.tabs
input#tab1(type="radio", name="tabs",checked="" @click="tab = 'auto'")
label(for="tab1", title="Run GCode programs",style="height:50px;width:100px") Auto
input#tab2(type="radio", name="tabs", @click="tab = 'mdi'")
label(for="tab2", title="Manual GCode entry",style="height:50px;width:100px") MDI
input#tab3(type="radio", name="tabs", @click="tab = 'messages'")
label(for="tab3",style="height:50px;width:100px") Messages
input#tab4(type="radio", name="tabs", @click="tab = 'indicators'")
label(for="tab4",style="height:50px;width:100px") Indicators
section#content1.tab-content.pure-form
.toolbar.pure-control-group
button.pure-button(:class="{'attention': is_holding}",
title="{{is_running ? 'Pause' : 'Start'}} program.",
@click="start_pause", :disabled="!state.selected",
style="height:100px;width:100px;font-weight:normal")
img(v-if="is_running" src="images/pause_gcode.png" style="height: 55px;")
img(v-else src="images/play_gcode.png" style="height: 55px;")
button.pure-button(title="Stop program.", @click="stop", style="height:100px;width:100px;font-weight:normal")
img(src="images/stop.png" style="height: 55px;")
button.pure-button(title="Pause program at next optional stop (M1).",
@click="optional_pause", v-if="false", style="height:100px;width:100px;font-weight:normal")
.fa.fa-stop-circle-o
message(:show.sync="uploading_files")
h3(slot="header") Files uploading
div(slot="body")
h3 Please wait...
p
p The files are currently being uploaded.
p Do not close the window.
div(slot="footer")
button.pure-button(title="Execute one program step.", @click="step",
:disabled="(!is_ready && !is_holding) || !state.selected",
v-if="false", style="height:100px;width:100px;font-weight:normal")
.fa.fa-step-forward
button.pure-button(title="Upload a new GCode folder.", @click="open_folder",
:disabled="!is_ready",style="height:100px;width:100px;font-weight:normal")
img(src="images/upload_folder.png" style="height: 65px;")
form.gcode-folder-input.file-upload
input#folderInput(type="file", @change="upload_folder", :disabled="!is_ready",
webkitdirectory, directory)
button.pure-button(title="Upload a new GCode program.", @click="open_file",
:disabled="!is_ready",style="height:100px;width:100px;font-weight:normal")
img(src="images/upload_gcode.png" style="height: 65px;")
form.gcode-file-input.file-upload
input(type="file", @change="upload_file", :disabled="!is_ready",
accept=".nc,.ngc,.gcode,.gc", multiple)
a(:disabled="!state.selected", download,
:href="'/api/file/' + state.selected",
title="Download the selected GCode program.")
button.pure-button(:disabled="!state.selected", style="height:100px;width:100px")
img(src="images/download_gcode.png" style="height: 65px;")
button.pure-button(title="Delete current GCode program.",
@click="deleteGCode = true",
:disabled="!state.selected || !is_ready",style="height:100px;width:100px;font-weight:normal")
img(src="images/delete_gcode.png" style="height: 55px;")
message.error-message(:show.sync="deleteGCode")
h3(slot="header") Select files to delete:
div(slot="body")
input.search-bar(type="text", v-model="search_query", placeholder="Search Files...")
.container
.folders
h3 Folders
div(v-for="(index, folder) in state.gcode_list", :key="index", @click="populateFiles(index)",
class="folder-item", :class="{ selected: index === selected_folder_index }") {{ folder.name }}
.files
h3 Files
label.file-item(v-for="item in gcode_filtered_files" :key="item")
input(type="checkbox" :value="item" v-model="selected_items_to_delete")
| {{ item }}
div(slot="footer")
button.pure-button(@click="cancel_delete",style="height:50px") Cancel
//- button.pure-button.button-error(@click="delete_all_except_macros")
//- .fa.fa-trash
//- | &nbsp;All
button.pure-button.button-success(@click="delete_current",style="height:50px")
.fa.fa-trash
| &nbsp;Selected
.drop-down-container
message(:show.sync="create_folder")
h3(slot="header") Enter folder name:
div(slot="body")
input.input-name(type="text",minlength='1',maxlength='15',style ="margin-top:1rem;margin-bottom:2rem;",
id="folder-name" ,v-model="folder_name",@keypress="edited_folder_name")
div(slot="footer")
button.pure-button(@click="cancel_new_folder") Cancel
button.pure-button.button-success(@click="create_new_folder",:disabled="!edited")
| Create
message(:show.sync="confirmDelete")
h3(slot="header") Delete Folder?
div(slot="body")
p Are you sure to delete the folder?
div(slot="footer")
button.pure-button(@click="confirmDelete=false") Cancel
button.pure-button.button-error(@click="delete_folder") Folder only
button.pure-button.button-success(@click="delete_folder_and_files") Folder and files
button.pure-button(title="Create a new folder.", @click="create_folder=true",
:disabled="!is_ready",style="height:100%")
| Create Folder
button.pure-button(title="Delete a folder.", @click="confirmDelete=true",
:disabled="!is_ready",style="height:100%;margin-left:5px")
| Delete Folder
select(title="Select previously uploaded GCode folder.",
v-model="state.folder", @change="reset_gcode", :disabled="!is_ready",
style="max-width:100%;margin-left:5px")
option( selected='' value='default') Default folder
option(v-for="file in gcode_folders", :value="file") {{file}}
select(title="Select previously uploaded GCode programs.",
v-model="state.selected", @change="load", :disabled="!is_ready",
style="max-width:300px;margin-left:5px")
option(v-for="file in gcode_files", :value="file") {{file}}
button.pure-button(@click="toggle_sorting", :disabled="!is_ready",
style="height:75%")
| {{files_sortby}}
.progress(v-if="toolpath_progress && toolpath_progress < 1",
title="Simulating GCode to check for errors, calculate ETA and " +
"generate 3D view. You can run GCode before the simulation " +
"finishes.")
div(:style="'width:' + (toolpath_progress || 0) * 100 + '%'")
label Simulating {{(toolpath_progress || 0) | percent}}
path-viewer(:toolpath="toolpath", :state="state", :config="config")
gcode-viewer
section#content2.tab-content
.mdi.pure-form(title="Manual GCode entry.")
button.pure-button(:disabled="!can_mdi",
:class="{'attention': is_holding}",
title="{{is_running ? 'Pause' : 'Start'}} command.",
@click="mdi_start_pause",style="height:100px;width:100px")
.fa(:class="is_running ? 'fa-pause' : 'fa-play'")
button.pure-button(title="Stop command.", @click="stop",style="height:100px;width:100px")
.fa.fa-stop
input(v-model="mdi", :disabled="!can_mdi", @keyup.enter="submit_mdi")
div
em The machine is currently operating in #[strong {{mach_units}}] units. Use G20/G21 to switch units.
.history(:class="{placeholder: !history}")
span(v-if="!history.length") MDI history displays here.
ul
li(v-for="item in history", @click="load_history($index)",
track-by="$index")
| {{item}}
section#content3.tab-content
console
section#content4.tab-content
indicators(:state="state", :template="template")
.override(title="Feed rate override.")
label Feed
input(type="range", min="0", max="2", step="0.01",
v-model="feed_override", @change="override_feed")
span.percent {{feed_override | percent 0}}
.override(title="Spindle speed override.")
label Speed
input(type="range", min="0", max="2", step="0.01",
v-model="speed_override", @change="override_speed")
span.percent {{speed_override | percent 0}}
// ----- Override drawer (anchored to bottom; toggled by Spindle KPI tile) -----
.override-drawer(:class="{open: overrides_open}")
.od-head
.od-title
.fa.fa-sliders
| &nbsp;Overrides
button.od-close(@click="overrides_open = false") ✕
.od-body
.od-row
label Feed
input(type="range", min="0", max="2", step="0.01",
v-model="feed_override", @change="override_feed")
.od-val {{feed_override | percent 0}}
button.od-reset(@click="feed_override = 1; override_feed()") Reset 100%
.od-row
label Spindle
input(type="range", min="0", max="2", step="0.01",
v-model="speed_override", @change="override_speed")
.od-val {{speed_override | percent 0}}
button.od-reset(@click="speed_override = 1; override_speed()") Reset 100%

View File

@@ -2,6 +2,8 @@ script#estop-template(type="text/x-template")
svg(version="1.1", xmlns:svg="http://www.w3.org/2000/svg",
xmlns="http://www.w3.org/2000/svg",
xmlns:xlink="http://www.w3.org/1999/xlink",
viewBox="0 0 130 130",
preserveAspectRatio="xMidYMid meet",
width="130", height="130")
defs
path#text-path-1(d="m 73.735,673.129 c 0,55.107 44.673,99.780 99.780,99.780 55.107,0 99.780,-44.673 99.780,-99.780 0,-55.107 -44.673,-99.780 -99.780,-99.780 -55.107,0 -99.780,44.673 -99.780,99.780 z")

View File

@@ -0,0 +1,137 @@
script#program-view-template(type="text/x-template")
.program-page
// ----- Modal dialogs -----
message(:show.sync="showGcodeMessage")
h3(slot="header") Processing New File
div(slot="body")
h3 Please wait..
p Simulating GCode to check for errors, calculate ETA and generate 3D view.
div(slot="footer")
label Simulating {{(toolpath_progress || 0) | percent}}
message(:show.sync="GCodeNotFound")
h3(slot="header") File not found
div(slot="body")
p It seems like the file you selected cannot be found. Try uploading again.
div(slot="footer")
button.pure-button.button-error(@click="GCodeNotFound=false") OK
message(:show.sync="uploading_files")
h3(slot="header") Files uploading
div(slot="body")
h3 Please wait...
p
p The files are currently being uploaded.
p Do not close the window.
div(slot="footer")
message.error-message(:show.sync="deleteGCode")
h3(slot="header") Select files to delete:
div(slot="body")
input.search-bar(type="text", v-model="search_query", placeholder="Search Files...")
.container
.folders
h3 Folders
div(v-for="(index, folder) in state.gcode_list", :key="index",
@click="populateFiles(index)",
class="folder-item",
:class="{ selected: index === selected_folder_index }") {{ folder.name }}
.files
h3 Files
label.file-item(v-for="item in gcode_filtered_files", :key="item")
input(type="checkbox", :value="item", v-model="selected_items_to_delete")
| {{ item }}
div(slot="footer")
button.pure-button(@click="cancel_delete", style="height:50px") Cancel
button.pure-button.button-success(@click="delete_current", style="height:50px")
.fa.fa-trash
| &nbsp;Selected
message(:show.sync="create_folder")
h3(slot="header") Enter folder name:
div(slot="body")
input.input-name(type="text", minlength="1", maxlength="15",
style="margin-top:1rem;margin-bottom:2rem;",
id="folder-name", v-model="folder_name", @keypress="edited_folder_name")
div(slot="footer")
button.pure-button(@click="cancel_new_folder") Cancel
button.pure-button.button-success(@click="create_new_folder", :disabled="!edited") Create
message(:show.sync="confirmDelete")
h3(slot="header") Delete Folder?
div(slot="body")
p Are you sure to delete the folder?
div(slot="footer")
button.pure-button(@click="confirmDelete=false") Cancel
button.pure-button.button-error(@click="delete_folder") Folder only
button.pure-button.button-success(@click="delete_folder_and_files") Folder and files
.program-card
// Action bar (RUN / STOP / Upload / Download / Delete)
.action-bar
button.action-btn.run(:class="{'attention': is_holding}",
@click="start_pause", :disabled="!state.selected",
:title="is_running ? 'Pause program.' : 'Start program.'")
.fa.fa-play.ico(v-if="!is_running")
.fa.fa-pause.ico(v-else)
span {{is_running ? 'PAUSE' : 'RUN'}}
button.action-btn.stop(@click="stop", title="Stop program.")
.fa.fa-stop.ico
span STOP
button.action-btn(@click="open_folder", :disabled="!is_ready",
title="Upload a new GCode folder.")
.fa.fa-folder-arrow-up.ico
span UPLOAD FOLDER
form.gcode-folder-input.file-upload
input#folderInput(type="file", @change="upload_folder",
:disabled="!is_ready", webkitdirectory, directory)
button.action-btn(@click="open_file", :disabled="!is_ready",
title="Upload a new GCode program.")
.fa.fa-file-arrow-up.ico
span UPLOAD FILE
form.gcode-file-input.file-upload
input(type="file", @change="upload_file", :disabled="!is_ready",
accept=".nc,.ngc,.gcode,.gc", multiple)
a(:href="state.selected ? '/api/file/' + state.selected : '#'",
download, :class="{disabled: !state.selected}",
title="Download the selected GCode program.")
button.action-btn(:disabled="!state.selected")
.fa.fa-file-arrow-down.ico
span DOWNLOAD FILE
button.action-btn.danger(@click="deleteGCode = true",
:disabled="!state.selected || !is_ready",
title="Delete current GCode program.")
.fa.fa-trash.ico
span DELETE
// File / folder selectors
.file-bar
button.file-btn(@click="create_folder=true", :disabled="!is_ready")
.fa.fa-folder-plus
| &nbsp;Create Folder
button.file-btn(@click="confirmDelete=true", :disabled="!is_ready")
.fa.fa-folder-minus
| &nbsp;Delete Folder
select.file-select(title="Select previously uploaded GCode folder.",
v-model="state.folder", @change="reset_gcode", :disabled="!is_ready")
option(selected, value="default") Default folder
option(v-for="file in gcode_folders", :value="file") {{file}}
select.file-select.primary(title="Select previously uploaded GCode programs.",
v-model="state.selected", @change="load", :disabled="!is_ready")
option(value="") (no file)
option(v-for="file in gcode_files", :value="file") {{file}}
button.file-btn(@click="toggle_sorting", :disabled="!is_ready")
.fa.fa-arrow-down-wide-short
| &nbsp;{{files_sortby}}
// Body: gcode listing on the left, 3D viewer on the right
.program-body
gcode-viewer
path-viewer(:toolpath="toolpath", :state="state", :config="config")
.progress-bar(v-if="toolpath_progress && toolpath_progress < 1",
title="Simulating GCode to check for errors, calculate ETA and generate 3D view.")
div(:style="'width:' + (toolpath_progress || 0) * 100 + '%'")
label Simulating {{(toolpath_progress || 0) | percent}}

View File

@@ -0,0 +1,44 @@
script#settings-shell-view-template(type="text/x-template")
.settings-shell
aside.settings-rail
// Use a single v-for over a data-driven items array so every
// rail item shares the same compiled :class binding template.
// This sidesteps a Vue 1 quirk where sibling-with-different-
// expression :class bindings sometimes fail to re-evaluate on
// hash navigation, leaving stale `.active` classes.
template(v-for="item in rail_items")
.set-section(v-if="item.section") {{item.section}}
a.set-item(v-if="!item.section", :class="{active: is_active(item)}",
:href="item.href", @click="on_rail_click(item, $event)")
.fa(:class="item.icon")
| &nbsp;{{item.label}}
.set-rail-foot
button.sp-shutdown(@click="showShutdownDialog")
.fa.fa-power-off
| &nbsp;Shutdown
button.sp-save(:disabled="!$root.modified", @click="$root.save()")
.fa.fa-save
| &nbsp;Save{{$root.modified ? '*' : ''}}
.settings-content
// Explicit v-if cascade so the inner template swaps reactively
// when sub changes (Vue 1's `<component :is>` does not always
// re-evaluate dynamic strings inside a kept-alive parent).
settings-view-inner(v-if="sub === 'settings'",
:index="index", :config="config", :template="template", :state="state")
admin-general-view(v-if="sub === 'admin-general'",
:index="index", :config="config", :template="template", :state="state")
admin-network-view(v-if="sub === 'admin-network'",
:index="index", :config="config", :template="template", :state="state")
motor-view(v-if="sub === 'motor'",
:index="index", :config="config", :template="template", :state="state")
tool-view(v-if="sub === 'tool'",
:index="index", :config="config", :template="template", :state="state")
io-view(v-if="sub === 'io'",
:index="index", :config="config", :template="template", :state="state")
macros-view(v-if="sub === 'macros'",
:index="index", :config="config", :template="template", :state="state")
help-view(v-if="sub === 'help'",
:index="index", :config="config", :template="template", :state="state")
cheat-sheet-view(v-if="sub === 'cheat-sheet'",
:index="index", :config="config", :template="template", :state="state")

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

@@ -0,0 +1,477 @@
################################################################################
#
# AuxAxis - W-axis serial driver for the auxcnc ESP32 controller
#
# Owns /dev/ttyUSB0 (or whatever serial.port is configured to). Provides
# blocking RPCs for use from a hook thread. Maintains:
#
# - aux_present : True if serial is open and we've seen a boot banner
# - aux_homed : True if we've successfully run HOME since last reset
# - aux_pos : current logical position in mm (from ESP step counter
# * (1 / steps_per_mm * dir_sign))
#
# Real-time decisions (limit switch monitoring, step pulse generation) live
# on the ESP. The host is responsible for units, soft limits, and tracking
# whether we've ever boot-cycled the ESP since last home.
#
################################################################################
import os
import json
import time
import threading
import traceback
try:
import serial
except ImportError:
serial = None
# Default config; overridden by ./aux.json or ctrl.config.
DEFAULTS = {
'enabled': False,
'port': '/dev/ttyUSB0',
'baud': 115200,
'steps_per_mm': 80.0, # logical steps per mm of W travel
'dir_sign': 1, # +1 or -1: maps logical+ to motor+ steps
'min_w': 0.0, # soft limit min (mm)
'max_w': 100.0, # soft limit max (mm)
'max_feed_mm_min': 600.0, # informational; rate caps are on the ESP
'home_dir': '-', # which direction is "toward limit" (host's view)
'home_position_mm': 0.0, # mm value to assign at home
# ESP-side homing rates (steps/sec). Pushed via HOMECFG on connect.
'home_fast_sps': 4000,
'home_slow_sps': 400,
'home_backoff_steps': 200,
'home_maxtravel_steps': 200000,
'step_max_sps': 4000,
'step_accel_sps2': 16000,
'step_start_sps': 200,
'limit_low': True,
}
class AuxAxisError(Exception):
pass
class AuxAxis(object):
def __init__(self, ctrl):
self.ctrl = ctrl
self.log = ctrl.log.get('AuxAxis')
self._cfg = dict(DEFAULTS)
self._load_config()
self._sp = None
self._sp_lock = threading.Lock() # serial write/RPC serialization
self._rx_lock = threading.Lock() # read-line buffer access
self._reader_thread = None
self._stop = threading.Event()
# Pending replies waiting for a [topic] line. Single-slot since we
# serialize RPCs via _sp_lock.
self._pending_topics = []
self._pending_replies = []
self._pending_cv = threading.Condition()
# Async lines that aren't replies (e.g. logs) are simply logged.
self._present = False
self._homed = False
self._pos_steps = 0 # ESP step counter mirror
# Publish initial state
self._publish_state()
if not self._cfg['enabled']:
self.log.info('Aux axis disabled in config')
return
if serial is None:
self.log.error('pyserial not available; aux axis disabled')
return
self._open()
# ------------------------------------------------------------------ config
def _config_path(self):
return self.ctrl.get_path(filename='aux.json')
def _load_config(self):
path = self._config_path()
if os.path.exists(path):
try:
with open(path) as f:
user = json.load(f)
# 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)
except Exception:
self.log.error('Failed to read aux.json: %s'
% traceback.format_exc())
def save_config(self, cfg):
merged = dict(DEFAULTS)
for k, v in cfg.items():
if k in DEFAULTS:
merged[k] = v
path = self._config_path()
with open(path, 'w') as f:
json.dump(merged, f, indent=2)
self._cfg = merged
self.log.info('Saved aux config')
# Push the relevant pieces to the ESP if connected.
if self._present:
try:
self._push_homecfg()
except Exception as e:
self.log.warning('Could not push HOMECFG after save: %s' % e)
def get_config(self):
return dict(self._cfg)
# ------------------------------------------------------------------ public
@property
def enabled(self):
return bool(self._cfg.get('enabled', False))
@property
def present(self):
return self._present
@property
def homed(self):
return self._homed
@property
def position_mm(self):
return self._steps_to_mm(self._pos_steps)
def home(self):
"""Run the homing cycle on the ESP. Blocks until done. Raises on
failure. Updates aux_homed and aux_pos."""
self._require_present()
line = self._rpc('HOME', topic='home', timeout=120.0)
# line is the body after '[home] '
if line.startswith('done'):
# ESP set its counter to home_zero; mirror that.
new_pos = self._parse_kv_int(line, 'pos', 0)
self._pos_steps = new_pos
self._homed = True
# Translate to home_position_mm. Conceptually the host says
# "after homing, W is here in mm". We achieve that by setting
# the ESP counter (WPOS) so the mm conversion works out.
target_pos = self._mm_to_steps(self._cfg['home_position_mm'])
if target_pos != new_pos:
self._rpc('WPOS %d' % target_pos, topic='ok', timeout=2.0)
self._pos_steps = target_pos
self._publish_state()
return
# failure
reason = line.split('reason=', 1)[1] if 'reason=' in line else line
raise AuxAxisError('Homing failed: %s' % reason)
def move_abs_mm(self, target_mm):
"""Move to absolute logical W position (mm). Blocks until done."""
self._require_present()
self._check_limits(target_mm)
target_steps = self._mm_to_steps(target_mm)
delta = target_steps - self._pos_steps
if delta == 0:
return
self._do_steps(delta)
def move_rel_mm(self, delta_mm):
"""Move by delta mm relative to current position. Blocks until done."""
self._require_present()
target_mm = self.position_mm + delta_mm
self._check_limits(target_mm)
target_steps = self._mm_to_steps(target_mm)
delta = target_steps - self._pos_steps
if delta == 0:
return
self._do_steps(delta)
def set_position_mm(self, mm):
"""Set current W to <mm> without moving (G92-style for W)."""
self._require_present()
steps = self._mm_to_steps(mm)
self._rpc('WPOS %d' % steps, topic='ok', timeout=2.0)
self._pos_steps = steps
# WPOS clears homed on the ESP; mirror it.
self._homed = False
self._publish_state()
def jog_steps(self, steps):
"""Raw step move bypassing mm conversion and soft limits.
Used by manual jog UI when axis isn't homed yet."""
self._require_present()
if steps == 0:
return
self._do_steps(int(steps), ignore_limits=True)
def abort(self):
"""Cancel any running ESP motion immediately."""
if not self._present:
return
try:
# Don't take the RPC lock; ABORT must be able to interrupt.
self._send_raw('ABORT')
except Exception as e:
self.log.warning('ABORT send failed: %s' % e)
def close(self):
self._stop.set()
try:
if self._sp is not None:
self._sp.close()
except Exception:
pass
# ------------------------------------------------------------------ guts
def _require_present(self):
if not self.enabled:
raise AuxAxisError('Aux axis disabled')
if not self._present:
raise AuxAxisError('Aux axis not connected')
def _check_limits(self, target_mm):
lo = float(self._cfg['min_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(
'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'])
sign = 1 if int(self._cfg.get('dir_sign', 1)) >= 0 else -1
return int(round(mm * spm * sign))
def _steps_to_mm(self, steps):
spm = float(self._cfg['steps_per_mm']) or 1.0
sign = 1 if int(self._cfg.get('dir_sign', 1)) >= 0 else -1
return (steps / spm) * sign
def _do_steps(self, signed_count, ignore_limits=False):
max_rate = int(self._cfg['step_max_sps'])
accel = int(self._cfg['step_accel_sps2'])
safe_flag = 0 if ignore_limits else 1
cmd = 'STEPS %d maxrate=%d accel=%d safe=%d' % (
signed_count, max_rate, accel, safe_flag)
line = self._rpc(cmd, topic='step', timeout=300.0)
# line: "done count=N pos=P limit=L" or "aborted count=N pos=P [reason=...]"
if line.startswith('done'):
self._pos_steps = self._parse_kv_int(line, 'pos', self._pos_steps)
self._publish_state()
return
# aborted
self._pos_steps = self._parse_kv_int(line, 'pos', self._pos_steps)
self._publish_state()
reason = self._parse_kv_str(line, 'reason')
if reason == 'limit':
self._homed = False
raise AuxAxisError('W move aborted by limit switch')
raise AuxAxisError('W move aborted: %s' % line)
# ------------------------------------------------------------ serial I/O
def _open(self):
port = self._cfg['port']
baud = int(self._cfg['baud'])
try:
self._sp = serial.Serial(port, baud, timeout=0.2)
except Exception as e:
self.log.error('Could not open %s: %s' % (port, e))
self._sp = None
return
self.log.info('Opened %s @ %d' % (port, baud))
self._reader_thread = threading.Thread(
target=self._reader_loop, name='AuxAxis-rx', daemon=True)
self._reader_thread.start()
# Give the ESP a moment to settle, then push HOMECFG and query state.
# This runs in a background thread to avoid blocking startup.
threading.Thread(target=self._on_connect, daemon=True).start()
def _on_connect(self):
time.sleep(0.5)
try:
self._push_homecfg()
self._refresh_state()
except Exception as e:
self.log.warning('Aux post-connect setup failed: %s' % e)
def _push_homecfg(self):
c = self._cfg
cmd = ('HOMECFG dir=%s fast=%d slow=%d backoff=%d maxtravel=%d '
'zero=0 accel=%d step_max=%d step_start=%d limit_low=%d') % (
c['home_dir'],
int(c['home_fast_sps']),
int(c['home_slow_sps']),
int(c['home_backoff_steps']),
int(c['home_maxtravel_steps']),
int(c['step_accel_sps2']),
int(c['step_max_sps']),
int(c['step_start_sps']),
1 if c['limit_low'] else 0,
)
self._rpc(cmd, topic='homecfg', timeout=3.0)
def _refresh_state(self):
try:
r = self._rpc('WPOS?', topic='wpos', timeout=2.0)
self._pos_steps = int(r.strip())
except Exception:
pass
try:
r = self._rpc('HOMED?', topic='homed', timeout=2.0)
self._homed = (r.strip() == '1')
except Exception:
pass
self._publish_state()
def _reader_loop(self):
buf = b''
while not self._stop.is_set():
sp = self._sp
if sp is None:
time.sleep(0.5)
continue
try:
chunk = sp.read(256)
except Exception as e:
self.log.warning('Aux serial read error: %s' % e)
time.sleep(0.5)
continue
if not chunk:
continue
buf += chunk
while True:
nl = buf.find(b'\n')
if nl < 0:
break
line = buf[:nl].rstrip(b'\r').decode('utf-8', errors='replace')
buf = buf[nl+1:]
self._on_line(line)
def _on_line(self, line):
if not line:
return
# Boot banner -> reset homed flag.
if line.startswith('[boot]'):
self.log.warning('Aux ESP booted: %s' % line)
self._homed = False
self._present = True
self._publish_state()
self.ctrl.state.add_message(
'W axis controller restarted - re-home before use')
return
# Topic dispatch: "[topic] body..."
if line.startswith('[') and ']' in line:
rb = line.index(']')
topic = line[1:rb]
body = line[rb+1:].lstrip()
# Mark present on first known topic.
if not self._present:
self._present = True
self._publish_state()
# Match against the head of the pending queue.
with self._pending_cv:
if (self._pending_topics
and topic in self._pending_topics[0]):
# Pop and deliver
self._pending_topics.pop(0)
self._pending_replies.append(body)
self._pending_cv.notify_all()
return
# Async informational line; just log.
self.log.info('aux: %s' % line)
else:
self.log.info('aux: %s' % line)
def _send_raw(self, cmd):
sp = self._sp
if sp is None:
raise AuxAxisError('Serial not open')
if not cmd.endswith('\n'):
cmd = cmd + '\n'
sp.write(cmd.encode('utf-8'))
sp.flush()
def _rpc(self, cmd, topic, timeout=5.0):
"""Send `cmd`, wait for a reply line whose topic is in `topic`.
topic may be a single string or a tuple/list of acceptable topics
(e.g. ('home', 'err'))."""
if isinstance(topic, str):
topics = (topic, 'err')
else:
topics = tuple(topic) + ('err',)
with self._sp_lock:
with self._pending_cv:
self._pending_topics.append(topics)
self._pending_replies = [] # reset
self.log.info('aux >> %s' % cmd.strip())
self._send_raw(cmd)
deadline = time.time() + timeout
with self._pending_cv:
while not self._pending_replies:
remaining = deadline - time.time()
if remaining <= 0:
# Drop the pending slot so we don't capture a
# late reply meant for the next caller.
try:
self._pending_topics.remove(topics)
except ValueError:
pass
raise AuxAxisError(
'Timeout waiting for %s reply to "%s"'
% (topics, cmd.strip()))
self._pending_cv.wait(timeout=remaining)
reply = self._pending_replies.pop(0)
self.log.info('aux << %s' % reply)
if reply.startswith('err') or reply.startswith('error'):
raise AuxAxisError('ESP error: %s' % reply)
return reply
@staticmethod
def _parse_kv_int(line, key, default=0):
# Parse "key=N" (signed integer) out of a line.
for tok in line.split():
if tok.startswith(key + '='):
try:
return int(tok.split('=', 1)[1])
except ValueError:
return default
return default
@staticmethod
def _parse_kv_str(line, key, default=''):
for tok in line.split():
if tok.startswith(key + '='):
return tok.split('=', 1)[1]
return default
# ------------------------------------------------------------ state push
def _publish_state(self):
st = self.ctrl.state
try:
st.set('aux_present', bool(self._present))
st.set('aux_homed', bool(self._homed))
st.set('aux_pos', round(self.position_mm, 4))
st.set('aux_enabled', bool(self.enabled))
except Exception:
# During very early startup, state may not be ready.
pass

View File

@@ -0,0 +1,237 @@
################################################################################
#
# AuxPreprocessor - rewrite W-axis G-code into hook calls
#
# The bbctrl planner only understands xyzabc. We expose a virtual W axis by
# rewriting the G-code file *before* it is fed to gplan, replacing each W
# move with a (MSG,HOOK:aux:...) line that the host's hook handler turns
# into a STEPS or HOME command on the ESP.
#
# Rules:
# - Mixed-axis blocks (W together with XYZABC) are split into two
# sequential blocks. By default the W move runs first; configurable.
# - G90/G91/G20/G21 modal state is tracked so we can convert relative-W
# and inch-W into the absolute mm value the hook handler expects.
# - G28 W0 / G28.2 W0 -> HOOK:aux_home
# - G92 Wx -> HOOK:aux_setzero:<mm>
# - G53 + W not specially handled (W only knows machine coords)
# - Lines inside parentheses or after `;` are passed through.
#
# The preprocessor is intentionally conservative: anything it doesn't
# understand involving W is left alone with a warning, so motion lands in
# gplan which will complain loudly rather than silently misbehaving.
#
################################################################################
import os
import re
import shutil
import tempfile
# Match a word like "W12.5" or "W-3" or "w0". Also matches inside the same
# line as XYZ words. We pull W out specifically.
_W_TOKEN_RE = re.compile(r'(?<![A-Za-z_0-9])[Ww]\s*([-+]?\d*\.?\d+)')
# Detect any axis-bearing word (so we can tell mixed-axis lines apart).
_AXIS_WORD_RE = re.compile(r'(?<![A-Za-z_0-9])[XYZABCxyzabc]\s*[-+]?\d*\.?\d+')
# Strip line comments so we don't get fooled by "(W axis)".
_PAREN_COMMENT_RE = re.compile(r'\([^)]*\)')
# Modal G-code groups we care about.
_MODAL_RE = re.compile(r'(?<![A-Za-z_0-9])[Gg]\s*0*(\d+(?:\.\d+)?)')
class AuxPreprocessorError(Exception):
pass
class AuxPreprocessor(object):
def __init__(self, log=None, w_first=True):
self.log = log
# If True, on a mixed-axis line (e.g. G1 X10 W5), emit the W move
# first, then the XYZ move. Set False to invert.
self.w_first = w_first
def _info(self, msg):
if self.log:
self.log.info(msg)
def _warn(self, msg):
if self.log:
self.log.warning(msg)
# ------------------------------------------------------------------ scan
@staticmethod
def file_uses_w(path):
"""Quick check: does this file contain any W-axis word? Used to skip
preprocessing entirely for files that don't care about W."""
try:
with open(path, 'r', encoding='utf-8', errors='replace') as f:
for line in f:
code = _PAREN_COMMENT_RE.sub('', line)
code = code.split(';', 1)[0]
if _W_TOKEN_RE.search(code):
return True
except Exception:
pass
return False
# ------------------------------------------------------------------ core
def _strip_w(self, line):
"""Return (line_without_w, w_value_str_or_None). Only first W kept."""
m = _W_TOKEN_RE.search(line)
if m is None:
return line, None
# Remove just the matched W<num> token, preserving surrounding spaces.
rewritten = line[:m.start()] + line[m.end():]
return rewritten, m.group(1)
def _has_other_axis(self, code_no_w):
return _AXIS_WORD_RE.search(code_no_w) is not None
def _detect_modals(self, code, modal):
"""Update modal dict in-place from G-codes on this line."""
for mm in _MODAL_RE.finditer(code):
try:
g = float(mm.group(1))
except ValueError:
continue
if g == 90: modal['abs'] = True
elif g == 91: modal['abs'] = False
elif g == 20: modal['inch'] = True
elif g == 21: modal['inch'] = False
# G28 / G28.2 / G92 are detected case-by-case below.
@staticmethod
def _is_g28_like(code):
# Match G28 or G28.2 (homing).
return bool(re.search(r'(?<![A-Za-z_0-9])[Gg]\s*0*28(?:\.2)?(?![\w.])', code))
@staticmethod
def _is_g92(code):
return bool(re.search(r'(?<![A-Za-z_0-9])[Gg]\s*0*92(?![\w.])', code))
# ------------------------------------------------------------------ run
def process(self, src_path, dst_path):
"""Read src_path, write rewritten G-code to dst_path. Returns True
if any rewrite happened."""
modal = {'abs': True, 'inch': False} # G90 G21 are common defaults
rewrote_any = False
with open(src_path, 'r', encoding='utf-8', errors='replace') as fin, \
open(dst_path, 'w', encoding='utf-8') as fout:
for raw in fin:
line = raw.rstrip('\n')
# Comment-only or blank lines pass through verbatim.
code = _PAREN_COMMENT_RE.sub('', line)
code = code.split(';', 1)[0]
if not code.strip():
fout.write(raw)
continue
# Update modal from G-codes on this line first (so absolute
# vs incremental matches what the planner sees for XYZ).
self._detect_modals(code, modal)
if not _W_TOKEN_RE.search(code):
fout.write(raw)
continue
rewrote_any = True
# G28[.2] W... -> aux_home (W value is ignored except as
# a flag that W is being homed).
if self._is_g28_like(code):
code_no_w, _ = self._strip_w(line)
fout.write('(MSG,HOOK:aux_home:)\n')
# Only keep the residual line if other axes were also
# present (e.g. G28.2 X0 Y0 W0 still homes X+Y). A bare
# "G28" without axis args means "home all" in gcode
# which we explicitly DON'T want to trigger from a
# W-only home command.
rest_code = _PAREN_COMMENT_RE.sub('', code_no_w)
rest_code = rest_code.split(';', 1)[0]
if self._has_other_axis(rest_code):
fout.write(code_no_w + '\n')
continue
# G92 W... -> set W zero (or other value) without motion.
if self._is_g92(code):
line_no_w, w_val = self._strip_w(line)
target_mm = self._w_to_mm(w_val, modal, set_pos=True)
fout.write('(MSG,HOOK:aux_setzero:%g)\n' % target_mm)
rest_code = _PAREN_COMMENT_RE.sub('', line_no_w)
rest_code = rest_code.split(';', 1)[0]
if self._has_other_axis(rest_code):
fout.write(line_no_w + '\n')
continue
# Plain motion: G0/G1 etc with W word.
line_no_w, w_val = self._strip_w(line)
target_mm = self._w_to_mm(w_val, modal, set_pos=False)
# Distinguish absolute vs relative: encode both, the hook
# handler will pick the right operation.
if modal['abs']:
hook_line = '(MSG,HOOK:aux:%g)' % target_mm
else:
hook_line = '(MSG,HOOK:aux_rel:%g)' % target_mm
rest_code = _PAREN_COMMENT_RE.sub('', line_no_w)
rest_code = rest_code.split(';', 1)[0]
has_xyz = self._has_other_axis(rest_code)
if not has_xyz:
# Pure W move; drop the (now-empty) original line.
fout.write(hook_line + '\n')
continue
# Mixed-axis: split. Default order is W first.
if self.w_first:
fout.write(hook_line + '\n')
fout.write(line_no_w + '\n')
else:
fout.write(line_no_w + '\n')
fout.write(hook_line + '\n')
return rewrote_any
# ------------------------------------------------------------ unit conv
def _w_to_mm(self, w_str, modal, set_pos):
try:
v = float(w_str)
except (TypeError, ValueError):
raise AuxPreprocessorError('Invalid W value: %r' % w_str)
if modal['inch']:
v *= 25.4
return v
def preprocess_file(src_path, log=None, w_first=True):
"""Convenience: rewrite src_path in place if it uses W.
Returns True if the file was rewritten."""
if not AuxPreprocessor.file_uses_w(src_path):
return False
pre = AuxPreprocessor(log=log, w_first=w_first)
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:
shutil.move(tmp, src_path)
return True
os.unlink(tmp)
return False
except Exception:
try:
os.unlink(tmp)
except OSError:
pass
raise

View File

@@ -468,8 +468,7 @@ class VideoHandler(web.RequestHandler):
self.camera = app.camera
@web.asynchronous
def get(self):
async def get(self):
self.request.connection.stream.max_write_buffer_size = 10000
self.set_header('Cache-Control', 'no-store, no-cache, must-revalidate, '

View File

@@ -223,6 +223,10 @@ class Comm(object):
self.ctrl.mach.process_log(msg)
elif 'firmware' in msg:
self.log.info('AVR firmware rebooted')
try:
import bbctrl.Trace as _T
_T.mark('avr.firmware_rebooted')
except Exception: pass
self.connect()
else:
self._update_state(msg)

View File

@@ -28,10 +28,12 @@
import os
import time
import bbctrl
import bbctrl.Trace as Trace
class Ctrl(object):
def __init__(self, args, ioloop, id):
Trace.mark('ctrl.init.start', id=id or '<default>')
self.args = args
self.ioloop = bbctrl.IOLoop(ioloop)
self.id = id
@@ -43,31 +45,52 @@ class Ctrl(object):
if args.demo: log_path = self.get_path(filename = 'bbctrl.log')
else: log_path = args.log
self.log = bbctrl.log.Log(args, self.ioloop, log_path)
Trace.mark('ctrl.log_open')
self.state = bbctrl.State(self)
self.config = bbctrl.Config(self)
Trace.mark('ctrl.state_config')
self.log.get('Ctrl').info('Starting %s' % self.id)
try:
if args.demo: self.avr = bbctrl.AVREmu(self)
else: self.avr = bbctrl.AVR(self)
with Trace.span('ctrl.avr'):
if args.demo: self.avr = bbctrl.AVREmu(self)
else: self.avr = bbctrl.AVR(self)
self.i2c = bbctrl.I2C(args.i2c_port, args.demo)
self.lcd = bbctrl.LCD(self)
self.mach = bbctrl.Mach(self, self.avr)
self.preplanner = bbctrl.Preplanner(self)
if not args.demo: self.jog = bbctrl.Jog(self)
self.pwr = bbctrl.Pwr(self)
with Trace.span('ctrl.i2c'):
self.i2c = bbctrl.I2C(args.i2c_port, args.demo)
with Trace.span('ctrl.lcd'):
self.lcd = bbctrl.LCD(self)
with Trace.span('ctrl.mach'):
self.mach = bbctrl.Mach(self, self.avr)
with Trace.span('ctrl.preplanner'):
self.preplanner = bbctrl.Preplanner(self)
if not args.demo:
with Trace.span('ctrl.jog'):
self.jog = bbctrl.Jog(self)
with Trace.span('ctrl.pwr'):
self.pwr = bbctrl.Pwr(self)
with Trace.span('ctrl.hooks'):
self.hooks = bbctrl.Hooks(self)
with Trace.span('ctrl.aux'):
self.aux = bbctrl.AuxAxis(self)
self._register_aux_hooks()
self.mach.connect()
with Trace.span('ctrl.mach.connect'):
self.mach.connect()
self.lcd.add_new_page(bbctrl.MainLCDPage(self))
self.lcd.add_new_page(bbctrl.IPLCDPage(self.lcd))
os.environ['GCODE_SCRIPT_PATH'] = self.get_upload()
except Exception: self.log.get('Ctrl').exception('Internal error: Control initialization failed')
Trace.mark('ctrl.init.end')
Trace.sd_notify('STATUS=ctrl initialized\n')
except Exception:
Trace.mark('ctrl.init.error')
self.log.get('Ctrl').exception('Internal error: Control initialization failed')
def __del__(self): print('Ctrl deleted')
@@ -109,8 +132,46 @@ class Ctrl(object):
self.preplanner.start()
def _register_aux_hooks(self):
"""Wire up the auxcnc HOOK: events to AuxAxis methods."""
log = self.log.get('AuxAxis')
def _hook_move(ctx):
data = (ctx.get('data') or '').strip()
if not data:
raise Exception('aux hook missing target')
self.aux.move_abs_mm(float(data))
def _hook_move_rel(ctx):
data = (ctx.get('data') or '').strip()
if not data:
raise Exception('aux_rel hook missing delta')
self.aux.move_rel_mm(float(data))
def _hook_home(ctx):
self.aux.home()
def _hook_setzero(ctx):
data = (ctx.get('data') or '').strip()
mm = float(data) if data else 0.0
self.aux.set_position_mm(mm)
self.hooks.register_internal('aux', _hook_move,
block_unpause=True, auto_resume=True)
self.hooks.register_internal('aux_rel', _hook_move_rel,
block_unpause=True, auto_resume=True)
self.hooks.register_internal('aux_home', _hook_home,
block_unpause=True, auto_resume=True,
timeout=180)
self.hooks.register_internal('aux_setzero', _hook_setzero,
block_unpause=True, auto_resume=True)
log.info('Aux hooks registered')
def close(self):
self.log.get('Ctrl').info('Closing %s' % self.id)
self.ioloop.close()
self.avr.close()
self.mach.planner.close()
try: self.aux.close()
except Exception: pass

View File

@@ -99,6 +99,19 @@ class FileHandler(bbctrl.APIHandler):
del (self.uploadFile)
# If the uploaded G-code uses the virtual W axis, rewrite the
# file in place so the planner sees (MSG,HOOK:aux:*) lines
# instead of W tokens it can't parse.
try:
from bbctrl.AuxPreprocessor import preprocess_file
log = self.get_log('AuxPreprocessor')
if preprocess_file(filename.decode('utf8'), log=log):
log.info('Rewrote W-axis tokens in %s' %
self.uploadFilename)
except Exception:
self.get_log('AuxPreprocessor').exception(
'W-axis preprocess failed; uploading unchanged')
self.get_ctrl().preplanner.invalidate(self.uploadFilename)
self.get_ctrl().state.add_file(self.uploadFilename)

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

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

View File

@@ -182,4 +182,11 @@ class Log(object):
if n == 16: os.unlink(fullpath)
else: self._rotate(path, nextN)
os.rename(fullpath, '%s.%d' % (path, nextN))
# The recursive call may have unlinked or rotated this
# path; tolerate a missing source rather than crashing
# bbctrl on startup. This also tolerates concurrent
# logrotate runs from /etc/cron.reboot.
try:
os.rename(fullpath, '%s.%d' % (path, nextN))
except FileNotFoundError:
pass

View File

@@ -256,6 +256,9 @@ class Mach(Comm):
if cmd[0] == '$': self._query_var(cmd)
elif cmd[0] == '\\': super().queue_command(cmd[1:])
else:
# Rewrite W-axis tokens in MDI input the same way the
# FileHandler rewrites uploaded files.
cmd = self._rewrite_w_mdi(cmd)
self._begin_cycle('mdi')
self.planner.mdi(cmd, with_limits)
super().resume()
@@ -263,6 +266,35 @@ class Mach(Comm):
self.mlog.info("Exception during MDI: %s" % err)
pass
def _rewrite_w_mdi(self, cmd):
"""Apply the W-axis preprocessor to a single MDI line. Returns
possibly-multi-line G-code with HOOK: comments inserted."""
try:
from bbctrl.AuxPreprocessor import AuxPreprocessor, _W_TOKEN_RE
if not _W_TOKEN_RE.search(cmd):
return cmd
import io, tempfile, os
# AuxPreprocessor.process is file-based; route through
# tempfiles so we don't fork the regex/state logic.
pre = AuxPreprocessor(log=self.mlog)
with tempfile.NamedTemporaryFile('w', suffix='.nc',
delete=False) as fi:
fi.write(cmd if cmd.endswith('\n') else cmd + '\n')
ipath = fi.name
opath = ipath + '.out'
try:
pre.process(ipath, opath)
rewritten = open(opath).read()
finally:
try: os.unlink(ipath)
except OSError: pass
try: os.unlink(opath)
except OSError: pass
return rewritten
except Exception as e:
self.mlog.warning('W-axis MDI rewrite failed: %s' % e)
return cmd
def set(self, code, value):
super().queue_command('${}={}'.format(code, value))
@@ -349,6 +381,10 @@ class Mach(Comm):
def unpause(self):
if self._is_paused():
# Gate unpause on hook completion
if hasattr(self.ctrl, 'hooks') and \
not self.ctrl.hooks.can_unpause():
return
self.ctrl.state.set('optional_pause', False)
self._unpause()

View File

@@ -30,7 +30,22 @@ import math
import re
import time
from collections import deque
import camotics.gplan as gplan # pylint: disable=no-name-in-module,import-error
# camotics.gplan is heavy (loads a C++ extension that pulls in libstdc++,
# boost::python, etc.). Defer it: bbctrl can listen on HTTP and serve
# the UI without ever touching the planner. Lazy-load the first time
# Planner.init() runs, which is when the user actually queues motion.
gplan = None
def _load_gplan():
global gplan
if gplan is None:
try:
import bbctrl.Trace as _T
with _T.span('imports.camotics_gplan'):
import camotics.gplan as _gplan # pylint: disable=no-name-in-module,import-error
except Exception:
import camotics.gplan as _gplan # pylint: disable=no-name-in-module,import-error
gplan = _gplan
return gplan
import bbctrl.Cmd as Cmd
from bbctrl.CommandQueue import CommandQueue
@@ -329,7 +344,7 @@ class Planner():
if stop:
self.ctrl.mach.stop()
self.planner = gplan.Planner()
self.planner = _load_gplan().Planner()
self.planner.set_resolver(self._get_var_cb)
# TODO logger is global and will not work correctly in demo mode
self.planner.set_logger(self._log_cb, 1, 'LinePlanner:3')

185
src/py/bbctrl/Trace.py Normal file
View File

@@ -0,0 +1,185 @@
################################################################################
# #
# Lightweight phase tracing for bbctrl restart / boot timing. #
# #
# Anchored at module import time. All timestamps are seconds since the #
# process anchor (monotonic). A wall-clock anchor is captured once so the #
# timeline can be aligned with journalctl / systemd-analyze. #
# #
# Set BBCTRL_TRACE=0 in the environment to disable all marks (no-op). #
# #
# Exposed by /api/diag/timing as JSON. #
# #
################################################################################
"""Bbctrl restart / startup tracing.
Usage:
import bbctrl.Trace as T
T.mark('proc.start')
with T.span('ctrl.avr.init'):
...
The timeline is also dumped on demand via /api/diag/timing.
"""
import os
import time
import json
import threading
_ENABLED = os.environ.get('BBCTRL_TRACE', '1') != '0'
_t0_monotonic = time.monotonic()
_t0_wall = time.time()
_lock = threading.Lock()
_events = [] # list of dicts: {t, name, fields}
_ui_timing = None # last timeline POSTed by the browser
def _read_kernel_anchors():
"""Return (btime_wall, uptime_at_anchor) so we can express bbctrl events
in seconds since kernel boot.
btime_wall: wall-clock epoch seconds when the kernel booted (from
/proc/stat 'btime').
uptime_at_anchor: monotonic offset (seconds since kernel boot) at the
moment Trace was imported. Equivalent to (Trace anchor) - btime
in wall time, but read directly from /proc/uptime so it isn't
sensitive to wall-clock skew.
"""
btime = None
uptime_at_anchor = None
try:
with open('/proc/stat') as f:
for line in f:
if line.startswith('btime '):
btime = int(line.split()[1])
break
except Exception:
pass
try:
with open('/proc/uptime') as f:
uptime_at_anchor = float(f.read().split()[0])
except Exception:
pass
return btime, uptime_at_anchor
_btime_wall, _uptime_at_anchor = _read_kernel_anchors()
def now():
return time.monotonic() - _t0_monotonic
def mark(name, **fields):
"""Record a single named event at the current monotonic time."""
if not _ENABLED:
return
t = now()
ev = {'t': round(t, 4), 'name': name}
if fields:
ev['fields'] = fields
with _lock:
_events.append(ev)
# Also surface in the regular log stream so journalctl shows it.
try:
extras = ''
if fields:
extras = ' ' + ' '.join('%s=%s' % (k, v) for k, v in fields.items())
print('TRACE +%.3fs %s%s' % (t, name, extras), flush=True)
except Exception:
pass
class span(object):
"""Context manager that emits <name>.start / <name>.end with duration."""
def __init__(self, name, **fields):
self.name = name
self.fields = fields
self._t = None
def __enter__(self):
if _ENABLED:
self._t = time.monotonic()
mark(self.name + '.start', **self.fields)
return self
def __exit__(self, exc_type, exc, tb):
if _ENABLED and self._t is not None:
dur_ms = int((time.monotonic() - self._t) * 1000)
extra = dict(self.fields)
extra['dur_ms'] = dur_ms
if exc_type is not None:
extra['error'] = exc_type.__name__
mark(self.name + '.end', **extra)
return False
def set_ui_timing(data):
global _ui_timing
_ui_timing = data
def _current_uptime():
try:
with open('/proc/uptime') as f:
return float(f.read().split()[0])
except Exception:
return None
def timeline():
with _lock:
events = list(_events)
return {
'enabled': _ENABLED,
't0_wall': _t0_wall,
't0_iso': time.strftime('%Y-%m-%dT%H:%M:%S', time.localtime(_t0_wall)),
'now': now(),
'pid': os.getpid(),
'events': events,
'ui': _ui_timing,
# Kernel-boot anchors so the timeline can be expressed in
# "seconds since power on".
'btime_wall': _btime_wall,
'uptime_at_anchor': _uptime_at_anchor,
'uptime_now': _current_uptime(),
}
def dump(path):
try:
with open(path, 'w') as f:
json.dump(timeline(), f, indent=2)
except Exception:
pass
# Sd_notify helper -------------------------------------------------------------
#
# Allows bbctrl to tell systemd "I am ready" / "current status is X" so
# `systemctl status bbctrl` and `systemd-analyze critical-chain` reflect the
# actual application state instead of just exec start.
def sd_notify(state):
"""Send a status line to systemd. Safe no-op when not under systemd."""
addr = os.environ.get('NOTIFY_SOCKET')
if not addr:
return
try:
import socket
sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
try:
# Abstract socket if it starts with '@'
target = '\0' + addr[1:] if addr.startswith('@') else addr
sock.sendto(state.encode('utf-8'), target)
finally:
sock.close()
except Exception:
pass
# Mark module-import time so even importing bbctrl shows up.
mark('trace.import')

View File

@@ -766,6 +766,93 @@ class RotaryHandler(bbctrl.APIHandler):
log.error('Unexpected error: {}'.format(e))
class HooksGetHandler(bbctrl.APIHandler):
def get(self):
self.write_json(self.get_ctrl().hooks.get_config())
class HooksSaveHandler(bbctrl.APIHandler):
def put_ok(self):
self.get_ctrl().hooks.save_config(self.json)
class HooksStatusHandler(bbctrl.APIHandler):
def get(self):
self.write_json(self.get_ctrl().hooks.get_status())
class HooksFireHandler(bbctrl.APIHandler):
def put_ok(self, event):
data = self.json if hasattr(self, 'json') and self.json else {}
self.get_ctrl().hooks._fire(event, data)
# ----- W axis (auxcnc) endpoints --------------------------------------------
class AuxConfigGetHandler(bbctrl.APIHandler):
def get(self):
self.write_json(self.get_ctrl().aux.get_config())
class AuxConfigSaveHandler(bbctrl.APIHandler):
def put_ok(self):
self.get_ctrl().aux.save_config(self.json or {})
class AuxStatusHandler(bbctrl.APIHandler):
def get(self):
aux = self.get_ctrl().aux
self.write_json({
'enabled': aux.enabled,
'present': aux.present,
'homed': aux.homed,
'pos_mm': aux.position_mm,
})
class AuxHomeHandler(bbctrl.APIHandler):
def put_ok(self):
# Run synchronously via the AuxAxis' own RPC; this blocks the
# request. Fine because the UI shows a spinner.
self.get_ctrl().aux.home()
class AuxAbortHandler(bbctrl.APIHandler):
def put_ok(self):
self.get_ctrl().aux.abort()
class AuxJogHandler(bbctrl.APIHandler):
"""Body: {"mm": 1.5} for relative-mm move,
{"steps": 200} for raw step move (bypasses soft limits)."""
def put_ok(self):
body = self.json or {}
aux = self.get_ctrl().aux
if 'mm' in body:
aux.move_rel_mm(float(body['mm']))
elif 'steps' in body:
aux.jog_steps(int(body['steps']))
else:
raise HTTPError(400, 'mm or steps required')
class AuxMoveHandler(bbctrl.APIHandler):
"""Body: {"mm": 12.5} absolute move in mm."""
def put_ok(self):
body = self.json or {}
if 'mm' not in body:
raise HTTPError(400, 'mm required')
self.get_ctrl().aux.move_abs_mm(float(body['mm']))
class AuxSetZeroHandler(bbctrl.APIHandler):
"""Body: {"mm": 0} set current position to <mm>."""
def put_ok(self):
body = self.json or {}
mm = float(body.get('mm', 0.0))
self.get_ctrl().aux.set_position_mm(mm)
class RemoteDiagnosticsHandler(bbctrl.APIHandler):
def get(self):
@@ -798,6 +885,31 @@ class RemoteDiagnosticsHandler(bbctrl.APIHandler):
'message': e.reason or "Unknown"
})
class TimingHandler(bbctrl.APIHandler):
"""Return the bbctrl process startup timeline as JSON.
Includes monotonic-anchored events from bbctrl.Trace, the wall
clock anchor (so the timeline can be aligned with journalctl /
systemd-analyze output), and the most recent UI-side timing
payload posted by the browser.
"""
def get(self):
import bbctrl.Trace as _T
self.write_json(_T.timeline())
class UITimingHandler(bbctrl.APIHandler):
"""Browser posts its performance.now() marks here once per load."""
def put_ok(self):
import bbctrl.Trace as _T
# self.json is parsed in APIHandler.prepare()
try:
_T.set_ui_timing(self.json)
_T.mark('ui.posted_timing',
marks=len(self.json.get('marks', []) or []))
except Exception: pass
# Base class for Web Socket connections
class ClientConnection(object):
def __init__(self, app):
@@ -873,6 +985,12 @@ class SockJSConnection(ClientConnection, sockjs.tornado.SockJSConnection):
ip = info.ip
if 'X-Real-IP' in info.headers: ip = info.headers['X-Real-IP']
self.app.get_ctrl(id).log.get('Web').info('Connection from %s' % ip)
try:
if not getattr(self.app, '_first_ws', False):
self.app._first_ws = True
import bbctrl.Trace as _T
_T.mark('ws.first_open', ip=ip)
except Exception: pass
super().on_open(id)
@@ -881,6 +999,23 @@ class StaticFileHandler(tornado.web.StaticFileHandler):
self.set_header('Cache-Control',
'no-store, no-cache, must-revalidate, max-age=0')
def prepare(self):
# Mark the first request for the index page so we can see when
# chromium actually started fetching the UI on cold boot.
try:
app = self.application
if not getattr(app, '_first_root_get', False):
# Treat any GET '/' or '/index.html' as the root fetch.
p = self.request.path
if p in ('/', '/index.html', ''):
app._first_root_get = True
import bbctrl.Trace as _T
_T.mark('web.first_root_get',
ip=self.request.remote_ip,
ua=(self.request.headers.get('User-Agent') or '')[:60])
except Exception: pass
return super().prepare()
class Web(tornado.web.Application):
def __init__(self, args, ioloop):
self.args = args
@@ -902,6 +1037,8 @@ class Web(tornado.web.Application):
handlers = [
(r'/websocket', WSConnection),
(r'/api/diag/timing', TimingHandler),
(r'/api/diag/timing/ui', UITimingHandler),
(r'/api/log', LogHandler),
(r'/api/message/(\d+)/ack', MessageAckHandler),
(r'/api/bugreport', BugReportHandler),
@@ -941,6 +1078,18 @@ class Web(tornado.web.Application):
(r'/api/time', TimeHandler),
(r'/api/rotary', RotaryHandler),
(r'/api/remote-diagnostics', RemoteDiagnosticsHandler),
(r'/api/hooks', HooksGetHandler),
(r'/api/hooks/save', HooksSaveHandler),
(r'/api/hooks/status', HooksStatusHandler),
(r'/api/hooks/fire/([\w-]+)', HooksFireHandler),
(r'/api/aux/config', AuxConfigGetHandler),
(r'/api/aux/config/save', AuxConfigSaveHandler),
(r'/api/aux/status', AuxStatusHandler),
(r'/api/aux/home', AuxHomeHandler),
(r'/api/aux/abort', AuxAbortHandler),
(r'/api/aux/jog', AuxJogHandler),
(r'/api/aux/move', AuxMoveHandler),
(r'/api/aux/set-zero', AuxSetZeroHandler),
(r'/(.*)', StaticFileHandler,
{'path': bbctrl.get_resource('http/'),
'default_filename': 'index.html'}),

View File

@@ -36,6 +36,13 @@ import datetime
from pkg_resources import Requirement, resource_filename
# Trace must be imported before the rest of bbctrl so its monotonic
# anchor is the earliest reasonable point and so import-time costs of
# heavy submodules (camotics gplan.so, sockjs, tornado, etc.) are
# attributable in /api/diag/timing.
import bbctrl.Trace as Trace
Trace.mark('imports.bbctrl.start')
from bbctrl.RequestHandler import RequestHandler
from bbctrl.APIHandler import APIHandler
from bbctrl.FileHandler import FileHandler
@@ -59,11 +66,15 @@ from bbctrl.AVR import AVR
from bbctrl.AVREmu import AVREmu
from bbctrl.IOLoop import IOLoop
from bbctrl.MonitorTemp import MonitorTemp
from bbctrl.Hooks import Hooks
from bbctrl.AuxAxis import AuxAxis
import bbctrl.Cmd as Cmd
import bbctrl.v4l2 as v4l2
import bbctrl.Log as log
import bbctrl.ObjGraph as ObjGraph
Trace.mark('imports.bbctrl.end')
ctrl = None
@@ -167,19 +178,28 @@ def parse_args():
def run():
global ctrl
Trace.mark('run.enter')
args = parse_args()
Trace.mark('args.parsed')
# Set signal handler
signal.signal(signal.SIGTERM, on_exit)
# Create ioloop
ioloop = tornado.ioloop.IOLoop.current()
Trace.mark('ioloop.created')
# Set ObjGraph signal handler
if args.debug: Debugger(ioloop, args.debug)
# Start server
web = Web(args, ioloop)
with Trace.span('web.init'):
web = Web(args, ioloop)
Trace.mark('listen', port=args.port, addr=args.addr)
# Notify systemd we are ready (no-op when not under systemd).
Trace.sd_notify('READY=1\nSTATUS=listening on %s:%d\n' %
(args.addr, args.port))
try:
ioloop.start()

View File

@@ -2,5 +2,5 @@
font-family: 'Audiowide';
font-style: normal;
font-weight: 400;
src: local('Audiowide'), local('Audiowide-Regular'), url(http://fonts.gstatic.com/s/audiowide/v4/8XtYtNKEyyZh481XVWfVOqCWcynf_cDxXwCLxiixG1c.ttf) format('truetype');
src: local('Audiowide'), local('Audiowide-Regular'), url(https://fonts.gstatic.com/s/audiowide/v4/8XtYtNKEyyZh481XVWfVOqCWcynf_cDxXwCLxiixG1c.ttf) format('truetype');
}

View File

@@ -1,35 +1,37 @@
// V09 redesign: the legacy side menu was removed. Keep this file
// shipped in case anything still references it, but no-op the click
// handler that used to wire up the burger menu so it does not throw
// "Cannot set properties of null" on the Settings tab.
(function (window, document) {
var menuLink = document.getElementById("menuLink");
if (!menuLink) {
return;
}
var layout = document.getElementById('layout'),
menu = document.getElementById('menu'),
menuLink = document.getElementById('menuLink');
var layout = document.getElementById("layout");
var menu = document.getElementById("menu");
function toggleClass(element, className) {
var classes = element.className.split(/\s+/),
length = classes.length,
i = 0;
for(; i < length; i++) {
if (classes[i] === className) {
classes.splice(i, 1);
break;
}
if (!element) return;
var classes = element.className.split(/\s+/);
var i;
for (i = 0; i < classes.length; i++) {
if (classes[i] === className) {
classes.splice(i, 1);
break;
}
}
// The className is not found
if (length === classes.length) {
if (i === classes.length) {
classes.push(className);
}
element.className = classes.join(' ');
element.className = classes.join(" ");
}
menuLink.onclick = function (e) {
var active = 'active';
var active = "active";
e.preventDefault();
toggleClass(layout, active);
toggleClass(menu, active);
toggleClass(menuLink, active);
};
}(this, this.document));

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@
import configTemplate from "../../../resources/config-template.json";
import ScreenRotationDialog from "$dialogs/ScreenRotationDialog.svelte";
import ConfigTemplatedInput from "./ConfigTemplatedInput.svelte";
import WAxisSettings from "./WAxisSettings.svelte";
import SetTimeDialog from "$dialogs/SetTimeDialog.svelte";
import Button, { Label } from "@smui/button";
@@ -94,6 +95,11 @@
{/each}
</fieldset>
<h2 id="w-axis">W Axis (auxcnc)</h2>
<fieldset>
<WAxisSettings />
</fieldset>
<h2>Path Accuracy</h2>
<fieldset>
<ConfigTemplatedInput key={`settings.max-deviation`} />

View File

@@ -0,0 +1,262 @@
<script lang="ts">
import { onMount } from "svelte";
import Button, { Label } from "@smui/button";
import * as api from "$lib/api";
// Mirrors the DEFAULTS in src/py/bbctrl/AuxAxis.py. The "enabled"
// flag is read-only here; toggling the 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;
baud: number;
steps_per_mm: number;
dir_sign: number;
min_w: number;
max_w: number;
max_feed_mm_min: number;
home_dir: string;
home_position_mm: number;
home_fast_sps: number;
home_slow_sps: number;
home_backoff_steps: number;
home_maxtravel_steps: number;
step_max_sps: number;
step_accel_sps2: number;
step_start_sps: number;
limit_low: boolean;
};
let cfg: AuxConfig | null = null;
let status: { enabled: boolean; present: boolean; homed: boolean; pos_mm: number } | null = null;
let busy = false;
let saveMessage = "";
onMount(async () => {
await refresh();
});
async function refresh() {
try {
cfg = await api.GET("aux/config");
status = await api.GET("aux/status");
} catch (e) {
console.error("Failed to load aux config/status:", e);
}
}
async function save() {
if (!cfg) return;
busy = true;
saveMessage = "";
try {
await api.PUT("aux/config/save", cfg);
saveMessage = "Saved.";
await refresh();
setTimeout(() => (saveMessage = ""), 3000);
} catch (e) {
console.error("Failed to save aux config:", e);
saveMessage = "Save failed - see console.";
} finally {
busy = false;
}
}
</script>
<div class="w-axis-settings">
{#if !cfg}
<p class="tip">Loading W axis configuration...</p>
{:else}
<div class="status">
{#if status}
<span>
Status:
{#if !status.enabled}
disabled
{:else if !status.present}
offline
{:else if status.homed}
homed at {status.pos_mm.toFixed(3)} mm
{:else}
connected, unhomed
{/if}
</span>
{/if}
</div>
<div class="pure-form pure-form-aligned">
<fieldset>
<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>
</div>
<div class="pure-control-group" title="Serial port for the auxcnc ESP32.">
<label for="port">serial port</label>
<input id="port" type="text" bind:value={cfg.port} />
</div>
<div class="pure-control-group" title="Serial baud rate.">
<label for="baud">baud</label>
<input id="baud" type="number" bind:value={cfg.baud} min={1200} step={1} />
</div>
</fieldset>
<h3>Mechanics</h3>
<fieldset>
<div class="pure-control-group" title="Logical steps per mm of 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>
</div>
<div class="pure-control-group" title="Direction sign: +1 or -1. Flip if W+ moves the wrong way.">
<label for="dir_sign">direction sign</label>
<select id="dir_sign" bind:value={cfg.dir_sign}>
<option value={1}>+1</option>
<option value={-1}>-1</option>
</select>
</div>
<div class="pure-control-group" title="Soft-limit minimum W in mm.">
<label for="min_w">soft min</label>
<input id="min_w" type="number" bind:value={cfg.min_w} step="any" />
<label for="" class="units">mm</label>
</div>
<div class="pure-control-group" title="Soft-limit maximum W in mm.">
<label for="max_w">soft max</label>
<input id="max_w" type="number" bind:value={cfg.max_w} step="any" />
<label for="" class="units">mm</label>
</div>
<div class="pure-control-group" title="Informational max feed; rate caps live on the ESP.">
<label for="max_feed_mm_min">max feed</label>
<input id="max_feed_mm_min" type="number" bind:value={cfg.max_feed_mm_min} step="any" />
<label for="" class="units">mm/min</label>
</div>
</fieldset>
<h3>Homing</h3>
<fieldset>
<div class="pure-control-group" title="Direction the axis moves when looking for the home limit switch.">
<label for="home_dir">home direction</label>
<select id="home_dir" bind:value={cfg.home_dir}>
<option value="-">- (toward W-)</option>
<option value="+">+ (toward W+)</option>
</select>
</div>
<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>
</div>
<div class="pure-control-group" title="Fast seek rate during homing search.">
<label for="home_fast_sps">fast seek</label>
<input id="home_fast_sps" type="number" bind:value={cfg.home_fast_sps} step={1} min={1} />
<label for="" class="units">steps/s</label>
</div>
<div class="pure-control-group" title="Slow seek rate during homing latch.">
<label for="home_slow_sps">slow seek</label>
<input id="home_slow_sps" type="number" bind:value={cfg.home_slow_sps} step={1} min={1} />
<label for="" class="units">steps/s</label>
</div>
<div class="pure-control-group" title="Backoff after the limit triggers, before the slow seek.">
<label for="home_backoff_steps">backoff</label>
<input id="home_backoff_steps" type="number" bind:value={cfg.home_backoff_steps} step={1} min={0} />
<label for="" class="units">steps</label>
</div>
<div class="pure-control-group" title="Maximum travel before homing aborts as a runaway.">
<label for="home_maxtravel_steps">max travel</label>
<input id="home_maxtravel_steps" type="number" bind:value={cfg.home_maxtravel_steps} step={1} min={1} />
<label for="" class="units">steps</label>
</div>
<div class="pure-control-group" title="Limit switch active-low? Off = active-high.">
<label for="limit_low">limit active low</label>
<input id="limit_low" type="checkbox" bind:checked={cfg.limit_low} />
</div>
</fieldset>
<h3>Step Profile</h3>
<fieldset>
<div class="pure-control-group" title="Maximum step rate during normal moves.">
<label for="step_max_sps">max rate</label>
<input id="step_max_sps" type="number" bind:value={cfg.step_max_sps} step={1} min={1} />
<label for="" class="units">steps/s</label>
</div>
<div class="pure-control-group" title="Acceleration in steps per second squared.">
<label for="step_accel_sps2">acceleration</label>
<input id="step_accel_sps2" type="number" bind:value={cfg.step_accel_sps2} step={1} min={1} />
<label for="" class="units">steps/s²</label>
</div>
<div class="pure-control-group" title="Initial step rate at the start of a move.">
<label for="step_start_sps">start rate</label>
<input id="step_start_sps" type="number" bind:value={cfg.step_start_sps} step={1} min={1} />
<label for="" class="units">steps/s</label>
</div>
</fieldset>
<div class="actions">
<Button
touch
variant="raised"
on:click={save}
disabled={busy}
>
<Label>Save W Axis Settings</Label>
</Button>
{#if saveMessage}
<span class="save-msg">{saveMessage}</span>
{/if}
</div>
<div class="tip">
Changes are written to aux.json. Homing rates and the
limit polarity are pushed to the ESP immediately; any
running motion is unaffected. Re-home the W axis after
changing direction, sign, or step settings.
</div>
</div>
{/if}
</div>
<style lang="scss">
.w-axis-settings {
.status {
margin-bottom: 1em;
font-size: 90%;
opacity: 0.8;
}
.actions {
margin-left: 210px;
margin-top: 1em;
display: flex;
align-items: center;
gap: 1em;
}
.save-msg {
font-style: italic;
}
.tip {
margin-left: 210px;
margin-top: 1em;
margin-bottom: 15px;
font-style: italic;
font-size: 90%;
line-height: 1.5;
}
}
</style>