Merge branch 'hooks' into master

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

# Conflicts:
#	.gitignore
This commit is contained in:
2026-04-30 21:33:48 +02:00
35 changed files with 6422 additions and 1855 deletions

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

14
.gitignore vendored
View File

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

249
.pi/BUILD.md Normal file
View File

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

48
.pi/Dockerfile.gplan Normal file
View File

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

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

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

123
README.md
View File

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

172
docs/AUX_W_AXIS.md Normal file
View File

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

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

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

View File

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

View File

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

View File

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

View File

@@ -32,6 +32,10 @@ module.exports = {
return this._compute_axis("c"); return this._compute_axis("c");
}, },
w: function() {
return this._compute_aux_axis();
},
axes: function() { axes: function() {
return this._compute_axes(); return this._compute_axes();
} }
@@ -203,6 +207,52 @@ module.exports = {
return false; return false;
}, },
_compute_aux_axis: function() {
// Virtual W axis driven by the auxcnc ESP32. Position, homed
// flag and presence come from the bbctrl AuxAxis driver via
// state.aux_*. No motor mapping, no soft-limit warnings on
// toolpath bounds (auxcnc enforces its own).
const enabled = !!this.state.aux_enabled;
const present = !!this.state.aux_present;
const homed = !!this.state.aux_homed;
const pos = this.state.aux_pos || 0;
let klass = `${homed ? "homed" : "unhomed"} axis-w`;
let state = present ? "UNHOMED" : "OFFLINE";
let icon = present ? "question-circle" : "plug";
let title = present
? "Click the home button to home W axis."
: "Aux controller not connected on /dev/ttyUSB0.";
if (homed) {
state = "HOMED";
icon = "check-circle";
title = "W axis successfully homed.";
} else if (!present) {
klass += " error";
}
return {
pos: pos,
abs: pos,
off: 0,
min: 0, max: 0, dim: 0,
pathMin: 0, pathMax: 0, pathDim: 0,
motor: -1,
enabled: enabled,
homingMode: "limit-switch",
homed: homed,
klass: klass,
state: state,
icon: icon,
title: title,
ticon: "check-circle",
tstate: "OK",
toolmsg: "W axis is not constrained by tool path bounds.",
tklass: `${homed ? "homed" : "unhomed"} axis-w`,
isAux: true,
};
},
_compute_axes: function() { _compute_axes: function() {
let homed = false; let homed = false;

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

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

View File

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

View File

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

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

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

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

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

View File

@@ -0,0 +1,109 @@
"use strict";
// Wrapper that adds a left-rail navigator around the settings family
// of views (Settings, Admin General, Admin Network, Tool, IO, Motor,
// Macros, Help, Cheat Sheet). The inner view is selected by the URL
// hash (parsed in app.js) and exposed as $root.sub_tab.
// Vue 1 has trouble making child components reactive to `$root.sub_tab`
// changes (whether via computed, watch, or prop binding through
// `<component :is>`). The shell instead listens to `hashchange`
// directly and parses the hash itself, mirroring app.js's logic, then
// keeps a local data prop `sub` that the template binds to. This is
// the only path that updates the rail's `:class` reactively.
module.exports = {
template: "#settings-shell-view-template",
props: ["config", "template", "state", "index"],
components: {
"settings-view-inner": require("./settings-view"),
"admin-general-view": require("./admin-general-view"),
"admin-network-view": require("./admin-network-view"),
"motor-view": require("./motor-view"),
"tool-view": require("./tool-view"),
"io-view": require("./io-view"),
"macros-view": require("./macros"),
"help-view": require("./help-view"),
"cheat-sheet-view": {
template: "#cheat-sheet-view-template",
data: function () {
return { showUnimplemented: false };
},
},
},
data: function () {
return {
sub: this.$root.sub_tab || "settings",
ridx: this.$root.index, // local copy of the motor index
rail_items: [
{ sub: "settings", href: "#settings", icon: "fa-display", label: "Display & Units" },
{ sub: "admin-network", href: "#admin-network", icon: "fa-network-wired", label: "Network" },
{ sub: "admin-general", href: "#admin-general", icon: "fa-shield-halved", label: "General / Firmware" },
{ sub: "tool", href: "#tool", icon: "fa-bolt", label: "Spindle & Tool" },
{ sub: "io", href: "#io", icon: "fa-plug", label: "I/O" },
{ section: "Motors" },
{ sub: "motor", motor: 0, href: "#motor:0", icon: "fa-arrows-up-down-left-right", label: "Motor 0" },
{ sub: "motor", motor: 1, href: "#motor:1", icon: "fa-arrows-up-down-left-right", label: "Motor 1" },
{ sub: "motor", motor: 2, href: "#motor:2", icon: "fa-arrows-up-down-left-right", label: "Motor 2" },
{ sub: "motor", motor: 3, href: "#motor:3", icon: "fa-arrows-up-down-left-right", label: "Motor 3" },
{ section: " " },
{ sub: "macros", href: "#macros", icon: "fa-keyboard", label: "Macros" },
{ sub: "cheat-sheet", href: "#cheat-sheet", icon: "fa-book", label: "G-code Cheat Sheet" },
{ sub: "help", href: "#help", icon: "fa-circle-question", label: "Help" },
],
};
},
ready: function () {
this._onHash = () => this.refresh_from_hash();
window.addEventListener("hashchange", this._onHash);
this.refresh_from_hash();
},
attached: function () {
// Vue 1 fires `attached` whenever the component is inserted into
// the DOM (which happens on every route change because the outer
// <component :is> recreates the instance). Re-bind the listener
// here so it works even after detach/attach cycles.
if (!this._onHash) {
this._onHash = () => this.refresh_from_hash();
}
window.addEventListener("hashchange", this._onHash);
this.refresh_from_hash();
},
detached: function () {
if (this._onHash) {
window.removeEventListener("hashchange", this._onHash);
}
},
beforeDestroy: function () {
if (this._onHash) {
window.removeEventListener("hashchange", this._onHash);
}
},
methods: {
refresh_from_hash: function () {
const hash = location.hash.substr(1) || "settings";
const parts = hash.split(":");
this.sub = parts[0] || "settings";
this.ridx = parts[1] !== undefined ? parts[1] : -1;
},
is_active: function (item) {
if (!item || item.section) return false;
if (item.sub !== this.sub) return false;
if (item.sub === "motor") {
return "" + item.motor === "" + this.ridx;
}
return true;
},
showShutdownDialog: function () {
SvelteComponents.showDialog("Shutdown");
},
},
};

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

@@ -59,6 +59,9 @@ class Ctrl(object):
self.preplanner = bbctrl.Preplanner(self) self.preplanner = bbctrl.Preplanner(self)
if not args.demo: self.jog = bbctrl.Jog(self) if not args.demo: self.jog = bbctrl.Jog(self)
self.pwr = bbctrl.Pwr(self) self.pwr = bbctrl.Pwr(self)
self.hooks = bbctrl.Hooks(self)
self.aux = bbctrl.AuxAxis(self)
self._register_aux_hooks()
self.mach.connect() self.mach.connect()
@@ -109,8 +112,46 @@ class Ctrl(object):
self.preplanner.start() self.preplanner.start()
def _register_aux_hooks(self):
"""Wire up the auxcnc HOOK: events to AuxAxis methods."""
log = self.log.get('AuxAxis')
def _hook_move(ctx):
data = (ctx.get('data') or '').strip()
if not data:
raise Exception('aux hook missing target')
self.aux.move_abs_mm(float(data))
def _hook_move_rel(ctx):
data = (ctx.get('data') or '').strip()
if not data:
raise Exception('aux_rel hook missing delta')
self.aux.move_rel_mm(float(data))
def _hook_home(ctx):
self.aux.home()
def _hook_setzero(ctx):
data = (ctx.get('data') or '').strip()
mm = float(data) if data else 0.0
self.aux.set_position_mm(mm)
self.hooks.register_internal('aux', _hook_move,
block_unpause=True, auto_resume=True)
self.hooks.register_internal('aux_rel', _hook_move_rel,
block_unpause=True, auto_resume=True)
self.hooks.register_internal('aux_home', _hook_home,
block_unpause=True, auto_resume=True,
timeout=180)
self.hooks.register_internal('aux_setzero', _hook_setzero,
block_unpause=True, auto_resume=True)
log.info('Aux hooks registered')
def close(self): def close(self):
self.log.get('Ctrl').info('Closing %s' % self.id) self.log.get('Ctrl').info('Closing %s' % self.id)
self.ioloop.close() self.ioloop.close()
self.avr.close() self.avr.close()
self.mach.planner.close() self.mach.planner.close()
try: self.aux.close()
except Exception: pass

View File

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

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

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

View File

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

View File

@@ -766,6 +766,93 @@ class RotaryHandler(bbctrl.APIHandler):
log.error('Unexpected error: {}'.format(e)) log.error('Unexpected error: {}'.format(e))
class HooksGetHandler(bbctrl.APIHandler):
def get(self):
self.write_json(self.get_ctrl().hooks.get_config())
class HooksSaveHandler(bbctrl.APIHandler):
def put_ok(self):
self.get_ctrl().hooks.save_config(self.json)
class HooksStatusHandler(bbctrl.APIHandler):
def get(self):
self.write_json(self.get_ctrl().hooks.get_status())
class HooksFireHandler(bbctrl.APIHandler):
def put_ok(self, event):
data = self.json if hasattr(self, 'json') and self.json else {}
self.get_ctrl().hooks._fire(event, data)
# ----- W axis (auxcnc) endpoints --------------------------------------------
class AuxConfigGetHandler(bbctrl.APIHandler):
def get(self):
self.write_json(self.get_ctrl().aux.get_config())
class AuxConfigSaveHandler(bbctrl.APIHandler):
def put_ok(self):
self.get_ctrl().aux.save_config(self.json or {})
class AuxStatusHandler(bbctrl.APIHandler):
def get(self):
aux = self.get_ctrl().aux
self.write_json({
'enabled': aux.enabled,
'present': aux.present,
'homed': aux.homed,
'pos_mm': aux.position_mm,
})
class AuxHomeHandler(bbctrl.APIHandler):
def put_ok(self):
# Run synchronously via the AuxAxis' own RPC; this blocks the
# request. Fine because the UI shows a spinner.
self.get_ctrl().aux.home()
class AuxAbortHandler(bbctrl.APIHandler):
def put_ok(self):
self.get_ctrl().aux.abort()
class AuxJogHandler(bbctrl.APIHandler):
"""Body: {"mm": 1.5} for relative-mm move,
{"steps": 200} for raw step move (bypasses soft limits)."""
def put_ok(self):
body = self.json or {}
aux = self.get_ctrl().aux
if 'mm' in body:
aux.move_rel_mm(float(body['mm']))
elif 'steps' in body:
aux.jog_steps(int(body['steps']))
else:
raise HTTPError(400, 'mm or steps required')
class AuxMoveHandler(bbctrl.APIHandler):
"""Body: {"mm": 12.5} absolute move in mm."""
def put_ok(self):
body = self.json or {}
if 'mm' not in body:
raise HTTPError(400, 'mm required')
self.get_ctrl().aux.move_abs_mm(float(body['mm']))
class AuxSetZeroHandler(bbctrl.APIHandler):
"""Body: {"mm": 0} set current position to <mm>."""
def put_ok(self):
body = self.json or {}
mm = float(body.get('mm', 0.0))
self.get_ctrl().aux.set_position_mm(mm)
class RemoteDiagnosticsHandler(bbctrl.APIHandler): class RemoteDiagnosticsHandler(bbctrl.APIHandler):
def get(self): def get(self):
@@ -941,6 +1028,18 @@ class Web(tornado.web.Application):
(r'/api/time', TimeHandler), (r'/api/time', TimeHandler),
(r'/api/rotary', RotaryHandler), (r'/api/rotary', RotaryHandler),
(r'/api/remote-diagnostics', RemoteDiagnosticsHandler), (r'/api/remote-diagnostics', RemoteDiagnosticsHandler),
(r'/api/hooks', HooksGetHandler),
(r'/api/hooks/save', HooksSaveHandler),
(r'/api/hooks/status', HooksStatusHandler),
(r'/api/hooks/fire/([\w-]+)', HooksFireHandler),
(r'/api/aux/config', AuxConfigGetHandler),
(r'/api/aux/config/save', AuxConfigSaveHandler),
(r'/api/aux/status', AuxStatusHandler),
(r'/api/aux/home', AuxHomeHandler),
(r'/api/aux/abort', AuxAbortHandler),
(r'/api/aux/jog', AuxJogHandler),
(r'/api/aux/move', AuxMoveHandler),
(r'/api/aux/set-zero', AuxSetZeroHandler),
(r'/(.*)', StaticFileHandler, (r'/(.*)', StaticFileHandler,
{'path': bbctrl.get_resource('http/'), {'path': bbctrl.get_resource('http/'),
'default_filename': 'index.html'}), 'default_filename': 'index.html'}),

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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