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:
0
.devcontainer/install_tools.sh
Normal file → Executable file
0
.devcontainer/install_tools.sh
Normal file → Executable file
14
.gitignore
vendored
14
.gitignore
vendored
@@ -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
249
.pi/BUILD.md
Normal 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
48
.pi/Dockerfile.gplan
Normal 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
30
.pi/build-gplan.sh
Executable 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
123
README.md
@@ -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
172
docs/AUX_W_AXIS.md
Normal 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
900
docs/mocks/v09_full_ux.html
Normal file
@@ -0,0 +1,900 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<title>Onefinity · V09 · Full UX</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@6.5.2/css/all.min.css" />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@500;700&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
html,body{margin:0;font-family:'Inter',system-ui,sans-serif;background:#0f172a;color:#e5e7eb}
|
||||||
|
.mono{font-family:'JetBrains Mono',monospace}
|
||||||
|
|
||||||
|
/* ---------- HOST CHROME ---------- */
|
||||||
|
.host{min-height:100vh;display:flex;flex-direction:column;background:radial-gradient(circle at 30% 0%,#374151,#0f172a 60%);}
|
||||||
|
.topbar{display:flex;align-items:center;gap:.6rem;flex-wrap:wrap;padding:.7rem 1rem;background:rgba(255,255,255,.04);border-bottom:1px solid rgba(255,255,255,.08);position:sticky;top:0;z-index:50;backdrop-filter:blur(10px);}
|
||||||
|
.topbar .brand{display:flex;align-items:center;gap:.5rem;font-weight:800;color:#fff}
|
||||||
|
.stripe-logo-sm{background:repeating-linear-gradient(135deg,#a7c7a3 0 6px,transparent 6px 14px);width:26px;height:26px;border-radius:6px}
|
||||||
|
.pill{padding:.3rem .65rem;border-radius:9999px;font-size:.75rem;font-weight:700;background:rgba(255,255,255,.08);color:#cbd5e1}
|
||||||
|
.seg-host{display:inline-flex;background:rgba(255,255,255,.05);border-radius:9999px;padding:3px;gap:3px}
|
||||||
|
.seg-host button{padding:.4rem .85rem;border-radius:9999px;font-size:.78rem;font-weight:700;color:#cbd5e1}
|
||||||
|
.seg-host button.on{background:#fde047;color:#0f172a}
|
||||||
|
.toggle{display:inline-flex;align-items:center;gap:.4rem;padding:.4rem .7rem;border-radius:8px;background:rgba(255,255,255,.08);font-size:.75rem;font-weight:600;color:#e5e7eb;cursor:pointer}
|
||||||
|
.toggle.on{background:#22c55e;color:#0b1220}
|
||||||
|
|
||||||
|
.stage{flex:1;display:flex;align-items:flex-start;justify-content:center;padding:1rem;overflow:auto}
|
||||||
|
.scaler-viewport{position:relative;flex:0 0 auto}
|
||||||
|
.scaler{position:absolute;top:0;left:0;width:1920px;height:auto;transform-origin:top left;transition:transform .2s}
|
||||||
|
|
||||||
|
/* ---------- KIOSK (1920x1080) ---------- */
|
||||||
|
.kiosk{
|
||||||
|
width:1920px;height:1080px;overflow:hidden;border-radius:14px;position:relative;
|
||||||
|
box-shadow:0 30px 60px rgba(0,0,0,.5);
|
||||||
|
display:flex;flex-direction:column;
|
||||||
|
background:#ffffff;color:#0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.head{
|
||||||
|
flex:0 0 96px;height:96px;
|
||||||
|
display:flex;align-items:center;gap:18px;
|
||||||
|
padding:0 24px;background:#ffffff;border-bottom:1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
.brand-blk{display:flex;align-items:center;gap:14px}
|
||||||
|
.menu-btn{width:54px;height:54px;border-radius:12px;background:#f1f5f9;border:1px solid #e2e8f0;color:#0f172a;display:inline-flex;align-items:center;justify-content:center;font-size:1.1rem}
|
||||||
|
.menu-btn:hover{background:#e2e8f0}
|
||||||
|
.brand-logo{width:42px;height:42px;border-radius:8px;background:repeating-linear-gradient(135deg,#a7c7a3 0 6px,transparent 6px 14px)}
|
||||||
|
.brand-name{font-weight:900;font-size:22px;letter-spacing:-.01em}
|
||||||
|
|
||||||
|
/* Underline-ribbon tab style (V02) */
|
||||||
|
.kiosk-tabs{display:inline-flex;gap:0;margin-right:auto;padding-left:18px;align-items:stretch;height:96px}
|
||||||
|
.ktab{
|
||||||
|
position:relative;
|
||||||
|
height:96px;padding:0 26px;
|
||||||
|
background:transparent;border:none;border-radius:0;
|
||||||
|
color:#475569;font-size:1.05rem;font-weight:700;
|
||||||
|
display:inline-flex;align-items:center;gap:.55rem;cursor:pointer;
|
||||||
|
transition:color .15s;
|
||||||
|
}
|
||||||
|
.ktab i{font-size:1.1rem;color:#94a3b8;transition:color .15s}
|
||||||
|
.ktab:hover{color:#0f172a}
|
||||||
|
.ktab:hover i{color:#475569}
|
||||||
|
.ktab.active{color:#0f172a}
|
||||||
|
.ktab.active i{color:#0f172a}
|
||||||
|
.ktab.active::after{
|
||||||
|
content:"";position:absolute;left:14px;right:14px;bottom:0;
|
||||||
|
height:5px;background:#fde047;border-radius:5px 5px 0 0;
|
||||||
|
}
|
||||||
|
.ktab .ktab-badge{background:#fee2e2;color:#991b1b;font-size:.7rem;padding:3px 8px;border-radius:9999px;font-weight:800;line-height:1}
|
||||||
|
.ktab.active .ktab-badge{background:#fde047;color:#0f172a}
|
||||||
|
|
||||||
|
.sys-btn{display:inline-flex;align-items:center;gap:.55rem;height:54px;padding:0 1.1rem;border-radius:14px;background:#f1f5f9;border:1px solid #e2e8f0;color:#0f172a;font-size:.9rem;font-weight:600}
|
||||||
|
.sys-btn .pip{width:9px;height:9px;border-radius:9999px;background:#22c55e}
|
||||||
|
.state-badge{display:inline-flex;align-items:center;gap:.6rem;height:54px;padding:0 1.1rem;border-radius:14px;background:#dcfce7;color:#166534;font-weight:800;font-size:1rem;letter-spacing:.04em}
|
||||||
|
.state-badge .dot{width:10px;height:10px;border-radius:9999px;background:currentColor;position:relative}
|
||||||
|
.state-badge .dot::after{content:"";position:absolute;inset:-3px;border-radius:9999px;border:2px solid currentColor;opacity:.5;animation:pls 1.6s ease-out infinite}
|
||||||
|
@keyframes pls{0%{transform:scale(.7);opacity:.6}100%{transform:scale(2.2);opacity:0}}
|
||||||
|
|
||||||
|
.estop{
|
||||||
|
width:88px;height:88px;background:#dc2626;color:#fff;font-weight:900;
|
||||||
|
clip-path:polygon(30% 0,70% 0,100% 30%,100% 70%,70% 100%,30% 100%,0 70%,0 30%);
|
||||||
|
display:flex;align-items:center;justify-content:center;
|
||||||
|
border:3px solid #fff;box-shadow:0 0 0 3px #b91c1c, 0 8px 20px rgba(220,38,38,.35);font-size:1rem;letter-spacing:.05em
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Body */
|
||||||
|
.body{flex:1;display:flex;flex-direction:column;background:#f1f5f9;min-height:0}
|
||||||
|
.panel{display:none;flex:1;min-height:0;flex-direction:column;padding:18px;gap:14px}
|
||||||
|
.panel.active{display:flex}
|
||||||
|
|
||||||
|
/* ----------------------- V09 jog/macro palette ----------------------- */
|
||||||
|
/* Flat soft slate, no shadow */
|
||||||
|
:root{
|
||||||
|
--jog-bg:#3f4b63;
|
||||||
|
--jog-hover:#4a5777;
|
||||||
|
--jog-dir-bg:#5b6885;
|
||||||
|
--jog-dir-hover:#6a779a;
|
||||||
|
--jog-ghost-bg:#8c97ad;
|
||||||
|
--jog-ghost-hover:#9ba6bb;
|
||||||
|
--jog-ink:#fff;
|
||||||
|
--jog-ghost-ink:#0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* JOG */
|
||||||
|
.jog-card{background:#fff;border:1px solid #e5e7eb;border-radius:18px;display:flex;flex-direction:column;padding:18px;min-height:0}
|
||||||
|
.jog-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:14px}
|
||||||
|
.jog-title{font-size:18px;font-weight:700;color:#0f172a}
|
||||||
|
.jog-title .step{color:#0ea5e9;font-family:'JetBrains Mono',monospace}
|
||||||
|
.step-seg{display:inline-flex;background:#f1f5f9;border:1px solid #e2e8f0;border-radius:14px;padding:4px}
|
||||||
|
.step-seg button{height:48px;min-width:64px;padding:0 1rem;border-radius:11px;font-size:1rem;font-weight:800;color:#475569;cursor:pointer}
|
||||||
|
.step-seg button.active{background:#0f172a;color:#fde047}
|
||||||
|
.jog-grid{display:grid;grid-template-columns:repeat(4,1fr);grid-template-rows:repeat(4,1fr);gap:10px;flex:1;min-height:0}
|
||||||
|
.jbtn{
|
||||||
|
border-radius:16px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:4px;
|
||||||
|
user-select:none;-webkit-tap-highlight-color:transparent;cursor:pointer;
|
||||||
|
font-weight:700;font-size:1.05rem;border:none;
|
||||||
|
transition:transform .06s, background .15s;
|
||||||
|
background:var(--jog-bg);color:var(--jog-ink);
|
||||||
|
}
|
||||||
|
.jbtn:hover{background:var(--jog-hover)}
|
||||||
|
.jbtn:active{transform:scale(.97)}
|
||||||
|
.jbtn .ico{font-size:1.6rem}
|
||||||
|
.jbtn .lbl{font-size:.8rem;color:inherit;opacity:.85;font-weight:600}
|
||||||
|
.jbtn.dir{background:var(--jog-dir-bg)} .jbtn.dir:hover{background:var(--jog-dir-hover)}
|
||||||
|
.jbtn.ghost{background:var(--jog-ghost-bg);color:var(--jog-ghost-ink)} .jbtn.ghost:hover{background:var(--jog-ghost-hover)}
|
||||||
|
|
||||||
|
/* DRO + STATUS */
|
||||||
|
.control-grid{display:grid;grid-template-columns:720px 1fr;gap:18px;flex:1;min-height:0}
|
||||||
|
.right-col{display:grid;grid-template-rows:1fr 158px;gap:18px;min-height:0}
|
||||||
|
.dro-card{background:#fff;border:1px solid #e5e7eb;border-radius:18px;overflow:hidden;display:flex;flex-direction:column}
|
||||||
|
.dro-head{display:grid;grid-template-columns:84px 1.4fr 1fr 1fr 170px 170px 280px;column-gap:.75rem;align-items:center;padding:14px 22px;background:#f8fafc;border-bottom:1px solid #e5e7eb;font-size:.78rem;font-weight:800;text-transform:uppercase;letter-spacing:.1em;color:#94a3b8}
|
||||||
|
.dro-row{display:grid;grid-template-columns:84px 1.4fr 1fr 1fr 170px 170px 280px;column-gap:.75rem;align-items:center;padding:14px 22px;border-bottom:1px solid #f1f5f9;flex:1;min-height:0}
|
||||||
|
.dro-row:last-child{border-bottom:none}
|
||||||
|
.dro-axis{font-weight:900;font-size:46px;line-height:1}
|
||||||
|
.dro-pos{font-family:'JetBrains Mono',monospace;font-size:36px;font-weight:800}
|
||||||
|
.dro-pos .u{font-size:14px;color:#94a3b8;font-weight:500;margin-left:6px}
|
||||||
|
.dro-sec{font-family:'JetBrains Mono',monospace;font-size:18px;color:#64748b;font-weight:600}
|
||||||
|
.axis-x{color:#dc2626} .axis-y{color:#16a34a} .axis-z{color:#2563eb} .axis-w{color:#7c3aed}
|
||||||
|
|
||||||
|
.chip{display:inline-flex;align-items:center;gap:.4rem;padding:.4rem .7rem;border-radius:9999px;font-size:.78rem;font-weight:700}
|
||||||
|
.chip-green{background:#dcfce7;color:#166534}
|
||||||
|
.chip-amber{background:#fef3c7;color:#92400e}
|
||||||
|
.chip-red{background:#fee2e2;color:#991b1b}
|
||||||
|
.chip-slate{background:#e2e8f0;color:#334155}
|
||||||
|
.chip-blue{background:#dbeafe;color:#1e40af}
|
||||||
|
|
||||||
|
.icon-btn{
|
||||||
|
width:72px;height:72px;border-radius:14px;cursor:pointer;
|
||||||
|
display:inline-flex;align-items:center;justify-content:center;
|
||||||
|
color:#334155;background:#f1f5f9;border:1px solid #e2e8f0;
|
||||||
|
font-size:1.45rem
|
||||||
|
}
|
||||||
|
.icon-btn:hover{background:#e2e8f0}
|
||||||
|
.actions-cell{display:flex;justify-content:flex-end;gap:10px}
|
||||||
|
.z-highlight{background:rgba(254,243,199,.4)}
|
||||||
|
|
||||||
|
.status-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:18px;min-height:0}
|
||||||
|
.stat-card{background:#fff;border:1px solid #e5e7eb;border-radius:18px;padding:18px 22px;display:flex;flex-direction:column;justify-content:center}
|
||||||
|
.stat-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.14em;color:#94a3b8}
|
||||||
|
.stat-val{font-family:'JetBrains Mono',monospace;font-size:30px;font-weight:800;margin-top:6px}
|
||||||
|
.stat-val.ok{color:#166534}
|
||||||
|
.stat-sub{font-size:13px;color:#64748b;margin-top:2px}
|
||||||
|
|
||||||
|
/* MACROS */
|
||||||
|
.macro-row{display:grid;grid-template-columns:repeat(8,1fr);gap:12px;flex:0 0 auto}
|
||||||
|
.macro-btn{
|
||||||
|
height:84px;border-radius:14px;border:none;cursor:pointer;
|
||||||
|
color:#fff;background:#3f4b63;
|
||||||
|
font-weight:800;font-size:1rem;
|
||||||
|
display:flex;align-items:center;justify-content:center;gap:.6rem;
|
||||||
|
transition:transform .06s, background .15s
|
||||||
|
}
|
||||||
|
.macro-btn:hover{background:#4a5777}
|
||||||
|
.macro-btn:active{transform:translateY(2px)}
|
||||||
|
.macro-btn .mnum{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:8px;background:#fde047;color:#0f172a;font-size:.85rem;font-weight:900}
|
||||||
|
.macro-btn .micon{font-size:1.1rem;opacity:.75}
|
||||||
|
|
||||||
|
/* =============================================================
|
||||||
|
PROGRAM PANEL
|
||||||
|
============================================================= */
|
||||||
|
.program-card{background:#fff;border:1px solid #e5e7eb;border-radius:18px;display:flex;flex-direction:column;flex:1;min-height:0;overflow:hidden}
|
||||||
|
.ptab-bar{display:flex;align-items:center;gap:6px;border-bottom:1px solid #e5e7eb;flex:0 0 auto;background:#fff;padding:0 18px}
|
||||||
|
.ptab{height:60px;padding:0 22px;font-weight:700;color:#64748b;border-bottom:3px solid transparent;font-size:1rem;display:inline-flex;align-items:center;gap:.5rem;cursor:pointer}
|
||||||
|
.ptab:hover{color:#0f172a}
|
||||||
|
.ptab.active{color:#0f172a;border-bottom-color:#0f172a}
|
||||||
|
.ptab .ptab-badge{background:#fde047;color:#0f172a;font-size:.7rem;padding:2px 7px;border-radius:9999px;font-weight:900}
|
||||||
|
|
||||||
|
.action-bar{display:flex;align-items:center;gap:12px;padding:18px;flex-wrap:wrap;border-bottom:1px solid #f1f5f9}
|
||||||
|
.action-btn{height:84px;padding:0 24px;border-radius:14px;background:#3f4b63;color:#fff;border:none;cursor:pointer;display:inline-flex;flex-direction:column;align-items:center;justify-content:center;gap:4px;font-weight:800;font-size:.9rem;letter-spacing:.04em;transition:background .15s}
|
||||||
|
.action-btn:hover{background:#4a5777}
|
||||||
|
.action-btn .ico{font-size:1.4rem}
|
||||||
|
.action-btn.run{background:#16a34a}
|
||||||
|
.action-btn.run:hover{background:#15803d}
|
||||||
|
.action-btn.stop{background:#0f172a}
|
||||||
|
.action-btn.stop:hover{background:#1e293b}
|
||||||
|
.action-btn.danger{background:#fee2e2;color:#7f1d1d}
|
||||||
|
.action-btn.danger:hover{background:#fecaca}
|
||||||
|
.action-btn.danger .ico{color:#dc2626}
|
||||||
|
|
||||||
|
.file-bar{display:flex;align-items:center;gap:10px;padding:14px 18px;flex-wrap:wrap;border-bottom:1px solid #f1f5f9}
|
||||||
|
.file-btn{height:54px;padding:0 18px;border-radius:12px;background:#f1f5f9;border:1px solid #e2e8f0;font-weight:700;color:#0f172a;font-size:.9rem;display:inline-flex;align-items:center;gap:.5rem;cursor:pointer}
|
||||||
|
.file-btn:hover{background:#e2e8f0}
|
||||||
|
.file-select{height:54px;padding:0 16px;border-radius:12px;background:#fff;border:1px solid #e2e8f0;font-weight:600;color:#0f172a;font-size:.9rem;display:inline-flex;align-items:center;gap:.5rem;cursor:pointer}
|
||||||
|
.file-select .caret{color:#94a3b8;margin-left:.5rem}
|
||||||
|
.file-select.primary{background:#fff;border:2px solid #0ea5e9;flex:1;min-width:300px}
|
||||||
|
|
||||||
|
.program-body{flex:1;display:grid;grid-template-columns:1fr 600px;min-height:0}
|
||||||
|
.gcode{font-family:'JetBrains Mono',monospace;font-size:14px;line-height:1.6;background:#fafafa;border-right:1px solid #f1f5f9;padding:14px 0;overflow:auto;color:#1e293b}
|
||||||
|
.gline{display:grid;grid-template-columns:60px 1fr;gap:14px;padding:1px 18px 1px 0}
|
||||||
|
.gline:nth-child(odd){background:#f4f4f5}
|
||||||
|
.gline .gn{color:#f59e0b;text-align:right;font-weight:700}
|
||||||
|
.gline.cur{background:#dbeafe !important}
|
||||||
|
.gline.cur .gn{color:#1e40af}
|
||||||
|
.gcomment{color:#64748b}
|
||||||
|
.gword{color:#0f172a}
|
||||||
|
.gnum{color:#16a34a}
|
||||||
|
|
||||||
|
.viewer{display:flex;flex-direction:column;min-height:0}
|
||||||
|
.viewer-3d{flex:1;background:#0b1220;position:relative;overflow:hidden;display:flex;align-items:center;justify-content:center}
|
||||||
|
.viewer-tools{display:flex;gap:8px;padding:14px;border-top:1px solid #f1f5f9;background:#fff;flex-wrap:wrap}
|
||||||
|
.vtool{height:60px;width:60px;border-radius:12px;background:#f1f5f9;border:1px solid #e2e8f0;color:#475569;display:inline-flex;align-items:center;justify-content:center;font-size:1.2rem;cursor:pointer}
|
||||||
|
.vtool:hover{background:#e2e8f0}
|
||||||
|
.vtool.on{background:#0f172a;color:#fff;border-color:#0f172a}
|
||||||
|
.vinfo{padding:14px 18px;background:#fff;font-size:13px;color:#64748b;border-top:1px solid #f1f5f9;display:flex;justify-content:space-between;align-items:center}
|
||||||
|
.vinfo .ext{color:#0f172a;font-weight:600}
|
||||||
|
|
||||||
|
/* =============================================================
|
||||||
|
MESSAGES PANEL
|
||||||
|
============================================================= */
|
||||||
|
.messages{display:none;flex-direction:column;flex:1;min-height:0;padding:18px;gap:12px;overflow:auto}
|
||||||
|
.messages.active{display:flex}
|
||||||
|
.msg{background:#fff;border:1px solid #e5e7eb;border-radius:14px;padding:18px 22px;display:grid;grid-template-columns:54px 1fr auto;gap:18px;align-items:flex-start}
|
||||||
|
.msg .mi{width:54px;height:54px;border-radius:12px;display:inline-flex;align-items:center;justify-content:center;font-size:1.4rem}
|
||||||
|
.msg.error{border-left:6px solid #dc2626}
|
||||||
|
.msg.error .mi{background:#fee2e2;color:#991b1b}
|
||||||
|
.msg.warn{border-left:6px solid #f59e0b}
|
||||||
|
.msg.warn .mi{background:#fef3c7;color:#92400e}
|
||||||
|
.msg.info{border-left:6px solid #0ea5e9}
|
||||||
|
.msg.info .mi{background:#dbeafe;color:#1e40af}
|
||||||
|
.msg.ok{border-left:6px solid #16a34a}
|
||||||
|
.msg.ok .mi{background:#dcfce7;color:#166534}
|
||||||
|
.msg .mtitle{font-weight:800;font-size:1.05rem;color:#0f172a}
|
||||||
|
.msg .mtime{font-size:.8rem;color:#94a3b8;margin-top:2px}
|
||||||
|
.msg .mbody{margin-top:6px;color:#475569;font-size:.95rem;line-height:1.5}
|
||||||
|
.msg .mbody .mono{background:#f1f5f9;padding:2px 6px;border-radius:4px;font-size:.85rem}
|
||||||
|
.msg .mactions{display:flex;gap:8px}
|
||||||
|
.mbtn{height:48px;padding:0 16px;border-radius:10px;background:#f1f5f9;border:1px solid #e2e8f0;font-weight:700;color:#0f172a;font-size:.85rem;cursor:pointer}
|
||||||
|
.mbtn:hover{background:#e2e8f0}
|
||||||
|
.mbtn.primary{background:#0f172a;color:#fff;border-color:#0f172a}
|
||||||
|
.mbtn.primary:hover{background:#1e293b}
|
||||||
|
|
||||||
|
/* =============================================================
|
||||||
|
INDICATORS PANEL
|
||||||
|
============================================================= */
|
||||||
|
.indicators{display:none;flex:1;min-height:0;padding:18px;gap:14px;overflow:auto;grid-template-columns:repeat(4,1fr);grid-auto-rows:min-content}
|
||||||
|
.indicators.active{display:grid}
|
||||||
|
.ind{background:#fff;border:1px solid #e5e7eb;border-radius:14px;padding:16px 18px;display:flex;flex-direction:column;gap:6px}
|
||||||
|
.ind-label{font-size:.8rem;font-weight:800;text-transform:uppercase;letter-spacing:.1em;color:#94a3b8}
|
||||||
|
.ind-val{font-family:'JetBrains Mono',monospace;font-size:1.6rem;font-weight:800;color:#0f172a}
|
||||||
|
.ind-state{display:inline-flex;align-items:center;gap:.4rem;font-size:.8rem;font-weight:700;color:#475569}
|
||||||
|
.ind-state .dot{width:10px;height:10px;border-radius:9999px}
|
||||||
|
.ind .progress{height:8px;background:#f1f5f9;border-radius:9999px;overflow:hidden;margin-top:4px}
|
||||||
|
.ind .progress > div{height:100%;background:#0ea5e9}
|
||||||
|
.ind.full{grid-column:span 2}
|
||||||
|
|
||||||
|
/* =============================================================
|
||||||
|
MDI PANEL
|
||||||
|
============================================================= */
|
||||||
|
.mdi{display:none;flex-direction:column;flex:1;min-height:0;padding:18px;gap:14px}
|
||||||
|
.mdi.active{display:flex}
|
||||||
|
.mdi-input{
|
||||||
|
background:#0b1220;color:#86efac;border:1px solid #1e293b;border-radius:14px;
|
||||||
|
padding:22px 24px;font-family:'JetBrains Mono',monospace;font-size:1.4rem;font-weight:600;
|
||||||
|
display:flex;align-items:center;gap:.6rem;
|
||||||
|
}
|
||||||
|
.mdi-input .prompt{color:#475569}
|
||||||
|
.mdi-input .cursor{display:inline-block;width:14px;height:1.4rem;background:#86efac;animation:blink 1s steps(2,end) infinite;vertical-align:middle}
|
||||||
|
@keyframes blink{50%{opacity:0}}
|
||||||
|
.mdi-keys{display:grid;grid-template-columns:repeat(8,1fr);gap:8px;flex:0 0 auto}
|
||||||
|
.mkey{height:64px;border-radius:12px;background:#fff;border:1px solid #e2e8f0;font-weight:800;font-size:1.05rem;color:#0f172a;cursor:pointer;font-family:'JetBrains Mono',monospace}
|
||||||
|
.mkey:hover{background:#f1f5f9}
|
||||||
|
.mkey.send{background:#16a34a;color:#fff;border-color:#15803d;grid-column:span 2;font-family:'Inter',sans-serif;font-size:.95rem;letter-spacing:.04em}
|
||||||
|
.mkey.send:hover{background:#15803d}
|
||||||
|
.mkey.clear{background:#fee2e2;color:#7f1d1d;border-color:#fca5a5;font-family:'Inter',sans-serif;font-size:.95rem;letter-spacing:.04em}
|
||||||
|
.mdi-history{flex:1;background:#fff;border:1px solid #e5e7eb;border-radius:14px;padding:14px 18px;overflow:auto;font-family:'JetBrains Mono',monospace;font-size:.95rem}
|
||||||
|
.mdi-history .h-row{display:grid;grid-template-columns:80px 1fr auto;gap:14px;padding:6px 0;border-bottom:1px solid #f1f5f9;align-items:center}
|
||||||
|
.mdi-history .h-time{color:#94a3b8;font-size:.8rem}
|
||||||
|
.mdi-history .h-cmd{color:#0f172a;font-weight:700}
|
||||||
|
.mdi-history .h-status{color:#16a34a;font-weight:700;font-size:.8rem}
|
||||||
|
.mdi-history .h-status.err{color:#dc2626}
|
||||||
|
|
||||||
|
/* =============================================================
|
||||||
|
SETTINGS PANEL
|
||||||
|
============================================================= */
|
||||||
|
.settings{display:none;flex:1;min-height:0;padding:18px;gap:14px;overflow:auto;grid-template-columns:280px 1fr}
|
||||||
|
.settings.active{display:grid}
|
||||||
|
.set-side{background:#fff;border:1px solid #e5e7eb;border-radius:14px;padding:10px;display:flex;flex-direction:column;gap:4px;height:fit-content}
|
||||||
|
.set-item{height:56px;padding:0 16px;border-radius:10px;display:flex;align-items:center;gap:.6rem;color:#475569;font-weight:700;cursor:pointer}
|
||||||
|
.set-item:hover{background:#f1f5f9}
|
||||||
|
.set-item.active{background:#0f172a;color:#fff}
|
||||||
|
.set-content{display:flex;flex-direction:column;gap:14px}
|
||||||
|
.set-card{background:#fff;border:1px solid #e5e7eb;border-radius:14px;padding:22px}
|
||||||
|
.set-title{font-weight:800;font-size:1.1rem;color:#0f172a;margin-bottom:14px}
|
||||||
|
.set-row{display:grid;grid-template-columns:280px 1fr auto;gap:14px;align-items:center;padding:14px 0;border-bottom:1px solid #f1f5f9}
|
||||||
|
.set-row:last-child{border-bottom:none}
|
||||||
|
.set-row .label{font-weight:700;color:#0f172a;font-size:.95rem}
|
||||||
|
.set-row .desc{color:#64748b;font-size:.85rem;margin-top:2px}
|
||||||
|
.set-row .val{font-family:'JetBrains Mono',monospace;color:#475569}
|
||||||
|
.set-input{height:48px;padding:0 14px;border-radius:10px;border:1px solid #e2e8f0;background:#fff;font-family:'JetBrains Mono',monospace;font-size:.95rem;color:#0f172a;min-width:200px}
|
||||||
|
.set-toggle{width:54px;height:30px;border-radius:9999px;background:#cbd5e1;position:relative;cursor:pointer;transition:background .15s}
|
||||||
|
.set-toggle::after{content:"";position:absolute;left:3px;top:3px;width:24px;height:24px;border-radius:9999px;background:#fff;transition:transform .15s}
|
||||||
|
.set-toggle.on{background:#16a34a}
|
||||||
|
.set-toggle.on::after{transform:translateX(24px)}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="host">
|
||||||
|
|
||||||
|
<div class="topbar">
|
||||||
|
<div class="brand">
|
||||||
|
<div class="stripe-logo-sm"></div>
|
||||||
|
ONEFINITY · V09 · Full UX preview
|
||||||
|
</div>
|
||||||
|
<span class="pill">Click the inner tabs to navigate</span>
|
||||||
|
<div style="margin-left:auto"></div>
|
||||||
|
<button id="oneToOne" class="toggle">1:1</button>
|
||||||
|
<button id="fitBtn" class="toggle on">Fit</button>
|
||||||
|
<span id="scaleInfo" class="pill mono">100%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stage" id="stage">
|
||||||
|
<div class="scaler-viewport" id="viewport">
|
||||||
|
<div class="scaler" id="scaler">
|
||||||
|
|
||||||
|
<!-- ============= KIOSK ============= -->
|
||||||
|
<div class="kiosk">
|
||||||
|
<header class="head">
|
||||||
|
<div class="brand-blk">
|
||||||
|
<div class="brand-logo"></div>
|
||||||
|
<div class="brand-name">ONEFINITY</div>
|
||||||
|
</div>
|
||||||
|
<div class="kiosk-tabs">
|
||||||
|
<button class="ktab active" data-target="control"><i class="fa-solid fa-gamepad"></i> Control</button>
|
||||||
|
<button class="ktab" data-target="program"><i class="fa-solid fa-list-ol"></i> Program</button>
|
||||||
|
<button class="ktab" data-target="console"><i class="fa-solid fa-terminal"></i> Console <span class="ktab-badge">2</span></button>
|
||||||
|
<button class="ktab" data-target="settings"><i class="fa-solid fa-sliders"></i> Settings</button>
|
||||||
|
</div>
|
||||||
|
<button class="sys-btn"><span class="pip"></span> All systems · view <i class="fa-solid fa-chevron-down" style="font-size:10px;opacity:.6"></i></button>
|
||||||
|
<span class="state-badge"><span class="dot"></span> READY</span>
|
||||||
|
<button class="estop">STOP</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="body">
|
||||||
|
|
||||||
|
<!-- ============= CONTROL ============= -->
|
||||||
|
<div class="panel active" data-panel="control">
|
||||||
|
<div class="control-grid">
|
||||||
|
<!-- jog -->
|
||||||
|
<div class="jog-card">
|
||||||
|
<div class="jog-head">
|
||||||
|
<div class="jog-title">Jog · step <span class="step">10mm</span></div>
|
||||||
|
<div class="step-seg">
|
||||||
|
<button>0.1</button><button>1</button><button class="active">10</button><button>100</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="jog-grid">
|
||||||
|
<button class="jbtn dir"><i class="fa-solid fa-arrow-up ico" style="transform:rotate(-45deg)"></i></button>
|
||||||
|
<button class="jbtn">Y+</button>
|
||||||
|
<button class="jbtn dir"><i class="fa-solid fa-arrow-up ico" style="transform:rotate(45deg)"></i></button>
|
||||||
|
<button class="jbtn">Z+</button>
|
||||||
|
<button class="jbtn">X−</button>
|
||||||
|
<button class="jbtn ghost"><span class="lbl">XY</span><span style="font-size:1rem;font-weight:700">Origin</span></button>
|
||||||
|
<button class="jbtn">X+</button>
|
||||||
|
<button class="jbtn ghost"><span class="lbl">Z</span><span style="font-size:1rem;font-weight:700">Origin</span></button>
|
||||||
|
<button class="jbtn dir"><i class="fa-solid fa-arrow-down ico" style="transform:rotate(45deg)"></i></button>
|
||||||
|
<button class="jbtn">Y−</button>
|
||||||
|
<button class="jbtn dir"><i class="fa-solid fa-arrow-down ico" style="transform:rotate(-45deg)"></i></button>
|
||||||
|
<button class="jbtn">Z−</button>
|
||||||
|
<button class="jbtn"><i class="fa-solid fa-arrow-down ico"></i><span class="lbl">W−</span></button>
|
||||||
|
<button class="jbtn ghost"><span class="lbl">W</span><span style="font-size:1rem;font-weight:700">Origin</span></button>
|
||||||
|
<button class="jbtn"><i class="fa-solid fa-arrow-up ico"></i><span class="lbl">W+</span></button>
|
||||||
|
<button class="jbtn"><i class="fa-solid fa-house ico"></i><span class="lbl">Home</span></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- DRO + status -->
|
||||||
|
<div class="right-col">
|
||||||
|
<div class="dro-card">
|
||||||
|
<div class="dro-head">
|
||||||
|
<div>Axis</div><div>Position</div><div>Absolute</div><div>Offset</div><div>State</div><div>Toolpath</div><div style="text-align:right">Actions</div>
|
||||||
|
</div>
|
||||||
|
<div class="dro-row">
|
||||||
|
<div class="dro-axis axis-x">X</div>
|
||||||
|
<div class="dro-pos">0.000<span class="u">mm</span></div>
|
||||||
|
<div class="dro-sec">0.000</div>
|
||||||
|
<div class="dro-sec">0.000</div>
|
||||||
|
<div><span class="chip chip-amber"><i class="fa-solid fa-question"></i> Unhomed</span></div>
|
||||||
|
<div><span class="chip chip-green"><i class="fa-solid fa-check"></i> OK</span></div>
|
||||||
|
<div class="actions-cell">
|
||||||
|
<button class="icon-btn"><i class="fa-solid fa-gear"></i></button>
|
||||||
|
<button class="icon-btn"><i class="fa-solid fa-location-dot"></i></button>
|
||||||
|
<button class="icon-btn"><i class="fa-solid fa-house"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="dro-row">
|
||||||
|
<div class="dro-axis axis-y">Y</div>
|
||||||
|
<div class="dro-pos">0.000<span class="u">mm</span></div>
|
||||||
|
<div class="dro-sec">0.000</div>
|
||||||
|
<div class="dro-sec">0.000</div>
|
||||||
|
<div><span class="chip chip-amber"><i class="fa-solid fa-question"></i> Unhomed</span></div>
|
||||||
|
<div><span class="chip chip-green"><i class="fa-solid fa-check"></i> OK</span></div>
|
||||||
|
<div class="actions-cell">
|
||||||
|
<button class="icon-btn"><i class="fa-solid fa-gear"></i></button>
|
||||||
|
<button class="icon-btn"><i class="fa-solid fa-location-dot"></i></button>
|
||||||
|
<button class="icon-btn"><i class="fa-solid fa-house"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="dro-row z-highlight">
|
||||||
|
<div class="dro-axis axis-z">Z</div>
|
||||||
|
<div class="dro-pos">0.000<span class="u">mm</span></div>
|
||||||
|
<div class="dro-sec">0.000</div>
|
||||||
|
<div class="dro-sec">0.000</div>
|
||||||
|
<div><span class="chip chip-amber"><i class="fa-solid fa-question"></i> Unhomed</span></div>
|
||||||
|
<div><span class="chip chip-amber"><i class="fa-solid fa-triangle-exclamation"></i> Over</span></div>
|
||||||
|
<div class="actions-cell">
|
||||||
|
<button class="icon-btn"><i class="fa-solid fa-gear"></i></button>
|
||||||
|
<button class="icon-btn"><i class="fa-solid fa-location-dot"></i></button>
|
||||||
|
<button class="icon-btn"><i class="fa-solid fa-house"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="dro-row">
|
||||||
|
<div class="dro-axis axis-w">W</div>
|
||||||
|
<div class="dro-pos">0.000<span class="u">mm</span></div>
|
||||||
|
<div class="dro-sec">0.000</div>
|
||||||
|
<div class="dro-sec" style="opacity:.4">—</div>
|
||||||
|
<div><span class="chip chip-amber"><i class="fa-solid fa-question"></i> Unhomed</span></div>
|
||||||
|
<div><span class="chip chip-green"><i class="fa-solid fa-check"></i> OK</span></div>
|
||||||
|
<div class="actions-cell">
|
||||||
|
<button class="icon-btn"><i class="fa-solid fa-location-dot"></i></button>
|
||||||
|
<button class="icon-btn"><i class="fa-solid fa-house"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="status-strip">
|
||||||
|
<div class="stat-card"><div class="stat-label">State</div><div class="stat-val ok">READY</div><div class="stat-sub">No alerts</div></div>
|
||||||
|
<div class="stat-card"><div class="stat-label">Velocity / Feed</div><div class="stat-val">0 · 0</div><div class="stat-sub">m/min · mm/min</div></div>
|
||||||
|
<div class="stat-card"><div class="stat-label">Spindle</div><div class="stat-val">0 (0)</div><div class="stat-sub">RPM (commanded / actual)</div></div>
|
||||||
|
<div class="stat-card"><div class="stat-label">Job</div><div class="stat-val">0 / 1,785</div><div class="stat-sub">Line · 19:07 remaining</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- macros -->
|
||||||
|
<div class="macro-row">
|
||||||
|
<button class="macro-btn"><span class="mnum">1</span><i class="fa-solid fa-circle-play micon"></i> Macro 1</button>
|
||||||
|
<button class="macro-btn"><span class="mnum">2</span><i class="fa-solid fa-circle-play micon"></i> Macro 2</button>
|
||||||
|
<button class="macro-btn"><span class="mnum">3</span><i class="fa-solid fa-circle-play micon"></i> Macro 3</button>
|
||||||
|
<button class="macro-btn"><span class="mnum">4</span><i class="fa-solid fa-circle-play micon"></i> Macro 4</button>
|
||||||
|
<button class="macro-btn"><span class="mnum">5</span><i class="fa-solid fa-circle-play micon"></i> Macro 5</button>
|
||||||
|
<button class="macro-btn"><span class="mnum">6</span><i class="fa-solid fa-circle-play micon"></i> Macro 6</button>
|
||||||
|
<button class="macro-btn"><span class="mnum">7</span><i class="fa-solid fa-circle-play micon"></i> Macro 7</button>
|
||||||
|
<button class="macro-btn"><span class="mnum">8</span><i class="fa-solid fa-circle-play micon"></i> Macro 8</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============= PROGRAM ============= -->
|
||||||
|
<div class="panel" data-panel="program" style="padding:0;gap:0">
|
||||||
|
<div class="program-card" style="margin:18px;border-radius:18px">
|
||||||
|
<!-- Auto sub-panel -->
|
||||||
|
<div class="auto-sub" data-sub="auto" style="display:flex;flex-direction:column;flex:1;min-height:0">
|
||||||
|
<div class="action-bar">
|
||||||
|
<button class="action-btn run"><i class="fa-solid fa-play ico"></i><span>RUN</span></button>
|
||||||
|
<button class="action-btn stop"><i class="fa-solid fa-stop ico"></i><span>STOP</span></button>
|
||||||
|
<button class="action-btn"><i class="fa-solid fa-folder-arrow-up ico"></i><span>UPLOAD FOLDER</span></button>
|
||||||
|
<button class="action-btn"><i class="fa-solid fa-file-arrow-up ico"></i><span>UPLOAD FILE</span></button>
|
||||||
|
<button class="action-btn"><i class="fa-solid fa-file-arrow-down ico"></i><span>DOWNLOAD FILE</span></button>
|
||||||
|
<button class="action-btn danger"><i class="fa-solid fa-trash ico"></i><span>DELETE</span></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="file-bar">
|
||||||
|
<button class="file-btn"><i class="fa-solid fa-folder-plus"></i> Create Folder</button>
|
||||||
|
<button class="file-btn"><i class="fa-solid fa-folder-minus"></i> Delete Folder</button>
|
||||||
|
<span class="file-select"><i class="fa-solid fa-folder-open" style="color:#64748b"></i> Default folder <i class="fa-solid fa-chevron-down caret"></i></span>
|
||||||
|
<span class="file-select primary"><i class="fa-solid fa-file-code" style="color:#0ea5e9"></i> thin-rough.nc <i class="fa-solid fa-chevron-down caret" style="margin-left:auto"></i></span>
|
||||||
|
<span class="file-select"><i class="fa-solid fa-arrow-down-wide-short" style="color:#64748b"></i> By Upload Date <i class="fa-solid fa-chevron-down caret"></i></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="program-body">
|
||||||
|
<div class="gcode" id="gcode-list"></div>
|
||||||
|
<div class="viewer">
|
||||||
|
<div class="viewer-3d">
|
||||||
|
<svg viewBox="0 0 400 220" style="width:100%;height:100%">
|
||||||
|
<defs>
|
||||||
|
<pattern id="gridv" width="20" height="20" patternUnits="userSpaceOnUse">
|
||||||
|
<path d="M 20 0 L 0 0 0 20" fill="none" stroke="#1e293b" stroke-width="1"/>
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect width="400" height="220" fill="url(#gridv)"/>
|
||||||
|
<rect x="40" y="80" width="320" height="60" stroke="#475569" stroke-width="1" fill="none" stroke-dasharray="3 3"/>
|
||||||
|
<text x="40" y="74" fill="#64748b" font-size="9" font-family="monospace">Stock: 250 × 25 × 16 mm</text>
|
||||||
|
<!-- toolpath -->
|
||||||
|
<path d="M40,110 L360,110 M40,100 L360,100 M40,120 L360,120 M40,90 L360,90 M40,130 L360,130" stroke="#22c55e" stroke-width="1.4" fill="none" opacity=".8"/>
|
||||||
|
<path d="M40,110 L40,80 L60,80 L60,110 M80,110 L80,80 L100,80 L100,110 M120,110 L120,80 L140,80 L140,110" stroke="#ef4444" stroke-width="1.4" fill="none" opacity=".8"/>
|
||||||
|
<circle cx="40" cy="110" r="3" fill="#22c55e"/>
|
||||||
|
<circle cx="360" cy="110" r="3" fill="#ef4444"/>
|
||||||
|
<text x="46" y="108" fill="#22c55e" font-size="8" font-family="monospace">START</text>
|
||||||
|
<text x="332" y="108" fill="#ef4444" font-size="8" font-family="monospace">END</text>
|
||||||
|
<!-- axes gizmo -->
|
||||||
|
<g transform="translate(28,196)">
|
||||||
|
<line x1="0" y1="0" x2="22" y2="0" stroke="#ef4444" stroke-width="2"/>
|
||||||
|
<line x1="0" y1="0" x2="0" y2="-22" stroke="#3b82f6" stroke-width="2"/>
|
||||||
|
<line x1="0" y1="0" x2="-12" y2="12" stroke="#22c55e" stroke-width="2"/>
|
||||||
|
<text x="24" y="4" fill="#ef4444" font-size="9" font-family="monospace">X</text>
|
||||||
|
<text x="-4" y="-26" fill="#3b82f6" font-size="9" font-family="monospace">Z</text>
|
||||||
|
<text x="-22" y="22" fill="#22c55e" font-size="9" font-family="monospace">Y</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="viewer-tools">
|
||||||
|
<button class="vtool" title="Fit"><i class="fa-solid fa-expand"></i></button>
|
||||||
|
<button class="vtool on" title="Tool"><i class="fa-solid fa-screwdriver-wrench"></i></button>
|
||||||
|
<button class="vtool" title="Stock"><i class="fa-solid fa-cube"></i></button>
|
||||||
|
<button class="vtool" title="Origin"><i class="fa-solid fa-up-right-and-down-left-from-center"></i></button>
|
||||||
|
<button class="vtool" title="Top"><i class="fa-solid fa-square"></i></button>
|
||||||
|
<button class="vtool" title="Front"><i class="fa-solid fa-square-full"></i></button>
|
||||||
|
<button class="vtool" title="Iso"><i class="fa-solid fa-cubes"></i></button>
|
||||||
|
</div>
|
||||||
|
<div class="vinfo">
|
||||||
|
<span><span class="ext">thin-rough.nc</span> · 1,785 lines · 12.4 KB</span>
|
||||||
|
<span class="mono">est. 19:07</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============= CONSOLE ============= -->
|
||||||
|
<div class="panel" data-panel="console" style="padding:0;gap:0">
|
||||||
|
<div class="program-card" style="margin:18px;border-radius:18px">
|
||||||
|
|
||||||
|
<div class="ptab-bar">
|
||||||
|
<button class="ptab active" data-ptab="mdi"><i class="fa-solid fa-keyboard"></i> MDI</button>
|
||||||
|
<button class="ptab" data-ptab="messages"><i class="fa-solid fa-comment-dots"></i> Messages <span class="ptab-badge">2</span></button>
|
||||||
|
<button class="ptab" data-ptab="indicators"><i class="fa-solid fa-bell"></i> Indicators</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- MDI sub-panel -->
|
||||||
|
<div class="mdi active" data-sub="mdi">
|
||||||
|
<div class="mdi-input">
|
||||||
|
<span class="prompt">G></span>
|
||||||
|
<span class="mono">G0 X100 Y50 F2000</span>
|
||||||
|
<span class="cursor"></span>
|
||||||
|
</div>
|
||||||
|
<div class="mdi-keys">
|
||||||
|
<button class="mkey">G0</button>
|
||||||
|
<button class="mkey">G1</button>
|
||||||
|
<button class="mkey">G2</button>
|
||||||
|
<button class="mkey">G3</button>
|
||||||
|
<button class="mkey">G28</button>
|
||||||
|
<button class="mkey">G92</button>
|
||||||
|
<button class="mkey">M3</button>
|
||||||
|
<button class="mkey">M5</button>
|
||||||
|
<button class="mkey">X</button>
|
||||||
|
<button class="mkey">Y</button>
|
||||||
|
<button class="mkey">Z</button>
|
||||||
|
<button class="mkey">W</button>
|
||||||
|
<button class="mkey">F</button>
|
||||||
|
<button class="mkey">S</button>
|
||||||
|
<button class="mkey clear">CLEAR</button>
|
||||||
|
<button class="mkey send">SEND ↵</button>
|
||||||
|
</div>
|
||||||
|
<div class="mdi-history">
|
||||||
|
<div class="h-row"><span class="h-time">19:42:11</span><span class="h-cmd">G21</span><span class="h-status">✓ ok</span></div>
|
||||||
|
<div class="h-row"><span class="h-time">19:42:14</span><span class="h-cmd">G90</span><span class="h-status">✓ ok</span></div>
|
||||||
|
<div class="h-row"><span class="h-time">19:43:02</span><span class="h-cmd">G0 Y12.800</span><span class="h-status">✓ ok</span></div>
|
||||||
|
<div class="h-row"><span class="h-time">19:43:08</span><span class="h-cmd">G0 Z19.040</span><span class="h-status">✓ ok</span></div>
|
||||||
|
<div class="h-row"><span class="h-time">19:43:30</span><span class="h-cmd">G1 Z-20 F800</span><span class="h-status err">✗ blocked: Z over travel</span></div>
|
||||||
|
<div class="h-row"><span class="h-time">19:44:01</span><span class="h-cmd">G0 Z5</span><span class="h-status">✓ ok</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Messages sub-panel -->
|
||||||
|
<div class="messages" data-sub="messages">
|
||||||
|
<div class="msg warn">
|
||||||
|
<div class="mi"><i class="fa-solid fa-triangle-exclamation"></i></div>
|
||||||
|
<div>
|
||||||
|
<div style="display:flex;align-items:baseline;gap:.6rem">
|
||||||
|
<div class="mtitle">Z toolpath exceeds soft-limit</div>
|
||||||
|
<div class="mtime">2 min ago · sticky</div>
|
||||||
|
</div>
|
||||||
|
<div class="mbody">Loaded program reaches <span class="mono">Z = -16.500</span>. Configured soft-limit is <span class="mono">Z = -15.000</span>. Adjust the Z origin or set a deeper soft-limit before running.</div>
|
||||||
|
</div>
|
||||||
|
<div class="mactions">
|
||||||
|
<button class="mbtn">Open settings</button>
|
||||||
|
<button class="mbtn primary">Acknowledge</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="msg info">
|
||||||
|
<div class="mi"><i class="fa-solid fa-circle-info"></i></div>
|
||||||
|
<div>
|
||||||
|
<div style="display:flex;align-items:baseline;gap:.6rem">
|
||||||
|
<div class="mtitle">Camera offline</div>
|
||||||
|
<div class="mtime">12 min ago</div>
|
||||||
|
</div>
|
||||||
|
<div class="mbody">Camera at <span class="mono">10.1.10.55:8554</span> did not respond on last poll. Live preview disabled.</div>
|
||||||
|
</div>
|
||||||
|
<div class="mactions">
|
||||||
|
<button class="mbtn">Retry</button>
|
||||||
|
<button class="mbtn">Dismiss</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="msg ok">
|
||||||
|
<div class="mi"><i class="fa-solid fa-check"></i></div>
|
||||||
|
<div>
|
||||||
|
<div style="display:flex;align-items:baseline;gap:.6rem">
|
||||||
|
<div class="mtitle">File uploaded · thin-rough.nc</div>
|
||||||
|
<div class="mtime">21 min ago</div>
|
||||||
|
</div>
|
||||||
|
<div class="mbody">1,785 lines · 12.4 KB · checksum verified.</div>
|
||||||
|
</div>
|
||||||
|
<div class="mactions">
|
||||||
|
<button class="mbtn">Open</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="msg error">
|
||||||
|
<div class="mi"><i class="fa-solid fa-circle-xmark"></i></div>
|
||||||
|
<div>
|
||||||
|
<div style="display:flex;align-items:baseline;gap:.6rem">
|
||||||
|
<div class="mtitle">WiFi: not connected</div>
|
||||||
|
<div class="mtime">1 h ago</div>
|
||||||
|
</div>
|
||||||
|
<div class="mbody">Falling back to wired ethernet. SSID <span class="mono">workshop-2g</span> last seen 53 min ago.</div>
|
||||||
|
</div>
|
||||||
|
<div class="mactions">
|
||||||
|
<button class="mbtn">Network…</button>
|
||||||
|
<button class="mbtn">Mute</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Indicators sub-panel -->
|
||||||
|
<div class="indicators" data-sub="indicators">
|
||||||
|
<div class="ind">
|
||||||
|
<div class="ind-label">Spindle Load</div>
|
||||||
|
<div class="ind-val">0 %</div>
|
||||||
|
<div class="ind-state"><span class="dot" style="background:#16a34a"></span> idle</div>
|
||||||
|
<div class="progress"><div style="width:0%"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="ind">
|
||||||
|
<div class="ind-label">Spindle Temp</div>
|
||||||
|
<div class="ind-val">24 °C</div>
|
||||||
|
<div class="ind-state"><span class="dot" style="background:#16a34a"></span> nominal</div>
|
||||||
|
<div class="progress"><div style="width:24%"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="ind">
|
||||||
|
<div class="ind-label">Driver Voltage</div>
|
||||||
|
<div class="ind-val">48.1 V</div>
|
||||||
|
<div class="ind-state"><span class="dot" style="background:#16a34a"></span> ok</div>
|
||||||
|
</div>
|
||||||
|
<div class="ind">
|
||||||
|
<div class="ind-label">Coolant</div>
|
||||||
|
<div class="ind-val">OFF</div>
|
||||||
|
<div class="ind-state"><span class="dot" style="background:#94a3b8"></span> standby</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ind">
|
||||||
|
<div class="ind-label">Limit X</div>
|
||||||
|
<div class="ind-val" style="color:#16a34a">CLEAR</div>
|
||||||
|
<div class="ind-state"><span class="dot" style="background:#16a34a"></span> ok</div>
|
||||||
|
</div>
|
||||||
|
<div class="ind">
|
||||||
|
<div class="ind-label">Limit Y</div>
|
||||||
|
<div class="ind-val" style="color:#16a34a">CLEAR</div>
|
||||||
|
<div class="ind-state"><span class="dot" style="background:#16a34a"></span> ok</div>
|
||||||
|
</div>
|
||||||
|
<div class="ind">
|
||||||
|
<div class="ind-label">Limit Z</div>
|
||||||
|
<div class="ind-val" style="color:#dc2626">BLOCKED</div>
|
||||||
|
<div class="ind-state"><span class="dot" style="background:#dc2626"></span> over-travel</div>
|
||||||
|
</div>
|
||||||
|
<div class="ind">
|
||||||
|
<div class="ind-label">Probe</div>
|
||||||
|
<div class="ind-val">OPEN</div>
|
||||||
|
<div class="ind-state"><span class="dot" style="background:#94a3b8"></span> not contacted</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ind">
|
||||||
|
<div class="ind-label">E-Stop</div>
|
||||||
|
<div class="ind-val" style="color:#16a34a">RELEASED</div>
|
||||||
|
<div class="ind-state"><span class="dot" style="background:#16a34a"></span> safe</div>
|
||||||
|
</div>
|
||||||
|
<div class="ind">
|
||||||
|
<div class="ind-label">Door</div>
|
||||||
|
<div class="ind-val">CLOSED</div>
|
||||||
|
<div class="ind-state"><span class="dot" style="background:#16a34a"></span> ok</div>
|
||||||
|
</div>
|
||||||
|
<div class="ind">
|
||||||
|
<div class="ind-label">Air Pressure</div>
|
||||||
|
<div class="ind-val">6.2 bar</div>
|
||||||
|
<div class="ind-state"><span class="dot" style="background:#16a34a"></span> ok</div>
|
||||||
|
<div class="progress"><div style="width:62%"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="ind">
|
||||||
|
<div class="ind-label">Vacuum</div>
|
||||||
|
<div class="ind-val">−0.81 bar</div>
|
||||||
|
<div class="ind-state"><span class="dot" style="background:#16a34a"></span> hold</div>
|
||||||
|
<div class="progress"><div style="width:81%"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============= SETTINGS ============= -->
|
||||||
|
<div class="panel" data-panel="settings" style="padding:0;gap:0">
|
||||||
|
<div class="settings active" style="padding:18px">
|
||||||
|
<div class="set-side">
|
||||||
|
<div class="set-item active"><i class="fa-solid fa-display"></i> Display & Units</div>
|
||||||
|
<div class="set-item"><i class="fa-solid fa-arrows-up-down-left-right"></i> Motion</div>
|
||||||
|
<div class="set-item"><i class="fa-solid fa-bolt"></i> Spindle</div>
|
||||||
|
<div class="set-item"><i class="fa-solid fa-shield-halved"></i> Safety / Soft-limits</div>
|
||||||
|
<div class="set-item"><i class="fa-solid fa-network-wired"></i> Network</div>
|
||||||
|
<div class="set-item"><i class="fa-solid fa-video"></i> Camera</div>
|
||||||
|
<div class="set-item"><i class="fa-solid fa-keyboard"></i> Macros</div>
|
||||||
|
<div class="set-item"><i class="fa-solid fa-circle-info"></i> About</div>
|
||||||
|
</div>
|
||||||
|
<div class="set-content">
|
||||||
|
<div class="set-card">
|
||||||
|
<div class="set-title">Display & Units</div>
|
||||||
|
<div class="set-row">
|
||||||
|
<div>
|
||||||
|
<div class="label">Display Units</div>
|
||||||
|
<div class="desc">Position, feed and dimensions throughout the UI.</div>
|
||||||
|
</div>
|
||||||
|
<div><div class="step-seg" style="display:inline-flex"><button class="active">METRIC</button><button>IMPERIAL</button></div></div>
|
||||||
|
<div></div>
|
||||||
|
</div>
|
||||||
|
<div class="set-row">
|
||||||
|
<div>
|
||||||
|
<div class="label">Decimal places</div>
|
||||||
|
<div class="desc">Position readout precision.</div>
|
||||||
|
</div>
|
||||||
|
<div><input class="set-input" value="3" /></div>
|
||||||
|
<div class="val">0–4</div>
|
||||||
|
</div>
|
||||||
|
<div class="set-row">
|
||||||
|
<div>
|
||||||
|
<div class="label">Pulse-dot animation</div>
|
||||||
|
<div class="desc">Animate status badges (ready, idle, alarm).</div>
|
||||||
|
</div>
|
||||||
|
<div><div class="set-toggle on"></div></div>
|
||||||
|
<div></div>
|
||||||
|
</div>
|
||||||
|
<div class="set-row">
|
||||||
|
<div>
|
||||||
|
<div class="label">Theme</div>
|
||||||
|
<div class="desc">Pick a tile finish.</div>
|
||||||
|
</div>
|
||||||
|
<div><span class="file-select"><i class="fa-solid fa-palette" style="color:#64748b"></i> V09 · Flat soft slate <i class="fa-solid fa-chevron-down caret"></i></span></div>
|
||||||
|
<div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="set-card">
|
||||||
|
<div class="set-title">Network</div>
|
||||||
|
<div class="set-row">
|
||||||
|
<div>
|
||||||
|
<div class="label">IP Address</div>
|
||||||
|
<div class="desc">Wired ethernet, DHCP.</div>
|
||||||
|
</div>
|
||||||
|
<div><span class="mono" style="font-size:1.05rem;font-weight:700">10.1.10.55</span></div>
|
||||||
|
<div><button class="mbtn">Edit</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="set-row">
|
||||||
|
<div>
|
||||||
|
<div class="label">WiFi</div>
|
||||||
|
<div class="desc">Wireless network connection.</div>
|
||||||
|
</div>
|
||||||
|
<div><span class="chip chip-red"><i class="fa-solid fa-wifi"></i> Not connected</span></div>
|
||||||
|
<div><button class="mbtn primary">Configure</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="set-row">
|
||||||
|
<div>
|
||||||
|
<div class="label">Hostname</div>
|
||||||
|
<div class="desc">Used in mDNS / Bonjour discovery.</div>
|
||||||
|
</div>
|
||||||
|
<div><input class="set-input" value="onefinity-shop.local" style="width:300px" /></div>
|
||||||
|
<div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ----- Build G-code list -----
|
||||||
|
const gcodeLines = [
|
||||||
|
[1,'G21','word'],[2,'; X = along blank, Z = tool entry from top, Y fixed','c'],
|
||||||
|
[3,'; Y fixed to blank center: 12.800','c'],[4,'; nominal rapid: 3200.0 mm/min','c'],
|
||||||
|
[5,'; stock top Z: -0.960','c'],[6,'; deepest allowed cut Z: -16.500','c'],
|
||||||
|
[7,'G21','word'],[8,'G90','word'],[9,'G0 Y12.800','word'],
|
||||||
|
[10,'G0 Z19.040','word'],[11,'; rough pass 1 radius=18.540','c'],
|
||||||
|
[12,'G0 X0.000','word'],[13,'G1 Z-0.710 F800.000','word cur'],
|
||||||
|
[14,'G1 Z-0.960 F200.000','word'],[15,'G4 P0.250','word'],
|
||||||
|
[16,'G1 X249.500 F200.000','word'],[17,'G4 P0.250','word'],
|
||||||
|
[18,'G0 Z19.040','word'],[19,'; rough pass 2 radius=17.540','c'],
|
||||||
|
[20,'G0 X0.000','word'],[21,'G1 Z-1.710 F800.000','word'],
|
||||||
|
[22,'G1 Z-1.960 F200.000','word'],[23,'G4 P0.250','word'],
|
||||||
|
[24,'G1 X249.500 F200.000','word'],[25,'G4 P0.250','word'],
|
||||||
|
[26,'G0 Z19.040','word'],[27,'; rough pass 3 radius=16.540','c'],
|
||||||
|
[28,'G0 X0.000','word'],[29,'G1 Z-2.710 F800.000','word'],
|
||||||
|
[30,'G1 Z-2.960 F200.000','word'],[31,'G4 P0.250','word'],
|
||||||
|
[32,'G1 X249.500 F200.000','word'],[33,'G4 P0.250','word'],
|
||||||
|
[34,'G0 Z19.040','word'],[35,'; rough pass 4 radius=15.540','c'],
|
||||||
|
];
|
||||||
|
document.getElementById('gcode-list').innerHTML = gcodeLines.map(([n,t,cls])=>{
|
||||||
|
const isComment = cls.includes('c');
|
||||||
|
const isCur = cls.includes('cur');
|
||||||
|
const cls2 = 'gline' + (isCur?' cur':'');
|
||||||
|
const inner = isComment ? `<span class="gcomment">${t}</span>` : `<span class="gword">${t}</span>`;
|
||||||
|
return `<div class="${cls2}"><span class="gn">${n}</span><span>${inner}</span></div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// ----- Top tab switching (Control / Program / Settings) -----
|
||||||
|
document.querySelectorAll('.ktab').forEach(b=>{
|
||||||
|
b.addEventListener('click', ()=>{
|
||||||
|
const target = b.dataset.target;
|
||||||
|
document.querySelectorAll('.ktab').forEach(x=>x.classList.remove('active'));
|
||||||
|
b.classList.add('active');
|
||||||
|
document.querySelectorAll('.panel').forEach(p=>p.classList.remove('active'));
|
||||||
|
document.querySelector(`.panel[data-panel="${target}"]`).classList.add('active');
|
||||||
|
applyScale();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ----- Console sub-tab switching (MDI / Messages / Indicators) -----
|
||||||
|
function showSub(name){
|
||||||
|
document.querySelectorAll('.ptab').forEach(x=>x.classList.toggle('active', x.dataset.ptab===name));
|
||||||
|
document.querySelectorAll('[data-sub]').forEach(s=>{
|
||||||
|
const on = s.dataset.sub===name;
|
||||||
|
if(s.classList.contains('messages') || s.classList.contains('indicators') || s.classList.contains('mdi')){
|
||||||
|
s.classList.toggle('active', on);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.querySelectorAll('.ptab').forEach(b=>{
|
||||||
|
b.addEventListener('click', ()=>{ showSub(b.dataset.ptab); });
|
||||||
|
});
|
||||||
|
// Default Console sub: MDI active
|
||||||
|
document.querySelectorAll('.messages[data-sub], .indicators[data-sub]').forEach(s=>s.classList.remove('active'));
|
||||||
|
|
||||||
|
// ----- Scaling -----
|
||||||
|
const stage = document.getElementById('stage');
|
||||||
|
const scaler = document.getElementById('scaler');
|
||||||
|
const viewport = document.getElementById('viewport');
|
||||||
|
const fitBtn = document.getElementById('fitBtn');
|
||||||
|
const oneToOne = document.getElementById('oneToOne');
|
||||||
|
const scaleInfo = document.getElementById('scaleInfo');
|
||||||
|
let mode = 'fit';
|
||||||
|
function activeKioskHeight(){
|
||||||
|
const m = document.querySelector('.kiosk');
|
||||||
|
return m ? Math.max(1080, m.offsetHeight) : 1080;
|
||||||
|
}
|
||||||
|
function applyScale(){
|
||||||
|
let s;
|
||||||
|
if(mode==='1:1'){
|
||||||
|
s = 1; scaleInfo.textContent = '100% · 1920px wide';
|
||||||
|
} else {
|
||||||
|
const sw = stage.clientWidth - 32;
|
||||||
|
s = Math.min(sw/1920, 1);
|
||||||
|
scaleInfo.textContent = Math.round(s*100) + '% · 1920px wide';
|
||||||
|
}
|
||||||
|
const h = activeKioskHeight();
|
||||||
|
scaler.style.transform = `scale(${s})`;
|
||||||
|
viewport.style.width = (1920 * s) + 'px';
|
||||||
|
viewport.style.height = (h * s) + 'px';
|
||||||
|
}
|
||||||
|
window.addEventListener('resize', applyScale);
|
||||||
|
fitBtn.addEventListener('click', ()=>{ mode='fit'; fitBtn.classList.add('on'); oneToOne.classList.remove('on'); applyScale(); });
|
||||||
|
oneToOne.addEventListener('click', ()=>{ mode='1:1'; oneToOne.classList.add('on'); fitBtn.classList.remove('on'); applyScale(); });
|
||||||
|
applyScale();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
169
plans/2026-04-30_ux_redesign.md
Normal file
169
plans/2026-04-30_ux_redesign.md
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
# UX Redesign — Implementation Plan
|
||||||
|
|
||||||
|
Reference mock: `docs/mocks/v09_full_ux.html`
|
||||||
|
Target hardware: 10.8" portable monitor, 1920×1080, capacitive touch, Chrome fullscreen.
|
||||||
|
|
||||||
|
## 1. Goals
|
||||||
|
|
||||||
|
The redesign keeps every existing feature but reorganizes the page into a single-screen control surface for finger-touch use:
|
||||||
|
|
||||||
|
- A slim 96 px header replaces the 140 px nav-header. Only logo + ONEFINITY wordmark + tab bar + system pill + READY badge + octagonal STOP.
|
||||||
|
- 4 top-level sections accessed via underline-ribbon tabs in the header:
|
||||||
|
1. **Control** — jog pad, DRO table, status strip, macro row.
|
||||||
|
2. **Program** — Auto run controls, file actions, G-code listing, 3D viewer.
|
||||||
|
3. **Console** — MDI, Messages, Indicators (sub-tabs).
|
||||||
|
4. **Settings** — paged settings (replaces the Pure left rail).
|
||||||
|
- Touch targets ≥ 64 px (jog tiles 72 px, axis action icons 72 px, macro buttons 84 px).
|
||||||
|
- All action chip-soup (WiFi/Camera/Rotary/IP/Version) collapses into one "All systems · view" pill that opens a popover. Burger menu removed (Settings tab supersedes it).
|
||||||
|
- V09 jog/macro palette: flat soft slate (#3f4b63), no drop shadow; yellow (#fde047) accent for active states (step seg, tab underline, macro number badge).
|
||||||
|
- Spindle override / feed override sliders live in a bottom-edge drawer triggered by tapping the Spindle KPI tile (no permanent screen real estate).
|
||||||
|
- Hard cut: no `config.ui.layout` flag; the new shell replaces the old in a single release.
|
||||||
|
|
||||||
|
## 2. Scope of code change
|
||||||
|
|
||||||
|
The build is Pug + Stylus + Browserify Vue (Vue 1.x). `index.pug` defines the chrome; `src/pug/templates/*.pug` defines each view; `src/js/*.js` mirrors them as Vue components routed by `currentView` from the URL hash.
|
||||||
|
|
||||||
|
Files we will touch:
|
||||||
|
|
||||||
|
- `src/pug/index.pug` — replace `#layout / #menu / #main / .nav-header` with the new header + tab bar + body. Drop the burger and the side-menu include.
|
||||||
|
- `src/pug/templates/control-view.pug` — restructure into the new Control panel (jog grid + DRO table + status strip + macro row). MDI/Messages/Indicators move out.
|
||||||
|
- New `src/pug/templates/program-view.pug` — Auto sub-panel content (action bar, file bar, gcode-viewer, path-viewer).
|
||||||
|
- New `src/pug/templates/console-view.pug` — MDI / Messages / Indicators sub-tabs hosting existing `console.pug` and `indicators.pug` partials.
|
||||||
|
- `src/js/app.js` — extend `parse_hash` so `#program`, `#console`, `#settings` resolve; expose tab state for the header to highlight.
|
||||||
|
- `src/js/control-view.js` — keep jog/DRO logic, drop the Auto/MDI/Messages/Indicators internal `tab` state and template hooks.
|
||||||
|
- New `src/js/program-view.js`, `src/js/console-view.js` — extracted Vue components.
|
||||||
|
- `src/stylus/style.styl` — add `.app-shell`, `.head`, `.tabs-host`, `.ktab`, panel styles, V09 jog tokens. Keep legacy classes alive until templates fully migrated.
|
||||||
|
- `src/static/css/side-menu.css` — stop including in `index.pug`.
|
||||||
|
- Settings: keep `settings-view.pug`, `admin-general-view.pug`, `admin-network-view.pug`, `motor-view.pug`, `tool-view.pug`, `io-view.pug`, etc., and surface them through a left-rail navigator inside the Settings panel rather than the sidebar.
|
||||||
|
- Settings → Macros owns the full macro list (1…N). Control's macro row is a slice of the first 8; reordering happens in Settings.
|
||||||
|
|
||||||
|
## 3. Routing model
|
||||||
|
|
||||||
|
We keep the existing URL hash routing because everything in `src/js/app.js#parse_hash` and the deep-linked menu items (`#motor:0`, `#admin-network`, etc.) depend on it.
|
||||||
|
|
||||||
|
| URL hash | Top tab | Notes |
|
||||||
|
|-------------------------|------------|-------------------------------------------------------|
|
||||||
|
| `#control` | Control | Default |
|
||||||
|
| `#program` / `#program:auto` | Program | Auto sub-view (only sub-view for now) |
|
||||||
|
| `#console` / `#console:mdi` | Console | MDI default, also `:messages` and `:indicators` |
|
||||||
|
| `#settings` | Settings | Settings home (Display & Units) |
|
||||||
|
| `#admin-general`, `#admin-network`, `#motor:N`, `#tool`, `#io`, `#help`, `#cheat-sheet` | Settings | Existing routes remain, surfaced in the Settings left rail |
|
||||||
|
|
||||||
|
The header tab bar maps URL prefix → active tab. A tiny helper `topTabFromHash(hash)` lives in `app.js` and is reused by the header template.
|
||||||
|
|
||||||
|
## 4. Step-by-step
|
||||||
|
|
||||||
|
### Phase 1 — Mock parity (1–2 days)
|
||||||
|
1. Add `docs/mocks/v09_full_ux.html` (done) so anyone can preview the target.
|
||||||
|
2. Move the V09 palette into Stylus tokens at the top of `style.styl`:
|
||||||
|
```styl
|
||||||
|
$jog-bg = #3f4b63
|
||||||
|
$jog-hover = #4a5777
|
||||||
|
$jog-dir = #5b6885
|
||||||
|
$jog-ghost = #8c97ad
|
||||||
|
$accent = #fde047
|
||||||
|
$accent-ink = #0f172a
|
||||||
|
```
|
||||||
|
3. Build the header in `index.pug`:
|
||||||
|
```pug
|
||||||
|
.app-shell
|
||||||
|
header.head
|
||||||
|
.brand-blk
|
||||||
|
.brand-logo
|
||||||
|
.brand-name ONEFINITY
|
||||||
|
nav.tabs-host(role="tablist")
|
||||||
|
a.ktab(:class="{active: topTab === 'control'}", href="#control")
|
||||||
|
.fa.fa-gamepad
|
||||||
|
| Control
|
||||||
|
a.ktab(:class="{active: topTab === 'program'}", href="#program") …
|
||||||
|
a.ktab(:class="{active: topTab === 'console'}", href="#console") …
|
||||||
|
a.ktab(:class="{active: topTab === 'settings'}", href="#settings") …
|
||||||
|
button.sys-btn(@click="toggle_sys_popover") …
|
||||||
|
span.state-badge(:class="state_class")
|
||||||
|
estop(@click="estop")
|
||||||
|
```
|
||||||
|
4. Style the header tabs as **underline ribbon** (V02): transparent fills, slate-gray text, dark text + 5 px yellow underline on active. CSS already proven in the mock.
|
||||||
|
5. Move the rotary toggle and pi-temp warning into the system pill popover.
|
||||||
|
|
||||||
|
### Phase 2 — Control panel (2 days)
|
||||||
|
1. Rewrite the outer markup of `control-view.pug` to a CSS grid:
|
||||||
|
```
|
||||||
|
.control-grid → 720px jog-card | 1fr right-col(dro-card + status-strip)
|
||||||
|
```
|
||||||
|
Drop the `<table>`-based outer layout (axes table stays — it's a real data table).
|
||||||
|
2. Replace the legacy `<button>` elements in the jog table with `.jbtn` markup that pulls colors from `$jog-*` tokens. Keep the `@click="jog_fn(...)"` bindings unchanged.
|
||||||
|
3. Build the new `.step-seg` with the existing `jog_incr` model. The four buttons stay wired to `jog_incr = 'fine' | 'small' | 'medium' | 'large'`.
|
||||||
|
4. Build `.dro-card` from the existing `table.axes` markup. Each row gets the new 7-column grid; axis cells just need `.dro-axis`, `.dro-pos`, `.dro-sec` classes.
|
||||||
|
5. Move the four KPI tiles (`State / Velocity-Feed / Spindle / Job`) into `.status-strip`. Existing `state.v`, `state.feed`, `state.s`, `state.line` bindings are unchanged.
|
||||||
|
6. Move `.macros-div` into a `.macro-row` 8-column grid. The row binds to `config.macros.slice(0, 8)`; macros 9…N are editable and runnable only from Settings → Macros (no drawer in Control). Reordering in Settings changes which macros appear in the visible 8.
|
||||||
|
7. Drop the legacy `.tabs / #tab1 …` block from `control-view.pug` entirely.
|
||||||
|
|
||||||
|
### Phase 3 — Program panel (1.5 days)
|
||||||
|
1. New file `src/pug/templates/program-view.pug` with `.program-card` and the action / file bars.
|
||||||
|
2. Move the Auto bar (RUN, STOP, UPLOAD FOLDER, UPLOAD FILE, DOWNLOAD FILE, DELETE) and the file-select strip (Create Folder, Delete Folder, folder picker, file picker, sort) out of `control-view.pug` into here. Use the V09 button styles (`.action-btn`, `.action-btn.run`, `.action-btn.danger`, `.file-btn`, `.file-select`).
|
||||||
|
3. Embed `path-viewer` and `gcode-viewer` in `.program-body { 1fr 600px }`. Both Vue components render unchanged.
|
||||||
|
4. New `src/js/program-view.js` exporting the same data model the existing `Auto` tab uses (`gcode_files`, `state.selected`, `start_pause`, etc.). The fastest path: move the relevant computed/methods into a mixin `gcode-program-mixin.js` consumed by both old and new components during the migration.
|
||||||
|
5. Wire `<component :is="currentView + '-view'">` in `index.pug` to pick up `program-view`.
|
||||||
|
|
||||||
|
### Phase 4 — Console panel (1 day)
|
||||||
|
1. New `src/pug/templates/console-view.pug` with the inner `.ptab-bar` (MDI / Messages / Indicators) and `data-sub` panels.
|
||||||
|
2. The MDI panel reuses the existing `<input v-model="mdi" @keyup.enter="submit_mdi">` plus the on-screen keypad (G0/G1/G2/G3/G28/G92/M3/M5 + axis letters + CLEAR/SEND).
|
||||||
|
3. The Messages panel pulls from the existing `popupMessages` array + a new `messages_log` state we will accumulate from `app.js`'s `error` and `popupMessages` channels (no protocol change).
|
||||||
|
4. The Indicators panel mounts the existing `<indicators :state="state" :template="template">` component.
|
||||||
|
5. Sub-tab state is local Vue state (`activeSub: 'mdi' | 'messages' | 'indicators'`) plus URL fragment after `:` so deep links keep working.
|
||||||
|
|
||||||
|
### Phase 5 — Settings panel (1 day)
|
||||||
|
1. New `src/pug/templates/settings-view.pug` with a left rail and a content slot.
|
||||||
|
2. The left rail is data-driven from a list of existing settings views: General, Network, Motion (settings-view), Spindle (tool-view), Safety (admin-general subset), Camera, Macros (settings-view subset), I/O, Motors, Help, About.
|
||||||
|
3. The content slot uses `<component :is="settingsSub + '-view'">` so each existing pug template renders unchanged (`admin-general-view.pug`, `admin-network-view.pug`, `motor-view.pug`, `tool-view.pug`, `io-view.pug`, `settings-view.pug`, `help-view.pug`, `cheat-sheet-view.pug`).
|
||||||
|
4. Existing routes (`#admin-network`, `#motor:0`, …) resolve to Settings + the matching left-rail item. We lose nothing.
|
||||||
|
5. Decommission the side menu in `index.pug` and stop including `side-menu.css`.
|
||||||
|
|
||||||
|
### Phase 6 — Polish & rollout (0.5 days)
|
||||||
|
1. Pulse-dot animation for the READY badge (CSS keyframes already in the mock).
|
||||||
|
2. System pill popover content: WiFi state + button, Camera state + retry, Rotary toggle, IP address, firmware version, "Open Settings".
|
||||||
|
3. Disabled states: jog buttons + macro buttons honor `is_ready` like before; gray them out instead of hiding.
|
||||||
|
4. Decimal-places setting from the existing `display_units` plumbing — wire to a new `precision` config the DRO reads.
|
||||||
|
5. Build the **Spindle override drawer**: clicking the `.stat-card` for Spindle toggles `.override-drawer.open` anchored to the bottom edge of the body. The drawer hosts the two existing `<input type="range">` controls for `feed_override` and `speed_override` plus `Reset` buttons. Bind to the existing `override_feed` / `override_speed` methods.
|
||||||
|
6. **Hard cut cleanup:** delete the legacy `.nav-header`, side-menu markup, and the inline `.tabs / #tab1…#tab4` block from `control-view.pug`. Remove `src/static/css/side-menu.css` from `index.pug` includes. Sweep `style.styl` for orphan rules (`.nav-header`, `.brand`, `.menu-link`, `.pure-menu*` overrides, `.tabs > input` selectors) and delete them in the same commit so we don't ship dead CSS.
|
||||||
|
|
||||||
|
## 5. Migration risks & mitigations
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|----------------------------------------------|---------------------------------------------------------------------------------------------|
|
||||||
|
| Existing deep links from PDFs / forum posts (`#admin-network`) break | Keep the same hashes; only their visual shell changes. `parse_hash` resolves them. |
|
||||||
|
| Vue 1.x doesn't support modern slot syntax we used in the mock | The mock is plain HTML for visual review; production code uses the existing Vue 1 patterns. No new Vue features required. |
|
||||||
|
| Touch monitor with HDMI vs USB-C may report different DPI | The new layout is fluid inside 1920 × 1080 only when fullscreen Chrome. Provide a CSS `@media (max-width: 1820px)` fallback that scales the macro row to 4 columns and stacks the right column under the jog. |
|
||||||
|
| Existing customers rely on muscle memory of the side menu | Settings tab opens directly to the same left-rail navigator. First-launch toast: "Side menu moved to Settings." |
|
||||||
|
| `path-viewer` / `gcode-viewer` are heavy three.js components | They live in the Program tab now; we lazy-mount with `v-if="currentView === 'program'"` so Control stays light. |
|
||||||
|
| MDI input could lose focus when the inner `.ptab` is switched | Keep the input mounted, just hide non-active subs with `display:none`. |
|
||||||
|
|
||||||
|
## 6. Testing checklist
|
||||||
|
|
||||||
|
- Chrome on the 10.8" 1920 × 1080 monitor, fullscreen — every panel fits without scrolling at 100 %.
|
||||||
|
- Chrome at 1366 × 768 — fallback layout works (Control collapses jog above DRO).
|
||||||
|
- Touch hit-tests: every interactive target ≥ 48 px on its shortest side, primary jog tiles ≥ 72 px.
|
||||||
|
- Existing flows still work end-to-end: home all axes, run a small program, MDI a `G0 X10`, switch to Imperial, upload a folder, delete a file.
|
||||||
|
- Hash routing: hand-type `#motor:1` and confirm Settings tab activates with Motor 1 selected.
|
||||||
|
- Spindle override drawer: tap Spindle KPI tile, sliders move feed/speed override, `Reset` returns both to 100 %, tile tap closes drawer.
|
||||||
|
- Macro row shows macros 1–8 only; reordering in Settings → Macros changes which 8 appear on Control.
|
||||||
|
- Pulse-dot animation respects `prefers-reduced-motion`.
|
||||||
|
- Hard-cut cleanup verified: `git grep` finds no references to the old `.nav-header`, `side-menu.css`, or the `#tab1…#tab4` selectors after the rename.
|
||||||
|
|
||||||
|
## 7. Estimated effort
|
||||||
|
|
||||||
|
About 6–7 working days for one developer:
|
||||||
|
|
||||||
|
1. Mock parity & header — 1.5 days
|
||||||
|
2. Control panel (incl. macro slice + DRO grid) — 2 days
|
||||||
|
3. Program panel — 1.5 days
|
||||||
|
4. Console panel — 1 day
|
||||||
|
5. Settings shell — 1 day
|
||||||
|
6. Override drawer, polish, hard-cut cleanup, regression tests — 0.5–1 day
|
||||||
|
|
||||||
|
## 8. Resolved decisions
|
||||||
|
|
||||||
|
- **Rollout: hard cut.** No `config.ui.layout` feature flag, no parallel legacy shell. The new `index.pug` tree replaces the old one in a single release; the old `.nav-header`, side menu, and embedded `.tabs` block are deleted (not gated). One pre-release internal QA pass on real hardware before tagging.
|
||||||
|
- **Macros above 8: Settings owns the master list; Control surfaces the first 8 (configurable).** The Control macro row reads from `config.macros[0..7]`; everything beyond index 7 is editable / runnable only from Settings → Macros. Users can reorder which macros land in the visible 8 there.
|
||||||
|
- **"Pin to Control" indicator slot: defer.** Not in this redesign. Tracked as a follow-up; current status strip stays fixed at State / Velocity·Feed / Spindle / Job.
|
||||||
|
- **Feed & spindle override: drawer triggered by the Spindle KPI tile.** The Spindle card in the status strip becomes tappable. Tap opens a bottom-edge drawer (≈ 220 px tall) containing the two existing range inputs (`feed_override`, `speed_override`) at touch-friendly size with `Reset to 100 %` buttons. Closes by tapping the tile again or the drawer chevron. No protocol change; reuses the existing `override_feed` / `override_speed` handlers.
|
||||||
2
setup.py
2
setup.py
@@ -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': [
|
||||||
|
|||||||
212
src/js/app.js
212
src/js/app.js
@@ -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() {
|
||||||
|
|||||||
@@ -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
125
src/js/console-view.js
Normal 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];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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")],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
554
src/js/program-mixin.js
Normal 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
60
src/js/program-view.js
Normal 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")],
|
||||||
|
};
|
||||||
109
src/js/settings-shell-view.js
Normal file
109
src/js/settings-shell-view.js
Normal 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");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
| Shutdown
|
||||||
|
button.sp-save(:disabled="!modified", @click="save")
|
||||||
|
.fa.fa-save
|
||||||
|
| 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")
|
||||||
|
|||||||
67
src/pug/templates/console-view.pug
Normal file
67
src/pug/templates/console-view.pug
Normal 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
|
||||||
|
| MDI
|
||||||
|
button.ptab(:class="{active: sub === 'messages'}", @click="select_sub('messages')")
|
||||||
|
.fa.fa-comment-dots
|
||||||
|
| Messages
|
||||||
|
span.ptab-badge(v-if="unread_messages") {{unread_messages}}
|
||||||
|
button.ptab(:class="{active: sub === 'indicators'}", @click="select_sub('indicators')")
|
||||||
|
.fa.fa-bell
|
||||||
|
| Indicators
|
||||||
|
|
||||||
|
// ----- MDI -----
|
||||||
|
.mdi-pane(v-show="sub === 'mdi'")
|
||||||
|
.mdi-input
|
||||||
|
span.prompt G>
|
||||||
|
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
|
||||||
|
| 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
|
||||||
|
| 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")
|
||||||
@@ -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}}
|
| {{#{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}}
|
| {{#{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")
|
||||||
|
| {{w.state}}
|
||||||
|
.dro-toolpath
|
||||||
|
span.chip.chip-green
|
||||||
|
.fa(:class="'fa-' + w.ticon")
|
||||||
|
| {{w.tstate}}
|
||||||
|
.actions-cell
|
||||||
|
button.icon-btn(disabled, style="visibility:hidden")
|
||||||
|
.fa.fa-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
|
| ·
|
||||||
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
|
| 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)") ({{state.s | fixed 0}})
|
|
||||||
= ' RPM'
|
|
||||||
|
|
||||||
tr(title="Load switch states.")
|
|
||||||
th Loads
|
|
||||||
td
|
|
||||||
span(:class="state['1oa'] ? 'load-on' : ''")
|
|
||||||
| 1:{{state['1oa'] ? 'On' : 'Off'}}
|
|
||||||
|
|
|
||||||
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")
|
|
||||||
| 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
|
|
||||||
//- | All
|
|
||||||
button.pure-button.button-success(@click="delete_current",style="height:50px")
|
|
||||||
.fa.fa-trash
|
|
||||||
| 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}}
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
137
src/pug/templates/program-view.pug
Normal file
137
src/pug/templates/program-view.pug
Normal 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
|
||||||
|
| 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
|
||||||
|
| Create Folder
|
||||||
|
button.file-btn(@click="confirmDelete=true", :disabled="!is_ready")
|
||||||
|
.fa.fa-folder-minus
|
||||||
|
| 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
|
||||||
|
| {{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}}
|
||||||
44
src/pug/templates/settings-shell.pug
Normal file
44
src/pug/templates/settings-shell.pug
Normal 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")
|
||||||
|
| {{item.label}}
|
||||||
|
.set-rail-foot
|
||||||
|
button.sp-shutdown(@click="showShutdownDialog")
|
||||||
|
.fa.fa-power-off
|
||||||
|
| Shutdown
|
||||||
|
button.sp-save(:disabled="!$root.modified", @click="$root.save()")
|
||||||
|
.fa.fa-save
|
||||||
|
| 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
477
src/py/bbctrl/AuxAxis.py
Normal 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
|
||||||
237
src/py/bbctrl/AuxPreprocessor.py
Normal file
237
src/py/bbctrl/AuxPreprocessor.py
Normal 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
|
||||||
@@ -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, '
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
429
src/py/bbctrl/Hooks.py
Normal 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'))
|
||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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'}),
|
||||||
|
|||||||
@@ -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
@@ -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`} />
|
||||||
|
|||||||
262
src/svelte-components/src/components/WAxisSettings.svelte
Normal file
262
src/svelte-components/src/components/WAxisSettings.svelte
Normal 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>
|
||||||
Reference in New Issue
Block a user