Compare commits
22 Commits
private-mo
...
b8c4f53bb1
| Author | SHA1 | Date | |
|---|---|---|---|
| b8c4f53bb1 | |||
| 32f3aca368 | |||
| 081209decf | |||
| ef4658aaf6 | |||
| ef78f20eaa | |||
| 36829020a5 | |||
| 2413fc49ab | |||
|
|
7d5949f5fc | ||
|
|
23f22105a8 | ||
|
|
4f74e75d44 | ||
|
|
c7cf9483b3 | ||
| 54a15f9d12 | |||
| 704bc8d35c | |||
| 4d2d5fd88c | |||
| eab204b7be | |||
| e3c059eb9b | |||
| 7306464440 | |||
| 1625b768d8 | |||
| 5be7515a92 | |||
| b10a6d537e | |||
| 7d0755c55b | |||
| 7f8fd23615 |
77
AGENTS.md
77
AGENTS.md
@@ -1,77 +0,0 @@
|
||||
# Onefinity firmware — agent guidelines
|
||||
|
||||
## Branch model
|
||||
|
||||
This fork lives on **two long-lived branches**:
|
||||
|
||||
- **`master`** — public-facing fork. General-use upgrades on top of
|
||||
upstream OneFinity firmware: V09 UX redesign, Font Awesome 6, faster
|
||||
cold boot, macOS dev/deploy tooling, build & flash docs, SD-card
|
||||
backup, `/api/diag/timing`, kiosk/tablet polish, and assorted
|
||||
bug-fixes. **No A-axis, ATC, hooks, or auxcnc/ESP content.** Aim for
|
||||
changes that benefit any Onefinity owner.
|
||||
|
||||
- **`private-mods`** — bespoke shop branch. Stacks on top of `master`
|
||||
and adds everything specific to the auxcnc-ESP-driven A axis and
|
||||
the ATC: `Hooks` (ATC IPC), `AuxAxis` (ESP serial driver),
|
||||
`ExternalAxis` (virtual A through gplan), `AuxPreprocessor` (M100-M103),
|
||||
Z-A coupling interlock, the A-axis UI surface, and the
|
||||
`/api/aux/*` endpoints.
|
||||
|
||||
Upstream:
|
||||
- `upstream` → `https://github.com/OneFinityCNC/onefinity-firmware.git`
|
||||
- `origin` → Gitea (`https://gitea.home.muehe.org/muehe/onefinity-firmware.git`)
|
||||
|
||||
`origin/pre-split-backup` is a tag preserving the pre-split master
|
||||
tip. Keep it indefinitely until further notice.
|
||||
|
||||
## Where does a change go?
|
||||
|
||||
| Change | Branch |
|
||||
|---|---|
|
||||
| UI polish, theme, layout that any user benefits from | `master` |
|
||||
| Build / install / boot performance | `master` |
|
||||
| Diagnostics, logging, generic Python / Tornado fixes | `master` |
|
||||
| Anything that touches `AuxAxis`, `ExternalAxis`, `Hooks`, `AuxPreprocessor` | `private-mods` |
|
||||
| Anything mentioning the auxcnc ESP, `/dev/ttyUSB0`, the M100-M103 ATC pneumatics, or motor index 4 | `private-mods` |
|
||||
| Z-A coupling interlock, ATC tool change sequencing | `private-mods` |
|
||||
| A-axis UI (DRO row, jog tile, settings page, A-axis routes) | `private-mods` |
|
||||
| W → A renames or aux.json migrations | `private-mods` |
|
||||
|
||||
When in doubt: ask "would this be useful on a stock Onefinity with no
|
||||
ESP attached?" If yes → `master`. If no → `private-mods`.
|
||||
|
||||
## Workflow
|
||||
|
||||
```bash
|
||||
# Day-to-day shop / hardware work (default)
|
||||
git checkout private-mods
|
||||
# … do work, commit …
|
||||
git push origin private-mods
|
||||
|
||||
# Generic improvement to master
|
||||
git checkout master
|
||||
# … do work, commit …
|
||||
git push origin master
|
||||
|
||||
# After landing on master, replay private-mods on top
|
||||
git checkout private-mods
|
||||
git rebase master
|
||||
git push --force-with-lease origin private-mods
|
||||
```
|
||||
|
||||
If a change accidentally lands on `master` but is bespoke (touches
|
||||
the file table above), move it: `git reset --hard <prev>` on master,
|
||||
cherry-pick onto `private-mods`, force-push master.
|
||||
|
||||
## Deploy
|
||||
|
||||
- `./deploy.sh local` — UI bundle on `localhost:8770` (tmux session
|
||||
`onefin-local`). No controller backend; A-axis row stays hidden.
|
||||
- `./deploy.sh hardware` — rsync to the Pi over SSH, restart
|
||||
`bbctrl.service`. Use the `private-mods` branch on the shop Pi.
|
||||
- `./deploy.sh prod` — bundle a release tarball.
|
||||
|
||||
See `.pi/BUILD.md` for the full build / flash / cross-compile flow.
|
||||
|
||||
## Commit before ending a turn; push after significant changes.
|
||||
61
CHANGELOG.md
61
CHANGELOG.md
@@ -1,67 +1,6 @@
|
||||
OneFinity CNC Controller Firmware Changelog
|
||||
===========================================
|
||||
|
||||
## Unreleased (community fork)
|
||||
|
||||
General-use additions on top of upstream OneFinity firmware.
|
||||
|
||||
### UI
|
||||
- V09 redesign: 4-tab top header (Control / Program / Console /
|
||||
Settings) replaces the legacy side menu.
|
||||
- Control: redesigned DRO with per-axis offset + zero + home
|
||||
actions, jog grid with consistent button sizing across kiosk
|
||||
and tablet, status strip with live state / velocity / spindle.
|
||||
- Program: dedicated tab for run / pause / stop, file browser,
|
||||
toolpath preview.
|
||||
- Console: MDI shell, message log, indicators.
|
||||
- Settings: rail-driven inner pages so each section is its own
|
||||
focused panel rather than one long scroll.
|
||||
- Tablet mode (`?tablet=1`) pins the UI to 1920x1080 and scales
|
||||
it to fit the actual viewport.
|
||||
- Kiosk mode (`?kiosk=1`, auto on localhost): tighter layout for
|
||||
the controller's onboard 1366x768 screen.
|
||||
- Font Awesome 6 throughout (replaces FA4).
|
||||
- Fix: stop clobbering motor settings while the user is editing
|
||||
them.
|
||||
- Fix: keep jog grid visible during jog/home/probe/MDI activity.
|
||||
- Fix: opaque dark canvas for path-viewer (no flash through page
|
||||
background).
|
||||
- Fix: OrbitControls now uses non-passive wheel/touch listeners so
|
||||
it can suppress page panning while interacting with the 3D
|
||||
viewer.
|
||||
- Fix: macros tab no longer renders placeholder color stripes for
|
||||
`#dedede`/`#fff`-only macros.
|
||||
- Fix: hide the X cursor in kiosk mode (touchscreen).
|
||||
- Fix: chromium 72 mime + flex-gap fallbacks (some kiosk Pis ship
|
||||
with that older browser build).
|
||||
- Fix: Vue 1 async batching disabled so reactive writes from
|
||||
`hashchange` listeners propagate synchronously.
|
||||
|
||||
### Boot / install
|
||||
- Cold-boot optimisations cutting bbctrl listen latency by ~8s on
|
||||
the Pi (mask sysstat, replace dphys-swapfile with an fstab swap
|
||||
entry, lazy-load `camotics.gplan`, `bbserial-rebind.service`
|
||||
with explicit `Before=bbctrl.service`).
|
||||
- `install.sh` now ships these with firmware updates.
|
||||
- `bbctrl.Trace` + `/api/diag/timing` for measuring startup, with
|
||||
a UI-side `restart-timing.js` client that POSTs browser marks.
|
||||
- `Camera.py` switched from deprecated `@web.asynchronous` to
|
||||
`async def` so the streaming endpoint works on newer Tornado.
|
||||
- `Log.py` tolerates missing rotated log files on startup
|
||||
(concurrent logrotate runs from `/etc/cron.reboot` no longer
|
||||
crash bbctrl).
|
||||
|
||||
### Build / tooling
|
||||
- `.pi/BUILD.md`: end-to-end macOS dev workflow, deploy paths,
|
||||
troubleshooting.
|
||||
- `.pi/Dockerfile.gplan` + `build-gplan.sh`: rebuild `gplan.so`
|
||||
from source on Raspbian Stretch (Bullseye is too new).
|
||||
- `deploy.sh` dispatcher with `local`, `hardware`, `prod` modes.
|
||||
- `backup/onefinity-backup.sh`: dd-based whole-card backup/restore
|
||||
with shrink/expand support.
|
||||
- `Makefile`: ensure trailing newlines between concatenated pug
|
||||
templates so Pug doesn't glue file boundaries together.
|
||||
|
||||
## v1.0.8
|
||||
- Fixed chatter and lost steps issues (most commonly seen by Fusion users), re-enabled support for G61, G61.1, G64.
|
||||
- Fixed 3d preview on Safari-based web browsers (MacOS & iOS)
|
||||
|
||||
6
Makefile
6
Makefile
@@ -68,11 +68,7 @@ update: pkg
|
||||
|
||||
build/templates.pug: $(TEMPLS)
|
||||
mkdir -p build
|
||||
# Use awk to ensure each template is followed by a newline so the
|
||||
# next file's first line never gets glued onto the previous file's
|
||||
# last line (some templates ship without a trailing newline, which
|
||||
# would produce subtle Pug parse failures).
|
||||
awk 'FNR==1 && NR>1 {print ""} {print} END{print ""}' $(TEMPLS) >$@
|
||||
cat $(TEMPLS) >$@
|
||||
|
||||
node_modules: package.json
|
||||
npm install && touch node_modules
|
||||
|
||||
22
README.md
22
README.md
@@ -1,9 +1,8 @@
|
||||
# OneFinity CNC Controller Firmware (A-axis fork)
|
||||
# OneFinity CNC Controller Firmware (W-axis fork)
|
||||
|
||||
This is the community-fork firmware (V09 UI, FA6, cold-boot work,
|
||||
macOS dev tooling) with a virtual A axis driven by an auxcnc ESP32
|
||||
over USB serial. See [docs/AUX_A_AXIS.md](docs/AUX_A_AXIS.md) for the
|
||||
design and config.
|
||||
This is the OneFinity / Buildbotics bbctrl firmware with a virtual W
|
||||
axis driven by an auxcnc ESP32 over USB serial. See
|
||||
[docs/AUX_W_AXIS.md](docs/AUX_W_AXIS.md) for the design and config.
|
||||
|
||||
## Layout
|
||||
|
||||
@@ -17,7 +16,7 @@ src/svelte-components/ Newer Svelte UI for dialogs and settings
|
||||
src/pug/ Pug templates compiled into build/http/index.html
|
||||
src/resources/ Static assets and config templates
|
||||
scripts/ Install / update / RPi build helpers
|
||||
docs/ Architecture, dev setup, A-axis docs
|
||||
docs/ Architecture, dev setup, W-axis docs
|
||||
```
|
||||
|
||||
## Build & flash (quick path, macOS or Linux)
|
||||
@@ -101,8 +100,7 @@ bbctrl restarts, then the new UI).
|
||||
|
||||
```bash
|
||||
curl -s http://onefinity.local/ | grep -c "OneFinity"
|
||||
curl -s http://onefinity.local/api/diag/timing | head
|
||||
curl -s http://onefinity.local/api/aux/status # if A axis is enabled
|
||||
curl -s http://onefinity.local/api/aux/status # if W axis is enabled
|
||||
```
|
||||
|
||||
## Build & flash (full path, Debian/Linux)
|
||||
@@ -111,12 +109,12 @@ For AVR + GPlan rebuilds, see [docs/development.md](docs/development.md).
|
||||
That path uses qemu + chroot to cross-compile gplan for ARM and needs
|
||||
the `gcc-avr` / `avr-libc` toolchain.
|
||||
|
||||
## A axis (auxcnc)
|
||||
## W axis (auxcnc)
|
||||
|
||||
This fork adds a virtual A axis. See
|
||||
[docs/AUX_A_AXIS.md](docs/AUX_A_AXIS.md) for:
|
||||
This fork adds a virtual W axis. See
|
||||
[docs/AUX_W_AXIS.md](docs/AUX_W_AXIS.md) for:
|
||||
|
||||
- G-code surface (`G28 A0`, `G1 A25`, etc.)
|
||||
- G-code surface (`G28 W0`, `G1 W25`, etc.)
|
||||
- The G-code preprocessor and hook architecture
|
||||
- aux.json keys
|
||||
- REST API (`/api/aux/*`)
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Shorthand for ./deploy.sh hardware
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
exec "$SCRIPT_DIR/deploy.sh" hardware "$@"
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Shorthand for ./deploy.sh local
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
exec "$SCRIPT_DIR/deploy.sh" local "$@"
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Shorthand for ./deploy.sh prod
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
exec "$SCRIPT_DIR/deploy.sh" prod "$@"
|
||||
52
deploy.sh
52
deploy.sh
@@ -1,52 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,18 +1,6 @@
|
||||
# A axis (auxcnc) integration
|
||||
# W axis (auxcnc) integration
|
||||
|
||||
> **Note:** This document describes the original out-of-band W-axis
|
||||
> architecture (gcode preprocessor rewriting W tokens into HOOK
|
||||
> messages dispatched between blocks). The current implementation
|
||||
> integrates the auxcnc-driven stepper as a *virtual A axis* through
|
||||
> gplan via a synthetic motor (`bbctrl/ExternalAxis.py`), so A is
|
||||
> blended with XYZ in the same S-curve plan and the gcode surface
|
||||
> below applies as plain `A` words.
|
||||
>
|
||||
> The HOOK pipeline still exists for ATC pneumatic atoms (M100 EJECT,
|
||||
> M102 RELEASE, M103 CLAMP) - see `bbctrl/AuxPreprocessor.py`. Macros
|
||||
> compose drop/grab tool sequences from those atoms.
|
||||
|
||||
This adds a virtual `A` axis to the bbctrl controller, driven by the
|
||||
This adds a virtual `W` axis to the bbctrl controller, driven by the
|
||||
auxcnc ESP32 over USB serial (`/dev/ttyUSB0`). The ESP owns step-pulse
|
||||
generation, real-time limit-switch monitoring, and the homing dance.
|
||||
The Pi owns units (mm), soft limits, sequencing inside G-code jobs, and
|
||||
@@ -22,15 +10,15 @@ a small REST API for jogging / homing from the UI.
|
||||
|
||||
The bbctrl planner (gplan) only understands `xyzabc`, so adding a true
|
||||
7th axis would require rebuilding gplan + the AVR firmware. We avoid
|
||||
that by treating W as a synchronous out-of-band axis: A moves run
|
||||
that by treating W as a synchronous out-of-band axis: W moves run
|
||||
*between* G-code blocks, not blended with XYZ.
|
||||
|
||||
Pipeline:
|
||||
|
||||
1. User uploads a G-code file containing `A` words.
|
||||
1. User uploads a G-code file containing `W` words.
|
||||
2. `FileHandler` runs `AuxPreprocessor` on the upload, rewriting W
|
||||
tokens in place into `(MSG,HOOK:aux:<mm>)` etc. The original line
|
||||
minus the A word continues to drive XYZ.
|
||||
minus the W word continues to drive XYZ.
|
||||
3. The planner sees only XYZ + message comments. When it reaches a
|
||||
message line, the message goes through `state.add_message` which
|
||||
`Hooks._on_state_change` watches for the `HOOK:` prefix.
|
||||
@@ -38,25 +26,25 @@ Pipeline:
|
||||
for the event name (`aux`, `aux_rel`, `aux_home`, `aux_setzero`).
|
||||
5. The handler runs in a hook thread, gating `Mach.unpause` until done.
|
||||
While the handler is busy the machine is in HOLDING - no XYZ motion
|
||||
can resume until A finishes.
|
||||
can resume until W finishes.
|
||||
6. The handler talks to the ESP over `/dev/ttyUSB0` via `AuxAxis`,
|
||||
blocking on a deterministic reply token (`[step] done`, `[home]
|
||||
done`, etc).
|
||||
|
||||
MDI commands containing `A` words are rewritten the same way at the
|
||||
MDI commands containing `W` words are rewritten the same way at the
|
||||
`Mach.mdi()` boundary so manual jog and macros work too.
|
||||
|
||||
## G-code surface
|
||||
|
||||
```gcode
|
||||
G21 G90
|
||||
G28 A0 ; home A axis
|
||||
G1 A25 F300 ; move A to 25 mm absolute
|
||||
G1 X100 W12.5 ; mixed: A moves first, then XYZ (configurable)
|
||||
G28 W0 ; home W axis
|
||||
G1 W25 F300 ; move W to 25 mm absolute
|
||||
G1 X100 W12.5 ; mixed: W moves first, then XYZ (configurable)
|
||||
G91
|
||||
G1 A-2.5 ; relative A move
|
||||
G1 W-2.5 ; relative W move
|
||||
G90
|
||||
G92 A0 ; set current A as zero (G92-style)
|
||||
G92 W0 ; set current W as zero (G92-style)
|
||||
```
|
||||
|
||||
Rules:
|
||||
@@ -117,18 +105,18 @@ limit switch when the axis isn't homed yet).
|
||||
**Control view**
|
||||
|
||||
- A jog row appears under the XYZ jog grid when `aux_enabled` is true,
|
||||
with three buttons: `A-`, `A+`, and a wide `Home W`. There is
|
||||
with three buttons: `W-`, `W+`, and a wide `Home W`. There is
|
||||
intentionally no separate "set zero" or "W origin" button - homing
|
||||
lands the axis at `home_position_mm` (0 by default), so home and
|
||||
zero are the same point.
|
||||
- The DRO table shows a A axis row with position, status (OFFLINE /
|
||||
- The DRO table shows a W axis row with position, status (OFFLINE /
|
||||
UNHOMED / HOMED), and a single Home button in the actions column
|
||||
(the cog and map-marker columns are placeholders for layout).
|
||||
|
||||
**Settings view**
|
||||
|
||||
A "W Axis (auxcnc)" section exposes every aux.json field except
|
||||
`enabled` (which stays read-only - flipping the A axis on/off requires
|
||||
`enabled` (which stays read-only - flipping the W axis on/off requires
|
||||
editingaux.json on the controller, so a fresh install can't surprise
|
||||
the user with hardware that isn't there). Saving PUTs the merged
|
||||
config to `/api/aux/config/save`, which writes aux.json and pushes
|
||||
@@ -147,19 +135,19 @@ These are pushed via `state.set` and visible in the websocket stream:
|
||||
## Edge cases
|
||||
|
||||
- **ESP reboots mid-job**: `[boot] auxcnc v=N` banner -> `aux_homed`
|
||||
cleared, message added: "A axis controller restarted - re-home
|
||||
before use". Subsequent A moves still run; if you want a hard fail
|
||||
cleared, message added: "W axis controller restarted - re-home
|
||||
before use". Subsequent W moves still run; if you want a hard fail
|
||||
instead, that's a one-line change in `_require_present`.
|
||||
- **Limit switch closed at boot of HOME**: `[home] failed
|
||||
reason=already_at_limit` -> hook raises -> Mach surfaces error.
|
||||
- **Pause mid-W-move**: the hook is blocking, so feed-hold takes
|
||||
effect *after* the A move completes. For an immediate stop hit
|
||||
effect *after* the W move completes. For an immediate stop hit
|
||||
estop; the Hooks listener will call `aux.abort()` which sends
|
||||
`ABORT\n` to the ESP and the step-pulse loop exits.
|
||||
- **Connection loss**: if `/dev/ttyUSB0` can't be opened at startup,
|
||||
`aux_present=False` and any G-code with W will fail-fast at the
|
||||
hook handler with "Aux axis not connected".
|
||||
- **No home enforcement**: per design, manual jogs and A moves are
|
||||
- **No home enforcement**: per design, manual jogs and W moves are
|
||||
allowed even without a successful home. Soft limits still apply
|
||||
unless you use the raw step jog endpoint.
|
||||
|
||||
900
docs/mocks/v09_full_ux.html
Normal file
900
docs/mocks/v09_full_ux.html
Normal 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></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">0–4</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>
|
||||
169
plans/2026-04-30_ux_redesign.md
Normal file
169
plans/2026-04-30_ux_redesign.md
Normal 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 (1–2 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 1–8 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 6–7 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.5–1 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.
|
||||
@@ -1,24 +1,13 @@
|
||||
[Unit]
|
||||
Description=Buildbotics Controller
|
||||
# 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
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
User=root
|
||||
ExecStart=/usr/local/bin/bbctrl -l /var/log/bbctrl.log
|
||||
WorkingDirectory=/var/lib/bbctrl
|
||||
Restart=always
|
||||
# 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
|
||||
StandardOutput=null
|
||||
Nice=-10
|
||||
KillMode=process
|
||||
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
[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
|
||||
@@ -1,84 +0,0 @@
|
||||
#!/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
|
||||
# REMOTE_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="${REMOTE_USER:-bbmc}"
|
||||
PASSWORD="${PASSWORD:-onefinity}"
|
||||
|
||||
echo "Building UI bundle (HTML + resources)..."
|
||||
make build/http/index.html >/dev/null
|
||||
# Copy src/resources/* into build/http/. The Makefile's "all" target
|
||||
# also does this, but pulls in cross-compiled subprojects (avr/boot/
|
||||
# pwr/jig) we don't have toolchains for on macOS. This rsync mirrors
|
||||
# only the resource tree.
|
||||
rsync -a src/resources/ build/http/
|
||||
|
||||
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 "ERROR: 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-rsync into
|
||||
# place. This avoids needing root over rsync. We do NOT use --delete
|
||||
# anywhere -- the Pi's egg ships extra runtime files (config-template
|
||||
# .json, default machine JSON, buildbotics.nc, etc.) that come with
|
||||
# the bbctrl package and are not in this repo's src/resources. If
|
||||
# they were deleted the controller's API would 500 because Python
|
||||
# imports fail.
|
||||
REMOTE_TMP="/tmp/onefin_ui_$$"
|
||||
ssh -o ConnectTimeout=5 "${REMOTE_USER}@${HOST}" "mkdir -p '${REMOTE_TMP}'"
|
||||
rsync -avz \
|
||||
--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 --exclude=hostinfo.txt \"${REMOTE_TMP}/\" \"${REMOTE_HTTP_DIR}/\" \
|
||||
&& rm -rf \"${REMOTE_TMP}\"
|
||||
'" 2>&1 | tail -3
|
||||
|
||||
# Patch bbctrl Web.py so font files get the correct MIME type. The
|
||||
# Pi ships Python 3.5, whose `mimetypes` module doesn't know about
|
||||
# woff/woff2/ttf, so Tornado serves them as application/octet-stream
|
||||
# which Chromium 72 (the Pi's onboard browser) refuses to use as a
|
||||
# web font, leading to all FontAwesome icons rendering as empty
|
||||
# boxes in the kiosk UI. The patch is idempotent.
|
||||
echo "Patching bbctrl font MIME types (idempotent)..."
|
||||
scp -o ConnectTimeout=5 "$SCRIPT_DIR/scripts/deploy/patch_font_mime.py" \
|
||||
"${REMOTE_USER}@${HOST}:/tmp/patch_font_mime.py" >/dev/null
|
||||
ssh -o ConnectTimeout=5 "${REMOTE_USER}@${HOST}" \
|
||||
"echo '${PASSWORD}' | sudo -S python3 /tmp/patch_font_mime.py" 2>&1 | tail -3
|
||||
|
||||
echo "Restarting bbctrl service..."
|
||||
ssh -o ConnectTimeout=5 "${REMOTE_USER}@${HOST}" \
|
||||
"echo '${PASSWORD}' | sudo -S systemctl restart bbctrl" 2>&1 | tail -3
|
||||
|
||||
echo ""
|
||||
echo "Deployed to http://${HOST}/"
|
||||
echo " Logs: ssh ${REMOTE_USER}@${HOST} 'journalctl -u bbctrl -f'"
|
||||
echo " Open: open -a 'Google Chrome' http://${HOST}/"
|
||||
@@ -1,75 +0,0 @@
|
||||
#!/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 A axis row in jog/DRO is hidden (correct: it appears only when
|
||||
# the controller reports `aux_enabled = true`). To exercise the A
|
||||
# axis end-to-end, deploy to the Pi (`./deploy.sh hardware`).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
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)"
|
||||
@@ -1,102 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Patch bbctrl Web.py so font files get the correct MIME type.
|
||||
|
||||
Background
|
||||
----------
|
||||
The Onefinity controller (Pi 3B running Raspbian stretch) ships Python
|
||||
3.5, whose ``mimetypes`` module does not recognize ``.woff``, ``.woff2``
|
||||
or ``.ttf``. Tornado's ``StaticFileHandler`` therefore falls back to
|
||||
``application/octet-stream`` for those, and Chromium 72 (the Pi's
|
||||
onboard kiosk browser) refuses to use such payloads as web fonts. The
|
||||
result is that every FontAwesome icon renders as an empty box on the
|
||||
kiosk display.
|
||||
|
||||
This patch monkey-patches ``StaticFileHandler.get_content_type`` to
|
||||
emit the right MIME types. It is idempotent: running it twice is a
|
||||
no-op. Run with ``sudo`` so it can rewrite the egg's Web.py.
|
||||
|
||||
Used by:
|
||||
scripts/deploy/hardware.sh
|
||||
"""
|
||||
|
||||
from __future__ import print_function
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def find_web_py():
|
||||
"""Return the absolute path to the bbctrl Web.py shipped in the egg."""
|
||||
base = "/usr/local/lib"
|
||||
for entry in os.listdir(base):
|
||||
if not entry.startswith("python"):
|
||||
continue
|
||||
candidate_dir = os.path.join(base, entry, "dist-packages")
|
||||
if not os.path.isdir(candidate_dir):
|
||||
continue
|
||||
for sub in os.listdir(candidate_dir):
|
||||
if sub.startswith("bbctrl-") and sub.endswith(".egg"):
|
||||
p = os.path.join(candidate_dir, sub, "bbctrl", "Web.py")
|
||||
if os.path.isfile(p):
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
OLD_BLOCK = (
|
||||
"class StaticFileHandler(tornado.web.StaticFileHandler):\n"
|
||||
" def set_extra_headers(self, path):\n"
|
||||
" self.set_header('Cache-Control',\n"
|
||||
" 'no-store, no-cache, must-revalidate, max-age=0')"
|
||||
)
|
||||
|
||||
NEW_BLOCK = (
|
||||
"class StaticFileHandler(tornado.web.StaticFileHandler):\n"
|
||||
" # FONT_MIME_FIX: Python 3.5's mimetypes module does not know\n"
|
||||
" # woff/woff2/ttf, so Tornado serves them as application/octet-\n"
|
||||
" # stream which Chromium 72 (the Pi's onboard kiosk browser)\n"
|
||||
" # refuses to use as web fonts. Set explicit types so the FA6\n"
|
||||
" # icon set actually renders on the kiosk display.\n"
|
||||
" def get_content_type(self):\n"
|
||||
" path = self.absolute_path or ''\n"
|
||||
" if path.endswith('.woff2'): return 'font/woff2'\n"
|
||||
" if path.endswith('.woff'): return 'font/woff'\n"
|
||||
" if path.endswith('.ttf'): return 'font/ttf'\n"
|
||||
" if path.endswith('.otf'): return 'font/otf'\n"
|
||||
" if path.endswith('.eot'): return 'application/vnd.ms-fontobject'\n"
|
||||
" return super().get_content_type()\n"
|
||||
"\n"
|
||||
" def set_extra_headers(self, path):\n"
|
||||
" self.set_header('Cache-Control',\n"
|
||||
" 'no-store, no-cache, must-revalidate, max-age=0')"
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
target = find_web_py()
|
||||
if target is None:
|
||||
print("ERROR: could not locate bbctrl Web.py under /usr/local/lib",
|
||||
file=sys.stderr)
|
||||
return 1
|
||||
|
||||
with open(target) as f:
|
||||
src = f.read()
|
||||
|
||||
if "FONT_MIME_FIX" in src:
|
||||
print("font mime: already patched ({})".format(target))
|
||||
return 0
|
||||
|
||||
if OLD_BLOCK not in src:
|
||||
print("font mime: expected block not found in {} -- skipping".format(target),
|
||||
file=sys.stderr)
|
||||
# Don't fail the deploy; just log and continue.
|
||||
return 0
|
||||
|
||||
new_src = src.replace(OLD_BLOCK, NEW_BLOCK, 1)
|
||||
with open(target, "w") as f:
|
||||
f.write(new_src)
|
||||
print("font mime: patched {}".format(target))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,42 +0,0 @@
|
||||
#!/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}/"
|
||||
@@ -19,17 +19,8 @@ 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
|
||||
@@ -127,50 +118,8 @@ if [ $? -ne 0 ]; then
|
||||
REBOOT=true
|
||||
fi
|
||||
|
||||
# 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
|
||||
# Install rc.local
|
||||
cp scripts/rc.local /etc/
|
||||
|
||||
# Ensure that the watchdog python library is installed
|
||||
pip3 list --format=columns | grep watchdog >/dev/null
|
||||
|
||||
@@ -28,4 +28,4 @@ plymouth quit
|
||||
|
||||
# Start X in /home/pi
|
||||
cd /home/pi
|
||||
sudo -u pi startx -- -nocursor
|
||||
sudo -u pi startx
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
#!/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
|
||||
# `-- -nocursor` hides the X pointer; this is a touchscreen kiosk and
|
||||
# the mouse cursor only gets in the way.
|
||||
nohup sudo -u pi startx -- -nocursor >/var/log/onefin-x.log 2>&1 &
|
||||
disown
|
||||
|
||||
exit 0
|
||||
@@ -75,7 +75,7 @@ sed -i 's/^PARTUUID=.*\//\/dev\/mmcblk0p2 \//' /etc/fstab
|
||||
|
||||
# Enable browser in xorg
|
||||
sed -i 's/allowed_users=console/allowed_users=anybody/' /etc/X11/Xwrapper.config
|
||||
echo "sudo -u pi startx -- -nocursor" >> /etc/rc.local
|
||||
echo "sudo -u pi startx" >> /etc/rc.local
|
||||
cp /mnt/host/xinitrc /home/pi/.xinitrc
|
||||
cp /mnt/host/ratpoisonrc /home/pi/.ratpoisonrc
|
||||
cp /mnt/host/xorg.conf /etc/X11/
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
// V09 A-axis page — mounts the AAxisSettings Svelte component
|
||||
// inside the settings shell so it gets a real top-level rail entry
|
||||
// instead of being a soft-link anchor inside Display & Units.
|
||||
|
||||
module.exports = {
|
||||
template: "#a-axis-view-template",
|
||||
|
||||
attached: function () {
|
||||
this.svelteComponent = SvelteComponents.createComponent(
|
||||
"AAxisSettings",
|
||||
document.getElementById("a-axis-mount")
|
||||
);
|
||||
},
|
||||
|
||||
detached: function () {
|
||||
if (this.svelteComponent) this.svelteComponent.$destroy();
|
||||
},
|
||||
};
|
||||
@@ -4,7 +4,6 @@ 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(
|
||||
@@ -251,19 +250,6 @@ module.exports = new Vue({
|
||||
},
|
||||
|
||||
computed: {
|
||||
// True when the UI is in kiosk mode — i.e. running on the
|
||||
// controller's own onboard browser (Pi 3B at 1366x768) or
|
||||
// explicitly forced via ?kiosk=1. Source-of-truth is the
|
||||
// `kiosk-mode` class added to <html> by the inline script
|
||||
// in index.pug, which already honors hostname + URL param +
|
||||
// localStorage. The Pi's VideoCore IV is too slow for the
|
||||
// three.js toolpath preview, so we suppress that panel in
|
||||
// kiosk mode and let the gcode listing take the full width.
|
||||
is_kiosk: function() {
|
||||
return typeof document !== "undefined"
|
||||
&& document.documentElement.classList.contains("kiosk-mode");
|
||||
},
|
||||
|
||||
popupMessages: function() {
|
||||
const msgs = [];
|
||||
|
||||
@@ -369,36 +355,11 @@ module.exports = new Vue({
|
||||
ready: function() {
|
||||
window.onhashchange = () => this.parse_hash();
|
||||
|
||||
// Embedded Svelte subviews (A axis settings, etc.) signal
|
||||
// unsaved changes via this event. The master Save button
|
||||
// highlights when modified is true.
|
||||
window.addEventListener("onefin:dirty", () => {
|
||||
this.modified = true;
|
||||
});
|
||||
|
||||
|
||||
|
||||
// Resolve the initial route before the websocket connects so
|
||||
// the shell shows the right view even on a slow / offline
|
||||
// controller. update() will call parse_hash() again once the
|
||||
// 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", "probing", "gcode",
|
||||
"admin-general", "admin-network",
|
||||
"motor", "tool", "io", "macros",
|
||||
"help", "cheat-sheet",
|
||||
"a-axis",
|
||||
];
|
||||
const initialHead = (location.hash || "").replace(/^#/, "").split(":")[0];
|
||||
if (settingsFamily.indexOf(initialHead) === -1) {
|
||||
this.parse_hash();
|
||||
}
|
||||
// else: stay on "loading" until update() completes and calls
|
||||
// parse_hash() itself.
|
||||
// first config is in.
|
||||
this.parse_hash();
|
||||
|
||||
this.connect();
|
||||
|
||||
@@ -488,12 +449,6 @@ module.exports = new Vue({
|
||||
toggle_rotary: async function(isActive) {
|
||||
try {
|
||||
await api.put("rotary", {status: isActive});
|
||||
// The /api/rotary endpoint rewrites motors[1]/[2]
|
||||
// in config.json on the server. Refetch so the UI
|
||||
// reflects the new motor config (otherwise the
|
||||
// motor settings page keeps showing pre-toggle
|
||||
// values until the next page reload).
|
||||
await this.update();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert("Error occured");
|
||||
@@ -528,19 +483,11 @@ 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);
|
||||
|
||||
@@ -550,11 +497,6 @@ 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") {
|
||||
@@ -579,7 +521,6 @@ module.exports = new Vue({
|
||||
|
||||
this.sock.onopen = () => {
|
||||
this.status = "connected";
|
||||
restartTiming.onWsOpen();
|
||||
this.$emit(this.status);
|
||||
this.$broadcast(this.status);
|
||||
};
|
||||
@@ -623,11 +564,9 @@ module.exports = new Vue({
|
||||
// Settings tab while keeping their existing top-level
|
||||
// hash. This preserves all existing deep links.
|
||||
const settingsViews = [
|
||||
"settings", "probing", "gcode",
|
||||
"admin-general", "admin-network",
|
||||
"settings", "admin-general", "admin-network",
|
||||
"motor", "tool", "io", "macros",
|
||||
"help", "cheat-sheet",
|
||||
"a-axis",
|
||||
];
|
||||
|
||||
if (head == "control") {
|
||||
@@ -689,13 +628,6 @@ module.exports = new Vue({
|
||||
|
||||
try {
|
||||
await api.put("config/save", this.config);
|
||||
// Notify any embedded Svelte subviews that own their
|
||||
// own persistence (A axis -> aux.json, etc.) that
|
||||
// the user just hit the master Save button. They
|
||||
// listen for `onefin:save-all` and PUT their state.
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent("onefin:save-all"));
|
||||
} catch (_e) {}
|
||||
this.modified = false;
|
||||
} catch (error) {
|
||||
console.error("Save failed:", error);
|
||||
|
||||
@@ -56,12 +56,7 @@ module.exports = {
|
||||
const abs = this.state[`${axis}p`] || 0;
|
||||
const off = this.state[`offset_${axis}`];
|
||||
const motor_id = this._get_motor_id(axis);
|
||||
// motor_id may be 4 for the synthetic external-axis motor;
|
||||
// there is no entry for it in config.motors so guard with
|
||||
// an empty object to avoid undefined property access.
|
||||
const motor = (motor_id == -1
|
||||
? {}
|
||||
: (this.config.motors[motor_id] || {}));
|
||||
const motor = motor_id == -1 ? {} : this.config.motors[motor_id];
|
||||
const enabled = this._check_is_enabled(axis);
|
||||
const homingMode = motor["homing-mode"];
|
||||
const homed = this.state[`${motor_id}homed`];
|
||||
@@ -194,61 +189,18 @@ module.exports = {
|
||||
_get_motor_id: function(axis) {
|
||||
for (let i = 0; i < this.config.motors.length; i++) {
|
||||
const motor = this.config.motors[i];
|
||||
// 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) {
|
||||
if (motor.axis.toLowerCase() == axis) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
// Synthetic external motor (index 4) used by ExternalAxis
|
||||
// to expose the auxcnc ESP stepper as a virtual axis.
|
||||
// Its `Nan` lives in state, not config.
|
||||
const axes = { x: 0, y: 1, z: 2, a: 3, b: 4, c: 5 };
|
||||
const wanted = axes[axis];
|
||||
const extAn = this.state && this.state["4an"];
|
||||
if (typeof wanted === "number" && typeof extAn === "number"
|
||||
&& extAn === wanted) {
|
||||
return 4;
|
||||
}
|
||||
|
||||
return -1;
|
||||
},
|
||||
|
||||
_check_is_enabled: function(axis){
|
||||
// Prefer config.motors[i].axis (always present once the
|
||||
// config has loaded). Fall back to the per-motor state
|
||||
// `Nan` field, which is what the legacy UI used. This
|
||||
// avoids hiding axis rows during the brief window after
|
||||
// config has loaded but before the controller has pushed
|
||||
// its first state delta.
|
||||
const axes = { x: 0, y: 1, z: 2, a: 3 };
|
||||
const 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Synthetic external motor (index 4) - the auxcnc ESP
|
||||
// stepper exposed as A via ExternalAxis.
|
||||
if (typeof wanted === "number") {
|
||||
const extAn = this.state["4an"];
|
||||
const extMe = this.state["4me"];
|
||||
if (typeof extAn === "number" && extAn === wanted
|
||||
&& extMe) {
|
||||
for(let i = 0; i < this.config.motors.length; i++){
|
||||
if(this.state[`${i}an`] == axes[axis]){
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -256,11 +208,10 @@ module.exports = {
|
||||
},
|
||||
|
||||
_compute_aux_axis: function() {
|
||||
// Auxiliary axis driven by the auxcnc ESP32 (typically
|
||||
// exposed to gplan as A). Position, homed flag and
|
||||
// presence come from the bbctrl AuxAxis driver via
|
||||
// state.aux_*. No motor mapping, no soft-limit warnings
|
||||
// on toolpath bounds (auxcnc enforces its own).
|
||||
// Virtual W axis driven by the auxcnc ESP32. Position, homed
|
||||
// flag and presence come from the bbctrl AuxAxis driver via
|
||||
// state.aux_*. No motor mapping, no soft-limit warnings on
|
||||
// toolpath bounds (auxcnc enforces its own).
|
||||
const enabled = !!this.state.aux_enabled;
|
||||
const present = !!this.state.aux_present;
|
||||
const homed = !!this.state.aux_homed;
|
||||
@@ -270,12 +221,12 @@ module.exports = {
|
||||
let state = present ? "UNHOMED" : "OFFLINE";
|
||||
let icon = present ? "question-circle" : "plug";
|
||||
let title = present
|
||||
? "Click the home button to home the auxiliary axis."
|
||||
? "Click the home button to home W axis."
|
||||
: "Aux controller not connected on /dev/ttyUSB0.";
|
||||
if (homed) {
|
||||
state = "HOMED";
|
||||
icon = "check-circle";
|
||||
title = "Auxiliary axis successfully homed.";
|
||||
title = "W axis successfully homed.";
|
||||
} else if (!present) {
|
||||
klass += " error";
|
||||
}
|
||||
@@ -296,7 +247,7 @@ module.exports = {
|
||||
title: title,
|
||||
ticon: "check-circle",
|
||||
tstate: "OK",
|
||||
toolmsg: "Auxiliary axis is not constrained by tool path bounds.",
|
||||
toolmsg: "W axis is not constrained by tool path bounds.",
|
||||
tklass: `${homed ? "homed" : "unhomed"} axis-w`,
|
||||
isAux: true,
|
||||
};
|
||||
|
||||
@@ -186,33 +186,6 @@ module.exports = {
|
||||
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];
|
||||
|
||||
@@ -251,74 +224,13 @@ module.exports = {
|
||||
|
||||
aux_home: function () {
|
||||
api.put("aux/home").catch(function (err) {
|
||||
console.error("Aux home failed:", err);
|
||||
});
|
||||
},
|
||||
|
||||
// Home every enabled axis (legacy Onefinity "Home All"). Sequence:
|
||||
// 1. Z, X, Y (and A/B/C if enabled) via /api/home on the AVR
|
||||
// 2. Auxiliary axis via /api/aux/home on the ESP
|
||||
// ONLY when the auxcnc axis is not integrated as a virtual
|
||||
// machine axis. With the gplan A-axis integration (synthetic
|
||||
// motor 4 enabled), Mach.home() already homes the external
|
||||
// axis as part of the xyzabc pass - calling aux/home
|
||||
// afterwards would home it a second time.
|
||||
// /api/home returns as soon as the request is queued, not when
|
||||
// homing completes, so we have to watch state.cycle:
|
||||
// - first wait for it to *leave* 'idle' (cycle began),
|
||||
// - then wait for it to come *back* to 'idle' (cycle ended).
|
||||
// Only then do we fire the auxiliary home, so the gantry and the
|
||||
// auxcnc ESP never move at the same time.
|
||||
home_all: async function () {
|
||||
this.ask_home = false;
|
||||
try {
|
||||
await api.put("home");
|
||||
} catch (e) {
|
||||
console.error("Home all (XYZ) failed:", e);
|
||||
return;
|
||||
}
|
||||
if (!this.w || !this.w.enabled) return;
|
||||
|
||||
// When the synthetic external motor (index 4) is enabled,
|
||||
// the auxcnc axis is mapped onto a real machine axis letter
|
||||
// (e.g. A) and was already homed by /api/home above.
|
||||
if (this.state && this.state["4me"]) return;
|
||||
|
||||
const wait = (ms) => new Promise(r => setTimeout(r, ms));
|
||||
const cycleNow = () => (this.state && this.state.cycle) || "idle";
|
||||
|
||||
// Phase 1: wait up to 5s for the homing cycle to actually start.
|
||||
// If the request was rejected upstream (e.g. estopped) cycle
|
||||
// never leaves idle and we bail rather than home A in isolation.
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < 5000) {
|
||||
if (cycleNow() != "idle") break;
|
||||
await wait(100);
|
||||
}
|
||||
if (cycleNow() == "idle") {
|
||||
console.warn("home_all: main homing cycle never started; skipping aux");
|
||||
return;
|
||||
}
|
||||
|
||||
// Phase 2: wait up to 2 minutes for the gantry to finish.
|
||||
const settledAt = Date.now();
|
||||
while (Date.now() - settledAt < 120000) {
|
||||
if (cycleNow() == "idle") break;
|
||||
await wait(200);
|
||||
}
|
||||
if (cycleNow() != "idle") {
|
||||
console.warn("home_all: gantry homing did not complete in time");
|
||||
return;
|
||||
}
|
||||
|
||||
api.put("aux/home").catch(function (err) {
|
||||
console.error("Aux home failed:", err);
|
||||
console.error("W home failed:", err);
|
||||
});
|
||||
},
|
||||
|
||||
aux_jog: function (delta_mm) {
|
||||
api.put("aux/jog", { mm: delta_mm }).catch(function (err) {
|
||||
console.error("Aux jog failed:", err);
|
||||
console.error("W jog failed:", err);
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -49,17 +49,14 @@ module.exports = {
|
||||
methods: {
|
||||
get_io_state_class: function(active, state) {
|
||||
if (typeof active == "undefined" || typeof state == "undefined") {
|
||||
return "fa-triangle-exclamation warn";
|
||||
return "fa-exclamation-triangle warn";
|
||||
}
|
||||
|
||||
// Tristated: render as the regular (outline) circle to
|
||||
// distinguish from active/inactive solid circles. Adding
|
||||
// `far` switches to the FA6 regular family.
|
||||
if (state == 2) {
|
||||
return "far fa-circle";
|
||||
return "fa-circle-o";
|
||||
}
|
||||
|
||||
const icon = state ? "fa-circle-plus" : "fa-circle-minus";
|
||||
const icon = state ? "fa-plus-circle" : "fa-minus-circle";
|
||||
return `${icon} ${active ? "active" : "inactive"}`;
|
||||
},
|
||||
|
||||
|
||||
@@ -87,16 +87,100 @@ module.exports = {
|
||||
return this.stallRPM * this.stepsPerRev * ustep / 60;
|
||||
},
|
||||
|
||||
// NOTE: do not add `current_xxx` computed props that mirror
|
||||
// controller state vars (`<idx>vm`, `<idx>am`, …) and pair
|
||||
// them with watchers that copy state -> motor config. The
|
||||
// controller streams those vars continuously over the WS;
|
||||
// any watcher that writes them back into
|
||||
// `config.motors[index]` will clobber whatever the user is
|
||||
// typing into the form between websocket ticks. The form
|
||||
// edits config directly; Save (app.js) PUTs it to the
|
||||
// server. The server-side rotary toggle is handled by
|
||||
// refetching config after the PUT, not by watching state.
|
||||
current_axis: function() {
|
||||
return this.state[this.index + 'an'];
|
||||
},
|
||||
|
||||
current_max_velocity: function() {
|
||||
return this.state[this.index + 'vm'];
|
||||
},
|
||||
|
||||
current_max_soft_limit: function() {
|
||||
return this.state[this.index + 'tm'];
|
||||
},
|
||||
|
||||
current_min_soft_limit: function() {
|
||||
return this.state[this.index + 'tn'];
|
||||
},
|
||||
current_max_accel: function() {
|
||||
return this.state[this.index + 'am'];
|
||||
},
|
||||
current_max_jerk: function() {
|
||||
return this.state[this.index + 'jm'];
|
||||
},
|
||||
current_step_angle: function() {
|
||||
return this.state[this.index + 'sa'];
|
||||
},
|
||||
current_travel_per_rev: function() {
|
||||
return this.state[this.index + 'tr'];
|
||||
},
|
||||
current_microsteps: function() {
|
||||
return this.state[this.index + 'mi'];
|
||||
}
|
||||
},
|
||||
|
||||
attached: function() {
|
||||
// Sync all state values with motor config when component is ready
|
||||
// This ensures UI shows correct values when component is first loaded
|
||||
console.log("Syncing state to motor config for motor index ",this.index);
|
||||
this.syncStateToConfig();
|
||||
},
|
||||
|
||||
watch: {
|
||||
current_axis(new_value) {
|
||||
const motor_axes = ["X", "Y", "Z", "A", "B", "C"]
|
||||
if(motor_axes[new_value] != this.motor['axis']){
|
||||
this.motor['axis'] = motor_axes[new_value];
|
||||
}
|
||||
},
|
||||
|
||||
current_max_velocity(new_value) {
|
||||
if(new_value != this.motor['max-velocity']) {
|
||||
this.motor['max-velocity'] = new_value;
|
||||
}
|
||||
},
|
||||
|
||||
current_max_soft_limit(new_value) {
|
||||
if(new_value != this.motor['max-soft-limit']) {
|
||||
this.motor['max-soft-limit'] = new_value;
|
||||
}
|
||||
},
|
||||
|
||||
current_min_soft_limit(new_value) {
|
||||
if(new_value != this.motor['min-soft-limit']) {
|
||||
this.motor['min-soft-limit'] = new_value;
|
||||
}
|
||||
},
|
||||
|
||||
current_max_accel(new_value) {
|
||||
if(new_value != this.motor['max-accel']) {
|
||||
this.motor['max-accel'] = new_value;
|
||||
}
|
||||
},
|
||||
|
||||
current_max_jerk(new_value) {
|
||||
if(new_value != this.motor['max-jerk']) {
|
||||
this.motor['max-jerk'] = new_value;
|
||||
}
|
||||
},
|
||||
|
||||
current_step_angle(new_value) {
|
||||
if(new_value != this.motor['step-angle']) {
|
||||
this.motor['step-angle'] = new_value;
|
||||
}
|
||||
},
|
||||
|
||||
current_travel_per_rev(new_value) {
|
||||
if(new_value != this.motor['travel-per-rev']) {
|
||||
this.motor['travel-per-rev'] = new_value;
|
||||
}
|
||||
},
|
||||
|
||||
current_microsteps(new_value) {
|
||||
if(new_value != this.motor['microsteps']) {
|
||||
this.motor['microsteps'] = new_value;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
events: {
|
||||
@@ -126,6 +210,45 @@ module.exports = {
|
||||
}
|
||||
|
||||
return templ.hmodes.indexOf(this.motor["homing-mode"]) != -1;
|
||||
},
|
||||
|
||||
syncStateToConfig: function() {
|
||||
// Force sync all state values to motor config
|
||||
// This ensures the UI reflects the current state even if changes happened while component was unmounted
|
||||
|
||||
if(this.state == undefined) {
|
||||
console.log("State is undefined");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.state[this.index + 'an'] != this.motor['axis']) {
|
||||
const motor_axes = ["X", "Y", "Z", "A", "B", "C"];
|
||||
this.$set('motor["axis"]', motor_axes[this.state[this.index + 'an']]);
|
||||
}
|
||||
if (this.state[this.index + 'vm'] != this.motor['max-velocity']) {
|
||||
this.$set('motor["max-velocity"]', this.state[this.index + 'vm']);
|
||||
}
|
||||
if (this.state[this.index + 'tm'] != this.motor['max-soft-limit']) {
|
||||
this.$set('motor["max-soft-limit"]', this.state[this.index + 'tm']);
|
||||
}
|
||||
if (this.state[this.index + 'tn'] != this.motor['min-soft-limit']) {
|
||||
this.$set('motor["min-soft-limit"]', this.state[this.index + 'tn']);
|
||||
}
|
||||
if (this.state[this.index + 'am'] != this.motor['max-accel']) {
|
||||
this.$set('motor["max-accel"]', this.state[this.index + 'am']);
|
||||
}
|
||||
if (this.state[this.index + 'jm'] != this.motor['max-jerk']) {
|
||||
this.$set('motor["max-jerk"]', this.state[this.index + 'jm']);
|
||||
}
|
||||
if (this.state[this.index + 'sa'] != this.motor['step-angle']) {
|
||||
this.$set('motor["step-angle"]', this.state[this.index + 'sa']);
|
||||
}
|
||||
if (this.state[this.index + 'tr'] != this.motor['travel-per-rev']) {
|
||||
this.$set('motor["travel-per-rev"]', this.state[this.index + 'tr']);
|
||||
}
|
||||
if (this.state[this.index + 'mi'] != this.motor['microsteps']) {
|
||||
this.$set('motor["microsteps"]', this.state[this.index + 'mi']);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -683,16 +683,12 @@ 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, { passive: false });
|
||||
scope.domElement.addEventListener("touchstart", onTouchStart, { passive: false });
|
||||
scope.domElement.addEventListener("wheel", onMouseWheel, false);
|
||||
scope.domElement.addEventListener("touchstart", onTouchStart, false);
|
||||
scope.domElement.addEventListener("touchend", onTouchEnd, false);
|
||||
scope.domElement.addEventListener("touchmove", onTouchMove, { passive: false });
|
||||
scope.domElement.addEventListener("touchmove", onTouchMove, false);
|
||||
window.addEventListener("keydown", onKeyDown, false);
|
||||
|
||||
this.update(); // force an update at start
|
||||
|
||||
@@ -101,13 +101,6 @@ module.exports = {
|
||||
Vue.nextTick(this.update);
|
||||
},
|
||||
|
||||
beforeDestroy: function() {
|
||||
if (this._sizeWatcher) {
|
||||
this._sizeWatcher.disconnect();
|
||||
this._sizeWatcher = null;
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
update: async function() {
|
||||
if (!this.webglAvailable) {
|
||||
@@ -208,12 +201,6 @@ module.exports = {
|
||||
}
|
||||
|
||||
const dims = this.get_dims();
|
||||
// Skip layouts where the target has no measurable size.
|
||||
// The render loop guard below will not draw frames until
|
||||
// a real size has been observed at least once.
|
||||
if (!(dims.width > 0 && dims.height > 0)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.camera.aspect = dims.width / dims.height;
|
||||
this.camera.updateProjectionMatrix();
|
||||
@@ -287,23 +274,12 @@ module.exports = {
|
||||
}
|
||||
|
||||
try {
|
||||
// Renderer. Use an opaque canvas with a clear color
|
||||
// that matches the page-side gradient so the moment
|
||||
// the canvas is appended (and before the first 3D
|
||||
// frame is drawn) the user does not see a flash from
|
||||
// the page background through transparency.
|
||||
this.renderer = new THREE.WebGLRenderer({
|
||||
antialias: true,
|
||||
alpha: false,
|
||||
});
|
||||
// Renderer
|
||||
this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
||||
this.renderer.setPixelRatio(window.devicePixelRatio);
|
||||
this.renderer.setClearColor(0x222222, 1);
|
||||
// Same color on the DOM element itself so the very
|
||||
// first paint (before the WebGL context has cleared)
|
||||
// is dark too.
|
||||
this.renderer.domElement.style.background = "#222222";
|
||||
this.renderer.domElement.style.display = "block";
|
||||
this.renderer.setClearColor(0, 0);
|
||||
this.target.appendChild(this.renderer.domElement);
|
||||
|
||||
} catch (e) {
|
||||
console.log("WebGL not supported: ", e);
|
||||
return;
|
||||
@@ -357,46 +333,8 @@ module.exports = {
|
||||
// Events
|
||||
window.addEventListener("resize", this.update_view, false);
|
||||
|
||||
// Start the render loop only after the target has a real,
|
||||
// stable size. Without this, the first frame paints into
|
||||
// a 0×0 / collapsed-flex canvas and a second frame paints
|
||||
// again at the right size — visible as a flash on the
|
||||
// very first mount of the Program tab.
|
||||
const startRendering = () => {
|
||||
if (this._rendering) return;
|
||||
this._rendering = true;
|
||||
this.update_view();
|
||||
this.render();
|
||||
};
|
||||
|
||||
const dims = this.get_dims();
|
||||
if (dims.width > 0 && dims.height > 0) {
|
||||
startRendering();
|
||||
} else if (typeof ResizeObserver !== "undefined") {
|
||||
this._sizeWatcher = new ResizeObserver(entries => {
|
||||
for (const entry of entries) {
|
||||
const r = entry.contentRect;
|
||||
if (r.width > 0 && r.height > 0) {
|
||||
this._sizeWatcher.disconnect();
|
||||
this._sizeWatcher = null;
|
||||
startRendering();
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
this._sizeWatcher.observe(this.target);
|
||||
} else {
|
||||
// Old browser fallback: poll for a non-zero size.
|
||||
const tick = () => {
|
||||
const d = this.get_dims();
|
||||
if (d.width > 0 && d.height > 0) {
|
||||
startRendering();
|
||||
} else {
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
// Start it
|
||||
this.render();
|
||||
},
|
||||
|
||||
create_surface_material: function() {
|
||||
@@ -708,14 +646,6 @@ module.exports = {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't paint frames while the target has no size; this
|
||||
// prevents an initial single-frame clear from painting
|
||||
// before the layout has settled (visible as a dark flash).
|
||||
const dims = this.get_dims();
|
||||
if (!(dims.width > 0 && dims.height > 0)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.controls.update() || this.dirty) {
|
||||
this.dirty = false;
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
|
||||
@@ -77,32 +77,6 @@ module.exports = {
|
||||
return this.state.cycle == "idle";
|
||||
},
|
||||
|
||||
// True only while a loaded G-code program is actually being
|
||||
// executed (running, paused/holding, or stopping). Excludes
|
||||
// jogging, homing, probing, MDI commands and other one-off
|
||||
// motion that also leave state.xx == "RUNNING" but must not
|
||||
// swap the jog grid out for the "Now Running" panel.
|
||||
//
|
||||
// Distinguishing signal is state.cycle:
|
||||
// - "idle" : nothing happening
|
||||
// - "jogging" : user-initiated jog
|
||||
// - "homing" : home cycle
|
||||
// - "probing" : probe cycle
|
||||
// - "mdi" : single MDI command
|
||||
// - "running" : an actual loaded program is being run
|
||||
// Only "running" (combined with a selected file) is what we want.
|
||||
is_program_executing: function () {
|
||||
if (!this.state) return false;
|
||||
const xx = this.state.xx;
|
||||
const cycle = this.state.cycle;
|
||||
const isExecState = xx == "RUNNING" || xx == "HOLDING" || xx == "STOPPING";
|
||||
if (!isExecState) return false;
|
||||
// The cycle string narrows it to a real program run; anything
|
||||
// else (jogging / homing / probing / mdi) is a one-off.
|
||||
if (cycle && cycle != "running" && cycle != "idle") return false;
|
||||
return !!this.state.selected;
|
||||
},
|
||||
|
||||
is_paused: function () {
|
||||
return this.is_holding && (this.pause_reason == "User pause" || this.pause_reason == "Program pause");
|
||||
},
|
||||
@@ -146,12 +120,10 @@ module.exports = {
|
||||
|
||||
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);
|
||||
const folder = this.state.gcode_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))
|
||||
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();
|
||||
if (this.files_sortby == "Z-A") return files.sort().reverse();
|
||||
@@ -164,8 +136,7 @@ module.exports = {
|
||||
},
|
||||
|
||||
gcode_folders: function () {
|
||||
const list = Array.isArray(this.state.gcode_list) ? this.state.gcode_list : [];
|
||||
return list
|
||||
return this.state.gcode_list
|
||||
.map(item => item.name)
|
||||
.filter(element => element !== "default")
|
||||
.sort();
|
||||
@@ -203,11 +174,7 @@ module.exports = {
|
||||
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)) {
|
||||
if (this.state.selected && !this.state.files.includes(this.state.selected)) {
|
||||
this.GCodeNotFound = true;
|
||||
return;
|
||||
}
|
||||
@@ -232,17 +199,6 @@ module.exports = {
|
||||
const toolpath = await api.get(`path/${file}`);
|
||||
this.toolpath_progress = toolpath.progress;
|
||||
|
||||
// Planner failure (e.g. AuxPreprocessor Z-A coupling
|
||||
// rejection). Close the dialog and surface the message
|
||||
// instead of polling the same broken plan forever.
|
||||
if (toolpath.error) {
|
||||
this.showGcodeMessage = false;
|
||||
this.toolpath_progress = 0;
|
||||
console.error("Plan failed:", toolpath.error);
|
||||
alert("Could not plan G-code:\n\n" + toolpath.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (toolpath.progress === 1 || typeof toolpath.progress == "undefined") {
|
||||
this.showGcodeMessage = false;
|
||||
|
||||
@@ -259,11 +215,7 @@ module.exports = {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// api.get throws on non-2xx; log and break the loop so the
|
||||
// dialog doesn't stay up forever.
|
||||
console.error(error);
|
||||
this.showGcodeMessage = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -579,43 +531,23 @@ module.exports = {
|
||||
override_feed: function () { api.put(`override/feed/${this.feed_override}`); },
|
||||
override_speed: function () { api.put(`override/speed/${this.speed_override}`); },
|
||||
|
||||
run_macro: async function (id) {
|
||||
run_macro: function (id) {
|
||||
if (this.state.macros[id].file_name == "default") {
|
||||
this.showNoGcodeMessage = true;
|
||||
return;
|
||||
}
|
||||
const file_name = this.state.macros[id].file_name;
|
||||
try {
|
||||
// Selecting a file on the server is a side effect of
|
||||
// GET /api/file/<name>. The macro button used to mutate
|
||||
// state.selected client-side and immediately call start, which
|
||||
// raced the file fetch: if the server hadn't seen the new
|
||||
// selection yet, mach.start() ran whichever file was selected
|
||||
// last. Do it explicitly and await so start always sees the
|
||||
// right file.
|
||||
if (file_name != this.state.selected) {
|
||||
this.state.selected = file_name;
|
||||
// GET /api/file/<name> returns gcode text (not JSON), so use
|
||||
// fetch directly. The server's FileHandler.get sets
|
||||
// state.selected as a side effect; we await the response
|
||||
// before starting so mach.start() reads the right file.
|
||||
const resp = await fetch(
|
||||
`/api/file/${encodeURIComponent(file_name)}`,
|
||||
{ cache: "no-cache" }
|
||||
);
|
||||
if (!resp.ok) {
|
||||
throw new Error(`file fetch failed: ${resp.status}`);
|
||||
} 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());
|
||||
}
|
||||
await resp.text();
|
||||
} catch (error) {
|
||||
console.warn("Error running program: ", error);
|
||||
}
|
||||
this.load();
|
||||
if (this.state.macros[id].alert == true) {
|
||||
this.macrosLoading = true;
|
||||
} else {
|
||||
await this.start_pause();
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Error running macro: ", error);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -26,8 +26,6 @@ module.exports = {
|
||||
},
|
||||
|
||||
computed: {
|
||||
is_kiosk: function () { return !!this.$root.is_kiosk; },
|
||||
|
||||
display_units: {
|
||||
cache: false,
|
||||
get: function () { return this.$root.display_units; },
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
// 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,
|
||||
};
|
||||
@@ -24,7 +24,6 @@ module.exports = {
|
||||
"io-view": require("./io-view"),
|
||||
"macros-view": require("./macros"),
|
||||
"help-view": require("./help-view"),
|
||||
"a-axis-view": require("./a-axis-view"),
|
||||
"cheat-sheet-view": {
|
||||
template: "#cheat-sheet-view-template",
|
||||
data: function () {
|
||||
@@ -37,17 +36,8 @@ module.exports = {
|
||||
return {
|
||||
sub: this.$root.sub_tab || "settings",
|
||||
ridx: this.$root.index, // local copy of the motor index
|
||||
// Whether the controller config has streamed in. The Svelte
|
||||
// settings views crash on first paint with the placeholder
|
||||
// config (settings.units / settings.easy-adapter / motion.*
|
||||
// are all undefined). Gate the inner mount on this flag.
|
||||
config_ready: false,
|
||||
rail_items: [
|
||||
{ sub: "settings", href: "#settings", icon: "fa-display", label: "Display & Units" },
|
||||
{ sub: "probing", href: "#probing", icon: "fa-bullseye", label: "Probing" },
|
||||
{ sub: "gcode", href: "#gcode", icon: "fa-code", label: "G-code & Motion" },
|
||||
{ sub: "macros", href: "#macros", icon: "fa-keyboard", label: "Macros" },
|
||||
{ sub: "cheat-sheet", href: "#cheat-sheet", icon: "fa-book", label: "G-code Cheat Sheet" },
|
||||
{ sub: "admin-network", href: "#admin-network", icon: "fa-network-wired", label: "Network" },
|
||||
{ sub: "admin-general", href: "#admin-general", icon: "fa-shield-halved", label: "General / Firmware" },
|
||||
{ sub: "tool", href: "#tool", icon: "fa-bolt", label: "Spindle & Tool" },
|
||||
@@ -57,10 +47,9 @@ module.exports = {
|
||||
{ sub: "motor", motor: 1, href: "#motor:1", icon: "fa-arrows-up-down-left-right", label: "Motor 1" },
|
||||
{ sub: "motor", motor: 2, href: "#motor:2", icon: "fa-arrows-up-down-left-right", label: "Motor 2" },
|
||||
{ sub: "motor", motor: 3, href: "#motor:3", icon: "fa-arrows-up-down-left-right", label: "Motor 3" },
|
||||
// Auxiliary axis (auxcnc ESP32 - exposed to gplan as A).
|
||||
// Mounts the AAxisSettings Svelte component on its own page.
|
||||
{ sub: "a-axis", href: "#a-axis", icon: "fa-arrows-up-down", label: "A Axis" },
|
||||
{ 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" },
|
||||
],
|
||||
};
|
||||
@@ -70,12 +59,6 @@ module.exports = {
|
||||
this._onHash = () => this.refresh_from_hash();
|
||||
window.addEventListener("hashchange", this._onHash);
|
||||
this.refresh_from_hash();
|
||||
this._configPoll = setInterval(() => {
|
||||
const c = this.$root && this.$root.config;
|
||||
const ready = !!(c && c.full_version && c.full_version !== "<loading>"
|
||||
&& c.settings && typeof c.settings === "object");
|
||||
if (ready !== this.config_ready) this.config_ready = ready;
|
||||
}, 200);
|
||||
},
|
||||
|
||||
attached: function () {
|
||||
@@ -100,7 +83,6 @@ module.exports = {
|
||||
if (this._onHash) {
|
||||
window.removeEventListener("hashchange", this._onHash);
|
||||
}
|
||||
if (this._configPoll) clearInterval(this._configPoll);
|
||||
},
|
||||
|
||||
methods: {
|
||||
@@ -120,54 +102,6 @@ module.exports = {
|
||||
return true;
|
||||
},
|
||||
|
||||
on_rail_click: function (item, ev) {
|
||||
if (!item) return;
|
||||
// Always preventDefault on rail clicks. Letting the browser
|
||||
// anchor-scroll to <div id="settings"> etc. inside .app-body
|
||||
// can pull the .app-head out of view; we drive navigation
|
||||
// ourselves through location.hash and our hashchange handler.
|
||||
if (ev && ev.preventDefault) ev.preventDefault();
|
||||
|
||||
if (item.anchor) {
|
||||
// Soft-link rail items use a #settings hash plus an in-page
|
||||
// anchor scroll once the Svelte page has mounted. We scroll
|
||||
// ONLY the .settings-content overflow container by setting
|
||||
// its scrollTop directly — element.scrollIntoView() walks all
|
||||
// ancestor scroll containers and can tug the .app-body / html
|
||||
// layout, which under tablet mode pulls the fixed header out
|
||||
// of view.
|
||||
if (location.hash !== item.href) location.hash = item.href;
|
||||
this._a_axis_focus = (item.sub === "a-axis");
|
||||
const reset = () => {
|
||||
// Force any inadvertent ancestor scroll back to 0 before
|
||||
// we move .settings-content explicitly.
|
||||
window.scrollTo(0, 0);
|
||||
const body = document.querySelector(".app-body");
|
||||
if (body) body.scrollTop = 0;
|
||||
document.documentElement.scrollTop = 0;
|
||||
};
|
||||
setTimeout(() => {
|
||||
reset();
|
||||
const el = document.getElementById(item.anchor);
|
||||
const scroller = document.querySelector(".settings-content");
|
||||
if (el && scroller) {
|
||||
const elTop = el.getBoundingClientRect().top;
|
||||
const scTop = scroller.getBoundingClientRect().top;
|
||||
scroller.scrollTop = scroller.scrollTop + (elTop - scTop) - 12;
|
||||
}
|
||||
// Re-assert ancestor scroll = 0 in case the assignment above
|
||||
// moved things.
|
||||
requestAnimationFrame(reset);
|
||||
}, 320);
|
||||
} else {
|
||||
this._a_axis_focus = false;
|
||||
if (location.hash !== item.href) location.hash = item.href;
|
||||
// Reset .app-body scroll so each route starts at the top.
|
||||
const body = document.querySelector(".app-body");
|
||||
if (body) body.scrollTop = 0;
|
||||
}
|
||||
},
|
||||
|
||||
showShutdownDialog: function () {
|
||||
SvelteComponents.showDialog("Shutdown");
|
||||
},
|
||||
|
||||
@@ -1,60 +1,14 @@
|
||||
// V09 wraps the legacy Svelte SettingsView and filters its big page
|
||||
// down to a single rail section so each rail item shows only the
|
||||
// relevant controls. The Svelte component is left untouched (it is
|
||||
// shared with the legacy UI) — we just hide the `<h2>` and `<fieldset>`
|
||||
// elements whose `data-sec` does not match the active section.
|
||||
|
||||
module.exports = {
|
||||
template: "#settings-view-template",
|
||||
|
||||
props: {
|
||||
// "display" | "probing" | "gcode". Default is "display" which
|
||||
// keeps the rail's "Display & Units" item working unchanged.
|
||||
section: { default: "display" },
|
||||
},
|
||||
|
||||
attached: function () {
|
||||
attached: function() {
|
||||
this.svelteComponent = SvelteComponents.createComponent(
|
||||
"SettingsView",
|
||||
document.getElementById("settings")
|
||||
);
|
||||
// Defer one tick so Svelte has rendered the section markup.
|
||||
setTimeout(() => this.apply_section_filter(), 0);
|
||||
},
|
||||
|
||||
detached: function () {
|
||||
if (this.svelteComponent) this.svelteComponent.$destroy();
|
||||
},
|
||||
|
||||
watch: {
|
||||
section: function () {
|
||||
this.apply_section_filter();
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
apply_section_filter: function () {
|
||||
const root = document.getElementById("settings");
|
||||
if (!root) return;
|
||||
const want = this.section || "display";
|
||||
// Hide every section block that does not match.
|
||||
root.querySelectorAll("[data-sec]").forEach(el => {
|
||||
el.style.display = el.dataset.sec === want ? "" : "none";
|
||||
});
|
||||
// Hide the global <h1>Settings</h1> on subsections so the
|
||||
// page reads as a focused panel.
|
||||
const h1 = root.querySelector(".settings-view > h1");
|
||||
if (h1) {
|
||||
if (want === "display") {
|
||||
h1.textContent = "Display & Units";
|
||||
} else if (want === "probing") {
|
||||
h1.textContent = "Probing";
|
||||
} else if (want === "gcode") {
|
||||
h1.textContent = "G-code & Motion";
|
||||
} else {
|
||||
h1.textContent = "Settings";
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
detached: function() {
|
||||
this.svelteComponent.$destroy();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -9,7 +9,7 @@ html(lang="en")
|
||||
|
||||
style: include ../static/css/pure-min.css
|
||||
|
||||
style: include ../static/css/fa6.min.css
|
||||
style: include ../static/css/font-awesome.min.css
|
||||
style: include ../static/css/Audiowide.css
|
||||
style: include ../static/css/clusterize.css
|
||||
style: include ../svelte-components/node_modules/svelte-material-ui/bare.css
|
||||
@@ -18,51 +18,6 @@ html(lang="en")
|
||||
style: include:stylus ../stylus/style.styl
|
||||
|
||||
body(v-cloak)
|
||||
// Tablet (kiosk) mode — pins the .app-shell to 1920x1080 and
|
||||
// scales it to fit the actual viewport so the UI always looks
|
||||
// exactly like the 10.8" 1920x1080 portable monitor.
|
||||
//
|
||||
// Toggle: ?tablet=1 to enable
|
||||
// ?tablet=0 to disable
|
||||
// Sticky in localStorage; once set, no querystring is needed.
|
||||
script.
|
||||
(function () {
|
||||
try {
|
||||
var p = new URLSearchParams(location.search);
|
||||
if (p.has("tablet")) {
|
||||
var on = p.get("tablet") !== "0" && p.get("tablet") !== "false";
|
||||
localStorage.setItem("ui-tablet-mode", on ? "1" : "0");
|
||||
}
|
||||
if (localStorage.getItem("ui-tablet-mode") === "1") {
|
||||
document.documentElement.classList.add("tablet-mode");
|
||||
}
|
||||
function fit() {
|
||||
if (!document.documentElement.classList.contains("tablet-mode")) return;
|
||||
var s = Math.min(window.innerWidth / 1920, window.innerHeight / 1080);
|
||||
document.documentElement.style.setProperty("--tablet-scale", s);
|
||||
}
|
||||
fit();
|
||||
window.addEventListener("resize", fit);
|
||||
|
||||
// Kiosk mode: when the UI is loaded by the controller's
|
||||
// own onboard browser (Chromium pointing at localhost on
|
||||
// the Pi 3B at 1366x768), apply a tighter layout that
|
||||
// packs the V09 UI into the smaller, slower display.
|
||||
// Override with ?kiosk=0 to force the desktop layout.
|
||||
if (p.has("kiosk")) {
|
||||
var k = p.get("kiosk") !== "0" && p.get("kiosk") !== "false";
|
||||
localStorage.setItem("ui-kiosk-mode", k ? "1" : "0");
|
||||
}
|
||||
var stored = localStorage.getItem("ui-kiosk-mode");
|
||||
var auto = location.hostname === "localhost"
|
||||
|| location.hostname === "127.0.0.1"
|
||||
|| location.hostname === "::1";
|
||||
if (stored === "1" || (stored !== "0" && auto)) {
|
||||
document.documentElement.classList.add("kiosk-mode");
|
||||
}
|
||||
} catch (_e) {}
|
||||
})();
|
||||
|
||||
#svelte-dialog-host
|
||||
|
||||
#overlay(v-if="status != 'connected'")
|
||||
@@ -102,7 +57,7 @@ html(lang="en")
|
||||
|
||||
.pi-temp-warning(v-if="80 <= state.rpi_temp",
|
||||
title="Raspberry Pi temperature too high.")
|
||||
.fa.fa-temperature-full
|
||||
.fa.fa-thermometer-full
|
||||
|
||||
span.state-badge(:class="state_class", :title="mach_state_full")
|
||||
span.dot
|
||||
@@ -120,7 +75,7 @@ html(lang="en")
|
||||
.sp-val v{{config.full_version}}
|
||||
a.sp-act(v-if="show_upgrade()", href="#admin-general")
|
||||
| Upgrade to v{{latestVersion}}
|
||||
.fa.fa-circle-exclamation.upgrade-attention
|
||||
.fa.fa-exclamation-circle.upgrade-attention
|
||||
.sp-row
|
||||
.sp-icon: .fa.fa-network-wired
|
||||
.sp-text
|
||||
@@ -167,22 +122,13 @@ html(lang="en")
|
||||
.fa.fa-save
|
||||
| Save{{modified ? '*' : ''}}
|
||||
|
||||
// Routed view. We keep instances alive across tab swaps so:
|
||||
// - The Program tab's WebGL <path-viewer> canvas does not
|
||||
// get destroyed and recreated each time (which caused a
|
||||
// dark flash as the GL context cleared the new canvas
|
||||
// before its first frame).
|
||||
// - The Program tab's clusterize.js gcode list does not
|
||||
// re-virtualize from scratch on every visit.
|
||||
// - The Settings shell's child Svelte components stay
|
||||
// mounted, preserving any in-flight form state.
|
||||
// The settings-shell handles its own inner v-if cascade so
|
||||
// the Vue 1 reactivity quirk that motivated removing
|
||||
// keep-alive earlier no longer applies here.
|
||||
// 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", keep-alive)
|
||||
:sub-tab="sub_tab")
|
||||
|
||||
message.error-message(:show.sync="errorShow")
|
||||
div(slot="header")
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
script#a-axis-view-template(type="text/x-template")
|
||||
#a-axis-page
|
||||
h1 A Axis (auxcnc)
|
||||
#a-axis-mount
|
||||
@@ -52,7 +52,7 @@ script#console-view-template(type="text/x-template")
|
||||
// ----- Messages -----
|
||||
.messages-pane(v-show="sub === 'messages'")
|
||||
.msg-empty(v-if="!$root.messages_log.length")
|
||||
.fa.fa-circle-check
|
||||
.fa.fa-check-circle
|
||||
| No messages.
|
||||
.msg(v-for="m in $root.messages_log",
|
||||
:class="m.level === 'warning' ? 'warn' : 'info'", track-by="$index")
|
||||
|
||||
@@ -35,9 +35,8 @@ script#control-view-template(type="text/x-template")
|
||||
button.pure-button.button-error(@click="GCodeNotFound=false") OK
|
||||
|
||||
message(:show.sync="show_probe_dialog")
|
||||
h3(slot="header") Choose probe type
|
||||
h3(slot="header") Probe Rotary
|
||||
div(slot="body")
|
||||
p Pick which probe routine to run.
|
||||
button.pure-button(:class="state['pw'] ? '' : 'load-on'", @click="showProbeDialog('xyz')") Probe XYZ
|
||||
button.pure-button(:class="state['pw'] ? '' : 'load-on'", @click="showProbeDialog('z')") Probe Z
|
||||
div(slot="footer")
|
||||
@@ -47,9 +46,7 @@ script#control-view-template(type="text/x-template")
|
||||
.control-grid
|
||||
|
||||
// ===== JOG =====
|
||||
// Hidden only while a G-code program is running / paused /
|
||||
// stopping. Jogging / homing / MDI moves do not hide it.
|
||||
.jog-card(v-if="!is_program_executing")
|
||||
.jog-card
|
||||
.jog-head
|
||||
.jog-title
|
||||
| Jog
|
||||
@@ -76,11 +73,11 @@ script#control-view-template(type="text/x-template")
|
||||
|
||||
// Row 2
|
||||
button.jbtn(@click="jog_fn(-1, 0, 0, 0)") X−
|
||||
button.jbtn(@click="showMoveToZeroDialog('xy')")
|
||||
button.jbtn.ghost(@click="showMoveToZeroDialog('xy')")
|
||||
span.lbl XY
|
||||
span Origin
|
||||
button.jbtn(@click="jog_fn(1, 0, 0, 0)") X+
|
||||
button.jbtn(@click="showMoveToZeroDialog('z')")
|
||||
button.jbtn.ghost(@click="showMoveToZeroDialog('z')")
|
||||
span.lbl Z
|
||||
span Origin
|
||||
|
||||
@@ -92,33 +89,22 @@ script#control-view-template(type="text/x-template")
|
||||
.fa.fa-arrow-down.ico(style="transform: rotate(-45deg)")
|
||||
button.jbtn(@click="jog_fn(0, 0, -1, 0)") Z−
|
||||
|
||||
// Row 4 — A axis (the auxcnc-driven external axis) when enabled.
|
||||
// A- | A+ | Probe XYZ | Probe Z
|
||||
// "Home A" lives in the DRO table's actions column on the
|
||||
// right, so it doesn't need a tile here. The legacy w.enabled
|
||||
// gate is kept so older installs (where the auxcnc axis still
|
||||
// appears as W via the side-channel) keep working.
|
||||
template(v-if="w.enabled || a.enabled")
|
||||
button.jbtn(@click="aux_jog_incr(-1)",
|
||||
:disabled="!(w.enabled || a.enabled)")
|
||||
// Row 4 — auxiliary axis (W or A) or probe shortcuts
|
||||
template(v-if="w.enabled")
|
||||
button.jbtn(@click="aux_jog_incr(-1)", :disabled="!w.enabled")
|
||||
.fa.fa-arrow-down.ico
|
||||
span.lbl A−
|
||||
button.jbtn(@click="aux_jog_incr(+1)",
|
||||
:disabled="!(w.enabled || a.enabled)")
|
||||
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 A+
|
||||
button.jbtn(@click="showProbeDialog('xyz')",
|
||||
span.lbl W+
|
||||
button.jbtn(@click="show_probe_dialog=true",
|
||||
:class="{'load-on': !state['pw']}")
|
||||
.fa.fa-bullseye.ico
|
||||
span.lbl Probe XYZ
|
||||
button.jbtn(@click="showProbeDialog('z')",
|
||||
:class="{'load-on': !state['pw']}")
|
||||
.fa.fa-bullseye.ico
|
||||
span.lbl Probe Z
|
||||
|
||||
// Row 4 — A axis (rotary) when no W and rotary is enabled
|
||||
// (Vue 1 has no v-else-if; we negate w.enabled explicitly.)
|
||||
template(v-if="!w.enabled && state['2an'] == 3")
|
||||
span.lbl Probe
|
||||
template(v-else-if="state['2an'] == 3")
|
||||
button.jbtn.dir(@click="jog_fn(0, 0, 0, -1)")
|
||||
.fa.fa-rotate-left.ico
|
||||
span.lbl A−
|
||||
@@ -132,80 +118,22 @@ script#control-view-template(type="text/x-template")
|
||||
:class="{'load-on': !state['pw']}")
|
||||
.fa.fa-bullseye.ico
|
||||
span.lbl Probe
|
||||
|
||||
// Row 4 — fallback probe / zero / home shortcuts
|
||||
template(v-if="!w.enabled && state['2an'] != 3")
|
||||
template(v-else)
|
||||
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-location-dot.ico
|
||||
.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()")
|
||||
button.jbtn.ghost(@click="home()", :disabled="!is_idle")
|
||||
.fa.fa-home.ico
|
||||
span.lbl Home all
|
||||
|
||||
// ===== NOW RUNNING (replaces jog grid only while a G-code
|
||||
// program is actually executing). Jogging is excluded.
|
||||
.running-panel(v-if="is_program_executing")
|
||||
.running-top
|
||||
div
|
||||
.running-file
|
||||
.fa.fa-file-code
|
||||
span(v-if="state.selected") {{state.selected}}
|
||||
span(v-else) {{(mach_state || 'BUSY').toLowerCase()}}
|
||||
.running-meta
|
||||
span(v-if="is_running") {{ (mach_state || 'RUNNING').toLowerCase() }}
|
||||
span(v-if="is_holding") paused
|
||||
span(v-if="is_holding && pause_reason") · {{pause_reason}}
|
||||
span(v-if="is_stopping") stopping
|
||||
span(v-if="toolpath.lines") · line {{state.line || 0 | number}} / {{toolpath.lines | number}}
|
||||
span(v-if="plan_time_remaining") · ETA {{plan_time_remaining | time}}
|
||||
.running-pct
|
||||
| {{((progress || 0) * 100) | fixed 0}}
|
||||
span %
|
||||
.running-progress
|
||||
div(:style="'width:' + ((progress || 0) * 100) + '%'")
|
||||
.running-stats
|
||||
.running-stat
|
||||
.lbl Velocity
|
||||
.val
|
||||
unit-value(:value="state.v", precision="2", unit="", iunit="", scale="0.0254")
|
||||
| {{metric ? 'm/min' : 'IPM'}}
|
||||
.running-stat
|
||||
.lbl Feed
|
||||
.val
|
||||
unit-value(:value="state.feed", precision="0", unit="", iunit="")
|
||||
| {{metric ? 'mm/min' : 'IPM'}}
|
||||
.running-stat
|
||||
.lbl Spindle
|
||||
.val
|
||||
| {{(state.speed || 0) | fixed 0}}
|
||||
span(v-if="state.s != null && !isNaN(state.s)") ({{state.s | fixed 0}})
|
||||
| RPM
|
||||
.running-stat
|
||||
.lbl Tool
|
||||
.val T{{state.tool || 0}}
|
||||
.running-row
|
||||
// While RUNNING the primary action is Pause; while HOLDING / STOPPING it's Resume.
|
||||
button.tx-btn.pause(v-if="is_running", @click="pause()")
|
||||
.fa.fa-pause
|
||||
span.lbl PAUSE
|
||||
button.tx-btn.run(v-if="is_holding || is_stopping", @click="unpause()")
|
||||
.fa.fa-play
|
||||
span.lbl RESUME
|
||||
button.tx-btn.stop(@click="stop()")
|
||||
.fa.fa-stop
|
||||
span.lbl STOP
|
||||
button.tx-btn.step(v-if="is_holding", @click="step()")
|
||||
.fa.fa-forward-step
|
||||
span.lbl STEP
|
||||
|
||||
// ===== DRO + status strip =====
|
||||
.right-col
|
||||
|
||||
@@ -215,60 +143,64 @@ script#control-view-template(type="text/x-template")
|
||||
div Position
|
||||
div Absolute
|
||||
div Offset
|
||||
.actions-cell
|
||||
// Master Home All. Each row's Actions cell has a per-axis
|
||||
// home button; this header-level button homes every
|
||||
// enabled axis (legacy Onefinity behavior). Auto-includes
|
||||
// the auxiliary A axis when it is enabled.
|
||||
button.icon-btn(:disabled="!is_idle",
|
||||
title="Home all axes.", @click="home_all()")
|
||||
.fa.fa-house-chimney
|
||||
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}.toolmsg ? (${axis}.title + ' — ' + ${axis}.toolmsg) : ${axis}.title`)
|
||||
:title=`${axis}.title`)
|
||||
.dro-axis(:class=`'axis-' + '${axis}'`)= axis.toUpperCase()
|
||||
.dro-pos: unit-value(:value=`${axis}.pos`, precision=4)
|
||||
.dro-sec: unit-value(:value=`${axis}.abs`, precision=3)
|
||||
.dro-sec: unit-value(:value=`${axis}.off`, precision=3)
|
||||
.dro-state
|
||||
span.chip(:class=`${axis}.tklass.indexOf('error') !== -1 ? 'chip-red' : (${axis}.homed ? 'chip-green' : 'chip-amber')`)
|
||||
.fa(:class=`'fa-' + ${axis}.icon`)
|
||||
| {{#{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}}
|
||||
.actions-cell
|
||||
button.icon-btn(:disabled="!can_set_axis",
|
||||
:title=`'Set ${axis.toUpperCase()} axis position.'`,
|
||||
@click=`show_set_position('${axis}')`)
|
||||
.fa.fa-gear
|
||||
button.icon-btn(:class=`${axis}.tklass.indexOf('error') !== -1 ? 'state-red' : (${axis}.tklass.indexOf('warn') !== -1 ? 'state-amber' : 'state-green')`,
|
||||
:disabled="!can_set_axis",
|
||||
:title=`${axis}.toolmsg || ('Zero ${axis.toUpperCase()} axis offset.')`,
|
||||
.fa.fa-cog
|
||||
button.icon-btn(:disabled="!can_set_axis",
|
||||
:title=`'Zero ${axis.toUpperCase()} axis offset.'`,
|
||||
@click=`zero('${axis}')`)
|
||||
.fa.fa-location-dot
|
||||
button.icon-btn(:class=`${axis}.klass.indexOf('error') !== -1 ? 'state-red' : (${axis}.homed ? 'state-green' : 'state-amber')`,
|
||||
:disabled="!is_idle",
|
||||
:title=`${axis}.title`,
|
||||
.fa.fa-map-marker
|
||||
button.icon-btn(:disabled="!is_idle",
|
||||
:title=`'Home ${axis.toUpperCase()} axis.'`,
|
||||
@click=`home('${axis}')`)
|
||||
.fa.fa-home
|
||||
|
||||
// Legacy auxiliary-axis row - shown only when the auxcnc stepper is
|
||||
// *not* exposed as a virtual A axis. After v2 the standard
|
||||
// A row above renders this axis natively (with full offset
|
||||
// + set-position support); this row only appears on legacy
|
||||
// installs that haven't migrated yet.
|
||||
.dro-row(:class="w.klass + ' ' + w.tklass",
|
||||
v-if="w.enabled && !a.enabled",
|
||||
// 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")
|
||||
| {{w.state}}
|
||||
.dro-toolpath
|
||||
span.chip.chip-green
|
||||
.fa(:class="'fa-' + w.ticon")
|
||||
| {{w.tstate}}
|
||||
.actions-cell
|
||||
button.icon-btn(disabled, style="visibility:hidden")
|
||||
.fa.fa-gear
|
||||
.fa.fa-cog
|
||||
button.icon-btn(disabled, style="visibility:hidden")
|
||||
.fa.fa-location-dot
|
||||
button.icon-btn(:class="w.homed ? 'state-green' : 'state-amber'",
|
||||
:disabled="!w.enabled",
|
||||
title="Home auxiliary axis.", @click="aux_home()")
|
||||
.fa.fa-map-marker
|
||||
button.icon-btn(:disabled="!w.enabled",
|
||||
title="Home W axis.", @click="aux_home()")
|
||||
.fa.fa-home
|
||||
|
||||
// ----- Status strip -----
|
||||
@@ -309,17 +241,14 @@ script#control-view-template(type="text/x-template")
|
||||
.stat-sub(v-else) Line · ETA --
|
||||
|
||||
// ----- 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} : {}")
|
||||
:style="{ borderLeftColor: macros.color || '#fde047' }")
|
||||
span.mnum {{index + 1}}
|
||||
.fa.fa-circle-play.micon
|
||||
span.mname {{macros.name || ('Macro ' + (index + 1))}}
|
||||
|
||||
// ----- Override drawer (anchored to bottom; toggled by Spindle KPI tile) -----
|
||||
|
||||
@@ -2,8 +2,6 @@ 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")
|
||||
|
||||
@@ -6,11 +6,11 @@ script#indicators-template(type="text/x-template")
|
||||
|
||||
tr
|
||||
td
|
||||
.fa.fa-circle-plus.io
|
||||
.fa.fa-plus-circle.io
|
||||
th Hi/+3.3v
|
||||
th.separator
|
||||
td
|
||||
.fa.fa-circle-minus.io
|
||||
.fa.fa-minus-circle.io
|
||||
th Lo/Gnd
|
||||
th.separator
|
||||
td
|
||||
@@ -22,7 +22,7 @@ script#indicators-template(type="text/x-template")
|
||||
th Inactive
|
||||
th.separator
|
||||
td
|
||||
.far.fa-circle.io
|
||||
.fa.fa-circle-o.io
|
||||
th Tristated/Disabled
|
||||
|
||||
table.inputs
|
||||
@@ -169,14 +169,14 @@ script#indicators-template(type="text/x-template")
|
||||
|
||||
tr
|
||||
th Motor
|
||||
th(title="Overtemperature fault"): .fa.fa-temperature-full
|
||||
th(title="Overtemperature fault"): .fa.fa-thermometer-full
|
||||
th(title="Overcurrent motor channel A") A #[.fa.fa-bolt]
|
||||
th(title="Predriver fault motor channel A")
|
||||
| A #[.fa.fa-triangle-exclamation]
|
||||
| A #[.fa.fa-exclamation-triangle]
|
||||
th(title="Overcurrent motor channel B") B #[.fa.fa-bolt]
|
||||
th(title="Predriver fault motor channel B")
|
||||
| B #[.fa.fa-triangle-exclamation]
|
||||
th(title="Driver communication failure"): .fa.fa-handshake
|
||||
| B #[.fa.fa-exclamation-triangle]
|
||||
th(title="Driver communication failure"): .fa.fa-handshake-o
|
||||
th(title="Reset all motor flags")
|
||||
.fa.fa-eraser(@click="motor_reset()")
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ script#path-viewer-template(type="text/x-template")
|
||||
.path-viewer-toolbar
|
||||
.tool-button(title="Toggle path view size.",
|
||||
@click="small = !small", :class="{active: !small}")
|
||||
.fa.fa-up-down-left-right
|
||||
.fa.fa-arrows-alt
|
||||
|
||||
.tool-button(@click="showTool = !showTool", :class="{active: showTool}",
|
||||
title="Show/hide tool.")
|
||||
|
||||
@@ -82,7 +82,7 @@ script#program-view-template(type="text/x-template")
|
||||
span STOP
|
||||
button.action-btn(@click="open_folder", :disabled="!is_ready",
|
||||
title="Upload a new GCode folder.")
|
||||
.fa.fa-folder-plus.ico
|
||||
.fa.fa-folder-arrow-up.ico
|
||||
span UPLOAD FOLDER
|
||||
form.gcode-folder-input.file-upload
|
||||
input#folderInput(type="file", @change="upload_folder",
|
||||
@@ -126,15 +126,10 @@ script#program-view-template(type="text/x-template")
|
||||
.fa.fa-arrow-down-wide-short
|
||||
| {{files_sortby}}
|
||||
|
||||
// Body: gcode listing on the left, 3D viewer on the right.
|
||||
// The 3D path-viewer is suppressed when the UI is loaded by
|
||||
// the Pi's onboard kiosk browser — the VideoCore IV cannot
|
||||
// run three.js at a usable frame rate. Off-Pi clients still
|
||||
// see the full split.
|
||||
.program-body(:class="{'no-preview': is_kiosk}")
|
||||
// Body: gcode listing on the left, 3D viewer on the right
|
||||
.program-body
|
||||
gcode-viewer
|
||||
path-viewer(v-if="!is_kiosk", :toolpath="toolpath",
|
||||
:state="state", :config="config")
|
||||
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.")
|
||||
|
||||
@@ -9,7 +9,7 @@ script#settings-shell-view-template(type="text/x-template")
|
||||
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)")
|
||||
:href="item.href")
|
||||
.fa(:class="item.icon")
|
||||
| {{item.label}}
|
||||
.set-rail-foot
|
||||
@@ -24,35 +24,21 @@ script#settings-shell-view-template(type="text/x-template")
|
||||
// Explicit v-if cascade so the inner template swaps reactively
|
||||
// when sub changes (Vue 1's `<component :is>` does not always
|
||||
// re-evaluate dynamic strings inside a kept-alive parent).
|
||||
// The Svelte settings views read many config keys eagerly on
|
||||
// attach (settings.units, settings.easy-adapter, motion.*),
|
||||
// so we gate the inner mount on config_ready.
|
||||
settings-view-inner(v-if="sub === 'settings' && config_ready",
|
||||
section="display",
|
||||
settings-view-inner(v-if="sub === 'settings'",
|
||||
:index="index", :config="config", :template="template", :state="state")
|
||||
settings-view-inner(v-if="sub === 'probing' && config_ready",
|
||||
section="probing",
|
||||
admin-general-view(v-if="sub === 'admin-general'",
|
||||
:index="index", :config="config", :template="template", :state="state")
|
||||
settings-view-inner(v-if="sub === 'gcode' && config_ready",
|
||||
section="gcode",
|
||||
admin-network-view(v-if="sub === 'admin-network'",
|
||||
:index="index", :config="config", :template="template", :state="state")
|
||||
admin-general-view(v-if="sub === 'admin-general' && config_ready",
|
||||
motor-view(v-if="sub === 'motor'",
|
||||
:index="index", :config="config", :template="template", :state="state")
|
||||
admin-network-view(v-if="sub === 'admin-network' && config_ready",
|
||||
tool-view(v-if="sub === 'tool'",
|
||||
:index="index", :config="config", :template="template", :state="state")
|
||||
motor-view(v-if="sub === 'motor' && config_ready",
|
||||
io-view(v-if="sub === 'io'",
|
||||
:index="index", :config="config", :template="template", :state="state")
|
||||
tool-view(v-if="sub === 'tool' && config_ready",
|
||||
macros-view(v-if="sub === 'macros'",
|
||||
:index="index", :config="config", :template="template", :state="state")
|
||||
io-view(v-if="sub === 'io' && config_ready",
|
||||
help-view(v-if="sub === 'help'",
|
||||
:index="index", :config="config", :template="template", :state="state")
|
||||
a-axis-view(v-if="sub === 'a-axis' && config_ready",
|
||||
cheat-sheet-view(v-if="sub === 'cheat-sheet'",
|
||||
:index="index", :config="config", :template="template", :state="state")
|
||||
macros-view(v-if="sub === 'macros' && config_ready",
|
||||
:index="index", :config="config", :template="template", :state="state")
|
||||
help-view(v-if="sub === 'help' && config_ready",
|
||||
:index="index", :config="config", :template="template", :state="state")
|
||||
cheat-sheet-view(v-if="sub === 'cheat-sheet' && config_ready",
|
||||
:index="index", :config="config", :template="template", :state="state")
|
||||
.settings-loading(v-if="!config_ready")
|
||||
| Loading configuration…
|
||||
|
||||
@@ -33,67 +33,22 @@ DEFAULTS = {
|
||||
'enabled': False,
|
||||
'port': '/dev/ttyUSB0',
|
||||
'baud': 115200,
|
||||
'steps_per_mm': 80.0, # logical steps per mm of axis travel
|
||||
'steps_per_mm': 80.0, # logical steps per mm of W travel
|
||||
'dir_sign': 1, # +1 or -1: maps logical+ to motor+ steps
|
||||
# Logical axis letter exposed to gplan. The auxcnc ESP stepper
|
||||
# is presented to the planner as this axis (default 'a' = standard
|
||||
# 4th axis). gcode uses A for moves; the host ExternalAxis layer
|
||||
# forks A motion to the ESP transparently.
|
||||
'axis_letter': 'a',
|
||||
'min_mm': 0.0, # soft limit min (mm), exposed as 4tn
|
||||
'max_mm': 100.0, # soft limit max (mm), exposed as 4tm
|
||||
# Per-axis kinematic limits used to populate the planner's config.
|
||||
# Units match the bbctrl/onefinity per-motor convention so the
|
||||
# values are directly comparable to motors 0-3:
|
||||
# max_velocity_m_per_min m/min (planner sees * 1000 = mm/min)
|
||||
# max_accel_km_per_min2 km/min2 (planner sees * 1e6 = mm/min2)
|
||||
# max_jerk_km_per_min3 km/min3 (planner sees * 1e6 = mm/min3)
|
||||
'max_velocity_m_per_min': 6.0,
|
||||
'max_accel_km_per_min2': 100.0,
|
||||
'max_jerk_km_per_min3': 500.0,
|
||||
# Informational only - rate caps that actually clamp the move
|
||||
# are on the ESP via step_max_sps below.
|
||||
'max_feed_mm_min': 600.0,
|
||||
'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.
|
||||
# Speeds tuned for a typical 25 steps/mm aux drive (so 1 step =
|
||||
# 0.04 mm). With the limit-aware ESP firmware these values give
|
||||
# a brisk seek (100 mm/s), enough backoff to clear the switch
|
||||
# hysteresis (16 mm), and a slow re-engage (10 mm/s) that's
|
||||
# accurate without being painfully slow on a longer axis.
|
||||
'home_fast_sps': 2500, # ≈ 100 mm/s @ 25 steps/mm
|
||||
'home_slow_sps': 250, # ≈ 10 mm/s
|
||||
'home_backoff_steps': 400, # ≈ 16 mm
|
||||
'home_fast_sps': 4000,
|
||||
'home_slow_sps': 400,
|
||||
'home_backoff_steps': 200,
|
||||
'home_maxtravel_steps': 200000,
|
||||
# If HOME starts with the limit switch already tripped the ESP
|
||||
# first moves this many steps away from the limit and then
|
||||
# rechecks. If the switch is still active afterward, HOME hard-
|
||||
# fails (refuses to set zero blindly when we may already be past
|
||||
# the home position). Default ≈ 10 mm @ 25 steps/mm. Set to 0 to
|
||||
# disable the preclear move (HOME then fails immediately if the
|
||||
# switch reads active at start, matching the original behaviour).
|
||||
'home_preclear_mm': 10.0,
|
||||
'step_max_sps': 4000, # ≈ 160 mm/s normal-move cap
|
||||
'step_accel_sps2': 12000,
|
||||
'step_max_sps': 4000,
|
||||
'step_accel_sps2': 16000,
|
||||
'step_start_sps': 200,
|
||||
'limit_low': True,
|
||||
# ------------------------------------------------------------------
|
||||
# Z-A coupling interlock
|
||||
# ------------------------------------------------------------------
|
||||
# The auxiliary A axis carries a tool that physically hangs below
|
||||
# the Z-axis spindle nose. Beyond a certain Z descent the two
|
||||
# collide unless A drops with Z. The constraint, in machine coords,
|
||||
# is:
|
||||
# A_machine - Z_machine <= K
|
||||
# where K = (A_home_mm - z_home_mm) + couple_z_clearance_mm.
|
||||
# When enabled this is enforced everywhere motion can be
|
||||
# initiated (planner, MDI, jog, file load) and the AuxPreprocessor
|
||||
# injects pre-position A moves before Z descends past the safe
|
||||
# band.
|
||||
'couple_z_enabled': True,
|
||||
'couple_z_clearance_mm': 22.0, # Z drop allowed before A must follow
|
||||
'z_home_mm': 0.0, # Z's machine position when homed
|
||||
}
|
||||
|
||||
|
||||
@@ -144,61 +99,23 @@ class AuxAxis(object):
|
||||
def _config_path(self):
|
||||
return self.ctrl.get_path(filename='aux.json')
|
||||
|
||||
# Legacy aux.json fields that have been renamed for clarity.
|
||||
# Loaded values are migrated up on every load/save so existing
|
||||
# installs keep working without operator intervention.
|
||||
_LEGACY_FIELD_MAP = {
|
||||
'min_w': 'min_mm',
|
||||
'max_w': 'max_mm',
|
||||
}
|
||||
|
||||
def _migrate_legacy_fields(self, cfg):
|
||||
"""In-place rename of legacy keys in `cfg` (dict). Returns
|
||||
True if anything was migrated, so callers can decide whether
|
||||
to persist the upgraded form.
|
||||
"""
|
||||
migrated = False
|
||||
for old, new in self._LEGACY_FIELD_MAP.items():
|
||||
if old in cfg:
|
||||
if new not in cfg:
|
||||
cfg[new] = cfg[old]
|
||||
del cfg[old]
|
||||
migrated = True
|
||||
return migrated
|
||||
|
||||
def _load_config(self):
|
||||
path = self._config_path()
|
||||
if os.path.exists(path):
|
||||
try:
|
||||
with open(path) as f:
|
||||
user = json.load(f)
|
||||
migrated = self._migrate_legacy_fields(user)
|
||||
# Be permissive; ignore unknown keys.
|
||||
for k, v in user.items():
|
||||
if k in self._cfg:
|
||||
self._cfg[k] = v
|
||||
self.log.info('Loaded aux config from %s' % path)
|
||||
if migrated:
|
||||
# Persist the upgraded form so future restarts
|
||||
# see the new field names directly.
|
||||
try:
|
||||
self.save_config(self._cfg)
|
||||
self.log.info(
|
||||
'Migrated aux.json legacy fields '
|
||||
'(min_w/max_w -> min_mm/max_mm)')
|
||||
except Exception:
|
||||
self.log.warning(
|
||||
'Could not persist aux.json migration')
|
||||
except Exception:
|
||||
self.log.error('Failed to read aux.json: %s'
|
||||
% traceback.format_exc())
|
||||
|
||||
def save_config(self, cfg):
|
||||
merged = dict(DEFAULTS)
|
||||
# Accept legacy keys from callers that may still send the
|
||||
# old names (older UI bundles, hand-edited POSTs).
|
||||
cfg = dict(cfg)
|
||||
self._migrate_legacy_fields(cfg)
|
||||
for k, v in cfg.items():
|
||||
if k in DEFAULTS:
|
||||
merged[k] = v
|
||||
@@ -235,29 +152,24 @@ class AuxAxis(object):
|
||||
def position_mm(self):
|
||||
return self._steps_to_mm(self._pos_steps)
|
||||
|
||||
def set_state_observer(self, fn):
|
||||
"""Register a callback invoked after every _publish_state.
|
||||
Used by ExternalAxis to mirror the homed flag into State."""
|
||||
self._state_observer = fn
|
||||
|
||||
def home(self):
|
||||
"""Run the homing cycle on the ESP. Blocks until done. Raises on
|
||||
failure. Updates aux_homed and aux_pos.
|
||||
|
||||
The ESP's home_zero is pre-loaded via HOMECFG so when the cycle
|
||||
completes the step counter already corresponds to home_position_mm.
|
||||
That way the homed-state survives a bbctrl restart correctly
|
||||
(we don't need a post-home WPOS write, which would clear HOMED)."""
|
||||
failure. Updates aux_homed and aux_pos."""
|
||||
self._require_present()
|
||||
# Make sure home_zero on the ESP matches our current
|
||||
# home_position_mm in case the user just edited config.
|
||||
self._push_homecfg()
|
||||
line = self._rpc('HOME', topic='home', timeout=120.0)
|
||||
# line is the body after '[home] '. Only terminal lines use
|
||||
# the [home] topic now (done / failed); progress is [home_log].
|
||||
# line is the body after '[home] '
|
||||
if line.startswith('done'):
|
||||
self._pos_steps = self._parse_kv_int(line, 'pos', 0)
|
||||
# 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
|
||||
@@ -303,75 +215,6 @@ class AuxAxis(object):
|
||||
return
|
||||
self._do_steps(int(steps), ignore_limits=True)
|
||||
|
||||
# ----------------------------------------------- continuous-rate jog
|
||||
#
|
||||
# Hold-to-jog support for the gamepad pendant. JOG / JOGSTOP on
|
||||
# the ESP give a smooth ramp-up, cruise-until-released, ramp-down
|
||||
# profile - much better than streaming small STEPS chunks.
|
||||
#
|
||||
# `jog_start` returns immediately after the ESP acknowledges with
|
||||
# `[jog] started ...`. The terminal `[jog] done count=<n>
|
||||
# pos=<p>` arrives later; our reader picks it up and resyncs
|
||||
# _pos_steps via the same path as STEPS.
|
||||
def jog_start(self, direction, max_rate_sps=None,
|
||||
accel_sps2=None, ignore_limits=False,
|
||||
target_steps=None):
|
||||
"""Begin a continuous-rate jog. `direction` is +1 or -1.
|
||||
Returns once the ESP has accepted the JOG command.
|
||||
|
||||
target_steps (optional): a signed step-counter value. The
|
||||
ESP picks the deceleration start point so the motor ramps
|
||||
smoothly from the current cruise rate to step_start_rate
|
||||
and stops AT this counter value. Used to enforce host-side
|
||||
soft limits without overshoot. The target must be on the
|
||||
side of the current g_pos that matches `direction`; the
|
||||
ESP rejects a wrong-side target with reason=softlimit."""
|
||||
self._require_present()
|
||||
if direction not in (-1, +1):
|
||||
raise AuxAxisError('jog_start direction must be +/-1')
|
||||
sign = '+' if direction > 0 else '-'
|
||||
rate = (int(max_rate_sps) if max_rate_sps is not None
|
||||
else int(self._cfg['step_max_sps']))
|
||||
accel = (int(accel_sps2) if accel_sps2 is not None
|
||||
else int(self._cfg['step_accel_sps2']))
|
||||
if rate < 1: rate = 1
|
||||
if accel < 1: accel = 1
|
||||
cmd = 'JOG dir=%s maxrate=%d accel=%d safe=%d' % (
|
||||
sign, rate, accel, 0 if ignore_limits else 1)
|
||||
if target_steps is not None:
|
||||
cmd += ' target=%d' % int(target_steps)
|
||||
# Capture both the immediate ack AND the eventual terminal
|
||||
# line in a single _rpc call would block; instead fire the
|
||||
# ack-only RPC here and let _on_line handle the terminal
|
||||
# `[jog] done` async (it falls through to the info log path,
|
||||
# but we hook _on_line to update _pos_steps).
|
||||
line = self._rpc(cmd, topic='jog', timeout=2.0)
|
||||
if line.startswith('error'):
|
||||
raise AuxAxisError('JOG rejected: %s' % line)
|
||||
if not line.startswith('started'):
|
||||
# Could be "done count=0 pos=..." if a near-instant abort
|
||||
# raced; treat as completed.
|
||||
self._pos_steps = self._parse_kv_int(
|
||||
line, 'pos', self._pos_steps)
|
||||
self._publish_state()
|
||||
# else: cruising, terminal [jog] reply will arrive later.
|
||||
|
||||
def jog_stop(self):
|
||||
"""Request the running JOG to ramp down to a stop. Returns
|
||||
immediately; the terminal `[jog] done` arrives async and is
|
||||
picked up by `_on_line` to resync _pos_steps.
|
||||
|
||||
Like abort(), this does NOT take the RPC lock - JOGSTOP is
|
||||
the on-release path of a hold-to-jog UI and must not block
|
||||
on whatever else is in flight."""
|
||||
if not self._present:
|
||||
return
|
||||
try:
|
||||
self.log.info('aux >> JOGSTOP')
|
||||
self._send_raw('JOGSTOP')
|
||||
except Exception as e:
|
||||
self.log.warning('JOGSTOP send failed: %s' % e)
|
||||
|
||||
def abort(self):
|
||||
"""Cancel any running ESP motion immediately."""
|
||||
if not self._present:
|
||||
@@ -382,63 +225,6 @@ class AuxAxis(object):
|
||||
except Exception as e:
|
||||
self.log.warning('ABORT send failed: %s' % e)
|
||||
|
||||
# ---------------------------------------------------------- ATC atoms
|
||||
#
|
||||
# The auxcnc firmware drives an AMB 1050 FME-W DI tool changer via
|
||||
# two pneumatic valves on relays 1-2:
|
||||
# V1 (clamp, 3/2 valve) - relay 2: ON = collet open, OFF = vent + spring closes
|
||||
# V2 (ejector) - relay 1: ON = ejector cylinder extends
|
||||
#
|
||||
# The host exposes three composable atoms - RELEASE, CLAMP, EJECT -
|
||||
# and composes drop/grab sequences from G-code macros that call
|
||||
# them in order. (Older firmware exposed monolithic DROPTOOL /
|
||||
# GRABTOOL verbs; protocol v3 dropped them in favour of these
|
||||
# atoms so callers can interleave Z moves between ejector pulses.)
|
||||
|
||||
def atc_release(self, timeout=5.0):
|
||||
"""Open the collet (V1 on). Instant. Idempotent. Pairs with
|
||||
atc_clamp() to bracket a sequence of host-side moves and/or
|
||||
ejector pulses with the collet held open."""
|
||||
self._require_present()
|
||||
line = self._rpc('RELEASE', topic='release', timeout=timeout)
|
||||
if line.startswith('done'):
|
||||
return
|
||||
reason = line.split('reason=', 1)[1] if 'reason=' in line else line
|
||||
raise AuxAxisError('RELEASE failed: %s' % reason)
|
||||
|
||||
def atc_clamp(self, timeout=10.0):
|
||||
"""Close the collet: V1 off, then dwell for the line to vent
|
||||
and the spring to re-engage. Idempotent."""
|
||||
self._require_present()
|
||||
line = self._rpc('CLAMP', topic='clamp', timeout=timeout)
|
||||
if line.startswith('done'):
|
||||
return
|
||||
reason = line.split('reason=', 1)[1] if 'reason=' in line else line
|
||||
raise AuxAxisError('CLAMP failed: %s' % reason)
|
||||
|
||||
def atc_eject(self, pulse_ms=None, dwell_ms=None, timeout=10.0):
|
||||
"""One ejector wiggle: V2 on for pulse_ms, then off for
|
||||
dwell_ms. The collet (V1) is left in whatever state the caller
|
||||
set it to via atc_release/atc_clamp - typically RELEASE first
|
||||
so the holder can actually drop.
|
||||
|
||||
Repeatedly calling atc_eject gives the wiggle that the old
|
||||
monolithic DROPTOOL did internally, but as discrete blocking
|
||||
calls so a macro can interleave Z moves between pulses.
|
||||
|
||||
pulse_ms / dwell_ms default to the ESP-side defaults
|
||||
(currently 500 / 500). Pass explicit values to override."""
|
||||
self._require_present()
|
||||
parts = ['EJECT']
|
||||
if pulse_ms is not None: parts.append('pulse=%d' % int(pulse_ms))
|
||||
if dwell_ms is not None: parts.append('dwell=%d' % int(dwell_ms))
|
||||
cmd = ' '.join(parts)
|
||||
line = self._rpc(cmd, topic='eject', timeout=timeout)
|
||||
if line.startswith('done'):
|
||||
return
|
||||
reason = line.split('reason=', 1)[1] if 'reason=' in line else line
|
||||
raise AuxAxisError('EJECT failed: %s' % reason)
|
||||
|
||||
def close(self):
|
||||
self._stop.set()
|
||||
try:
|
||||
@@ -456,13 +242,13 @@ class AuxAxis(object):
|
||||
raise AuxAxisError('Aux axis not connected')
|
||||
|
||||
def _check_limits(self, target_mm):
|
||||
lo = float(self._cfg['min_mm'])
|
||||
hi = float(self._cfg['max_mm'])
|
||||
lo = float(self._cfg['min_w'])
|
||||
hi = float(self._cfg['max_w'])
|
||||
if hi <= lo:
|
||||
return # no limits
|
||||
if target_mm < lo - 1e-6 or target_mm > hi + 1e-6:
|
||||
raise AuxAxisError(
|
||||
'A=%.3f out of soft limits [%.3f, %.3f]' % (target_mm, lo, hi))
|
||||
'W=%.3f out of soft limits [%.3f, %.3f]' % (target_mm, lo, hi))
|
||||
|
||||
def _mm_to_steps(self, mm):
|
||||
spm = float(self._cfg['steps_per_mm'])
|
||||
@@ -495,64 +281,6 @@ class AuxAxis(object):
|
||||
raise AuxAxisError('W move aborted by limit switch')
|
||||
raise AuxAxisError('W move aborted: %s' % line)
|
||||
|
||||
def _do_line(self, signed_steps, length_mm,
|
||||
max_accel_mm_min2, max_jerk_mm_min3,
|
||||
entry_vel_mm_min, exit_vel_mm_min,
|
||||
times_min, ignore_limits=False, timeout=300.0):
|
||||
"""Run a 7-segment jerk-limited S-curve on the ESP that mirrors
|
||||
gplan/buildbotics' planner output exactly.
|
||||
|
||||
Parameters are in the same units the AVR/gplan use:
|
||||
- length_mm: absolute travel in mm (>= 0)
|
||||
- max_accel: mm/min^2
|
||||
- max_jerk: mm/min^3
|
||||
- entry/exit_vel: mm/min
|
||||
- times_min: 7-tuple of section durations in minutes
|
||||
|
||||
ignore_limits sets safe=0 on the ESP - used for jog/move
|
||||
endpoints that may run before homing.
|
||||
|
||||
Blocks until the ESP reports done or aborted. Updates the
|
||||
position mirror and re-publishes state on every reply.
|
||||
"""
|
||||
if signed_steps == 0 or length_mm <= 0:
|
||||
return
|
||||
if not any(times_min):
|
||||
raise AuxAxisError('LINE rejected: all section times are zero')
|
||||
# Build the LINE command. Float formatting matches the AVR's
|
||||
# printf precision (6 sig figs) - that's well above what the
|
||||
# ESP needs given it integrates into a few thousand 4 ms
|
||||
# segments per move.
|
||||
parts = [
|
||||
'LINE',
|
||||
'steps=%d' % int(signed_steps),
|
||||
'length=%.6f' % float(length_mm),
|
||||
'max_accel=%.6f' % float(max_accel_mm_min2),
|
||||
'max_jerk=%.6f' % float(max_jerk_mm_min3),
|
||||
'entry_vel=%.6f' % float(entry_vel_mm_min),
|
||||
'exit_vel=%.6f' % float(exit_vel_mm_min),
|
||||
]
|
||||
for i, t in enumerate(times_min):
|
||||
if t and t > 0:
|
||||
parts.append('t%d=%.9f' % (i, float(t)))
|
||||
if ignore_limits:
|
||||
parts.append('safe=0')
|
||||
cmd = ' '.join(parts)
|
||||
line = self._rpc(cmd, topic='line', timeout=timeout)
|
||||
# line: "done pos=P emitted=N" or "aborted pos=P emitted=N reason=..."
|
||||
if line.startswith('done'):
|
||||
self._pos_steps = self._parse_kv_int(line, 'pos', self._pos_steps)
|
||||
self._publish_state()
|
||||
return
|
||||
# aborted
|
||||
self._pos_steps = self._parse_kv_int(line, 'pos', self._pos_steps)
|
||||
self._publish_state()
|
||||
reason = self._parse_kv_str(line, 'reason')
|
||||
if reason == 'limit':
|
||||
self._homed = False
|
||||
raise AuxAxisError('W move aborted by limit switch')
|
||||
raise AuxAxisError('W move aborted: %s' % line)
|
||||
|
||||
# ------------------------------------------------------------ serial I/O
|
||||
|
||||
def _open(self):
|
||||
@@ -584,26 +312,17 @@ class AuxAxis(object):
|
||||
|
||||
def _push_homecfg(self):
|
||||
c = self._cfg
|
||||
zero_steps = self._mm_to_steps(c['home_position_mm'])
|
||||
# preclear: how far (in steps) the ESP backs off if HOME is
|
||||
# invoked while the limit switch is already tripped. Computed
|
||||
# from home_preclear_mm so the operator configures it in mm.
|
||||
spm = float(c.get('steps_per_mm', 1.0)) or 1.0
|
||||
preclear_steps = int(round(abs(float(c['home_preclear_mm'])) * spm))
|
||||
cmd = ('HOMECFG dir=%s fast=%d slow=%d backoff=%d maxtravel=%d '
|
||||
'zero=%d accel=%d step_max=%d step_start=%d limit_low=%d '
|
||||
'preclear=%d') % (
|
||||
'zero=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(zero_steps),
|
||||
int(c['step_accel_sps2']),
|
||||
int(c['step_max_sps']),
|
||||
int(c['step_start_sps']),
|
||||
1 if c['limit_low'] else 0,
|
||||
preclear_steps,
|
||||
)
|
||||
self._rpc(cmd, topic='homecfg', timeout=3.0)
|
||||
|
||||
@@ -613,28 +332,11 @@ class AuxAxis(object):
|
||||
self._pos_steps = int(r.strip())
|
||||
except Exception:
|
||||
pass
|
||||
# Force the host to start unhomed regardless of what the ESP
|
||||
# remembers from a prior session. The ESP's homed flag survives
|
||||
# bbctrl restarts (since the ESP itself wasn't power-cycled),
|
||||
# but the host's planner offsets and DRO position get reset to
|
||||
# zero on bbctrl boot. Trusting the ESP's homed flag would mean
|
||||
# the user thinks A is homed at the wrong work-coord origin
|
||||
# (offset_a=0 but ESP physically at home_position_mm). Sending
|
||||
# UNHOME forces the user to re-home explicitly, which sets up
|
||||
# the offset and gplan state correctly via the homing path in
|
||||
# Mach.home.
|
||||
try:
|
||||
self._rpc('UNHOME', topic='ok', timeout=2.0)
|
||||
self._homed = False
|
||||
r = self._rpc('HOMED?', topic='homed', timeout=2.0)
|
||||
self._homed = (r.strip() == '1')
|
||||
except Exception:
|
||||
# Fall back to whatever HOMED? says - but treat any
|
||||
# missing UNHOME support as "trust ESP's flag" so we
|
||||
# don't break older firmware.
|
||||
try:
|
||||
r = self._rpc('HOMED?', topic='homed', timeout=2.0)
|
||||
self._homed = (r.strip() == '1')
|
||||
except Exception:
|
||||
pass
|
||||
pass
|
||||
self._publish_state()
|
||||
|
||||
def _reader_loop(self):
|
||||
@@ -671,7 +373,7 @@ class AuxAxis(object):
|
||||
self._present = True
|
||||
self._publish_state()
|
||||
self.ctrl.state.add_message(
|
||||
'Auxiliary axis controller restarted - re-home before use')
|
||||
'W axis controller restarted - re-home before use')
|
||||
return
|
||||
|
||||
# Topic dispatch: "[topic] body..."
|
||||
@@ -692,22 +394,7 @@ class AuxAxis(object):
|
||||
self._pending_replies.append(body)
|
||||
self._pending_cv.notify_all()
|
||||
return
|
||||
# Async informational line.
|
||||
#
|
||||
# The terminal [jog] done|aborted line for a continuous
|
||||
# JOG arrives long after the JOG _rpc returned (the JOG
|
||||
# _rpc only waits for the immediate `[jog] started`
|
||||
# ack). Use this async path to keep _pos_steps in sync
|
||||
# so subsequent moves compute the correct delta.
|
||||
if topic == 'jog' and ('pos=' in body):
|
||||
try:
|
||||
self._pos_steps = self._parse_kv_int(
|
||||
body, 'pos', self._pos_steps)
|
||||
if 'reason=limit' in body:
|
||||
self._homed = False
|
||||
self._publish_state()
|
||||
except Exception:
|
||||
pass
|
||||
# Async informational line; just log.
|
||||
self.log.info('aux: %s' % line)
|
||||
else:
|
||||
self.log.info('aux: %s' % line)
|
||||
@@ -788,11 +475,3 @@ class AuxAxis(object):
|
||||
except Exception:
|
||||
# During very early startup, state may not be ready.
|
||||
pass
|
||||
# Notify the external-axis layer so it can mirror state
|
||||
# (e.g. homed flag) into the synthetic motor vars.
|
||||
observer = getattr(self, '_state_observer', None)
|
||||
if observer is not None:
|
||||
try:
|
||||
observer()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -1,41 +1,25 @@
|
||||
################################################################################
|
||||
#
|
||||
# AuxPreprocessor - rewrite ATC M-codes into hook calls
|
||||
# AuxPreprocessor - rewrite W-axis G-code into hook calls
|
||||
#
|
||||
# History
|
||||
# -------
|
||||
# v1: rewrote W tokens into (MSG,HOOK:aux:N) lines because the bbctrl
|
||||
# planner only understood XYZABC and the W axis was driven via a
|
||||
# side-channel.
|
||||
# v2: W is now exposed to gplan as a virtual A axis (see ExternalAxis),
|
||||
# so gplan handles W motion natively. The preprocessor no longer
|
||||
# touches W tokens. ATC pneumatics still go through the hook
|
||||
# channel because they're events, not motion.
|
||||
# v3: ATC primitives split into atoms. The composite DROPTOOL /
|
||||
# GRABTOOL hooks are gone; macros now compose tool changes from
|
||||
# RELEASE / CLAMP / EJECT.
|
||||
# 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.
|
||||
#
|
||||
# What this still does
|
||||
# --------------------
|
||||
# Maps three user-defined M-codes onto pneumatic-tool-changer atoms:
|
||||
#
|
||||
# M100 EJECT -> (MSG,HOOK:eject:) one V2 ejector pulse
|
||||
# M102 RELEASE -> (MSG,HOOK:release:) open collet (V1 on)
|
||||
# M103 CLAMP -> (MSG,HOOK:clamp:) close collet (V1 off + vent)
|
||||
#
|
||||
# M101 (formerly GRABTOOL) is intentionally unmapped - it's now a
|
||||
# pure host-side macro composed from RELEASE / dwell / CLAMP. If a
|
||||
# legacy file still emits M101 the preprocessor leaves it alone and
|
||||
# the planner ignores it (M101 is in the user-defined range, so it
|
||||
# won't error - it just won't do anything).
|
||||
#
|
||||
# M100-M103 are in LinuxCNC/Buildbotics' user-defined range, so the
|
||||
# planner won't error if the codes leak through unrewritten - it just
|
||||
# won't *do* anything. We strip the recognized ones out and emit the
|
||||
# matching hook line in their place.
|
||||
# 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 is left alone.
|
||||
# understand involving W is left alone with a warning, so motion lands in
|
||||
# gplan which will complain loudly rather than silently misbehaving.
|
||||
#
|
||||
################################################################################
|
||||
|
||||
@@ -45,73 +29,18 @@ import shutil
|
||||
import tempfile
|
||||
|
||||
|
||||
# Strip line comments so we don't get fooled by "(M100 not really)".
|
||||
# Note this is a simple regex and doesn't handle nested parentheses
|
||||
# - which actually occur in real macro headers like
|
||||
# `(Composed from atoms: M102 = RELEASE (V1 on), M103 = CLAMP)`.
|
||||
# Use _strip_comments() below for a parser that does handle them.
|
||||
# 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'\([^)]*\)')
|
||||
|
||||
|
||||
def _strip_comments(line):
|
||||
"""Return `line` with paren comments and the trailing semicolon
|
||||
comment removed. Handles arbitrarily nested parentheses (RS274
|
||||
technically forbids them but real-world gcode comments often
|
||||
contain prose with parens, e.g. `(M102 = RELEASE (V1 on))`).
|
||||
|
||||
Returns just the executable code, with the original whitespace
|
||||
preserved between tokens."""
|
||||
out = []
|
||||
depth = 0
|
||||
i = 0
|
||||
n = len(line)
|
||||
while i < n:
|
||||
c = line[i]
|
||||
if c == ';' and depth == 0:
|
||||
break
|
||||
if c == '(':
|
||||
depth += 1
|
||||
i += 1
|
||||
continue
|
||||
if c == ')':
|
||||
if depth > 0: depth -= 1
|
||||
i += 1
|
||||
continue
|
||||
if depth == 0:
|
||||
out.append(c)
|
||||
i += 1
|
||||
return ''.join(out)
|
||||
|
||||
# ATC pneumatics M-codes mapped onto hook events. M101 is
|
||||
# deliberately unassigned (see header).
|
||||
_ATC_M_CODES = {
|
||||
100: 'eject',
|
||||
102: 'release',
|
||||
103: 'clamp',
|
||||
}
|
||||
_ATC_M_RE = re.compile(
|
||||
r'(?<![A-Za-z_0-9])[Mm]\s*0*(' +
|
||||
'|'.join(str(n) for n in _ATC_M_CODES) +
|
||||
r')(?![\w.])'
|
||||
)
|
||||
|
||||
# Detect a W axis token. We no longer rewrite W to A automatically;
|
||||
# instead we warn so the user knows their old gcode needs migration.
|
||||
# (The W support was removed when the axis was integrated as a real
|
||||
# A axis through gplan.)
|
||||
_W_TOKEN_RE = re.compile(r'(?<![A-Za-z_0-9])[Ww]\s*[-+]?\d*\.?\d+')
|
||||
|
||||
# Match a single axis word (letter + optional whitespace + signed decimal)
|
||||
# for Z, A, X, Y. Used to extract modal targets while preserving the
|
||||
# original line for emission. We deliberately ignore I/J/K/R (arc params)
|
||||
# because they're not endpoints.
|
||||
_AXIS_TOKEN_RES = {
|
||||
'z': re.compile(r'(?<![A-Za-z_0-9])[Zz]\s*([-+]?\d*\.?\d+)'),
|
||||
'a': re.compile(r'(?<![A-Za-z_0-9])[Aa]\s*([-+]?\d*\.?\d+)'),
|
||||
'x': re.compile(r'(?<![A-Za-z_0-9])[Xx]\s*([-+]?\d*\.?\d+)'),
|
||||
'y': re.compile(r'(?<![A-Za-z_0-9])[Yy]\s*([-+]?\d*\.?\d+)'),
|
||||
}
|
||||
_G_CODE_RE = re.compile(r'(?<![A-Za-z_0-9])[Gg]\s*0*(\d+(?:\.\d+)?)')
|
||||
# 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):
|
||||
@@ -119,239 +48,79 @@ class AuxPreprocessorError(Exception):
|
||||
|
||||
|
||||
class AuxPreprocessor(object):
|
||||
def __init__(self, log=None, coupling=None):
|
||||
"""`coupling`, when supplied, enables Z-A coupling injection.
|
||||
Expected shape:
|
||||
{
|
||||
'enabled': bool,
|
||||
'clearance_mm': float, # max (A_wc - Z_wc)
|
||||
'a_initial_wc': float, # A's work-coord position at
|
||||
# file start (typically 0 if
|
||||
# operator zeroed at home)
|
||||
'z_initial_wc': float, # Z's work-coord position at
|
||||
# file start (typically 0)
|
||||
}
|
||||
Pass None to disable injection (preprocessor still rewrites
|
||||
ATC M-codes)."""
|
||||
def __init__(self, log=None, w_first=True):
|
||||
self.log = log
|
||||
self._w_warned = False
|
||||
self._coupling = coupling if (coupling and
|
||||
coupling.get('enabled')) else None
|
||||
# Modal state used while scanning the file.
|
||||
if self._coupling is not None:
|
||||
self._a_wc = float(coupling.get('a_initial_wc', 0.0))
|
||||
self._z_wc = float(coupling.get('z_initial_wc', 0.0))
|
||||
self._K = float(coupling.get('clearance_mm', 0.0))
|
||||
else:
|
||||
self._a_wc = 0.0
|
||||
self._z_wc = 0.0
|
||||
self._K = 0.0
|
||||
self._g91_warned = False
|
||||
# Distance mode: True for absolute (G90), False for incremental
|
||||
# (G91). Per RS274 the modal default at start is G90.
|
||||
self._g90 = True
|
||||
# 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)
|
||||
if self.log:
|
||||
self.log.info(msg)
|
||||
|
||||
def _warn(self, msg):
|
||||
if self.log: self.log.warning(msg)
|
||||
if self.log:
|
||||
self.log.warning(msg)
|
||||
|
||||
# ------------------------------------------------------------------ scan
|
||||
|
||||
@staticmethod
|
||||
def file_uses_aux(path, coupling=None):
|
||||
"""Quick check: does this file contain anything the preprocessor
|
||||
would rewrite? Returns True for ATC M-codes always, and for
|
||||
any Z/A move if coupling is enabled (we have to scan to know
|
||||
whether injection is needed, so any motion file qualifies)."""
|
||||
couple_active = bool(coupling and coupling.get('enabled'))
|
||||
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 = _strip_comments(line)
|
||||
if _ATC_M_RE.search(code):
|
||||
code = _PAREN_COMMENT_RE.sub('', line)
|
||||
code = code.split(';', 1)[0]
|
||||
if _W_TOKEN_RE.search(code):
|
||||
return True
|
||||
if couple_active:
|
||||
if _AXIS_TOKEN_RES['z'].search(code) or \
|
||||
_AXIS_TOKEN_RES['a'].search(code):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
# Backwards-compat alias.
|
||||
file_uses_w = file_uses_aux
|
||||
# ------------------------------------------------------------------ core
|
||||
|
||||
# ------------------------------------------------------------------ Z-A coupling
|
||||
#
|
||||
# Track modal Z and A targets across the file. Whenever a line
|
||||
# would put A above Z by more than `clearance_mm` (i.e. A_wc -
|
||||
# Z_wc > K), we inject `G0 A<safe>` immediately before it so A is
|
||||
# already at the safe position when Z descends. The injected move
|
||||
# uses G0 (rapid) so it's quick.
|
||||
#
|
||||
# Endpoint-only check: gplan plans line endpoints. As long as
|
||||
# (target_A_wc - target_Z_wc) <= K, the trajectory stays safe
|
||||
# because Z's *minimum* during a single line is its endpoint (Z
|
||||
# moves monotonically along a single line block in absolute
|
||||
# mode) and A is held at the pre-positioned value during the move.
|
||||
def _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 _extract_g_codes(self, code):
|
||||
"""Return the set of G-codes referenced on `code`. Numeric
|
||||
only, e.g. {0, 1, 90, 17}. Used to track modal state."""
|
||||
out = set()
|
||||
for m in _G_CODE_RE.finditer(code):
|
||||
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:
|
||||
out.add(int(float(m.group(1))))
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
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.
|
||||
|
||||
def _extract_axis(self, axis, code):
|
||||
"""Return the last value of `axis` token on `code`, or None."""
|
||||
rx = _AXIS_TOKEN_RES.get(axis)
|
||||
if rx is None:
|
||||
return None
|
||||
last = None
|
||||
for m in rx.finditer(code):
|
||||
try:
|
||||
last = float(m.group(1))
|
||||
except Exception:
|
||||
pass
|
||||
return last
|
||||
@staticmethod
|
||||
def _is_g28_like(code):
|
||||
# Match G28 or G28.2 (homing).
|
||||
return bool(re.search(r'(?<![A-Za-z_0-9])[Gg]\s*0*28(?:\.2)?(?![\w.])', code))
|
||||
|
||||
def _maybe_inject_a_down(self, code, fout):
|
||||
"""Inspect `code` (with comments stripped) for an upcoming Z
|
||||
descent; emit a `G0 A<safe>` line on `fout` if needed and
|
||||
update self._a_wc accordingly. Returns True if anything was
|
||||
injected.
|
||||
|
||||
On a violation that cannot be fixed by lowering A (e.g. the
|
||||
operator wrote `G0 A0` while Z is too deep), raise
|
||||
AuxPreprocessorError so the file load surfaces the problem -
|
||||
per the rule we agreed: error, don't silently insert a Z-up.
|
||||
"""
|
||||
if self._coupling is None:
|
||||
return False
|
||||
|
||||
# Distance mode tracking.
|
||||
gs = self._extract_g_codes(code)
|
||||
if 90 in gs: self._g90 = True
|
||||
if 91 in gs:
|
||||
if self._g90 and not self._g91_warned:
|
||||
self._warn(
|
||||
'AuxPreprocessor: G91 (incremental mode) detected; '
|
||||
'Z-A coupling injection is disabled for the rest of '
|
||||
'the file. The runtime check still applies.')
|
||||
self._g91_warned = True
|
||||
self._g90 = False
|
||||
|
||||
# G92 sets coordinate offsets. The new modal value of an
|
||||
# axis is whatever value follows on the same word (e.g.
|
||||
# G92 A0 sets A_wc = 0). Apply that and skip injection.
|
||||
if 92 in gs:
|
||||
new_a = self._extract_axis('a', code)
|
||||
new_z = self._extract_axis('z', code)
|
||||
if new_a is not None: self._a_wc = new_a
|
||||
if new_z is not None: self._z_wc = new_z
|
||||
return False
|
||||
|
||||
# In incremental mode we can still track approximately, but
|
||||
# the user has been warned; skip injection.
|
||||
if not self._g90:
|
||||
return False
|
||||
|
||||
new_z_target = self._extract_axis('z', code)
|
||||
new_a_target = self._extract_axis('a', code)
|
||||
if new_z_target is None and new_a_target is None:
|
||||
return False
|
||||
|
||||
# Modal values after the line executes.
|
||||
a_after = new_a_target if new_a_target is not None else self._a_wc
|
||||
z_after = new_z_target if new_z_target is not None else self._z_wc
|
||||
|
||||
eps = 1e-4
|
||||
if a_after - z_after <= self._K + eps:
|
||||
# Move is safe as authored. Update modal state.
|
||||
self._a_wc = a_after
|
||||
self._z_wc = z_after
|
||||
return False
|
||||
|
||||
# Violation. Two cases:
|
||||
#
|
||||
# (a) The line lowers Z (z_after < self._z_wc) and A is
|
||||
# held or moved upward, so A needs to drop to keep up.
|
||||
# We can fix this by pre-positioning A at z_after + K
|
||||
# BEFORE the line - at which point gplan's plan for the
|
||||
# line is safe at every point along it.
|
||||
#
|
||||
# (b) The line raises A above the safe band while Z is
|
||||
# held (z_after >= self._z_wc) - i.e. the operator
|
||||
# wrote `G0 A0` while Z is parked deep. Auto-injecting
|
||||
# a Z-up here is unsafe (Z could swing into a fixture
|
||||
# or the part) so we error out and let the operator
|
||||
# author the lift.
|
||||
|
||||
safe_a = z_after + self._K
|
||||
|
||||
# If the line itself targets an A above the safe band, the
|
||||
# endpoint violates the rule no matter what we pre-position.
|
||||
# Refuse rather than emit something that runs the gantry into
|
||||
# the tool.
|
||||
if new_a_target is not None and new_a_target > safe_a + eps:
|
||||
raise AuxPreprocessorError(
|
||||
'Z-A coupling violation: line targets A=%.3f at '
|
||||
'Z=%.3f, but max A allowed is %.3f (clearance %.3f). '
|
||||
'Lower the A target or add a Z-up move first.' % (
|
||||
new_a_target, z_after, safe_a, self._K))
|
||||
|
||||
# If the line raises A above the current safe band but Z
|
||||
# isn't dropping with it (no Z target on the line, or Z stays
|
||||
# put), the violation is the operator's A-up, not a Z-down.
|
||||
# Refuse rather than insert a Z-up (which could swing through
|
||||
# a fixture or part).
|
||||
if (new_a_target is not None and
|
||||
new_a_target > self._a_wc + eps and
|
||||
new_z_target is None):
|
||||
raise AuxPreprocessorError(
|
||||
'Z-A coupling violation at line raising A to %.3f '
|
||||
'while Z is at %.3f (max A allowed is %.3f given '
|
||||
'clearance %.3f). Add a Z-up move first.' % (
|
||||
new_a_target, z_after, safe_a, self._K))
|
||||
|
||||
# Case (a): pre-position A.
|
||||
# Don't move A *up* as part of pre-position - if the safe
|
||||
# value is above where A already is, we'd lift A into a
|
||||
# potential collision elsewhere. In practice safe_a < a_wc
|
||||
# whenever we get here (otherwise no violation), but assert
|
||||
# to be sure.
|
||||
if safe_a > self._a_wc + eps:
|
||||
raise AuxPreprocessorError(
|
||||
'Z-A coupling: cannot fix line by lowering A '
|
||||
'(safe A = %.3f > current A = %.3f).' % (
|
||||
safe_a, self._a_wc))
|
||||
fout.write('(injected by AuxPreprocessor: Z-A coupling)\n')
|
||||
fout.write('G0 A%.4f\n' % safe_a)
|
||||
self._a_wc = safe_a
|
||||
# Don't update z_wc yet - the original line will do that
|
||||
# when it runs. But our modal copy must reflect the post-line
|
||||
# value so subsequent injections compute correctly.
|
||||
self._z_wc = z_after
|
||||
# If the original line also moved A, our pre-positioning
|
||||
# supersedes it (we overwrite a_wc above with safe_a then
|
||||
# the original line's A target may push it back up). Update
|
||||
# a_wc to the line's authored A value so further checks see
|
||||
# the post-line state.
|
||||
if new_a_target is not None:
|
||||
self._a_wc = new_a_target
|
||||
return True
|
||||
@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."""
|
||||
"""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, \
|
||||
@@ -360,148 +129,109 @@ class AuxPreprocessor(object):
|
||||
line = raw.rstrip('\n')
|
||||
|
||||
# Comment-only or blank lines pass through verbatim.
|
||||
code = _strip_comments(line)
|
||||
code = _PAREN_COMMENT_RE.sub('', line)
|
||||
code = code.split(';', 1)[0]
|
||||
if not code.strip():
|
||||
fout.write(raw)
|
||||
continue
|
||||
|
||||
# Warn (once) if the file still uses W tokens. The
|
||||
# standard way is now G1 A<value>; old files must be
|
||||
# migrated by hand.
|
||||
if (not self._w_warned) and _W_TOKEN_RE.search(code):
|
||||
self._warn('Found W axis token in gcode; W is no '
|
||||
'longer recognized by bbctrl. Use A '
|
||||
'instead. (warning suppressed for '
|
||||
'subsequent W tokens in this file)')
|
||||
self._w_warned = True
|
||||
# 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)
|
||||
|
||||
# Z-A coupling injection BEFORE the line is emitted.
|
||||
if self._maybe_inject_a_down(code, fout):
|
||||
rewrote_any = True
|
||||
|
||||
# ATC M-codes (M100/M102/M103). Match against the
|
||||
# comment-stripped `code` so prose mentions like
|
||||
# `(M102 = RELEASE)` inside a comment don't spuriously
|
||||
# fire hooks. Each match emits a (MSG,HOOK:<event>:)
|
||||
# line; the M-code is stripped from the executable
|
||||
# residual but the original line's comments are kept
|
||||
# for log readability.
|
||||
atc_matches = list(_ATC_M_RE.finditer(code))
|
||||
if atc_matches:
|
||||
rewrote_any = True
|
||||
for m in atc_matches:
|
||||
try: num = int(m.group(1))
|
||||
except ValueError: continue
|
||||
event = _ATC_M_CODES.get(num)
|
||||
if event:
|
||||
# We need two things here that aren't
|
||||
# naturally provided by the (MSG,...)
|
||||
# transport:
|
||||
#
|
||||
# (1) Synchronization. (MSG,HOOK:...) is
|
||||
# fire-and-forget from gplan's view -
|
||||
# gplan emits the message and keeps
|
||||
# streaming subsequent blocks (Z
|
||||
# moves, the next eject, etc.) to the
|
||||
# AVR. Meanwhile the hook handler
|
||||
# runs the actual ESP RPC in a
|
||||
# thread, and Z lifts while V2 is
|
||||
# still wiggling. To make M-codes
|
||||
# behave like proper blocking gcode,
|
||||
# we precede each HOOK with M0
|
||||
# (program pause). The Hooks layer
|
||||
# registers the atom as block_unpause
|
||||
# + auto_resume, so:
|
||||
# M0 -> machine pauses
|
||||
# (MSG,HOOK:event:) fires hook
|
||||
# hook thread runs ESP RPC
|
||||
# hook completes, auto-unpauses
|
||||
# next block streams
|
||||
# End result: M100/M102/M103 block
|
||||
# until the ESP says done, just like
|
||||
# a G-code dwell.
|
||||
#
|
||||
# (2) Block separation. gplan collapses
|
||||
# consecutive comment-only lines
|
||||
# into a single block, so back-to-
|
||||
# back HOOK lines used to drop all
|
||||
# but the last. M0 is its own block
|
||||
# so this falls out automatically -
|
||||
# the (MSG,...) attaches cleanly to
|
||||
# each M0.
|
||||
fout.write('M0 (MSG,HOOK:%s:)\n' % event)
|
||||
code_stripped = _ATC_M_RE.sub('', code).strip()
|
||||
if code_stripped:
|
||||
# Mixed line: keep the residual executable
|
||||
# gcode. Drop the comments to keep the
|
||||
# rewritten file tidy (the original line's
|
||||
# text already appears once as the input).
|
||||
fout.write(code_stripped + '\n')
|
||||
if not _W_TOKEN_RE.search(code):
|
||||
fout.write(raw)
|
||||
continue
|
||||
|
||||
# No rewrite needed.
|
||||
fout.write(raw)
|
||||
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 preprocess_to_tempfile(src_path, log=None, coupling=None):
|
||||
"""Run the preprocessor on `src_path` and return the path to a
|
||||
rewritten temp file (or None if no rewriting was needed). Caller
|
||||
owns the temp file and must os.unlink() it when done.
|
||||
def _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
|
||||
|
||||
The original source file is never modified - this is the
|
||||
intentional design: the macro / job file the operator authored
|
||||
is what they see in the macro editor and the file viewer; the
|
||||
rewriting happens only on the in-memory copy that gplan loads.
|
||||
|
||||
Why we rewrite at all: gplan (the camotics planner) treats the
|
||||
user-defined M-codes M100/M102/M103 as no-ops. The only callback
|
||||
channel it exposes during a running program is the (MSG,...)
|
||||
message stream, so the only way for the host to react to those
|
||||
M-codes mid-program is to substitute (MSG,HOOK:<event>:) lines
|
||||
in their place. This rewriting is an implementation detail the
|
||||
operator should never have to know about - hence the tempfile.
|
||||
"""
|
||||
if not AuxPreprocessor.file_uses_aux(src_path, coupling=coupling):
|
||||
return None
|
||||
pre = AuxPreprocessor(log=log, coupling=coupling)
|
||||
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:
|
||||
return tmp
|
||||
shutil.move(tmp, src_path)
|
||||
return True
|
||||
os.unlink(tmp)
|
||||
return None
|
||||
except Exception:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def preprocess_file(src_path, log=None, coupling=None, **_unused):
|
||||
"""DEPRECATED in-place version of the preprocessor. Kept for
|
||||
callers that still rewrite their input on disk (chiefly the
|
||||
upload path, where mutating the file is fine because there's no
|
||||
operator-authored source to preserve).
|
||||
|
||||
Returns True if the file was rewritten, False otherwise.
|
||||
|
||||
For new callers prefer preprocess_to_tempfile() which never
|
||||
touches the source."""
|
||||
tmp = preprocess_to_tempfile(src_path, log=log, coupling=coupling)
|
||||
if tmp is None:
|
||||
return False
|
||||
try:
|
||||
shutil.move(tmp, src_path)
|
||||
except Exception:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
return True
|
||||
|
||||
@@ -223,10 +223,6 @@ 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)
|
||||
|
||||
@@ -216,32 +216,6 @@ class Config(object):
|
||||
defaults = json.load(f)
|
||||
config['selected-tool-settings'] = defaults['selected-tool-settings'];
|
||||
|
||||
# Auxiliary axis nomenclature: rename W -> A in macro names and
|
||||
# filenames. The auxcnc-driven stepper has been integrated into
|
||||
# gplan as A since the option-b migration; old configs may
|
||||
# still carry W Down/W Up macro entries pointing at
|
||||
# w_down.nc/w_up.nc which were renamed on disk to a_down.nc /
|
||||
# a_up.nc. Migrate idempotently on every load so a stale
|
||||
# in-memory copy can never reintroduce the old names.
|
||||
macros = config.get('macros') if isinstance(config, dict) else None
|
||||
if isinstance(macros, list):
|
||||
renames = {
|
||||
'w_down.nc': 'a_down.nc',
|
||||
'w_up.nc': 'a_up.nc',
|
||||
}
|
||||
display_renames = {
|
||||
'W Down': 'A Down',
|
||||
'W Up': 'A Up',
|
||||
}
|
||||
for m in macros:
|
||||
if not isinstance(m, dict): continue
|
||||
fn = m.get('file_name')
|
||||
if isinstance(fn, str) and fn in renames:
|
||||
m['file_name'] = renames[fn]
|
||||
nm = m.get('name')
|
||||
if isinstance(nm, str) and nm in display_renames:
|
||||
m['name'] = display_renames[nm]
|
||||
|
||||
config['version'] = self.version.split('b')[0]
|
||||
config['full_version'] = self.version
|
||||
|
||||
|
||||
@@ -28,12 +28,10 @@
|
||||
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
|
||||
@@ -45,65 +43,34 @@ 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:
|
||||
with Trace.span('ctrl.avr'):
|
||||
if args.demo: self.avr = bbctrl.AVREmu(self)
|
||||
else: self.avr = bbctrl.AVR(self)
|
||||
if args.demo: self.avr = bbctrl.AVREmu(self)
|
||||
else: self.avr = bbctrl.AVR(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)
|
||||
with Trace.span('ctrl.ext_axis'):
|
||||
# ExternalAxis exposes the auxcnc ESP stepper as a
|
||||
# virtual A axis that gplan handles natively. Created
|
||||
# unconditionally so State sees the synthetic motor
|
||||
# vars even when aux is disabled (kept inert in that
|
||||
# case via ext_axis.enabled).
|
||||
axis_letter = self.aux._cfg.get('axis_letter', 'a')
|
||||
self.ext_axis = bbctrl.ExternalAxis(
|
||||
self, self.aux, axis_letter=axis_letter)
|
||||
# Hook AuxAxis post-publish callback so homed flag
|
||||
# mirrors into State after homing.
|
||||
self.aux.set_state_observer(
|
||||
self.ext_axis.refresh_homed)
|
||||
self.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)
|
||||
self.hooks = bbctrl.Hooks(self)
|
||||
self.aux = bbctrl.AuxAxis(self)
|
||||
self._register_aux_hooks()
|
||||
|
||||
with Trace.span('ctrl.mach.connect'):
|
||||
self.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()
|
||||
|
||||
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')
|
||||
except Exception: self.log.get('Ctrl').exception('Internal error: Control initialization failed')
|
||||
|
||||
|
||||
def __del__(self): print('Ctrl deleted')
|
||||
@@ -146,66 +113,38 @@ class Ctrl(object):
|
||||
|
||||
|
||||
def _register_aux_hooks(self):
|
||||
"""Wire up auxcnc HOOK: events to AuxAxis methods.
|
||||
|
||||
v2: motion hooks (aux/aux_rel/aux_home/aux_setzero) are
|
||||
retired now that the W axis is integrated through gplan as
|
||||
a virtual A axis (see ExternalAxis). Only the ATC pneumatic
|
||||
hooks remain - those are events, not motion.
|
||||
|
||||
For backwards compatibility with files that still contain
|
||||
(MSG,HOOK:aux_home:) (e.g. older preprocessed gcode), keep
|
||||
an aux_home alias that routes to the standard ext_axis homing
|
||||
path."""
|
||||
"""Wire up the auxcnc HOOK: events to AuxAxis methods."""
|
||||
log = self.log.get('AuxAxis')
|
||||
|
||||
def _hook_aux_home(ctx):
|
||||
# Legacy: route to the standard external-axis homing.
|
||||
if self.ext_axis is not None and self.ext_axis.enabled:
|
||||
self.ext_axis.home()
|
||||
else:
|
||||
self.aux.home()
|
||||
|
||||
def _hook_release(ctx): self.aux.atc_release()
|
||||
def _hook_clamp(ctx): self.aux.atc_clamp()
|
||||
|
||||
def _hook_eject(ctx):
|
||||
# ctx['data'] is the payload after HOOK:eject:. Allow
|
||||
# operators to override pulse / dwell from gcode via
|
||||
# (MSG,HOOK:eject:pulse=400 dwell=300). Empty data ->
|
||||
# ESP defaults.
|
||||
def _hook_move(ctx):
|
||||
data = (ctx.get('data') or '').strip()
|
||||
kw = {}
|
||||
for tok in data.split():
|
||||
if '=' not in tok: continue
|
||||
k, v = tok.split('=', 1)
|
||||
k = k.strip().lower()
|
||||
if k in ('pulse', 'pulse_ms'):
|
||||
try: kw['pulse_ms'] = int(v)
|
||||
except ValueError: pass
|
||||
elif k in ('dwell', 'dwell_ms'):
|
||||
try: kw['dwell_ms'] = int(v)
|
||||
except ValueError: pass
|
||||
self.aux.atc_eject(**kw)
|
||||
if not data:
|
||||
raise Exception('aux hook missing target')
|
||||
self.aux.move_abs_mm(float(data))
|
||||
|
||||
# Legacy alias for older gcode that used aux_home.
|
||||
self.hooks.register_internal('aux_home', _hook_aux_home,
|
||||
def _hook_move_rel(ctx):
|
||||
data = (ctx.get('data') or '').strip()
|
||||
if not data:
|
||||
raise Exception('aux_rel hook missing delta')
|
||||
self.aux.move_rel_mm(float(data))
|
||||
|
||||
def _hook_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)
|
||||
|
||||
# ATC pneumatic atoms. block_unpause + auto_resume so a
|
||||
# program using M100/M102/M103 pauses at the right point and
|
||||
# resumes once each atom finishes. Macros compose drop/grab
|
||||
# sequences from these primitives.
|
||||
self.hooks.register_internal('release', _hook_release,
|
||||
block_unpause=True, auto_resume=True,
|
||||
timeout=10)
|
||||
self.hooks.register_internal('clamp', _hook_clamp,
|
||||
block_unpause=True, auto_resume=True,
|
||||
timeout=15)
|
||||
self.hooks.register_internal('eject', _hook_eject,
|
||||
block_unpause=True, auto_resume=True,
|
||||
timeout=15)
|
||||
self.hooks.register_internal('aux_setzero', _hook_setzero,
|
||||
block_unpause=True, auto_resume=True)
|
||||
log.info('Aux hooks registered')
|
||||
|
||||
|
||||
@@ -214,7 +153,5 @@ class Ctrl(object):
|
||||
self.ioloop.close()
|
||||
self.avr.close()
|
||||
self.mach.planner.close()
|
||||
try: self.ext_axis.close()
|
||||
except Exception: pass
|
||||
try: self.aux.close()
|
||||
except Exception: pass
|
||||
|
||||
@@ -1,677 +0,0 @@
|
||||
################################################################################
|
||||
#
|
||||
# ExternalAxis - bridges a logical motorless axis to step generation on
|
||||
# the auxcnc ESP, so the Buildbotics planner can drive a stepper that
|
||||
# isn't on the AVR.
|
||||
#
|
||||
# Architecture
|
||||
# ------------
|
||||
# The bbctrl planner (camotics gplan) handles parsing, units, modal
|
||||
# state, soft limits, accel ramping and S-curve timing for axes
|
||||
# X, Y, Z, A, B, C. The AVR has 4 motor channels (0-3) and only
|
||||
# generates step pulses for axes that have a motor mapped to them.
|
||||
# An axis with no mapped motor is fully accepted by the AVR - it
|
||||
# updates its internal `ex.position[axis]` and reports `<axis>p` to
|
||||
# the host, but no stepper turns.
|
||||
#
|
||||
# We exploit that: the W stepper is exposed to gplan as A, but no
|
||||
# AVR motor maps to A. The planner does all the gcode-level work
|
||||
# correctly (G90/G91, soft limits, accel, units, modal feed rate);
|
||||
# we intercept the resulting `Cmd.line` blocks in `Planner.__encode`,
|
||||
# strip A out, and forward the A delta to the auxcnc ESP as STEPS.
|
||||
#
|
||||
# To make gplan and State *believe* A is enabled we register a
|
||||
# synthetic motor (index 4) into State.vars, populated from
|
||||
# aux.json, with `4an=3` (axis A), `4me=1` (enabled), and the
|
||||
# usual velocity/accel/jerk/soft-limit vars. State.find_motor and
|
||||
# the snapshot projection are extended to walk index 4. Motor-4
|
||||
# vars never leave the host (they're not in the AVR's schema) so
|
||||
# the AVR is undisturbed.
|
||||
#
|
||||
# v1 coupling: serialize. If a line has any A delta we wait for
|
||||
# the ESP to finish before letting subsequent commands flow. This
|
||||
# matches the behaviour of the previous hook-based approach (no
|
||||
# XYZ+A blending) but with all the planner's correctness guarantees.
|
||||
#
|
||||
# v2 could match ESP move duration to the gplan trapezoid time and
|
||||
# allow concurrent motion; out of scope for v1.
|
||||
#
|
||||
################################################################################
|
||||
|
||||
import threading
|
||||
|
||||
try:
|
||||
from queue import Queue
|
||||
except ImportError:
|
||||
from Queue import Queue # py2 just in case
|
||||
|
||||
|
||||
# Synthetic motor index used to expose the external axis to State.
|
||||
# The AVR has motors 0..3; we use 4 as a host-only sentinel.
|
||||
EXTERNAL_MOTOR_INDEX = 4
|
||||
|
||||
# Axis letters in their canonical order; 'a' is index 3.
|
||||
_AXIS_LETTERS = 'xyzabc'
|
||||
|
||||
|
||||
class ExternalAxisError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ExternalAxis(object):
|
||||
"""Bridge between Planner line blocks and AuxAxis serial RPCs.
|
||||
|
||||
Owns no thread; runs RPC calls inline on whatever thread invokes
|
||||
execute_to_mm / home / abort. The Planner runs `__encode` on its
|
||||
own thread which is allowed to block on planner I/O, so blocking
|
||||
inside the interceptor is fine.
|
||||
|
||||
Position tracking: gplan emits absolute targets in mm; the ESP
|
||||
counts steps relative to home_zero. We mirror the last commanded
|
||||
mm position so subsequent line blocks compute the correct delta.
|
||||
`_pos_mm` is also published as `<axis>p` so DRO updates."""
|
||||
|
||||
def __init__(self, ctrl, aux, axis_letter='a'):
|
||||
self.ctrl = ctrl
|
||||
self.aux = aux
|
||||
self.log = ctrl.log.get('ExternalAxis')
|
||||
|
||||
self.axis_letter = (axis_letter or 'a').lower()[:1]
|
||||
if self.axis_letter not in _AXIS_LETTERS:
|
||||
raise ExternalAxisError(
|
||||
'Invalid external axis letter: %r' % axis_letter)
|
||||
# Index in 'xyzabc' (0..5)
|
||||
self.axis_index = _AXIS_LETTERS.index(self.axis_letter)
|
||||
|
||||
self._busy = threading.Event()
|
||||
# Last absolute mm we committed; None until first move /
|
||||
# homing event syncs us up.
|
||||
self._pos_mm = None
|
||||
|
||||
# Single-slot worker queue: __encode posts (target_mm,) tuples
|
||||
# here; the worker thread runs the ESP RPC. Capacity is
|
||||
# intentionally bounded - if it fills it means motion is
|
||||
# outpacing the ESP and we should backpressure the planner.
|
||||
self._work_q = Queue(maxsize=64)
|
||||
self._stop = threading.Event()
|
||||
self._worker = threading.Thread(
|
||||
target=self._worker_loop,
|
||||
name='ExternalAxis-worker', daemon=True)
|
||||
self._worker.start()
|
||||
|
||||
# Push synthetic motor vars into State so the planner sees
|
||||
# this axis as enabled with proper limits/velocity/accel.
|
||||
self._publish_synthetic_motor()
|
||||
# Also seed <axis>p so the DRO has something to render.
|
||||
self.ctrl.state.set(self.axis_letter + 'p', 0.0)
|
||||
|
||||
# -------------------------------------------------------------- enabled
|
||||
|
||||
@property
|
||||
def enabled(self):
|
||||
try:
|
||||
return bool(self.aux is not None
|
||||
and self.aux.enabled
|
||||
and self.aux.present)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
# -------------------------------------------------------- configuration
|
||||
|
||||
@property
|
||||
def steps_per_mm(self):
|
||||
try:
|
||||
return float(self.aux._cfg.get('steps_per_mm', 25.0))
|
||||
except Exception:
|
||||
return 25.0
|
||||
|
||||
@property
|
||||
def dir_sign(self):
|
||||
try:
|
||||
v = int(self.aux._cfg.get('dir_sign', 1))
|
||||
return -1 if v < 0 else 1
|
||||
except Exception:
|
||||
return 1
|
||||
|
||||
@property
|
||||
def home_position_mm(self):
|
||||
try:
|
||||
return float(self.aux._cfg.get('home_position_mm', 0.0))
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
# ------------------------------------------------------- soft limits
|
||||
|
||||
def _soft_limits(self):
|
||||
"""Return (min_mm, max_mm) in machine coords, or (None, None)
|
||||
if soft limits are disabled (max <= min)."""
|
||||
try:
|
||||
lo = float(self.aux._cfg.get('min_mm', 0.0))
|
||||
hi = float(self.aux._cfg.get('max_mm', 0.0))
|
||||
except Exception:
|
||||
return (None, None)
|
||||
if hi <= lo:
|
||||
return (None, None)
|
||||
return (lo, hi)
|
||||
|
||||
def _check_soft_limit(self, target_abs_mm):
|
||||
"""Raise ExternalAxisError if target_abs_mm is outside the
|
||||
configured soft limits. Skips the check when the axis isn't
|
||||
homed (matching the standard bbctrl convention that soft
|
||||
limits are gated by homing state) - that lets the user jog
|
||||
away from a stuck position before homing without false
|
||||
rejections.
|
||||
|
||||
Called by both planner-driven motion (enqueue_target_mm) and
|
||||
UI motion (execute_to_mm), so this is the single source of
|
||||
truth regardless of which path triggered the move."""
|
||||
# Honour the homing gate.
|
||||
try:
|
||||
homed = bool(self.aux._homed)
|
||||
except Exception:
|
||||
homed = False
|
||||
if not homed:
|
||||
return
|
||||
lo, hi = self._soft_limits()
|
||||
if lo is None:
|
||||
return
|
||||
# Use a tiny epsilon so floating-point round-trip targets
|
||||
# right at the boundary aren't rejected.
|
||||
eps = 1e-4
|
||||
target = float(target_abs_mm)
|
||||
if target < lo - eps or target > hi + eps:
|
||||
raise ExternalAxisError(
|
||||
'%s axis target %.4f mm is outside soft limits '
|
||||
'[%.3f, %.3f] mm' % (
|
||||
self.axis_letter.upper(), target, lo, hi))
|
||||
|
||||
# ----------------------------------------------- Z-A coupling
|
||||
#
|
||||
# The auxiliary tool hangs below the Z spindle. Beyond a small
|
||||
# Z descent the two collide unless A drops with Z. The
|
||||
# constraint, in machine coords, is
|
||||
#
|
||||
# A_machine - Z_machine <= K
|
||||
# K = (A_home_mm - z_home_mm) + couple_z_clearance_mm
|
||||
#
|
||||
# Enforced before any motion (planner blocks, MDI, jogs). The
|
||||
# AuxPreprocessor injects pre-position A moves into uploaded
|
||||
# files so well-formed gcode runs without having to think about
|
||||
# this. Disabled when couple_z_enabled is false.
|
||||
|
||||
@property
|
||||
def couple_z_enabled(self):
|
||||
try:
|
||||
return bool(self.aux._cfg.get('couple_z_enabled', False))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@property
|
||||
def couple_K(self):
|
||||
"""Limit constant K (machine-coord units): the maximum value
|
||||
of (A_machine - Z_machine) before the tool collides. Returns
|
||||
None if the rule isn't applicable (coupling disabled or
|
||||
config missing)."""
|
||||
try:
|
||||
cfg = self.aux._cfg
|
||||
clearance = float(cfg.get('couple_z_clearance_mm', 0.0))
|
||||
a_home = float(cfg.get('home_position_mm', 0.0))
|
||||
z_home = float(cfg.get('z_home_mm', 0.0))
|
||||
return (a_home - z_home) + clearance
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@property
|
||||
def couple_clearance_mm(self):
|
||||
"""Raw clearance from config: how far Z may travel below its
|
||||
home before A has to start dropping with it. Used by the
|
||||
AuxPreprocessor to inject pre-position A moves into uploaded
|
||||
gcode."""
|
||||
try:
|
||||
return float(self.aux._cfg.get('couple_z_clearance_mm', 0.0))
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
def _z_machine_now(self):
|
||||
"""Read Z's current machine position from State, or None if
|
||||
Z isn't homed/reported yet. The AVR reports absolute machine
|
||||
positions in <axis>p; the work-coord display is computed by
|
||||
the UI as zp - offset_z, but here we want machine directly."""
|
||||
try:
|
||||
st = self.ctrl.state
|
||||
zp = st.get('zp', None)
|
||||
if zp is None:
|
||||
return None
|
||||
return float(zp)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _a_machine_now(self):
|
||||
"""A's current machine position. ExternalAxis tracks this
|
||||
directly in self._pos_mm (mm in machine coords - we don't
|
||||
apply G92 to A internally; offset_a is informational)."""
|
||||
try:
|
||||
if self._pos_mm is not None:
|
||||
return float(self._pos_mm)
|
||||
# Fall back to whatever the ESP last reported.
|
||||
return float(self.aux.position_mm)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def coupling_for_preprocessor(self):
|
||||
"""Return the dict the AuxPreprocessor wants for in-file
|
||||
injection, or None when coupling is off. We assume the
|
||||
operator authors gcode in a frame where the at-home position
|
||||
is A_wc=0, Z_wc=0 - which matches our home-zeroed setup.
|
||||
Files that use a different convention will fall through to
|
||||
the runtime check."""
|
||||
if not self.couple_z_enabled:
|
||||
return None
|
||||
return {
|
||||
'enabled': True,
|
||||
'clearance_mm': self.couple_clearance_mm,
|
||||
'a_initial_wc': 0.0,
|
||||
'z_initial_wc': 0.0,
|
||||
}
|
||||
|
||||
def check_coupling(self, target_a_machine=None, target_z_machine=None):
|
||||
"""Validate that a proposed motion respects the Z-A coupling.
|
||||
|
||||
Each argument is a target *machine* mm position; pass None to
|
||||
keep the current value of that axis.
|
||||
|
||||
Improvement-aware: a move is rejected only when it *worsens*
|
||||
an already-violating state (or moves a healthy state into
|
||||
violation). Pure XY jogs that touch neither Z nor A are not
|
||||
passed through here; jogs that hold Z or A at their current
|
||||
value (gplan emits the unchanged value in `target`) pass
|
||||
because (a-z) doesn't change. Z-up moves while in violation
|
||||
also pass because they reduce (a-z) toward the bound.
|
||||
|
||||
Raises ExternalAxisError on violation. Skipped when coupling
|
||||
is disabled, the aux axis isn't homed, or current positions
|
||||
aren't yet known.
|
||||
"""
|
||||
if not self.couple_z_enabled:
|
||||
return
|
||||
try:
|
||||
homed = bool(self.aux._homed)
|
||||
except Exception:
|
||||
homed = False
|
||||
if not homed:
|
||||
return
|
||||
K = self.couple_K
|
||||
if K is None:
|
||||
return
|
||||
a_now = self._a_machine_now()
|
||||
z_now = self._z_machine_now()
|
||||
if a_now is None or z_now is None:
|
||||
return
|
||||
a_after = (float(target_a_machine)
|
||||
if target_a_machine is not None else a_now)
|
||||
z_after = (float(target_z_machine)
|
||||
if target_z_machine is not None else z_now)
|
||||
eps = 1e-4
|
||||
gap_after = a_after - z_after
|
||||
gap_before = a_now - z_now
|
||||
# Only refuse when (a) the resulting state would violate the
|
||||
# constraint AND (b) the move makes things at least as bad
|
||||
# as the current state. This lets the operator escape an
|
||||
# already-violating state by moving in the right direction
|
||||
# (Z up, A down).
|
||||
if gap_after > K + eps and gap_after > gap_before - eps:
|
||||
raise ExternalAxisError(
|
||||
'Z-A coupling violation: A=%.3f mm and Z=%.3f mm '
|
||||
'(machine) would put A above Z by %.3f mm; max '
|
||||
'allowed is %.3f mm. Drop A or raise Z first.' % (
|
||||
a_after, z_after, gap_after, K))
|
||||
|
||||
# ----------------------------------------------------------- conversion
|
||||
|
||||
def mm_to_steps_delta(self, delta_mm):
|
||||
return int(round(float(delta_mm) * self.steps_per_mm * self.dir_sign))
|
||||
|
||||
def steps_to_mm(self, steps):
|
||||
return (float(steps) / self.steps_per_mm) * self.dir_sign
|
||||
|
||||
# ---------------------------------------------------- synthetic motor
|
||||
|
||||
def _publish_synthetic_motor(self):
|
||||
"""Write motor-4 vars into State so find_motor('a') and
|
||||
get_axis_vector('vm') see A as a real axis. The AVR never
|
||||
sees these (motor index 4 is not in its var schema)."""
|
||||
cfg = self.aux._cfg if self.aux is not None else {}
|
||||
st = self.ctrl.state
|
||||
i = str(EXTERNAL_MOTOR_INDEX)
|
||||
|
||||
# Axis assignment: 'an' is the 0-based axis index in xyzabc.
|
||||
st.set(i + 'an', self.axis_index)
|
||||
# Motor enabled.
|
||||
st.set(i + 'me', 1 if (self.aux and self.aux.enabled) else 0)
|
||||
# Homed flag - cleared until aux reports homed.
|
||||
try:
|
||||
homed = bool(self.aux._homed)
|
||||
except Exception:
|
||||
homed = False
|
||||
st.set(i + 'h', 1 if homed else 0)
|
||||
|
||||
# Velocity / accel / jerk: the planner reads these via
|
||||
# state.get_axis_vector('<code>', SCALE) which multiplies the
|
||||
# stored raw value by SCALE. The bbctrl convention (matching
|
||||
# what motors 0-3 store) is:
|
||||
# vm: stored in m/min, planner expects mm/min (scale 1000)
|
||||
# am: stored in km/min^2, planner expects mm/min^2 (scale 1e6)
|
||||
# jm: stored in km/min^3, planner expects mm/min^3 (scale 1e6)
|
||||
# Onefinity defaults for XY are vm=10, am=750, jm=1000. We
|
||||
# follow the same convention; aux.json exposes the values in
|
||||
# those user-facing units so they're directly comparable.
|
||||
st.set(i + 'vm', float(cfg.get('max_velocity_m_per_min', 6.0)))
|
||||
st.set(i + 'am', float(cfg.get('max_accel_km_per_min2', 100.0)))
|
||||
st.set(i + 'jm', float(cfg.get('max_jerk_km_per_min3', 500.0)))
|
||||
|
||||
# Soft limits in machine units (mm). State.get_soft_limit_vector
|
||||
# returns these directly, no scaling.
|
||||
st.set(i + 'tn', float(cfg.get('min_mm', 0.0)))
|
||||
st.set(i + 'tm', float(cfg.get('max_mm', 0.0)))
|
||||
|
||||
# home_position / home_travel are exposed as callbacks for
|
||||
# motors 0..3 (see State.__init__). Register the same lazy
|
||||
# callbacks for motor 4 so gplan's resolver lookup
|
||||
# (_<axis>_home_position / _<axis>_home_travel) returns the
|
||||
# right values for the external axis.
|
||||
st.set_callback(
|
||||
i + 'home_position', lambda name: self.home_position_mm)
|
||||
st.set_callback(
|
||||
i + 'home_travel',
|
||||
lambda name: float(self.aux._cfg.get('max_mm', 0.0))
|
||||
- self.home_position_mm)
|
||||
|
||||
# Misc fields that other code paths might query. Defaults
|
||||
# mirror what the AVR pushes for motors 0-3.
|
||||
st.set(i + 'sa', 1.8)
|
||||
st.set(i + 'mi', 16)
|
||||
st.set(i + 'tr', 4.0)
|
||||
st.set(i + 'sp', 200)
|
||||
st.set(i + 'ic', 0.0)
|
||||
st.set(i + 'dc', 0.0)
|
||||
st.set(i + 'rv', False)
|
||||
st.set(i + 'tc', 1)
|
||||
st.set(i + 'lb', 5)
|
||||
st.set(i + 'ho', 0)
|
||||
st.set(i + 'os', 0)
|
||||
st.set(i + 'oa', False)
|
||||
st.set(i + 'lm', 8)
|
||||
st.set(i + 'lv', 0.1)
|
||||
st.set(i + 'sv', 1.688)
|
||||
st.set(i + 'tv', 1.997)
|
||||
st.set(i + 'lw', 2) # min-switch
|
||||
st.set(i + 'xw', 2) # max-switch
|
||||
st.set(i + 'ls', 0)
|
||||
st.set(i + 'xs', 0)
|
||||
st.set(i + 'df', 0)
|
||||
|
||||
def refresh_homed(self):
|
||||
"""Called when AuxAxis updates its homed flag. Mirrors into
|
||||
State so is_axis_homed('a') returns the right answer.
|
||||
|
||||
Updates several places at once because different layers read
|
||||
the homed state via different keys:
|
||||
- synthetic motor flag: 4h (used by snapshot -> a_h)
|
||||
- axis-level flag: a_homed (used by State.is_axis_homed
|
||||
and gplan _a_homed resolver)"""
|
||||
try:
|
||||
homed = bool(self.aux._homed)
|
||||
except Exception:
|
||||
homed = False
|
||||
st = self.ctrl.state
|
||||
st.set(str(EXTERNAL_MOTOR_INDEX) + 'h', 1 if homed else 0)
|
||||
st.set(self.axis_letter + '_homed', bool(homed))
|
||||
|
||||
# ----------------------------------------------------------- line split
|
||||
|
||||
def split_target(self, target):
|
||||
"""Pop the external axis out of a target dict and return
|
||||
(target_without_ext, ext_mm_or_None). Both case variants
|
||||
accepted defensively."""
|
||||
if not target:
|
||||
return target, None
|
||||
ax = self.axis_letter
|
||||
new_target = dict(target)
|
||||
ext_mm = new_target.pop(ax, None)
|
||||
if ext_mm is None:
|
||||
ext_mm = new_target.pop(ax.upper(), None)
|
||||
return new_target, ext_mm
|
||||
|
||||
# -------------------------------------------------------- execution API
|
||||
|
||||
def is_busy(self):
|
||||
return self._busy.is_set()
|
||||
|
||||
def execute_to_mm(self, ext_mm):
|
||||
"""Synchronously run an external move. Blocks until the ESP
|
||||
reports done. Used by the legacy /api/aux/move and /api/aux/jog
|
||||
endpoints which may want to wait. Most planner-driven motion
|
||||
goes through enqueue_target_mm instead, which is non-blocking.
|
||||
|
||||
Soft limits are enforced here (not just in gplan) because the
|
||||
UI jog/move endpoints don't go through the planner.
|
||||
|
||||
Updates state.<axis>p immediately on completion. For the
|
||||
planner-driven path that goes through enqueue_target_mm, the
|
||||
AVR's own ap reports drive state.<axis>p instead."""
|
||||
if not self.enabled:
|
||||
raise ExternalAxisError(
|
||||
'External axis %r not available (aux disabled or '
|
||||
'not connected)' % self.axis_letter)
|
||||
|
||||
self._check_soft_limit(ext_mm)
|
||||
# Coupling: A is in machine coords directly (we don't apply
|
||||
# a G92 offset to A), so target_a_machine == ext_mm.
|
||||
self.check_coupling(target_a_machine=ext_mm)
|
||||
steps, abs_mm = self._compute_move(ext_mm)
|
||||
if steps == 0:
|
||||
self._pos_mm = abs_mm
|
||||
self.ctrl.state.set(self.axis_letter + 'p', self._pos_mm)
|
||||
return
|
||||
|
||||
self._busy.set()
|
||||
try:
|
||||
self.aux._do_steps(steps, ignore_limits=True)
|
||||
self._pos_mm = abs_mm
|
||||
self.ctrl.state.set(self.axis_letter + 'p', self._pos_mm)
|
||||
finally:
|
||||
self._busy.clear()
|
||||
|
||||
def enqueue_target_mm(self, ext_mm):
|
||||
"""Legacy non-blocking variant: post a fixed-rate STEPS move
|
||||
to the worker queue. No longer used by Planner.__encode (which
|
||||
uses enqueue_line for full S-curve mirroring), but kept for
|
||||
UI jog endpoints that don't have planner timing data.
|
||||
|
||||
Soft limits are enforced here (defense in depth on top of
|
||||
gplan)."""
|
||||
if not self.enabled:
|
||||
raise ExternalAxisError(
|
||||
'External axis %r not available' % self.axis_letter)
|
||||
self._check_soft_limit(ext_mm)
|
||||
self.check_coupling(target_a_machine=ext_mm)
|
||||
steps, abs_mm = self._compute_move(ext_mm)
|
||||
# Internal mirror only - drives subsequent delta computation.
|
||||
# state.<axis>p is left to the AVR's status reports.
|
||||
self._pos_mm = abs_mm
|
||||
if steps == 0:
|
||||
return
|
||||
self._work_q.put(('move', steps))
|
||||
|
||||
def enqueue_line(self, ext_mm, max_accel_mm_min2, max_jerk_mm_min3,
|
||||
entry_vel_mm_min, exit_vel_mm_min, times_ms):
|
||||
"""Post a full S-curve LINE block to the ESP worker. Mirrors
|
||||
gplan's planned trajectory exactly (same 7-segment math, same
|
||||
unit system) so the ESP's move duration matches what the AVR
|
||||
would have produced for an A motor.
|
||||
|
||||
Called by Planner.__encode for every line block that touches
|
||||
the external axis.
|
||||
|
||||
Parameters:
|
||||
ext_mm: absolute target in mm (gplan target['a'])
|
||||
max_accel_mm_min2:from block['max-accel']
|
||||
max_jerk_mm_min3: from block['max-jerk']
|
||||
entry_vel_mm_min: from block['entry-vel'] (typically 0 for
|
||||
the first block, exit_vel of the prior
|
||||
block otherwise)
|
||||
exit_vel_mm_min: from block['exit-vel']
|
||||
times_ms: 7-tuple of section durations in ms
|
||||
(block['times'] - the same units gplan uses)
|
||||
"""
|
||||
if not self.enabled:
|
||||
raise ExternalAxisError(
|
||||
'External axis %r not available' % self.axis_letter)
|
||||
self._check_soft_limit(ext_mm)
|
||||
self.check_coupling(target_a_machine=ext_mm)
|
||||
steps, abs_mm = self._compute_move(ext_mm)
|
||||
delta_mm = abs(abs_mm - (self._pos_mm if self._pos_mm is not None
|
||||
else 0.0))
|
||||
# Update internal mirror; AVR drives state.<axis>p.
|
||||
self._pos_mm = abs_mm
|
||||
if steps == 0 or delta_mm <= 0:
|
||||
return
|
||||
# ms -> minutes (the unit gplan/AVR/ESP use internally for
|
||||
# SCurve math).
|
||||
times_min = tuple((t / 60000.0) if t else 0.0 for t in times_ms)
|
||||
self._work_q.put(('line', steps, delta_mm,
|
||||
float(max_accel_mm_min2),
|
||||
float(max_jerk_mm_min3),
|
||||
float(entry_vel_mm_min),
|
||||
float(exit_vel_mm_min),
|
||||
times_min))
|
||||
|
||||
def _compute_move(self, ext_mm):
|
||||
"""Return (signed_steps, absolute_mm) for a target in mm.
|
||||
Caches first-time position from the ESP."""
|
||||
if self._pos_mm is None:
|
||||
self._pos_mm = self._read_esp_position_mm()
|
||||
delta_mm = float(ext_mm) - self._pos_mm
|
||||
return self.mm_to_steps_delta(delta_mm), float(ext_mm)
|
||||
|
||||
def _worker_loop(self):
|
||||
"""Background thread that drains the work queue. RPCs to the
|
||||
ESP are slow (multi-second moves) and must not run on the
|
||||
ioloop thread. We serialize ESP commands here so multiple
|
||||
line-block enqueues for the external axis are processed in
|
||||
the order the planner emitted them."""
|
||||
while not self._stop.is_set():
|
||||
try:
|
||||
op = self._work_q.get(timeout=0.5)
|
||||
except Exception:
|
||||
continue
|
||||
if op is None:
|
||||
continue
|
||||
kind = op[0]
|
||||
try:
|
||||
self._busy.set()
|
||||
if kind == 'move':
|
||||
steps = op[1]
|
||||
self.aux._do_steps(steps, ignore_limits=True)
|
||||
elif kind == 'line':
|
||||
(_, steps, length_mm,
|
||||
max_accel, max_jerk,
|
||||
entry_vel, exit_vel,
|
||||
times_min) = op
|
||||
self.aux._do_line(
|
||||
steps, length_mm, max_accel, max_jerk,
|
||||
entry_vel, exit_vel, times_min,
|
||||
ignore_limits=True)
|
||||
elif kind == 'home':
|
||||
self.aux.home()
|
||||
# _pos_mm and DRO updated by the caller's enqueue.
|
||||
except Exception as e:
|
||||
self.log.error('External axis worker failed on %s: %s'
|
||||
% (kind, e))
|
||||
finally:
|
||||
self._busy.clear()
|
||||
self._work_q.task_done()
|
||||
|
||||
def wait_idle(self, timeout=None):
|
||||
"""Block until the worker queue is empty. Used by callers
|
||||
that need post-motion state to be settled (e.g. homing,
|
||||
stop/abort handlers)."""
|
||||
try:
|
||||
# Queue.join blocks until task_done has been called for
|
||||
# every item put. It does not honour a timeout, so we
|
||||
# poll instead when one is requested.
|
||||
if timeout is None:
|
||||
self._work_q.join()
|
||||
return True
|
||||
import time
|
||||
deadline = time.time() + float(timeout)
|
||||
while time.time() < deadline:
|
||||
if self._work_q.unfinished_tasks == 0:
|
||||
return True
|
||||
time.sleep(0.05)
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def close(self):
|
||||
self._stop.set()
|
||||
try:
|
||||
self._work_q.put(None, block=False)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def home(self):
|
||||
"""Run the ESP homing cycle and sync our recorded position
|
||||
to the configured home_position_mm. Blocks; called from
|
||||
Mach.home (which already runs synchronously per axis)."""
|
||||
if not self.enabled:
|
||||
raise ExternalAxisError(
|
||||
'External axis %r not available' % self.axis_letter)
|
||||
# Drain pending moves so we don't home into stale work.
|
||||
self.wait_idle(timeout=30.0)
|
||||
self._busy.set()
|
||||
try:
|
||||
self.aux.home()
|
||||
self._pos_mm = self.home_position_mm
|
||||
self.ctrl.state.set(self.axis_letter + 'p', self._pos_mm)
|
||||
self.refresh_homed()
|
||||
finally:
|
||||
self._busy.clear()
|
||||
|
||||
def abort(self):
|
||||
"""Cancel the ESP move and drop pending queued work.
|
||||
Caller (estop / stop handler) is responsible for the
|
||||
planner-side cleanup."""
|
||||
try:
|
||||
if self.aux is not None:
|
||||
self.aux.abort()
|
||||
finally:
|
||||
self._busy.clear()
|
||||
# Drain any pending ops so resume after an abort doesn't
|
||||
# replay stale targets.
|
||||
try:
|
||||
while True:
|
||||
self._work_q.get_nowait()
|
||||
self._work_q.task_done()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ------------------------------------------------------- ESP introspection
|
||||
|
||||
def _read_esp_position_mm(self):
|
||||
"""Convert AuxAxis._pos_steps mirror to mm. Falls back to 0."""
|
||||
try:
|
||||
steps = int(self.aux._pos_steps)
|
||||
except Exception:
|
||||
steps = 0
|
||||
return self.steps_to_mm(steps)
|
||||
|
||||
# ---------------------------------------------------------- DRO update
|
||||
|
||||
def sync_dro(self):
|
||||
"""Push the current position to State as <axis>p so the DRO
|
||||
reflects what we believe gplan/ESP agreed on. Called after
|
||||
moves; also safe to call from external code."""
|
||||
if self._pos_mm is None:
|
||||
return
|
||||
self.ctrl.state.set(self.axis_letter + 'p', self._pos_mm)
|
||||
@@ -99,38 +99,18 @@ class FileHandler(bbctrl.APIHandler):
|
||||
|
||||
del (self.uploadFile)
|
||||
|
||||
# If the uploaded G-code uses ATC M-codes (M100..M103),
|
||||
# rewrite them into (MSG,HOOK:droptool:) etc so the hook
|
||||
# layer can dispatch them at runtime. The planner accepts
|
||||
# M100-M103 in user-defined range but doesn't *do* anything
|
||||
# with them. Motion in A goes through gplan unchanged - the
|
||||
# auxcnc stepper is exposed as a virtual A axis (see
|
||||
# ExternalAxis).
|
||||
# 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, AuxPreprocessorError)
|
||||
from bbctrl.AuxPreprocessor import preprocess_file
|
||||
log = self.get_log('AuxPreprocessor')
|
||||
ext = getattr(self.get_ctrl(), 'ext_axis', None)
|
||||
coupling = (ext.coupling_for_preprocessor()
|
||||
if ext is not None else None)
|
||||
try:
|
||||
if preprocess_file(filename.decode('utf8'),
|
||||
log=log, coupling=coupling):
|
||||
log.info('Rewrote upload (ATC / Z-A coupling) in %s'
|
||||
% self.uploadFilename)
|
||||
except AuxPreprocessorError as e:
|
||||
# Surface coupling-violation errors to the operator
|
||||
# via the message stream so the upload doesn't go
|
||||
# silently un-rewritten and then trip the runtime
|
||||
# check (which can hang the planner dialog).
|
||||
log.warning('Aux preprocess refused upload: %s' % e)
|
||||
try:
|
||||
self.get_ctrl().state.add_message(
|
||||
'Z-A coupling: ' + str(e))
|
||||
except Exception: pass
|
||||
if preprocess_file(filename.decode('utf8'), log=log):
|
||||
log.info('Rewrote W-axis tokens in %s' %
|
||||
self.uploadFilename)
|
||||
except Exception:
|
||||
self.get_log('AuxPreprocessor').exception(
|
||||
'Aux preprocess failed; uploading unchanged')
|
||||
'W-axis preprocess failed; uploading unchanged')
|
||||
|
||||
self.get_ctrl().preplanner.invalidate(self.uploadFilename)
|
||||
self.get_ctrl().state.add_file(self.uploadFilename)
|
||||
|
||||
@@ -201,15 +201,7 @@ class Hooks:
|
||||
# Cancel any running hook on estop. The hook thread
|
||||
# cannot be killed from Python, but we can ask the
|
||||
# AuxAxis to send ABORT to the ESP so its in-flight
|
||||
# motion stops. Also drain the external-axis
|
||||
# worker queue so resume after clear doesn't replay
|
||||
# stale moves.
|
||||
try:
|
||||
ext = getattr(self.ctrl, 'ext_axis', None)
|
||||
if ext is not None:
|
||||
ext.abort()
|
||||
except Exception:
|
||||
pass
|
||||
# motion stops.
|
||||
if self._hook_busy:
|
||||
self.log.warning('E-stop: cancelling hook "%s"' %
|
||||
self._hook_busy_event)
|
||||
@@ -257,23 +249,6 @@ class Hooks:
|
||||
|
||||
# -- Hook execution --
|
||||
|
||||
def dispatch_hook_message(self, text):
|
||||
"""Direct entry point for HOOK:<event>:<data> messages emitted
|
||||
by the planner via (MSG,HOOK:...) comments. Bypasses the
|
||||
state.messages list (which the UI also reads), so callers can
|
||||
suppress popup display without losing the hook dispatch.
|
||||
|
||||
Returns True if the text matched a HOOK: line and was
|
||||
dispatched, False otherwise."""
|
||||
if not isinstance(text, str) or not text.startswith('HOOK:'):
|
||||
return False
|
||||
parts = text[5:].split(':', 1)
|
||||
event = parts[0]
|
||||
data = parts[1] if len(parts) > 1 else ''
|
||||
self._fire('custom', {'event': event, 'data': data},
|
||||
custom_name=event)
|
||||
return True
|
||||
|
||||
def register_internal(self, name, fn, block_unpause=True,
|
||||
auto_resume=True, timeout=120):
|
||||
"""Register an in-process handler for HOOK:<name> events.
|
||||
|
||||
@@ -25,21 +25,10 @@
|
||||
# #
|
||||
################################################################################
|
||||
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
|
||||
import inevent
|
||||
from inevent.Constants import *
|
||||
|
||||
|
||||
# Set to True (or BBCTRL_AJOG_DRYRUN=1 in env) to log press/release
|
||||
# events and would-be ESP commands without actually sending JOG /
|
||||
# JOGSTOP. Useful for debugging the gamepad event path without
|
||||
# touching the gantry. Defaults to live actuation.
|
||||
A_DRY_RUN = os.environ.get('BBCTRL_AJOG_DRYRUN', '') == '1'
|
||||
|
||||
|
||||
# Listen for input events
|
||||
class Jog(inevent.JogHandler):
|
||||
def __init__(self, ctrl):
|
||||
@@ -62,23 +51,12 @@ class Jog(inevent.JogHandler):
|
||||
"dir": [1, -1, -1, 1],
|
||||
"arrows": [ABS_HAT0X, ABS_HAT0Y],
|
||||
"speed": [0x133, 0x130, 0x131, 0x134],
|
||||
"lock": [0x136], # L1 = horiz-lock; RB/RT now A axis
|
||||
# Right back controls drive the A axis while held.
|
||||
# Verified on Xbox 360 pad (Vendor=045e Product=028e):
|
||||
# RB (upper-right bumper) -> BTN_TR (0x137) digital -> A+
|
||||
# RT (lower-right trigger) -> ABS_RZ analog 0..255 -> A-
|
||||
# Some pads expose RT as BTN_TR2 (0x139) instead -- that
|
||||
# works too via a_neg_btn.
|
||||
"a_pos_btn": 0x137,
|
||||
"a_neg_btn": 0x139,
|
||||
"a_neg_abs": ABS_RZ,
|
||||
"a_abs_thresh": 32, # 0..255 trigger press threshold
|
||||
"lock": [0x136, 0x137],
|
||||
}
|
||||
}
|
||||
|
||||
super().__init__(config)
|
||||
|
||||
self.a_button = 0 # -1, 0, +1 from RB / RT hold state
|
||||
self.v = [0.0] * 4
|
||||
self.lastV = self.v
|
||||
self.callback()
|
||||
@@ -86,276 +64,6 @@ class Jog(inevent.JogHandler):
|
||||
self.processor = inevent.InEvent(ctrl.ioloop, self, types = ['js'])
|
||||
|
||||
|
||||
# -------- A-axis (external, ESP-driven) hold-to-jog ---------------
|
||||
#
|
||||
# The Mach jog path only knows about AVR axes; the A axis is
|
||||
# handled by ExternalAxis on the auxcnc ESP, which has a proper
|
||||
# JOG / JOGSTOP protocol added for hold-to-jog: ramp up on press,
|
||||
# cruise while held, ramp down on release.
|
||||
#
|
||||
# Speed buttons (X/A/B/Y) scale the cruise rate (1/128, 1/32,
|
||||
# 1/4, 1.0x of the configured step_max_sps).
|
||||
def _a_speed_scale(self):
|
||||
if self.speed == 1: return 1.0 / 128.0
|
||||
if self.speed == 2: return 1.0 / 32.0
|
||||
if self.speed == 3: return 1.0 / 4.0
|
||||
return 1.0
|
||||
|
||||
def _a_stop(self):
|
||||
ext = getattr(self.ctrl, 'ext_axis', None)
|
||||
ext_state = ('present' if (ext is not None and ext.enabled)
|
||||
else 'unavailable')
|
||||
if A_DRY_RUN:
|
||||
self.log.info('AJOG DRYRUN _a_stop ext=%s (would send JOGSTOP)',
|
||||
ext_state)
|
||||
return
|
||||
if ext is None or not ext.enabled:
|
||||
return
|
||||
try:
|
||||
ext.aux.jog_stop()
|
||||
except Exception as e:
|
||||
self.log.warning('A-axis jog_stop failed: %s', e)
|
||||
|
||||
def _a_soft_limit_target_steps(self, aux, direction):
|
||||
"""Return a step-counter target for the configured soft
|
||||
limit (`min_mm` / `max_mm`) on the `direction` side of the
|
||||
current position, or None when no limit applies (axis
|
||||
unhomed or limits not configured)."""
|
||||
try:
|
||||
if not bool(aux._homed):
|
||||
return None
|
||||
cfg = aux._cfg
|
||||
lo_mm = float(cfg.get('min_mm', 0.0))
|
||||
hi_mm = float(cfg.get('max_mm', 0.0))
|
||||
if hi_mm <= lo_mm:
|
||||
return None
|
||||
lo_steps = aux._mm_to_steps(lo_mm)
|
||||
hi_steps = aux._mm_to_steps(hi_mm)
|
||||
# _mm_to_steps applies dir_sign; sort so we know which
|
||||
# is "more positive in g_pos".
|
||||
top_steps = max(lo_steps, hi_steps)
|
||||
bottom_steps = min(lo_steps, hi_steps)
|
||||
return top_steps if direction > 0 else bottom_steps
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _a_coupling_target_steps(self, ext, direction):
|
||||
"""Return a step-counter target that prevents the Z-A
|
||||
coupling rule (a - z <= K) from being violated by this jog.
|
||||
Returns None when coupling is disabled or doesn't constrain
|
||||
motion in `direction`.
|
||||
|
||||
The constraint is on machine-mm: the rule limits how far A
|
||||
may go *up* (toward larger machine A) for the current Z. So
|
||||
only the +A jog direction can ever violate it; -A jogs are
|
||||
unconstrained by coupling and we return None for them.
|
||||
|
||||
Note: 'direction' here refers to the gamepad axis sign, not
|
||||
machine-mm. dir_sign in aux config maps gamepad+ to
|
||||
machine+ steps. We translate via the existing
|
||||
ext._a_machine_now / aux._mm_to_steps so the result is in
|
||||
the same g_pos space as _a_soft_limit_target_steps."""
|
||||
try:
|
||||
if not ext.couple_z_enabled:
|
||||
return None
|
||||
if not bool(ext.aux._homed):
|
||||
return None
|
||||
K = ext.couple_K
|
||||
if K is None:
|
||||
return None
|
||||
z_now = ext._z_machine_now()
|
||||
if z_now is None:
|
||||
return None
|
||||
# Max permitted A in machine-mm: a_max = z_now + K.
|
||||
a_max_mm = float(z_now) + float(K)
|
||||
a_max_steps = ext.aux._mm_to_steps(a_max_mm)
|
||||
# The coupling only caps the *upper* side (more-positive
|
||||
# machine A). With dir_sign=+1 that's g_pos+; with
|
||||
# dir_sign=-1 it's g_pos-. Jogs in the opposite gamepad
|
||||
# direction don't approach the coupling bound, return
|
||||
# None so the soft-limit target alone applies.
|
||||
dir_sign = 1 if int(ext.aux._cfg.get('dir_sign', 1)) >= 0 else -1
|
||||
# Gamepad+ moves toward larger machine-mm when dir_sign>0.
|
||||
machine_dir = direction * dir_sign
|
||||
if machine_dir <= 0:
|
||||
return None
|
||||
return a_max_steps
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _a_combined_target_steps(self, ext, direction):
|
||||
"""Pick the more restrictive of soft-limit and coupling
|
||||
targets. Returns (target_steps, source_label) where
|
||||
target_steps is None when neither rule applies."""
|
||||
soft = self._a_soft_limit_target_steps(ext.aux, direction)
|
||||
couple = self._a_coupling_target_steps(ext, direction)
|
||||
if soft is None and couple is None:
|
||||
return None, 'none'
|
||||
if soft is None: return couple, 'coupling'
|
||||
if couple is None: return soft, 'softlimit'
|
||||
# Both present: pick whichever is reached first when moving
|
||||
# in `direction` from the current g_pos.
|
||||
try:
|
||||
cur = int(ext.aux._pos_steps)
|
||||
except Exception:
|
||||
cur = 0
|
||||
if direction > 0:
|
||||
return ((soft, 'softlimit') if soft <= couple
|
||||
else (couple, 'coupling'))
|
||||
else:
|
||||
return ((soft, 'softlimit') if soft >= couple
|
||||
else (couple, 'coupling'))
|
||||
|
||||
def _a_start(self, direction):
|
||||
ext = getattr(self.ctrl, 'ext_axis', None)
|
||||
ext_state = ('present' if (ext is not None and ext.enabled)
|
||||
else 'unavailable')
|
||||
scale = self._a_speed_scale()
|
||||
target_steps = None
|
||||
target_src = 'none'
|
||||
cur_steps = None
|
||||
if ext is not None and ext.enabled:
|
||||
target_steps, target_src = self._a_combined_target_steps(
|
||||
ext, direction)
|
||||
try: cur_steps = int(ext.aux._pos_steps)
|
||||
except Exception: cur_steps = None
|
||||
if A_DRY_RUN:
|
||||
try:
|
||||
step_max = (int(ext.aux._cfg['step_max_sps'])
|
||||
if ext is not None and ext.enabled else -1)
|
||||
accel = (int(ext.aux._cfg['step_accel_sps2'])
|
||||
if ext is not None and ext.enabled else -1)
|
||||
except Exception:
|
||||
step_max, accel = -1, -1
|
||||
self.log.info(
|
||||
'AJOG DRYRUN _a_start dir=%+d ext=%s speed=%d scale=%.4f '
|
||||
'step_max=%d accel=%d cur_steps=%s target_steps=%s '
|
||||
'target_src=%s (would send JOG)',
|
||||
direction, ext_state, self.speed, scale, step_max, accel,
|
||||
cur_steps, target_steps, target_src)
|
||||
return
|
||||
if ext is None or not ext.enabled or direction == 0:
|
||||
return
|
||||
try:
|
||||
aux = ext.aux
|
||||
max_rate = max(1, int(int(aux._cfg['step_max_sps']) * scale))
|
||||
accel = int(aux._cfg['step_accel_sps2'])
|
||||
# If the axis is already at-or-past the more-restrictive
|
||||
# boundary (soft limit OR Z-A coupling) in the requested
|
||||
# direction, refuse the jog rather than sending a
|
||||
# wrong-side target the ESP would reject.
|
||||
if target_steps is not None and cur_steps is not None:
|
||||
at_limit = ((direction > 0 and cur_steps >= target_steps)
|
||||
or (direction < 0 and cur_steps <= target_steps))
|
||||
if at_limit:
|
||||
self.log.info(
|
||||
'A-axis jog refused: at %s limit '
|
||||
'(cur=%d target=%d dir=%+d)',
|
||||
target_src, cur_steps, target_steps, direction)
|
||||
return
|
||||
# ignore_limits=True (safe=0) when the axis is unhomed:
|
||||
# pendant jog is allowed before homing for setup. When
|
||||
# homed, soft limits AND Z-A coupling are enforced via
|
||||
# target_steps and the ESP's hardware-limit abort still
|
||||
# applies unconditionally (movingTowardLimit in
|
||||
# jogTask).
|
||||
ignore = not bool(aux._homed)
|
||||
aux.jog_start(direction,
|
||||
max_rate_sps=max_rate,
|
||||
accel_sps2=accel,
|
||||
ignore_limits=ignore,
|
||||
target_steps=target_steps)
|
||||
if target_steps is not None:
|
||||
self.log.info(
|
||||
'A-axis jog_start dir=%+d cur=%d target=%d (%s)',
|
||||
direction, cur_steps, target_steps, target_src)
|
||||
except Exception as e:
|
||||
self.log.warning('A-axis jog_start failed: %s', e)
|
||||
|
||||
def _a_apply(self, new_dir, old_dir):
|
||||
if new_dir == old_dir:
|
||||
return
|
||||
# On any state change we stop the current jog and (if the
|
||||
# new direction is non-zero) start a fresh one. JOG / JOGSTOP
|
||||
# are non-blocking on the host side.
|
||||
if old_dir != 0:
|
||||
self._a_stop()
|
||||
if new_dir != 0:
|
||||
self._a_start(new_dir)
|
||||
|
||||
def _a_resync_pos(self):
|
||||
"""Pull the ESP step counter back into ExternalAxis after a
|
||||
JOG ends, so subsequent gplan-driven A motion computes the
|
||||
right delta. Called opportunistically on state changes; the
|
||||
AuxAxis reader also updates _pos_steps from the terminal
|
||||
[jog] done line."""
|
||||
ext = getattr(self.ctrl, 'ext_axis', None)
|
||||
if ext is None or not ext.enabled:
|
||||
return
|
||||
try:
|
||||
ext._pos_mm = ext.aux.position_mm
|
||||
self.ctrl.state.set(ext.axis_letter + 'p', ext._pos_mm)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def event(self, event, state, dev_name):
|
||||
cfg = self.get_config(dev_name)
|
||||
old = self.a_button
|
||||
|
||||
# DEBUG: log EVERY incoming gamepad event so we can see
|
||||
# exactly what the pendant is producing on press/release.
|
||||
# Skip noisy stick / report-syn events to keep the journal
|
||||
# readable but log all KEY events and any ABS event whose
|
||||
# code matches one we care about.
|
||||
try:
|
||||
tname = ev_type_name.get(event.type, '?')
|
||||
except Exception:
|
||||
tname = '?'
|
||||
if event.type == EV_KEY:
|
||||
self.log.info(
|
||||
'AJOG EV dev=%r type=%s(%d) code=0x%x val=%d '
|
||||
'cfg.a_pos_btn=0x%x cfg.a_neg_btn=0x%x',
|
||||
dev_name, tname, event.type, event.code, event.value,
|
||||
cfg.get('a_pos_btn', 0), cfg.get('a_neg_btn', 0))
|
||||
elif event.type == EV_ABS and event.code in (
|
||||
cfg.get('a_neg_abs', -1),
|
||||
cfg.get('a_pos_abs', -1)):
|
||||
self.log.info(
|
||||
'AJOG EV dev=%r type=%s(%d) code=0x%x val=%d (trigger ABS)',
|
||||
dev_name, tname, event.type, event.code, event.value)
|
||||
|
||||
if event.type == EV_KEY:
|
||||
if event.code == cfg.get('a_pos_btn'):
|
||||
if event.value: self.a_button = 1
|
||||
elif self.a_button == 1: self.a_button = 0
|
||||
elif event.code == cfg.get('a_neg_btn'):
|
||||
if event.value: self.a_button = -1
|
||||
elif self.a_button == -1: self.a_button = 0
|
||||
|
||||
elif event.type == EV_ABS:
|
||||
thresh = cfg.get('a_abs_thresh', 32)
|
||||
if event.code == cfg.get('a_neg_abs'):
|
||||
if event.value >= thresh: self.a_button = -1
|
||||
elif self.a_button == -1: self.a_button = 0
|
||||
|
||||
if self.a_button != old:
|
||||
self.log.info(
|
||||
'AJOG STATE %+d -> %+d (t=%.3f dry_run=%s)',
|
||||
old, self.a_button, time.monotonic(), A_DRY_RUN)
|
||||
self._a_apply(self.a_button, old)
|
||||
# On every release pull a fresh position mirror in case
|
||||
# the user does a gplan-driven A move next. The terminal
|
||||
# [jog] done line itself already updates aux._pos_steps;
|
||||
# this propagates that into ExternalAxis._pos_mm.
|
||||
if self.a_button == 0 and not A_DRY_RUN:
|
||||
# Wait briefly so the [jog] done line has time to
|
||||
# arrive before we read aux.position_mm.
|
||||
self.ctrl.ioloop.call_later(0.2, self._a_resync_pos)
|
||||
|
||||
super().event(event, state, dev_name)
|
||||
|
||||
|
||||
def up(self): self.ctrl.lcd.page_up()
|
||||
def down(self): self.ctrl.lcd.page_down()
|
||||
def left(self): self.ctrl.lcd.page_left()
|
||||
@@ -382,7 +90,4 @@ class Jog(inevent.JogHandler):
|
||||
if self.speed == 2: scale = 1.0 / 32.0
|
||||
if self.speed == 3: scale = 1.0 / 4.0
|
||||
|
||||
# axes[3] is left untouched by RB/RT -- the A axis is the
|
||||
# ESP-driven external axis on this branch and is jogged via
|
||||
# discrete relative moves through ExternalAxis (see _a_pump).
|
||||
self.v = [x * scale for x in self.axes]
|
||||
|
||||
@@ -182,11 +182,4 @@ class Log(object):
|
||||
if n == 16: os.unlink(fullpath)
|
||||
else: self._rotate(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
|
||||
os.rename(fullpath, '%s.%d' % (path, nextN))
|
||||
|
||||
@@ -95,10 +95,6 @@ class Mach(Comm):
|
||||
self.planner = bbctrl.Planner(ctrl)
|
||||
self.unpausing = False
|
||||
self.stopping = False
|
||||
# Guard against overlapping deferred-external-homing threads
|
||||
# if the user clicks Home (All) again while the previous run
|
||||
# is still waiting for the AVR cycle to finish.
|
||||
self._ext_home_thread = None
|
||||
|
||||
ctrl.state.set('cycle', 'idle')
|
||||
|
||||
@@ -260,12 +256,9 @@ class Mach(Comm):
|
||||
if cmd[0] == '$': self._query_var(cmd)
|
||||
elif cmd[0] == '\\': super().queue_command(cmd[1:])
|
||||
else:
|
||||
# Rewrite ATC M-codes in MDI input the same way the
|
||||
# FileHandler rewrites uploaded files. Motion (X/Y/Z/A)
|
||||
# is left unchanged: the planner handles it natively
|
||||
# now that the auxcnc stepper is exposed as a virtual
|
||||
# A axis (see ExternalAxis).
|
||||
cmd = self._rewrite_aux_mdi(cmd)
|
||||
# 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()
|
||||
@@ -273,12 +266,12 @@ class Mach(Comm):
|
||||
self.mlog.info("Exception during MDI: %s" % err)
|
||||
pass
|
||||
|
||||
def _rewrite_aux_mdi(self, cmd):
|
||||
"""Apply the ATC M-code preprocessor to a single MDI line.
|
||||
Returns possibly-multi-line G-code with HOOK: comments inserted."""
|
||||
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, _ATC_M_RE
|
||||
if not _ATC_M_RE.search(cmd):
|
||||
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
|
||||
@@ -299,7 +292,7 @@ class Mach(Comm):
|
||||
except OSError: pass
|
||||
return rewritten
|
||||
except Exception as e:
|
||||
self.mlog.warning('Aux MDI rewrite failed: %s' % e)
|
||||
self.mlog.warning('W-axis MDI rewrite failed: %s' % e)
|
||||
return cmd
|
||||
|
||||
def set(self, code, value):
|
||||
@@ -307,17 +300,6 @@ class Mach(Comm):
|
||||
|
||||
|
||||
def jog(self, axes):
|
||||
# Strip the external axis from the jog request before sending
|
||||
# to the AVR. v1 doesn't support continuous-rate jogging on
|
||||
# the ESP-driven axis - users jog A via /api/aux/jog (relative
|
||||
# mm steps) instead. Sending A to the AVR is harmless (no
|
||||
# motor maps to it) but cleaner to strip.
|
||||
ext = getattr(self.ctrl, 'ext_axis', None)
|
||||
if ext is not None and isinstance(axes, dict):
|
||||
axes = {k: v for k, v in axes.items()
|
||||
if k.lower() != ext.axis_letter}
|
||||
if not axes:
|
||||
return
|
||||
self._begin_cycle('jogging')
|
||||
self.planner.position_change()
|
||||
super().queue_command(Cmd.jog(axes))
|
||||
@@ -331,52 +313,10 @@ class Mach(Comm):
|
||||
axes = 'zxybc' if is_rotary_active else 'zxyabc' # TODO This should be configurable
|
||||
else: axes = '%c' % axis
|
||||
|
||||
# Collect external axes here and process them *after* every
|
||||
# AVR axis above has finished its homing cycle. Without this,
|
||||
# the AVR is still running Z/X/Y homing G-code in the
|
||||
# planner queue while ext.home() synchronously drives the ESP
|
||||
# to home A in parallel - which is unsafe (the gantry and W
|
||||
# axis can move at the same time) and visually confusing.
|
||||
# We defer external homing to a background thread that
|
||||
# polls cycle until the AVR cycle completes.
|
||||
external_pending = []
|
||||
|
||||
for axis in axes:
|
||||
enabled = state.is_axis_enabled(axis)
|
||||
mode = state.axis_homing_mode(axis)
|
||||
|
||||
# External axes (e.g. the auxcnc-driven A axis) home via
|
||||
# their own ESP-side homing routine; the standard
|
||||
# G28.2 / G38.6 / latch sequence doesn't apply.
|
||||
#
|
||||
# After homing we want a deterministic outcome regardless
|
||||
# of where the user was before:
|
||||
# physical position = home_position_mm (e.g. 134 mm)
|
||||
# work-coord origin = home position (user A = 0)
|
||||
# work offset = home_position_mm (so abs - off = 0)
|
||||
#
|
||||
# ext.home() blocks on the ESP and updates state.ap to
|
||||
# home_position_mm. We then need to tell the AVR (so its
|
||||
# ex.position[A] matches physical reality) and gplan
|
||||
# (so trajectory planning sees abs at home).
|
||||
#
|
||||
# We deliberately avoid G28.3 here: gplan's G28.3 keeps the
|
||||
# current user-coord position fixed and adjusts the offset
|
||||
# to match the new abs, which means re-homing after a move
|
||||
# accumulates offset (134 -> 268 -> ...). Using G92 a0
|
||||
# *after* syncing abs gives the desired "user A = 0 here"
|
||||
# outcome with offset = home_position every time.
|
||||
ext = getattr(self.ctrl, 'ext_axis', None)
|
||||
if ext is not None and ext.enabled \
|
||||
and ext.axis_letter == axis.lower():
|
||||
if 1 < len(axes) and not enabled:
|
||||
continue
|
||||
# Defer until AVR axes are done. We capture the axis
|
||||
# letter and ext reference; the actual homing runs
|
||||
# in _run_external_homing below.
|
||||
external_pending.append((axis, ext))
|
||||
continue
|
||||
|
||||
# If this is not a request to home a specific axis and the
|
||||
# axis is disabled or in manual homing mode, don't show any
|
||||
# warnings
|
||||
@@ -407,138 +347,8 @@ class Mach(Comm):
|
||||
self.planner.mdi(gcode, False)
|
||||
super().resume()
|
||||
|
||||
# Kick off the deferred external-axis homing on a background
|
||||
# thread so we don't block the HTTP handler (which is on the
|
||||
# IOLoop) waiting for the AVR cycle to finish.
|
||||
if external_pending:
|
||||
prev = self._ext_home_thread
|
||||
if prev is not None and prev.is_alive():
|
||||
self.mlog.info(
|
||||
'External homing already in progress; ignoring '
|
||||
'duplicate request')
|
||||
else:
|
||||
import threading
|
||||
t = threading.Thread(
|
||||
target=self._run_external_homing,
|
||||
args=(list(external_pending),),
|
||||
name='ext-home-deferred',
|
||||
daemon=True)
|
||||
self._ext_home_thread = t
|
||||
t.start()
|
||||
|
||||
def _run_external_homing(self, pending):
|
||||
"""Background worker: wait for the AVR cycle to drop to idle
|
||||
(meaning all queued AVR-side homing is done), then run each
|
||||
deferred external-axis home in order.
|
||||
|
||||
We split the work between two threads:
|
||||
- this background thread blocks on the ESP serial RPC
|
||||
(ext.home(), which can take 5-10 seconds while the
|
||||
carriage seeks the limit and backs off twice);
|
||||
- small bookkeeping operations that touch gplan, the AVR
|
||||
command queue, or shared State are scheduled back onto
|
||||
the IOLoop via ctrl.ioloop.add_callback() so we don't
|
||||
race with the rest of the controller.
|
||||
"""
|
||||
import time
|
||||
# Wait up to 5 minutes for the AVR cycle to leave 'homing'.
|
||||
# Long enough for any reasonable Onefinity full-travel home
|
||||
# (Y axis at slow rate covers ~800 mm).
|
||||
deadline = time.time() + 300.0
|
||||
while time.time() < deadline:
|
||||
cycle = self._get_cycle()
|
||||
# 'homing' is the AVR's homing cycle; we wait for it to
|
||||
# return to idle. If the user estopped or the cycle was
|
||||
# aborted, cycle goes to idle too - we still proceed and
|
||||
# the external home will fail-soft if conditions are wrong.
|
||||
if cycle == 'idle':
|
||||
break
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
self.mlog.error(
|
||||
'External axis homing aborted: AVR cycle did not '
|
||||
'return to idle within timeout')
|
||||
return
|
||||
|
||||
for axis, ext in pending:
|
||||
self.mlog.info('Homing external %s axis via auxcnc' %
|
||||
axis.upper())
|
||||
# Begin the cycle on the IOLoop so cycle-state writes go
|
||||
# through the same thread that all other state writes do.
|
||||
self.ctrl.ioloop.add_callback(self._begin_cycle, 'homing')
|
||||
try:
|
||||
# ext.home() runs on this background thread - it
|
||||
# blocks on serial I/O and is fully thread-safe (the
|
||||
# AuxAxis driver has its own RPC lock).
|
||||
ext.home()
|
||||
home_mm = ext.home_position_mm
|
||||
# All of the post-home bookkeeping touches gplan and
|
||||
# the AVR command queue, both of which run on the
|
||||
# IOLoop. Schedule it there in a single callback so
|
||||
# the steps run in order without intervening events.
|
||||
self.ctrl.ioloop.add_callback(
|
||||
self._finish_external_home, axis, home_mm)
|
||||
except Exception as e:
|
||||
self.mlog.error(
|
||||
'External axis homing failed: %s' % e)
|
||||
# Cycle reset must also happen on the IOLoop. Without
|
||||
# this the UI stays locked at 'homing' since the AVR
|
||||
# never moved (no state change to drive _update's
|
||||
# cycle-end path).
|
||||
self.ctrl.ioloop.add_callback(
|
||||
self._abort_external_home_cycle)
|
||||
|
||||
def _finish_external_home(self, axis, home_mm):
|
||||
"""IOLoop-side completion of an external axis home.
|
||||
Synchronizes AVR position, refreshes the planner, and emits
|
||||
a G92 to set the user-coord origin at the home position.
|
||||
"""
|
||||
try:
|
||||
# 1) Update AVR: no motor steps, just position sync.
|
||||
super().queue_command(Cmd.set_axis(axis, home_mm))
|
||||
# 2) Force planner to resync abs from State on the next
|
||||
# planner call (which is the MDI below).
|
||||
self.planner.position_change()
|
||||
# 3) G92 <axis>0: with abs already at home_mm, sets
|
||||
# user-coord A = 0 and offset = home_mm. Use
|
||||
# planner.mdi (not Mach.mdi) so we don't flip cycle
|
||||
# to 'mdi' inside the 'homing' cycle.
|
||||
self.planner.mdi('G92 %c0' % axis, False)
|
||||
super().resume()
|
||||
except Exception:
|
||||
self.mlog.exception(
|
||||
'Post-home bookkeeping failed for external axis')
|
||||
self._abort_external_home_cycle()
|
||||
|
||||
def _abort_external_home_cycle(self):
|
||||
"""Reset cycle to idle from the IOLoop after a failed
|
||||
external axis home. The AVR never moved so _update's normal
|
||||
cycle-end path won't fire; do it explicitly here.
|
||||
"""
|
||||
if self._get_cycle() == 'homing':
|
||||
try:
|
||||
self._set_cycle('idle')
|
||||
except Exception:
|
||||
self.mlog.exception(
|
||||
'Failed to reset cycle to idle after external '
|
||||
'homing error')
|
||||
|
||||
|
||||
def unhome(self, axis):
|
||||
# External axes don't have AVR-side homed state to clear; the
|
||||
# ESP holds its own homed flag. We don't have an explicit
|
||||
# "unhome" verb on the ESP, but a stale homed flag is harmless
|
||||
# because the next absolute move will fail-soft via
|
||||
# ExternalAxis._pos_mm sync. Still mirror the cleared flag
|
||||
# into State for the UI.
|
||||
ext = getattr(self.ctrl, 'ext_axis', None)
|
||||
if ext is not None and ext.enabled \
|
||||
and chr(axis).lower() == ext.axis_letter:
|
||||
from bbctrl.ExternalAxis import EXTERNAL_MOTOR_INDEX
|
||||
self.ctrl.state.set('%dh' % EXTERNAL_MOTOR_INDEX, 0)
|
||||
self.ctrl.state.set(ext.axis_letter + '_homed', False)
|
||||
return
|
||||
self.mdi('G28.2 %c0' % axis)
|
||||
def unhome(self, axis): self.mdi('G28.2 %c0' % axis)
|
||||
def estop(self): super().estop()
|
||||
|
||||
|
||||
@@ -565,12 +375,6 @@ class Mach(Comm):
|
||||
def stop(self):
|
||||
if self._get_state() != 'jogging': self.stopping = True
|
||||
super().i2c_command(Cmd.STOP)
|
||||
# Drain the external-axis worker queue so post-stop resumption
|
||||
# doesn't replay queued moves that the user wanted cancelled.
|
||||
ext = getattr(self.ctrl, 'ext_axis', None)
|
||||
if ext is not None:
|
||||
try: ext.abort()
|
||||
except Exception: pass
|
||||
|
||||
def pause(self): super().pause()
|
||||
|
||||
|
||||
@@ -27,26 +27,10 @@
|
||||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from collections import deque
|
||||
# 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 camotics.gplan as gplan # pylint: disable=no-name-in-module,import-error
|
||||
import bbctrl.Cmd as Cmd
|
||||
from bbctrl.CommandQueue import CommandQueue
|
||||
|
||||
@@ -77,10 +61,6 @@ class Planner():
|
||||
self.planner = None
|
||||
self._position_dirty = False
|
||||
self.where = ''
|
||||
# Tracks the rewritten temp file (if any) returned by the
|
||||
# AuxPreprocessor for the currently-loaded program. We delete
|
||||
# it on the next load() so it doesn't pile up under /tmp.
|
||||
self._aux_tempfile = None
|
||||
|
||||
ctrl.state.add_listener(self._update)
|
||||
|
||||
@@ -201,23 +181,12 @@ class Planner():
|
||||
|
||||
|
||||
def _add_message(self, text):
|
||||
self.ctrl.state.add_message(text)
|
||||
|
||||
line = self.ctrl.state.get('line', 0)
|
||||
if 0 <= line: where = '%s:%d' % (self.where, line)
|
||||
else: where = self.where
|
||||
|
||||
# HOOK:<event>:<data> messages are an internal IPC channel
|
||||
# between the gcode preprocessor and Hooks; bypass the user
|
||||
# message list so they don't surface as popups, and dispatch
|
||||
# the hook directly. Routing through state.messages would
|
||||
# only deliver it after the 0.25s state-change debounce, by
|
||||
# which point we'd have to keep it visible to ensure Hooks
|
||||
# could see it.
|
||||
hooks = getattr(self.ctrl, 'hooks', None)
|
||||
if hooks is not None and hooks.dispatch_hook_message(text):
|
||||
self.log.info('HOOK msg: %s' % text, where = where)
|
||||
return
|
||||
|
||||
self.ctrl.state.add_message(text)
|
||||
self.log.message(text, where = where)
|
||||
|
||||
|
||||
@@ -275,54 +244,6 @@ class Planner():
|
||||
if type != 'set': self.log.info('Cmd:' + log_json(block))
|
||||
|
||||
if type == 'line':
|
||||
# Z-A coupling check: every line block that touches Z (or
|
||||
# A) is validated against the projected (A,Z) machine
|
||||
# pair. The ExternalAxis check is improvement-aware: it
|
||||
# only refuses moves that worsen an existing violation
|
||||
# or push a healthy state into one. So pure-XY jogs and
|
||||
# recovery moves (Z up, A down) are not rejected even
|
||||
# when (A-Z) is currently above the bound.
|
||||
ext_check = getattr(self.ctrl, 'ext_axis', None)
|
||||
if ext_check is not None:
|
||||
from bbctrl.ExternalAxis import ExternalAxisError
|
||||
target = block.get('target') or {}
|
||||
z_target = target.get('z')
|
||||
if z_target is None: z_target = target.get('Z')
|
||||
a_letter = ext_check.axis_letter
|
||||
a_target = target.get(a_letter)
|
||||
if a_target is None:
|
||||
a_target = target.get(a_letter.upper())
|
||||
if z_target is not None or a_target is not None:
|
||||
try:
|
||||
ext_check.check_coupling(
|
||||
target_a_machine=a_target,
|
||||
target_z_machine=z_target)
|
||||
except ExternalAxisError as e:
|
||||
# Convert the raw error into a clean abort:
|
||||
# surface the message to the operator, stop
|
||||
# the cycle, and skip this block. Returning
|
||||
# None drops the block from the AVR queue;
|
||||
# mach.stop() halts further planner output
|
||||
# so the rest of an offending program can't
|
||||
# leak through. The planner stays usable
|
||||
# for new MDI / jog commands.
|
||||
self.log.warning('Z-A coupling refused: %s' % e)
|
||||
try:
|
||||
self.ctrl.state.add_message(
|
||||
'Z-A coupling refused move: ' + str(e))
|
||||
except Exception: pass
|
||||
try:
|
||||
self.ctrl.mach.stop()
|
||||
except Exception: pass
|
||||
return None
|
||||
|
||||
ext = self._external_axis_for_line(block)
|
||||
if ext is not None:
|
||||
# Side effect: enqueue the ESP move on the external-
|
||||
# axis worker. The AVR still receives the full target
|
||||
# (including A) so ex.position[A] tracks gplan; no
|
||||
# motor steps for A because no motor maps to it.
|
||||
self._dispatch_external_line(block, ext)
|
||||
self._enqueue_line_time(block)
|
||||
return Cmd.line(block['target'], block['exit-vel'],
|
||||
block['max-accel'], block['max-jerk'],
|
||||
@@ -353,17 +274,8 @@ class Planner():
|
||||
|
||||
if name[2:] == '_homed':
|
||||
motor = self.ctrl.state.find_motor(name[1])
|
||||
# Synthetic external motor (index 4) doesn't exist
|
||||
# on the AVR; mirror the homed flag in State only.
|
||||
from bbctrl.ExternalAxis import EXTERNAL_MOTOR_INDEX
|
||||
if motor is not None and motor < EXTERNAL_MOTOR_INDEX:
|
||||
if motor is not None:
|
||||
return Cmd.set_sync('%dh' % motor, value)
|
||||
if motor == EXTERNAL_MOTOR_INDEX:
|
||||
# Update synthetic motor flag and the<axis>_homed
|
||||
# projection consumed by the DRO.
|
||||
self.cmdq.enqueue(
|
||||
id, self.ctrl.state.set,
|
||||
'%dh' % EXTERNAL_MOTOR_INDEX, value)
|
||||
|
||||
return
|
||||
|
||||
@@ -412,74 +324,12 @@ class Planner():
|
||||
self.planner.set_logger(None)
|
||||
|
||||
|
||||
# ----------------------------------------------- external-axis routing
|
||||
#
|
||||
# When an axis is exposed to gplan via a synthetic motor (no AVR
|
||||
# channel), we need to fork its motion off to the ESP at line
|
||||
# encode time and let the rest of the line proceed to the AVR.
|
||||
# The split is done here rather than in gplan because gplan
|
||||
# treats all six axes uniformly and just emits target dicts; we
|
||||
# don't want to teach it about the ESP.
|
||||
|
||||
def _external_axis_for_line(self, block):
|
||||
"""Return the ExternalAxis instance for whichever axis in
|
||||
block['target'] is external, or None."""
|
||||
ext = getattr(self.ctrl, 'ext_axis', None)
|
||||
if ext is None or not ext.enabled:
|
||||
return None
|
||||
target = block.get('target') or {}
|
||||
if ext.axis_letter in target or ext.axis_letter.upper() in target:
|
||||
return ext
|
||||
return None
|
||||
|
||||
def _dispatch_external_line(self, block, ext):
|
||||
"""Side-effect: enqueue the ESP move on the external-axis
|
||||
worker thread (non-blocking). Returns the block (possibly
|
||||
unchanged) for the AVR.
|
||||
|
||||
We do NOT strip the external axis target from the AVR line.
|
||||
The AVR's exec_move_to_target updates ex.position[axis] for
|
||||
every axis in the target dict regardless of motor mapping,
|
||||
and reports it back via the `p` indexed var. Leaving A in
|
||||
the target keeps state.ap in sync with gplan's idea of A
|
||||
(otherwise the AVR's stale ex.position[A] would clobber
|
||||
ExternalAxis's state.ap=N update on the next status report).
|
||||
|
||||
The AVR doesn't step any motor for the external axis (no
|
||||
motor maps to it) - so leaving A in the target is
|
||||
physically a no-op for the steppers, while keeping the
|
||||
host-side state coherent.
|
||||
|
||||
We pass the full S-curve parameters to the ESP so its move
|
||||
duration matches the AVR's exactly. The ESP runs the same
|
||||
7-segment jerk-limited trajectory the AVR would have run
|
||||
if A had been a real motor."""
|
||||
target = block.get('target') or {}
|
||||
# Read the external target (case-insensitive) without modifying
|
||||
# the dict so the AVR still sees A.
|
||||
ext_mm = target.get(ext.axis_letter)
|
||||
if ext_mm is None:
|
||||
ext_mm = target.get(ext.axis_letter.upper())
|
||||
try:
|
||||
ext.enqueue_line(
|
||||
ext_mm,
|
||||
block.get('max-accel', 0.0),
|
||||
block.get('max-jerk', 0.0),
|
||||
block.get('entry-vel', 0.0),
|
||||
block.get('exit-vel', 0.0),
|
||||
block.get('times', [0]*7),
|
||||
)
|
||||
except Exception as e:
|
||||
self.log.error('External axis enqueue failed: %s' % e)
|
||||
raise
|
||||
return block
|
||||
|
||||
def reset(self, *args, **kwargs):
|
||||
stop = kwargs.get('stop', True)
|
||||
if stop:
|
||||
self.ctrl.mach.stop()
|
||||
|
||||
self.planner = _load_gplan().Planner()
|
||||
self.planner = 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')
|
||||
@@ -487,16 +337,6 @@ class Planner():
|
||||
self.cmdq.clear()
|
||||
self.reset_times()
|
||||
|
||||
# Drain the external-axis worker queue and force the next
|
||||
# move to re-sync position from the ESP (since State.reset
|
||||
# below will zero <axis>p which makes ext._pos_mm stale).
|
||||
ext = getattr(self.ctrl, 'ext_axis', None)
|
||||
if ext is not None:
|
||||
try: ext.abort()
|
||||
except Exception: pass
|
||||
try: ext._pos_mm = None
|
||||
except Exception: pass
|
||||
|
||||
resetState = kwargs.get('resetState', True)
|
||||
if resetState:
|
||||
self.ctrl.state.reset()
|
||||
@@ -512,57 +352,12 @@ class Planner():
|
||||
|
||||
def load(self, path):
|
||||
self.where = path
|
||||
src_path = self.ctrl.get_path('upload', path)
|
||||
self.log.info('GCode:' + src_path)
|
||||
|
||||
# Clean up any leftover temp file from a previous load.
|
||||
self._cleanup_aux_tempfile()
|
||||
|
||||
# Rewrite ATC M-codes (M100/M102/M103) and inject Z-A
|
||||
# coupling moves before gplan sees them. The rewriting goes
|
||||
# to a temp file -- the operator's macro / job source is
|
||||
# never modified. This matters because:
|
||||
#
|
||||
# 1. The macro editor reads back the source. If we
|
||||
# rewrote in place, the operator would open `drop.nc`
|
||||
# and see (MSG,HOOK:...) blobs instead of the M-code
|
||||
# sequence they wrote.
|
||||
# 2. Re-running a rewritten file would re-rewrite it; any
|
||||
# bug in the regex (e.g. with paren comments) would
|
||||
# compound on every load.
|
||||
#
|
||||
# Why we rewrite at all: gplan treats M100..M103 as no-ops
|
||||
# by spec and exposes no callback for user M-codes. Its only
|
||||
# in-band channel back to Python during a running program is
|
||||
# the (MSG,...) message stream, so we substitute hook
|
||||
# messages for the M-codes purely as transport.
|
||||
load_path = src_path
|
||||
try:
|
||||
from bbctrl.AuxPreprocessor import preprocess_to_tempfile
|
||||
ext = getattr(self.ctrl, 'ext_axis', None)
|
||||
coupling = (ext.coupling_for_preprocessor()
|
||||
if ext is not None else None)
|
||||
tmp = preprocess_to_tempfile(
|
||||
src_path, log=self.log, coupling=coupling)
|
||||
if tmp is not None:
|
||||
self._aux_tempfile = tmp
|
||||
load_path = tmp
|
||||
self.log.info(
|
||||
'Rewrote (ATC / Z-A coupling) for gplan: %s -> %s'
|
||||
% (src_path, tmp))
|
||||
except Exception:
|
||||
self.log.exception('Aux preprocess at load failed; '
|
||||
'attempting to load file unchanged')
|
||||
path = self.ctrl.get_path('upload', path)
|
||||
self.log.info('GCode:' + path)
|
||||
self._sync_position()
|
||||
self.planner.load(load_path, self.get_config(False, True))
|
||||
self.planner.load(path, self.get_config(False, True))
|
||||
self.reset_times()
|
||||
|
||||
def _cleanup_aux_tempfile(self):
|
||||
if self._aux_tempfile and os.path.exists(self._aux_tempfile):
|
||||
try: os.unlink(self._aux_tempfile)
|
||||
except OSError: pass
|
||||
self._aux_tempfile = None
|
||||
|
||||
|
||||
def stop(self):
|
||||
try:
|
||||
|
||||
@@ -74,7 +74,6 @@ class Plan(object):
|
||||
self.progress = 0
|
||||
self.cancel = False
|
||||
self.pid = None
|
||||
self.error = None
|
||||
|
||||
root = ctrl.get_path()
|
||||
self.gcode = '%s/upload/%s' % (root, filename)
|
||||
@@ -203,16 +202,8 @@ class Plan(object):
|
||||
if not self._exists(): yield self._exec()
|
||||
self.future.set_result(self._read())
|
||||
|
||||
except Exception as e:
|
||||
# Record the error and ALWAYS resolve the future, otherwise
|
||||
# PathHandler.get keeps timing out at 1s forever and the UI
|
||||
# gets stuck on the "Processing New File" dialog.
|
||||
self.preplanner.log.exception(
|
||||
"Failed to plan file: " + str(e))
|
||||
self.error = str(e) or 'Plan failed'
|
||||
self.progress = 1
|
||||
if not self.future.done():
|
||||
self.future.set_result(None)
|
||||
except:
|
||||
self.preplanner.log.exception("Failed to load file - doesn't appear to be GCode.")
|
||||
|
||||
|
||||
class Preplanner(object):
|
||||
@@ -277,6 +268,3 @@ class Preplanner(object):
|
||||
|
||||
def get_plan_progress(self, filename):
|
||||
return self.plans[filename].progress if filename in self.plans else 0
|
||||
|
||||
def get_plan_error(self, filename):
|
||||
return self.plans[filename].error if filename in self.plans else None
|
||||
|
||||
@@ -107,14 +107,8 @@ class State(object):
|
||||
|
||||
|
||||
def reset(self):
|
||||
# Unhome all motors (real AVR motors 0..3 and the synthetic
|
||||
# external-axis motor at index 4 used by ExternalAxis).
|
||||
# Both <motor>homed and <motor>h are cleared - they're set
|
||||
# by different code paths (gplan emits homed via _<axis>_homed
|
||||
# set blocks, AVR reports h directly).
|
||||
for i in range(5):
|
||||
self.set('%dhomed' % i, False)
|
||||
self.set('%dh' % i, 0)
|
||||
# Unhome all motors
|
||||
for i in range(4): self.set('%dhomed' % i, False)
|
||||
|
||||
# Zero offsets and positions
|
||||
for axis in 'xyzabc':
|
||||
@@ -286,11 +280,8 @@ class State(object):
|
||||
axis_motors = {axis: self.find_motor(axis) for axis in 'xyzabc'}
|
||||
axis_vars = {}
|
||||
|
||||
# NOTE: motor index '4' is a host-only synthetic motor used
|
||||
# by ExternalAxis to expose the auxcnc ESP-driven stepper as
|
||||
# an additional axis. Real AVR motors are 0..3.
|
||||
for name, value in vars.items():
|
||||
if name[0] in '01234':
|
||||
if name[0] in '0123':
|
||||
motor = int(name[0])
|
||||
|
||||
for axis in 'xyzabc':
|
||||
@@ -339,9 +330,6 @@ class State(object):
|
||||
def get_axis_vector(self, name, scale = 1):
|
||||
v = {}
|
||||
|
||||
# 0..3 are AVR motor channels. 4 is the host-side synthetic
|
||||
# motor used by ExternalAxis. find_motor returns the right
|
||||
# index regardless of whether the axis is physical or external.
|
||||
for axis in 'xyzabc':
|
||||
motor = self.find_motor(axis)
|
||||
|
||||
@@ -363,10 +351,7 @@ class State(object):
|
||||
|
||||
|
||||
def find_motor(self, axis):
|
||||
# Walk 0..4: 0..3 are real AVR motors, 4 is the synthetic
|
||||
# host-side motor used to expose the auxcnc ESP stepper as
|
||||
# an external axis.
|
||||
for motor in range(5):
|
||||
for motor in range(4):
|
||||
if not ('%dan' % motor) in self.vars: continue
|
||||
motor_axis = 'xyzabc'[self.vars['%dan' % motor]]
|
||||
if motor_axis == axis.lower() and self.vars.get('%dme' % motor, 0):
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
################################################################################
|
||||
# #
|
||||
# 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')
|
||||
@@ -411,22 +411,11 @@ class PathHandler(bbctrl.APIHandler):
|
||||
|
||||
except gen.TimeoutError:
|
||||
progress = preplanner.get_plan_progress(filename)
|
||||
err = preplanner.get_plan_error(filename)
|
||||
resp = dict(progress = progress)
|
||||
if err: resp['error'] = err
|
||||
self.write_json(resp)
|
||||
self.write_json(dict(progress = progress))
|
||||
return
|
||||
|
||||
try:
|
||||
# Plan finished but produced no data (planner subprocess
|
||||
# failed, e.g. AuxPreprocessor coupling rejection at
|
||||
# planner-load time). Surface the error so the UI can
|
||||
# close the "Processing New File" dialog instead of
|
||||
# polling forever.
|
||||
if data is None:
|
||||
err = preplanner.get_plan_error(filename) or 'Plan failed'
|
||||
self.write_json(dict(progress = 1, error = err))
|
||||
return
|
||||
if data is None: return
|
||||
meta, positions, speeds = data
|
||||
|
||||
if dataType == '/positions': data = positions
|
||||
@@ -823,13 +812,9 @@ class AuxStatusHandler(bbctrl.APIHandler):
|
||||
|
||||
class AuxHomeHandler(bbctrl.APIHandler):
|
||||
def put_ok(self):
|
||||
# Run synchronously. Route through ExternalAxis so the
|
||||
# synthetic motor's homed flag and DRO update.
|
||||
ext = getattr(self.get_ctrl(), 'ext_axis', None)
|
||||
if ext is not None and ext.enabled:
|
||||
ext.home()
|
||||
else:
|
||||
self.get_ctrl().aux.home()
|
||||
# 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):
|
||||
@@ -839,22 +824,12 @@ class AuxAbortHandler(bbctrl.APIHandler):
|
||||
|
||||
class AuxJogHandler(bbctrl.APIHandler):
|
||||
"""Body: {"mm": 1.5} for relative-mm move,
|
||||
{"steps": 200} for raw step move (bypasses soft limits).
|
||||
|
||||
Note: with the gplan-integrated W axis, jog-by-mm goes through
|
||||
ExternalAxis so the DRO updates and gplan's idea of A's position
|
||||
stays in sync. jog-by-steps still bypasses everything for the
|
||||
homing/setup workflow where the axis isn't homed yet."""
|
||||
{"steps": 200} for raw step move (bypasses soft limits)."""
|
||||
def put_ok(self):
|
||||
body = self.json or {}
|
||||
aux = self.get_ctrl().aux
|
||||
ext = getattr(self.get_ctrl(), 'ext_axis', None)
|
||||
if 'mm' in body:
|
||||
delta_mm = float(body['mm'])
|
||||
if ext is not None and ext.enabled and ext._pos_mm is not None:
|
||||
ext.execute_to_mm(ext._pos_mm + delta_mm)
|
||||
else:
|
||||
aux.move_rel_mm(delta_mm)
|
||||
aux.move_rel_mm(float(body['mm']))
|
||||
elif 'steps' in body:
|
||||
aux.jog_steps(int(body['steps']))
|
||||
else:
|
||||
@@ -867,11 +842,7 @@ class AuxMoveHandler(bbctrl.APIHandler):
|
||||
body = self.json or {}
|
||||
if 'mm' not in body:
|
||||
raise HTTPError(400, 'mm required')
|
||||
ext = getattr(self.get_ctrl(), 'ext_axis', None)
|
||||
if ext is not None and ext.enabled:
|
||||
ext.execute_to_mm(float(body['mm']))
|
||||
else:
|
||||
self.get_ctrl().aux.move_abs_mm(float(body['mm']))
|
||||
self.get_ctrl().aux.move_abs_mm(float(body['mm']))
|
||||
|
||||
|
||||
class AuxSetZeroHandler(bbctrl.APIHandler):
|
||||
@@ -914,31 +885,6 @@ 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):
|
||||
@@ -1014,12 +960,6 @@ 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)
|
||||
|
||||
|
||||
@@ -1028,23 +968,6 @@ 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
|
||||
@@ -1066,8 +989,6 @@ 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),
|
||||
|
||||
@@ -36,13 +36,6 @@ 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
|
||||
@@ -68,14 +61,11 @@ from bbctrl.IOLoop import IOLoop
|
||||
from bbctrl.MonitorTemp import MonitorTemp
|
||||
from bbctrl.Hooks import Hooks
|
||||
from bbctrl.AuxAxis import AuxAxis
|
||||
from bbctrl.ExternalAxis import ExternalAxis
|
||||
import bbctrl.Cmd as Cmd
|
||||
import bbctrl.v4l2 as v4l2
|
||||
import bbctrl.Log as log
|
||||
import bbctrl.ObjGraph as ObjGraph
|
||||
|
||||
Trace.mark('imports.bbctrl.end')
|
||||
|
||||
|
||||
ctrl = None
|
||||
|
||||
@@ -179,28 +169,19 @@ 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
|
||||
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))
|
||||
web = Web(args, ioloop)
|
||||
|
||||
try:
|
||||
ioloop.start()
|
||||
|
||||
BIN
src/resources/fonts/fontawesome-webfont.ttf
Normal file
BIN
src/resources/fonts/fontawesome-webfont.ttf
Normal file
Binary file not shown.
BIN
src/resources/fonts/fontawesome-webfont.woff
Normal file
BIN
src/resources/fonts/fontawesome-webfont.woff
Normal file
Binary file not shown.
BIN
src/resources/fonts/fontawesome-webfont.woff2
Normal file
BIN
src/resources/fonts/fontawesome-webfont.woff2
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -2,5 +2,5 @@
|
||||
font-family: 'Audiowide';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: local('Audiowide'), local('Audiowide-Regular'), url(https://fonts.gstatic.com/s/audiowide/v4/8XtYtNKEyyZh481XVWfVOqCWcynf_cDxXwCLxiixG1c.ttf) format('truetype');
|
||||
src: local('Audiowide'), local('Audiowide-Regular'), url(http://fonts.gstatic.com/s/audiowide/v4/8XtYtNKEyyZh481XVWfVOqCWcynf_cDxXwCLxiixG1c.ttf) format('truetype');
|
||||
}
|
||||
|
||||
9
src/static/css/fa6.min.css
vendored
9
src/static/css/fa6.min.css
vendored
File diff suppressed because one or more lines are too long
4
src/static/css/font-awesome.min.css
vendored
Normal file
4
src/static/css/font-awesome.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -1,37 +1,35 @@
|
||||
// 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");
|
||||
var menu = document.getElementById("menu");
|
||||
var layout = document.getElementById('layout'),
|
||||
menu = document.getElementById('menu'),
|
||||
menuLink = document.getElementById('menuLink');
|
||||
|
||||
function toggleClass(element, className) {
|
||||
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;
|
||||
}
|
||||
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 (i === classes.length) {
|
||||
// The className is not found
|
||||
if (length === 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));
|
||||
|
||||
@@ -30,16 +30,6 @@ $jog-ghost-hov = #9ba6bb
|
||||
$jog-ink = #fff
|
||||
$jog-ghost-ink = $ink
|
||||
|
||||
// Lock html + body so nothing other than the explicit .app-body or
|
||||
// inner scroll containers can scroll. Without this, autofocus inside
|
||||
// nested Svelte components (Settings, Admin Network, etc.) can call
|
||||
// scrollIntoView() on the html element and push our fixed header off
|
||||
// the top of the viewport.
|
||||
html, body
|
||||
height 100%
|
||||
overflow hidden
|
||||
overscroll-behavior none
|
||||
|
||||
body
|
||||
margin 0
|
||||
font-family 'Inter', system-ui, -apple-system, sans-serif
|
||||
@@ -84,70 +74,8 @@ tt
|
||||
.app-shell
|
||||
display flex
|
||||
flex-direction column
|
||||
height 100vh // cap at viewport so children that ask for 1fr/flex:1
|
||||
width 100%
|
||||
overflow hidden
|
||||
min-height 100vh
|
||||
background $body-bg
|
||||
// Hint to the browser that this layer is a stable composited
|
||||
// surface so tab swaps inside it don't invalidate the whole page.
|
||||
contain layout paint
|
||||
isolation isolate
|
||||
|
||||
// Program tab pre-warmer. Mounts the program-view at app start so
|
||||
// the WebGL canvas is already initialized when the user first
|
||||
// clicks the Program tab — avoids the first-time dark flash that
|
||||
// happens when WebGL is created on demand. We render the component
|
||||
// at full size off-screen with the same width as the live tab so
|
||||
// the canvas dims match.
|
||||
.program-warmer
|
||||
position absolute
|
||||
left -10000px
|
||||
top 0
|
||||
width 1920px
|
||||
height 980px
|
||||
pointer-events none
|
||||
visibility hidden
|
||||
|
||||
// =====================================================================
|
||||
// Tablet / kiosk mode
|
||||
//
|
||||
// When <html class="tablet-mode"> is set (via ?tablet=1, sticky in
|
||||
// localStorage), pin the app shell to a fixed 1920 x 1080 box and
|
||||
// scale it to fit the real viewport. The scale ratio is published as
|
||||
// the --tablet-scale CSS variable by the inline script in index.pug.
|
||||
//
|
||||
// On the actual 10.8" 1920x1080 portable monitor the scale is 1; on
|
||||
// a desktop browser at e.g. 1440x900 you get a faithfully shrunk
|
||||
// preview of exactly what the kiosk renders.
|
||||
// =====================================================================
|
||||
html.tablet-mode
|
||||
background #0f172a
|
||||
overflow hidden
|
||||
|
||||
html.tablet-mode body
|
||||
margin 0
|
||||
width 100vw
|
||||
height 100vh
|
||||
overflow hidden
|
||||
display flex
|
||||
align-items center
|
||||
justify-content center
|
||||
|
||||
html.tablet-mode .app-shell
|
||||
width 1920px
|
||||
height 1080px
|
||||
flex 0 0 auto
|
||||
transform scale(var(--tablet-scale, 1))
|
||||
transform-origin center center
|
||||
box-shadow 0 30px 60px rgba(0, 0, 0, 0.45)
|
||||
border-radius 14px
|
||||
|
||||
// Keep dialog hosts above the scaled shell.
|
||||
html.tablet-mode #svelte-dialog-host
|
||||
position fixed
|
||||
top 0
|
||||
left 0
|
||||
z-index 10000
|
||||
|
||||
.app-body
|
||||
flex 1
|
||||
@@ -155,10 +83,9 @@ html.tablet-mode #svelte-dialog-host
|
||||
display flex
|
||||
flex-direction column
|
||||
padding 18px
|
||||
overflow auto // settings/motor pages can scroll inside the body
|
||||
|
||||
> *
|
||||
flex 1 1 auto
|
||||
flex 1
|
||||
min-height 0
|
||||
|
||||
.app-head
|
||||
@@ -170,10 +97,7 @@ html.tablet-mode #svelte-dialog-host
|
||||
padding 0 24px
|
||||
background $bg
|
||||
border-bottom 1px solid $line
|
||||
// sticky so the header stays visible even if a nested scroll
|
||||
// container manages to move under it.
|
||||
position sticky
|
||||
top 0
|
||||
position relative
|
||||
z-index 30
|
||||
|
||||
.brand-blk
|
||||
@@ -572,34 +496,28 @@ span.unit
|
||||
50%
|
||||
fill #ff9d00
|
||||
|
||||
// E-Stop in the header — wraps the legacy <estop> SVG component.
|
||||
// Sized to fit the 96px header with breathing room. The SVG carries
|
||||
// its own yellow safety ring and EMERGENCY/STOP text; we only frame
|
||||
// it with a soft drop shadow and a hover/active hit target.
|
||||
// Octagonal STOP wrapper around the existing <estop> SVG. The SVG
|
||||
// rules below (`.button`, `.ring`, etc.) keep working unchanged.
|
||||
.app-head .estop
|
||||
width 80px
|
||||
height 80px
|
||||
display inline-flex
|
||||
width 88px
|
||||
height 88px
|
||||
background #dc2626
|
||||
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-radius 9999px
|
||||
border 3px solid #fff
|
||||
box-shadow 0 0 0 3px #b91c1c, 0 8px 20px rgba(220, 38, 38, 0.35)
|
||||
cursor pointer
|
||||
transition transform 0.06s, filter 0.15s
|
||||
flex 0 0 auto
|
||||
// Make sure the SVG's internal coordinate space scales correctly
|
||||
overflow visible
|
||||
|
||||
&:hover
|
||||
filter brightness(1.05)
|
||||
transition transform 0.06s
|
||||
|
||||
&:active
|
||||
transform scale(0.96)
|
||||
|
||||
svg
|
||||
width 80px
|
||||
height 80px
|
||||
width 56px
|
||||
height 56px
|
||||
cursor pointer
|
||||
display block
|
||||
|
||||
.button:hover
|
||||
filter brightness(120%)
|
||||
@@ -717,12 +635,8 @@ span.unit
|
||||
text-transform capitalize
|
||||
|
||||
.path-viewer-content
|
||||
// Solid dark background matching the WebGL renderer's clear
|
||||
// colour. We used to use a gradient (#666 -> #222) but the
|
||||
// visible-during-mount color difference between the gradient
|
||||
// top and the GL clear bottom caused a brief dark flash when
|
||||
// the canvas was reattached on every tab switch.
|
||||
background-color #222
|
||||
background-color #333
|
||||
background linear-gradient(to bottom, #666 0%, #222 100%);
|
||||
margin-bottom 0.5em
|
||||
|
||||
&.small
|
||||
@@ -1095,12 +1009,11 @@ tt.save
|
||||
// CONTROL page (V09)
|
||||
// =====================================================================
|
||||
.control-page
|
||||
flex 1 1 auto
|
||||
flex 1
|
||||
min-height 0
|
||||
display flex
|
||||
flex-direction column
|
||||
gap 14px
|
||||
overflow hidden
|
||||
|
||||
.control-page .control-grid
|
||||
display grid
|
||||
@@ -1150,150 +1063,6 @@ tt.save
|
||||
font-size 0.85em
|
||||
margin-left 2px
|
||||
|
||||
// =====================================================================
|
||||
// NOW RUNNING panel
|
||||
// Replaces the jog grid (left column) while the machine is not idle.
|
||||
// Same outer dimensions as .jog-card so the rest of the layout doesn't
|
||||
// reflow.
|
||||
// =====================================================================
|
||||
.control-page .running-panel
|
||||
flex 1 1 auto
|
||||
min-height 0
|
||||
display flex
|
||||
flex-direction column
|
||||
gap 18px
|
||||
padding 24px
|
||||
border-radius 18px
|
||||
color #fff
|
||||
background linear-gradient(160deg, #0f172a 0%, #1e293b 100%)
|
||||
|
||||
.running-top
|
||||
display flex
|
||||
align-items flex-start
|
||||
justify-content space-between
|
||||
gap 18px
|
||||
|
||||
.running-file
|
||||
font-size 1.6rem
|
||||
font-weight 900
|
||||
line-height 1.1
|
||||
display flex
|
||||
align-items center
|
||||
gap 4px
|
||||
|
||||
.fa
|
||||
color $accent
|
||||
|
||||
.running-meta
|
||||
font-size 0.9rem
|
||||
color #cbd5e1
|
||||
font-weight 600
|
||||
margin-top 6px
|
||||
text-transform capitalize
|
||||
|
||||
.running-pct
|
||||
font-family 'JetBrains Mono', monospace
|
||||
font-size 3.6rem
|
||||
font-weight 900
|
||||
line-height 1
|
||||
color #fff
|
||||
|
||||
span
|
||||
font-size 2rem
|
||||
color $accent
|
||||
font-weight 800
|
||||
margin-left 4px
|
||||
|
||||
.running-progress
|
||||
height 14px
|
||||
background rgba(255, 255, 255, 0.18)
|
||||
border-radius 9999px
|
||||
overflow hidden
|
||||
width 100%
|
||||
|
||||
> div
|
||||
height 100%
|
||||
background $accent
|
||||
transition width 0.4s ease-out
|
||||
|
||||
.running-stats
|
||||
display grid
|
||||
grid-template-columns repeat(4, 1fr)
|
||||
gap 14px
|
||||
|
||||
.running-stat
|
||||
background rgba(255, 255, 255, 0.06)
|
||||
border-radius 14px
|
||||
padding 14px 16px
|
||||
|
||||
.lbl
|
||||
font-size 0.7rem
|
||||
letter-spacing 0.14em
|
||||
text-transform uppercase
|
||||
color #cbd5e1
|
||||
font-weight 700
|
||||
|
||||
.val
|
||||
font-family 'JetBrains Mono', monospace
|
||||
font-weight 900
|
||||
font-size 1.5rem
|
||||
margin-top 4px
|
||||
color #fff
|
||||
|
||||
.running-row
|
||||
display grid
|
||||
grid-template-columns repeat(auto-fit, minmax(140px, 1fr))
|
||||
gap 14px
|
||||
margin-top auto
|
||||
|
||||
.running-row .tx-btn
|
||||
display inline-flex
|
||||
flex-direction column
|
||||
align-items center
|
||||
justify-content center
|
||||
gap 6px
|
||||
height 120px
|
||||
border-radius 18px
|
||||
border none
|
||||
font-weight 800
|
||||
letter-spacing 0.05em
|
||||
cursor pointer
|
||||
background #1e293b
|
||||
color #fff
|
||||
transition transform 0.06s, background 0.15s
|
||||
|
||||
.running-row .tx-btn .fa
|
||||
font-size 2.4rem
|
||||
|
||||
.running-row .tx-btn .lbl
|
||||
font-size 0.85rem
|
||||
opacity 0.9
|
||||
|
||||
.running-row .tx-btn:hover
|
||||
background #334155
|
||||
|
||||
.running-row .tx-btn:active
|
||||
transform translateY(2px)
|
||||
|
||||
.running-row .tx-btn.pause
|
||||
background #f59e0b
|
||||
color #0f172a
|
||||
|
||||
.running-row .tx-btn.pause:hover
|
||||
background #d97706
|
||||
|
||||
.running-row .tx-btn.run
|
||||
background #16a34a
|
||||
|
||||
.running-row .tx-btn.run:hover
|
||||
background #15803d
|
||||
|
||||
.running-row .tx-btn.stop
|
||||
background #dc2626
|
||||
|
||||
.running-row .tx-btn.stop:hover
|
||||
background #b91c1c
|
||||
|
||||
// Step segmented control
|
||||
.step-seg
|
||||
display inline-flex
|
||||
@@ -1333,16 +1102,12 @@ tt.save
|
||||
flex-direction column
|
||||
align-items center
|
||||
justify-content center
|
||||
gap 6px
|
||||
gap 4px
|
||||
user-select none
|
||||
-webkit-tap-highlight-color transparent
|
||||
cursor pointer
|
||||
font-weight 700
|
||||
// Single sizing used by both the 1920x1080 portable touchscreen and
|
||||
// the Pi 1366x768 kiosk — large enough to be readable at arm's
|
||||
// length on the smaller display, still proportionate on the bigger
|
||||
// one. No mode-specific override.
|
||||
font-size 1.6rem
|
||||
font-size 1.05rem
|
||||
border none
|
||||
background $jog-bg
|
||||
color $jog-ink
|
||||
@@ -1351,13 +1116,13 @@ tt.save
|
||||
min-width 0
|
||||
|
||||
.ico
|
||||
font-size 2.4rem
|
||||
font-size 1.6rem
|
||||
|
||||
.lbl
|
||||
font-size 1.5rem
|
||||
font-size 0.8rem
|
||||
color inherit
|
||||
opacity 0.95
|
||||
font-weight 700
|
||||
opacity 0.85
|
||||
font-weight 600
|
||||
|
||||
&:hover:not([disabled])
|
||||
background $jog-hover
|
||||
@@ -1396,7 +1161,7 @@ tt.save
|
||||
|
||||
.control-page .dro-head, .control-page .dro-row
|
||||
display grid
|
||||
grid-template-columns 84px 1.4fr 1fr 1fr 280px
|
||||
grid-template-columns 84px 1.4fr 1fr 1fr 170px 170px 280px
|
||||
column-gap 0.75rem
|
||||
align-items center
|
||||
padding 14px 22px
|
||||
@@ -1410,15 +1175,6 @@ tt.save
|
||||
letter-spacing 0.1em
|
||||
color $muted-2
|
||||
|
||||
// Master Home All sits in the header's Actions cell. Make it
|
||||
// visually subordinate to the per-axis home buttons in the rows
|
||||
// below — same family, smaller scale.
|
||||
.icon-btn
|
||||
width 44px
|
||||
height 44px
|
||||
font-size 0.95rem
|
||||
border-radius 9px
|
||||
|
||||
.control-page .dro-row
|
||||
border-bottom 1px solid $line-soft
|
||||
flex 1
|
||||
@@ -1529,39 +1285,6 @@ tt.save
|
||||
opacity 0.45
|
||||
cursor not-allowed
|
||||
|
||||
// State-tinted variants used on home + zero buttons in DRO rows
|
||||
// to communicate per-axis homing / toolpath fit at a glance,
|
||||
// replacing the explicit HOMED / OK chips that used to live in
|
||||
// their own columns.
|
||||
&.state-green
|
||||
background #dcfce7
|
||||
border-color #86efac
|
||||
color #166534
|
||||
|
||||
&:hover:not([disabled])
|
||||
background #bbf7d0
|
||||
|
||||
&.state-amber
|
||||
background #fef3c7
|
||||
border-color #fcd34d
|
||||
color #92400e
|
||||
|
||||
&:hover:not([disabled])
|
||||
background #fde68a
|
||||
|
||||
&.state-red
|
||||
background #fee2e2
|
||||
border-color #fca5a5
|
||||
color #991b1b
|
||||
|
||||
&:hover:not([disabled])
|
||||
background #fecaca
|
||||
|
||||
&[disabled].state-green,
|
||||
&[disabled].state-amber,
|
||||
&[disabled].state-red
|
||||
opacity 0.7
|
||||
|
||||
.actions-cell
|
||||
display flex
|
||||
justify-content flex-end
|
||||
@@ -1639,6 +1362,7 @@ tt.save
|
||||
height 84px
|
||||
border-radius 14px
|
||||
border 1px solid transparent
|
||||
border-left 6px solid $accent
|
||||
color #fff
|
||||
background $jog-bg
|
||||
font-weight 800
|
||||
@@ -1662,12 +1386,6 @@ tt.save
|
||||
opacity 0.45
|
||||
cursor not-allowed
|
||||
|
||||
// Per-macro color stripe is opt-in via :class="has-color" set by
|
||||
// the template only when state.macros[i].color is configured.
|
||||
&.has-color
|
||||
border-left-width 6px
|
||||
border-left-style solid
|
||||
|
||||
.mnum
|
||||
display inline-flex
|
||||
align-items center
|
||||
@@ -1680,12 +1398,16 @@ tt.save
|
||||
font-size 0.85rem
|
||||
font-weight 900
|
||||
text-shadow none
|
||||
flex 0 0 auto
|
||||
|
||||
.micon
|
||||
font-size 1.1rem
|
||||
opacity 0.75
|
||||
|
||||
.mname
|
||||
white-space nowrap
|
||||
overflow hidden
|
||||
text-overflow ellipsis
|
||||
max-width 9em
|
||||
|
||||
// Override drawer
|
||||
.override-drawer
|
||||
@@ -1765,11 +1487,10 @@ tt.save
|
||||
// PROGRAM page (V09)
|
||||
// =====================================================================
|
||||
.program-page
|
||||
flex 1 1 auto
|
||||
flex 1
|
||||
min-height 0
|
||||
display flex
|
||||
flex-direction column
|
||||
overflow hidden
|
||||
|
||||
.program-card
|
||||
flex 1
|
||||
@@ -1888,59 +1609,21 @@ tt.save
|
||||
min-width 300px
|
||||
|
||||
.program-body
|
||||
flex 1 1 auto
|
||||
flex 1
|
||||
display grid
|
||||
grid-template-columns 1fr 600px
|
||||
min-height 0
|
||||
overflow hidden
|
||||
|
||||
// On the Pi's onboard kiosk browser the 3D toolpath preview is
|
||||
// suppressed (Pi 3B's VideoCore IV can't run three.js fast
|
||||
// enough), so the gcode listing claims the full width.
|
||||
&.no-preview
|
||||
grid-template-columns 1fr
|
||||
|
||||
> .gcode
|
||||
border-right none
|
||||
|
||||
> .gcode
|
||||
border-right 1px solid $line-soft
|
||||
overflow auto
|
||||
background #fafafa
|
||||
padding 0
|
||||
margin 0
|
||||
overflow hidden
|
||||
min-height 0
|
||||
display flex
|
||||
flex-direction column
|
||||
|
||||
// 3D toolpath preview — fill the entire 600px column. Override the
|
||||
// legacy `.path-viewer.small` rule which would clamp the canvas to
|
||||
// 340x150 and float it into the corner.
|
||||
> .path-viewer
|
||||
overflow hidden
|
||||
min-height 0
|
||||
height 100%
|
||||
background #222 // matches the WebGL clear colour so any
|
||||
// first-frame mismatch reads as the same dark
|
||||
// panel rather than a flash.
|
||||
display flex
|
||||
flex-direction column
|
||||
|
||||
.path-viewer-content
|
||||
flex 1 1 auto
|
||||
width 100% !important
|
||||
height 100% !important
|
||||
min-height 0
|
||||
float none !important
|
||||
margin 0 !important
|
||||
background #222
|
||||
|
||||
&.small .path-viewer-content
|
||||
width 100% !important
|
||||
height 100% !important
|
||||
float none !important
|
||||
margin 0 !important
|
||||
background #222
|
||||
|
||||
.progress-bar
|
||||
height 28px
|
||||
@@ -1962,20 +1645,8 @@ tt.save
|
||||
font-size 13px
|
||||
line-height 1.55
|
||||
|
||||
.clusterize
|
||||
flex 1 1 0
|
||||
min-height 0
|
||||
overflow hidden
|
||||
display flex
|
||||
flex-direction column
|
||||
height 100%
|
||||
|
||||
.clusterize-scroll
|
||||
flex 1 1 0
|
||||
min-height 0
|
||||
height 100%
|
||||
max-height none // override clusterize.css default of 200px
|
||||
width 100%
|
||||
max-height 100%
|
||||
overflow auto
|
||||
|
||||
ul
|
||||
@@ -2004,10 +1675,9 @@ tt.save
|
||||
// CONSOLE page (V09)
|
||||
// =====================================================================
|
||||
.console-page
|
||||
flex 1 1 auto
|
||||
flex 1
|
||||
display flex
|
||||
min-height 0
|
||||
overflow hidden
|
||||
|
||||
.console-card
|
||||
flex 1
|
||||
@@ -2336,321 +2006,3 @@ tt.save
|
||||
|
||||
h1, h2, h3
|
||||
margin-top 0
|
||||
|
||||
.settings-loading
|
||||
color $muted
|
||||
font-style italic
|
||||
padding 24px
|
||||
|
||||
// =====================================================================
|
||||
// KIOSK MODE — compact layout for the controller's own onboard browser
|
||||
// (Pi 3B at 1366x768). Activated by `html.kiosk-mode` (auto-applied
|
||||
// when location.hostname is localhost). All overrides target the V09
|
||||
// shell so the desktop / portable touchscreen layout is unaffected.
|
||||
// =====================================================================
|
||||
html.kiosk-mode
|
||||
font-size 13px
|
||||
|
||||
.app-head
|
||||
flex 0 0 56px
|
||||
height 56px
|
||||
padding 0 12px
|
||||
gap 10px
|
||||
|
||||
.brand-blk
|
||||
display none
|
||||
|
||||
.estop
|
||||
transform scale(0.6)
|
||||
transform-origin right center
|
||||
|
||||
.tabs-host
|
||||
height 56px
|
||||
padding-left 0
|
||||
|
||||
.ktab
|
||||
height 56px
|
||||
padding 0 14px
|
||||
font-size 14px
|
||||
gap 6px
|
||||
|
||||
.fa
|
||||
font-size 16px
|
||||
|
||||
.ktab .ktab-underline,
|
||||
.ktab.active::after
|
||||
bottom 0
|
||||
|
||||
.app-body
|
||||
padding 8px
|
||||
gap 8px
|
||||
|
||||
// Control page: tighten everything
|
||||
.control-page
|
||||
gap 8px
|
||||
|
||||
// Keep two columns at 1366x768 — vertical space is the constraint.
|
||||
// Shrink the jog column from 720px to 540px so the DRO has more
|
||||
// breathing room.
|
||||
.control-page .control-grid
|
||||
grid-template-columns 540px 1fr
|
||||
gap 8px
|
||||
|
||||
.control-page .right-col
|
||||
grid-template-rows 1fr 110px
|
||||
gap 8px
|
||||
|
||||
.control-page .jog-card
|
||||
padding 10px
|
||||
|
||||
.control-page .jog-head
|
||||
margin-bottom 8px
|
||||
|
||||
.control-page .jog-title
|
||||
font-size 14px
|
||||
|
||||
.control-page .jog-grid
|
||||
gap 6px
|
||||
|
||||
.control-page .dro-head, .control-page .dro-row
|
||||
grid-template-columns 56px 1fr 0.85fr 0.85fr 1fr
|
||||
column-gap 0.4rem
|
||||
padding 6px 10px
|
||||
|
||||
.control-page .dro-head
|
||||
font-size 0.65rem
|
||||
|
||||
.control-page .dro-row
|
||||
font-size 0.95rem
|
||||
|
||||
// Axis-action buttons in DRO rows (settings/zero/home).
|
||||
.control-page .dro-row .icon-btn
|
||||
width 56px
|
||||
height 56px
|
||||
font-size 1.25rem
|
||||
border-radius 11px
|
||||
|
||||
.control-page .status-strip
|
||||
grid-template-columns repeat(2, 1fr)
|
||||
gap 8px
|
||||
|
||||
.control-page .stat-card
|
||||
padding 8px 12px
|
||||
|
||||
.stat-label
|
||||
font-size 9px
|
||||
|
||||
.stat-val
|
||||
font-size 18px
|
||||
margin-top 2px
|
||||
|
||||
.stat-sub
|
||||
font-size 11px
|
||||
margin-top 0
|
||||
|
||||
// Macros: 8 -> 4 column grid; shorter buttons.
|
||||
.control-page .macro-row
|
||||
grid-template-columns repeat(4, 1fr)
|
||||
gap 6px
|
||||
|
||||
.macro-btn
|
||||
height 56px
|
||||
font-size 0.85rem
|
||||
border-radius 10px
|
||||
|
||||
// Now-running panel: tighten paddings, smaller percent text.
|
||||
.control-page .running-panel
|
||||
padding 12px
|
||||
gap 10px
|
||||
|
||||
.running-file
|
||||
font-size 1.15rem
|
||||
|
||||
.running-pct
|
||||
font-size 2.2rem
|
||||
|
||||
span
|
||||
font-size 1.2rem
|
||||
|
||||
.running-stats
|
||||
grid-template-columns repeat(2, 1fr)
|
||||
gap 6px
|
||||
|
||||
.running-stat
|
||||
padding 8px 10px
|
||||
|
||||
.val
|
||||
font-size 1rem
|
||||
|
||||
// Program page: gcode listing fills (path-viewer is hidden via JS).
|
||||
.program-page
|
||||
gap 8px
|
||||
|
||||
// Settings shell: tighter rail.
|
||||
.settings-shell .rail
|
||||
padding 8px
|
||||
|
||||
.settings-shell .rail .rail-item
|
||||
padding 8px 10px
|
||||
font-size 0.9rem
|
||||
|
||||
.settings-content
|
||||
padding 10px
|
||||
|
||||
// System pill / sidebar headers smaller.
|
||||
.system-pill, .sidebar-pill
|
||||
font-size 0.8rem
|
||||
|
||||
// Inside system-pill, the icon + text need explicit spacing.
|
||||
.system-pill > * + *,
|
||||
.sidebar-pill > * + *
|
||||
margin-left 6px
|
||||
|
||||
// Modal dialogs scaled down for the smaller viewport.
|
||||
.modal-bg
|
||||
font-size 13px
|
||||
|
||||
|
||||
// =====================================================================
|
||||
// LEGACY-CHROMIUM FLEX-GAP FALLBACK
|
||||
// =====================================================================
|
||||
// Chromium 72 (which ships on the controller's Pi 3B) does not support
|
||||
// `gap` on flexbox containers — it landed in Chrome 84 (2020-05). Grid
|
||||
// `gap` IS supported (Chrome 57+) so anything `display: grid` is fine.
|
||||
//
|
||||
// We can't use @supports not (gap: 1px) because Chromium 72 reports
|
||||
// it supports `gap` (the spec considers it a grid-only property in
|
||||
// older snapshots). Instead, we add explicit margin-based spacing for
|
||||
// every known flex container in the V09 shell that visibly breaks on
|
||||
// the Pi kiosk. The modern CSS gap rule still applies in newer Chrome
|
||||
// — these rules are inert (margin-left:auto rules elsewhere keep
|
||||
// their meaning) because the gap pushes children apart anyway.
|
||||
|
||||
// App header — brand block, tabs, system pill, estop
|
||||
.app-head > * + *
|
||||
margin-left 18px
|
||||
|
||||
.app-head .brand-blk > * + *
|
||||
margin-left 14px
|
||||
|
||||
// Tabs ribbon — the tab button itself uses flex+gap to space its
|
||||
// icon and label. Without flex-gap support those collapse.
|
||||
.ktab > * + *
|
||||
margin-left 0.55rem
|
||||
|
||||
// Header system pill (sys-btn) and machine state badge
|
||||
.sys-btn > * + *
|
||||
margin-left 0.55rem
|
||||
.state-badge > * + *
|
||||
margin-left 0.6rem
|
||||
|
||||
// Jog card title row
|
||||
.control-page .jog-head > * + *
|
||||
margin-left 12px
|
||||
|
||||
// Now-running panel — top, file/meta, stats, row, transport
|
||||
.control-page .running-panel > * + *
|
||||
margin-top 18px
|
||||
.running-top > * + *
|
||||
margin-left 18px
|
||||
.running-row > * + *
|
||||
margin-left 14px
|
||||
.transport-row > * + *
|
||||
margin-left 14px
|
||||
|
||||
// Console card tabs
|
||||
.console-card .ptab-bar > * + *
|
||||
margin-left 6px
|
||||
|
||||
// Settings shell rail and content
|
||||
.settings-shell > * + *
|
||||
margin-left 18px
|
||||
|
||||
// Macro buttons (.macro-btn icon + label)
|
||||
.macro-btn > * + *
|
||||
margin-left 0.6rem
|
||||
|
||||
// Jog buttons (.jbtn ico + lbl) — column flex, vertical gap
|
||||
.jbtn > * + *
|
||||
margin-top 4px
|
||||
|
||||
// DRO actions cell already uses gap; emulate via margin
|
||||
.actions-cell > * + *
|
||||
margin-left 10px
|
||||
|
||||
// Generic ".header"-style flex rows in older subpages
|
||||
.app-body .pure-form > * + *
|
||||
margin-top 4px
|
||||
|
||||
// =====================================================================
|
||||
// KIOSK-MODE-SPECIFIC LEGACY FALLBACKS
|
||||
// =====================================================================
|
||||
html.kiosk-mode
|
||||
.app-head > * + *
|
||||
margin-left 10px
|
||||
|
||||
.control-page
|
||||
> * + *
|
||||
margin-top 8px
|
||||
|
||||
.control-page .control-grid
|
||||
// grid-gap works on Chromium 72 so nothing here.
|
||||
|
||||
.control-page .right-col
|
||||
// grid-gap works.
|
||||
grid-template-rows 1fr 158px // tighter than the desktop 158px
|
||||
|
||||
.running-row > * + *
|
||||
margin-left 6px
|
||||
|
||||
.control-page .jog-head > * + *
|
||||
margin-left 8px
|
||||
|
||||
|
||||
// Settings rail must be scrollable in kiosk mode \u2014 the 14+
|
||||
// item list overflows the 768px viewport at default heights.
|
||||
.settings-shell
|
||||
grid-template-columns 220px 1fr
|
||||
gap 10px
|
||||
|
||||
.settings-rail
|
||||
position static
|
||||
align-self stretch
|
||||
max-height 100%
|
||||
overflow-y auto
|
||||
|
||||
.settings-rail .set-item
|
||||
height 36px
|
||||
font-size 0.85rem
|
||||
padding 0 10px
|
||||
|
||||
.fa
|
||||
width 14px
|
||||
font-size 0.9rem
|
||||
|
||||
.settings-rail .set-section
|
||||
margin 6px 4px 2px
|
||||
font-size 0.62rem
|
||||
|
||||
.settings-rail .set-rail-foot
|
||||
margin-top 4px
|
||||
padding-top 6px
|
||||
|
||||
.sp-shutdown, .sp-save
|
||||
height 32px
|
||||
font-size 0.85rem
|
||||
|
||||
|
||||
// Program tab flex-gap fallbacks for Chromium 72.
|
||||
// Action bar (RUN/STOP/UPLOAD/.../DELETE) and the action buttons
|
||||
// themselves (icon stacked over label).
|
||||
.action-bar > * + *
|
||||
margin-left 12px
|
||||
.action-btn > * + *
|
||||
margin-top 4px
|
||||
|
||||
// File bar (Create Folder / folder select / file select / sort).
|
||||
.file-bar > * + *
|
||||
margin-left 10px
|
||||
.file-btn > * + *
|
||||
margin-left 0.4rem
|
||||
|
||||
@@ -2,10 +2,7 @@
|
||||
import configTemplate from "../../../resources/config-template.json";
|
||||
import ScreenRotationDialog from "$dialogs/ScreenRotationDialog.svelte";
|
||||
import ConfigTemplatedInput from "./ConfigTemplatedInput.svelte";
|
||||
// AAxisSettings is mounted directly by the V09 settings shell at
|
||||
// #a-axis instead of being embedded here — see
|
||||
// src/pug/templates/a-axis-view.pug.
|
||||
// import AAxisSettings from "./AAxisSettings.svelte";
|
||||
import WAxisSettings from "./WAxisSettings.svelte";
|
||||
import SetTimeDialog from "$dialogs/SetTimeDialog.svelte";
|
||||
import Button, { Label } from "@smui/button";
|
||||
|
||||
@@ -22,8 +19,8 @@
|
||||
<h1>Settings</h1>
|
||||
|
||||
<div class="pure-form pure-form-aligned">
|
||||
<h2 id="sec-display" data-sec="display">User Interface</h2>
|
||||
<fieldset data-sec="display">
|
||||
<h2>User Interface</h2>
|
||||
<fieldset>
|
||||
<div class="pure-control-group">
|
||||
<label for="screen-rotation" />
|
||||
<Button
|
||||
@@ -49,8 +46,8 @@
|
||||
</div> -->
|
||||
</fieldset>
|
||||
|
||||
<h2 id="sec-units" data-sec="display">Units</h2>
|
||||
<fieldset data-sec="display">
|
||||
<h2>Units</h2>
|
||||
<fieldset>
|
||||
<ConfigTemplatedInput key={`settings.units`} />
|
||||
<div class="tip">
|
||||
Note, units sets both the machine default units and the units used in motor configuration. GCode program-start,
|
||||
@@ -58,13 +55,13 @@
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<h2 id="sec-easy-adapter" data-sec="display">Easy Adapter</h2>
|
||||
<fieldset data-sec="display">
|
||||
<h2>Easy Adapter</h2>
|
||||
<fieldset>
|
||||
<ConfigTemplatedInput key={`settings.easy-adapter`} />
|
||||
</fieldset>
|
||||
|
||||
<h2 id="sec-probing" data-sec="probing">Probing</h2>
|
||||
<fieldset data-sec="probing">
|
||||
<h2>Probing</h2>
|
||||
<fieldset>
|
||||
<ConfigTemplatedInput key={`settings.probing-prompts`} />
|
||||
<div class="tip">
|
||||
Onefinity highly recommends that you keep the safety prompts
|
||||
@@ -91,19 +88,20 @@
|
||||
{/each}
|
||||
</fieldset>
|
||||
|
||||
<fieldset data-sec="gcode">
|
||||
<h2 id="sec-gcode" data-sec="gcode">GCode</h2>
|
||||
<fieldset>
|
||||
<h2>GCode</h2>
|
||||
{#each Object.keys(configTemplate.gcode) as key}
|
||||
<ConfigTemplatedInput key={`gcode.${key}`} />
|
||||
{/each}
|
||||
</fieldset>
|
||||
|
||||
<!-- W Axis (auxcnc) is now its own routed page in the V09
|
||||
settings shell (#a-axis). Keep the SettingsView free of
|
||||
that section so we don't render it twice. -->
|
||||
<h2>W Axis (auxcnc)</h2>
|
||||
<fieldset>
|
||||
<WAxisSettings />
|
||||
</fieldset>
|
||||
|
||||
<h2 id="sec-path-accuracy" data-sec="gcode">Path Accuracy</h2>
|
||||
<fieldset data-sec="gcode">
|
||||
<h2>Path Accuracy</h2>
|
||||
<fieldset>
|
||||
<ConfigTemplatedInput key={`settings.max-deviation`} />
|
||||
|
||||
<div class="tip">
|
||||
@@ -126,8 +124,8 @@
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<h2 id="sec-cornering" data-sec="gcode">Cornering Speed (Advanced)</h2>
|
||||
<fieldset data-sec="gcode">
|
||||
<h2>Cornering Speed (Advanced)</h2>
|
||||
<fieldset>
|
||||
<ConfigTemplatedInput key={`settings.junction-accel`} />
|
||||
<div class="tip">
|
||||
Junction acceleration limits the cornering speed the planner
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
>
|
||||
<div slot="trailingIcon">
|
||||
{#if valid}
|
||||
<Icon class="fa fa-circle-check" style="color: green;" />
|
||||
<Icon class="fa fa-check-circle-o" style="color: green;" />
|
||||
{/if}
|
||||
</div>
|
||||
<HelperText persistent slot="helper">{helperText}</HelperText>
|
||||
|
||||
@@ -4,24 +4,18 @@
|
||||
import * as api from "$lib/api";
|
||||
|
||||
// Mirrors the DEFAULTS in src/py/bbctrl/AuxAxis.py. The "enabled"
|
||||
// flag is read-only here; toggling the auxiliary A axis on/off
|
||||
// is done via aux.json on disk, so adding/removing the hardware
|
||||
// doesn't have a surprise UI that bricks bring-up. Legacy aux.json
|
||||
// files using min_w/max_w are migrated up to min_mm/max_mm by
|
||||
// AuxAxis._migrate_legacy_fields on load.
|
||||
// flag is read-only here; toggling the W axis on/off is done via
|
||||
// aux.json on disk, so adding/removing the hardware doesn't have a
|
||||
// surprise UI that bricks bring-up.
|
||||
type AuxConfig = {
|
||||
enabled: boolean;
|
||||
port: string;
|
||||
baud: number;
|
||||
steps_per_mm: number;
|
||||
dir_sign: number;
|
||||
axis_letter: string;
|
||||
min_mm: number;
|
||||
max_mm: number;
|
||||
min_w: number;
|
||||
max_w: number;
|
||||
max_feed_mm_min: number;
|
||||
max_velocity_m_per_min: number;
|
||||
max_accel_km_per_min2: number;
|
||||
max_jerk_km_per_min3: number;
|
||||
home_dir: string;
|
||||
home_position_mm: number;
|
||||
home_fast_sps: number;
|
||||
@@ -32,27 +26,15 @@
|
||||
step_accel_sps2: number;
|
||||
step_start_sps: number;
|
||||
limit_low: boolean;
|
||||
couple_z_enabled: boolean;
|
||||
couple_z_clearance_mm: number;
|
||||
z_home_mm: number;
|
||||
};
|
||||
|
||||
let cfg: AuxConfig | null = null;
|
||||
let status: { enabled: boolean; present: boolean; homed: boolean; pos_mm: number } | null = null;
|
||||
let busy = false;
|
||||
|
||||
// Listen for the global "save-all" event the Vue root dispatches
|
||||
// when the user clicks the master Save button. We persist our
|
||||
// current cfg the same way the in-form button used to. This way
|
||||
// the user only ever needs one Save button.
|
||||
function onGlobalSave() {
|
||||
save().catch(e => console.error("aux save failed:", e));
|
||||
}
|
||||
let saveMessage = "";
|
||||
|
||||
onMount(async () => {
|
||||
await refresh();
|
||||
window.addEventListener("onefin:save-all", onGlobalSave);
|
||||
return () => window.removeEventListener("onefin:save-all", onGlobalSave);
|
||||
});
|
||||
|
||||
async function refresh() {
|
||||
@@ -67,33 +49,24 @@
|
||||
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);
|
||||
throw e;
|
||||
saveMessage = "Save failed - see console.";
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Mark the root config as modified whenever an auxiliary axis
|
||||
// field is edited, so the master Save button highlights and
|
||||
// the user knows there are unsaved changes.
|
||||
function markDirty() {
|
||||
try {
|
||||
const root = (window as any).$root || (window as any).Vue?.root;
|
||||
if (root && "modified" in root) root.modified = true;
|
||||
} catch (_e) {}
|
||||
// Also dispatch a generic event the Vue root listens for.
|
||||
window.dispatchEvent(new CustomEvent("onefin:dirty"));
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="a-axis-settings">
|
||||
<div class="w-axis-settings">
|
||||
{#if !cfg}
|
||||
<p class="tip">Loading A axis configuration...</p>
|
||||
<p class="tip">Loading W axis configuration...</p>
|
||||
{:else}
|
||||
<div class="status">
|
||||
{#if status}
|
||||
@@ -112,9 +85,9 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="pure-form pure-form-aligned" on:input={markDirty} on:change={markDirty}>
|
||||
<div class="pure-form pure-form-aligned">
|
||||
<fieldset>
|
||||
<div class="pure-control-group" title="Enable the auxiliary axis (auxcnc-driven A). Edit aux.json to toggle.">
|
||||
<div class="pure-control-group" title="Enable the auxcnc W axis. Edit aux.json to toggle.">
|
||||
<label for="enabled">enabled</label>
|
||||
<input id="enabled" type="checkbox" checked={cfg.enabled} disabled />
|
||||
<label for="" class="units">(edit aux.json)</label>
|
||||
@@ -133,13 +106,13 @@
|
||||
|
||||
<h3>Mechanics</h3>
|
||||
<fieldset>
|
||||
<div class="pure-control-group" title="Logical steps per mm of axis travel.">
|
||||
<div class="pure-control-group" title="Logical steps per mm of W travel.">
|
||||
<label for="steps_per_mm">steps per mm</label>
|
||||
<input id="steps_per_mm" type="number" bind:value={cfg.steps_per_mm} step="any" />
|
||||
<label for="" class="units">steps/mm</label>
|
||||
</div>
|
||||
|
||||
<div class="pure-control-group" title="Direction sign: +1 or -1. Flip if A+ moves the wrong way.">
|
||||
<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>
|
||||
@@ -147,78 +120,19 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="pure-control-group" title="gcode axis letter exposed to the planner. Default 'a' (the standard 4th axis).">
|
||||
<label for="axis_letter">axis letter</label>
|
||||
<select id="axis_letter" bind:value={cfg.axis_letter}>
|
||||
<option value="a">A</option>
|
||||
<option value="b">B</option>
|
||||
<option value="c">C</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="pure-control-group" title="Soft-limit minimum in mm.">
|
||||
<label for="min_mm">soft min</label>
|
||||
<input id="min_mm" type="number" bind:value={cfg.min_mm} step="any" />
|
||||
<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 in mm.">
|
||||
<label for="max_mm">soft max</label>
|
||||
<input id="max_mm" type="number" bind:value={cfg.max_mm} step="any" />
|
||||
<label for="" class="units">mm</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<h3>Z-A Coupling</h3>
|
||||
<p class="tip">
|
||||
The auxiliary tool hangs below the Z spindle. Beyond a small
|
||||
Z descent the two collide unless A drops with Z. The rule
|
||||
in machine coordinates is
|
||||
<code>A − Z ≤ (A_home − Z_home) + clearance</code>.
|
||||
When enabled, the planner refuses moves that would violate
|
||||
it and the gcode preprocessor injects pre-position A moves
|
||||
into uploaded files.
|
||||
</p>
|
||||
<fieldset>
|
||||
<div class="pure-control-group" title="Master switch for the Z-A interlock. When off, no checks are performed.">
|
||||
<label for="couple_z_enabled">enable coupling</label>
|
||||
<input id="couple_z_enabled" type="checkbox" bind:checked={cfg.couple_z_enabled} />
|
||||
</div>
|
||||
|
||||
<div class="pure-control-group" title="How far Z may descend below its home position before A must move with it.">
|
||||
<label for="couple_z_clearance_mm">Z clearance</label>
|
||||
<input id="couple_z_clearance_mm" type="number" bind:value={cfg.couple_z_clearance_mm} step="any" />
|
||||
<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="Z's machine position when homed. Almost always 0.">
|
||||
<label for="z_home_mm">Z home position</label>
|
||||
<input id="z_home_mm" type="number" bind:value={cfg.z_home_mm} step="any" />
|
||||
<label for="" class="units">mm</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<h3>Planner Limits</h3>
|
||||
<fieldset>
|
||||
<div class="pure-control-group" title="Maximum velocity used by gplan trajectory planning.">
|
||||
<label for="max_velocity_m_per_min">max velocity</label>
|
||||
<input id="max_velocity_m_per_min" type="number" bind:value={cfg.max_velocity_m_per_min} step="any" />
|
||||
<label for="" class="units">m/min</label>
|
||||
</div>
|
||||
|
||||
<div class="pure-control-group" title="Maximum acceleration used by gplan trajectory planning.">
|
||||
<label for="max_accel_km_per_min2">max acceleration</label>
|
||||
<input id="max_accel_km_per_min2" type="number" bind:value={cfg.max_accel_km_per_min2} step="any" />
|
||||
<label for="" class="units">km/min²</label>
|
||||
</div>
|
||||
|
||||
<div class="pure-control-group" title="Maximum jerk used by gplan trajectory planning.">
|
||||
<label for="max_jerk_km_per_min3">max jerk</label>
|
||||
<input id="max_jerk_km_per_min3" type="number" bind:value={cfg.max_jerk_km_per_min3} step="any" />
|
||||
<label for="" class="units">km/min³</label>
|
||||
</div>
|
||||
|
||||
<div class="pure-control-group" title="Informational max feed; rate caps live on the ESP via step_max_sps.">
|
||||
<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>
|
||||
@@ -230,12 +144,12 @@
|
||||
<div class="pure-control-group" title="Direction the axis moves when looking for the home limit switch.">
|
||||
<label for="home_dir">home direction</label>
|
||||
<select id="home_dir" bind:value={cfg.home_dir}>
|
||||
<option value="-">- (toward A-)</option>
|
||||
<option value="+">+ (toward A+)</option>
|
||||
<option value="-">- (toward W-)</option>
|
||||
<option value="+">+ (toward W+)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="pure-control-group" title="Axis position assigned when homing completes.">
|
||||
<div class="pure-control-group" title="W position assigned when homing completes.">
|
||||
<label for="home_position_mm">home position</label>
|
||||
<input id="home_position_mm" type="number" bind:value={cfg.home_position_mm} step="any" />
|
||||
<label for="" class="units">mm</label>
|
||||
@@ -292,20 +206,32 @@
|
||||
</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 when you click the
|
||||
master <strong>Save</strong> button at the bottom of the
|
||||
settings rail. Homing rates and the limit polarity are
|
||||
pushed to the ESP immediately; any running motion is
|
||||
unaffected. Re-home the auxiliary axis after changing direction,
|
||||
sign, or step settings.
|
||||
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">
|
||||
.a-axis-settings {
|
||||
.w-axis-settings {
|
||||
.status {
|
||||
margin-bottom: 1em;
|
||||
font-size: 90%;
|
||||
@@ -6,7 +6,6 @@ matchAll.shim();
|
||||
import AdminNetworkView from "$components/AdminNetworkView.svelte";
|
||||
import SettingsView from "$components/SettingsView.svelte";
|
||||
import HelpView from "$components/HelpView.svelte";
|
||||
import AAxisSettings from "$components/AAxisSettings.svelte";
|
||||
import DialogHost, { showDialog } from "$dialogs/DialogHost.svelte";
|
||||
import { handleConfigUpdate, setDisplayUnits } from "$lib/ConfigStore";
|
||||
import { handleControllerStateUpdate } from "$lib/ControllerState";
|
||||
@@ -23,9 +22,6 @@ export function createComponent(component: string, target: HTMLElement, props: R
|
||||
case "HelpView":
|
||||
return new HelpView({ target, props });
|
||||
|
||||
case "AAxisSettings":
|
||||
return new AAxisSettings({ target, props });
|
||||
|
||||
case "DialogHost":
|
||||
return new DialogHost({ target, props });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user