Compare commits
64 Commits
6185c65afa
...
pre-split-
| Author | SHA1 | Date | |
|---|---|---|---|
| 4470fcee0a | |||
| 39e308d9ae | |||
| 8e6e72a8b9 | |||
| 226b44053c | |||
| 0493a4ddc7 | |||
| c4c20c6d0a | |||
| 4a494a101d | |||
| ca7e30aa05 | |||
| 983e06b53d | |||
| 53b65dc30e | |||
| f545438fa8 | |||
| 3b622d3d17 | |||
| aa747dcc85 | |||
| 56c3406f25 | |||
| 7cdab010b3 | |||
| 53f26b0be8 | |||
| 3614a2bcd4 | |||
| 06f0e6517e | |||
| 1a6f926181 | |||
| b0712a5bf0 | |||
| 1c69c0a157 | |||
|
|
748f092795 | ||
|
|
cfc14643d2 | ||
|
|
b0f38619ba | ||
| 549b69c234 | |||
|
|
5376d23f8b | ||
|
|
ecf3191fcc | ||
|
|
3baa67360c | ||
|
|
b7bd7a1c9c | ||
|
|
6fe2e79bff | ||
|
|
19e6cc6c93 | ||
|
|
50839718e2 | ||
| 68a92bb297 | |||
|
|
41d720c1d0 | ||
| 3d73e6c59d | |||
| 860ca30aba | |||
| 8e3b7a29e5 | |||
| 420caf52be | |||
| 561d2fd7ea | |||
| 73c6a4f160 | |||
| 5926316a25 | |||
| ea23f94b87 | |||
| b8c4f53bb1 | |||
| 32f3aca368 | |||
| 081209decf | |||
| ef4658aaf6 | |||
| ef78f20eaa | |||
| 36829020a5 | |||
| 2413fc49ab | |||
|
|
7d5949f5fc | ||
|
|
23f22105a8 | ||
|
|
4f74e75d44 | ||
|
|
c7cf9483b3 | ||
| 54a15f9d12 | |||
| 704bc8d35c | |||
| 4d2d5fd88c | |||
| eab204b7be | |||
| e3c059eb9b | |||
| 7306464440 | |||
| 1625b768d8 | |||
| 5be7515a92 | |||
| b10a6d537e | |||
| 7d0755c55b | |||
| 7f8fd23615 |
0
.devcontainer/install_tools.sh
Normal file → Executable file
0
.devcontainer/install_tools.sh
Normal file → Executable file
16
.gitignore
vendored
16
.gitignore
vendored
@@ -27,3 +27,19 @@ __pycache__
|
||||
*.elf
|
||||
*.hex
|
||||
.idea/deployment.xml
|
||||
backup/*.img.gz
|
||||
backup/*.partial
|
||||
|
||||
# Demo mode artifacts
|
||||
bbctrl.log*
|
||||
hooks.json
|
||||
/*/bbctrl.log*
|
||||
src/py/camotics/__init__.py
|
||||
src/py/camotics/gplan.so
|
||||
src/avr/emu/bbemu
|
||||
src/avr/emu/build/
|
||||
|
||||
.pi/pi-python35.tar.gz
|
||||
src/py/camotics/gplan.so.built
|
||||
tmp/
|
||||
backup/
|
||||
|
||||
249
.pi/BUILD.md
Normal file
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"
|
||||
6
Makefile
6
Makefile
@@ -68,7 +68,11 @@ update: pkg
|
||||
|
||||
build/templates.pug: $(TEMPLS)
|
||||
mkdir -p build
|
||||
cat $(TEMPLS) >$@
|
||||
# Use awk to ensure each template is followed by a newline so the
|
||||
# next file's first line never gets glued onto the previous file's
|
||||
# last line (some templates ship without a trailing newline, which
|
||||
# would produce subtle Pug parse failures).
|
||||
awk 'FNR==1 && NR>1 {print ""} {print} END{print ""}' $(TEMPLS) >$@
|
||||
|
||||
node_modules: package.json
|
||||
npm install && touch node_modules
|
||||
|
||||
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, …)
|
||||
|
||||
278
backup/onefinity-backup.sh
Executable file
278
backup/onefinity-backup.sh
Executable file
@@ -0,0 +1,278 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Onefinity CNC Controller - SD Card Backup & Restore
|
||||
#
|
||||
# Backs up the Raspberry Pi's SD card over SSH as a compressed image.
|
||||
# Compression runs on the local machine (fast), raw bytes stream from the Pi.
|
||||
#
|
||||
# Usage:
|
||||
# ./onefinity-backup.sh backup # backup with defaults
|
||||
# ./onefinity-backup.sh backup -o myfile.gz # custom output file
|
||||
# ./onefinity-backup.sh restore image.gz # restore to SD card
|
||||
# ./onefinity-backup.sh verify image.gz # verify image integrity
|
||||
#
|
||||
# Environment:
|
||||
# ONEFINITY_HOST - Pi IP/hostname (default: 10.1.10.55)
|
||||
# ONEFINITY_USER - SSH user (default: bbmc)
|
||||
# ONEFINITY_PASS - sudo password (default: onefinity)
|
||||
|
||||
HOST="${ONEFINITY_HOST:-10.1.10.55}"
|
||||
USER="${ONEFINITY_USER:-bbmc}"
|
||||
PASS="${ONEFINITY_PASS:-onefinity}"
|
||||
BACKUP_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
DEVICE="/dev/mmcblk0"
|
||||
|
||||
ssh_cmd() {
|
||||
ssh -o ConnectTimeout=10 -o StrictHostKeyChecking=no -o LogLevel=ERROR "$USER@$HOST" "$@"
|
||||
}
|
||||
|
||||
sudo_ssh() {
|
||||
ssh_cmd "echo '$PASS' | sudo -S bash -c '$1' 2>/dev/null"
|
||||
}
|
||||
|
||||
die() { echo "ERROR: $*" >&2; exit 1; }
|
||||
|
||||
# ── Backup ──────────────────────────────────────────────────────────────────
|
||||
|
||||
do_backup() {
|
||||
local outfile=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-o|--output) outfile="$2"; shift 2 ;;
|
||||
*) die "Unknown option: $1" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$outfile" ]]; then
|
||||
outfile="$BACKUP_DIR/onefinity-$(date +%Y%m%d-%H%M).img.gz"
|
||||
fi
|
||||
|
||||
echo "╔══════════════════════════════════════════════════════╗"
|
||||
echo "║ Onefinity CNC Controller - SD Card Backup ║"
|
||||
echo "╚══════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
echo " Host: $USER@$HOST"
|
||||
echo " Device: $DEVICE"
|
||||
echo " Output: $outfile"
|
||||
echo ""
|
||||
|
||||
# Check connectivity
|
||||
echo "→ Checking SSH connection..."
|
||||
ssh_cmd 'hostname' >/dev/null 2>&1 || die "Cannot SSH to $USER@$HOST"
|
||||
|
||||
# Get card size
|
||||
local card_bytes
|
||||
card_bytes=$(sudo_ssh "blockdev --getsize64 $DEVICE")
|
||||
local card_gb=$(echo "scale=1; $card_bytes / 1073741824" | bc)
|
||||
echo " SD card: ${card_gb}GB ($card_bytes bytes)"
|
||||
echo ""
|
||||
|
||||
# Check for enough local disk space (compressed is ~4% of raw)
|
||||
local avail_bytes
|
||||
avail_bytes=$(df -P "$(dirname "$outfile")" | tail -1 | awk '{print $4 * 1024}')
|
||||
local need_bytes=$((card_bytes / 10)) # conservative: assume 10% compressed
|
||||
if (( avail_bytes < need_bytes )); then
|
||||
die "Not enough local disk space. Need ~$(echo "scale=1; $need_bytes/1073741824" | bc)GB, have $(echo "scale=1; $avail_bytes/1073741824" | bc)GB"
|
||||
fi
|
||||
|
||||
# Stream raw dd from Pi, compress locally with gzip
|
||||
# The Pi's SD card reads at ~20MB/s which is the bottleneck.
|
||||
# Compressing locally on a fast machine is much better than on the ARM.
|
||||
echo "→ Streaming SD card image (this takes ~20-50 minutes)..."
|
||||
echo " Pi: dd → SSH → local gzip → $outfile"
|
||||
echo ""
|
||||
|
||||
local start_time=$SECONDS
|
||||
local tmpfile="${outfile}.partial"
|
||||
|
||||
ssh_cmd "echo '$PASS' | sudo -S dd if=$DEVICE bs=4M 2>/dev/null" 2>/dev/null \
|
||||
| gzip -1 > "$tmpfile" &
|
||||
local pid=$!
|
||||
|
||||
# Progress monitor
|
||||
while kill -0 $pid 2>/dev/null; do
|
||||
sleep 15
|
||||
if [[ -f "$tmpfile" ]]; then
|
||||
local size_h
|
||||
size_h=$(ls -lh "$tmpfile" 2>/dev/null | awk '{print $5}')
|
||||
local elapsed=$(( SECONDS - start_time ))
|
||||
local min=$(( elapsed / 60 ))
|
||||
local sec=$(( elapsed % 60 ))
|
||||
printf "\r %dm%02ds elapsed — %s compressed" "$min" "$sec" "$size_h"
|
||||
fi
|
||||
done
|
||||
|
||||
wait $pid
|
||||
local exit_code=$?
|
||||
echo ""
|
||||
|
||||
if [[ $exit_code -ne 0 ]]; then
|
||||
rm -f "$tmpfile"
|
||||
die "Backup failed (exit code $exit_code)"
|
||||
fi
|
||||
|
||||
mv "$tmpfile" "$outfile"
|
||||
|
||||
local elapsed=$(( SECONDS - start_time ))
|
||||
local final_size
|
||||
final_size=$(ls -lh "$outfile" | awk '{print $5}')
|
||||
|
||||
echo ""
|
||||
echo "→ Verifying image integrity..."
|
||||
if gzip -t "$outfile" 2>/dev/null; then
|
||||
echo " ✓ gzip integrity OK"
|
||||
else
|
||||
die "Image file is corrupt!"
|
||||
fi
|
||||
|
||||
# Verify full size by counting decompressed bytes
|
||||
local actual_bytes
|
||||
actual_bytes=$(gzip -d -c "$outfile" | wc -c | tr -d ' ')
|
||||
if [[ "$actual_bytes" -eq "$card_bytes" ]]; then
|
||||
echo " ✓ Size matches: $actual_bytes bytes (full ${card_gb}GB card)"
|
||||
else
|
||||
echo " ⚠ Size mismatch: expected $card_bytes, got $actual_bytes"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "╔══════════════════════════════════════════════════════╗"
|
||||
echo " ✓ Backup complete"
|
||||
echo " File: $outfile"
|
||||
echo " Size: $final_size compressed (${card_gb}GB raw)"
|
||||
echo " Time: $(( elapsed / 60 ))m $(( elapsed % 60 ))s"
|
||||
echo "╚══════════════════════════════════════════════════════╝"
|
||||
}
|
||||
|
||||
# ── Restore ─────────────────────────────────────────────────────────────────
|
||||
|
||||
do_restore() {
|
||||
local imgfile="$1"
|
||||
local target="${2:-}"
|
||||
|
||||
[[ -f "$imgfile" ]] || die "Image file not found: $imgfile"
|
||||
|
||||
echo "╔══════════════════════════════════════════════════════╗"
|
||||
echo "║ Onefinity CNC Controller - SD Card Restore ║"
|
||||
echo "╚══════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
|
||||
if [[ -n "$target" ]]; then
|
||||
# ── Local restore: write to a local SD card device ──
|
||||
[[ -b "$target" ]] || die "$target is not a block device"
|
||||
|
||||
local target_bytes
|
||||
target_bytes=$(diskutil info -plist "$target" 2>/dev/null \
|
||||
| plutil -extract TotalSize raw - 2>/dev/null \
|
||||
|| blockdev --getsize64 "$target" 2>/dev/null \
|
||||
|| echo 0)
|
||||
|
||||
echo " Image: $imgfile"
|
||||
echo " Target: $target ($(echo "scale=1; $target_bytes/1073741824" | bc)GB)"
|
||||
echo ""
|
||||
echo " ⚠ THIS WILL ERASE ALL DATA ON $target"
|
||||
echo ""
|
||||
read -rp " Type YES to continue: " confirm
|
||||
[[ "$confirm" == "YES" ]] || die "Aborted"
|
||||
|
||||
echo ""
|
||||
echo "→ Unmounting target..."
|
||||
diskutil unmountDisk "$target" 2>/dev/null || true
|
||||
|
||||
echo "→ Writing image to $target..."
|
||||
local raw_target
|
||||
raw_target=$(echo "$target" | sed 's|/dev/disk|/dev/rdisk|')
|
||||
gzip -d -c "$imgfile" | sudo dd of="$raw_target" bs=4M status=progress
|
||||
sync
|
||||
|
||||
echo ""
|
||||
echo " ✓ Restore complete. Safe to eject $target."
|
||||
|
||||
else
|
||||
# ── Remote restore: write back to Pi over SSH ──
|
||||
echo " Image: $imgfile"
|
||||
echo " Target: $USER@$HOST:$DEVICE"
|
||||
echo ""
|
||||
echo " ⚠ THIS WILL ERASE THE PI'S SD CARD"
|
||||
echo " ⚠ The Pi must be booted from USB/network, not the SD card"
|
||||
echo ""
|
||||
read -rp " Type YES to continue: " confirm
|
||||
[[ "$confirm" == "YES" ]] || die "Aborted"
|
||||
|
||||
echo ""
|
||||
echo "→ Writing image to $HOST:$DEVICE..."
|
||||
gzip -d -c "$imgfile" \
|
||||
| ssh_cmd "echo '$PASS' | sudo -S dd of=$DEVICE bs=4M 2>/dev/null"
|
||||
|
||||
echo ""
|
||||
echo " ✓ Remote restore complete."
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Verify ──────────────────────────────────────────────────────────────────
|
||||
|
||||
do_verify() {
|
||||
local imgfile="$1"
|
||||
[[ -f "$imgfile" ]] || die "Image file not found: $imgfile"
|
||||
|
||||
echo "Verifying: $imgfile"
|
||||
echo ""
|
||||
|
||||
local compressed_size
|
||||
compressed_size=$(ls -lh "$imgfile" | awk '{print $5}')
|
||||
echo " Compressed size: $compressed_size"
|
||||
|
||||
echo " Checking gzip integrity..."
|
||||
if gzip -t "$imgfile" 2>/dev/null; then
|
||||
echo " ✓ gzip OK"
|
||||
else
|
||||
die "gzip integrity check FAILED"
|
||||
fi
|
||||
|
||||
echo " Counting uncompressed bytes..."
|
||||
local raw_bytes
|
||||
raw_bytes=$(gzip -d -c "$imgfile" | wc -c | tr -d ' ')
|
||||
local raw_gb=$(echo "scale=1; $raw_bytes / 1073741824" | bc)
|
||||
echo " ✓ Uncompressed size: ${raw_gb}GB ($raw_bytes bytes)"
|
||||
|
||||
echo " Checking partition table..."
|
||||
gzip -d -c "$imgfile" 2>/dev/null | head -c 512 | xxd | head -4 || true
|
||||
|
||||
echo ""
|
||||
echo " ✓ Image looks valid"
|
||||
}
|
||||
|
||||
# ── Main ────────────────────────────────────────────────────────────────────
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $(basename "$0") <command> [options]
|
||||
|
||||
Commands:
|
||||
backup [-o file.img.gz] Backup SD card from Pi over SSH
|
||||
restore <image.gz> [/dev/diskN] Restore image to local SD card or remote Pi
|
||||
verify <image.gz> Verify image integrity
|
||||
|
||||
Environment variables:
|
||||
ONEFINITY_HOST Pi address (default: 10.1.10.55)
|
||||
ONEFINITY_USER SSH user (default: bbmc)
|
||||
ONEFINITY_PASS sudo password (default: onefinity)
|
||||
|
||||
Examples:
|
||||
$(basename "$0") backup
|
||||
$(basename "$0") backup -o /tmp/mybackup.img.gz
|
||||
$(basename "$0") restore backup/onefinity-20260430.img.gz /dev/disk4
|
||||
$(basename "$0") verify backup/onefinity-20260430.img.gz
|
||||
EOF
|
||||
exit 1
|
||||
}
|
||||
|
||||
[[ $# -ge 1 ]] || usage
|
||||
|
||||
case "$1" in
|
||||
backup) shift; do_backup "$@" ;;
|
||||
restore) shift; [[ $# -ge 1 ]] || usage; do_restore "$@" ;;
|
||||
verify) shift; [[ $# -ge 1 ]] || usage; do_verify "$@" ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
4
deploy-hardware.sh
Executable file
4
deploy-hardware.sh
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/bin/bash
|
||||
# Shorthand for ./deploy.sh hardware
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
exec "$SCRIPT_DIR/deploy.sh" hardware "$@"
|
||||
4
deploy-local.sh
Executable file
4
deploy-local.sh
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/bin/bash
|
||||
# Shorthand for ./deploy.sh local
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
exec "$SCRIPT_DIR/deploy.sh" local "$@"
|
||||
4
deploy-prod.sh
Executable file
4
deploy-prod.sh
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/bin/bash
|
||||
# Shorthand for ./deploy.sh prod
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
exec "$SCRIPT_DIR/deploy.sh" prod "$@"
|
||||
52
deploy.sh
Executable file
52
deploy.sh
Executable file
@@ -0,0 +1,52 @@
|
||||
#!/bin/bash
|
||||
# Onefinity firmware deploy script.
|
||||
#
|
||||
# ./deploy.sh local — build & static-serve the UI on macOS
|
||||
# (chrome only; no controller, shows
|
||||
# DISCONNECTED overlay)
|
||||
# ./deploy.sh hardware — fast iteration: rsync build/http/
|
||||
# contents to the running Pi at
|
||||
# onefinity.local, then restart bbctrl
|
||||
# ./deploy.sh prod — full firmware update via the Pi's
|
||||
# /api/firmware/update endpoint
|
||||
# (equivalent to `make update`)
|
||||
#
|
||||
# Notes:
|
||||
# * On macOS we cannot run the Python `bbctrl` controller directly
|
||||
# because it imports the ARM-only camotics gplan.so. For full UI
|
||||
# testing with live data, deploy to the Pi (hardware or prod).
|
||||
# * `prod` requires a clean working tree.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
CMD="${1:-}"
|
||||
|
||||
case "$CMD" in
|
||||
local) exec "$SCRIPT_DIR/scripts/deploy/local.sh" "$@" ;;
|
||||
hardware) exec "$SCRIPT_DIR/scripts/deploy/hardware.sh" "$@" ;;
|
||||
prod) exec "$SCRIPT_DIR/scripts/deploy/prod.sh" "$@" ;;
|
||||
*)
|
||||
cat <<USAGE
|
||||
usage: $0 {local | hardware | prod}
|
||||
|
||||
local Build the UI and static-serve build/http/ in a tmux session
|
||||
on macOS. Useful for iterating on the V09 chrome and routing.
|
||||
URL: http://localhost:8770/
|
||||
tmux: tmux attach -t onefin-local
|
||||
|
||||
hardware Fast iteration on the actual controller: rsync the freshly
|
||||
built build/http/ tree onto onefinity.local, then restart
|
||||
the bbctrl service. Requires SSH access as bbmc@onefinity.local.
|
||||
Defaults: HOST=onefinity.local PASSWORD=onefinity
|
||||
|
||||
prod Build a full firmware package (.tar.bz2) and PUT it through
|
||||
/api/firmware/update on the Pi. Equivalent to:
|
||||
make update HOST=onefinity.local PASSWORD=onefinity
|
||||
Requires a clean working tree.
|
||||
USAGE
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
172
docs/AUX_W_AXIS.md
Normal file
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.
|
||||
@@ -1,13 +1,24 @@
|
||||
[Unit]
|
||||
Description=Buildbotics Controller
|
||||
After=network.target
|
||||
# Note: bbctrl previously had `After=network.target`. That delays
|
||||
# start by ~5s on this Pi while dhcpcd brings up wlan0/eth0, but
|
||||
# bbctrl does not actually require network connectivity to come up
|
||||
# (the AVR is on a local serial port, the LCD on I2C). Dropping it
|
||||
# means the Pi shows the UI faster on cold boot. The wifi config UI
|
||||
# still works because it queries iw/dhcpcd lazily on demand.
|
||||
After=local-fs.target bbserial-rebind.service
|
||||
Wants=bbserial-rebind.service
|
||||
|
||||
[Service]
|
||||
User=root
|
||||
ExecStart=/usr/local/bin/bbctrl -l /var/log/bbctrl.log
|
||||
WorkingDirectory=/var/lib/bbctrl
|
||||
Restart=always
|
||||
StandardOutput=null
|
||||
# StandardOutput was 'null'. Set to 'journal' so TRACE lines emitted by
|
||||
# bbctrl.Trace are visible via `journalctl -u bbctrl`. Bbctrl still
|
||||
# writes its own log via -l above; this only affects stdout/stderr.
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
Nice=-10
|
||||
KillMode=process
|
||||
|
||||
|
||||
21
scripts/bbserial-rebind.service
Normal file
21
scripts/bbserial-rebind.service
Normal file
@@ -0,0 +1,21 @@
|
||||
[Unit]
|
||||
Description=Unbind ttyAMA0 from pl011 and reload bbserial
|
||||
DefaultDependencies=no
|
||||
After=systemd-modules-load.service local-fs.target
|
||||
Before=bbctrl.service
|
||||
ConditionPathExists=/sys/bus/amba/drivers/uart-pl011
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
# Tolerate the device already being bound elsewhere or the module
|
||||
# already being loaded — the goal is the end state (bbserial owns
|
||||
# ttyAMA0), not running the steps.
|
||||
ExecStart=/bin/sh -c '\
|
||||
echo 3f201000.serial > /sys/bus/amba/drivers/uart-pl011/unbind 2>/dev/null || true; \
|
||||
/sbin/modprobe -r bbserial 2>/dev/null || true; \
|
||||
/sbin/modprobe bbserial \
|
||||
'
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
84
scripts/deploy/hardware.sh
Executable file
84
scripts/deploy/hardware.sh
Executable file
@@ -0,0 +1,84 @@
|
||||
#!/bin/bash
|
||||
# --- Hardware iteration (live Pi at onefinity.local) ---
|
||||
#
|
||||
# Rsyncs the freshly built static UI tree (build/http/) onto the Pi's
|
||||
# bbctrl egg directory and restarts bbctrl. This is much faster than
|
||||
# a full firmware update and is the fastest way to iterate on the V09
|
||||
# UI changes against real machine state (W axis, jog feedback, etc).
|
||||
#
|
||||
# Defaults:
|
||||
# HOST=onefinity.local
|
||||
# REMOTE_USER=bbmc
|
||||
# PASSWORD=onefinity (used for sudo on the Pi)
|
||||
#
|
||||
# Override:
|
||||
# HOST=10.1.10.55 ./deploy.sh hardware
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
HOST="${HOST:-onefinity.local}"
|
||||
REMOTE_USER="${REMOTE_USER:-bbmc}"
|
||||
PASSWORD="${PASSWORD:-onefinity}"
|
||||
|
||||
echo "Building UI bundle (HTML + resources)..."
|
||||
make build/http/index.html >/dev/null
|
||||
# Copy src/resources/* into build/http/. The Makefile's "all" target
|
||||
# also does this, but pulls in cross-compiled subprojects (avr/boot/
|
||||
# pwr/jig) we don't have toolchains for on macOS. This rsync mirrors
|
||||
# only the resource tree.
|
||||
rsync -a src/resources/ build/http/
|
||||
|
||||
echo "Locating bbctrl http/ directory on $HOST..."
|
||||
REMOTE_HTTP_DIR="$(ssh -o ConnectTimeout=5 "${REMOTE_USER}@${HOST}" \
|
||||
"ls -d /usr/local/lib/python*/dist-packages/bbctrl-*-py*.egg/bbctrl/http 2>/dev/null | head -1")"
|
||||
if [[ -z "$REMOTE_HTTP_DIR" ]]; then
|
||||
echo "ERROR: could not find bbctrl http/ directory on $HOST"
|
||||
exit 1
|
||||
fi
|
||||
echo " $REMOTE_HTTP_DIR"
|
||||
|
||||
echo "Rsyncing build/http/ -> $HOST:$REMOTE_HTTP_DIR/"
|
||||
# Stage to a tmp dir owned by $REMOTE_USER, then sudo-rsync into
|
||||
# place. This avoids needing root over rsync. We do NOT use --delete
|
||||
# anywhere -- the Pi's egg ships extra runtime files (config-template
|
||||
# .json, default machine JSON, buildbotics.nc, etc.) that come with
|
||||
# the bbctrl package and are not in this repo's src/resources. If
|
||||
# they were deleted the controller's API would 500 because Python
|
||||
# imports fail.
|
||||
REMOTE_TMP="/tmp/onefin_ui_$$"
|
||||
ssh -o ConnectTimeout=5 "${REMOTE_USER}@${HOST}" "mkdir -p '${REMOTE_TMP}'"
|
||||
rsync -avz \
|
||||
--exclude='hostinfo.txt' \
|
||||
-e "ssh -o ConnectTimeout=5" \
|
||||
build/http/ "${REMOTE_USER}@${HOST}:${REMOTE_TMP}/"
|
||||
|
||||
echo "Installing into ${REMOTE_HTTP_DIR}/ (sudo)..."
|
||||
ssh -o ConnectTimeout=5 "${REMOTE_USER}@${HOST}" \
|
||||
"echo '${PASSWORD}' | sudo -S bash -c '
|
||||
rsync -a --exclude=hostinfo.txt \"${REMOTE_TMP}/\" \"${REMOTE_HTTP_DIR}/\" \
|
||||
&& rm -rf \"${REMOTE_TMP}\"
|
||||
'" 2>&1 | tail -3
|
||||
|
||||
# Patch bbctrl Web.py so font files get the correct MIME type. The
|
||||
# Pi ships Python 3.5, whose `mimetypes` module doesn't know about
|
||||
# woff/woff2/ttf, so Tornado serves them as application/octet-stream
|
||||
# which Chromium 72 (the Pi's onboard browser) refuses to use as a
|
||||
# web font, leading to all FontAwesome icons rendering as empty
|
||||
# boxes in the kiosk UI. The patch is idempotent.
|
||||
echo "Patching bbctrl font MIME types (idempotent)..."
|
||||
scp -o ConnectTimeout=5 "$SCRIPT_DIR/scripts/deploy/patch_font_mime.py" \
|
||||
"${REMOTE_USER}@${HOST}:/tmp/patch_font_mime.py" >/dev/null
|
||||
ssh -o ConnectTimeout=5 "${REMOTE_USER}@${HOST}" \
|
||||
"echo '${PASSWORD}' | sudo -S python3 /tmp/patch_font_mime.py" 2>&1 | tail -3
|
||||
|
||||
echo "Restarting bbctrl service..."
|
||||
ssh -o ConnectTimeout=5 "${REMOTE_USER}@${HOST}" \
|
||||
"echo '${PASSWORD}' | sudo -S systemctl restart bbctrl" 2>&1 | tail -3
|
||||
|
||||
echo ""
|
||||
echo "Deployed to http://${HOST}/"
|
||||
echo " Logs: ssh ${REMOTE_USER}@${HOST} 'journalctl -u bbctrl -f'"
|
||||
echo " Open: open -a 'Google Chrome' http://${HOST}/"
|
||||
75
scripts/deploy/local.sh
Executable file
75
scripts/deploy/local.sh
Executable file
@@ -0,0 +1,75 @@
|
||||
#!/bin/bash
|
||||
# --- Local development (macOS) ---
|
||||
#
|
||||
# Builds the UI bundle and static-serves it on http://localhost:8770/.
|
||||
# Runs in a named tmux session so we can iterate (re-running this script
|
||||
# rebuilds and restarts the server in-place, you keep your browser tab).
|
||||
#
|
||||
# What you'll see:
|
||||
# * The full V09 chrome (header tabs, settings rail, jog grid, DRO
|
||||
# skeleton, status strip).
|
||||
# * A "DISCONNECTED" overlay because there's no controller backend.
|
||||
# * The W axis row in jog/DRO is hidden (correct: it appears only when
|
||||
# the controller reports `aux_enabled = true`). To exercise the W
|
||||
# axis end-to-end, deploy to the Pi (`./deploy.sh hardware`).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
echo "🛠 Building UI bundle..."
|
||||
make build/http/index.html >/dev/null
|
||||
|
||||
PORT="${PORT:-8770}"
|
||||
SESSION="onefin-local"
|
||||
|
||||
ensure_tmux_window() {
|
||||
local session="$1"
|
||||
local window="${2:-}"
|
||||
local target="${session}${window:+:$window}"
|
||||
if tmux has-session -t "$session" 2>/dev/null; then
|
||||
if tmux send-keys -t "$target" "" 2>/dev/null; then
|
||||
echo "🔁 Reusing tmux session '$session'..."
|
||||
tmux send-keys -t "$target" C-c
|
||||
sleep 1
|
||||
return
|
||||
fi
|
||||
echo "⚠️ Dead pane in '$session', recreating..."
|
||||
tmux kill-session -t "$session" 2>/dev/null
|
||||
fi
|
||||
echo "🆕 Creating tmux session '$session'..."
|
||||
tmux new-session -d -s "$session"
|
||||
}
|
||||
|
||||
ensure_tmux_window "$SESSION"
|
||||
|
||||
# Free the port if a previous run is still listening.
|
||||
if lsof -iTCP:"$PORT" -sTCP:LISTEN >/dev/null 2>&1; then
|
||||
echo "⚠️ Port $PORT is busy; killing previous server..."
|
||||
lsof -tiTCP:"$PORT" -sTCP:LISTEN | xargs -r kill 2>/dev/null || true
|
||||
sleep 1
|
||||
fi
|
||||
|
||||
tmux send-keys -t "$SESSION" \
|
||||
"cd '$SCRIPT_DIR' && python3 -m http.server --directory build/http $PORT" \
|
||||
C-m
|
||||
|
||||
echo ""
|
||||
echo "✅ Static UI server started on http://localhost:$PORT/"
|
||||
echo ""
|
||||
echo " Routes to try:"
|
||||
echo " http://localhost:$PORT/#control"
|
||||
echo " http://localhost:$PORT/#program"
|
||||
echo " http://localhost:$PORT/#console"
|
||||
echo " http://localhost:$PORT/#settings (Display & Units)"
|
||||
echo " http://localhost:$PORT/#admin-network (WiFi / IP)"
|
||||
echo " http://localhost:$PORT/#motor:0 (Motor 0 settings)"
|
||||
echo ""
|
||||
echo " tmux: tmux attach -t $SESSION"
|
||||
echo " stop: tmux kill-session -t $SESSION"
|
||||
echo ""
|
||||
echo "ℹ️ No controller is running, so the page shows DISCONNECTED and"
|
||||
echo " axis values stay empty. For live data + W axis, run:"
|
||||
echo " ./deploy.sh hardware (fast: rsync build/http -> Pi)"
|
||||
echo " ./deploy.sh prod (full firmware update)"
|
||||
102
scripts/deploy/patch_font_mime.py
Normal file
102
scripts/deploy/patch_font_mime.py
Normal file
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Patch bbctrl Web.py so font files get the correct MIME type.
|
||||
|
||||
Background
|
||||
----------
|
||||
The Onefinity controller (Pi 3B running Raspbian stretch) ships Python
|
||||
3.5, whose ``mimetypes`` module does not recognize ``.woff``, ``.woff2``
|
||||
or ``.ttf``. Tornado's ``StaticFileHandler`` therefore falls back to
|
||||
``application/octet-stream`` for those, and Chromium 72 (the Pi's
|
||||
onboard kiosk browser) refuses to use such payloads as web fonts. The
|
||||
result is that every FontAwesome icon renders as an empty box on the
|
||||
kiosk display.
|
||||
|
||||
This patch monkey-patches ``StaticFileHandler.get_content_type`` to
|
||||
emit the right MIME types. It is idempotent: running it twice is a
|
||||
no-op. Run with ``sudo`` so it can rewrite the egg's Web.py.
|
||||
|
||||
Used by:
|
||||
scripts/deploy/hardware.sh
|
||||
"""
|
||||
|
||||
from __future__ import print_function
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def find_web_py():
|
||||
"""Return the absolute path to the bbctrl Web.py shipped in the egg."""
|
||||
base = "/usr/local/lib"
|
||||
for entry in os.listdir(base):
|
||||
if not entry.startswith("python"):
|
||||
continue
|
||||
candidate_dir = os.path.join(base, entry, "dist-packages")
|
||||
if not os.path.isdir(candidate_dir):
|
||||
continue
|
||||
for sub in os.listdir(candidate_dir):
|
||||
if sub.startswith("bbctrl-") and sub.endswith(".egg"):
|
||||
p = os.path.join(candidate_dir, sub, "bbctrl", "Web.py")
|
||||
if os.path.isfile(p):
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
OLD_BLOCK = (
|
||||
"class StaticFileHandler(tornado.web.StaticFileHandler):\n"
|
||||
" def set_extra_headers(self, path):\n"
|
||||
" self.set_header('Cache-Control',\n"
|
||||
" 'no-store, no-cache, must-revalidate, max-age=0')"
|
||||
)
|
||||
|
||||
NEW_BLOCK = (
|
||||
"class StaticFileHandler(tornado.web.StaticFileHandler):\n"
|
||||
" # FONT_MIME_FIX: Python 3.5's mimetypes module does not know\n"
|
||||
" # woff/woff2/ttf, so Tornado serves them as application/octet-\n"
|
||||
" # stream which Chromium 72 (the Pi's onboard kiosk browser)\n"
|
||||
" # refuses to use as web fonts. Set explicit types so the FA6\n"
|
||||
" # icon set actually renders on the kiosk display.\n"
|
||||
" def get_content_type(self):\n"
|
||||
" path = self.absolute_path or ''\n"
|
||||
" if path.endswith('.woff2'): return 'font/woff2'\n"
|
||||
" if path.endswith('.woff'): return 'font/woff'\n"
|
||||
" if path.endswith('.ttf'): return 'font/ttf'\n"
|
||||
" if path.endswith('.otf'): return 'font/otf'\n"
|
||||
" if path.endswith('.eot'): return 'application/vnd.ms-fontobject'\n"
|
||||
" return super().get_content_type()\n"
|
||||
"\n"
|
||||
" def set_extra_headers(self, path):\n"
|
||||
" self.set_header('Cache-Control',\n"
|
||||
" 'no-store, no-cache, must-revalidate, max-age=0')"
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
target = find_web_py()
|
||||
if target is None:
|
||||
print("ERROR: could not locate bbctrl Web.py under /usr/local/lib",
|
||||
file=sys.stderr)
|
||||
return 1
|
||||
|
||||
with open(target) as f:
|
||||
src = f.read()
|
||||
|
||||
if "FONT_MIME_FIX" in src:
|
||||
print("font mime: already patched ({})".format(target))
|
||||
return 0
|
||||
|
||||
if OLD_BLOCK not in src:
|
||||
print("font mime: expected block not found in {} -- skipping".format(target),
|
||||
file=sys.stderr)
|
||||
# Don't fail the deploy; just log and continue.
|
||||
return 0
|
||||
|
||||
new_src = src.replace(OLD_BLOCK, NEW_BLOCK, 1)
|
||||
with open(target, "w") as f:
|
||||
f.write(new_src)
|
||||
print("font mime: patched {}".format(target))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
42
scripts/deploy/prod.sh
Executable file
42
scripts/deploy/prod.sh
Executable file
@@ -0,0 +1,42 @@
|
||||
#!/bin/bash
|
||||
# --- Production firmware update (Pi at onefinity.local) ---
|
||||
#
|
||||
# Builds a full firmware package (.tar.bz2) and PUTs it through the Pi's
|
||||
# /api/firmware/update endpoint. This is the canonical OTA flow and goes
|
||||
# through the bbctrl Tornado server's update handler.
|
||||
#
|
||||
# Defaults:
|
||||
# HOST=onefinity.local
|
||||
# PASSWORD=onefinity
|
||||
#
|
||||
# Override:
|
||||
# HOST=10.1.10.55 PASSWORD=secret ./deploy.sh prod
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
HOST="${HOST:-onefinity.local}"
|
||||
PASSWORD="${PASSWORD:-onefinity}"
|
||||
|
||||
# Require a clean working tree.
|
||||
echo "🔍 Checking git state..."
|
||||
if ! git diff --quiet || ! git diff --cached --quiet \
|
||||
|| [[ -n "$(git ls-files --others --exclude-standard)" ]]; then
|
||||
echo "❌ Refusing to deploy: working tree has uncommitted changes."
|
||||
git status --short
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Working tree is clean."
|
||||
|
||||
echo "🛠 Building firmware package..."
|
||||
make pkg
|
||||
|
||||
echo "🚚 Uploading to http://${HOST}/api/firmware/update..."
|
||||
make update HOST="$HOST" PASSWORD="$PASSWORD"
|
||||
|
||||
echo ""
|
||||
echo "✅ Firmware update PUT to ${HOST}."
|
||||
echo " The Pi will reboot itself after applying the update."
|
||||
echo " Once it comes back, open: http://${HOST}/"
|
||||
@@ -19,8 +19,17 @@ if $UPDATE_PY; then
|
||||
# Update service
|
||||
rm -f /etc/init.d/bbctrl
|
||||
cp scripts/bbctrl.service /etc/systemd/system/
|
||||
|
||||
# Cold-boot fast path:
|
||||
# - bbserial-rebind.service replaces the bbserial unbind/reload
|
||||
# that used to live in rc.local AFTER bbctrl was already
|
||||
# listening on /dev/ttyAMA0. Doing it as a unit ordered
|
||||
# Before=bbctrl.service eliminates a full bbctrl restart
|
||||
# mid-boot (~5s saved).
|
||||
cp scripts/bbserial-rebind.service /etc/systemd/system/
|
||||
systemctl daemon-reload
|
||||
systemctl enable bbctrl
|
||||
systemctl enable bbserial-rebind.service
|
||||
fi
|
||||
|
||||
if $UPDATE_AVR; then
|
||||
@@ -118,8 +127,50 @@ if [ $? -ne 0 ]; then
|
||||
REBOOT=true
|
||||
fi
|
||||
|
||||
# Install rc.local
|
||||
cp scripts/rc.local /etc/
|
||||
# Install rc.local. Use the slimmed "fast" variant if it exists in this
|
||||
# checkout (preferred); fall back to the legacy rc.local for older
|
||||
# firmware tarballs that don't ship rc.local.fast yet.
|
||||
if [ -f scripts/rc.local.fast ]; then
|
||||
cp scripts/rc.local.fast /etc/rc.local
|
||||
else
|
||||
cp scripts/rc.local /etc/rc.local
|
||||
fi
|
||||
chmod +x /etc/rc.local
|
||||
|
||||
# Cold-boot: mask units that contribute to userspace startup time but
|
||||
# do not benefit a deployed Onefinity Pi. Each is reversible with
|
||||
# `systemctl unmask <unit>`.
|
||||
# plymouth-read-write : 4s of work for a splash that rc.local kills
|
||||
# immediately with `plymouth quit`.
|
||||
# plymouth-quit-wait : holds graphical.target until the splash is
|
||||
# fully gone; redundant once the splash is
|
||||
# masked.
|
||||
# raspi-config : one-shot first-boot config; on a deployed
|
||||
# image it's a 2s no-op.
|
||||
# sysstat : sadc CPU/IO stats logger; not used.
|
||||
# Use --now so the change also applies to the running system; harmless
|
||||
# on a fresh install where the units are inactive.
|
||||
for unit in \
|
||||
plymouth-read-write.service \
|
||||
plymouth-quit-wait.service \
|
||||
raspi-config.service \
|
||||
sysstat.service; do
|
||||
systemctl mask --now "$unit" 2>/dev/null || true
|
||||
done
|
||||
|
||||
# Cold-boot: switch swap activation from dphys-swapfile (~4.3s LSB
|
||||
# wrapper that re-checks the swap file size on every boot) to a plain
|
||||
# fstab entry. The swap file itself is already created at
|
||||
# /var/swap by the previous boot; we only need to make sure it gets
|
||||
# `swapon`'d at local-fs.target instead.
|
||||
SWAPFILE=/var/swap
|
||||
if [ -f "$SWAPFILE" ]; then
|
||||
if ! grep -qE "^[^#]*${SWAPFILE//\//\\/}[[:space:]]+swap" /etc/fstab; then
|
||||
echo "$SWAPFILE none swap sw 0 0" >> /etc/fstab
|
||||
fi
|
||||
systemctl mask --now dphys-swapfile.service 2>/dev/null || true
|
||||
swapon -a 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Ensure that the watchdog python library is installed
|
||||
pip3 list --format=columns | grep watchdog >/dev/null
|
||||
|
||||
@@ -28,4 +28,4 @@ plymouth quit
|
||||
|
||||
# Start X in /home/pi
|
||||
cd /home/pi
|
||||
sudo -u pi startx
|
||||
sudo -u pi startx -- -nocursor
|
||||
|
||||
42
scripts/rc.local.fast
Normal file
42
scripts/rc.local.fast
Normal file
@@ -0,0 +1,42 @@
|
||||
#!/bin/bash
|
||||
# rc.local for the OneFinity Pi, "fast" variant.
|
||||
#
|
||||
# What changed vs. scripts/rc.local:
|
||||
# - bbserial unbind/rebind moved to bbserial-rebind.service (runs
|
||||
# once, before bbctrl, instead of after bbctrl is already
|
||||
# listening on the serial port).
|
||||
# - startx moved to kiosk.service so chromium starts in parallel
|
||||
# with bbctrl rather than blocking on rc.local.
|
||||
# - rc.local no longer keeps the Pi in 'starting' state forever,
|
||||
# which fixes systemd-analyze.
|
||||
|
||||
set -e
|
||||
|
||||
# Mount /boot read only
|
||||
mount -o remount,ro /boot 2>/dev/null || true
|
||||
|
||||
# Set SPI GPIO mode
|
||||
gpio mode 27 alt3 || true
|
||||
|
||||
# Create browser memory limited cgroup
|
||||
if [ -d /sys/fs/cgroup/memory ]; then
|
||||
CGROUP=/sys/fs/cgroup/memory/chrome
|
||||
[ -d "$CGROUP" ] || mkdir -p "$CGROUP"
|
||||
chown -R pi:pi "$CGROUP"
|
||||
echo 650000000 > "$CGROUP/memory.soft_limit_in_bytes"
|
||||
echo 750000000 > "$CGROUP/memory.limit_in_bytes"
|
||||
fi
|
||||
|
||||
# Stop boot splash; harmless if plymouth already gone.
|
||||
plymouth quit 2>/dev/null || true
|
||||
|
||||
# Start X (chromium kiosk) in the background so rc.local can exit and
|
||||
# late-boot units (bbctrl logrotate, etc.) don't block on it. Output
|
||||
# is redirected so the journal doesn't fill up with X warnings.
|
||||
cd /home/pi
|
||||
# `-- -nocursor` hides the X pointer; this is a touchscreen kiosk and
|
||||
# the mouse cursor only gets in the way.
|
||||
nohup sudo -u pi startx -- -nocursor >/var/log/onefin-x.log 2>&1 &
|
||||
disown
|
||||
|
||||
exit 0
|
||||
@@ -75,7 +75,7 @@ sed -i 's/^PARTUUID=.*\//\/dev\/mmcblk0p2 \//' /etc/fstab
|
||||
|
||||
# Enable browser in xorg
|
||||
sed -i 's/allowed_users=console/allowed_users=anybody/' /etc/X11/Xwrapper.config
|
||||
echo "sudo -u pi startx" >> /etc/rc.local
|
||||
echo "sudo -u pi startx -- -nocursor" >> /etc/rc.local
|
||||
cp /mnt/host/xinitrc /home/pi/.xinitrc
|
||||
cp /mnt/host/ratpoisonrc /home/pi/.ratpoisonrc
|
||||
cp /mnt/host/xorg.conf /etc/X11/
|
||||
|
||||
2
setup.py
2
setup.py
@@ -17,7 +17,7 @@ setup(
|
||||
license = pkg['license'],
|
||||
url = pkg['homepage'],
|
||||
package_dir = {'': 'src/py'},
|
||||
packages = ['bbctrl', 'inevent', 'lcd', 'camotics','iw_parse'],
|
||||
packages = ['bbctrl', 'inevent', 'lcd', 'camotics', 'iw_parse'],
|
||||
include_package_data = True,
|
||||
entry_points = {
|
||||
'console_scripts': [
|
||||
|
||||
20
src/js/a-axis-view.js
Normal file
20
src/js/a-axis-view.js
Normal file
@@ -0,0 +1,20 @@
|
||||
"use strict";
|
||||
|
||||
// V09 A-axis page — mounts the AAxisSettings Svelte component
|
||||
// inside the settings shell so it gets a real top-level rail entry
|
||||
// instead of being a soft-link anchor inside Display & Units.
|
||||
|
||||
module.exports = {
|
||||
template: "#a-axis-view-template",
|
||||
|
||||
attached: function () {
|
||||
this.svelteComponent = SvelteComponents.createComponent(
|
||||
"AAxisSettings",
|
||||
document.getElementById("a-axis-mount")
|
||||
);
|
||||
},
|
||||
|
||||
detached: function () {
|
||||
if (this.svelteComponent) this.svelteComponent.$destroy();
|
||||
},
|
||||
};
|
||||
282
src/js/app.js
282
src/js/app.js
@@ -4,6 +4,7 @@ const api = require("./api");
|
||||
const cookie = require("./cookie")("bbctrl-");
|
||||
const Sock = require("./sock");
|
||||
const semverLt = require("semver/functions/lt");
|
||||
const restartTiming = require("./restart-timing");
|
||||
|
||||
if (document.getElementById("svelte-dialog-host") != undefined) {
|
||||
SvelteComponents.createComponent(
|
||||
@@ -103,12 +104,23 @@ module.exports = new Vue({
|
||||
return {
|
||||
status: "connecting",
|
||||
currentView: "loading",
|
||||
// Top-level shell tab. Mapped from the URL hash by parse_hash().
|
||||
// One of: control | program | console | settings
|
||||
top_tab: "control",
|
||||
// Sub-route when a tab has internal pages (e.g. console:mdi,
|
||||
// settings:admin-network, settings:motor:0). The settings sub
|
||||
// also drives which inner view is mounted.
|
||||
sub_tab: "",
|
||||
sys_open: false,
|
||||
has_camera: true,
|
||||
messages_log: [],
|
||||
messages_seen: 0,
|
||||
display_units: localStorage.getItem("display_units") || "METRIC",
|
||||
index: -1,
|
||||
modified: false,
|
||||
template: require("../resources/config-template.json"),
|
||||
config: {
|
||||
settings: {
|
||||
settings: {
|
||||
units: "METRIC",
|
||||
"easy-adapter": false
|
||||
},
|
||||
@@ -143,22 +155,15 @@ module.exports = new Vue({
|
||||
estop: { template: "#estop-template" },
|
||||
"loading-view": { template: "<h1>Loading...</h1>" },
|
||||
"control-view": require("./control-view"),
|
||||
"settings-view": require("./settings-view"),
|
||||
"motor-view": require("./motor-view"),
|
||||
"tool-view": require("./tool-view"),
|
||||
"io-view": require("./io-view"),
|
||||
"admin-general-view": require("./admin-general-view"),
|
||||
"admin-network-view": require("./admin-network-view"),
|
||||
"macros-view": require('./macros'),
|
||||
"help-view": require("./help-view"),
|
||||
"cheat-sheet-view": {
|
||||
template: "#cheat-sheet-view-template",
|
||||
data: function() {
|
||||
return {
|
||||
showUnimplemented: false
|
||||
};
|
||||
},
|
||||
},
|
||||
"program-view": require("./program-view"),
|
||||
"console-view": require("./console-view"),
|
||||
|
||||
// The settings-shell renders the rail + an inner routed view.
|
||||
// All settings-family hashes (settings, admin-general,
|
||||
// admin-network, motor:N, tool, io, macros, help, cheat-sheet)
|
||||
// resolve to this same shell; parse_hash() sets sub_tab so the
|
||||
// shell knows which inner template to mount.
|
||||
"settings-shell-view": require("./settings-shell-view"),
|
||||
},
|
||||
|
||||
watch: {
|
||||
@@ -166,6 +171,25 @@ module.exports = new Vue({
|
||||
localStorage.setItem("display_units", value);
|
||||
SvelteComponents.setDisplayUnits(value);
|
||||
},
|
||||
|
||||
// Mirror controller messages into a console log used by the
|
||||
// Console > Messages tab and the header badge counter.
|
||||
"state.messages": {
|
||||
handler: function(messages) {
|
||||
if (!Array.isArray(messages)) return;
|
||||
this.messages_log = messages.map(m => ({
|
||||
text: m.text,
|
||||
id: m.id,
|
||||
level: /^#/.test(m.text || "") ? "info" : "warning",
|
||||
ts: m.ts || Date.now(),
|
||||
}));
|
||||
if (this.top_tab === "console" && this.sub_tab === "messages") {
|
||||
this.messages_seen = this.messages_log.length;
|
||||
}
|
||||
},
|
||||
deep: true,
|
||||
immediate: true,
|
||||
},
|
||||
},
|
||||
|
||||
events: {
|
||||
@@ -227,6 +251,19 @@ module.exports = new Vue({
|
||||
},
|
||||
|
||||
computed: {
|
||||
// True when the UI is in kiosk mode — i.e. running on the
|
||||
// controller's own onboard browser (Pi 3B at 1366x768) or
|
||||
// explicitly forced via ?kiosk=1. Source-of-truth is the
|
||||
// `kiosk-mode` class added to <html> by the inline script
|
||||
// in index.pug, which already honors hostname + URL param +
|
||||
// localStorage. The Pi's VideoCore IV is too slow for the
|
||||
// three.js toolpath preview, so we suppress that panel in
|
||||
// kiosk mode and let the gcode listing take the full width.
|
||||
is_kiosk: function() {
|
||||
return typeof document !== "undefined"
|
||||
&& document.documentElement.classList.contains("kiosk-mode");
|
||||
},
|
||||
|
||||
popupMessages: function() {
|
||||
const msgs = [];
|
||||
|
||||
@@ -252,18 +289,131 @@ module.exports = new Vue({
|
||||
enable_rotary: function() {
|
||||
if(this.state["2an"] == 1 || this.state["2an"] == 3) return true;
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
// ---------------- header chrome helpers ----------------
|
||||
|
||||
// Underlying machine state from the controller. Mirrors
|
||||
// control-view's `mach_state` so the header has access without
|
||||
// depending on the routed component.
|
||||
mach_state: function() {
|
||||
const cycle = this.state.cycle;
|
||||
const xx = this.state.xx;
|
||||
|
||||
if (xx != "ESTOPPED" && (cycle == "jogging" || cycle == "homing")) {
|
||||
return cycle.toUpperCase();
|
||||
}
|
||||
|
||||
return xx || "";
|
||||
},
|
||||
|
||||
// Short text for the READY pill in the header.
|
||||
state_label: function() {
|
||||
const s = this.mach_state;
|
||||
if (!s) return "--";
|
||||
return s;
|
||||
},
|
||||
|
||||
// Class added to the READY pill (.state-badge) so styling can
|
||||
// reflect ready / running / holding / fault / estop.
|
||||
state_class: function() {
|
||||
const s = this.mach_state;
|
||||
if (s == "ESTOPPED" || s == "FAULT" || s == "SHUTDOWN") return "bad";
|
||||
if (s == "HOLDING" || s == "STOPPING") return "warn";
|
||||
if (s == "RUNNING" || s == "HOMING" || s == "JOGGING") return "busy";
|
||||
if (s == "READY") return "ok";
|
||||
return "unknown";
|
||||
},
|
||||
|
||||
mach_state_full: function() {
|
||||
const s = this.mach_state;
|
||||
if (s == "ESTOPPED") return "E-Stopped \u2014 release to clear";
|
||||
if (s == "HOLDING") return "Feed hold (" + (this.state.pr || "paused") + ")";
|
||||
if (s == "RUNNING") return "Running program";
|
||||
if (s == "HOMING") return "Homing axes";
|
||||
if (s == "JOGGING") return "Jogging";
|
||||
if (s == "READY") return "Ready";
|
||||
return s;
|
||||
},
|
||||
|
||||
// Pip color for the unified system pill.
|
||||
sys_class: function() {
|
||||
const wifi_off = !this.config.wifiName || this.config.wifiName == "not connected";
|
||||
const cam_off = !this.has_camera;
|
||||
const hot = this.state && 80 <= this.state.rpi_temp;
|
||||
if (hot) return "red";
|
||||
if (wifi_off || cam_off) return "amber";
|
||||
return "green";
|
||||
},
|
||||
|
||||
// Compact summary for the system pill.
|
||||
sys_summary: function() {
|
||||
const issues = [];
|
||||
if (!this.config.wifiName || this.config.wifiName == "not connected") {
|
||||
issues.push("WiFi off");
|
||||
}
|
||||
if (!this.has_camera) issues.push("Camera offline");
|
||||
if (this.state && 80 <= this.state.rpi_temp) issues.push("Pi hot");
|
||||
if (this.is_rotary_active) issues.push("Rotary");
|
||||
if (issues.length === 0) return "All systems";
|
||||
if (issues.length === 1) return issues[0];
|
||||
return issues.length + " notes";
|
||||
},
|
||||
|
||||
// Number of unread Console > Messages entries.
|
||||
messages_count: function() {
|
||||
return Math.max(0, this.messages_log.length - this.messages_seen);
|
||||
},
|
||||
},
|
||||
|
||||
ready: function() {
|
||||
window.onhashchange = () => this.parse_hash();
|
||||
|
||||
// Embedded Svelte subviews (A axis settings, etc.) signal
|
||||
// unsaved changes via this event. The master Save button
|
||||
// highlights when modified is true.
|
||||
window.addEventListener("onefin:dirty", () => {
|
||||
this.modified = true;
|
||||
});
|
||||
|
||||
|
||||
|
||||
// Resolve the initial route before the websocket connects so
|
||||
// the shell shows the right view even on a slow / offline
|
||||
// controller. update() will call parse_hash() again once the
|
||||
// first config is in. Skip routing into the Svelte settings
|
||||
// family before config has loaded — those components read
|
||||
// many config keys (settings.units, settings.probing-prompts,
|
||||
// motion.*, etc.) and would throw on first paint with the
|
||||
// empty placeholder config.
|
||||
const settingsFamily = [
|
||||
"settings", "probing", "gcode",
|
||||
"admin-general", "admin-network",
|
||||
"motor", "tool", "io", "macros",
|
||||
"help", "cheat-sheet",
|
||||
"a-axis",
|
||||
];
|
||||
const initialHead = (location.hash || "").replace(/^#/, "").split(":")[0];
|
||||
if (settingsFamily.indexOf(initialHead) === -1) {
|
||||
this.parse_hash();
|
||||
}
|
||||
// else: stay on "loading" until update() completes and calls
|
||||
// parse_hash() itself.
|
||||
|
||||
this.connect();
|
||||
|
||||
// Close the system popover when clicking anywhere else.
|
||||
document.addEventListener("click", () => {
|
||||
if (this.sys_open) this.sys_open = false;
|
||||
});
|
||||
|
||||
SvelteComponents.registerControllerMethods({
|
||||
dispatch: (...args) => this.$dispatch(...args)
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
|
||||
methods: {
|
||||
block_error_dialog: function() {
|
||||
this.errorTimeoutStart = Date.now();
|
||||
@@ -338,6 +488,12 @@ module.exports = new Vue({
|
||||
toggle_rotary: async function(isActive) {
|
||||
try {
|
||||
await api.put("rotary", {status: isActive});
|
||||
// The /api/rotary endpoint rewrites motors[1]/[2]
|
||||
// in config.json on the server. Refetch so the UI
|
||||
// reflects the new motor config (otherwise the
|
||||
// motor settings page keeps showing pre-toggle
|
||||
// values until the next page reload).
|
||||
await this.update();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert("Error occured");
|
||||
@@ -372,11 +528,19 @@ module.exports = new Vue({
|
||||
connect: function() {
|
||||
this.sock = new Sock(`//${location.host}/sockjs`);
|
||||
|
||||
let _gotFirstMsg = false;
|
||||
let _gotFirstState = false;
|
||||
|
||||
this.sock.onmessage = (e) => {
|
||||
if (typeof e.data != "object") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_gotFirstMsg) {
|
||||
_gotFirstMsg = true;
|
||||
restartTiming.onWsFirstMessage();
|
||||
}
|
||||
|
||||
if (e.data.log && e.data.log.msg !== "Switch not found") {
|
||||
this.$broadcast("log", e.data.log);
|
||||
|
||||
@@ -386,6 +550,11 @@ module.exports = new Vue({
|
||||
}
|
||||
}
|
||||
|
||||
if (!_gotFirstState) {
|
||||
_gotFirstState = true;
|
||||
restartTiming.onFirstState();
|
||||
}
|
||||
|
||||
// Check for session ID change on controller
|
||||
if ("sid" in e.data) {
|
||||
if (typeof this.sid == "undefined") {
|
||||
@@ -410,6 +579,7 @@ module.exports = new Vue({
|
||||
|
||||
this.sock.onopen = () => {
|
||||
this.status = "connected";
|
||||
restartTiming.onWsOpen();
|
||||
this.$emit(this.status);
|
||||
this.$broadcast(this.status);
|
||||
};
|
||||
@@ -421,6 +591,21 @@ module.exports = new Vue({
|
||||
};
|
||||
},
|
||||
|
||||
// Maps a URL hash to (currentView, top_tab, sub_tab, index).
|
||||
// Hash layouts supported (all kept for backward compat):
|
||||
// #control -> control tab
|
||||
// #program[:auto] -> program tab
|
||||
// #console[:mdi|messages|indicators]
|
||||
// -> console tab
|
||||
// #settings -> settings tab home
|
||||
// #admin-general -> settings tab, admin-general inside
|
||||
// #admin-network -> settings tab, admin-network inside
|
||||
// #motor:0..3 -> settings tab, motor 0..3
|
||||
// #tool -> settings tab, tool view
|
||||
// #io -> settings tab, io view
|
||||
// #macros -> settings tab, macros view
|
||||
// #help -> settings tab, help view
|
||||
// #cheat-sheet -> settings tab, cheat sheet view
|
||||
parse_hash: function() {
|
||||
const hash = location.hash.substr(1);
|
||||
|
||||
@@ -430,12 +615,58 @@ module.exports = new Vue({
|
||||
}
|
||||
|
||||
const parts = hash.split(":");
|
||||
const head = parts[0];
|
||||
|
||||
if (parts.length == 2) {
|
||||
this.index = parts[1];
|
||||
this.index = parts.length > 1 ? parts[1] : -1;
|
||||
|
||||
// Legacy / settings-managed views resolve under the
|
||||
// Settings tab while keeping their existing top-level
|
||||
// hash. This preserves all existing deep links.
|
||||
const settingsViews = [
|
||||
"settings", "probing", "gcode",
|
||||
"admin-general", "admin-network",
|
||||
"motor", "tool", "io", "macros",
|
||||
"help", "cheat-sheet",
|
||||
"a-axis",
|
||||
];
|
||||
|
||||
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() {
|
||||
@@ -455,9 +686,16 @@ module.exports = new Vue({
|
||||
|
||||
this.config["selected-tool-settings"][selected_tool] = settings;
|
||||
this.display_units = this.config.settings["units"];
|
||||
|
||||
|
||||
try {
|
||||
await api.put("config/save", this.config);
|
||||
// Notify any embedded Svelte subviews that own their
|
||||
// own persistence (A axis -> aux.json, etc.) that
|
||||
// the user just hit the master Save button. They
|
||||
// listen for `onefin:save-all` and PUT their state.
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent("onefin:save-all"));
|
||||
} catch (_e) {}
|
||||
this.modified = false;
|
||||
} catch (error) {
|
||||
console.error("Save failed:", error);
|
||||
|
||||
@@ -32,6 +32,10 @@ module.exports = {
|
||||
return this._compute_axis("c");
|
||||
},
|
||||
|
||||
w: function() {
|
||||
return this._compute_aux_axis();
|
||||
},
|
||||
|
||||
axes: function() {
|
||||
return this._compute_axes();
|
||||
}
|
||||
@@ -52,7 +56,12 @@ module.exports = {
|
||||
const abs = this.state[`${axis}p`] || 0;
|
||||
const off = this.state[`offset_${axis}`];
|
||||
const motor_id = this._get_motor_id(axis);
|
||||
const motor = motor_id == -1 ? {} : this.config.motors[motor_id];
|
||||
// motor_id may be 4 for the synthetic external-axis motor;
|
||||
// there is no entry for it in config.motors so guard with
|
||||
// an empty object to avoid undefined property access.
|
||||
const motor = (motor_id == -1
|
||||
? {}
|
||||
: (this.config.motors[motor_id] || {}));
|
||||
const enabled = this._check_is_enabled(axis);
|
||||
const homingMode = motor["homing-mode"];
|
||||
const homed = this.state[`${motor_id}homed`];
|
||||
@@ -185,24 +194,114 @@ module.exports = {
|
||||
_get_motor_id: function(axis) {
|
||||
for (let i = 0; i < this.config.motors.length; i++) {
|
||||
const motor = this.config.motors[i];
|
||||
if (motor.axis.toLowerCase() == axis) {
|
||||
// motor.axis can be undefined on initial load before
|
||||
// config has streamed in. Guard so the computed does
|
||||
// not throw and bubble a Vue warning into the console.
|
||||
if (motor && typeof motor.axis === "string" &&
|
||||
motor.axis.toLowerCase() == axis) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
// Synthetic external motor (index 4) used by ExternalAxis
|
||||
// to expose the auxcnc ESP stepper as a virtual axis.
|
||||
// Its `Nan` lives in state, not config.
|
||||
const axes = { x: 0, y: 1, z: 2, a: 3, b: 4, c: 5 };
|
||||
const wanted = axes[axis];
|
||||
const extAn = this.state && this.state["4an"];
|
||||
if (typeof wanted === "number" && typeof extAn === "number"
|
||||
&& extAn === wanted) {
|
||||
return 4;
|
||||
}
|
||||
|
||||
return -1;
|
||||
},
|
||||
|
||||
_check_is_enabled: function(axis){
|
||||
// Prefer config.motors[i].axis (always present once the
|
||||
// config has loaded). Fall back to the per-motor state
|
||||
// `Nan` field, which is what the legacy UI used. This
|
||||
// avoids hiding axis rows during the brief window after
|
||||
// config has loaded but before the controller has pushed
|
||||
// its first state delta.
|
||||
const axes = { x: 0, y: 1, z: 2, a: 3 };
|
||||
for(let i = 0; i < this.config.motors.length; i++){
|
||||
if(this.state[`${i}an`] == axes[axis]){
|
||||
const wanted = axes[axis];
|
||||
for (let i = 0; i < this.config.motors.length; i++) {
|
||||
const motor = this.config.motors[i] || {};
|
||||
if (typeof motor.axis === "string" &&
|
||||
motor.axis.toLowerCase() == axis) {
|
||||
return motor.enabled !== false;
|
||||
}
|
||||
// Only use the state Nan fallback for axes we know
|
||||
// about (x/y/z/a). Otherwise undefined == undefined
|
||||
// would mistakenly match every axis (b, c, ...).
|
||||
if (typeof wanted === "number") {
|
||||
const an = this.state[`${i}an`];
|
||||
if (typeof an === "number" && an === wanted) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Synthetic external motor (index 4) - the auxcnc ESP
|
||||
// stepper exposed as A via ExternalAxis.
|
||||
if (typeof wanted === "number") {
|
||||
const extAn = this.state["4an"];
|
||||
const extMe = this.state["4me"];
|
||||
if (typeof extAn === "number" && extAn === wanted
|
||||
&& extMe) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
_compute_aux_axis: function() {
|
||||
// Auxiliary axis driven by the auxcnc ESP32 (typically
|
||||
// exposed to gplan as A). Position, homed flag and
|
||||
// presence come from the bbctrl AuxAxis driver via
|
||||
// state.aux_*. No motor mapping, no soft-limit warnings
|
||||
// on toolpath bounds (auxcnc enforces its own).
|
||||
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 the auxiliary axis."
|
||||
: "Aux controller not connected on /dev/ttyUSB0.";
|
||||
if (homed) {
|
||||
state = "HOMED";
|
||||
icon = "check-circle";
|
||||
title = "Auxiliary 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: "Auxiliary axis is not constrained by tool path bounds.",
|
||||
tklass: `${homed ? "homed" : "unhomed"} axis-w`,
|
||||
isAux: true,
|
||||
};
|
||||
},
|
||||
|
||||
_compute_axes: function() {
|
||||
let homed = false;
|
||||
|
||||
|
||||
125
src/js/console-view.js
Normal file
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";
|
||||
|
||||
const api = require("./api");
|
||||
const utils = require("./utils");
|
||||
const cookie = require("./cookie")("bbctrl-");
|
||||
|
||||
module.exports = {
|
||||
@@ -12,15 +11,7 @@ module.exports = {
|
||||
return {
|
||||
current_time: "",
|
||||
mach_units: this.$root.state.metric ? "METRIC" : "IMPERIAL",
|
||||
mdi: "",
|
||||
last_file: undefined,
|
||||
last_file_time: undefined,
|
||||
toolpath: {},
|
||||
toolpath_progress: 0,
|
||||
axes: "xyzabc",
|
||||
history: [],
|
||||
speed_override: 1,
|
||||
feed_override: 1,
|
||||
jog_incr_amounts: {
|
||||
METRIC: {
|
||||
fine: 0.1,
|
||||
@@ -38,34 +29,14 @@ module.exports = {
|
||||
jog_incr: localStorage.getItem("jog_incr") || "small",
|
||||
jog_step: cookie.get_bool("jog-step"),
|
||||
jog_adjust: parseInt(cookie.get("jog-adjust", 2)),
|
||||
deleteGCode: false,
|
||||
tab: "auto",
|
||||
ask_home: true,
|
||||
folder_name: "",
|
||||
edited: false,
|
||||
uploading_files: false,
|
||||
confirmDelete: false,
|
||||
create_folder: false,
|
||||
showGcodeMessage: false,
|
||||
showNoGcodeMessage: false,
|
||||
macrosLoading: false,
|
||||
show_gcodes: false,
|
||||
GCodeNotFound: false,
|
||||
show_probe_dialog: false,
|
||||
filesUploaded: 0,
|
||||
totalFiles: 0,
|
||||
files_sortby: "By Upload Date",
|
||||
selected_items_to_delete: [],
|
||||
search_query: "",
|
||||
filtered_files: [],
|
||||
selected_folder_index: null,
|
||||
overrides_open: false,
|
||||
};
|
||||
},
|
||||
|
||||
components: {
|
||||
"axis-control": require("./axis-control"),
|
||||
"path-viewer": require("./path-viewer"),
|
||||
"gcode-viewer": require("./gcode-viewer"),
|
||||
},
|
||||
|
||||
watch: {
|
||||
@@ -80,16 +51,6 @@ module.exports = {
|
||||
immediate: true,
|
||||
},
|
||||
|
||||
"state.line": function () {
|
||||
if (this.mach_state != "HOMING") {
|
||||
this.$broadcast("gcode-line", this.state.line);
|
||||
}
|
||||
},
|
||||
|
||||
"state.selected_time": function () {
|
||||
this.load();
|
||||
},
|
||||
|
||||
jog_step: function () {
|
||||
cookie.set_bool("jog-step", this.jog_step);
|
||||
},
|
||||
@@ -127,43 +88,16 @@ module.exports = {
|
||||
return state || "";
|
||||
},
|
||||
|
||||
pause_reason: function () {
|
||||
return this.state.pr;
|
||||
},
|
||||
|
||||
is_running: function () {
|
||||
return this.mach_state == "RUNNING" || this.mach_state == "HOMING";
|
||||
},
|
||||
|
||||
is_stopping: function () {
|
||||
return this.mach_state == "STOPPING";
|
||||
},
|
||||
|
||||
is_holding: function () {
|
||||
return this.mach_state == "HOLDING";
|
||||
},
|
||||
|
||||
is_ready: function () {
|
||||
return this.mach_state == "READY";
|
||||
can_set_axis: function () {
|
||||
return this.state.cycle == "idle";
|
||||
},
|
||||
|
||||
is_idle: function () {
|
||||
return this.state.cycle == "idle";
|
||||
},
|
||||
|
||||
is_paused: function () {
|
||||
return this.is_holding && (this.pause_reason == "User pause" || this.pause_reason == "Program pause");
|
||||
},
|
||||
|
||||
can_mdi: function () {
|
||||
return this.is_idle || this.state.cycle == "mdi";
|
||||
},
|
||||
|
||||
can_set_axis: function () {
|
||||
return this.is_idle;
|
||||
|
||||
// TODO allow setting axis position during pause
|
||||
// return this.is_idle || this.is_paused;
|
||||
is_ready: function () {
|
||||
return this.mach_state == "READY";
|
||||
},
|
||||
|
||||
message: function () {
|
||||
@@ -191,57 +125,21 @@ module.exports = {
|
||||
},
|
||||
|
||||
plan_time_remaining: function () {
|
||||
if (!(this.is_stopping || this.is_running || this.is_holding)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return this.toolpath.time - this.plan_time;
|
||||
const stopping = this.mach_state == "STOPPING";
|
||||
const running = this.mach_state == "RUNNING" || this.mach_state == "HOMING";
|
||||
const holding = this.mach_state == "HOLDING";
|
||||
if (!(stopping || running || holding)) return 0;
|
||||
const tp = this.$root && this.$root.toolpath ? this.$root.toolpath.time : 0;
|
||||
return (tp || 0) - this.plan_time;
|
||||
},
|
||||
|
||||
eta: function () {
|
||||
if (this.mach_state != "RUNNING") {
|
||||
return "";
|
||||
}
|
||||
|
||||
const remaining = this.plan_time_remaining;
|
||||
const d = new Date();
|
||||
d.setSeconds(d.getSeconds() + remaining);
|
||||
return d.toLocaleString();
|
||||
},
|
||||
|
||||
progress: function () {
|
||||
if (!this.toolpath.time || this.is_ready) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const p = this.plan_time / this.toolpath.time;
|
||||
return Math.min(1, p);
|
||||
},
|
||||
gcode_files: function () {
|
||||
if (!this.state.folder) {
|
||||
return [];
|
||||
}
|
||||
const folder = this.state.gcode_list.find(item => item.name == this.state.folder);
|
||||
if (!folder) {
|
||||
return [];
|
||||
}
|
||||
const files = folder.files.filter(item => this.state.files.includes(item.file_name)).map(item => item.file_name);
|
||||
if (this.files_sortby == "A-Z") {
|
||||
return files.sort();
|
||||
} else if (this.files_sortby == "Z-A") {
|
||||
return files.sort().reverse();
|
||||
} else {
|
||||
return files;
|
||||
}
|
||||
},
|
||||
gcode_filtered_files: function () {
|
||||
return this.filtered_files.filter(file => file.toLowerCase().includes(this.search_query.toLowerCase()));
|
||||
},
|
||||
gcode_folders: function () {
|
||||
return this.state.gcode_list
|
||||
.map(item => item.name)
|
||||
.filter(element => element !== "default")
|
||||
.sort();
|
||||
state_kpi_class: function () {
|
||||
const s = this.mach_state;
|
||||
if (s == "ESTOPPED" || s == "FAULT" || s == "SHUTDOWN") return "bad";
|
||||
if (s == "HOLDING" || s == "STOPPING") return "warn";
|
||||
if (s == "RUNNING" || s == "HOMING" || s == "JOGGING") return "busy";
|
||||
if (s == "READY") return "ok";
|
||||
return "";
|
||||
},
|
||||
},
|
||||
|
||||
@@ -264,14 +162,9 @@ module.exports = {
|
||||
M72
|
||||
`);
|
||||
},
|
||||
folder_name_edited: function () {
|
||||
this.edited = true;
|
||||
},
|
||||
},
|
||||
|
||||
ready: function () {
|
||||
this.load();
|
||||
|
||||
setInterval(() => {
|
||||
this.current_time = new Date().toLocaleTimeString();
|
||||
}, 1000);
|
||||
@@ -287,28 +180,39 @@ module.exports = {
|
||||
},
|
||||
|
||||
methods: {
|
||||
save_config: async function (config) {
|
||||
try {
|
||||
await api.put("config/save", config);
|
||||
this.$dispatch("update");
|
||||
} catch (error) {
|
||||
console.error("Restore Failed: ", error);
|
||||
alert("Restore failed");
|
||||
}
|
||||
},
|
||||
|
||||
populateFiles(index) {
|
||||
this.selected_folder_index = index;
|
||||
this.filtered_files = this.state.gcode_list[index].files.map(item => item.file_name);
|
||||
},
|
||||
|
||||
getJogIncrStyle(value) {
|
||||
const weight = `font-weight:${this.jog_incr === value ? "bold" : "normal"}`;
|
||||
const color = this.jog_incr === value ? "color:#0078e7" : "";
|
||||
|
||||
return [weight, color].join(";");
|
||||
},
|
||||
|
||||
// Should the macro row render a colored left stripe for this
|
||||
// macro? Only when the user has explicitly picked a color. The
|
||||
// controller seeds new macros with default placeholders like
|
||||
// "#ffffff" or "#dedede"; treat anything that close to white as
|
||||
// "no color".
|
||||
has_macro_color(macros) {
|
||||
if (!macros || typeof macros.color !== "string") return false;
|
||||
const c = macros.color.trim().toLowerCase();
|
||||
if (!c) return false;
|
||||
const defaults = [
|
||||
"#fff", "#ffffff", "#fefefe", "#fdfdfd", "#fcfcfc",
|
||||
"#dedede", "#dddddd", "#cccccc",
|
||||
];
|
||||
if (defaults.indexOf(c) !== -1) return false;
|
||||
// Fallback: if the color is very close to white (sum of RGB
|
||||
// > 690), suppress the stripe.
|
||||
const m = c.match(/^#([0-9a-f]{6})$/);
|
||||
if (m) {
|
||||
const v = parseInt(m[1], 16);
|
||||
const r = (v >> 16) & 0xff;
|
||||
const g = (v >> 8) & 0xff;
|
||||
const b = v & 0xff;
|
||||
if (r + g + b > 690) return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
jog_fn: function (x_jog, y_jog, z_jog, a_jog) {
|
||||
const amount = this.jog_incr_amounts[this.display_units][this.jog_incr];
|
||||
|
||||
@@ -324,426 +228,6 @@ module.exports = {
|
||||
`);
|
||||
},
|
||||
|
||||
send: function (msg) {
|
||||
this.$dispatch("send", msg);
|
||||
},
|
||||
|
||||
toggle_sorting: function () {
|
||||
if (this.files_sortby === "By Upload Date") {
|
||||
this.files_sortby = "A-Z";
|
||||
} else if (this.files_sortby === "A-Z") {
|
||||
this.files_sortby = "Z-A";
|
||||
} else if (this.files_sortby === "Z-A") {
|
||||
this.files_sortby = "By Upload Date";
|
||||
}
|
||||
},
|
||||
|
||||
load: function () {
|
||||
const file_time = this.state.selected_time;
|
||||
const file = this.state.selected;
|
||||
if (this.last_file == file && this.last_file_time == file_time) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.state.selected && !this.state.files.includes(this.state.selected)) {
|
||||
this.GCodeNotFound = true;
|
||||
return;
|
||||
}
|
||||
|
||||
this.last_file = file;
|
||||
this.last_file_time = file_time;
|
||||
|
||||
this.$broadcast("gcode-load", file);
|
||||
this.$broadcast("gcode-line", this.state.line);
|
||||
this.toolpath_progress = 0;
|
||||
this.load_toolpath(file, file_time);
|
||||
},
|
||||
|
||||
load_toolpath: async function (file, file_time) {
|
||||
this.toolpath = {};
|
||||
|
||||
if (!file || this.last_file_time != file_time) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.showGcodeMessage = true;
|
||||
|
||||
while (this.showGcodeMessage) {
|
||||
try {
|
||||
const toolpath = await api.get(`path/${file}`);
|
||||
this.toolpath_progress = toolpath.progress;
|
||||
|
||||
if (toolpath.progress === 1 || typeof toolpath.progress == "undefined") {
|
||||
this.showGcodeMessage = false;
|
||||
|
||||
if (toolpath.bounds) {
|
||||
toolpath.filename = file;
|
||||
this.toolpath_progress = 1;
|
||||
this.toolpath = toolpath;
|
||||
|
||||
const state = this.$root.state;
|
||||
for (const axis of "xyzabc") {
|
||||
Vue.set(state, `path_min_${axis}`, toolpath.bounds.min[axis]);
|
||||
Vue.set(state, `path_max_${axis}`, toolpath.bounds.max[axis]);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
submit_mdi: function () {
|
||||
this.send(this.mdi);
|
||||
|
||||
if (!this.history.length || this.history[0] != this.mdi) {
|
||||
this.history.unshift(this.mdi);
|
||||
}
|
||||
|
||||
this.mdi = "";
|
||||
},
|
||||
|
||||
mdi_start_pause: function () {
|
||||
if (this.state.xx == "RUNNING") {
|
||||
this.pause();
|
||||
} else if (this.state.xx == "STOPPING" || this.state.xx == "HOLDING") {
|
||||
this.unpause();
|
||||
} else {
|
||||
this.submit_mdi();
|
||||
}
|
||||
},
|
||||
|
||||
load_history: function (index) {
|
||||
this.mdi = this.history[index];
|
||||
},
|
||||
|
||||
open_file: function () {
|
||||
utils.clickFileInput("gcode-file-input");
|
||||
},
|
||||
|
||||
open_folder: function () {
|
||||
utils.clickFileInput("gcode-folder-input");
|
||||
},
|
||||
|
||||
edited_folder_name: function (event) {
|
||||
if (event.target.value.trim() != "") {
|
||||
this.$dispatch("folder_name_edited");
|
||||
}
|
||||
},
|
||||
|
||||
update_config: function () {
|
||||
this.config.gcode_list = [...this.state.gcode_list];
|
||||
this.config.non_macros_list = [...this.state.non_macros_list];
|
||||
this.config.macros_list = [...this.state.macros_list];
|
||||
this.config.macros = [...this.state.macros];
|
||||
},
|
||||
|
||||
reset_gcode: function () {
|
||||
this.state.selected = "";
|
||||
this.last_file = "";
|
||||
this.$broadcast("gcode-load", "");
|
||||
},
|
||||
|
||||
upload_gcode: async function (filename, file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
|
||||
this.filesUploaded++;
|
||||
if (this.filesUploaded == this.totalFiles) {
|
||||
this.uploading_files = false;
|
||||
}
|
||||
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve("file uploaded");
|
||||
} else {
|
||||
console.error("File upload failed:", xhr.statusText);
|
||||
reject("upload failed");
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = () => {
|
||||
alert("Upload failed.");
|
||||
reject("upload failed");
|
||||
};
|
||||
|
||||
xhr.open("PUT", `/api/file/${encodeURIComponent(filename)}`, true);
|
||||
xhr.send(file);
|
||||
});
|
||||
},
|
||||
|
||||
readFile: function (file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = () => {
|
||||
resolve(reader.result);
|
||||
};
|
||||
|
||||
reader.onerror = error => {
|
||||
reject(error);
|
||||
};
|
||||
|
||||
reader.readAsText(file, "utf-8");
|
||||
});
|
||||
},
|
||||
|
||||
validateFiles: async function (files) {
|
||||
const validFiles = [];
|
||||
for (const file of files) {
|
||||
const extension = file.name.split(".").pop().toLowerCase();
|
||||
const validExtensions = ["nc", "ngc", "gcode", "gc"];
|
||||
|
||||
if (validExtensions.includes(extension)) {
|
||||
validFiles.push(file);
|
||||
} else {
|
||||
alert(`Unsupported file : ${file.name}`);
|
||||
this.filesUploaded++;
|
||||
if (this.filesUploaded == this.totalFiles) {
|
||||
this.uploadFiles = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return validFiles;
|
||||
},
|
||||
|
||||
uploadValidFiles: async function (files, folderName) {
|
||||
const updatedConfig = { ...this.config };
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const gcode = await this.readFile(file);
|
||||
await this.upload_gcode(file.name, gcode);
|
||||
|
||||
const isAlreadyPresent = updatedConfig.non_macros_list.some(element => element.file_name === file.name);
|
||||
|
||||
if (!isAlreadyPresent) {
|
||||
updatedConfig.non_macros_list.push({ file_name: file.name });
|
||||
}
|
||||
|
||||
if (folderName) {
|
||||
const folder = updatedConfig.gcode_list.find(item => item.type == "folder" && item.name == folderName);
|
||||
if (folder) {
|
||||
if (!folder.files.map(item => item.file_name).includes(file.name)) {
|
||||
folder.files.push({ file_name: file.name });
|
||||
}
|
||||
} else {
|
||||
updatedConfig.gcode_list.push({
|
||||
name: folderName,
|
||||
type: "folder",
|
||||
files: [
|
||||
{
|
||||
file_name: file.name,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
} else {
|
||||
var folder_to_add = updatedConfig.gcode_list.find(
|
||||
item => item.type == "folder" && item.name == this.state.folder,
|
||||
);
|
||||
if (!folder_to_add) {
|
||||
folder_to_add = updatedConfig.gcode_list.unshift({
|
||||
name: this.state.folder,
|
||||
type: "folder",
|
||||
files: [
|
||||
{
|
||||
file_name: file.name,
|
||||
},
|
||||
],
|
||||
});
|
||||
folder_to_add = updatedConfig.gcode_list[0];
|
||||
}
|
||||
if (!folder_to_add.files.find(item => item.file_name == file.name)) {
|
||||
folder_to_add.files.push({ file_name: file.name });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`error uploading file : `, error);
|
||||
}
|
||||
}
|
||||
return updatedConfig;
|
||||
},
|
||||
|
||||
upload_files: async function (files, folderName) {
|
||||
this.update_config();
|
||||
|
||||
const validFiles = await this.validateFiles(files);
|
||||
const updatedConfig = await this.uploadValidFiles(validFiles, folderName);
|
||||
|
||||
await this.save_config(updatedConfig);
|
||||
},
|
||||
|
||||
upload_file: async function (e) {
|
||||
this.uploading_files = true;
|
||||
this.filesUploaded = 0;
|
||||
|
||||
const files = e.target.files || e.dataTransfer.files;
|
||||
if (!files.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.totalFiles = files.length;
|
||||
|
||||
await this.upload_files(files);
|
||||
},
|
||||
|
||||
create_new_folder: async function () {
|
||||
const folder_name = this.folder_name.trim();
|
||||
if (folder_name != "") {
|
||||
if (this.state.gcode_list.find(item => item.type == "folder" && item.name == folder_name)) {
|
||||
alert("Folder with the same name already exists!");
|
||||
return;
|
||||
} else {
|
||||
this.update_config();
|
||||
this.config.gcode_list.push({
|
||||
name: folder_name,
|
||||
type: "folder",
|
||||
files: [],
|
||||
});
|
||||
}
|
||||
this.state.folder = folder_name;
|
||||
this.edited = false;
|
||||
this.create_folder = false;
|
||||
this.folder_name = "";
|
||||
this.save_config(this.config);
|
||||
}
|
||||
},
|
||||
|
||||
cancel_new_folder: function () {
|
||||
this.create_folder = false;
|
||||
this.folder_name = "";
|
||||
},
|
||||
|
||||
upload_folder: async function (e) {
|
||||
this.uploading_files = true;
|
||||
this.filesUploaded = 0;
|
||||
|
||||
const files = e.target.files || e.dataTransfer.files;
|
||||
if (!files.length) {
|
||||
return;
|
||||
}
|
||||
this.totalFiles = files.length;
|
||||
const folderName = files[0].webkitRelativePath.split("/")[0];
|
||||
|
||||
this.upload_files(files, folderName);
|
||||
},
|
||||
|
||||
delete_current: async function () {
|
||||
if (!this.state.selected) {
|
||||
this.deleteGCode = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.update_config();
|
||||
|
||||
this.config.non_macros_list = this.config.non_macros_list.filter(
|
||||
item => !this.selected_items_to_delete.includes(item.file_name),
|
||||
);
|
||||
const folder_to_update = this.config.gcode_list.find(
|
||||
item => item.name == this.config.gcode_list[this.selected_folder_index].name && item.type == "folder",
|
||||
);
|
||||
folder_to_update.files = folder_to_update.files.filter(
|
||||
item => !this.selected_items_to_delete.includes(item.file_name),
|
||||
);
|
||||
|
||||
const exception_list = this.state.macros_list.map(item => item.file_name);
|
||||
let files_to_delete = this.selected_items_to_delete.filter(item => !exception_list.includes(item));
|
||||
|
||||
await api.delete(`file/DINCAIQABiDARixAxiABDIHCAMQABiABDIHCAQQABiABDIH${files_to_delete.toString()}`);
|
||||
|
||||
this.save_config(this.config);
|
||||
this.filtered_files = [];
|
||||
this.search_query = "";
|
||||
this.selected_folder_index = null;
|
||||
this.selected_items_to_delete = [];
|
||||
this.deleteGCode = false;
|
||||
},
|
||||
|
||||
cancel_delete: function () {
|
||||
this.filtered_files = [];
|
||||
this.search_query = "";
|
||||
this.selected_folder_index = null;
|
||||
this.selected_items_to_delete = [];
|
||||
this.deleteGCode = false;
|
||||
},
|
||||
|
||||
delete_all: function () {
|
||||
api.delete("file");
|
||||
this.deleteGCode = false;
|
||||
},
|
||||
|
||||
delete_all_except_macros: async function () {
|
||||
this.update_config();
|
||||
const macrosList = this.state.macros_list.map(item => item.file_name).toString();
|
||||
api.delete(`file/EgZjaHJvbWUqCggBEAAYsQMYgAQyBggAEEUYOTIKCAE${macrosList}`);
|
||||
this.config.non_macros_list = [];
|
||||
this.config.gcode_list = [
|
||||
{
|
||||
name: "default",
|
||||
type: "folder",
|
||||
files: [],
|
||||
},
|
||||
];
|
||||
|
||||
this.save_config(this.config);
|
||||
this.state.folder = "default";
|
||||
this.state.selected = "";
|
||||
this.selected_items_to_delete = [];
|
||||
this.deleteGCode = false;
|
||||
},
|
||||
|
||||
delete_folder: async function () {
|
||||
this.update_config();
|
||||
if (this.state.folder && this.state.folder != "default") {
|
||||
const files_to_move = this.config.gcode_list.find(
|
||||
item => item.type == "folder" && item.name == this.state.folder,
|
||||
);
|
||||
if (files_to_move) {
|
||||
const default_folder = this.config.gcode_list.find(item => item.name == "default");
|
||||
default_folder.files = [...default_folder.files, ...files_to_move.files].sort();
|
||||
this.config.gcode_list = this.config.gcode_list.filter(item => item.name != this.state.folder);
|
||||
this.save_config(this.config);
|
||||
}
|
||||
}
|
||||
this.state.folder = "default";
|
||||
this.confirmDelete = false;
|
||||
},
|
||||
delete_folder_and_files: async function () {
|
||||
if (!this.state.folder) {
|
||||
this.confirmDelete = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.update_config();
|
||||
|
||||
const selected_folder = this.config.gcode_list.find(
|
||||
item => item.type == "folder" && item.name == this.state.folder,
|
||||
);
|
||||
if (!selected_folder) {
|
||||
return;
|
||||
}
|
||||
const macrosList = this.state.macros_list.map(item => item.file_name);
|
||||
var files_to_delete = selected_folder.files
|
||||
.map(item => item.file_name)
|
||||
.filter(item => !macrosList.includes(item));
|
||||
if (selected_folder.name != "default") {
|
||||
this.config.gcode_list = this.config.gcode_list.filter(item => item.name != this.state.folder);
|
||||
} else {
|
||||
selected_folder.files = [];
|
||||
}
|
||||
|
||||
await api.delete(`file/DINCAIQABiDARixAxiABDIHCAMQABiABDIHCAQQABiABDIH${files_to_delete.toString()}`);
|
||||
this.config.non_macros_list = this.config.non_macros_list.filter(
|
||||
item => !files_to_delete.includes(item.file_name),
|
||||
);
|
||||
this.save_config(this.config);
|
||||
this.state.folder = "default";
|
||||
this.confirmDelete = false;
|
||||
},
|
||||
|
||||
home: function (axis) {
|
||||
this.ask_home = false;
|
||||
|
||||
@@ -765,6 +249,85 @@ module.exports = {
|
||||
api.put(`home/${axis}/clear`);
|
||||
},
|
||||
|
||||
aux_home: function () {
|
||||
api.put("aux/home").catch(function (err) {
|
||||
console.error("Aux home failed:", err);
|
||||
});
|
||||
},
|
||||
|
||||
// Home every enabled axis (legacy Onefinity "Home All"). Sequence:
|
||||
// 1. Z, X, Y (and A/B/C if enabled) via /api/home on the AVR
|
||||
// 2. Auxiliary axis via /api/aux/home on the ESP
|
||||
// ONLY when the auxcnc axis is not integrated as a virtual
|
||||
// machine axis. With the gplan A-axis integration (synthetic
|
||||
// motor 4 enabled), Mach.home() already homes the external
|
||||
// axis as part of the xyzabc pass - calling aux/home
|
||||
// afterwards would home it a second time.
|
||||
// /api/home returns as soon as the request is queued, not when
|
||||
// homing completes, so we have to watch state.cycle:
|
||||
// - first wait for it to *leave* 'idle' (cycle began),
|
||||
// - then wait for it to come *back* to 'idle' (cycle ended).
|
||||
// Only then do we fire the auxiliary home, so the gantry and the
|
||||
// auxcnc ESP never move at the same time.
|
||||
home_all: async function () {
|
||||
this.ask_home = false;
|
||||
try {
|
||||
await api.put("home");
|
||||
} catch (e) {
|
||||
console.error("Home all (XYZ) failed:", e);
|
||||
return;
|
||||
}
|
||||
if (!this.w || !this.w.enabled) return;
|
||||
|
||||
// When the synthetic external motor (index 4) is enabled,
|
||||
// the auxcnc axis is mapped onto a real machine axis letter
|
||||
// (e.g. A) and was already homed by /api/home above.
|
||||
if (this.state && this.state["4me"]) return;
|
||||
|
||||
const wait = (ms) => new Promise(r => setTimeout(r, ms));
|
||||
const cycleNow = () => (this.state && this.state.cycle) || "idle";
|
||||
|
||||
// Phase 1: wait up to 5s for the homing cycle to actually start.
|
||||
// If the request was rejected upstream (e.g. estopped) cycle
|
||||
// never leaves idle and we bail rather than home A in isolation.
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < 5000) {
|
||||
if (cycleNow() != "idle") break;
|
||||
await wait(100);
|
||||
}
|
||||
if (cycleNow() == "idle") {
|
||||
console.warn("home_all: main homing cycle never started; skipping aux");
|
||||
return;
|
||||
}
|
||||
|
||||
// Phase 2: wait up to 2 minutes for the gantry to finish.
|
||||
const settledAt = Date.now();
|
||||
while (Date.now() - settledAt < 120000) {
|
||||
if (cycleNow() == "idle") break;
|
||||
await wait(200);
|
||||
}
|
||||
if (cycleNow() != "idle") {
|
||||
console.warn("home_all: gantry homing did not complete in time");
|
||||
return;
|
||||
}
|
||||
|
||||
api.put("aux/home").catch(function (err) {
|
||||
console.error("Aux home failed:", err);
|
||||
});
|
||||
},
|
||||
|
||||
aux_jog: function (delta_mm) {
|
||||
api.put("aux/jog", { mm: delta_mm }).catch(function (err) {
|
||||
console.error("Aux jog failed:", err);
|
||||
});
|
||||
},
|
||||
|
||||
aux_jog_incr: function (sign) {
|
||||
const amount = this.jog_incr_amounts[this.display_units][this.jog_incr];
|
||||
const delta_mm = sign * (this.metric ? amount : amount * 25.4);
|
||||
this.aux_jog(delta_mm);
|
||||
},
|
||||
|
||||
show_set_position: function (axis) {
|
||||
SvelteComponents.showDialog("SetAxisPosition", { axis });
|
||||
},
|
||||
@@ -790,93 +353,20 @@ module.exports = {
|
||||
},
|
||||
|
||||
zero: function (axis) {
|
||||
if (typeof axis == "undefined") {
|
||||
this.zero_all();
|
||||
} else {
|
||||
this.set_position(axis, 0);
|
||||
}
|
||||
},
|
||||
|
||||
start_pause: function () {
|
||||
this.macrosLoading = false;
|
||||
if (this.state.xx == "RUNNING") {
|
||||
this.pause();
|
||||
} else if (this.state.xx == "STOPPING" || this.state.xx == "HOLDING") {
|
||||
this.unpause();
|
||||
} else {
|
||||
this.start();
|
||||
}
|
||||
},
|
||||
|
||||
start: function () {
|
||||
api.put("start");
|
||||
},
|
||||
|
||||
pause: function () {
|
||||
api.put("pause");
|
||||
},
|
||||
|
||||
unpause: function () {
|
||||
api.put("unpause");
|
||||
},
|
||||
|
||||
optional_pause: function () {
|
||||
api.put("pause/optional");
|
||||
},
|
||||
|
||||
stop: function () {
|
||||
api.put("stop");
|
||||
},
|
||||
|
||||
step: function () {
|
||||
api.put("step");
|
||||
},
|
||||
|
||||
override_feed: function () {
|
||||
api.put(`override/feed/${this.feed_override}`);
|
||||
},
|
||||
|
||||
override_speed: function () {
|
||||
api.put(`override/speed/${this.speed_override}`);
|
||||
},
|
||||
|
||||
current: function (axis, value) {
|
||||
const x = value / 32.0;
|
||||
if (this.state[`${axis}pl`] == x) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = {};
|
||||
data[`${axis}pl`] = x;
|
||||
this.send(JSON.stringify(data));
|
||||
if (typeof axis == "undefined") this.zero_all();
|
||||
else this.set_position(axis, 0);
|
||||
},
|
||||
|
||||
showProbeDialog: function (probeType) {
|
||||
if(this.show_probe_dialog){
|
||||
if (this.show_probe_dialog) {
|
||||
this.show_probe_dialog = false;
|
||||
}
|
||||
SvelteComponents.showDialog("Probe", { probeType, isRotaryActive: this.state["2an"] == 3 });
|
||||
},
|
||||
run_macro: function (id) {
|
||||
if (this.state.macros[id].file_name == "default") {
|
||||
this.showNoGcodeMessage = true;
|
||||
} else {
|
||||
if (this.state.macros[id].file_name != this.state.selected) {
|
||||
this.state.selected = this.state.macros[id].file_name;
|
||||
}
|
||||
try {
|
||||
this.load();
|
||||
if (this.state.macros[id].alert == true) {
|
||||
this.macrosLoading = true;
|
||||
} else {
|
||||
setImmediate(() => this.start_pause());
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Error running program: ", error);
|
||||
}
|
||||
}
|
||||
SvelteComponents.showDialog("Probe", {
|
||||
probeType,
|
||||
isRotaryActive: this.state["2an"] == 3,
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
mixins: [require("./axis-vars")],
|
||||
mixins: [require("./program-mixin"), require("./axis-vars")],
|
||||
};
|
||||
|
||||
@@ -49,14 +49,17 @@ module.exports = {
|
||||
methods: {
|
||||
get_io_state_class: function(active, state) {
|
||||
if (typeof active == "undefined" || typeof state == "undefined") {
|
||||
return "fa-exclamation-triangle warn";
|
||||
return "fa-triangle-exclamation warn";
|
||||
}
|
||||
|
||||
// Tristated: render as the regular (outline) circle to
|
||||
// distinguish from active/inactive solid circles. Adding
|
||||
// `far` switches to the FA6 regular family.
|
||||
if (state == 2) {
|
||||
return "fa-circle-o";
|
||||
return "far fa-circle";
|
||||
}
|
||||
|
||||
const icon = state ? "fa-plus-circle" : "fa-minus-circle";
|
||||
const icon = state ? "fa-circle-plus" : "fa-circle-minus";
|
||||
return `${icon} ${active ? "active" : "inactive"}`;
|
||||
},
|
||||
|
||||
|
||||
@@ -44,6 +44,16 @@ window.onload = function() {
|
||||
cookie_set("client-id", uuid(), 10000);
|
||||
}
|
||||
|
||||
// Vue 1's async queue can drop dependent watcher updates when
|
||||
// data props are mutated outside the normal event flow (e.g. from
|
||||
// a `hashchange` listener that fires before Vue's tick scheduler
|
||||
// has caught up). Disable async batching so every reactive write
|
||||
// synchronously re-evaluates dependents — this matches Vue 1's
|
||||
// older default and is what the legacy UI implicitly relied on.
|
||||
if (Vue.config) {
|
||||
Vue.config.async = false;
|
||||
}
|
||||
|
||||
// Register global components
|
||||
Vue.component("templated-input", require("./templated-input"));
|
||||
Vue.component("message", require("./message"));
|
||||
|
||||
@@ -87,100 +87,16 @@ module.exports = {
|
||||
return this.stallRPM * this.stepsPerRev * ustep / 60;
|
||||
},
|
||||
|
||||
current_axis: function() {
|
||||
return this.state[this.index + 'an'];
|
||||
},
|
||||
|
||||
current_max_velocity: function() {
|
||||
return this.state[this.index + 'vm'];
|
||||
},
|
||||
|
||||
current_max_soft_limit: function() {
|
||||
return this.state[this.index + 'tm'];
|
||||
},
|
||||
|
||||
current_min_soft_limit: function() {
|
||||
return this.state[this.index + 'tn'];
|
||||
},
|
||||
current_max_accel: function() {
|
||||
return this.state[this.index + 'am'];
|
||||
},
|
||||
current_max_jerk: function() {
|
||||
return this.state[this.index + 'jm'];
|
||||
},
|
||||
current_step_angle: function() {
|
||||
return this.state[this.index + 'sa'];
|
||||
},
|
||||
current_travel_per_rev: function() {
|
||||
return this.state[this.index + 'tr'];
|
||||
},
|
||||
current_microsteps: function() {
|
||||
return this.state[this.index + 'mi'];
|
||||
}
|
||||
},
|
||||
|
||||
attached: function() {
|
||||
// Sync all state values with motor config when component is ready
|
||||
// This ensures UI shows correct values when component is first loaded
|
||||
console.log("Syncing state to motor config for motor index ",this.index);
|
||||
this.syncStateToConfig();
|
||||
},
|
||||
|
||||
watch: {
|
||||
current_axis(new_value) {
|
||||
const motor_axes = ["X", "Y", "Z", "A", "B", "C"]
|
||||
if(motor_axes[new_value] != this.motor['axis']){
|
||||
this.motor['axis'] = motor_axes[new_value];
|
||||
}
|
||||
},
|
||||
|
||||
current_max_velocity(new_value) {
|
||||
if(new_value != this.motor['max-velocity']) {
|
||||
this.motor['max-velocity'] = new_value;
|
||||
}
|
||||
},
|
||||
|
||||
current_max_soft_limit(new_value) {
|
||||
if(new_value != this.motor['max-soft-limit']) {
|
||||
this.motor['max-soft-limit'] = new_value;
|
||||
}
|
||||
},
|
||||
|
||||
current_min_soft_limit(new_value) {
|
||||
if(new_value != this.motor['min-soft-limit']) {
|
||||
this.motor['min-soft-limit'] = new_value;
|
||||
}
|
||||
},
|
||||
|
||||
current_max_accel(new_value) {
|
||||
if(new_value != this.motor['max-accel']) {
|
||||
this.motor['max-accel'] = new_value;
|
||||
}
|
||||
},
|
||||
|
||||
current_max_jerk(new_value) {
|
||||
if(new_value != this.motor['max-jerk']) {
|
||||
this.motor['max-jerk'] = new_value;
|
||||
}
|
||||
},
|
||||
|
||||
current_step_angle(new_value) {
|
||||
if(new_value != this.motor['step-angle']) {
|
||||
this.motor['step-angle'] = new_value;
|
||||
}
|
||||
},
|
||||
|
||||
current_travel_per_rev(new_value) {
|
||||
if(new_value != this.motor['travel-per-rev']) {
|
||||
this.motor['travel-per-rev'] = new_value;
|
||||
}
|
||||
},
|
||||
|
||||
current_microsteps(new_value) {
|
||||
if(new_value != this.motor['microsteps']) {
|
||||
this.motor['microsteps'] = new_value;
|
||||
}
|
||||
}
|
||||
// NOTE: do not add `current_xxx` computed props that mirror
|
||||
// controller state vars (`<idx>vm`, `<idx>am`, …) and pair
|
||||
// them with watchers that copy state -> motor config. The
|
||||
// controller streams those vars continuously over the WS;
|
||||
// any watcher that writes them back into
|
||||
// `config.motors[index]` will clobber whatever the user is
|
||||
// typing into the form between websocket ticks. The form
|
||||
// edits config directly; Save (app.js) PUTs it to the
|
||||
// server. The server-side rotary toggle is handled by
|
||||
// refetching config after the PUT, not by watching state.
|
||||
},
|
||||
|
||||
events: {
|
||||
@@ -210,45 +126,6 @@ module.exports = {
|
||||
}
|
||||
|
||||
return templ.hmodes.indexOf(this.motor["homing-mode"]) != -1;
|
||||
},
|
||||
|
||||
syncStateToConfig: function() {
|
||||
// Force sync all state values to motor config
|
||||
// This ensures the UI reflects the current state even if changes happened while component was unmounted
|
||||
|
||||
if(this.state == undefined) {
|
||||
console.log("State is undefined");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.state[this.index + 'an'] != this.motor['axis']) {
|
||||
const motor_axes = ["X", "Y", "Z", "A", "B", "C"];
|
||||
this.$set('motor["axis"]', motor_axes[this.state[this.index + 'an']]);
|
||||
}
|
||||
if (this.state[this.index + 'vm'] != this.motor['max-velocity']) {
|
||||
this.$set('motor["max-velocity"]', this.state[this.index + 'vm']);
|
||||
}
|
||||
if (this.state[this.index + 'tm'] != this.motor['max-soft-limit']) {
|
||||
this.$set('motor["max-soft-limit"]', this.state[this.index + 'tm']);
|
||||
}
|
||||
if (this.state[this.index + 'tn'] != this.motor['min-soft-limit']) {
|
||||
this.$set('motor["min-soft-limit"]', this.state[this.index + 'tn']);
|
||||
}
|
||||
if (this.state[this.index + 'am'] != this.motor['max-accel']) {
|
||||
this.$set('motor["max-accel"]', this.state[this.index + 'am']);
|
||||
}
|
||||
if (this.state[this.index + 'jm'] != this.motor['max-jerk']) {
|
||||
this.$set('motor["max-jerk"]', this.state[this.index + 'jm']);
|
||||
}
|
||||
if (this.state[this.index + 'sa'] != this.motor['step-angle']) {
|
||||
this.$set('motor["step-angle"]', this.state[this.index + 'sa']);
|
||||
}
|
||||
if (this.state[this.index + 'tr'] != this.motor['travel-per-rev']) {
|
||||
this.$set('motor["travel-per-rev"]', this.state[this.index + 'tr']);
|
||||
}
|
||||
if (this.state[this.index + 'mi'] != this.motor['microsteps']) {
|
||||
this.$set('motor["microsteps"]', this.state[this.index + 'mi']);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -683,12 +683,16 @@ const OrbitControls = function(object, domElement) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
// Chrome treats touch/wheel listeners as passive by default,
|
||||
// which prevents OrbitControls.preventDefault() from suppressing
|
||||
// page panning while interacting with the 3D viewer. Pass
|
||||
// {passive: false} on the events that need to call preventDefault.
|
||||
scope.domElement.addEventListener("contextmenu", onContextMenu, false);
|
||||
scope.domElement.addEventListener("mousedown", onMouseDown, false);
|
||||
scope.domElement.addEventListener("wheel", onMouseWheel, false);
|
||||
scope.domElement.addEventListener("touchstart", onTouchStart, false);
|
||||
scope.domElement.addEventListener("wheel", onMouseWheel, { passive: false });
|
||||
scope.domElement.addEventListener("touchstart", onTouchStart, { passive: false });
|
||||
scope.domElement.addEventListener("touchend", onTouchEnd, false);
|
||||
scope.domElement.addEventListener("touchmove", onTouchMove, false);
|
||||
scope.domElement.addEventListener("touchmove", onTouchMove, { passive: false });
|
||||
window.addEventListener("keydown", onKeyDown, false);
|
||||
|
||||
this.update(); // force an update at start
|
||||
|
||||
@@ -101,6 +101,13 @@ module.exports = {
|
||||
Vue.nextTick(this.update);
|
||||
},
|
||||
|
||||
beforeDestroy: function() {
|
||||
if (this._sizeWatcher) {
|
||||
this._sizeWatcher.disconnect();
|
||||
this._sizeWatcher = null;
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
update: async function() {
|
||||
if (!this.webglAvailable) {
|
||||
@@ -201,6 +208,12 @@ module.exports = {
|
||||
}
|
||||
|
||||
const dims = this.get_dims();
|
||||
// Skip layouts where the target has no measurable size.
|
||||
// The render loop guard below will not draw frames until
|
||||
// a real size has been observed at least once.
|
||||
if (!(dims.width > 0 && dims.height > 0)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.camera.aspect = dims.width / dims.height;
|
||||
this.camera.updateProjectionMatrix();
|
||||
@@ -274,12 +287,23 @@ module.exports = {
|
||||
}
|
||||
|
||||
try {
|
||||
// Renderer
|
||||
this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
||||
// Renderer. Use an opaque canvas with a clear color
|
||||
// that matches the page-side gradient so the moment
|
||||
// the canvas is appended (and before the first 3D
|
||||
// frame is drawn) the user does not see a flash from
|
||||
// the page background through transparency.
|
||||
this.renderer = new THREE.WebGLRenderer({
|
||||
antialias: true,
|
||||
alpha: false,
|
||||
});
|
||||
this.renderer.setPixelRatio(window.devicePixelRatio);
|
||||
this.renderer.setClearColor(0, 0);
|
||||
this.renderer.setClearColor(0x222222, 1);
|
||||
// Same color on the DOM element itself so the very
|
||||
// first paint (before the WebGL context has cleared)
|
||||
// is dark too.
|
||||
this.renderer.domElement.style.background = "#222222";
|
||||
this.renderer.domElement.style.display = "block";
|
||||
this.target.appendChild(this.renderer.domElement);
|
||||
|
||||
} catch (e) {
|
||||
console.log("WebGL not supported: ", e);
|
||||
return;
|
||||
@@ -333,8 +357,46 @@ module.exports = {
|
||||
// Events
|
||||
window.addEventListener("resize", this.update_view, false);
|
||||
|
||||
// Start it
|
||||
this.render();
|
||||
// Start the render loop only after the target has a real,
|
||||
// stable size. Without this, the first frame paints into
|
||||
// a 0×0 / collapsed-flex canvas and a second frame paints
|
||||
// again at the right size — visible as a flash on the
|
||||
// very first mount of the Program tab.
|
||||
const startRendering = () => {
|
||||
if (this._rendering) return;
|
||||
this._rendering = true;
|
||||
this.update_view();
|
||||
this.render();
|
||||
};
|
||||
|
||||
const dims = this.get_dims();
|
||||
if (dims.width > 0 && dims.height > 0) {
|
||||
startRendering();
|
||||
} else if (typeof ResizeObserver !== "undefined") {
|
||||
this._sizeWatcher = new ResizeObserver(entries => {
|
||||
for (const entry of entries) {
|
||||
const r = entry.contentRect;
|
||||
if (r.width > 0 && r.height > 0) {
|
||||
this._sizeWatcher.disconnect();
|
||||
this._sizeWatcher = null;
|
||||
startRendering();
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
this._sizeWatcher.observe(this.target);
|
||||
} else {
|
||||
// Old browser fallback: poll for a non-zero size.
|
||||
const tick = () => {
|
||||
const d = this.get_dims();
|
||||
if (d.width > 0 && d.height > 0) {
|
||||
startRendering();
|
||||
} else {
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
},
|
||||
|
||||
create_surface_material: function() {
|
||||
@@ -646,6 +708,14 @@ module.exports = {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't paint frames while the target has no size; this
|
||||
// prevents an initial single-frame clear from painting
|
||||
// before the layout has settled (visible as a dark flash).
|
||||
const dims = this.get_dims();
|
||||
if (!(dims.width > 0 && dims.height > 0)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.controls.update() || this.dirty) {
|
||||
this.dirty = false;
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
|
||||
607
src/js/program-mixin.js
Normal file
607
src/js/program-mixin.js
Normal file
@@ -0,0 +1,607 @@
|
||||
"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";
|
||||
},
|
||||
|
||||
// True only while a loaded G-code program is actually being
|
||||
// executed (running, paused/holding, or stopping). Excludes
|
||||
// jogging, homing, probing, MDI commands and other one-off
|
||||
// motion that also leave state.xx == "RUNNING" but must not
|
||||
// swap the jog grid out for the "Now Running" panel.
|
||||
//
|
||||
// Distinguishing signal is state.cycle:
|
||||
// - "idle" : nothing happening
|
||||
// - "jogging" : user-initiated jog
|
||||
// - "homing" : home cycle
|
||||
// - "probing" : probe cycle
|
||||
// - "mdi" : single MDI command
|
||||
// - "running" : an actual loaded program is being run
|
||||
// Only "running" (combined with a selected file) is what we want.
|
||||
is_program_executing: function () {
|
||||
if (!this.state) return false;
|
||||
const xx = this.state.xx;
|
||||
const cycle = this.state.cycle;
|
||||
const isExecState = xx == "RUNNING" || xx == "HOLDING" || xx == "STOPPING";
|
||||
if (!isExecState) return false;
|
||||
// The cycle string narrows it to a real program run; anything
|
||||
// else (jogging / homing / probing / mdi) is a one-off.
|
||||
if (cycle && cycle != "running" && cycle != "idle") return false;
|
||||
return !!this.state.selected;
|
||||
},
|
||||
|
||||
is_paused: function () {
|
||||
return this.is_holding && (this.pause_reason == "User pause" || this.pause_reason == "Program pause");
|
||||
},
|
||||
|
||||
can_mdi: function () {
|
||||
return this.is_idle || this.state.cycle == "mdi";
|
||||
},
|
||||
|
||||
pause_reason: function () {
|
||||
return this.state.pr;
|
||||
},
|
||||
|
||||
plan_time: function () {
|
||||
return this.state.plan_time;
|
||||
},
|
||||
|
||||
plan_time_remaining: function () {
|
||||
if (!(this.is_stopping || this.is_running || this.is_holding)) {
|
||||
return 0;
|
||||
}
|
||||
return this.toolpath.time - this.plan_time;
|
||||
},
|
||||
|
||||
eta: function () {
|
||||
if (this.mach_state != "RUNNING") {
|
||||
return "";
|
||||
}
|
||||
const remaining = this.plan_time_remaining;
|
||||
const d = new Date();
|
||||
d.setSeconds(d.getSeconds() + remaining);
|
||||
return d.toLocaleString();
|
||||
},
|
||||
|
||||
progress: function () {
|
||||
if (!this.toolpath.time || this.is_ready) {
|
||||
return 0;
|
||||
}
|
||||
const p = this.plan_time / this.toolpath.time;
|
||||
return Math.min(1, p);
|
||||
},
|
||||
|
||||
gcode_files: function () {
|
||||
if (!this.state.folder) return [];
|
||||
const list = Array.isArray(this.state.gcode_list) ? this.state.gcode_list : [];
|
||||
const folder = list.find(item => item.name == this.state.folder);
|
||||
if (!folder) return [];
|
||||
const stateFiles = Array.isArray(this.state.files) ? this.state.files : [];
|
||||
const files = (folder.files || [])
|
||||
.filter(item => stateFiles.includes(item.file_name))
|
||||
.map(item => item.file_name);
|
||||
if (this.files_sortby == "A-Z") return files.sort();
|
||||
if (this.files_sortby == "Z-A") return files.sort().reverse();
|
||||
return files;
|
||||
},
|
||||
|
||||
gcode_filtered_files: function () {
|
||||
return this.filtered_files.filter(file =>
|
||||
file.toLowerCase().includes(this.search_query.toLowerCase()));
|
||||
},
|
||||
|
||||
gcode_folders: function () {
|
||||
const list = Array.isArray(this.state.gcode_list) ? this.state.gcode_list : [];
|
||||
return list
|
||||
.map(item => item.name)
|
||||
.filter(element => element !== "default")
|
||||
.sort();
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
save_config: async function (config) {
|
||||
try {
|
||||
await api.put("config/save", config);
|
||||
this.$dispatch("update");
|
||||
} catch (error) {
|
||||
console.error("Restore Failed: ", error);
|
||||
alert("Restore failed");
|
||||
}
|
||||
},
|
||||
|
||||
populateFiles(index) {
|
||||
this.selected_folder_index = index;
|
||||
this.filtered_files = this.state.gcode_list[index].files.map(item => item.file_name);
|
||||
},
|
||||
|
||||
send: function (msg) {
|
||||
this.$dispatch("send", msg);
|
||||
},
|
||||
|
||||
toggle_sorting: function () {
|
||||
if (this.files_sortby === "By Upload Date") this.files_sortby = "A-Z";
|
||||
else if (this.files_sortby === "A-Z") this.files_sortby = "Z-A";
|
||||
else if (this.files_sortby === "Z-A") this.files_sortby = "By Upload Date";
|
||||
},
|
||||
|
||||
load: function () {
|
||||
const file_time = this.state.selected_time;
|
||||
const file = this.state.selected;
|
||||
if (this.last_file == file && this.last_file_time == file_time) return;
|
||||
|
||||
// state.files can be undefined briefly after connect, before the
|
||||
// controller has pushed its file list. Skip the existence check
|
||||
// until we have a list to consult.
|
||||
const files = Array.isArray(this.state.files) ? this.state.files : null;
|
||||
if (this.state.selected && files && !files.includes(this.state.selected)) {
|
||||
this.GCodeNotFound = true;
|
||||
return;
|
||||
}
|
||||
|
||||
this.last_file = file;
|
||||
this.last_file_time = file_time;
|
||||
|
||||
this.$broadcast("gcode-load", file);
|
||||
this.$broadcast("gcode-line", this.state.line);
|
||||
this.toolpath_progress = 0;
|
||||
this.load_toolpath(file, file_time);
|
||||
},
|
||||
|
||||
load_toolpath: async function (file, file_time) {
|
||||
this.toolpath = {};
|
||||
if (!file || this.last_file_time != file_time) return;
|
||||
|
||||
this.showGcodeMessage = true;
|
||||
|
||||
while (this.showGcodeMessage) {
|
||||
try {
|
||||
const toolpath = await api.get(`path/${file}`);
|
||||
this.toolpath_progress = toolpath.progress;
|
||||
|
||||
if (toolpath.progress === 1 || typeof toolpath.progress == "undefined") {
|
||||
this.showGcodeMessage = false;
|
||||
|
||||
if (toolpath.bounds) {
|
||||
toolpath.filename = file;
|
||||
this.toolpath_progress = 1;
|
||||
this.toolpath = toolpath;
|
||||
|
||||
const state = this.$root.state;
|
||||
for (const axis of "xyzabc") {
|
||||
Vue.set(state, `path_min_${axis}`, toolpath.bounds.min[axis]);
|
||||
Vue.set(state, `path_max_${axis}`, toolpath.bounds.max[axis]);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
submit_mdi: function () {
|
||||
this.send(this.mdi);
|
||||
if (!this.history.length || this.history[0] != this.mdi) {
|
||||
this.history.unshift(this.mdi);
|
||||
}
|
||||
this.mdi = "";
|
||||
},
|
||||
|
||||
mdi_start_pause: function () {
|
||||
if (this.state.xx == "RUNNING") this.pause();
|
||||
else if (this.state.xx == "STOPPING" || this.state.xx == "HOLDING") this.unpause();
|
||||
else this.submit_mdi();
|
||||
},
|
||||
|
||||
load_history: function (index) {
|
||||
this.mdi = this.history[index];
|
||||
},
|
||||
|
||||
open_file: function () {
|
||||
utils.clickFileInput("gcode-file-input");
|
||||
},
|
||||
|
||||
open_folder: function () {
|
||||
utils.clickFileInput("gcode-folder-input");
|
||||
},
|
||||
|
||||
edited_folder_name: function (event) {
|
||||
if (event.target.value.trim() != "") {
|
||||
this.$dispatch("folder_name_edited");
|
||||
}
|
||||
},
|
||||
|
||||
update_config: function () {
|
||||
this.config.gcode_list = [...this.state.gcode_list];
|
||||
this.config.non_macros_list = [...this.state.non_macros_list];
|
||||
this.config.macros_list = [...this.state.macros_list];
|
||||
this.config.macros = [...this.state.macros];
|
||||
},
|
||||
|
||||
reset_gcode: function () {
|
||||
this.state.selected = "";
|
||||
this.last_file = "";
|
||||
this.$broadcast("gcode-load", "");
|
||||
},
|
||||
|
||||
upload_gcode: async function (filename, file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
this.filesUploaded++;
|
||||
if (this.filesUploaded == this.totalFiles) {
|
||||
this.uploading_files = false;
|
||||
}
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) resolve("file uploaded");
|
||||
else { console.error("File upload failed:", xhr.statusText); reject("upload failed"); }
|
||||
};
|
||||
xhr.onerror = () => { alert("Upload failed."); reject("upload failed"); };
|
||||
xhr.open("PUT", `/api/file/${encodeURIComponent(filename)}`, true);
|
||||
xhr.send(file);
|
||||
});
|
||||
},
|
||||
|
||||
readFile: function (file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result);
|
||||
reader.onerror = error => reject(error);
|
||||
reader.readAsText(file, "utf-8");
|
||||
});
|
||||
},
|
||||
|
||||
validateFiles: async function (files) {
|
||||
const validFiles = [];
|
||||
for (const file of files) {
|
||||
const extension = file.name.split(".").pop().toLowerCase();
|
||||
const validExtensions = ["nc", "ngc", "gcode", "gc"];
|
||||
if (validExtensions.includes(extension)) {
|
||||
validFiles.push(file);
|
||||
} else {
|
||||
alert(`Unsupported file : ${file.name}`);
|
||||
this.filesUploaded++;
|
||||
if (this.filesUploaded == this.totalFiles) {
|
||||
this.uploadFiles = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return validFiles;
|
||||
},
|
||||
|
||||
uploadValidFiles: async function (files, folderName) {
|
||||
const updatedConfig = { ...this.config };
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const gcode = await this.readFile(file);
|
||||
await this.upload_gcode(file.name, gcode);
|
||||
|
||||
const isAlreadyPresent = updatedConfig.non_macros_list.some(element => element.file_name === file.name);
|
||||
if (!isAlreadyPresent) {
|
||||
updatedConfig.non_macros_list.push({ file_name: file.name });
|
||||
}
|
||||
|
||||
if (folderName) {
|
||||
const folder = updatedConfig.gcode_list.find(item => item.type == "folder" && item.name == folderName);
|
||||
if (folder) {
|
||||
if (!folder.files.map(item => item.file_name).includes(file.name)) {
|
||||
folder.files.push({ file_name: file.name });
|
||||
}
|
||||
} else {
|
||||
updatedConfig.gcode_list.push({
|
||||
name: folderName,
|
||||
type: "folder",
|
||||
files: [{ file_name: file.name }],
|
||||
});
|
||||
}
|
||||
} else {
|
||||
var folder_to_add = updatedConfig.gcode_list.find(
|
||||
item => item.type == "folder" && item.name == this.state.folder,
|
||||
);
|
||||
if (!folder_to_add) {
|
||||
folder_to_add = updatedConfig.gcode_list.unshift({
|
||||
name: this.state.folder,
|
||||
type: "folder",
|
||||
files: [{ file_name: file.name }],
|
||||
});
|
||||
folder_to_add = updatedConfig.gcode_list[0];
|
||||
}
|
||||
if (!folder_to_add.files.find(item => item.file_name == file.name)) {
|
||||
folder_to_add.files.push({ file_name: file.name });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`error uploading file : `, error);
|
||||
}
|
||||
}
|
||||
return updatedConfig;
|
||||
},
|
||||
|
||||
upload_files: async function (files, folderName) {
|
||||
this.update_config();
|
||||
const validFiles = await this.validateFiles(files);
|
||||
const updatedConfig = await this.uploadValidFiles(validFiles, folderName);
|
||||
await this.save_config(updatedConfig);
|
||||
},
|
||||
|
||||
upload_file: async function (e) {
|
||||
this.uploading_files = true;
|
||||
this.filesUploaded = 0;
|
||||
const files = e.target.files || e.dataTransfer.files;
|
||||
if (!files.length) return;
|
||||
this.totalFiles = files.length;
|
||||
await this.upload_files(files);
|
||||
},
|
||||
|
||||
create_new_folder: async function () {
|
||||
const folder_name = this.folder_name.trim();
|
||||
if (folder_name != "") {
|
||||
if (this.state.gcode_list.find(item => item.type == "folder" && item.name == folder_name)) {
|
||||
alert("Folder with the same name already exists!");
|
||||
return;
|
||||
}
|
||||
this.update_config();
|
||||
this.config.gcode_list.push({
|
||||
name: folder_name,
|
||||
type: "folder",
|
||||
files: [],
|
||||
});
|
||||
this.state.folder = folder_name;
|
||||
this.edited = false;
|
||||
this.create_folder = false;
|
||||
this.folder_name = "";
|
||||
this.save_config(this.config);
|
||||
}
|
||||
},
|
||||
|
||||
cancel_new_folder: function () {
|
||||
this.create_folder = false;
|
||||
this.folder_name = "";
|
||||
},
|
||||
|
||||
upload_folder: async function (e) {
|
||||
this.uploading_files = true;
|
||||
this.filesUploaded = 0;
|
||||
const files = e.target.files || e.dataTransfer.files;
|
||||
if (!files.length) return;
|
||||
this.totalFiles = files.length;
|
||||
const folderName = files[0].webkitRelativePath.split("/")[0];
|
||||
this.upload_files(files, folderName);
|
||||
},
|
||||
|
||||
delete_current: async function () {
|
||||
if (!this.state.selected) {
|
||||
this.deleteGCode = false;
|
||||
return;
|
||||
}
|
||||
this.update_config();
|
||||
|
||||
this.config.non_macros_list = this.config.non_macros_list.filter(
|
||||
item => !this.selected_items_to_delete.includes(item.file_name),
|
||||
);
|
||||
const folder_to_update = this.config.gcode_list.find(
|
||||
item => item.name == this.config.gcode_list[this.selected_folder_index].name && item.type == "folder",
|
||||
);
|
||||
folder_to_update.files = folder_to_update.files.filter(
|
||||
item => !this.selected_items_to_delete.includes(item.file_name),
|
||||
);
|
||||
|
||||
const exception_list = this.state.macros_list.map(item => item.file_name);
|
||||
let files_to_delete = this.selected_items_to_delete.filter(item => !exception_list.includes(item));
|
||||
|
||||
await api.delete(`file/DINCAIQABiDARixAxiABDIHCAMQABiABDIHCAQQABiABDIH${files_to_delete.toString()}`);
|
||||
|
||||
this.save_config(this.config);
|
||||
this.filtered_files = [];
|
||||
this.search_query = "";
|
||||
this.selected_folder_index = null;
|
||||
this.selected_items_to_delete = [];
|
||||
this.deleteGCode = false;
|
||||
},
|
||||
|
||||
cancel_delete: function () {
|
||||
this.filtered_files = [];
|
||||
this.search_query = "";
|
||||
this.selected_folder_index = null;
|
||||
this.selected_items_to_delete = [];
|
||||
this.deleteGCode = false;
|
||||
},
|
||||
|
||||
delete_all: function () {
|
||||
api.delete("file");
|
||||
this.deleteGCode = false;
|
||||
},
|
||||
|
||||
delete_all_except_macros: async function () {
|
||||
this.update_config();
|
||||
const macrosList = this.state.macros_list.map(item => item.file_name).toString();
|
||||
api.delete(`file/EgZjaHJvbWUqCggBEAAYsQMYgAQyBggAEEUYOTIKCAE${macrosList}`);
|
||||
this.config.non_macros_list = [];
|
||||
this.config.gcode_list = [{ name: "default", type: "folder", files: [] }];
|
||||
this.save_config(this.config);
|
||||
this.state.folder = "default";
|
||||
this.state.selected = "";
|
||||
this.selected_items_to_delete = [];
|
||||
this.deleteGCode = false;
|
||||
},
|
||||
|
||||
delete_folder: async function () {
|
||||
this.update_config();
|
||||
if (this.state.folder && this.state.folder != "default") {
|
||||
const files_to_move = this.config.gcode_list.find(
|
||||
item => item.type == "folder" && item.name == this.state.folder,
|
||||
);
|
||||
if (files_to_move) {
|
||||
const default_folder = this.config.gcode_list.find(item => item.name == "default");
|
||||
default_folder.files = [...default_folder.files, ...files_to_move.files].sort();
|
||||
this.config.gcode_list = this.config.gcode_list.filter(item => item.name != this.state.folder);
|
||||
this.save_config(this.config);
|
||||
}
|
||||
}
|
||||
this.state.folder = "default";
|
||||
this.confirmDelete = false;
|
||||
},
|
||||
|
||||
delete_folder_and_files: async function () {
|
||||
if (!this.state.folder) {
|
||||
this.confirmDelete = false;
|
||||
return;
|
||||
}
|
||||
this.update_config();
|
||||
const selected_folder = this.config.gcode_list.find(
|
||||
item => item.type == "folder" && item.name == this.state.folder,
|
||||
);
|
||||
if (!selected_folder) return;
|
||||
|
||||
const macrosList = this.state.macros_list.map(item => item.file_name);
|
||||
var files_to_delete = selected_folder.files
|
||||
.map(item => item.file_name)
|
||||
.filter(item => !macrosList.includes(item));
|
||||
|
||||
if (selected_folder.name != "default") {
|
||||
this.config.gcode_list = this.config.gcode_list.filter(item => item.name != this.state.folder);
|
||||
} else {
|
||||
selected_folder.files = [];
|
||||
}
|
||||
|
||||
await api.delete(`file/DINCAIQABiDARixAxiABDIHCAMQABiABDIHCAQQABiABDIH${files_to_delete.toString()}`);
|
||||
this.config.non_macros_list = this.config.non_macros_list.filter(
|
||||
item => !files_to_delete.includes(item.file_name),
|
||||
);
|
||||
this.save_config(this.config);
|
||||
this.state.folder = "default";
|
||||
this.confirmDelete = false;
|
||||
},
|
||||
|
||||
start_pause: function () {
|
||||
this.macrosLoading = false;
|
||||
if (this.state.xx == "RUNNING") this.pause();
|
||||
else if (this.state.xx == "STOPPING" || this.state.xx == "HOLDING") this.unpause();
|
||||
else this.start();
|
||||
},
|
||||
|
||||
start: function () { api.put("start"); },
|
||||
pause: function () { api.put("pause"); },
|
||||
unpause: function () { api.put("unpause"); },
|
||||
optional_pause: function () { api.put("pause/optional"); },
|
||||
stop: function () { api.put("stop"); },
|
||||
step: function () { api.put("step"); },
|
||||
|
||||
override_feed: function () { api.put(`override/feed/${this.feed_override}`); },
|
||||
override_speed: function () { api.put(`override/speed/${this.speed_override}`); },
|
||||
|
||||
run_macro: async function (id) {
|
||||
if (this.state.macros[id].file_name == "default") {
|
||||
this.showNoGcodeMessage = true;
|
||||
return;
|
||||
}
|
||||
const file_name = this.state.macros[id].file_name;
|
||||
try {
|
||||
// Selecting a file on the server is a side effect of
|
||||
// GET /api/file/<name>. The macro button used to mutate
|
||||
// state.selected client-side and immediately call start, which
|
||||
// raced the file fetch: if the server hadn't seen the new
|
||||
// selection yet, mach.start() ran whichever file was selected
|
||||
// last. Do it explicitly and await so start always sees the
|
||||
// right file.
|
||||
if (file_name != this.state.selected) {
|
||||
this.state.selected = file_name;
|
||||
// GET /api/file/<name> returns gcode text (not JSON), so use
|
||||
// fetch directly. The server's FileHandler.get sets
|
||||
// state.selected as a side effect; we await the response
|
||||
// before starting so mach.start() reads the right file.
|
||||
const resp = await fetch(
|
||||
`/api/file/${encodeURIComponent(file_name)}`,
|
||||
{ cache: "no-cache" }
|
||||
);
|
||||
if (!resp.ok) {
|
||||
throw new Error(`file fetch failed: ${resp.status}`);
|
||||
}
|
||||
await resp.text();
|
||||
}
|
||||
this.load();
|
||||
if (this.state.macros[id].alert == true) {
|
||||
this.macrosLoading = true;
|
||||
} else {
|
||||
await this.start_pause();
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Error running macro: ", error);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
62
src/js/program-view.js
Normal file
62
src/js/program-view.js
Normal file
@@ -0,0 +1,62 @@
|
||||
"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: {
|
||||
is_kiosk: function () { return !!this.$root.is_kiosk; },
|
||||
|
||||
display_units: {
|
||||
cache: false,
|
||||
get: function () { return this.$root.display_units; },
|
||||
set: function (value) {
|
||||
this.config.settings.units = value;
|
||||
this.$root.display_units = value;
|
||||
this.$dispatch("config-changed");
|
||||
},
|
||||
},
|
||||
|
||||
metric: function () {
|
||||
return this.display_units === "METRIC";
|
||||
},
|
||||
|
||||
mach_state: function () {
|
||||
const cycle = this.state.cycle;
|
||||
const xx = this.state.xx;
|
||||
if (xx != "ESTOPPED" && (cycle == "jogging" || cycle == "homing")) {
|
||||
return cycle.toUpperCase();
|
||||
}
|
||||
return xx || "";
|
||||
},
|
||||
|
||||
can_set_axis: function () { return this.state.cycle == "idle"; },
|
||||
},
|
||||
|
||||
ready: function () {
|
||||
this.load();
|
||||
},
|
||||
|
||||
mixins: [require("./program-mixin")],
|
||||
};
|
||||
80
src/js/restart-timing.js
Normal file
80
src/js/restart-timing.js
Normal file
@@ -0,0 +1,80 @@
|
||||
// Lightweight UI-side restart/cold-load timing.
|
||||
//
|
||||
// Records a few key marks using performance.now(), then POSTs them to
|
||||
// /api/diag/timing/ui once 'ui.first_state' has fired. Disabled by
|
||||
// setting window.BBCTRL_TRACE = false before this module is loaded.
|
||||
//
|
||||
// Marks collected:
|
||||
// script.load -- this module evaluated
|
||||
// ws.open -- websocket onopen
|
||||
// ws.first_msg -- first message from controller
|
||||
// ui.first_state -- first message that contained controller state
|
||||
// window.load -- window 'load' event
|
||||
//
|
||||
// Aligning these with /api/diag/timing on the server gives the full
|
||||
// picture from systemd start -> bbctrl up -> WS open -> UI rendered.
|
||||
"use strict";
|
||||
|
||||
const _enabled = typeof window !== "undefined" && window.BBCTRL_TRACE !== false;
|
||||
const _t0 = (typeof performance !== "undefined" && performance.now)
|
||||
? performance.now()
|
||||
: Date.now();
|
||||
const _navStart = (typeof performance !== "undefined" && performance.timeOrigin)
|
||||
? performance.timeOrigin
|
||||
: Date.now();
|
||||
|
||||
const marks = [];
|
||||
let posted = false;
|
||||
|
||||
function _now() {
|
||||
return (typeof performance !== "undefined" && performance.now)
|
||||
? performance.now() - _t0
|
||||
: Date.now() - _t0;
|
||||
}
|
||||
|
||||
function mark(name, fields) {
|
||||
if (!_enabled) return;
|
||||
marks.push(Object.assign({ n: name, t: Math.round(_now()) }, fields || {}));
|
||||
}
|
||||
|
||||
function _post() {
|
||||
if (!_enabled || posted) return;
|
||||
posted = true;
|
||||
const body = JSON.stringify({
|
||||
navStart: _navStart,
|
||||
t0_perf: _t0,
|
||||
href: typeof location !== "undefined" ? location.href : "",
|
||||
ua: typeof navigator !== "undefined" ? navigator.userAgent : "",
|
||||
marks: marks,
|
||||
});
|
||||
try {
|
||||
if (typeof fetch === "function") {
|
||||
fetch("/api/diag/timing/ui", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: body,
|
||||
keepalive: true,
|
||||
}).catch(() => {});
|
||||
}
|
||||
} catch (e) { /* swallow */ }
|
||||
}
|
||||
|
||||
// Record window load too; doesn't block posting.
|
||||
if (_enabled && typeof window !== "undefined") {
|
||||
window.addEventListener("load", () => mark("window.load"));
|
||||
}
|
||||
|
||||
mark("script.load");
|
||||
|
||||
module.exports = {
|
||||
enabled: _enabled,
|
||||
mark: mark,
|
||||
onWsOpen: () => mark("ws.open"),
|
||||
onWsFirstMessage: () => mark("ws.first_msg"),
|
||||
onFirstState: () => {
|
||||
mark("ui.first_state");
|
||||
// Defer slightly so any synchronous render finishes first.
|
||||
setTimeout(_post, 100);
|
||||
},
|
||||
flush: _post,
|
||||
};
|
||||
175
src/js/settings-shell-view.js
Normal file
175
src/js/settings-shell-view.js
Normal file
@@ -0,0 +1,175 @@
|
||||
"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"),
|
||||
"a-axis-view": require("./a-axis-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
|
||||
// Whether the controller config has streamed in. The Svelte
|
||||
// settings views crash on first paint with the placeholder
|
||||
// config (settings.units / settings.easy-adapter / motion.*
|
||||
// are all undefined). Gate the inner mount on this flag.
|
||||
config_ready: false,
|
||||
rail_items: [
|
||||
{ sub: "settings", href: "#settings", icon: "fa-display", label: "Display & Units" },
|
||||
{ sub: "probing", href: "#probing", icon: "fa-bullseye", label: "Probing" },
|
||||
{ sub: "gcode", href: "#gcode", icon: "fa-code", label: "G-code & Motion" },
|
||||
{ sub: "macros", href: "#macros", icon: "fa-keyboard", label: "Macros" },
|
||||
{ sub: "cheat-sheet", href: "#cheat-sheet", icon: "fa-book", label: "G-code Cheat Sheet" },
|
||||
{ sub: "admin-network", href: "#admin-network", icon: "fa-network-wired", label: "Network" },
|
||||
{ sub: "admin-general", href: "#admin-general", icon: "fa-shield-halved", label: "General / Firmware" },
|
||||
{ sub: "tool", href: "#tool", icon: "fa-bolt", label: "Spindle & Tool" },
|
||||
{ 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" },
|
||||
// Auxiliary axis (auxcnc ESP32 - exposed to gplan as A).
|
||||
// Mounts the AAxisSettings Svelte component on its own page.
|
||||
{ sub: "a-axis", href: "#a-axis", icon: "fa-arrows-up-down", label: "A Axis" },
|
||||
{ section: " " },
|
||||
{ sub: "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();
|
||||
this._configPoll = setInterval(() => {
|
||||
const c = this.$root && this.$root.config;
|
||||
const ready = !!(c && c.full_version && c.full_version !== "<loading>"
|
||||
&& c.settings && typeof c.settings === "object");
|
||||
if (ready !== this.config_ready) this.config_ready = ready;
|
||||
}, 200);
|
||||
},
|
||||
|
||||
attached: function () {
|
||||
// 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);
|
||||
}
|
||||
if (this._configPoll) clearInterval(this._configPoll);
|
||||
},
|
||||
|
||||
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;
|
||||
},
|
||||
|
||||
on_rail_click: function (item, ev) {
|
||||
if (!item) return;
|
||||
// Always preventDefault on rail clicks. Letting the browser
|
||||
// anchor-scroll to <div id="settings"> etc. inside .app-body
|
||||
// can pull the .app-head out of view; we drive navigation
|
||||
// ourselves through location.hash and our hashchange handler.
|
||||
if (ev && ev.preventDefault) ev.preventDefault();
|
||||
|
||||
if (item.anchor) {
|
||||
// Soft-link rail items use a #settings hash plus an in-page
|
||||
// anchor scroll once the Svelte page has mounted. We scroll
|
||||
// ONLY the .settings-content overflow container by setting
|
||||
// its scrollTop directly — element.scrollIntoView() walks all
|
||||
// ancestor scroll containers and can tug the .app-body / html
|
||||
// layout, which under tablet mode pulls the fixed header out
|
||||
// of view.
|
||||
if (location.hash !== item.href) location.hash = item.href;
|
||||
this._a_axis_focus = (item.sub === "a-axis");
|
||||
const reset = () => {
|
||||
// Force any inadvertent ancestor scroll back to 0 before
|
||||
// we move .settings-content explicitly.
|
||||
window.scrollTo(0, 0);
|
||||
const body = document.querySelector(".app-body");
|
||||
if (body) body.scrollTop = 0;
|
||||
document.documentElement.scrollTop = 0;
|
||||
};
|
||||
setTimeout(() => {
|
||||
reset();
|
||||
const el = document.getElementById(item.anchor);
|
||||
const scroller = document.querySelector(".settings-content");
|
||||
if (el && scroller) {
|
||||
const elTop = el.getBoundingClientRect().top;
|
||||
const scTop = scroller.getBoundingClientRect().top;
|
||||
scroller.scrollTop = scroller.scrollTop + (elTop - scTop) - 12;
|
||||
}
|
||||
// Re-assert ancestor scroll = 0 in case the assignment above
|
||||
// moved things.
|
||||
requestAnimationFrame(reset);
|
||||
}, 320);
|
||||
} else {
|
||||
this._a_axis_focus = false;
|
||||
if (location.hash !== item.href) location.hash = item.href;
|
||||
// Reset .app-body scroll so each route starts at the top.
|
||||
const body = document.querySelector(".app-body");
|
||||
if (body) body.scrollTop = 0;
|
||||
}
|
||||
},
|
||||
|
||||
showShutdownDialog: function () {
|
||||
SvelteComponents.showDialog("Shutdown");
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,14 +1,60 @@
|
||||
// V09 wraps the legacy Svelte SettingsView and filters its big page
|
||||
// down to a single rail section so each rail item shows only the
|
||||
// relevant controls. The Svelte component is left untouched (it is
|
||||
// shared with the legacy UI) — we just hide the `<h2>` and `<fieldset>`
|
||||
// elements whose `data-sec` does not match the active section.
|
||||
|
||||
module.exports = {
|
||||
template: "#settings-view-template",
|
||||
|
||||
attached: function() {
|
||||
props: {
|
||||
// "display" | "probing" | "gcode". Default is "display" which
|
||||
// keeps the rail's "Display & Units" item working unchanged.
|
||||
section: { default: "display" },
|
||||
},
|
||||
|
||||
attached: function () {
|
||||
this.svelteComponent = SvelteComponents.createComponent(
|
||||
"SettingsView",
|
||||
document.getElementById("settings")
|
||||
);
|
||||
// Defer one tick so Svelte has rendered the section markup.
|
||||
setTimeout(() => this.apply_section_filter(), 0);
|
||||
},
|
||||
|
||||
detached: function() {
|
||||
this.svelteComponent.$destroy();
|
||||
}
|
||||
detached: function () {
|
||||
if (this.svelteComponent) this.svelteComponent.$destroy();
|
||||
},
|
||||
|
||||
watch: {
|
||||
section: function () {
|
||||
this.apply_section_filter();
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
apply_section_filter: function () {
|
||||
const root = document.getElementById("settings");
|
||||
if (!root) return;
|
||||
const want = this.section || "display";
|
||||
// Hide every section block that does not match.
|
||||
root.querySelectorAll("[data-sec]").forEach(el => {
|
||||
el.style.display = el.dataset.sec === want ? "" : "none";
|
||||
});
|
||||
// Hide the global <h1>Settings</h1> on subsections so the
|
||||
// page reads as a focused panel.
|
||||
const h1 = root.querySelector(".settings-view > h1");
|
||||
if (h1) {
|
||||
if (want === "display") {
|
||||
h1.textContent = "Display & Units";
|
||||
} else if (want === "probing") {
|
||||
h1.textContent = "Probing";
|
||||
} else if (want === "gcode") {
|
||||
h1.textContent = "G-code & Motion";
|
||||
} else {
|
||||
h1.textContent = "Settings";
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -8,9 +8,8 @@ html(lang="en")
|
||||
|
||||
|
||||
style: include ../static/css/pure-min.css
|
||||
style: include ../static/css/side-menu.css
|
||||
|
||||
style: include ../static/css/font-awesome.min.css
|
||||
style: include ../static/css/fa6.min.css
|
||||
style: include ../static/css/Audiowide.css
|
||||
style: include ../static/css/clusterize.css
|
||||
style: include ../svelte-components/node_modules/svelte-material-ui/bare.css
|
||||
@@ -19,103 +18,171 @@ html(lang="en")
|
||||
style: include:stylus ../stylus/style.styl
|
||||
|
||||
body(v-cloak)
|
||||
// Tablet (kiosk) mode — pins the .app-shell to 1920x1080 and
|
||||
// scales it to fit the actual viewport so the UI always looks
|
||||
// exactly like the 10.8" 1920x1080 portable monitor.
|
||||
//
|
||||
// Toggle: ?tablet=1 to enable
|
||||
// ?tablet=0 to disable
|
||||
// Sticky in localStorage; once set, no querystring is needed.
|
||||
script.
|
||||
(function () {
|
||||
try {
|
||||
var p = new URLSearchParams(location.search);
|
||||
if (p.has("tablet")) {
|
||||
var on = p.get("tablet") !== "0" && p.get("tablet") !== "false";
|
||||
localStorage.setItem("ui-tablet-mode", on ? "1" : "0");
|
||||
}
|
||||
if (localStorage.getItem("ui-tablet-mode") === "1") {
|
||||
document.documentElement.classList.add("tablet-mode");
|
||||
}
|
||||
function fit() {
|
||||
if (!document.documentElement.classList.contains("tablet-mode")) return;
|
||||
var s = Math.min(window.innerWidth / 1920, window.innerHeight / 1080);
|
||||
document.documentElement.style.setProperty("--tablet-scale", s);
|
||||
}
|
||||
fit();
|
||||
window.addEventListener("resize", fit);
|
||||
|
||||
// Kiosk mode: when the UI is loaded by the controller's
|
||||
// own onboard browser (Chromium pointing at localhost on
|
||||
// the Pi 3B at 1366x768), apply a tighter layout that
|
||||
// packs the V09 UI into the smaller, slower display.
|
||||
// Override with ?kiosk=0 to force the desktop layout.
|
||||
if (p.has("kiosk")) {
|
||||
var k = p.get("kiosk") !== "0" && p.get("kiosk") !== "false";
|
||||
localStorage.setItem("ui-kiosk-mode", k ? "1" : "0");
|
||||
}
|
||||
var stored = localStorage.getItem("ui-kiosk-mode");
|
||||
var auto = location.hostname === "localhost"
|
||||
|| location.hostname === "127.0.0.1"
|
||||
|| location.hostname === "::1";
|
||||
if (stored === "1" || (stored !== "0" && auto)) {
|
||||
document.documentElement.classList.add("kiosk-mode");
|
||||
}
|
||||
} catch (_e) {}
|
||||
})();
|
||||
|
||||
#svelte-dialog-host
|
||||
|
||||
#overlay(v-if="status != 'connected'")
|
||||
span {{status}}
|
||||
|
||||
#layout
|
||||
a#menuLink.menu-link(href="#menu"): span
|
||||
|
||||
#menu
|
||||
button.save.pure-button.button-success(:disabled="!modified",
|
||||
@click="save") Save
|
||||
.app-shell
|
||||
header.app-head
|
||||
.brand-blk
|
||||
.brand-logo
|
||||
.brand-name ONEFINITY
|
||||
|
||||
.pure-menu
|
||||
ul.pure-menu-list
|
||||
li.pure-menu-heading
|
||||
a.pure-menu-link(href="#control") Control
|
||||
nav.tabs-host(role="tablist")
|
||||
a.ktab(:class="{active: top_tab === 'control'}", href="#control",
|
||||
title="Jog, DRO, macros")
|
||||
.fa.fa-gamepad
|
||||
span Control
|
||||
a.ktab(:class="{active: top_tab === 'program'}", href="#program",
|
||||
title="Run programs, files, toolpath preview")
|
||||
.fa.fa-list-ol
|
||||
span Program
|
||||
a.ktab(:class="{active: top_tab === 'console'}", href="#console",
|
||||
title="MDI, messages, indicators")
|
||||
.fa.fa-terminal
|
||||
span Console
|
||||
span.ktab-badge(v-if="messages_count") {{messages_count}}
|
||||
a.ktab(:class="{active: top_tab === 'settings'}", href="#settings",
|
||||
title="Configuration, network, macros")
|
||||
.fa.fa-sliders
|
||||
span Settings
|
||||
|
||||
li.pure-menu-heading
|
||||
a.pure-menu-link(href="#macros") Macros
|
||||
.head-spacer
|
||||
|
||||
li.pure-menu-heading
|
||||
a.pure-menu-link(href="#settings") Settings
|
||||
.sys-btn(@click.stop="toggle_sys_popover", :class="{open: sys_open}")
|
||||
span.pip(:class="sys_class")
|
||||
span.sys-text {{sys_summary}}
|
||||
.fa.fa-chevron-down
|
||||
|
||||
li.pure-menu-heading
|
||||
a.pure-menu-link(href="#motor:0") Motors
|
||||
.pi-temp-warning(v-if="80 <= state.rpi_temp",
|
||||
title="Raspberry Pi temperature too high.")
|
||||
.fa.fa-temperature-full
|
||||
|
||||
li.pure-menu-item(v-for="motor in config.motors")
|
||||
a.pure-menu-link(:href="'#motor:' + $index") Motor {{$index}}
|
||||
span.state-badge(:class="state_class", :title="mach_state_full")
|
||||
span.dot
|
||||
span {{state_label}}
|
||||
|
||||
li.pure-menu-heading
|
||||
a.pure-menu-link(href="#tool") Tool
|
||||
.estop(:class="{active: state.es}")
|
||||
estop(@click="estop")
|
||||
|
||||
li.pure-menu-heading
|
||||
a.pure-menu-link(href="#io") I/O
|
||||
|
||||
li.pure-menu-heading
|
||||
a.pure-menu-link(href="#admin-general") Admin
|
||||
|
||||
li.pure-menu-item
|
||||
a.pure-menu-link(href="#admin-general") General
|
||||
|
||||
li.pure-menu-item
|
||||
a.pure-menu-link(href="#admin-network") Network
|
||||
|
||||
li.pure-menu-heading
|
||||
a.pure-menu-link(href="#cheat-sheet") Cheat Sheet
|
||||
|
||||
li.pure-menu-heading
|
||||
a.pure-menu-link(href="#help") Help
|
||||
|
||||
button.pure-button.pure-button-primary(@click="showShutdownDialog", style="width: 100%")
|
||||
.fa.fa-power-off
|
||||
|
||||
#main
|
||||
.nav-header
|
||||
.brand
|
||||
img(src="/images/onefinity_logo.png")
|
||||
.version
|
||||
div Version: v{{config.full_version}}
|
||||
div IP Address: {{config.ip}}
|
||||
div WiFi: {{config.wifiName}}
|
||||
a.upgrade-link(v-if="show_upgrade()", href="#admin-general")
|
||||
| Upgrade to v{{latestVersion}}
|
||||
.fa.fa-exclamation-circle.upgrade-attention(v-if="show_upgrade()")
|
||||
|
||||
.pi-temp-warning
|
||||
.fa.fa-thermometer-full(class="error",
|
||||
v-if="80 <= state.rpi_temp",
|
||||
title="Raspberry Pi temperature too high.")
|
||||
|
||||
.easy-adapter(v-if="is_easy_adapter_active")
|
||||
.round-dot
|
||||
div.easy-adapter-text Easy Adapter
|
||||
|
||||
.whitespace
|
||||
|
||||
div
|
||||
button.rotary-button(:disabled="!enable_rotary", :class="is_rotary_active && 'active'", @click="showSwitchRotaryModeDialog")
|
||||
img(src="/images/rotary.svg", alt="rotary", :style="is_rotary_active ? 'width:90%;' : 'width:85%;'")
|
||||
div.rotary-text Rotary
|
||||
|
||||
.video(title="Plug camera into USB.\n" +
|
||||
"Left click to toggle video size.\n" +
|
||||
"Right click to toggle crosshair.", @click="toggle_video",
|
||||
@contextmenu="toggle_crosshair", :class="video_size")
|
||||
// System popover (chip-soup destination)
|
||||
.sys-popover(v-if="sys_open", @click.stop="")
|
||||
.sp-row
|
||||
.sp-icon: .fa.fa-microchip
|
||||
.sp-text
|
||||
.sp-label Firmware
|
||||
.sp-val v{{config.full_version}}
|
||||
a.sp-act(v-if="show_upgrade()", href="#admin-general")
|
||||
| Upgrade to v{{latestVersion}}
|
||||
.fa.fa-circle-exclamation.upgrade-attention
|
||||
.sp-row
|
||||
.sp-icon: .fa.fa-network-wired
|
||||
.sp-text
|
||||
.sp-label IP Address
|
||||
.sp-val {{config.ip}}
|
||||
.sp-row
|
||||
.sp-icon: .fa.fa-wifi(:class="{'sp-warn': config.wifiName === 'not connected'}")
|
||||
.sp-text
|
||||
.sp-label WiFi
|
||||
.sp-val {{config.wifiName}}
|
||||
a.sp-act(href="#admin-network", @click="sys_open=false") Configure
|
||||
.sp-row(v-if="enable_rotary")
|
||||
.sp-icon: img(src="/images/rotary.svg", alt="rotary")
|
||||
.sp-text
|
||||
.sp-label Rotary
|
||||
.sp-val {{is_rotary_active ? 'Active' : 'Inactive'}}
|
||||
button.sp-act(@click="showSwitchRotaryModeDialog")
|
||||
| {{is_rotary_active ? 'Disable' : 'Enable'}}
|
||||
.sp-row(v-if="is_easy_adapter_active")
|
||||
.sp-icon: .fa.fa-puzzle-piece
|
||||
.sp-text
|
||||
.sp-label Easy Adapter
|
||||
.sp-val Active
|
||||
.sp-row.video-row
|
||||
.sp-icon: .fa.fa-video
|
||||
.sp-text
|
||||
.sp-label Camera
|
||||
.sp-val {{has_camera ? 'Live' : 'Plug camera into USB'}}
|
||||
.sp-act(v-if="has_camera", @click="toggle_video")
|
||||
| {{video_size === 'small' ? 'Enlarge' : 'Shrink'}}
|
||||
.video(v-if="sys_open && has_camera", title="Camera feed",
|
||||
@click="toggle_video", @contextmenu="toggle_crosshair",
|
||||
:class="video_size")
|
||||
.crosshair(v-if="crosshair")
|
||||
.vertical
|
||||
.horizontal
|
||||
.box
|
||||
img(src="/api/video")
|
||||
img(src="/api/video", @error="has_camera=false")
|
||||
.sp-foot
|
||||
button.sp-shutdown(@click="showShutdownDialog")
|
||||
.fa.fa-power-off
|
||||
| Shutdown
|
||||
button.sp-save(:disabled="!modified", @click="save")
|
||||
.fa.fa-save
|
||||
| Save{{modified ? '*' : ''}}
|
||||
|
||||
.estop(:class="{active: state.es}")
|
||||
estop(@click="estop")
|
||||
|
||||
.content(class="{{currentView}}-view")
|
||||
component(:is="currentView + '-view'", :index="index",
|
||||
:config="config", :template="template", :state="state", keep-alive)
|
||||
// Routed view. We keep instances alive across tab swaps so:
|
||||
// - The Program tab's WebGL <path-viewer> canvas does not
|
||||
// get destroyed and recreated each time (which caused a
|
||||
// dark flash as the GL context cleared the new canvas
|
||||
// before its first frame).
|
||||
// - The Program tab's clusterize.js gcode list does not
|
||||
// re-virtualize from scratch on every visit.
|
||||
// - The Settings shell's child Svelte components stay
|
||||
// mounted, preserving any in-flight form state.
|
||||
// The settings-shell handles its own inner v-if cascade so
|
||||
// the Vue 1 reactivity quirk that motivated removing
|
||||
// keep-alive earlier no longer applies here.
|
||||
.app-body
|
||||
component(:is="currentView + '-view'", :index="index",
|
||||
:config="config", :template="template", :state="state",
|
||||
:sub-tab="sub_tab", keep-alive)
|
||||
|
||||
message.error-message(:show.sync="errorShow")
|
||||
div(slot="header")
|
||||
|
||||
4
src/pug/templates/a-axis-view.pug
Normal file
4
src/pug/templates/a-axis-view.pug
Normal file
@@ -0,0 +1,4 @@
|
||||
script#a-axis-view-template(type="text/x-template")
|
||||
#a-axis-page
|
||||
h1 A Axis (auxcnc)
|
||||
#a-axis-mount
|
||||
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-circle-check
|
||||
| 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,475 +1,344 @@
|
||||
script#control-view-template(type="text/x-template")
|
||||
#control
|
||||
.control-page
|
||||
// ----- Modal dialogs (kept verbatim from legacy) -----
|
||||
message(:show.sync="showGcodeMessage")
|
||||
h3(slot="header") Processing New File
|
||||
|
||||
div(slot="body")
|
||||
h3 Please wait..
|
||||
p Simulating GCode to check for errors, calculate ETA and generate 3D view.
|
||||
|
||||
div(slot="footer")
|
||||
label Simulating {{(toolpath_progress || 0) | percent}}
|
||||
|
||||
|
||||
message(:show.sync="showNoGcodeMessage")
|
||||
h3(slot="header") GCode Not Set
|
||||
div(slot="body")
|
||||
p Configure the GCode for the selected macro to use it
|
||||
|
||||
div(slot="footer")
|
||||
button.pure-button(@click="showNoGcodeMessage=false") OK
|
||||
h3(slot="header") GCode Not Set
|
||||
div(slot="body")
|
||||
p Configure the GCode for the selected macro to use it
|
||||
div(slot="footer")
|
||||
button.pure-button(@click="showNoGcodeMessage=false") OK
|
||||
|
||||
message(:show.sync="macrosLoading")
|
||||
h3(slot="header") Run Macro?
|
||||
div(slot="body")
|
||||
p
|
||||
| The macro file
|
||||
strong {{state.selected}}
|
||||
| is being loaded.
|
||||
|
||||
div(slot="footer")
|
||||
button.pure-button(@click="macrosLoading=false") Cancel
|
||||
button.pure-button.pure-button-primary(@click="start_pause") Run
|
||||
|
||||
h3(slot="header") Run Macro?
|
||||
div(slot="body")
|
||||
p
|
||||
| The macro file
|
||||
strong {{state.selected}}
|
||||
| is being loaded.
|
||||
div(slot="footer")
|
||||
button.pure-button(@click="macrosLoading=false") Cancel
|
||||
button.pure-button.pure-button-primary(@click="start_pause") Run
|
||||
|
||||
message(:show.sync="GCodeNotFound")
|
||||
h3(slot="header") File not found
|
||||
div(slot="body")
|
||||
p It seems like the file you selected cannot be found. Try uploading again.
|
||||
p It seems like the file you selected cannot be found. Try uploading again.
|
||||
div(slot="footer")
|
||||
button.pure-button.button-error(@click="GCodeNotFound=false")
|
||||
| OK
|
||||
|
||||
button.pure-button.button-error(@click="GCodeNotFound=false") OK
|
||||
|
||||
message(:show.sync="show_probe_dialog")
|
||||
h3(slot="header") Probe Rotary
|
||||
h3(slot="header") Choose probe type
|
||||
div(slot="body")
|
||||
p Pick which probe routine to run.
|
||||
button.pure-button(:class="state['pw'] ? '' : 'load-on'", @click="showProbeDialog('xyz')") Probe XYZ
|
||||
button.pure-button(:class="state['pw'] ? '' : 'load-on'", @click="showProbeDialog('z')") Probe Z
|
||||
div(slot="footer")
|
||||
button.pure-button(@click="show_probe_dialog=false") Cancel
|
||||
|
||||
// ----- Main grid: jog | (DRO + status strip) -----
|
||||
.control-grid
|
||||
|
||||
table(style="table-layout: fixed; width: 100%;")
|
||||
tr(style="height: fit-content;")
|
||||
td(style="white-space: nowrap; width: 410px;", rowspan="2")
|
||||
table.control-buttons(table-layout="fixed")
|
||||
colgroup
|
||||
col(style="width:100px")
|
||||
col(style="width:100px")
|
||||
col(style="width:100px")
|
||||
col(style="width:100px")
|
||||
tr
|
||||
td(style="height:100px",align="center")
|
||||
button(@click="jog_fn(-1,1,0,0)")
|
||||
.fa.fa-arrow-right(style="transform: rotate(-135deg);")
|
||||
td(style="height:100px",align="center")
|
||||
button(@click="jog_fn(0,1,0,0)") Y+
|
||||
td(style="height:100px",align="center")
|
||||
button(@click="jog_fn(1,1,0,0)")
|
||||
.fa.fa-arrow-right(style="transform: rotate(-45deg);")
|
||||
td(style="height:100px",align="center")
|
||||
button(,@click="jog_fn(0,0,1,0)") Z+
|
||||
tr
|
||||
td(style="height:100px",align="center")
|
||||
button(@click="jog_fn(-1,0,0,0)") X-
|
||||
td(style="height:100px",align="center")
|
||||
button(@click="showMoveToZeroDialog('xy')")
|
||||
| XY
|
||||
br
|
||||
| Origin
|
||||
td(style="height:100px",align="center")
|
||||
button(@click="jog_fn(1,0,0,0)") X+
|
||||
td(style="height:100px",align="center")
|
||||
button(@click="showMoveToZeroDialog('z')")
|
||||
| Z
|
||||
br
|
||||
| Origin
|
||||
tr
|
||||
td(style="height:100px",align="center")
|
||||
button(@click="jog_fn(-1,-1,0,0)")
|
||||
.fa.fa-arrow-right(style="transform: rotate(135deg);")
|
||||
td(style="height:100px",align="center")
|
||||
button(@click="jog_fn(0,-1,0,0)") Y-
|
||||
td(style="height:100px",align="center")
|
||||
button(@click="jog_fn(1,-1,0,0)")
|
||||
.fa.fa-arrow-right(style="transform: rotate(45deg);")
|
||||
td(style="height:100px",align="center")
|
||||
button(@click="jog_fn(0,0,-1,0)") Z-
|
||||
tr
|
||||
td(style="height:100px",align="center")
|
||||
button(:style="getJogIncrStyle('fine')", @click="jog_incr = 'fine'")
|
||||
span {{jog_incr_amounts[display_units].fine}}#[span.jog-units {{metric ? 'mm' : 'in'}}]
|
||||
td(style="height:100px",align="center")
|
||||
button(:style="getJogIncrStyle('small')", @click="jog_incr = 'small'")
|
||||
span {{jog_incr_amounts[display_units].small}}#[span.jog-units {{metric ? 'mm' : 'in'}}]
|
||||
td(style="height:100px",align="center")
|
||||
button(:style="getJogIncrStyle('medium')", @click="jog_incr = 'medium'")
|
||||
span {{jog_incr_amounts[display_units].medium}}#[span.jog-units {{metric ? 'mm' : 'in'}}]
|
||||
td(style="height:100px",align="center")
|
||||
button(:style="getJogIncrStyle('large')", @click="jog_incr = 'large'")
|
||||
span {{jog_incr_amounts[display_units].large}}#[span.jog-units {{metric ? 'mm' : 'in'}}]
|
||||
// ===== JOG =====
|
||||
// Hidden only while a G-code program is running / paused /
|
||||
// stopping. Jogging / homing / MDI moves do not hide it.
|
||||
.jog-card(v-if="!is_program_executing")
|
||||
.jog-head
|
||||
.jog-title
|
||||
| Jog
|
||||
span.step-pre · step
|
||||
span.step {{jog_incr_amounts[display_units][jog_incr]}}#[span.unit {{metric ? 'mm' : 'in'}}]
|
||||
.step-seg
|
||||
button(:class="{active: jog_incr === 'fine'}", @click="jog_incr = 'fine'")
|
||||
| {{jog_incr_amounts[display_units].fine}}
|
||||
button(:class="{active: jog_incr === 'small'}", @click="jog_incr = 'small'")
|
||||
| {{jog_incr_amounts[display_units].small}}
|
||||
button(:class="{active: jog_incr === 'medium'}", @click="jog_incr = 'medium'")
|
||||
| {{jog_incr_amounts[display_units].medium}}
|
||||
button(:class="{active: jog_incr === 'large'}", @click="jog_incr = 'large'")
|
||||
| {{jog_incr_amounts[display_units].large}}
|
||||
|
||||
tr(v-if="state['2an'] == 3")
|
||||
td(style="height:100px", align="center", colspan="1")
|
||||
button(@click="show_probe_dialog=true")
|
||||
| Probe
|
||||
br
|
||||
| Rotary
|
||||
.jog-grid
|
||||
// Row 1
|
||||
button.jbtn.dir(@click="jog_fn(-1, 1, 0, 0)", title="X- Y+")
|
||||
.fa.fa-arrow-up.ico(style="transform: rotate(-45deg)")
|
||||
button.jbtn(@click="jog_fn(0, 1, 0, 0)") Y+
|
||||
button.jbtn.dir(@click="jog_fn(1, 1, 0, 0)", title="X+ Y+")
|
||||
.fa.fa-arrow-up.ico(style="transform: rotate(45deg)")
|
||||
button.jbtn(@click="jog_fn(0, 0, 1, 0)") Z+
|
||||
|
||||
td(style="height:100px", align="center", colspan="1")
|
||||
button(@click="jog_fn(0,0,0,-1)", style="display: grid;justify-content: center;align-items: center;padding: 14px;")
|
||||
| A-
|
||||
.fa.fa-rotate-left
|
||||
|
||||
td(style="height:100px", align="center", colspan="1")
|
||||
button(@click="showMoveToZeroDialog('a')")
|
||||
| A
|
||||
br
|
||||
| Origin
|
||||
// Row 2
|
||||
button.jbtn(@click="jog_fn(-1, 0, 0, 0)") X−
|
||||
button.jbtn(@click="showMoveToZeroDialog('xy')")
|
||||
span.lbl XY
|
||||
span Origin
|
||||
button.jbtn(@click="jog_fn(1, 0, 0, 0)") X+
|
||||
button.jbtn(@click="showMoveToZeroDialog('z')")
|
||||
span.lbl Z
|
||||
span Origin
|
||||
|
||||
td(style="height:100px", align="center", colspan="1")
|
||||
button(@click="jog_fn(0,0,0,1)", style="display: grid;justify-content: center;align-items: center;padding: 14px;")
|
||||
| A+
|
||||
.fa.fa-rotate-right
|
||||
// Row 3
|
||||
button.jbtn.dir(@click="jog_fn(-1, -1, 0, 0)", title="X- Y-")
|
||||
.fa.fa-arrow-down.ico(style="transform: rotate(45deg)")
|
||||
button.jbtn(@click="jog_fn(0, -1, 0, 0)") Y−
|
||||
button.jbtn.dir(@click="jog_fn(1, -1, 0, 0)", title="X+ Y-")
|
||||
.fa.fa-arrow-down.ico(style="transform: rotate(-45deg)")
|
||||
button.jbtn(@click="jog_fn(0, 0, -1, 0)") Z−
|
||||
|
||||
tr(v-else)
|
||||
td(style="height:100px", align="center", colspan="2")
|
||||
button(:class="state['pw'] ? '' : 'load-on'",
|
||||
style="height:100px;width:200px",
|
||||
@click="showProbeDialog('xyz')")
|
||||
| Probe XYZ
|
||||
// Row 4 — A axis (the auxcnc-driven external axis) when enabled.
|
||||
// A- | A+ | Probe XYZ | Probe Z
|
||||
// "Home A" lives in the DRO table's actions column on the
|
||||
// right, so it doesn't need a tile here. The legacy w.enabled
|
||||
// gate is kept so older installs (where the auxcnc axis still
|
||||
// appears as W via the side-channel) keep working.
|
||||
template(v-if="w.enabled || a.enabled")
|
||||
button.jbtn(@click="aux_jog_incr(-1)",
|
||||
:disabled="!(w.enabled || a.enabled)")
|
||||
.fa.fa-arrow-down.ico
|
||||
span.lbl A−
|
||||
button.jbtn(@click="aux_jog_incr(+1)",
|
||||
:disabled="!(w.enabled || a.enabled)")
|
||||
.fa.fa-arrow-up.ico
|
||||
span.lbl A+
|
||||
button.jbtn(@click="showProbeDialog('xyz')",
|
||||
:class="{'load-on': !state['pw']}")
|
||||
.fa.fa-bullseye.ico
|
||||
span.lbl Probe XYZ
|
||||
button.jbtn(@click="showProbeDialog('z')",
|
||||
:class="{'load-on': !state['pw']}")
|
||||
.fa.fa-bullseye.ico
|
||||
span.lbl Probe Z
|
||||
|
||||
td(style="height:100px", align="center", colspan="2")
|
||||
button(:class="state['pw'] ? '' : 'load-on'",
|
||||
style="height:100px;width:200px",
|
||||
@click="showProbeDialog('z')")
|
||||
| Probe Z
|
||||
// Row 4 — A axis (rotary) when no W and rotary is enabled
|
||||
// (Vue 1 has no v-else-if; we negate w.enabled explicitly.)
|
||||
template(v-if="!w.enabled && state['2an'] == 3")
|
||||
button.jbtn.dir(@click="jog_fn(0, 0, 0, -1)")
|
||||
.fa.fa-rotate-left.ico
|
||||
span.lbl A−
|
||||
button.jbtn.ghost(@click="showMoveToZeroDialog('a')")
|
||||
span.lbl A
|
||||
span Origin
|
||||
button.jbtn.dir(@click="jog_fn(0, 0, 0, 1)")
|
||||
.fa.fa-rotate-right.ico
|
||||
span.lbl A+
|
||||
button.jbtn(@click="show_probe_dialog=true",
|
||||
:class="{'load-on': !state['pw']}")
|
||||
.fa.fa-bullseye.ico
|
||||
span.lbl Probe
|
||||
|
||||
td(style="vertical-align: top;")
|
||||
table.axes
|
||||
tr(:class="axes.klass")
|
||||
th.name Axis
|
||||
th.position Position
|
||||
th.absolute Absolute
|
||||
th.offset Offset
|
||||
th.state State
|
||||
th.tstate Toolpath
|
||||
th.actions
|
||||
button.pure-button(disabled, style="height:60px;width:60px;display:none;")
|
||||
// Row 4 — fallback probe / zero / home shortcuts
|
||||
template(v-if="!w.enabled && state['2an'] != 3")
|
||||
button.jbtn(@click="showProbeDialog('xyz')",
|
||||
:class="{'load-on': !state['pw']}")
|
||||
.fa.fa-bullseye.ico
|
||||
span.lbl Probe XYZ
|
||||
button.jbtn.ghost(@click="zero()", :disabled="!can_set_axis")
|
||||
.fa.fa-location-dot.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()")
|
||||
.fa.fa-home.ico
|
||||
span.lbl Home all
|
||||
|
||||
button.pure-button(:disabled="!can_set_axis",
|
||||
title="Zero all axis offsets.", @click="zero()",style="height:60px;width:60px")
|
||||
.fa.fa-map-marker
|
||||
|
||||
button.pure-button(title="Home all axes.", @click="home()",
|
||||
:disabled="!is_idle",style="height:60px;width:60px")
|
||||
.fa.fa-home
|
||||
|
||||
each axis in 'xyzabc'
|
||||
tr.axis(:class=`${axis}.klass`, v-if=`${axis}.enabled`,
|
||||
:title=`${axis}.title`)
|
||||
th.name= axis
|
||||
td.position: unit-value(:value=`${axis}.pos`, precision=4)
|
||||
td.absolute: unit-value(:value=`${axis}.abs`, precision=3)
|
||||
td.offset: unit-value(:value=`${axis}.off`, precision=3)
|
||||
td.state
|
||||
.fa(:class=`'fa-' + ${axis}.icon`)
|
||||
| {{#{axis}.state}}
|
||||
td.tstate(:class=`${axis}.tklass`, :title=`${axis}.toolmsg`, @click=`showToolpathMessageDialog('${axis}')`)
|
||||
.fa(:class=`'fa-' + ${axis}.ticon`)
|
||||
| {{#{axis}.tstate}}
|
||||
|
||||
th.actions
|
||||
button.pure-button(:disabled="!can_set_axis",
|
||||
title=`Set {{'${axis}' | upper}} axis position.`,
|
||||
@click=`show_set_position('${axis}')`, style="height:60px;width:60px")
|
||||
.fa.fa-cog
|
||||
|
||||
button.pure-button(:disabled="!can_set_axis",
|
||||
title=`Zero {{'${axis}' | upper}} axis offset.`,
|
||||
@click=`zero('${axis}')`, style="height:60px;width:60px")
|
||||
.fa.fa-map-marker
|
||||
|
||||
button.pure-button(:disabled="!is_idle", @click=`home('${axis}')`,
|
||||
title=`Home {{'${axis}' | upper}} axis.`, style="height:60px;width:60px")
|
||||
.fa.fa-home
|
||||
|
||||
tr(style="vertical-align: top;")
|
||||
td
|
||||
table(width="100%")
|
||||
tr
|
||||
td(style="text-align:center")
|
||||
table.info
|
||||
tr
|
||||
th State
|
||||
td(:class="{attention: highlight_state}") {{mach_state}}
|
||||
|
||||
tr
|
||||
th Message
|
||||
td.message(:class="{attention: highlight_state}")
|
||||
| {{message.replace(/^#/, '')}}
|
||||
|
||||
tr
|
||||
th Display Units
|
||||
td.units
|
||||
select(v-model="display_units")
|
||||
option(value="METRIC") METRIC
|
||||
option(value="IMPERIAL") IMPERIAL
|
||||
|
||||
tr(title="Active tool")
|
||||
th Tool
|
||||
td {{state.tool || 0}}
|
||||
|
||||
td
|
||||
table.info
|
||||
tr(
|
||||
title="Current velocity in {{metric ? 'meters' : 'inches'}} per minute")
|
||||
th Velocity
|
||||
td
|
||||
unit-value(:value="state.v", precision="2", unit="", iunit="",
|
||||
scale="0.0254")
|
||||
| {{metric ? ' m/min' : ' IPM'}}
|
||||
|
||||
tr(title="Programmed feed rate.")
|
||||
th Feed
|
||||
td
|
||||
unit-value(:value="state.feed", precision="2", unit="", iunit="")
|
||||
| {{metric ? ' mm/min' : ' IPM'}}
|
||||
|
||||
tr(title="Programed and actual speed.")
|
||||
th Speed
|
||||
td
|
||||
| {{state.speed || 0 | fixed 0}}
|
||||
span(v-if="!isNaN(state.s)") ({{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")
|
||||
// ===== NOW RUNNING (replaces jog grid only while a G-code
|
||||
// program is actually executing). Jogging is excluded.
|
||||
.running-panel(v-if="is_program_executing")
|
||||
.running-top
|
||||
div
|
||||
.running-file
|
||||
.fa.fa-file-code
|
||||
span(v-if="state.selected") {{state.selected}}
|
||||
span(v-else) {{(mach_state || 'BUSY').toLowerCase()}}
|
||||
.running-meta
|
||||
span(v-if="is_running") {{ (mach_state || 'RUNNING').toLowerCase() }}
|
||||
span(v-if="is_holding") paused
|
||||
span(v-if="is_holding && pause_reason") · {{pause_reason}}
|
||||
span(v-if="is_stopping") stopping
|
||||
span(v-if="toolpath.lines") · line {{state.line || 0 | number}} / {{toolpath.lines | number}}
|
||||
span(v-if="plan_time_remaining") · ETA {{plan_time_remaining | time}}
|
||||
.running-pct
|
||||
| {{((progress || 0) * 100) | fixed 0}}
|
||||
span %
|
||||
.running-progress
|
||||
div(:style="'width:' + ((progress || 0) * 100) + '%'")
|
||||
.running-stats
|
||||
.running-stat
|
||||
.lbl Velocity
|
||||
.val
|
||||
unit-value(:value="state.v", precision="2", unit="", iunit="", scale="0.0254")
|
||||
| {{metric ? 'm/min' : 'IPM'}}
|
||||
.running-stat
|
||||
.lbl Feed
|
||||
.val
|
||||
unit-value(:value="state.feed", precision="0", unit="", iunit="")
|
||||
| {{metric ? 'mm/min' : 'IPM'}}
|
||||
.running-stat
|
||||
.lbl Spindle
|
||||
.val
|
||||
| {{(state.speed || 0) | fixed 0}}
|
||||
span(v-if="state.s != null && !isNaN(state.s)") ({{state.s | fixed 0}})
|
||||
| RPM
|
||||
.running-stat
|
||||
.lbl Tool
|
||||
.val T{{state.tool || 0}}
|
||||
.running-row
|
||||
// While RUNNING the primary action is Pause; while HOLDING / STOPPING it's Resume.
|
||||
button.tx-btn.pause(v-if="is_running", @click="pause()")
|
||||
.fa.fa-pause
|
||||
span.lbl PAUSE
|
||||
button.tx-btn.run(v-if="is_holding || is_stopping", @click="unpause()")
|
||||
.fa.fa-play
|
||||
span.lbl RESUME
|
||||
button.tx-btn.stop(@click="stop()")
|
||||
.fa.fa-stop
|
||||
span.lbl STOP
|
||||
button.tx-btn.step(v-if="is_holding", @click="step()")
|
||||
.fa.fa-forward-step
|
||||
span.lbl STEP
|
||||
|
||||
input(v-model="mdi", :disabled="!can_mdi", @keyup.enter="submit_mdi")
|
||||
// ===== DRO + status strip =====
|
||||
.right-col
|
||||
|
||||
div
|
||||
em The machine is currently operating in #[strong {{mach_units}}] units. Use G20/G21 to switch units.
|
||||
.dro-card
|
||||
.dro-head
|
||||
div Axis
|
||||
div Position
|
||||
div Absolute
|
||||
div Offset
|
||||
.actions-cell
|
||||
// Master Home All. Each row's Actions cell has a per-axis
|
||||
// home button; this header-level button homes every
|
||||
// enabled axis (legacy Onefinity behavior). Auto-includes
|
||||
// the auxiliary A axis when it is enabled.
|
||||
button.icon-btn(:disabled="!is_idle",
|
||||
title="Home all axes.", @click="home_all()")
|
||||
.fa.fa-house-chimney
|
||||
|
||||
.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}}
|
||||
// Per-axis rows — keep unit-value + bindings from axis-vars
|
||||
each axis in 'xyzabc'
|
||||
.dro-row(:class=`${axis}.klass + ' ' + ${axis}.tklass`,
|
||||
v-if=`${axis}.enabled`,
|
||||
:title=`${axis}.toolmsg ? (${axis}.title + ' — ' + ${axis}.toolmsg) : ${axis}.title`)
|
||||
.dro-axis(:class=`'axis-' + '${axis}'`)= axis.toUpperCase()
|
||||
.dro-pos: unit-value(:value=`${axis}.pos`, precision=4)
|
||||
.dro-sec: unit-value(:value=`${axis}.abs`, precision=3)
|
||||
.dro-sec: unit-value(:value=`${axis}.off`, precision=3)
|
||||
.actions-cell
|
||||
button.icon-btn(:disabled="!can_set_axis",
|
||||
:title=`'Set ${axis.toUpperCase()} axis position.'`,
|
||||
@click=`show_set_position('${axis}')`)
|
||||
.fa.fa-gear
|
||||
button.icon-btn(:class=`${axis}.tklass.indexOf('error') !== -1 ? 'state-red' : (${axis}.tklass.indexOf('warn') !== -1 ? 'state-amber' : 'state-green')`,
|
||||
:disabled="!can_set_axis",
|
||||
:title=`${axis}.toolmsg || ('Zero ${axis.toUpperCase()} axis offset.')`,
|
||||
@click=`zero('${axis}')`)
|
||||
.fa.fa-location-dot
|
||||
button.icon-btn(:class=`${axis}.klass.indexOf('error') !== -1 ? 'state-red' : (${axis}.homed ? 'state-green' : 'state-amber')`,
|
||||
:disabled="!is_idle",
|
||||
:title=`${axis}.title`,
|
||||
@click=`home('${axis}')`)
|
||||
.fa.fa-home
|
||||
|
||||
section#content3.tab-content
|
||||
console
|
||||
// Legacy auxiliary-axis row - shown only when the auxcnc stepper is
|
||||
// *not* exposed as a virtual A axis. After v2 the standard
|
||||
// A row above renders this axis natively (with full offset
|
||||
// + set-position support); this row only appears on legacy
|
||||
// installs that haven't migrated yet.
|
||||
.dro-row(:class="w.klass + ' ' + w.tklass",
|
||||
v-if="w.enabled && !a.enabled",
|
||||
:title="w.title")
|
||||
.dro-axis.axis-w W
|
||||
.dro-pos: unit-value(:value="w.pos", precision=4)
|
||||
.dro-sec: unit-value(:value="w.abs", precision=3)
|
||||
.dro-sec —
|
||||
.actions-cell
|
||||
button.icon-btn(disabled, style="visibility:hidden")
|
||||
.fa.fa-gear
|
||||
button.icon-btn(disabled, style="visibility:hidden")
|
||||
.fa.fa-location-dot
|
||||
button.icon-btn(:class="w.homed ? 'state-green' : 'state-amber'",
|
||||
:disabled="!w.enabled",
|
||||
title="Home auxiliary axis.", @click="aux_home()")
|
||||
.fa.fa-home
|
||||
|
||||
section#content4.tab-content
|
||||
indicators(:state="state", :template="template")
|
||||
// ----- Status strip -----
|
||||
.status-strip
|
||||
.stat-card
|
||||
.stat-label State
|
||||
.stat-val(:class="state_kpi_class") {{mach_state || '--'}}
|
||||
.stat-sub(v-if="message") {{message.replace(/^#/, '')}}
|
||||
.stat-sub(v-else) No alerts
|
||||
|
||||
.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}}
|
||||
.stat-card
|
||||
.stat-label Velocity / Feed
|
||||
.stat-val
|
||||
unit-value(:value="state.v", precision="2", unit="", iunit="",
|
||||
scale="0.0254")
|
||||
| ·
|
||||
unit-value(:value="state.feed", precision="0", unit="", iunit="")
|
||||
.stat-sub {{metric ? 'm/min · mm/min' : 'IPM · IPM'}}
|
||||
|
||||
.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}}
|
||||
.stat-card.stat-tappable(@click="overrides_open = !overrides_open",
|
||||
:class="{open: overrides_open}", title="Tap to adjust feed/spindle override")
|
||||
.stat-label Spindle
|
||||
.stat-val
|
||||
| {{(state.speed || 0) | fixed 0}}
|
||||
span(v-if="state.s != null && !isNaN(state.s)") ({{state.s | fixed 0}})
|
||||
.stat-sub
|
||||
| RPM (commanded / actual)
|
||||
.fa.fa-sliders.tap-hint(title="Open override drawer")
|
||||
|
||||
|
||||
.stat-card
|
||||
.stat-label Job
|
||||
.stat-val
|
||||
| {{0 <= state.line ? state.line : 0 | number}}
|
||||
span(v-if="toolpath.lines")
|
||||
| / {{toolpath.lines | number}}
|
||||
.stat-sub(v-if="plan_time_remaining || toolpath.time")
|
||||
| Line · {{plan_time_remaining ? (plan_time_remaining | time) : (toolpath.time | time)}} remaining
|
||||
.stat-sub(v-else) Line · ETA --
|
||||
|
||||
// ----- Macro row (slice 0..7); full list lives in Settings → Macros -----
|
||||
// The colored left stripe (.has-color) is suppressed for white,
|
||||
// near-white and other default placeholder colors so unconfigured
|
||||
// macros render as clean slate tiles instead of looking lopsided.
|
||||
.macro-row(v-if="state.macros && state.macros.length")
|
||||
button.macro-btn(v-for="(index, macros) in state.macros.slice(0, 8)",
|
||||
title="Click to run macro",
|
||||
@click="run_macro(index)",
|
||||
:disabled="!is_ready",
|
||||
:class="{'has-color': has_macro_color(macros)}",
|
||||
:style="has_macro_color(macros) ? {borderLeftColor: macros.color} : {}")
|
||||
span.mnum {{index + 1}}
|
||||
span.mname {{macros.name || ('Macro ' + (index + 1))}}
|
||||
|
||||
// ----- Override drawer (anchored to bottom; toggled by Spindle KPI tile) -----
|
||||
.override-drawer(:class="{open: overrides_open}")
|
||||
.od-head
|
||||
.od-title
|
||||
.fa.fa-sliders
|
||||
| Overrides
|
||||
button.od-close(@click="overrides_open = false") ✕
|
||||
.od-body
|
||||
.od-row
|
||||
label Feed
|
||||
input(type="range", min="0", max="2", step="0.01",
|
||||
v-model="feed_override", @change="override_feed")
|
||||
.od-val {{feed_override | percent 0}}
|
||||
button.od-reset(@click="feed_override = 1; override_feed()") Reset 100%
|
||||
.od-row
|
||||
label Spindle
|
||||
input(type="range", min="0", max="2", step="0.01",
|
||||
v-model="speed_override", @change="override_speed")
|
||||
.od-val {{speed_override | percent 0}}
|
||||
button.od-reset(@click="speed_override = 1; override_speed()") Reset 100%
|
||||
|
||||
@@ -2,6 +2,8 @@ script#estop-template(type="text/x-template")
|
||||
svg(version="1.1", xmlns:svg="http://www.w3.org/2000/svg",
|
||||
xmlns="http://www.w3.org/2000/svg",
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink",
|
||||
viewBox="0 0 130 130",
|
||||
preserveAspectRatio="xMidYMid meet",
|
||||
width="130", height="130")
|
||||
defs
|
||||
path#text-path-1(d="m 73.735,673.129 c 0,55.107 44.673,99.780 99.780,99.780 55.107,0 99.780,-44.673 99.780,-99.780 0,-55.107 -44.673,-99.780 -99.780,-99.780 -55.107,0 -99.780,44.673 -99.780,99.780 z")
|
||||
|
||||
@@ -6,11 +6,11 @@ script#indicators-template(type="text/x-template")
|
||||
|
||||
tr
|
||||
td
|
||||
.fa.fa-plus-circle.io
|
||||
.fa.fa-circle-plus.io
|
||||
th Hi/+3.3v
|
||||
th.separator
|
||||
td
|
||||
.fa.fa-minus-circle.io
|
||||
.fa.fa-circle-minus.io
|
||||
th Lo/Gnd
|
||||
th.separator
|
||||
td
|
||||
@@ -22,7 +22,7 @@ script#indicators-template(type="text/x-template")
|
||||
th Inactive
|
||||
th.separator
|
||||
td
|
||||
.fa.fa-circle-o.io
|
||||
.far.fa-circle.io
|
||||
th Tristated/Disabled
|
||||
|
||||
table.inputs
|
||||
@@ -169,14 +169,14 @@ script#indicators-template(type="text/x-template")
|
||||
|
||||
tr
|
||||
th Motor
|
||||
th(title="Overtemperature fault"): .fa.fa-thermometer-full
|
||||
th(title="Overtemperature fault"): .fa.fa-temperature-full
|
||||
th(title="Overcurrent motor channel A") A #[.fa.fa-bolt]
|
||||
th(title="Predriver fault motor channel A")
|
||||
| A #[.fa.fa-exclamation-triangle]
|
||||
| A #[.fa.fa-triangle-exclamation]
|
||||
th(title="Overcurrent motor channel B") B #[.fa.fa-bolt]
|
||||
th(title="Predriver fault motor channel B")
|
||||
| B #[.fa.fa-exclamation-triangle]
|
||||
th(title="Driver communication failure"): .fa.fa-handshake-o
|
||||
| B #[.fa.fa-triangle-exclamation]
|
||||
th(title="Driver communication failure"): .fa.fa-handshake
|
||||
th(title="Reset all motor flags")
|
||||
.fa.fa-eraser(@click="motor_reset()")
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ script#path-viewer-template(type="text/x-template")
|
||||
.path-viewer-toolbar
|
||||
.tool-button(title="Toggle path view size.",
|
||||
@click="small = !small", :class="{active: !small}")
|
||||
.fa.fa-arrows-alt
|
||||
.fa.fa-up-down-left-right
|
||||
|
||||
.tool-button(@click="showTool = !showTool", :class="{active: showTool}",
|
||||
title="Show/hide tool.")
|
||||
|
||||
142
src/pug/templates/program-view.pug
Normal file
142
src/pug/templates/program-view.pug
Normal file
@@ -0,0 +1,142 @@
|
||||
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-plus.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.
|
||||
// The 3D path-viewer is suppressed when the UI is loaded by
|
||||
// the Pi's onboard kiosk browser — the VideoCore IV cannot
|
||||
// run three.js at a usable frame rate. Off-Pi clients still
|
||||
// see the full split.
|
||||
.program-body(:class="{'no-preview': is_kiosk}")
|
||||
gcode-viewer
|
||||
path-viewer(v-if="!is_kiosk", :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}}
|
||||
58
src/pug/templates/settings-shell.pug
Normal file
58
src/pug/templates/settings-shell.pug
Normal file
@@ -0,0 +1,58 @@
|
||||
script#settings-shell-view-template(type="text/x-template")
|
||||
.settings-shell
|
||||
aside.settings-rail
|
||||
// Use a single v-for over a data-driven items array so every
|
||||
// rail item shares the same compiled :class binding template.
|
||||
// This sidesteps a Vue 1 quirk where sibling-with-different-
|
||||
// expression :class bindings sometimes fail to re-evaluate on
|
||||
// hash navigation, leaving stale `.active` classes.
|
||||
template(v-for="item in rail_items")
|
||||
.set-section(v-if="item.section") {{item.section}}
|
||||
a.set-item(v-if="!item.section", :class="{active: is_active(item)}",
|
||||
:href="item.href", @click="on_rail_click(item, $event)")
|
||||
.fa(:class="item.icon")
|
||||
| {{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).
|
||||
// The Svelte settings views read many config keys eagerly on
|
||||
// attach (settings.units, settings.easy-adapter, motion.*),
|
||||
// so we gate the inner mount on config_ready.
|
||||
settings-view-inner(v-if="sub === 'settings' && config_ready",
|
||||
section="display",
|
||||
:index="index", :config="config", :template="template", :state="state")
|
||||
settings-view-inner(v-if="sub === 'probing' && config_ready",
|
||||
section="probing",
|
||||
:index="index", :config="config", :template="template", :state="state")
|
||||
settings-view-inner(v-if="sub === 'gcode' && config_ready",
|
||||
section="gcode",
|
||||
:index="index", :config="config", :template="template", :state="state")
|
||||
admin-general-view(v-if="sub === 'admin-general' && config_ready",
|
||||
:index="index", :config="config", :template="template", :state="state")
|
||||
admin-network-view(v-if="sub === 'admin-network' && config_ready",
|
||||
:index="index", :config="config", :template="template", :state="state")
|
||||
motor-view(v-if="sub === 'motor' && config_ready",
|
||||
:index="index", :config="config", :template="template", :state="state")
|
||||
tool-view(v-if="sub === 'tool' && config_ready",
|
||||
:index="index", :config="config", :template="template", :state="state")
|
||||
io-view(v-if="sub === 'io' && config_ready",
|
||||
:index="index", :config="config", :template="template", :state="state")
|
||||
a-axis-view(v-if="sub === 'a-axis' && config_ready",
|
||||
:index="index", :config="config", :template="template", :state="state")
|
||||
macros-view(v-if="sub === 'macros' && config_ready",
|
||||
:index="index", :config="config", :template="template", :state="state")
|
||||
help-view(v-if="sub === 'help' && config_ready",
|
||||
:index="index", :config="config", :template="template", :state="state")
|
||||
cheat-sheet-view(v-if="sub === 'cheat-sheet' && config_ready",
|
||||
:index="index", :config="config", :template="template", :state="state")
|
||||
.settings-loading(v-if="!config_ready")
|
||||
| Loading configuration…
|
||||
@@ -433,4 +433,4 @@ script#tool-view-template(type="text/x-template")
|
||||
| Other settings according to the
|
||||
|
|
||||
a(href="https://buildbotics.com/upload/vfd/stepperonline-v70.pdf",
|
||||
target="_blank") Stepper Online V70 VFD manual
|
||||
target="_blank") Stepper Online V70 VFD manual
|
||||
|
||||
706
src/py/bbctrl/AuxAxis.py
Normal file
706
src/py/bbctrl/AuxAxis.py
Normal file
@@ -0,0 +1,706 @@
|
||||
################################################################################
|
||||
#
|
||||
# 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 axis travel
|
||||
'dir_sign': 1, # +1 or -1: maps logical+ to motor+ steps
|
||||
# Logical axis letter exposed to gplan. The auxcnc ESP stepper
|
||||
# is presented to the planner as this axis (default 'a' = standard
|
||||
# 4th axis). gcode uses A for moves; the host ExternalAxis layer
|
||||
# forks A motion to the ESP transparently.
|
||||
'axis_letter': 'a',
|
||||
'min_mm': 0.0, # soft limit min (mm), exposed as 4tn
|
||||
'max_mm': 100.0, # soft limit max (mm), exposed as 4tm
|
||||
# Per-axis kinematic limits used to populate the planner's config.
|
||||
# Units match the bbctrl/onefinity per-motor convention so the
|
||||
# values are directly comparable to motors 0-3:
|
||||
# max_velocity_m_per_min m/min (planner sees * 1000 = mm/min)
|
||||
# max_accel_km_per_min2 km/min2 (planner sees * 1e6 = mm/min2)
|
||||
# max_jerk_km_per_min3 km/min3 (planner sees * 1e6 = mm/min3)
|
||||
'max_velocity_m_per_min': 6.0,
|
||||
'max_accel_km_per_min2': 100.0,
|
||||
'max_jerk_km_per_min3': 500.0,
|
||||
# Informational only - rate caps that actually clamp the move
|
||||
# are on the ESP via step_max_sps below.
|
||||
'max_feed_mm_min': 600.0,
|
||||
'home_dir': '-', # which direction is "toward limit" (host's view)
|
||||
'home_position_mm': 0.0, # mm value to assign at home
|
||||
# ESP-side homing rates (steps/sec). Pushed via HOMECFG on connect.
|
||||
# Speeds tuned for a typical 25 steps/mm aux drive (so 1 step =
|
||||
# 0.04 mm). With the limit-aware ESP firmware these values give
|
||||
# a brisk seek (100 mm/s), enough backoff to clear the switch
|
||||
# hysteresis (16 mm), and a slow re-engage (10 mm/s) that's
|
||||
# accurate without being painfully slow on a longer axis.
|
||||
'home_fast_sps': 2500, # ≈ 100 mm/s @ 25 steps/mm
|
||||
'home_slow_sps': 250, # ≈ 10 mm/s
|
||||
'home_backoff_steps': 400, # ≈ 16 mm
|
||||
'home_maxtravel_steps': 200000,
|
||||
# If HOME starts with the limit switch already tripped the ESP
|
||||
# first moves this many steps away from the limit and then
|
||||
# rechecks. If the switch is still active afterward, HOME hard-
|
||||
# fails (refuses to set zero blindly when we may already be past
|
||||
# the home position). Default ≈ 10 mm @ 25 steps/mm. Set to 0 to
|
||||
# disable the preclear move (HOME then fails immediately if the
|
||||
# switch reads active at start, matching the original behaviour).
|
||||
'home_preclear_mm': 10.0,
|
||||
'step_max_sps': 4000, # ≈ 160 mm/s normal-move cap
|
||||
'step_accel_sps2': 12000,
|
||||
'step_start_sps': 200,
|
||||
'limit_low': True,
|
||||
# ------------------------------------------------------------------
|
||||
# Z-A coupling interlock
|
||||
# ------------------------------------------------------------------
|
||||
# The auxiliary A axis carries a tool that physically hangs below
|
||||
# the Z-axis spindle nose. Beyond a certain Z descent the two
|
||||
# collide unless A drops with Z. The constraint, in machine coords,
|
||||
# is:
|
||||
# A_machine - Z_machine <= K
|
||||
# where K = (A_home_mm - z_home_mm) + couple_z_clearance_mm.
|
||||
# When enabled this is enforced everywhere motion can be
|
||||
# initiated (planner, MDI, jog, file load) and the AuxPreprocessor
|
||||
# injects pre-position A moves before Z descends past the safe
|
||||
# band.
|
||||
'couple_z_enabled': True,
|
||||
'couple_z_clearance_mm': 22.0, # Z drop allowed before A must follow
|
||||
'z_home_mm': 0.0, # Z's machine position when homed
|
||||
}
|
||||
|
||||
|
||||
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')
|
||||
|
||||
# Legacy aux.json fields that have been renamed for clarity.
|
||||
# Loaded values are migrated up on every load/save so existing
|
||||
# installs keep working without operator intervention.
|
||||
_LEGACY_FIELD_MAP = {
|
||||
'min_w': 'min_mm',
|
||||
'max_w': 'max_mm',
|
||||
}
|
||||
|
||||
def _migrate_legacy_fields(self, cfg):
|
||||
"""In-place rename of legacy keys in `cfg` (dict). Returns
|
||||
True if anything was migrated, so callers can decide whether
|
||||
to persist the upgraded form.
|
||||
"""
|
||||
migrated = False
|
||||
for old, new in self._LEGACY_FIELD_MAP.items():
|
||||
if old in cfg:
|
||||
if new not in cfg:
|
||||
cfg[new] = cfg[old]
|
||||
del cfg[old]
|
||||
migrated = True
|
||||
return migrated
|
||||
|
||||
def _load_config(self):
|
||||
path = self._config_path()
|
||||
if os.path.exists(path):
|
||||
try:
|
||||
with open(path) as f:
|
||||
user = json.load(f)
|
||||
migrated = self._migrate_legacy_fields(user)
|
||||
# Be permissive; ignore unknown keys.
|
||||
for k, v in user.items():
|
||||
if k in self._cfg:
|
||||
self._cfg[k] = v
|
||||
self.log.info('Loaded aux config from %s' % path)
|
||||
if migrated:
|
||||
# Persist the upgraded form so future restarts
|
||||
# see the new field names directly.
|
||||
try:
|
||||
self.save_config(self._cfg)
|
||||
self.log.info(
|
||||
'Migrated aux.json legacy fields '
|
||||
'(min_w/max_w -> min_mm/max_mm)')
|
||||
except Exception:
|
||||
self.log.warning(
|
||||
'Could not persist aux.json migration')
|
||||
except Exception:
|
||||
self.log.error('Failed to read aux.json: %s'
|
||||
% traceback.format_exc())
|
||||
|
||||
def save_config(self, cfg):
|
||||
merged = dict(DEFAULTS)
|
||||
# Accept legacy keys from callers that may still send the
|
||||
# old names (older UI bundles, hand-edited POSTs).
|
||||
cfg = dict(cfg)
|
||||
self._migrate_legacy_fields(cfg)
|
||||
for k, v in cfg.items():
|
||||
if k in DEFAULTS:
|
||||
merged[k] = v
|
||||
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 set_state_observer(self, fn):
|
||||
"""Register a callback invoked after every _publish_state.
|
||||
Used by ExternalAxis to mirror the homed flag into State."""
|
||||
self._state_observer = fn
|
||||
|
||||
def home(self):
|
||||
"""Run the homing cycle on the ESP. Blocks until done. Raises on
|
||||
failure. Updates aux_homed and aux_pos.
|
||||
|
||||
The ESP's home_zero is pre-loaded via HOMECFG so when the cycle
|
||||
completes the step counter already corresponds to home_position_mm.
|
||||
That way the homed-state survives a bbctrl restart correctly
|
||||
(we don't need a post-home WPOS write, which would clear HOMED)."""
|
||||
self._require_present()
|
||||
# Make sure home_zero on the ESP matches our current
|
||||
# home_position_mm in case the user just edited config.
|
||||
self._push_homecfg()
|
||||
line = self._rpc('HOME', topic='home', timeout=120.0)
|
||||
# line is the body after '[home] '. Only terminal lines use
|
||||
# the [home] topic now (done / failed); progress is [home_log].
|
||||
if line.startswith('done'):
|
||||
self._pos_steps = self._parse_kv_int(line, 'pos', 0)
|
||||
self._homed = True
|
||||
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)
|
||||
|
||||
# ---------------------------------------------------------- ATC commands
|
||||
#
|
||||
# The auxcnc firmware drives an AMB 1050 FME-W DI tool changer via
|
||||
# three pneumatic valves on relays 1-3. The ESP runs the timed
|
||||
# sequences itself; the host just kicks them off and waits for the
|
||||
# terminal reply.
|
||||
|
||||
def atc_droptool(self, timeout=30.0):
|
||||
"""Eject the current tool. Opens the collet (V1), oscillates the
|
||||
ejector (V2), then re-clamps with a bleed cycle. Blocks until
|
||||
the ESP reports done. Raises on failure."""
|
||||
self._require_present()
|
||||
line = self._rpc('DROPTOOL', topic='droptool', timeout=timeout)
|
||||
if line.startswith('done'):
|
||||
return
|
||||
reason = line.split('reason=', 1)[1] if 'reason=' in line else line
|
||||
raise AuxAxisError('DROPTOOL failed: %s' % reason)
|
||||
|
||||
def atc_grabtool(self, timeout=30.0):
|
||||
"""Pick up a tool that's already been seated by the operator.
|
||||
Opens V1 (releases the collet), waits for the operator to insert
|
||||
the holder, then re-clamps with a bleed cycle. Blocks."""
|
||||
self._require_present()
|
||||
line = self._rpc('GRABTOOL', topic='grabtool', timeout=timeout)
|
||||
if line.startswith('done'):
|
||||
return
|
||||
reason = line.split('reason=', 1)[1] if 'reason=' in line else line
|
||||
raise AuxAxisError('GRABTOOL failed: %s' % reason)
|
||||
|
||||
def atc_release(self, timeout=5.0):
|
||||
"""Manually open the collet (release-only, no clamp). Use
|
||||
atc_clamp() afterwards once the new holder is in place."""
|
||||
self._require_present()
|
||||
line = self._rpc('RELEASE', topic='release', timeout=timeout)
|
||||
if line.startswith('done'):
|
||||
return
|
||||
reason = line.split('reason=', 1)[1] if 'reason=' in line else line
|
||||
raise AuxAxisError('RELEASE failed: %s' % reason)
|
||||
|
||||
def atc_clamp(self, timeout=10.0):
|
||||
"""Manually clamp the collet (run a full bleed cycle). Pairs
|
||||
with atc_release() for two-step manual tool changes."""
|
||||
self._require_present()
|
||||
line = self._rpc('CLAMP', topic='clamp', timeout=timeout)
|
||||
if line.startswith('done'):
|
||||
return
|
||||
reason = line.split('reason=', 1)[1] if 'reason=' in line else line
|
||||
raise AuxAxisError('CLAMP failed: %s' % reason)
|
||||
|
||||
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_mm'])
|
||||
hi = float(self._cfg['max_mm'])
|
||||
if hi <= lo:
|
||||
return # no limits
|
||||
if target_mm < lo - 1e-6 or target_mm > hi + 1e-6:
|
||||
raise AuxAxisError(
|
||||
'A=%.3f out of soft limits [%.3f, %.3f]' % (target_mm, lo, hi))
|
||||
|
||||
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)
|
||||
|
||||
def _do_line(self, signed_steps, length_mm,
|
||||
max_accel_mm_min2, max_jerk_mm_min3,
|
||||
entry_vel_mm_min, exit_vel_mm_min,
|
||||
times_min, ignore_limits=False, timeout=300.0):
|
||||
"""Run a 7-segment jerk-limited S-curve on the ESP that mirrors
|
||||
gplan/buildbotics' planner output exactly.
|
||||
|
||||
Parameters are in the same units the AVR/gplan use:
|
||||
- length_mm: absolute travel in mm (>= 0)
|
||||
- max_accel: mm/min^2
|
||||
- max_jerk: mm/min^3
|
||||
- entry/exit_vel: mm/min
|
||||
- times_min: 7-tuple of section durations in minutes
|
||||
|
||||
ignore_limits sets safe=0 on the ESP - used for jog/move
|
||||
endpoints that may run before homing.
|
||||
|
||||
Blocks until the ESP reports done or aborted. Updates the
|
||||
position mirror and re-publishes state on every reply.
|
||||
"""
|
||||
if signed_steps == 0 or length_mm <= 0:
|
||||
return
|
||||
if not any(times_min):
|
||||
raise AuxAxisError('LINE rejected: all section times are zero')
|
||||
# Build the LINE command. Float formatting matches the AVR's
|
||||
# printf precision (6 sig figs) - that's well above what the
|
||||
# ESP needs given it integrates into a few thousand 4 ms
|
||||
# segments per move.
|
||||
parts = [
|
||||
'LINE',
|
||||
'steps=%d' % int(signed_steps),
|
||||
'length=%.6f' % float(length_mm),
|
||||
'max_accel=%.6f' % float(max_accel_mm_min2),
|
||||
'max_jerk=%.6f' % float(max_jerk_mm_min3),
|
||||
'entry_vel=%.6f' % float(entry_vel_mm_min),
|
||||
'exit_vel=%.6f' % float(exit_vel_mm_min),
|
||||
]
|
||||
for i, t in enumerate(times_min):
|
||||
if t and t > 0:
|
||||
parts.append('t%d=%.9f' % (i, float(t)))
|
||||
if ignore_limits:
|
||||
parts.append('safe=0')
|
||||
cmd = ' '.join(parts)
|
||||
line = self._rpc(cmd, topic='line', timeout=timeout)
|
||||
# line: "done pos=P emitted=N" or "aborted pos=P emitted=N reason=..."
|
||||
if line.startswith('done'):
|
||||
self._pos_steps = self._parse_kv_int(line, 'pos', self._pos_steps)
|
||||
self._publish_state()
|
||||
return
|
||||
# aborted
|
||||
self._pos_steps = self._parse_kv_int(line, 'pos', self._pos_steps)
|
||||
self._publish_state()
|
||||
reason = self._parse_kv_str(line, 'reason')
|
||||
if reason == 'limit':
|
||||
self._homed = False
|
||||
raise AuxAxisError('W move aborted by limit switch')
|
||||
raise AuxAxisError('W move aborted: %s' % line)
|
||||
|
||||
# ------------------------------------------------------------ serial I/O
|
||||
|
||||
def _open(self):
|
||||
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
|
||||
zero_steps = self._mm_to_steps(c['home_position_mm'])
|
||||
# preclear: how far (in steps) the ESP backs off if HOME is
|
||||
# invoked while the limit switch is already tripped. Computed
|
||||
# from home_preclear_mm so the operator configures it in mm.
|
||||
spm = float(c.get('steps_per_mm', 1.0)) or 1.0
|
||||
preclear_steps = int(round(abs(float(c['home_preclear_mm'])) * spm))
|
||||
cmd = ('HOMECFG dir=%s fast=%d slow=%d backoff=%d maxtravel=%d '
|
||||
'zero=%d accel=%d step_max=%d step_start=%d limit_low=%d '
|
||||
'preclear=%d') % (
|
||||
c['home_dir'],
|
||||
int(c['home_fast_sps']),
|
||||
int(c['home_slow_sps']),
|
||||
int(c['home_backoff_steps']),
|
||||
int(c['home_maxtravel_steps']),
|
||||
int(zero_steps),
|
||||
int(c['step_accel_sps2']),
|
||||
int(c['step_max_sps']),
|
||||
int(c['step_start_sps']),
|
||||
1 if c['limit_low'] else 0,
|
||||
preclear_steps,
|
||||
)
|
||||
self._rpc(cmd, topic='homecfg', timeout=3.0)
|
||||
|
||||
def _refresh_state(self):
|
||||
try:
|
||||
r = self._rpc('WPOS?', topic='wpos', timeout=2.0)
|
||||
self._pos_steps = int(r.strip())
|
||||
except Exception:
|
||||
pass
|
||||
# Force the host to start unhomed regardless of what the ESP
|
||||
# remembers from a prior session. The ESP's homed flag survives
|
||||
# bbctrl restarts (since the ESP itself wasn't power-cycled),
|
||||
# but the host's planner offsets and DRO position get reset to
|
||||
# zero on bbctrl boot. Trusting the ESP's homed flag would mean
|
||||
# the user thinks A is homed at the wrong work-coord origin
|
||||
# (offset_a=0 but ESP physically at home_position_mm). Sending
|
||||
# UNHOME forces the user to re-home explicitly, which sets up
|
||||
# the offset and gplan state correctly via the homing path in
|
||||
# Mach.home.
|
||||
try:
|
||||
self._rpc('UNHOME', topic='ok', timeout=2.0)
|
||||
self._homed = False
|
||||
except Exception:
|
||||
# Fall back to whatever HOMED? says - but treat any
|
||||
# missing UNHOME support as "trust ESP's flag" so we
|
||||
# don't break older firmware.
|
||||
try:
|
||||
r = self._rpc('HOMED?', topic='homed', timeout=2.0)
|
||||
self._homed = (r.strip() == '1')
|
||||
except Exception:
|
||||
pass
|
||||
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(
|
||||
'Auxiliary 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
|
||||
# Notify the external-axis layer so it can mirror state
|
||||
# (e.g. homed flag) into the synthetic motor vars.
|
||||
observer = getattr(self, '_state_observer', None)
|
||||
if observer is not None:
|
||||
try:
|
||||
observer()
|
||||
except Exception:
|
||||
pass
|
||||
400
src/py/bbctrl/AuxPreprocessor.py
Normal file
400
src/py/bbctrl/AuxPreprocessor.py
Normal file
@@ -0,0 +1,400 @@
|
||||
################################################################################
|
||||
#
|
||||
# AuxPreprocessor - rewrite ATC M-codes into hook calls
|
||||
#
|
||||
# History
|
||||
# -------
|
||||
# v1: rewrote W tokens into (MSG,HOOK:aux:N) lines because the bbctrl
|
||||
# planner only understood XYZABC and the W axis was driven via a
|
||||
# side-channel.
|
||||
# v2: W is now exposed to gplan as a virtual A axis (see ExternalAxis),
|
||||
# so gplan handles W motion natively. The preprocessor no longer
|
||||
# touches W tokens. ATC pneumatics still go through the hook
|
||||
# channel because they're events, not motion.
|
||||
#
|
||||
# What this still does
|
||||
# --------------------
|
||||
# Maps four user-defined M-codes onto pneumatic-tool-changer events:
|
||||
#
|
||||
# M100 DROPTOOL -> (MSG,HOOK:droptool:)
|
||||
# M101 GRABTOOL -> (MSG,HOOK:grabtool:)
|
||||
# M102 RELEASE -> (MSG,HOOK:release:)
|
||||
# M103 CLAMP -> (MSG,HOOK:clamp:)
|
||||
#
|
||||
# M100-M103 are in LinuxCNC/Buildbotics' user-defined range, so the
|
||||
# planner won't error if the codes leak through unrewritten - it just
|
||||
# won't *do* anything. We strip them out and emit the matching hook
|
||||
# line in their place.
|
||||
#
|
||||
# The preprocessor is intentionally conservative: anything it doesn't
|
||||
# understand is left alone.
|
||||
#
|
||||
################################################################################
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
|
||||
# Strip line comments so we don't get fooled by "(M100 not really)".
|
||||
_PAREN_COMMENT_RE = re.compile(r'\([^)]*\)')
|
||||
|
||||
# ATC pneumatics M-codes mapped onto hook events.
|
||||
_ATC_M_CODES = {
|
||||
100: 'droptool',
|
||||
101: 'grabtool',
|
||||
102: 'release',
|
||||
103: 'clamp',
|
||||
}
|
||||
_ATC_M_RE = re.compile(
|
||||
r'(?<![A-Za-z_0-9])[Mm]\s*0*(' +
|
||||
'|'.join(str(n) for n in _ATC_M_CODES) +
|
||||
r')(?![\w.])'
|
||||
)
|
||||
|
||||
# Detect a W axis token. We no longer rewrite W to A automatically;
|
||||
# instead we warn so the user knows their old gcode needs migration.
|
||||
# (The W support was removed when the axis was integrated as a real
|
||||
# A axis through gplan.)
|
||||
_W_TOKEN_RE = re.compile(r'(?<![A-Za-z_0-9])[Ww]\s*[-+]?\d*\.?\d+')
|
||||
|
||||
# Match a single axis word (letter + optional whitespace + signed decimal)
|
||||
# for Z, A, X, Y. Used to extract modal targets while preserving the
|
||||
# original line for emission. We deliberately ignore I/J/K/R (arc params)
|
||||
# because they're not endpoints.
|
||||
_AXIS_TOKEN_RES = {
|
||||
'z': re.compile(r'(?<![A-Za-z_0-9])[Zz]\s*([-+]?\d*\.?\d+)'),
|
||||
'a': re.compile(r'(?<![A-Za-z_0-9])[Aa]\s*([-+]?\d*\.?\d+)'),
|
||||
'x': re.compile(r'(?<![A-Za-z_0-9])[Xx]\s*([-+]?\d*\.?\d+)'),
|
||||
'y': re.compile(r'(?<![A-Za-z_0-9])[Yy]\s*([-+]?\d*\.?\d+)'),
|
||||
}
|
||||
_G_CODE_RE = re.compile(r'(?<![A-Za-z_0-9])[Gg]\s*0*(\d+(?:\.\d+)?)')
|
||||
|
||||
|
||||
class AuxPreprocessorError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class AuxPreprocessor(object):
|
||||
def __init__(self, log=None, coupling=None):
|
||||
"""`coupling`, when supplied, enables Z-A coupling injection.
|
||||
Expected shape:
|
||||
{
|
||||
'enabled': bool,
|
||||
'clearance_mm': float, # max (A_wc - Z_wc)
|
||||
'a_initial_wc': float, # A's work-coord position at
|
||||
# file start (typically 0 if
|
||||
# operator zeroed at home)
|
||||
'z_initial_wc': float, # Z's work-coord position at
|
||||
# file start (typically 0)
|
||||
}
|
||||
Pass None to disable injection (preprocessor still rewrites
|
||||
ATC M-codes)."""
|
||||
self.log = log
|
||||
self._w_warned = False
|
||||
self._coupling = coupling if (coupling and
|
||||
coupling.get('enabled')) else None
|
||||
# Modal state used while scanning the file.
|
||||
if self._coupling is not None:
|
||||
self._a_wc = float(coupling.get('a_initial_wc', 0.0))
|
||||
self._z_wc = float(coupling.get('z_initial_wc', 0.0))
|
||||
self._K = float(coupling.get('clearance_mm', 0.0))
|
||||
else:
|
||||
self._a_wc = 0.0
|
||||
self._z_wc = 0.0
|
||||
self._K = 0.0
|
||||
self._g91_warned = False
|
||||
# Distance mode: True for absolute (G90), False for incremental
|
||||
# (G91). Per RS274 the modal default at start is G90.
|
||||
self._g90 = True
|
||||
|
||||
def _info(self, msg):
|
||||
if self.log: self.log.info(msg)
|
||||
|
||||
def _warn(self, msg):
|
||||
if self.log: self.log.warning(msg)
|
||||
|
||||
# ------------------------------------------------------------------ scan
|
||||
|
||||
@staticmethod
|
||||
def file_uses_aux(path, coupling=None):
|
||||
"""Quick check: does this file contain anything the preprocessor
|
||||
would rewrite? Returns True for ATC M-codes always, and for
|
||||
any Z/A move if coupling is enabled (we have to scan to know
|
||||
whether injection is needed, so any motion file qualifies)."""
|
||||
couple_active = bool(coupling and coupling.get('enabled'))
|
||||
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 _ATC_M_RE.search(code):
|
||||
return True
|
||||
if couple_active:
|
||||
if _AXIS_TOKEN_RES['z'].search(code) or \
|
||||
_AXIS_TOKEN_RES['a'].search(code):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
# Backwards-compat alias.
|
||||
file_uses_w = file_uses_aux
|
||||
|
||||
# ------------------------------------------------------------------ Z-A coupling
|
||||
#
|
||||
# Track modal Z and A targets across the file. Whenever a line
|
||||
# would put A above Z by more than `clearance_mm` (i.e. A_wc -
|
||||
# Z_wc > K), we inject `G0 A<safe>` immediately before it so A is
|
||||
# already at the safe position when Z descends. The injected move
|
||||
# uses G0 (rapid) so it's quick.
|
||||
#
|
||||
# Endpoint-only check: gplan plans line endpoints. As long as
|
||||
# (target_A_wc - target_Z_wc) <= K, the trajectory stays safe
|
||||
# because Z's *minimum* during a single line is its endpoint (Z
|
||||
# moves monotonically along a single line block in absolute
|
||||
# mode) and A is held at the pre-positioned value during the move.
|
||||
|
||||
def _extract_g_codes(self, code):
|
||||
"""Return the set of G-codes referenced on `code`. Numeric
|
||||
only, e.g. {0, 1, 90, 17}. Used to track modal state."""
|
||||
out = set()
|
||||
for m in _G_CODE_RE.finditer(code):
|
||||
try:
|
||||
out.add(int(float(m.group(1))))
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
|
||||
def _extract_axis(self, axis, code):
|
||||
"""Return the last value of `axis` token on `code`, or None."""
|
||||
rx = _AXIS_TOKEN_RES.get(axis)
|
||||
if rx is None:
|
||||
return None
|
||||
last = None
|
||||
for m in rx.finditer(code):
|
||||
try:
|
||||
last = float(m.group(1))
|
||||
except Exception:
|
||||
pass
|
||||
return last
|
||||
|
||||
def _maybe_inject_a_down(self, code, fout):
|
||||
"""Inspect `code` (with comments stripped) for an upcoming Z
|
||||
descent; emit a `G0 A<safe>` line on `fout` if needed and
|
||||
update self._a_wc accordingly. Returns True if anything was
|
||||
injected.
|
||||
|
||||
On a violation that cannot be fixed by lowering A (e.g. the
|
||||
operator wrote `G0 A0` while Z is too deep), raise
|
||||
AuxPreprocessorError so the file load surfaces the problem -
|
||||
per the rule we agreed: error, don't silently insert a Z-up.
|
||||
"""
|
||||
if self._coupling is None:
|
||||
return False
|
||||
|
||||
# Distance mode tracking.
|
||||
gs = self._extract_g_codes(code)
|
||||
if 90 in gs: self._g90 = True
|
||||
if 91 in gs:
|
||||
if self._g90 and not self._g91_warned:
|
||||
self._warn(
|
||||
'AuxPreprocessor: G91 (incremental mode) detected; '
|
||||
'Z-A coupling injection is disabled for the rest of '
|
||||
'the file. The runtime check still applies.')
|
||||
self._g91_warned = True
|
||||
self._g90 = False
|
||||
|
||||
# G92 sets coordinate offsets. The new modal value of an
|
||||
# axis is whatever value follows on the same word (e.g.
|
||||
# G92 A0 sets A_wc = 0). Apply that and skip injection.
|
||||
if 92 in gs:
|
||||
new_a = self._extract_axis('a', code)
|
||||
new_z = self._extract_axis('z', code)
|
||||
if new_a is not None: self._a_wc = new_a
|
||||
if new_z is not None: self._z_wc = new_z
|
||||
return False
|
||||
|
||||
# In incremental mode we can still track approximately, but
|
||||
# the user has been warned; skip injection.
|
||||
if not self._g90:
|
||||
return False
|
||||
|
||||
new_z_target = self._extract_axis('z', code)
|
||||
new_a_target = self._extract_axis('a', code)
|
||||
if new_z_target is None and new_a_target is None:
|
||||
return False
|
||||
|
||||
# Modal values after the line executes.
|
||||
a_after = new_a_target if new_a_target is not None else self._a_wc
|
||||
z_after = new_z_target if new_z_target is not None else self._z_wc
|
||||
|
||||
eps = 1e-4
|
||||
if a_after - z_after <= self._K + eps:
|
||||
# Move is safe as authored. Update modal state.
|
||||
self._a_wc = a_after
|
||||
self._z_wc = z_after
|
||||
return False
|
||||
|
||||
# Violation. Two cases:
|
||||
#
|
||||
# (a) The line lowers Z (z_after < self._z_wc) and A is
|
||||
# held or moved upward, so A needs to drop to keep up.
|
||||
# We can fix this by pre-positioning A at z_after + K
|
||||
# BEFORE the line - at which point gplan's plan for the
|
||||
# line is safe at every point along it.
|
||||
#
|
||||
# (b) The line raises A above the safe band while Z is
|
||||
# held (z_after >= self._z_wc) - i.e. the operator
|
||||
# wrote `G0 A0` while Z is parked deep. Auto-injecting
|
||||
# a Z-up here is unsafe (Z could swing into a fixture
|
||||
# or the part) so we error out and let the operator
|
||||
# author the lift.
|
||||
|
||||
safe_a = z_after + self._K
|
||||
|
||||
# If the line itself targets an A above the safe band, the
|
||||
# endpoint violates the rule no matter what we pre-position.
|
||||
# Refuse rather than emit something that runs the gantry into
|
||||
# the tool.
|
||||
if new_a_target is not None and new_a_target > safe_a + eps:
|
||||
raise AuxPreprocessorError(
|
||||
'Z-A coupling violation: line targets A=%.3f at '
|
||||
'Z=%.3f, but max A allowed is %.3f (clearance %.3f). '
|
||||
'Lower the A target or add a Z-up move first.' % (
|
||||
new_a_target, z_after, safe_a, self._K))
|
||||
|
||||
# If the line raises A above the current safe band but Z
|
||||
# isn't dropping with it (no Z target on the line, or Z stays
|
||||
# put), the violation is the operator's A-up, not a Z-down.
|
||||
# Refuse rather than insert a Z-up (which could swing through
|
||||
# a fixture or part).
|
||||
if (new_a_target is not None and
|
||||
new_a_target > self._a_wc + eps and
|
||||
new_z_target is None):
|
||||
raise AuxPreprocessorError(
|
||||
'Z-A coupling violation at line raising A to %.3f '
|
||||
'while Z is at %.3f (max A allowed is %.3f given '
|
||||
'clearance %.3f). Add a Z-up move first.' % (
|
||||
new_a_target, z_after, safe_a, self._K))
|
||||
|
||||
# Case (a): pre-position A.
|
||||
# Don't move A *up* as part of pre-position - if the safe
|
||||
# value is above where A already is, we'd lift A into a
|
||||
# potential collision elsewhere. In practice safe_a < a_wc
|
||||
# whenever we get here (otherwise no violation), but assert
|
||||
# to be sure.
|
||||
if safe_a > self._a_wc + eps:
|
||||
raise AuxPreprocessorError(
|
||||
'Z-A coupling: cannot fix line by lowering A '
|
||||
'(safe A = %.3f > current A = %.3f).' % (
|
||||
safe_a, self._a_wc))
|
||||
fout.write('(injected by AuxPreprocessor: Z-A coupling)\n')
|
||||
fout.write('G0 A%.4f\n' % safe_a)
|
||||
self._a_wc = safe_a
|
||||
# Don't update z_wc yet - the original line will do that
|
||||
# when it runs. But our modal copy must reflect the post-line
|
||||
# value so subsequent injections compute correctly.
|
||||
self._z_wc = z_after
|
||||
# If the original line also moved A, our pre-positioning
|
||||
# supersedes it (we overwrite a_wc above with safe_a then
|
||||
# the original line's A target may push it back up). Update
|
||||
# a_wc to the line's authored A value so further checks see
|
||||
# the post-line state.
|
||||
if new_a_target is not None:
|
||||
self._a_wc = new_a_target
|
||||
return True
|
||||
|
||||
# ------------------------------------------------------------------ run
|
||||
|
||||
def process(self, src_path, dst_path):
|
||||
"""Read src_path, write rewritten G-code to dst_path. Returns
|
||||
True if any rewrite happened."""
|
||||
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
|
||||
|
||||
# Warn (once) if the file still uses W tokens. The
|
||||
# standard way is now G1 A<value>; old files must be
|
||||
# migrated by hand.
|
||||
if (not self._w_warned) and _W_TOKEN_RE.search(code):
|
||||
self._warn('Found W axis token in gcode; W is no '
|
||||
'longer recognized by bbctrl. Use A '
|
||||
'instead. (warning suppressed for '
|
||||
'subsequent W tokens in this file)')
|
||||
self._w_warned = True
|
||||
|
||||
# Z-A coupling injection BEFORE the line is emitted.
|
||||
if self._maybe_inject_a_down(code, fout):
|
||||
rewrote_any = True
|
||||
|
||||
# ATC M-codes (M100-M103). Each ATC M-code on the line
|
||||
# is replaced with its (MSG,HOOK:<event>:) line and
|
||||
# stripped from the residual.
|
||||
atc_matches = list(_ATC_M_RE.finditer(line))
|
||||
if atc_matches:
|
||||
rewrote_any = True
|
||||
for m in atc_matches:
|
||||
try: num = int(m.group(1))
|
||||
except ValueError: continue
|
||||
event = _ATC_M_CODES.get(num)
|
||||
if event:
|
||||
fout.write('(MSG,HOOK:%s:)\n' % event)
|
||||
line = _ATC_M_RE.sub('', line)
|
||||
code = _PAREN_COMMENT_RE.sub('', line)
|
||||
code = code.split(';', 1)[0]
|
||||
if not code.strip():
|
||||
# Nothing meaningful left; preserve any trailing
|
||||
# comment text but skip empty lines.
|
||||
rest = line.rstrip()
|
||||
if rest:
|
||||
fout.write(rest + '\n')
|
||||
continue
|
||||
# Other gcode remains on the line - emit it.
|
||||
fout.write(line + '\n')
|
||||
continue
|
||||
|
||||
# No rewrite needed.
|
||||
fout.write(raw)
|
||||
|
||||
return rewrote_any
|
||||
|
||||
|
||||
def preprocess_file(src_path, log=None, coupling=None, **_unused):
|
||||
"""Convenience: rewrite src_path in place if it contains ATC
|
||||
M-codes or needs Z-A coupling injection. Returns True if the
|
||||
file was rewritten.
|
||||
|
||||
`coupling` is an optional dict (see AuxPreprocessor.__init__).
|
||||
Extra keyword args are accepted for backwards compat (the old
|
||||
w_first arg is no longer used)."""
|
||||
if not AuxPreprocessor.file_uses_aux(src_path, coupling=coupling):
|
||||
return False
|
||||
pre = AuxPreprocessor(log=log, coupling=coupling)
|
||||
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
|
||||
|
||||
|
||||
@web.asynchronous
|
||||
def get(self):
|
||||
async def get(self):
|
||||
self.request.connection.stream.max_write_buffer_size = 10000
|
||||
|
||||
self.set_header('Cache-Control', 'no-store, no-cache, must-revalidate, '
|
||||
|
||||
@@ -223,6 +223,10 @@ class Comm(object):
|
||||
self.ctrl.mach.process_log(msg)
|
||||
elif 'firmware' in msg:
|
||||
self.log.info('AVR firmware rebooted')
|
||||
try:
|
||||
import bbctrl.Trace as _T
|
||||
_T.mark('avr.firmware_rebooted')
|
||||
except Exception: pass
|
||||
self.connect()
|
||||
else:
|
||||
self._update_state(msg)
|
||||
|
||||
@@ -216,6 +216,32 @@ class Config(object):
|
||||
defaults = json.load(f)
|
||||
config['selected-tool-settings'] = defaults['selected-tool-settings'];
|
||||
|
||||
# Auxiliary axis nomenclature: rename W -> A in macro names and
|
||||
# filenames. The auxcnc-driven stepper has been integrated into
|
||||
# gplan as A since the option-b migration; old configs may
|
||||
# still carry W Down/W Up macro entries pointing at
|
||||
# w_down.nc/w_up.nc which were renamed on disk to a_down.nc /
|
||||
# a_up.nc. Migrate idempotently on every load so a stale
|
||||
# in-memory copy can never reintroduce the old names.
|
||||
macros = config.get('macros') if isinstance(config, dict) else None
|
||||
if isinstance(macros, list):
|
||||
renames = {
|
||||
'w_down.nc': 'a_down.nc',
|
||||
'w_up.nc': 'a_up.nc',
|
||||
}
|
||||
display_renames = {
|
||||
'W Down': 'A Down',
|
||||
'W Up': 'A Up',
|
||||
}
|
||||
for m in macros:
|
||||
if not isinstance(m, dict): continue
|
||||
fn = m.get('file_name')
|
||||
if isinstance(fn, str) and fn in renames:
|
||||
m['file_name'] = renames[fn]
|
||||
nm = m.get('name')
|
||||
if isinstance(nm, str) and nm in display_renames:
|
||||
m['name'] = display_renames[nm]
|
||||
|
||||
config['version'] = self.version.split('b')[0]
|
||||
config['full_version'] = self.version
|
||||
|
||||
|
||||
@@ -28,10 +28,12 @@
|
||||
import os
|
||||
import time
|
||||
import bbctrl
|
||||
import bbctrl.Trace as Trace
|
||||
|
||||
|
||||
class Ctrl(object):
|
||||
def __init__(self, args, ioloop, id):
|
||||
Trace.mark('ctrl.init.start', id=id or '<default>')
|
||||
self.args = args
|
||||
self.ioloop = bbctrl.IOLoop(ioloop)
|
||||
self.id = id
|
||||
@@ -43,31 +45,65 @@ class Ctrl(object):
|
||||
if args.demo: log_path = self.get_path(filename = 'bbctrl.log')
|
||||
else: log_path = args.log
|
||||
self.log = bbctrl.log.Log(args, self.ioloop, log_path)
|
||||
Trace.mark('ctrl.log_open')
|
||||
|
||||
self.state = bbctrl.State(self)
|
||||
self.config = bbctrl.Config(self)
|
||||
Trace.mark('ctrl.state_config')
|
||||
|
||||
self.log.get('Ctrl').info('Starting %s' % self.id)
|
||||
|
||||
try:
|
||||
if args.demo: self.avr = bbctrl.AVREmu(self)
|
||||
else: self.avr = bbctrl.AVR(self)
|
||||
with Trace.span('ctrl.avr'):
|
||||
if args.demo: self.avr = bbctrl.AVREmu(self)
|
||||
else: self.avr = bbctrl.AVR(self)
|
||||
|
||||
self.i2c = bbctrl.I2C(args.i2c_port, args.demo)
|
||||
self.lcd = bbctrl.LCD(self)
|
||||
self.mach = bbctrl.Mach(self, self.avr)
|
||||
self.preplanner = bbctrl.Preplanner(self)
|
||||
if not args.demo: self.jog = bbctrl.Jog(self)
|
||||
self.pwr = bbctrl.Pwr(self)
|
||||
with Trace.span('ctrl.i2c'):
|
||||
self.i2c = bbctrl.I2C(args.i2c_port, args.demo)
|
||||
with Trace.span('ctrl.lcd'):
|
||||
self.lcd = bbctrl.LCD(self)
|
||||
with Trace.span('ctrl.mach'):
|
||||
self.mach = bbctrl.Mach(self, self.avr)
|
||||
with Trace.span('ctrl.preplanner'):
|
||||
self.preplanner = bbctrl.Preplanner(self)
|
||||
if not args.demo:
|
||||
with Trace.span('ctrl.jog'):
|
||||
self.jog = bbctrl.Jog(self)
|
||||
with Trace.span('ctrl.pwr'):
|
||||
self.pwr = bbctrl.Pwr(self)
|
||||
with Trace.span('ctrl.hooks'):
|
||||
self.hooks = bbctrl.Hooks(self)
|
||||
with Trace.span('ctrl.aux'):
|
||||
self.aux = bbctrl.AuxAxis(self)
|
||||
with Trace.span('ctrl.ext_axis'):
|
||||
# ExternalAxis exposes the auxcnc ESP stepper as a
|
||||
# virtual A axis that gplan handles natively. Created
|
||||
# unconditionally so State sees the synthetic motor
|
||||
# vars even when aux is disabled (kept inert in that
|
||||
# case via ext_axis.enabled).
|
||||
axis_letter = self.aux._cfg.get('axis_letter', 'a')
|
||||
self.ext_axis = bbctrl.ExternalAxis(
|
||||
self, self.aux, axis_letter=axis_letter)
|
||||
# Hook AuxAxis post-publish callback so homed flag
|
||||
# mirrors into State after homing.
|
||||
self.aux.set_state_observer(
|
||||
self.ext_axis.refresh_homed)
|
||||
self._register_aux_hooks()
|
||||
|
||||
self.mach.connect()
|
||||
with Trace.span('ctrl.mach.connect'):
|
||||
self.mach.connect()
|
||||
|
||||
self.lcd.add_new_page(bbctrl.MainLCDPage(self))
|
||||
self.lcd.add_new_page(bbctrl.IPLCDPage(self.lcd))
|
||||
|
||||
os.environ['GCODE_SCRIPT_PATH'] = self.get_upload()
|
||||
|
||||
except Exception: self.log.get('Ctrl').exception('Internal error: Control initialization failed')
|
||||
Trace.mark('ctrl.init.end')
|
||||
Trace.sd_notify('STATUS=ctrl initialized\n')
|
||||
|
||||
except Exception:
|
||||
Trace.mark('ctrl.init.error')
|
||||
self.log.get('Ctrl').exception('Internal error: Control initialization failed')
|
||||
|
||||
|
||||
def __del__(self): print('Ctrl deleted')
|
||||
@@ -109,8 +145,61 @@ class Ctrl(object):
|
||||
self.preplanner.start()
|
||||
|
||||
|
||||
def _register_aux_hooks(self):
|
||||
"""Wire up auxcnc HOOK: events to AuxAxis methods.
|
||||
|
||||
v2: motion hooks (aux/aux_rel/aux_home/aux_setzero) are
|
||||
retired now that the W axis is integrated through gplan as
|
||||
a virtual A axis (see ExternalAxis). Only the ATC pneumatic
|
||||
hooks remain - those are events, not motion.
|
||||
|
||||
For backwards compatibility with files that still contain
|
||||
(MSG,HOOK:aux_home:) (e.g. older preprocessed gcode), keep
|
||||
an aux_home alias that routes to the standard ext_axis homing
|
||||
path."""
|
||||
log = self.log.get('AuxAxis')
|
||||
|
||||
def _hook_aux_home(ctx):
|
||||
# Legacy: route to the standard external-axis homing.
|
||||
if self.ext_axis is not None and self.ext_axis.enabled:
|
||||
self.ext_axis.home()
|
||||
else:
|
||||
self.aux.home()
|
||||
|
||||
def _hook_droptool(ctx): self.aux.atc_droptool()
|
||||
def _hook_grabtool(ctx): self.aux.atc_grabtool()
|
||||
def _hook_release(ctx): self.aux.atc_release()
|
||||
def _hook_clamp(ctx): self.aux.atc_clamp()
|
||||
|
||||
# Legacy alias for older gcode that used aux_home.
|
||||
self.hooks.register_internal('aux_home', _hook_aux_home,
|
||||
block_unpause=True, auto_resume=True,
|
||||
timeout=180)
|
||||
|
||||
# ATC pneumatics. block_unpause + auto_resume so a program
|
||||
# using M100/M101/M102/M103 pauses at the right point and
|
||||
# resumes once the sequence is done.
|
||||
self.hooks.register_internal('droptool', _hook_droptool,
|
||||
block_unpause=True, auto_resume=True,
|
||||
timeout=60)
|
||||
self.hooks.register_internal('grabtool', _hook_grabtool,
|
||||
block_unpause=True, auto_resume=True,
|
||||
timeout=60)
|
||||
self.hooks.register_internal('release', _hook_release,
|
||||
block_unpause=True, auto_resume=True,
|
||||
timeout=10)
|
||||
self.hooks.register_internal('clamp', _hook_clamp,
|
||||
block_unpause=True, auto_resume=True,
|
||||
timeout=15)
|
||||
log.info('Aux hooks registered')
|
||||
|
||||
|
||||
def close(self):
|
||||
self.log.get('Ctrl').info('Closing %s' % self.id)
|
||||
self.ioloop.close()
|
||||
self.avr.close()
|
||||
self.mach.planner.close()
|
||||
try: self.ext_axis.close()
|
||||
except Exception: pass
|
||||
try: self.aux.close()
|
||||
except Exception: pass
|
||||
|
||||
677
src/py/bbctrl/ExternalAxis.py
Normal file
677
src/py/bbctrl/ExternalAxis.py
Normal file
@@ -0,0 +1,677 @@
|
||||
################################################################################
|
||||
#
|
||||
# ExternalAxis - bridges a logical motorless axis to step generation on
|
||||
# the auxcnc ESP, so the Buildbotics planner can drive a stepper that
|
||||
# isn't on the AVR.
|
||||
#
|
||||
# Architecture
|
||||
# ------------
|
||||
# The bbctrl planner (camotics gplan) handles parsing, units, modal
|
||||
# state, soft limits, accel ramping and S-curve timing for axes
|
||||
# X, Y, Z, A, B, C. The AVR has 4 motor channels (0-3) and only
|
||||
# generates step pulses for axes that have a motor mapped to them.
|
||||
# An axis with no mapped motor is fully accepted by the AVR - it
|
||||
# updates its internal `ex.position[axis]` and reports `<axis>p` to
|
||||
# the host, but no stepper turns.
|
||||
#
|
||||
# We exploit that: the W stepper is exposed to gplan as A, but no
|
||||
# AVR motor maps to A. The planner does all the gcode-level work
|
||||
# correctly (G90/G91, soft limits, accel, units, modal feed rate);
|
||||
# we intercept the resulting `Cmd.line` blocks in `Planner.__encode`,
|
||||
# strip A out, and forward the A delta to the auxcnc ESP as STEPS.
|
||||
#
|
||||
# To make gplan and State *believe* A is enabled we register a
|
||||
# synthetic motor (index 4) into State.vars, populated from
|
||||
# aux.json, with `4an=3` (axis A), `4me=1` (enabled), and the
|
||||
# usual velocity/accel/jerk/soft-limit vars. State.find_motor and
|
||||
# the snapshot projection are extended to walk index 4. Motor-4
|
||||
# vars never leave the host (they're not in the AVR's schema) so
|
||||
# the AVR is undisturbed.
|
||||
#
|
||||
# v1 coupling: serialize. If a line has any A delta we wait for
|
||||
# the ESP to finish before letting subsequent commands flow. This
|
||||
# matches the behaviour of the previous hook-based approach (no
|
||||
# XYZ+A blending) but with all the planner's correctness guarantees.
|
||||
#
|
||||
# v2 could match ESP move duration to the gplan trapezoid time and
|
||||
# allow concurrent motion; out of scope for v1.
|
||||
#
|
||||
################################################################################
|
||||
|
||||
import threading
|
||||
|
||||
try:
|
||||
from queue import Queue
|
||||
except ImportError:
|
||||
from Queue import Queue # py2 just in case
|
||||
|
||||
|
||||
# Synthetic motor index used to expose the external axis to State.
|
||||
# The AVR has motors 0..3; we use 4 as a host-only sentinel.
|
||||
EXTERNAL_MOTOR_INDEX = 4
|
||||
|
||||
# Axis letters in their canonical order; 'a' is index 3.
|
||||
_AXIS_LETTERS = 'xyzabc'
|
||||
|
||||
|
||||
class ExternalAxisError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ExternalAxis(object):
|
||||
"""Bridge between Planner line blocks and AuxAxis serial RPCs.
|
||||
|
||||
Owns no thread; runs RPC calls inline on whatever thread invokes
|
||||
execute_to_mm / home / abort. The Planner runs `__encode` on its
|
||||
own thread which is allowed to block on planner I/O, so blocking
|
||||
inside the interceptor is fine.
|
||||
|
||||
Position tracking: gplan emits absolute targets in mm; the ESP
|
||||
counts steps relative to home_zero. We mirror the last commanded
|
||||
mm position so subsequent line blocks compute the correct delta.
|
||||
`_pos_mm` is also published as `<axis>p` so DRO updates."""
|
||||
|
||||
def __init__(self, ctrl, aux, axis_letter='a'):
|
||||
self.ctrl = ctrl
|
||||
self.aux = aux
|
||||
self.log = ctrl.log.get('ExternalAxis')
|
||||
|
||||
self.axis_letter = (axis_letter or 'a').lower()[:1]
|
||||
if self.axis_letter not in _AXIS_LETTERS:
|
||||
raise ExternalAxisError(
|
||||
'Invalid external axis letter: %r' % axis_letter)
|
||||
# Index in 'xyzabc' (0..5)
|
||||
self.axis_index = _AXIS_LETTERS.index(self.axis_letter)
|
||||
|
||||
self._busy = threading.Event()
|
||||
# Last absolute mm we committed; None until first move /
|
||||
# homing event syncs us up.
|
||||
self._pos_mm = None
|
||||
|
||||
# Single-slot worker queue: __encode posts (target_mm,) tuples
|
||||
# here; the worker thread runs the ESP RPC. Capacity is
|
||||
# intentionally bounded - if it fills it means motion is
|
||||
# outpacing the ESP and we should backpressure the planner.
|
||||
self._work_q = Queue(maxsize=64)
|
||||
self._stop = threading.Event()
|
||||
self._worker = threading.Thread(
|
||||
target=self._worker_loop,
|
||||
name='ExternalAxis-worker', daemon=True)
|
||||
self._worker.start()
|
||||
|
||||
# Push synthetic motor vars into State so the planner sees
|
||||
# this axis as enabled with proper limits/velocity/accel.
|
||||
self._publish_synthetic_motor()
|
||||
# Also seed <axis>p so the DRO has something to render.
|
||||
self.ctrl.state.set(self.axis_letter + 'p', 0.0)
|
||||
|
||||
# -------------------------------------------------------------- enabled
|
||||
|
||||
@property
|
||||
def enabled(self):
|
||||
try:
|
||||
return bool(self.aux is not None
|
||||
and self.aux.enabled
|
||||
and self.aux.present)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
# -------------------------------------------------------- configuration
|
||||
|
||||
@property
|
||||
def steps_per_mm(self):
|
||||
try:
|
||||
return float(self.aux._cfg.get('steps_per_mm', 25.0))
|
||||
except Exception:
|
||||
return 25.0
|
||||
|
||||
@property
|
||||
def dir_sign(self):
|
||||
try:
|
||||
v = int(self.aux._cfg.get('dir_sign', 1))
|
||||
return -1 if v < 0 else 1
|
||||
except Exception:
|
||||
return 1
|
||||
|
||||
@property
|
||||
def home_position_mm(self):
|
||||
try:
|
||||
return float(self.aux._cfg.get('home_position_mm', 0.0))
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
# ------------------------------------------------------- soft limits
|
||||
|
||||
def _soft_limits(self):
|
||||
"""Return (min_mm, max_mm) in machine coords, or (None, None)
|
||||
if soft limits are disabled (max <= min)."""
|
||||
try:
|
||||
lo = float(self.aux._cfg.get('min_mm', 0.0))
|
||||
hi = float(self.aux._cfg.get('max_mm', 0.0))
|
||||
except Exception:
|
||||
return (None, None)
|
||||
if hi <= lo:
|
||||
return (None, None)
|
||||
return (lo, hi)
|
||||
|
||||
def _check_soft_limit(self, target_abs_mm):
|
||||
"""Raise ExternalAxisError if target_abs_mm is outside the
|
||||
configured soft limits. Skips the check when the axis isn't
|
||||
homed (matching the standard bbctrl convention that soft
|
||||
limits are gated by homing state) - that lets the user jog
|
||||
away from a stuck position before homing without false
|
||||
rejections.
|
||||
|
||||
Called by both planner-driven motion (enqueue_target_mm) and
|
||||
UI motion (execute_to_mm), so this is the single source of
|
||||
truth regardless of which path triggered the move."""
|
||||
# Honour the homing gate.
|
||||
try:
|
||||
homed = bool(self.aux._homed)
|
||||
except Exception:
|
||||
homed = False
|
||||
if not homed:
|
||||
return
|
||||
lo, hi = self._soft_limits()
|
||||
if lo is None:
|
||||
return
|
||||
# Use a tiny epsilon so floating-point round-trip targets
|
||||
# right at the boundary aren't rejected.
|
||||
eps = 1e-4
|
||||
target = float(target_abs_mm)
|
||||
if target < lo - eps or target > hi + eps:
|
||||
raise ExternalAxisError(
|
||||
'%s axis target %.4f mm is outside soft limits '
|
||||
'[%.3f, %.3f] mm' % (
|
||||
self.axis_letter.upper(), target, lo, hi))
|
||||
|
||||
# ----------------------------------------------- Z-A coupling
|
||||
#
|
||||
# The auxiliary tool hangs below the Z spindle. Beyond a small
|
||||
# Z descent the two collide unless A drops with Z. The
|
||||
# constraint, in machine coords, is
|
||||
#
|
||||
# A_machine - Z_machine <= K
|
||||
# K = (A_home_mm - z_home_mm) + couple_z_clearance_mm
|
||||
#
|
||||
# Enforced before any motion (planner blocks, MDI, jogs). The
|
||||
# AuxPreprocessor injects pre-position A moves into uploaded
|
||||
# files so well-formed gcode runs without having to think about
|
||||
# this. Disabled when couple_z_enabled is false.
|
||||
|
||||
@property
|
||||
def couple_z_enabled(self):
|
||||
try:
|
||||
return bool(self.aux._cfg.get('couple_z_enabled', False))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@property
|
||||
def couple_K(self):
|
||||
"""Limit constant K (machine-coord units): the maximum value
|
||||
of (A_machine - Z_machine) before the tool collides. Returns
|
||||
None if the rule isn't applicable (coupling disabled or
|
||||
config missing)."""
|
||||
try:
|
||||
cfg = self.aux._cfg
|
||||
clearance = float(cfg.get('couple_z_clearance_mm', 0.0))
|
||||
a_home = float(cfg.get('home_position_mm', 0.0))
|
||||
z_home = float(cfg.get('z_home_mm', 0.0))
|
||||
return (a_home - z_home) + clearance
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@property
|
||||
def couple_clearance_mm(self):
|
||||
"""Raw clearance from config: how far Z may travel below its
|
||||
home before A has to start dropping with it. Used by the
|
||||
AuxPreprocessor to inject pre-position A moves into uploaded
|
||||
gcode."""
|
||||
try:
|
||||
return float(self.aux._cfg.get('couple_z_clearance_mm', 0.0))
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
def _z_machine_now(self):
|
||||
"""Read Z's current machine position from State, or None if
|
||||
Z isn't homed/reported yet. The AVR reports absolute machine
|
||||
positions in <axis>p; the work-coord display is computed by
|
||||
the UI as zp - offset_z, but here we want machine directly."""
|
||||
try:
|
||||
st = self.ctrl.state
|
||||
zp = st.get('zp', None)
|
||||
if zp is None:
|
||||
return None
|
||||
return float(zp)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _a_machine_now(self):
|
||||
"""A's current machine position. ExternalAxis tracks this
|
||||
directly in self._pos_mm (mm in machine coords - we don't
|
||||
apply G92 to A internally; offset_a is informational)."""
|
||||
try:
|
||||
if self._pos_mm is not None:
|
||||
return float(self._pos_mm)
|
||||
# Fall back to whatever the ESP last reported.
|
||||
return float(self.aux.position_mm)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def coupling_for_preprocessor(self):
|
||||
"""Return the dict the AuxPreprocessor wants for in-file
|
||||
injection, or None when coupling is off. We assume the
|
||||
operator authors gcode in a frame where the at-home position
|
||||
is A_wc=0, Z_wc=0 - which matches our home-zeroed setup.
|
||||
Files that use a different convention will fall through to
|
||||
the runtime check."""
|
||||
if not self.couple_z_enabled:
|
||||
return None
|
||||
return {
|
||||
'enabled': True,
|
||||
'clearance_mm': self.couple_clearance_mm,
|
||||
'a_initial_wc': 0.0,
|
||||
'z_initial_wc': 0.0,
|
||||
}
|
||||
|
||||
def check_coupling(self, target_a_machine=None, target_z_machine=None):
|
||||
"""Validate that a proposed motion respects the Z-A coupling.
|
||||
|
||||
Each argument is a target *machine* mm position; pass None to
|
||||
keep the current value of that axis.
|
||||
|
||||
Improvement-aware: a move is rejected only when it *worsens*
|
||||
an already-violating state (or moves a healthy state into
|
||||
violation). Pure XY jogs that touch neither Z nor A are not
|
||||
passed through here; jogs that hold Z or A at their current
|
||||
value (gplan emits the unchanged value in `target`) pass
|
||||
because (a-z) doesn't change. Z-up moves while in violation
|
||||
also pass because they reduce (a-z) toward the bound.
|
||||
|
||||
Raises ExternalAxisError on violation. Skipped when coupling
|
||||
is disabled, the aux axis isn't homed, or current positions
|
||||
aren't yet known.
|
||||
"""
|
||||
if not self.couple_z_enabled:
|
||||
return
|
||||
try:
|
||||
homed = bool(self.aux._homed)
|
||||
except Exception:
|
||||
homed = False
|
||||
if not homed:
|
||||
return
|
||||
K = self.couple_K
|
||||
if K is None:
|
||||
return
|
||||
a_now = self._a_machine_now()
|
||||
z_now = self._z_machine_now()
|
||||
if a_now is None or z_now is None:
|
||||
return
|
||||
a_after = (float(target_a_machine)
|
||||
if target_a_machine is not None else a_now)
|
||||
z_after = (float(target_z_machine)
|
||||
if target_z_machine is not None else z_now)
|
||||
eps = 1e-4
|
||||
gap_after = a_after - z_after
|
||||
gap_before = a_now - z_now
|
||||
# Only refuse when (a) the resulting state would violate the
|
||||
# constraint AND (b) the move makes things at least as bad
|
||||
# as the current state. This lets the operator escape an
|
||||
# already-violating state by moving in the right direction
|
||||
# (Z up, A down).
|
||||
if gap_after > K + eps and gap_after > gap_before - eps:
|
||||
raise ExternalAxisError(
|
||||
'Z-A coupling violation: A=%.3f mm and Z=%.3f mm '
|
||||
'(machine) would put A above Z by %.3f mm; max '
|
||||
'allowed is %.3f mm. Drop A or raise Z first.' % (
|
||||
a_after, z_after, gap_after, K))
|
||||
|
||||
# ----------------------------------------------------------- conversion
|
||||
|
||||
def mm_to_steps_delta(self, delta_mm):
|
||||
return int(round(float(delta_mm) * self.steps_per_mm * self.dir_sign))
|
||||
|
||||
def steps_to_mm(self, steps):
|
||||
return (float(steps) / self.steps_per_mm) * self.dir_sign
|
||||
|
||||
# ---------------------------------------------------- synthetic motor
|
||||
|
||||
def _publish_synthetic_motor(self):
|
||||
"""Write motor-4 vars into State so find_motor('a') and
|
||||
get_axis_vector('vm') see A as a real axis. The AVR never
|
||||
sees these (motor index 4 is not in its var schema)."""
|
||||
cfg = self.aux._cfg if self.aux is not None else {}
|
||||
st = self.ctrl.state
|
||||
i = str(EXTERNAL_MOTOR_INDEX)
|
||||
|
||||
# Axis assignment: 'an' is the 0-based axis index in xyzabc.
|
||||
st.set(i + 'an', self.axis_index)
|
||||
# Motor enabled.
|
||||
st.set(i + 'me', 1 if (self.aux and self.aux.enabled) else 0)
|
||||
# Homed flag - cleared until aux reports homed.
|
||||
try:
|
||||
homed = bool(self.aux._homed)
|
||||
except Exception:
|
||||
homed = False
|
||||
st.set(i + 'h', 1 if homed else 0)
|
||||
|
||||
# Velocity / accel / jerk: the planner reads these via
|
||||
# state.get_axis_vector('<code>', SCALE) which multiplies the
|
||||
# stored raw value by SCALE. The bbctrl convention (matching
|
||||
# what motors 0-3 store) is:
|
||||
# vm: stored in m/min, planner expects mm/min (scale 1000)
|
||||
# am: stored in km/min^2, planner expects mm/min^2 (scale 1e6)
|
||||
# jm: stored in km/min^3, planner expects mm/min^3 (scale 1e6)
|
||||
# Onefinity defaults for XY are vm=10, am=750, jm=1000. We
|
||||
# follow the same convention; aux.json exposes the values in
|
||||
# those user-facing units so they're directly comparable.
|
||||
st.set(i + 'vm', float(cfg.get('max_velocity_m_per_min', 6.0)))
|
||||
st.set(i + 'am', float(cfg.get('max_accel_km_per_min2', 100.0)))
|
||||
st.set(i + 'jm', float(cfg.get('max_jerk_km_per_min3', 500.0)))
|
||||
|
||||
# Soft limits in machine units (mm). State.get_soft_limit_vector
|
||||
# returns these directly, no scaling.
|
||||
st.set(i + 'tn', float(cfg.get('min_mm', 0.0)))
|
||||
st.set(i + 'tm', float(cfg.get('max_mm', 0.0)))
|
||||
|
||||
# home_position / home_travel are exposed as callbacks for
|
||||
# motors 0..3 (see State.__init__). Register the same lazy
|
||||
# callbacks for motor 4 so gplan's resolver lookup
|
||||
# (_<axis>_home_position / _<axis>_home_travel) returns the
|
||||
# right values for the external axis.
|
||||
st.set_callback(
|
||||
i + 'home_position', lambda name: self.home_position_mm)
|
||||
st.set_callback(
|
||||
i + 'home_travel',
|
||||
lambda name: float(self.aux._cfg.get('max_mm', 0.0))
|
||||
- self.home_position_mm)
|
||||
|
||||
# Misc fields that other code paths might query. Defaults
|
||||
# mirror what the AVR pushes for motors 0-3.
|
||||
st.set(i + 'sa', 1.8)
|
||||
st.set(i + 'mi', 16)
|
||||
st.set(i + 'tr', 4.0)
|
||||
st.set(i + 'sp', 200)
|
||||
st.set(i + 'ic', 0.0)
|
||||
st.set(i + 'dc', 0.0)
|
||||
st.set(i + 'rv', False)
|
||||
st.set(i + 'tc', 1)
|
||||
st.set(i + 'lb', 5)
|
||||
st.set(i + 'ho', 0)
|
||||
st.set(i + 'os', 0)
|
||||
st.set(i + 'oa', False)
|
||||
st.set(i + 'lm', 8)
|
||||
st.set(i + 'lv', 0.1)
|
||||
st.set(i + 'sv', 1.688)
|
||||
st.set(i + 'tv', 1.997)
|
||||
st.set(i + 'lw', 2) # min-switch
|
||||
st.set(i + 'xw', 2) # max-switch
|
||||
st.set(i + 'ls', 0)
|
||||
st.set(i + 'xs', 0)
|
||||
st.set(i + 'df', 0)
|
||||
|
||||
def refresh_homed(self):
|
||||
"""Called when AuxAxis updates its homed flag. Mirrors into
|
||||
State so is_axis_homed('a') returns the right answer.
|
||||
|
||||
Updates several places at once because different layers read
|
||||
the homed state via different keys:
|
||||
- synthetic motor flag: 4h (used by snapshot -> a_h)
|
||||
- axis-level flag: a_homed (used by State.is_axis_homed
|
||||
and gplan _a_homed resolver)"""
|
||||
try:
|
||||
homed = bool(self.aux._homed)
|
||||
except Exception:
|
||||
homed = False
|
||||
st = self.ctrl.state
|
||||
st.set(str(EXTERNAL_MOTOR_INDEX) + 'h', 1 if homed else 0)
|
||||
st.set(self.axis_letter + '_homed', bool(homed))
|
||||
|
||||
# ----------------------------------------------------------- line split
|
||||
|
||||
def split_target(self, target):
|
||||
"""Pop the external axis out of a target dict and return
|
||||
(target_without_ext, ext_mm_or_None). Both case variants
|
||||
accepted defensively."""
|
||||
if not target:
|
||||
return target, None
|
||||
ax = self.axis_letter
|
||||
new_target = dict(target)
|
||||
ext_mm = new_target.pop(ax, None)
|
||||
if ext_mm is None:
|
||||
ext_mm = new_target.pop(ax.upper(), None)
|
||||
return new_target, ext_mm
|
||||
|
||||
# -------------------------------------------------------- execution API
|
||||
|
||||
def is_busy(self):
|
||||
return self._busy.is_set()
|
||||
|
||||
def execute_to_mm(self, ext_mm):
|
||||
"""Synchronously run an external move. Blocks until the ESP
|
||||
reports done. Used by the legacy /api/aux/move and /api/aux/jog
|
||||
endpoints which may want to wait. Most planner-driven motion
|
||||
goes through enqueue_target_mm instead, which is non-blocking.
|
||||
|
||||
Soft limits are enforced here (not just in gplan) because the
|
||||
UI jog/move endpoints don't go through the planner.
|
||||
|
||||
Updates state.<axis>p immediately on completion. For the
|
||||
planner-driven path that goes through enqueue_target_mm, the
|
||||
AVR's own ap reports drive state.<axis>p instead."""
|
||||
if not self.enabled:
|
||||
raise ExternalAxisError(
|
||||
'External axis %r not available (aux disabled or '
|
||||
'not connected)' % self.axis_letter)
|
||||
|
||||
self._check_soft_limit(ext_mm)
|
||||
# Coupling: A is in machine coords directly (we don't apply
|
||||
# a G92 offset to A), so target_a_machine == ext_mm.
|
||||
self.check_coupling(target_a_machine=ext_mm)
|
||||
steps, abs_mm = self._compute_move(ext_mm)
|
||||
if steps == 0:
|
||||
self._pos_mm = abs_mm
|
||||
self.ctrl.state.set(self.axis_letter + 'p', self._pos_mm)
|
||||
return
|
||||
|
||||
self._busy.set()
|
||||
try:
|
||||
self.aux._do_steps(steps, ignore_limits=True)
|
||||
self._pos_mm = abs_mm
|
||||
self.ctrl.state.set(self.axis_letter + 'p', self._pos_mm)
|
||||
finally:
|
||||
self._busy.clear()
|
||||
|
||||
def enqueue_target_mm(self, ext_mm):
|
||||
"""Legacy non-blocking variant: post a fixed-rate STEPS move
|
||||
to the worker queue. No longer used by Planner.__encode (which
|
||||
uses enqueue_line for full S-curve mirroring), but kept for
|
||||
UI jog endpoints that don't have planner timing data.
|
||||
|
||||
Soft limits are enforced here (defense in depth on top of
|
||||
gplan)."""
|
||||
if not self.enabled:
|
||||
raise ExternalAxisError(
|
||||
'External axis %r not available' % self.axis_letter)
|
||||
self._check_soft_limit(ext_mm)
|
||||
self.check_coupling(target_a_machine=ext_mm)
|
||||
steps, abs_mm = self._compute_move(ext_mm)
|
||||
# Internal mirror only - drives subsequent delta computation.
|
||||
# state.<axis>p is left to the AVR's status reports.
|
||||
self._pos_mm = abs_mm
|
||||
if steps == 0:
|
||||
return
|
||||
self._work_q.put(('move', steps))
|
||||
|
||||
def enqueue_line(self, ext_mm, max_accel_mm_min2, max_jerk_mm_min3,
|
||||
entry_vel_mm_min, exit_vel_mm_min, times_ms):
|
||||
"""Post a full S-curve LINE block to the ESP worker. Mirrors
|
||||
gplan's planned trajectory exactly (same 7-segment math, same
|
||||
unit system) so the ESP's move duration matches what the AVR
|
||||
would have produced for an A motor.
|
||||
|
||||
Called by Planner.__encode for every line block that touches
|
||||
the external axis.
|
||||
|
||||
Parameters:
|
||||
ext_mm: absolute target in mm (gplan target['a'])
|
||||
max_accel_mm_min2:from block['max-accel']
|
||||
max_jerk_mm_min3: from block['max-jerk']
|
||||
entry_vel_mm_min: from block['entry-vel'] (typically 0 for
|
||||
the first block, exit_vel of the prior
|
||||
block otherwise)
|
||||
exit_vel_mm_min: from block['exit-vel']
|
||||
times_ms: 7-tuple of section durations in ms
|
||||
(block['times'] - the same units gplan uses)
|
||||
"""
|
||||
if not self.enabled:
|
||||
raise ExternalAxisError(
|
||||
'External axis %r not available' % self.axis_letter)
|
||||
self._check_soft_limit(ext_mm)
|
||||
self.check_coupling(target_a_machine=ext_mm)
|
||||
steps, abs_mm = self._compute_move(ext_mm)
|
||||
delta_mm = abs(abs_mm - (self._pos_mm if self._pos_mm is not None
|
||||
else 0.0))
|
||||
# Update internal mirror; AVR drives state.<axis>p.
|
||||
self._pos_mm = abs_mm
|
||||
if steps == 0 or delta_mm <= 0:
|
||||
return
|
||||
# ms -> minutes (the unit gplan/AVR/ESP use internally for
|
||||
# SCurve math).
|
||||
times_min = tuple((t / 60000.0) if t else 0.0 for t in times_ms)
|
||||
self._work_q.put(('line', steps, delta_mm,
|
||||
float(max_accel_mm_min2),
|
||||
float(max_jerk_mm_min3),
|
||||
float(entry_vel_mm_min),
|
||||
float(exit_vel_mm_min),
|
||||
times_min))
|
||||
|
||||
def _compute_move(self, ext_mm):
|
||||
"""Return (signed_steps, absolute_mm) for a target in mm.
|
||||
Caches first-time position from the ESP."""
|
||||
if self._pos_mm is None:
|
||||
self._pos_mm = self._read_esp_position_mm()
|
||||
delta_mm = float(ext_mm) - self._pos_mm
|
||||
return self.mm_to_steps_delta(delta_mm), float(ext_mm)
|
||||
|
||||
def _worker_loop(self):
|
||||
"""Background thread that drains the work queue. RPCs to the
|
||||
ESP are slow (multi-second moves) and must not run on the
|
||||
ioloop thread. We serialize ESP commands here so multiple
|
||||
line-block enqueues for the external axis are processed in
|
||||
the order the planner emitted them."""
|
||||
while not self._stop.is_set():
|
||||
try:
|
||||
op = self._work_q.get(timeout=0.5)
|
||||
except Exception:
|
||||
continue
|
||||
if op is None:
|
||||
continue
|
||||
kind = op[0]
|
||||
try:
|
||||
self._busy.set()
|
||||
if kind == 'move':
|
||||
steps = op[1]
|
||||
self.aux._do_steps(steps, ignore_limits=True)
|
||||
elif kind == 'line':
|
||||
(_, steps, length_mm,
|
||||
max_accel, max_jerk,
|
||||
entry_vel, exit_vel,
|
||||
times_min) = op
|
||||
self.aux._do_line(
|
||||
steps, length_mm, max_accel, max_jerk,
|
||||
entry_vel, exit_vel, times_min,
|
||||
ignore_limits=True)
|
||||
elif kind == 'home':
|
||||
self.aux.home()
|
||||
# _pos_mm and DRO updated by the caller's enqueue.
|
||||
except Exception as e:
|
||||
self.log.error('External axis worker failed on %s: %s'
|
||||
% (kind, e))
|
||||
finally:
|
||||
self._busy.clear()
|
||||
self._work_q.task_done()
|
||||
|
||||
def wait_idle(self, timeout=None):
|
||||
"""Block until the worker queue is empty. Used by callers
|
||||
that need post-motion state to be settled (e.g. homing,
|
||||
stop/abort handlers)."""
|
||||
try:
|
||||
# Queue.join blocks until task_done has been called for
|
||||
# every item put. It does not honour a timeout, so we
|
||||
# poll instead when one is requested.
|
||||
if timeout is None:
|
||||
self._work_q.join()
|
||||
return True
|
||||
import time
|
||||
deadline = time.time() + float(timeout)
|
||||
while time.time() < deadline:
|
||||
if self._work_q.unfinished_tasks == 0:
|
||||
return True
|
||||
time.sleep(0.05)
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def close(self):
|
||||
self._stop.set()
|
||||
try:
|
||||
self._work_q.put(None, block=False)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def home(self):
|
||||
"""Run the ESP homing cycle and sync our recorded position
|
||||
to the configured home_position_mm. Blocks; called from
|
||||
Mach.home (which already runs synchronously per axis)."""
|
||||
if not self.enabled:
|
||||
raise ExternalAxisError(
|
||||
'External axis %r not available' % self.axis_letter)
|
||||
# Drain pending moves so we don't home into stale work.
|
||||
self.wait_idle(timeout=30.0)
|
||||
self._busy.set()
|
||||
try:
|
||||
self.aux.home()
|
||||
self._pos_mm = self.home_position_mm
|
||||
self.ctrl.state.set(self.axis_letter + 'p', self._pos_mm)
|
||||
self.refresh_homed()
|
||||
finally:
|
||||
self._busy.clear()
|
||||
|
||||
def abort(self):
|
||||
"""Cancel the ESP move and drop pending queued work.
|
||||
Caller (estop / stop handler) is responsible for the
|
||||
planner-side cleanup."""
|
||||
try:
|
||||
if self.aux is not None:
|
||||
self.aux.abort()
|
||||
finally:
|
||||
self._busy.clear()
|
||||
# Drain any pending ops so resume after an abort doesn't
|
||||
# replay stale targets.
|
||||
try:
|
||||
while True:
|
||||
self._work_q.get_nowait()
|
||||
self._work_q.task_done()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ------------------------------------------------------- ESP introspection
|
||||
|
||||
def _read_esp_position_mm(self):
|
||||
"""Convert AuxAxis._pos_steps mirror to mm. Falls back to 0."""
|
||||
try:
|
||||
steps = int(self.aux._pos_steps)
|
||||
except Exception:
|
||||
steps = 0
|
||||
return self.steps_to_mm(steps)
|
||||
|
||||
# ---------------------------------------------------------- DRO update
|
||||
|
||||
def sync_dro(self):
|
||||
"""Push the current position to State as <axis>p so the DRO
|
||||
reflects what we believe gplan/ESP agreed on. Called after
|
||||
moves; also safe to call from external code."""
|
||||
if self._pos_mm is None:
|
||||
return
|
||||
self.ctrl.state.set(self.axis_letter + 'p', self._pos_mm)
|
||||
@@ -99,6 +99,27 @@ class FileHandler(bbctrl.APIHandler):
|
||||
|
||||
del (self.uploadFile)
|
||||
|
||||
# If the uploaded G-code uses ATC M-codes (M100..M103),
|
||||
# rewrite them into (MSG,HOOK:droptool:) etc so the hook
|
||||
# layer can dispatch them at runtime. The planner accepts
|
||||
# M100-M103 in user-defined range but doesn't *do* anything
|
||||
# with them. Motion in A goes through gplan unchanged - the
|
||||
# auxcnc stepper is exposed as a virtual A axis (see
|
||||
# ExternalAxis).
|
||||
try:
|
||||
from bbctrl.AuxPreprocessor import preprocess_file
|
||||
log = self.get_log('AuxPreprocessor')
|
||||
ext = getattr(self.get_ctrl(), 'ext_axis', None)
|
||||
coupling = (ext.coupling_for_preprocessor()
|
||||
if ext is not None else None)
|
||||
if preprocess_file(filename.decode('utf8'),
|
||||
log=log, coupling=coupling):
|
||||
log.info('Rewrote upload (ATC / Z-A coupling) in %s'
|
||||
% self.uploadFilename)
|
||||
except Exception:
|
||||
self.get_log('AuxPreprocessor').exception(
|
||||
'Aux preprocess failed; uploading unchanged')
|
||||
|
||||
self.get_ctrl().preplanner.invalidate(self.uploadFilename)
|
||||
self.get_ctrl().state.add_file(self.uploadFilename)
|
||||
|
||||
|
||||
454
src/py/bbctrl/Hooks.py
Normal file
454
src/py/bbctrl/Hooks.py
Normal file
@@ -0,0 +1,454 @@
|
||||
################################################################################
|
||||
#
|
||||
# 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. Also drain the external-axis
|
||||
# worker queue so resume after clear doesn't replay
|
||||
# stale moves.
|
||||
try:
|
||||
ext = getattr(self.ctrl, 'ext_axis', None)
|
||||
if ext is not None:
|
||||
ext.abort()
|
||||
except Exception:
|
||||
pass
|
||||
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 dispatch_hook_message(self, text):
|
||||
"""Direct entry point for HOOK:<event>:<data> messages emitted
|
||||
by the planner via (MSG,HOOK:...) comments. Bypasses the
|
||||
state.messages list (which the UI also reads), so callers can
|
||||
suppress popup display without losing the hook dispatch.
|
||||
|
||||
Returns True if the text matched a HOOK: line and was
|
||||
dispatched, False otherwise."""
|
||||
if not isinstance(text, str) or not text.startswith('HOOK:'):
|
||||
return False
|
||||
parts = text[5:].split(':', 1)
|
||||
event = parts[0]
|
||||
data = parts[1] if len(parts) > 1 else ''
|
||||
self._fire('custom', {'event': event, 'data': data},
|
||||
custom_name=event)
|
||||
return True
|
||||
|
||||
def register_internal(self, name, fn, block_unpause=True,
|
||||
auto_resume=True, timeout=120):
|
||||
"""Register an in-process handler for HOOK:<name> events.
|
||||
|
||||
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'))
|
||||
@@ -182,4 +182,11 @@ class Log(object):
|
||||
if n == 16: os.unlink(fullpath)
|
||||
else: self._rotate(path, nextN)
|
||||
|
||||
os.rename(fullpath, '%s.%d' % (path, nextN))
|
||||
# The recursive call may have unlinked or rotated this
|
||||
# path; tolerate a missing source rather than crashing
|
||||
# bbctrl on startup. This also tolerates concurrent
|
||||
# logrotate runs from /etc/cron.reboot.
|
||||
try:
|
||||
os.rename(fullpath, '%s.%d' % (path, nextN))
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
@@ -95,6 +95,10 @@ class Mach(Comm):
|
||||
self.planner = bbctrl.Planner(ctrl)
|
||||
self.unpausing = False
|
||||
self.stopping = False
|
||||
# Guard against overlapping deferred-external-homing threads
|
||||
# if the user clicks Home (All) again while the previous run
|
||||
# is still waiting for the AVR cycle to finish.
|
||||
self._ext_home_thread = None
|
||||
|
||||
ctrl.state.set('cycle', 'idle')
|
||||
|
||||
@@ -256,6 +260,12 @@ class Mach(Comm):
|
||||
if cmd[0] == '$': self._query_var(cmd)
|
||||
elif cmd[0] == '\\': super().queue_command(cmd[1:])
|
||||
else:
|
||||
# Rewrite ATC M-codes in MDI input the same way the
|
||||
# FileHandler rewrites uploaded files. Motion (X/Y/Z/A)
|
||||
# is left unchanged: the planner handles it natively
|
||||
# now that the auxcnc stepper is exposed as a virtual
|
||||
# A axis (see ExternalAxis).
|
||||
cmd = self._rewrite_aux_mdi(cmd)
|
||||
self._begin_cycle('mdi')
|
||||
self.planner.mdi(cmd, with_limits)
|
||||
super().resume()
|
||||
@@ -263,11 +273,51 @@ class Mach(Comm):
|
||||
self.mlog.info("Exception during MDI: %s" % err)
|
||||
pass
|
||||
|
||||
def _rewrite_aux_mdi(self, cmd):
|
||||
"""Apply the ATC M-code preprocessor to a single MDI line.
|
||||
Returns possibly-multi-line G-code with HOOK: comments inserted."""
|
||||
try:
|
||||
from bbctrl.AuxPreprocessor import AuxPreprocessor, _ATC_M_RE
|
||||
if not _ATC_M_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('Aux MDI rewrite failed: %s' % e)
|
||||
return cmd
|
||||
|
||||
def set(self, code, value):
|
||||
super().queue_command('${}={}'.format(code, value))
|
||||
|
||||
|
||||
def jog(self, axes):
|
||||
# Strip the external axis from the jog request before sending
|
||||
# to the AVR. v1 doesn't support continuous-rate jogging on
|
||||
# the ESP-driven axis - users jog A via /api/aux/jog (relative
|
||||
# mm steps) instead. Sending A to the AVR is harmless (no
|
||||
# motor maps to it) but cleaner to strip.
|
||||
ext = getattr(self.ctrl, 'ext_axis', None)
|
||||
if ext is not None and isinstance(axes, dict):
|
||||
axes = {k: v for k, v in axes.items()
|
||||
if k.lower() != ext.axis_letter}
|
||||
if not axes:
|
||||
return
|
||||
self._begin_cycle('jogging')
|
||||
self.planner.position_change()
|
||||
super().queue_command(Cmd.jog(axes))
|
||||
@@ -281,10 +331,52 @@ class Mach(Comm):
|
||||
axes = 'zxybc' if is_rotary_active else 'zxyabc' # TODO This should be configurable
|
||||
else: axes = '%c' % axis
|
||||
|
||||
# Collect external axes here and process them *after* every
|
||||
# AVR axis above has finished its homing cycle. Without this,
|
||||
# the AVR is still running Z/X/Y homing G-code in the
|
||||
# planner queue while ext.home() synchronously drives the ESP
|
||||
# to home A in parallel - which is unsafe (the gantry and W
|
||||
# axis can move at the same time) and visually confusing.
|
||||
# We defer external homing to a background thread that
|
||||
# polls cycle until the AVR cycle completes.
|
||||
external_pending = []
|
||||
|
||||
for axis in axes:
|
||||
enabled = state.is_axis_enabled(axis)
|
||||
mode = state.axis_homing_mode(axis)
|
||||
|
||||
# External axes (e.g. the auxcnc-driven A axis) home via
|
||||
# their own ESP-side homing routine; the standard
|
||||
# G28.2 / G38.6 / latch sequence doesn't apply.
|
||||
#
|
||||
# After homing we want a deterministic outcome regardless
|
||||
# of where the user was before:
|
||||
# physical position = home_position_mm (e.g. 134 mm)
|
||||
# work-coord origin = home position (user A = 0)
|
||||
# work offset = home_position_mm (so abs - off = 0)
|
||||
#
|
||||
# ext.home() blocks on the ESP and updates state.ap to
|
||||
# home_position_mm. We then need to tell the AVR (so its
|
||||
# ex.position[A] matches physical reality) and gplan
|
||||
# (so trajectory planning sees abs at home).
|
||||
#
|
||||
# We deliberately avoid G28.3 here: gplan's G28.3 keeps the
|
||||
# current user-coord position fixed and adjusts the offset
|
||||
# to match the new abs, which means re-homing after a move
|
||||
# accumulates offset (134 -> 268 -> ...). Using G92 a0
|
||||
# *after* syncing abs gives the desired "user A = 0 here"
|
||||
# outcome with offset = home_position every time.
|
||||
ext = getattr(self.ctrl, 'ext_axis', None)
|
||||
if ext is not None and ext.enabled \
|
||||
and ext.axis_letter == axis.lower():
|
||||
if 1 < len(axes) and not enabled:
|
||||
continue
|
||||
# Defer until AVR axes are done. We capture the axis
|
||||
# letter and ext reference; the actual homing runs
|
||||
# in _run_external_homing below.
|
||||
external_pending.append((axis, ext))
|
||||
continue
|
||||
|
||||
# If this is not a request to home a specific axis and the
|
||||
# axis is disabled or in manual homing mode, don't show any
|
||||
# warnings
|
||||
@@ -315,8 +407,138 @@ class Mach(Comm):
|
||||
self.planner.mdi(gcode, False)
|
||||
super().resume()
|
||||
|
||||
# Kick off the deferred external-axis homing on a background
|
||||
# thread so we don't block the HTTP handler (which is on the
|
||||
# IOLoop) waiting for the AVR cycle to finish.
|
||||
if external_pending:
|
||||
prev = self._ext_home_thread
|
||||
if prev is not None and prev.is_alive():
|
||||
self.mlog.info(
|
||||
'External homing already in progress; ignoring '
|
||||
'duplicate request')
|
||||
else:
|
||||
import threading
|
||||
t = threading.Thread(
|
||||
target=self._run_external_homing,
|
||||
args=(list(external_pending),),
|
||||
name='ext-home-deferred',
|
||||
daemon=True)
|
||||
self._ext_home_thread = t
|
||||
t.start()
|
||||
|
||||
def unhome(self, axis): self.mdi('G28.2 %c0' % axis)
|
||||
def _run_external_homing(self, pending):
|
||||
"""Background worker: wait for the AVR cycle to drop to idle
|
||||
(meaning all queued AVR-side homing is done), then run each
|
||||
deferred external-axis home in order.
|
||||
|
||||
We split the work between two threads:
|
||||
- this background thread blocks on the ESP serial RPC
|
||||
(ext.home(), which can take 5-10 seconds while the
|
||||
carriage seeks the limit and backs off twice);
|
||||
- small bookkeeping operations that touch gplan, the AVR
|
||||
command queue, or shared State are scheduled back onto
|
||||
the IOLoop via ctrl.ioloop.add_callback() so we don't
|
||||
race with the rest of the controller.
|
||||
"""
|
||||
import time
|
||||
# Wait up to 5 minutes for the AVR cycle to leave 'homing'.
|
||||
# Long enough for any reasonable Onefinity full-travel home
|
||||
# (Y axis at slow rate covers ~800 mm).
|
||||
deadline = time.time() + 300.0
|
||||
while time.time() < deadline:
|
||||
cycle = self._get_cycle()
|
||||
# 'homing' is the AVR's homing cycle; we wait for it to
|
||||
# return to idle. If the user estopped or the cycle was
|
||||
# aborted, cycle goes to idle too - we still proceed and
|
||||
# the external home will fail-soft if conditions are wrong.
|
||||
if cycle == 'idle':
|
||||
break
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
self.mlog.error(
|
||||
'External axis homing aborted: AVR cycle did not '
|
||||
'return to idle within timeout')
|
||||
return
|
||||
|
||||
for axis, ext in pending:
|
||||
self.mlog.info('Homing external %s axis via auxcnc' %
|
||||
axis.upper())
|
||||
# Begin the cycle on the IOLoop so cycle-state writes go
|
||||
# through the same thread that all other state writes do.
|
||||
self.ctrl.ioloop.add_callback(self._begin_cycle, 'homing')
|
||||
try:
|
||||
# ext.home() runs on this background thread - it
|
||||
# blocks on serial I/O and is fully thread-safe (the
|
||||
# AuxAxis driver has its own RPC lock).
|
||||
ext.home()
|
||||
home_mm = ext.home_position_mm
|
||||
# All of the post-home bookkeeping touches gplan and
|
||||
# the AVR command queue, both of which run on the
|
||||
# IOLoop. Schedule it there in a single callback so
|
||||
# the steps run in order without intervening events.
|
||||
self.ctrl.ioloop.add_callback(
|
||||
self._finish_external_home, axis, home_mm)
|
||||
except Exception as e:
|
||||
self.mlog.error(
|
||||
'External axis homing failed: %s' % e)
|
||||
# Cycle reset must also happen on the IOLoop. Without
|
||||
# this the UI stays locked at 'homing' since the AVR
|
||||
# never moved (no state change to drive _update's
|
||||
# cycle-end path).
|
||||
self.ctrl.ioloop.add_callback(
|
||||
self._abort_external_home_cycle)
|
||||
|
||||
def _finish_external_home(self, axis, home_mm):
|
||||
"""IOLoop-side completion of an external axis home.
|
||||
Synchronizes AVR position, refreshes the planner, and emits
|
||||
a G92 to set the user-coord origin at the home position.
|
||||
"""
|
||||
try:
|
||||
# 1) Update AVR: no motor steps, just position sync.
|
||||
super().queue_command(Cmd.set_axis(axis, home_mm))
|
||||
# 2) Force planner to resync abs from State on the next
|
||||
# planner call (which is the MDI below).
|
||||
self.planner.position_change()
|
||||
# 3) G92 <axis>0: with abs already at home_mm, sets
|
||||
# user-coord A = 0 and offset = home_mm. Use
|
||||
# planner.mdi (not Mach.mdi) so we don't flip cycle
|
||||
# to 'mdi' inside the 'homing' cycle.
|
||||
self.planner.mdi('G92 %c0' % axis, False)
|
||||
super().resume()
|
||||
except Exception:
|
||||
self.mlog.exception(
|
||||
'Post-home bookkeeping failed for external axis')
|
||||
self._abort_external_home_cycle()
|
||||
|
||||
def _abort_external_home_cycle(self):
|
||||
"""Reset cycle to idle from the IOLoop after a failed
|
||||
external axis home. The AVR never moved so _update's normal
|
||||
cycle-end path won't fire; do it explicitly here.
|
||||
"""
|
||||
if self._get_cycle() == 'homing':
|
||||
try:
|
||||
self._set_cycle('idle')
|
||||
except Exception:
|
||||
self.mlog.exception(
|
||||
'Failed to reset cycle to idle after external '
|
||||
'homing error')
|
||||
|
||||
|
||||
def unhome(self, axis):
|
||||
# External axes don't have AVR-side homed state to clear; the
|
||||
# ESP holds its own homed flag. We don't have an explicit
|
||||
# "unhome" verb on the ESP, but a stale homed flag is harmless
|
||||
# because the next absolute move will fail-soft via
|
||||
# ExternalAxis._pos_mm sync. Still mirror the cleared flag
|
||||
# into State for the UI.
|
||||
ext = getattr(self.ctrl, 'ext_axis', None)
|
||||
if ext is not None and ext.enabled \
|
||||
and chr(axis).lower() == ext.axis_letter:
|
||||
from bbctrl.ExternalAxis import EXTERNAL_MOTOR_INDEX
|
||||
self.ctrl.state.set('%dh' % EXTERNAL_MOTOR_INDEX, 0)
|
||||
self.ctrl.state.set(ext.axis_letter + '_homed', False)
|
||||
return
|
||||
self.mdi('G28.2 %c0' % axis)
|
||||
def estop(self): super().estop()
|
||||
|
||||
|
||||
@@ -343,12 +565,22 @@ class Mach(Comm):
|
||||
def stop(self):
|
||||
if self._get_state() != 'jogging': self.stopping = True
|
||||
super().i2c_command(Cmd.STOP)
|
||||
# Drain the external-axis worker queue so post-stop resumption
|
||||
# doesn't replay queued moves that the user wanted cancelled.
|
||||
ext = getattr(self.ctrl, 'ext_axis', None)
|
||||
if ext is not None:
|
||||
try: ext.abort()
|
||||
except Exception: pass
|
||||
|
||||
def pause(self): super().pause()
|
||||
|
||||
|
||||
def unpause(self):
|
||||
if self._is_paused():
|
||||
# Gate unpause on hook completion
|
||||
if hasattr(self.ctrl, 'hooks') and \
|
||||
not self.ctrl.hooks.can_unpause():
|
||||
return
|
||||
self.ctrl.state.set('optional_pause', False)
|
||||
self._unpause()
|
||||
|
||||
|
||||
@@ -30,7 +30,22 @@ import math
|
||||
import re
|
||||
import time
|
||||
from collections import deque
|
||||
import camotics.gplan as gplan # pylint: disable=no-name-in-module,import-error
|
||||
# camotics.gplan is heavy (loads a C++ extension that pulls in libstdc++,
|
||||
# boost::python, etc.). Defer it: bbctrl can listen on HTTP and serve
|
||||
# the UI without ever touching the planner. Lazy-load the first time
|
||||
# Planner.init() runs, which is when the user actually queues motion.
|
||||
gplan = None
|
||||
def _load_gplan():
|
||||
global gplan
|
||||
if gplan is None:
|
||||
try:
|
||||
import bbctrl.Trace as _T
|
||||
with _T.span('imports.camotics_gplan'):
|
||||
import camotics.gplan as _gplan # pylint: disable=no-name-in-module,import-error
|
||||
except Exception:
|
||||
import camotics.gplan as _gplan # pylint: disable=no-name-in-module,import-error
|
||||
gplan = _gplan
|
||||
return gplan
|
||||
import bbctrl.Cmd as Cmd
|
||||
from bbctrl.CommandQueue import CommandQueue
|
||||
|
||||
@@ -181,12 +196,23 @@ class Planner():
|
||||
|
||||
|
||||
def _add_message(self, text):
|
||||
self.ctrl.state.add_message(text)
|
||||
|
||||
line = self.ctrl.state.get('line', 0)
|
||||
if 0 <= line: where = '%s:%d' % (self.where, line)
|
||||
else: where = self.where
|
||||
|
||||
# HOOK:<event>:<data> messages are an internal IPC channel
|
||||
# between the gcode preprocessor and Hooks; bypass the user
|
||||
# message list so they don't surface as popups, and dispatch
|
||||
# the hook directly. Routing through state.messages would
|
||||
# only deliver it after the 0.25s state-change debounce, by
|
||||
# which point we'd have to keep it visible to ensure Hooks
|
||||
# could see it.
|
||||
hooks = getattr(self.ctrl, 'hooks', None)
|
||||
if hooks is not None and hooks.dispatch_hook_message(text):
|
||||
self.log.info('HOOK msg: %s' % text, where = where)
|
||||
return
|
||||
|
||||
self.ctrl.state.add_message(text)
|
||||
self.log.message(text, where = where)
|
||||
|
||||
|
||||
@@ -244,6 +270,54 @@ class Planner():
|
||||
if type != 'set': self.log.info('Cmd:' + log_json(block))
|
||||
|
||||
if type == 'line':
|
||||
# Z-A coupling check: every line block that touches Z (or
|
||||
# A) is validated against the projected (A,Z) machine
|
||||
# pair. The ExternalAxis check is improvement-aware: it
|
||||
# only refuses moves that worsen an existing violation
|
||||
# or push a healthy state into one. So pure-XY jogs and
|
||||
# recovery moves (Z up, A down) are not rejected even
|
||||
# when (A-Z) is currently above the bound.
|
||||
ext_check = getattr(self.ctrl, 'ext_axis', None)
|
||||
if ext_check is not None:
|
||||
from bbctrl.ExternalAxis import ExternalAxisError
|
||||
target = block.get('target') or {}
|
||||
z_target = target.get('z')
|
||||
if z_target is None: z_target = target.get('Z')
|
||||
a_letter = ext_check.axis_letter
|
||||
a_target = target.get(a_letter)
|
||||
if a_target is None:
|
||||
a_target = target.get(a_letter.upper())
|
||||
if z_target is not None or a_target is not None:
|
||||
try:
|
||||
ext_check.check_coupling(
|
||||
target_a_machine=a_target,
|
||||
target_z_machine=z_target)
|
||||
except ExternalAxisError as e:
|
||||
# Convert the raw error into a clean abort:
|
||||
# surface the message to the operator, stop
|
||||
# the cycle, and skip this block. Returning
|
||||
# None drops the block from the AVR queue;
|
||||
# mach.stop() halts further planner output
|
||||
# so the rest of an offending program can't
|
||||
# leak through. The planner stays usable
|
||||
# for new MDI / jog commands.
|
||||
self.log.warning('Z-A coupling refused: %s' % e)
|
||||
try:
|
||||
self.ctrl.state.add_message(
|
||||
'Z-A coupling refused move: ' + str(e))
|
||||
except Exception: pass
|
||||
try:
|
||||
self.ctrl.mach.stop()
|
||||
except Exception: pass
|
||||
return None
|
||||
|
||||
ext = self._external_axis_for_line(block)
|
||||
if ext is not None:
|
||||
# Side effect: enqueue the ESP move on the external-
|
||||
# axis worker. The AVR still receives the full target
|
||||
# (including A) so ex.position[A] tracks gplan; no
|
||||
# motor steps for A because no motor maps to it.
|
||||
self._dispatch_external_line(block, ext)
|
||||
self._enqueue_line_time(block)
|
||||
return Cmd.line(block['target'], block['exit-vel'],
|
||||
block['max-accel'], block['max-jerk'],
|
||||
@@ -274,8 +348,17 @@ class Planner():
|
||||
|
||||
if name[2:] == '_homed':
|
||||
motor = self.ctrl.state.find_motor(name[1])
|
||||
if motor is not None:
|
||||
# Synthetic external motor (index 4) doesn't exist
|
||||
# on the AVR; mirror the homed flag in State only.
|
||||
from bbctrl.ExternalAxis import EXTERNAL_MOTOR_INDEX
|
||||
if motor is not None and motor < EXTERNAL_MOTOR_INDEX:
|
||||
return Cmd.set_sync('%dh' % motor, value)
|
||||
if motor == EXTERNAL_MOTOR_INDEX:
|
||||
# Update synthetic motor flag and the<axis>_homed
|
||||
# projection consumed by the DRO.
|
||||
self.cmdq.enqueue(
|
||||
id, self.ctrl.state.set,
|
||||
'%dh' % EXTERNAL_MOTOR_INDEX, value)
|
||||
|
||||
return
|
||||
|
||||
@@ -324,12 +407,74 @@ class Planner():
|
||||
self.planner.set_logger(None)
|
||||
|
||||
|
||||
# ----------------------------------------------- external-axis routing
|
||||
#
|
||||
# When an axis is exposed to gplan via a synthetic motor (no AVR
|
||||
# channel), we need to fork its motion off to the ESP at line
|
||||
# encode time and let the rest of the line proceed to the AVR.
|
||||
# The split is done here rather than in gplan because gplan
|
||||
# treats all six axes uniformly and just emits target dicts; we
|
||||
# don't want to teach it about the ESP.
|
||||
|
||||
def _external_axis_for_line(self, block):
|
||||
"""Return the ExternalAxis instance for whichever axis in
|
||||
block['target'] is external, or None."""
|
||||
ext = getattr(self.ctrl, 'ext_axis', None)
|
||||
if ext is None or not ext.enabled:
|
||||
return None
|
||||
target = block.get('target') or {}
|
||||
if ext.axis_letter in target or ext.axis_letter.upper() in target:
|
||||
return ext
|
||||
return None
|
||||
|
||||
def _dispatch_external_line(self, block, ext):
|
||||
"""Side-effect: enqueue the ESP move on the external-axis
|
||||
worker thread (non-blocking). Returns the block (possibly
|
||||
unchanged) for the AVR.
|
||||
|
||||
We do NOT strip the external axis target from the AVR line.
|
||||
The AVR's exec_move_to_target updates ex.position[axis] for
|
||||
every axis in the target dict regardless of motor mapping,
|
||||
and reports it back via the `p` indexed var. Leaving A in
|
||||
the target keeps state.ap in sync with gplan's idea of A
|
||||
(otherwise the AVR's stale ex.position[A] would clobber
|
||||
ExternalAxis's state.ap=N update on the next status report).
|
||||
|
||||
The AVR doesn't step any motor for the external axis (no
|
||||
motor maps to it) - so leaving A in the target is
|
||||
physically a no-op for the steppers, while keeping the
|
||||
host-side state coherent.
|
||||
|
||||
We pass the full S-curve parameters to the ESP so its move
|
||||
duration matches the AVR's exactly. The ESP runs the same
|
||||
7-segment jerk-limited trajectory the AVR would have run
|
||||
if A had been a real motor."""
|
||||
target = block.get('target') or {}
|
||||
# Read the external target (case-insensitive) without modifying
|
||||
# the dict so the AVR still sees A.
|
||||
ext_mm = target.get(ext.axis_letter)
|
||||
if ext_mm is None:
|
||||
ext_mm = target.get(ext.axis_letter.upper())
|
||||
try:
|
||||
ext.enqueue_line(
|
||||
ext_mm,
|
||||
block.get('max-accel', 0.0),
|
||||
block.get('max-jerk', 0.0),
|
||||
block.get('entry-vel', 0.0),
|
||||
block.get('exit-vel', 0.0),
|
||||
block.get('times', [0]*7),
|
||||
)
|
||||
except Exception as e:
|
||||
self.log.error('External axis enqueue failed: %s' % e)
|
||||
raise
|
||||
return block
|
||||
|
||||
def reset(self, *args, **kwargs):
|
||||
stop = kwargs.get('stop', True)
|
||||
if stop:
|
||||
self.ctrl.mach.stop()
|
||||
|
||||
self.planner = gplan.Planner()
|
||||
self.planner = _load_gplan().Planner()
|
||||
self.planner.set_resolver(self._get_var_cb)
|
||||
# TODO logger is global and will not work correctly in demo mode
|
||||
self.planner.set_logger(self._log_cb, 1, 'LinePlanner:3')
|
||||
@@ -337,6 +482,16 @@ class Planner():
|
||||
self.cmdq.clear()
|
||||
self.reset_times()
|
||||
|
||||
# Drain the external-axis worker queue and force the next
|
||||
# move to re-sync position from the ESP (since State.reset
|
||||
# below will zero <axis>p which makes ext._pos_mm stale).
|
||||
ext = getattr(self.ctrl, 'ext_axis', None)
|
||||
if ext is not None:
|
||||
try: ext.abort()
|
||||
except Exception: pass
|
||||
try: ext._pos_mm = None
|
||||
except Exception: pass
|
||||
|
||||
resetState = kwargs.get('resetState', True)
|
||||
if resetState:
|
||||
self.ctrl.state.reset()
|
||||
@@ -354,6 +509,22 @@ class Planner():
|
||||
self.where = path
|
||||
path = self.ctrl.get_path('upload', path)
|
||||
self.log.info('GCode:' + path)
|
||||
# Rewrite ATC M-codes (M100..M103) before gplan sees them.
|
||||
# preprocess_file is a no-op when no rewriting is needed and
|
||||
# idempotent when run twice on the same file, so this is
|
||||
# safe on every load. W tokens are no longer rewritten - the
|
||||
# auxcnc stepper is now exposed as a virtual A axis and gcode
|
||||
# should use A directly.
|
||||
try:
|
||||
from bbctrl.AuxPreprocessor import preprocess_file
|
||||
ext = getattr(self.ctrl, 'ext_axis', None)
|
||||
coupling = (ext.coupling_for_preprocessor()
|
||||
if ext is not None else None)
|
||||
if preprocess_file(path, log=self.log, coupling=coupling):
|
||||
self.log.info('Rewrote (ATC / Z-A coupling) in %s' % path)
|
||||
except Exception:
|
||||
self.log.exception('Aux preprocess at load failed; '
|
||||
'attempting to load file unchanged')
|
||||
self._sync_position()
|
||||
self.planner.load(path, self.get_config(False, True))
|
||||
self.reset_times()
|
||||
|
||||
@@ -107,8 +107,14 @@ class State(object):
|
||||
|
||||
|
||||
def reset(self):
|
||||
# Unhome all motors
|
||||
for i in range(4): self.set('%dhomed' % i, False)
|
||||
# Unhome all motors (real AVR motors 0..3 and the synthetic
|
||||
# external-axis motor at index 4 used by ExternalAxis).
|
||||
# Both <motor>homed and <motor>h are cleared - they're set
|
||||
# by different code paths (gplan emits homed via _<axis>_homed
|
||||
# set blocks, AVR reports h directly).
|
||||
for i in range(5):
|
||||
self.set('%dhomed' % i, False)
|
||||
self.set('%dh' % i, 0)
|
||||
|
||||
# Zero offsets and positions
|
||||
for axis in 'xyzabc':
|
||||
@@ -280,8 +286,11 @@ class State(object):
|
||||
axis_motors = {axis: self.find_motor(axis) for axis in 'xyzabc'}
|
||||
axis_vars = {}
|
||||
|
||||
# NOTE: motor index '4' is a host-only synthetic motor used
|
||||
# by ExternalAxis to expose the auxcnc ESP-driven stepper as
|
||||
# an additional axis. Real AVR motors are 0..3.
|
||||
for name, value in vars.items():
|
||||
if name[0] in '0123':
|
||||
if name[0] in '01234':
|
||||
motor = int(name[0])
|
||||
|
||||
for axis in 'xyzabc':
|
||||
@@ -330,6 +339,9 @@ class State(object):
|
||||
def get_axis_vector(self, name, scale = 1):
|
||||
v = {}
|
||||
|
||||
# 0..3 are AVR motor channels. 4 is the host-side synthetic
|
||||
# motor used by ExternalAxis. find_motor returns the right
|
||||
# index regardless of whether the axis is physical or external.
|
||||
for axis in 'xyzabc':
|
||||
motor = self.find_motor(axis)
|
||||
|
||||
@@ -351,7 +363,10 @@ class State(object):
|
||||
|
||||
|
||||
def find_motor(self, axis):
|
||||
for motor in range(4):
|
||||
# Walk 0..4: 0..3 are real AVR motors, 4 is the synthetic
|
||||
# host-side motor used to expose the auxcnc ESP stepper as
|
||||
# an external axis.
|
||||
for motor in range(5):
|
||||
if not ('%dan' % motor) in self.vars: continue
|
||||
motor_axis = 'xyzabc'[self.vars['%dan' % motor]]
|
||||
if motor_axis == axis.lower() and self.vars.get('%dme' % motor, 0):
|
||||
|
||||
185
src/py/bbctrl/Trace.py
Normal file
185
src/py/bbctrl/Trace.py
Normal file
@@ -0,0 +1,185 @@
|
||||
################################################################################
|
||||
# #
|
||||
# Lightweight phase tracing for bbctrl restart / boot timing. #
|
||||
# #
|
||||
# Anchored at module import time. All timestamps are seconds since the #
|
||||
# process anchor (monotonic). A wall-clock anchor is captured once so the #
|
||||
# timeline can be aligned with journalctl / systemd-analyze. #
|
||||
# #
|
||||
# Set BBCTRL_TRACE=0 in the environment to disable all marks (no-op). #
|
||||
# #
|
||||
# Exposed by /api/diag/timing as JSON. #
|
||||
# #
|
||||
################################################################################
|
||||
"""Bbctrl restart / startup tracing.
|
||||
|
||||
Usage:
|
||||
import bbctrl.Trace as T
|
||||
T.mark('proc.start')
|
||||
with T.span('ctrl.avr.init'):
|
||||
...
|
||||
|
||||
The timeline is also dumped on demand via /api/diag/timing.
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
import json
|
||||
import threading
|
||||
|
||||
|
||||
_ENABLED = os.environ.get('BBCTRL_TRACE', '1') != '0'
|
||||
|
||||
_t0_monotonic = time.monotonic()
|
||||
_t0_wall = time.time()
|
||||
_lock = threading.Lock()
|
||||
_events = [] # list of dicts: {t, name, fields}
|
||||
_ui_timing = None # last timeline POSTed by the browser
|
||||
|
||||
|
||||
def _read_kernel_anchors():
|
||||
"""Return (btime_wall, uptime_at_anchor) so we can express bbctrl events
|
||||
in seconds since kernel boot.
|
||||
|
||||
btime_wall: wall-clock epoch seconds when the kernel booted (from
|
||||
/proc/stat 'btime').
|
||||
uptime_at_anchor: monotonic offset (seconds since kernel boot) at the
|
||||
moment Trace was imported. Equivalent to (Trace anchor) - btime
|
||||
in wall time, but read directly from /proc/uptime so it isn't
|
||||
sensitive to wall-clock skew.
|
||||
"""
|
||||
btime = None
|
||||
uptime_at_anchor = None
|
||||
try:
|
||||
with open('/proc/stat') as f:
|
||||
for line in f:
|
||||
if line.startswith('btime '):
|
||||
btime = int(line.split()[1])
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
with open('/proc/uptime') as f:
|
||||
uptime_at_anchor = float(f.read().split()[0])
|
||||
except Exception:
|
||||
pass
|
||||
return btime, uptime_at_anchor
|
||||
|
||||
|
||||
_btime_wall, _uptime_at_anchor = _read_kernel_anchors()
|
||||
|
||||
|
||||
def now():
|
||||
return time.monotonic() - _t0_monotonic
|
||||
|
||||
|
||||
def mark(name, **fields):
|
||||
"""Record a single named event at the current monotonic time."""
|
||||
if not _ENABLED:
|
||||
return
|
||||
t = now()
|
||||
ev = {'t': round(t, 4), 'name': name}
|
||||
if fields:
|
||||
ev['fields'] = fields
|
||||
with _lock:
|
||||
_events.append(ev)
|
||||
# Also surface in the regular log stream so journalctl shows it.
|
||||
try:
|
||||
extras = ''
|
||||
if fields:
|
||||
extras = ' ' + ' '.join('%s=%s' % (k, v) for k, v in fields.items())
|
||||
print('TRACE +%.3fs %s%s' % (t, name, extras), flush=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class span(object):
|
||||
"""Context manager that emits <name>.start / <name>.end with duration."""
|
||||
|
||||
def __init__(self, name, **fields):
|
||||
self.name = name
|
||||
self.fields = fields
|
||||
self._t = None
|
||||
|
||||
def __enter__(self):
|
||||
if _ENABLED:
|
||||
self._t = time.monotonic()
|
||||
mark(self.name + '.start', **self.fields)
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
if _ENABLED and self._t is not None:
|
||||
dur_ms = int((time.monotonic() - self._t) * 1000)
|
||||
extra = dict(self.fields)
|
||||
extra['dur_ms'] = dur_ms
|
||||
if exc_type is not None:
|
||||
extra['error'] = exc_type.__name__
|
||||
mark(self.name + '.end', **extra)
|
||||
return False
|
||||
|
||||
|
||||
def set_ui_timing(data):
|
||||
global _ui_timing
|
||||
_ui_timing = data
|
||||
|
||||
|
||||
def _current_uptime():
|
||||
try:
|
||||
with open('/proc/uptime') as f:
|
||||
return float(f.read().split()[0])
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def timeline():
|
||||
with _lock:
|
||||
events = list(_events)
|
||||
return {
|
||||
'enabled': _ENABLED,
|
||||
't0_wall': _t0_wall,
|
||||
't0_iso': time.strftime('%Y-%m-%dT%H:%M:%S', time.localtime(_t0_wall)),
|
||||
'now': now(),
|
||||
'pid': os.getpid(),
|
||||
'events': events,
|
||||
'ui': _ui_timing,
|
||||
# Kernel-boot anchors so the timeline can be expressed in
|
||||
# "seconds since power on".
|
||||
'btime_wall': _btime_wall,
|
||||
'uptime_at_anchor': _uptime_at_anchor,
|
||||
'uptime_now': _current_uptime(),
|
||||
}
|
||||
|
||||
|
||||
def dump(path):
|
||||
try:
|
||||
with open(path, 'w') as f:
|
||||
json.dump(timeline(), f, indent=2)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# Sd_notify helper -------------------------------------------------------------
|
||||
#
|
||||
# Allows bbctrl to tell systemd "I am ready" / "current status is X" so
|
||||
# `systemctl status bbctrl` and `systemd-analyze critical-chain` reflect the
|
||||
# actual application state instead of just exec start.
|
||||
def sd_notify(state):
|
||||
"""Send a status line to systemd. Safe no-op when not under systemd."""
|
||||
addr = os.environ.get('NOTIFY_SOCKET')
|
||||
if not addr:
|
||||
return
|
||||
try:
|
||||
import socket
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
|
||||
try:
|
||||
# Abstract socket if it starts with '@'
|
||||
target = '\0' + addr[1:] if addr.startswith('@') else addr
|
||||
sock.sendto(state.encode('utf-8'), target)
|
||||
finally:
|
||||
sock.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# Mark module-import time so even importing bbctrl shows up.
|
||||
mark('trace.import')
|
||||
@@ -766,6 +766,111 @@ class RotaryHandler(bbctrl.APIHandler):
|
||||
log.error('Unexpected error: {}'.format(e))
|
||||
|
||||
|
||||
class HooksGetHandler(bbctrl.APIHandler):
|
||||
def get(self):
|
||||
self.write_json(self.get_ctrl().hooks.get_config())
|
||||
|
||||
|
||||
class HooksSaveHandler(bbctrl.APIHandler):
|
||||
def put_ok(self):
|
||||
self.get_ctrl().hooks.save_config(self.json)
|
||||
|
||||
|
||||
class HooksStatusHandler(bbctrl.APIHandler):
|
||||
def get(self):
|
||||
self.write_json(self.get_ctrl().hooks.get_status())
|
||||
|
||||
|
||||
class HooksFireHandler(bbctrl.APIHandler):
|
||||
def put_ok(self, event):
|
||||
data = self.json if hasattr(self, 'json') and self.json else {}
|
||||
self.get_ctrl().hooks._fire(event, data)
|
||||
|
||||
|
||||
# ----- W axis (auxcnc) endpoints --------------------------------------------
|
||||
|
||||
class AuxConfigGetHandler(bbctrl.APIHandler):
|
||||
def get(self):
|
||||
self.write_json(self.get_ctrl().aux.get_config())
|
||||
|
||||
|
||||
class AuxConfigSaveHandler(bbctrl.APIHandler):
|
||||
def put_ok(self):
|
||||
self.get_ctrl().aux.save_config(self.json or {})
|
||||
|
||||
|
||||
class AuxStatusHandler(bbctrl.APIHandler):
|
||||
def get(self):
|
||||
aux = self.get_ctrl().aux
|
||||
self.write_json({
|
||||
'enabled': aux.enabled,
|
||||
'present': aux.present,
|
||||
'homed': aux.homed,
|
||||
'pos_mm': aux.position_mm,
|
||||
})
|
||||
|
||||
|
||||
class AuxHomeHandler(bbctrl.APIHandler):
|
||||
def put_ok(self):
|
||||
# Run synchronously. Route through ExternalAxis so the
|
||||
# synthetic motor's homed flag and DRO update.
|
||||
ext = getattr(self.get_ctrl(), 'ext_axis', None)
|
||||
if ext is not None and ext.enabled:
|
||||
ext.home()
|
||||
else:
|
||||
self.get_ctrl().aux.home()
|
||||
|
||||
|
||||
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).
|
||||
|
||||
Note: with the gplan-integrated W axis, jog-by-mm goes through
|
||||
ExternalAxis so the DRO updates and gplan's idea of A's position
|
||||
stays in sync. jog-by-steps still bypasses everything for the
|
||||
homing/setup workflow where the axis isn't homed yet."""
|
||||
def put_ok(self):
|
||||
body = self.json or {}
|
||||
aux = self.get_ctrl().aux
|
||||
ext = getattr(self.get_ctrl(), 'ext_axis', None)
|
||||
if 'mm' in body:
|
||||
delta_mm = float(body['mm'])
|
||||
if ext is not None and ext.enabled and ext._pos_mm is not None:
|
||||
ext.execute_to_mm(ext._pos_mm + delta_mm)
|
||||
else:
|
||||
aux.move_rel_mm(delta_mm)
|
||||
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')
|
||||
ext = getattr(self.get_ctrl(), 'ext_axis', None)
|
||||
if ext is not None and ext.enabled:
|
||||
ext.execute_to_mm(float(body['mm']))
|
||||
else:
|
||||
self.get_ctrl().aux.move_abs_mm(float(body['mm']))
|
||||
|
||||
|
||||
class AuxSetZeroHandler(bbctrl.APIHandler):
|
||||
"""Body: {"mm": 0} set current position to <mm>."""
|
||||
def put_ok(self):
|
||||
body = self.json or {}
|
||||
mm = float(body.get('mm', 0.0))
|
||||
self.get_ctrl().aux.set_position_mm(mm)
|
||||
|
||||
|
||||
class RemoteDiagnosticsHandler(bbctrl.APIHandler):
|
||||
|
||||
def get(self):
|
||||
@@ -798,6 +903,31 @@ class RemoteDiagnosticsHandler(bbctrl.APIHandler):
|
||||
'message': e.reason or "Unknown"
|
||||
})
|
||||
|
||||
class TimingHandler(bbctrl.APIHandler):
|
||||
"""Return the bbctrl process startup timeline as JSON.
|
||||
|
||||
Includes monotonic-anchored events from bbctrl.Trace, the wall
|
||||
clock anchor (so the timeline can be aligned with journalctl /
|
||||
systemd-analyze output), and the most recent UI-side timing
|
||||
payload posted by the browser.
|
||||
"""
|
||||
def get(self):
|
||||
import bbctrl.Trace as _T
|
||||
self.write_json(_T.timeline())
|
||||
|
||||
|
||||
class UITimingHandler(bbctrl.APIHandler):
|
||||
"""Browser posts its performance.now() marks here once per load."""
|
||||
def put_ok(self):
|
||||
import bbctrl.Trace as _T
|
||||
# self.json is parsed in APIHandler.prepare()
|
||||
try:
|
||||
_T.set_ui_timing(self.json)
|
||||
_T.mark('ui.posted_timing',
|
||||
marks=len(self.json.get('marks', []) or []))
|
||||
except Exception: pass
|
||||
|
||||
|
||||
# Base class for Web Socket connections
|
||||
class ClientConnection(object):
|
||||
def __init__(self, app):
|
||||
@@ -873,6 +1003,12 @@ class SockJSConnection(ClientConnection, sockjs.tornado.SockJSConnection):
|
||||
ip = info.ip
|
||||
if 'X-Real-IP' in info.headers: ip = info.headers['X-Real-IP']
|
||||
self.app.get_ctrl(id).log.get('Web').info('Connection from %s' % ip)
|
||||
try:
|
||||
if not getattr(self.app, '_first_ws', False):
|
||||
self.app._first_ws = True
|
||||
import bbctrl.Trace as _T
|
||||
_T.mark('ws.first_open', ip=ip)
|
||||
except Exception: pass
|
||||
super().on_open(id)
|
||||
|
||||
|
||||
@@ -881,6 +1017,23 @@ class StaticFileHandler(tornado.web.StaticFileHandler):
|
||||
self.set_header('Cache-Control',
|
||||
'no-store, no-cache, must-revalidate, max-age=0')
|
||||
|
||||
def prepare(self):
|
||||
# Mark the first request for the index page so we can see when
|
||||
# chromium actually started fetching the UI on cold boot.
|
||||
try:
|
||||
app = self.application
|
||||
if not getattr(app, '_first_root_get', False):
|
||||
# Treat any GET '/' or '/index.html' as the root fetch.
|
||||
p = self.request.path
|
||||
if p in ('/', '/index.html', ''):
|
||||
app._first_root_get = True
|
||||
import bbctrl.Trace as _T
|
||||
_T.mark('web.first_root_get',
|
||||
ip=self.request.remote_ip,
|
||||
ua=(self.request.headers.get('User-Agent') or '')[:60])
|
||||
except Exception: pass
|
||||
return super().prepare()
|
||||
|
||||
class Web(tornado.web.Application):
|
||||
def __init__(self, args, ioloop):
|
||||
self.args = args
|
||||
@@ -902,6 +1055,8 @@ class Web(tornado.web.Application):
|
||||
|
||||
handlers = [
|
||||
(r'/websocket', WSConnection),
|
||||
(r'/api/diag/timing', TimingHandler),
|
||||
(r'/api/diag/timing/ui', UITimingHandler),
|
||||
(r'/api/log', LogHandler),
|
||||
(r'/api/message/(\d+)/ack', MessageAckHandler),
|
||||
(r'/api/bugreport', BugReportHandler),
|
||||
@@ -941,6 +1096,18 @@ class Web(tornado.web.Application):
|
||||
(r'/api/time', TimeHandler),
|
||||
(r'/api/rotary', RotaryHandler),
|
||||
(r'/api/remote-diagnostics', RemoteDiagnosticsHandler),
|
||||
(r'/api/hooks', HooksGetHandler),
|
||||
(r'/api/hooks/save', HooksSaveHandler),
|
||||
(r'/api/hooks/status', HooksStatusHandler),
|
||||
(r'/api/hooks/fire/([\w-]+)', HooksFireHandler),
|
||||
(r'/api/aux/config', AuxConfigGetHandler),
|
||||
(r'/api/aux/config/save', AuxConfigSaveHandler),
|
||||
(r'/api/aux/status', AuxStatusHandler),
|
||||
(r'/api/aux/home', AuxHomeHandler),
|
||||
(r'/api/aux/abort', AuxAbortHandler),
|
||||
(r'/api/aux/jog', AuxJogHandler),
|
||||
(r'/api/aux/move', AuxMoveHandler),
|
||||
(r'/api/aux/set-zero', AuxSetZeroHandler),
|
||||
(r'/(.*)', StaticFileHandler,
|
||||
{'path': bbctrl.get_resource('http/'),
|
||||
'default_filename': 'index.html'}),
|
||||
|
||||
@@ -36,6 +36,13 @@ import datetime
|
||||
|
||||
from pkg_resources import Requirement, resource_filename
|
||||
|
||||
# Trace must be imported before the rest of bbctrl so its monotonic
|
||||
# anchor is the earliest reasonable point and so import-time costs of
|
||||
# heavy submodules (camotics gplan.so, sockjs, tornado, etc.) are
|
||||
# attributable in /api/diag/timing.
|
||||
import bbctrl.Trace as Trace
|
||||
Trace.mark('imports.bbctrl.start')
|
||||
|
||||
from bbctrl.RequestHandler import RequestHandler
|
||||
from bbctrl.APIHandler import APIHandler
|
||||
from bbctrl.FileHandler import FileHandler
|
||||
@@ -59,11 +66,16 @@ from bbctrl.AVR import AVR
|
||||
from bbctrl.AVREmu import AVREmu
|
||||
from bbctrl.IOLoop import IOLoop
|
||||
from bbctrl.MonitorTemp import MonitorTemp
|
||||
from bbctrl.Hooks import Hooks
|
||||
from bbctrl.AuxAxis import AuxAxis
|
||||
from bbctrl.ExternalAxis import ExternalAxis
|
||||
import bbctrl.Cmd as Cmd
|
||||
import bbctrl.v4l2 as v4l2
|
||||
import bbctrl.Log as log
|
||||
import bbctrl.ObjGraph as ObjGraph
|
||||
|
||||
Trace.mark('imports.bbctrl.end')
|
||||
|
||||
|
||||
ctrl = None
|
||||
|
||||
@@ -167,19 +179,28 @@ def parse_args():
|
||||
def run():
|
||||
global ctrl
|
||||
|
||||
Trace.mark('run.enter')
|
||||
args = parse_args()
|
||||
Trace.mark('args.parsed')
|
||||
|
||||
# Set signal handler
|
||||
signal.signal(signal.SIGTERM, on_exit)
|
||||
|
||||
# Create ioloop
|
||||
ioloop = tornado.ioloop.IOLoop.current()
|
||||
Trace.mark('ioloop.created')
|
||||
|
||||
# Set ObjGraph signal handler
|
||||
if args.debug: Debugger(ioloop, args.debug)
|
||||
|
||||
# Start server
|
||||
web = Web(args, ioloop)
|
||||
with Trace.span('web.init'):
|
||||
web = Web(args, ioloop)
|
||||
Trace.mark('listen', port=args.port, addr=args.addr)
|
||||
|
||||
# Notify systemd we are ready (no-op when not under systemd).
|
||||
Trace.sd_notify('READY=1\nSTATUS=listening on %s:%d\n' %
|
||||
(args.addr, args.port))
|
||||
|
||||
try:
|
||||
ioloop.start()
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
src/resources/webfonts/fa-brands-400.ttf
Normal file
BIN
src/resources/webfonts/fa-brands-400.ttf
Normal file
Binary file not shown.
BIN
src/resources/webfonts/fa-brands-400.woff2
Normal file
BIN
src/resources/webfonts/fa-brands-400.woff2
Normal file
Binary file not shown.
BIN
src/resources/webfonts/fa-regular-400.ttf
Normal file
BIN
src/resources/webfonts/fa-regular-400.ttf
Normal file
Binary file not shown.
BIN
src/resources/webfonts/fa-regular-400.woff2
Normal file
BIN
src/resources/webfonts/fa-regular-400.woff2
Normal file
Binary file not shown.
BIN
src/resources/webfonts/fa-solid-900.ttf
Normal file
BIN
src/resources/webfonts/fa-solid-900.ttf
Normal file
Binary file not shown.
BIN
src/resources/webfonts/fa-solid-900.woff2
Normal file
BIN
src/resources/webfonts/fa-solid-900.woff2
Normal file
Binary file not shown.
@@ -2,5 +2,5 @@
|
||||
font-family: 'Audiowide';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: local('Audiowide'), local('Audiowide-Regular'), url(http://fonts.gstatic.com/s/audiowide/v4/8XtYtNKEyyZh481XVWfVOqCWcynf_cDxXwCLxiixG1c.ttf) format('truetype');
|
||||
src: local('Audiowide'), local('Audiowide-Regular'), url(https://fonts.gstatic.com/s/audiowide/v4/8XtYtNKEyyZh481XVWfVOqCWcynf_cDxXwCLxiixG1c.ttf) format('truetype');
|
||||
}
|
||||
|
||||
9
src/static/css/fa6.min.css
vendored
Normal file
9
src/static/css/fa6.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
4
src/static/css/font-awesome.min.css
vendored
4
src/static/css/font-awesome.min.css
vendored
File diff suppressed because one or more lines are too long
@@ -1,35 +1,37 @@
|
||||
// V09 redesign: the legacy side menu was removed. Keep this file
|
||||
// shipped in case anything still references it, but no-op the click
|
||||
// handler that used to wire up the burger menu so it does not throw
|
||||
// "Cannot set properties of null" on the Settings tab.
|
||||
(function (window, document) {
|
||||
var menuLink = document.getElementById("menuLink");
|
||||
if (!menuLink) {
|
||||
return;
|
||||
}
|
||||
|
||||
var layout = document.getElementById('layout'),
|
||||
menu = document.getElementById('menu'),
|
||||
menuLink = document.getElementById('menuLink');
|
||||
var layout = document.getElementById("layout");
|
||||
var menu = document.getElementById("menu");
|
||||
|
||||
function toggleClass(element, className) {
|
||||
var classes = element.className.split(/\s+/),
|
||||
length = classes.length,
|
||||
i = 0;
|
||||
|
||||
for(; i < length; i++) {
|
||||
if (classes[i] === className) {
|
||||
classes.splice(i, 1);
|
||||
break;
|
||||
}
|
||||
if (!element) return;
|
||||
var classes = element.className.split(/\s+/);
|
||||
var i;
|
||||
for (i = 0; i < classes.length; i++) {
|
||||
if (classes[i] === className) {
|
||||
classes.splice(i, 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
// The className is not found
|
||||
if (length === classes.length) {
|
||||
if (i === classes.length) {
|
||||
classes.push(className);
|
||||
}
|
||||
|
||||
element.className = classes.join(' ');
|
||||
element.className = classes.join(" ");
|
||||
}
|
||||
|
||||
menuLink.onclick = function (e) {
|
||||
var active = 'active';
|
||||
|
||||
var active = "active";
|
||||
e.preventDefault();
|
||||
toggleClass(layout, active);
|
||||
toggleClass(menu, active);
|
||||
toggleClass(menuLink, active);
|
||||
};
|
||||
|
||||
}(this, this.document));
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
336
src/svelte-components/src/components/AAxisSettings.svelte
Normal file
336
src/svelte-components/src/components/AAxisSettings.svelte
Normal file
@@ -0,0 +1,336 @@
|
||||
<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 auxiliary A axis on/off
|
||||
// is done via aux.json on disk, so adding/removing the hardware
|
||||
// doesn't have a surprise UI that bricks bring-up. Legacy aux.json
|
||||
// files using min_w/max_w are migrated up to min_mm/max_mm by
|
||||
// AuxAxis._migrate_legacy_fields on load.
|
||||
type AuxConfig = {
|
||||
enabled: boolean;
|
||||
port: string;
|
||||
baud: number;
|
||||
steps_per_mm: number;
|
||||
dir_sign: number;
|
||||
axis_letter: string;
|
||||
min_mm: number;
|
||||
max_mm: number;
|
||||
max_feed_mm_min: number;
|
||||
max_velocity_m_per_min: number;
|
||||
max_accel_km_per_min2: number;
|
||||
max_jerk_km_per_min3: number;
|
||||
home_dir: string;
|
||||
home_position_mm: number;
|
||||
home_fast_sps: number;
|
||||
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;
|
||||
couple_z_enabled: boolean;
|
||||
couple_z_clearance_mm: number;
|
||||
z_home_mm: number;
|
||||
};
|
||||
|
||||
let cfg: AuxConfig | null = null;
|
||||
let status: { enabled: boolean; present: boolean; homed: boolean; pos_mm: number } | null = null;
|
||||
let busy = false;
|
||||
|
||||
// Listen for the global "save-all" event the Vue root dispatches
|
||||
// when the user clicks the master Save button. We persist our
|
||||
// current cfg the same way the in-form button used to. This way
|
||||
// the user only ever needs one Save button.
|
||||
function onGlobalSave() {
|
||||
save().catch(e => console.error("aux save failed:", e));
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
await refresh();
|
||||
window.addEventListener("onefin:save-all", onGlobalSave);
|
||||
return () => window.removeEventListener("onefin:save-all", onGlobalSave);
|
||||
});
|
||||
|
||||
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;
|
||||
try {
|
||||
await api.PUT("aux/config/save", cfg);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
console.error("Failed to save aux config:", e);
|
||||
throw e;
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Mark the root config as modified whenever an auxiliary axis
|
||||
// field is edited, so the master Save button highlights and
|
||||
// the user knows there are unsaved changes.
|
||||
function markDirty() {
|
||||
try {
|
||||
const root = (window as any).$root || (window as any).Vue?.root;
|
||||
if (root && "modified" in root) root.modified = true;
|
||||
} catch (_e) {}
|
||||
// Also dispatch a generic event the Vue root listens for.
|
||||
window.dispatchEvent(new CustomEvent("onefin:dirty"));
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="a-axis-settings">
|
||||
{#if !cfg}
|
||||
<p class="tip">Loading A 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" on:input={markDirty} on:change={markDirty}>
|
||||
<fieldset>
|
||||
<div class="pure-control-group" title="Enable the auxiliary axis (auxcnc-driven A). Edit aux.json to toggle.">
|
||||
<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 axis travel.">
|
||||
<label for="steps_per_mm">steps per mm</label>
|
||||
<input id="steps_per_mm" type="number" bind:value={cfg.steps_per_mm} step="any" />
|
||||
<label for="" class="units">steps/mm</label>
|
||||
</div>
|
||||
|
||||
<div class="pure-control-group" title="Direction sign: +1 or -1. Flip if A+ moves the wrong way.">
|
||||
<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="gcode axis letter exposed to the planner. Default 'a' (the standard 4th axis).">
|
||||
<label for="axis_letter">axis letter</label>
|
||||
<select id="axis_letter" bind:value={cfg.axis_letter}>
|
||||
<option value="a">A</option>
|
||||
<option value="b">B</option>
|
||||
<option value="c">C</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="pure-control-group" title="Soft-limit minimum in mm.">
|
||||
<label for="min_mm">soft min</label>
|
||||
<input id="min_mm" type="number" bind:value={cfg.min_mm} step="any" />
|
||||
<label for="" class="units">mm</label>
|
||||
</div>
|
||||
|
||||
<div class="pure-control-group" title="Soft-limit maximum in mm.">
|
||||
<label for="max_mm">soft max</label>
|
||||
<input id="max_mm" type="number" bind:value={cfg.max_mm} step="any" />
|
||||
<label for="" class="units">mm</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<h3>Z-A Coupling</h3>
|
||||
<p class="tip">
|
||||
The auxiliary tool hangs below the Z spindle. Beyond a small
|
||||
Z descent the two collide unless A drops with Z. The rule
|
||||
in machine coordinates is
|
||||
<code>A − Z ≤ (A_home − Z_home) + clearance</code>.
|
||||
When enabled, the planner refuses moves that would violate
|
||||
it and the gcode preprocessor injects pre-position A moves
|
||||
into uploaded files.
|
||||
</p>
|
||||
<fieldset>
|
||||
<div class="pure-control-group" title="Master switch for the Z-A interlock. When off, no checks are performed.">
|
||||
<label for="couple_z_enabled">enable coupling</label>
|
||||
<input id="couple_z_enabled" type="checkbox" bind:checked={cfg.couple_z_enabled} />
|
||||
</div>
|
||||
|
||||
<div class="pure-control-group" title="How far Z may descend below its home position before A must move with it.">
|
||||
<label for="couple_z_clearance_mm">Z clearance</label>
|
||||
<input id="couple_z_clearance_mm" type="number" bind:value={cfg.couple_z_clearance_mm} step="any" />
|
||||
<label for="" class="units">mm</label>
|
||||
</div>
|
||||
|
||||
<div class="pure-control-group" title="Z's machine position when homed. Almost always 0.">
|
||||
<label for="z_home_mm">Z home position</label>
|
||||
<input id="z_home_mm" type="number" bind:value={cfg.z_home_mm} step="any" />
|
||||
<label for="" class="units">mm</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<h3>Planner Limits</h3>
|
||||
<fieldset>
|
||||
<div class="pure-control-group" title="Maximum velocity used by gplan trajectory planning.">
|
||||
<label for="max_velocity_m_per_min">max velocity</label>
|
||||
<input id="max_velocity_m_per_min" type="number" bind:value={cfg.max_velocity_m_per_min} step="any" />
|
||||
<label for="" class="units">m/min</label>
|
||||
</div>
|
||||
|
||||
<div class="pure-control-group" title="Maximum acceleration used by gplan trajectory planning.">
|
||||
<label for="max_accel_km_per_min2">max acceleration</label>
|
||||
<input id="max_accel_km_per_min2" type="number" bind:value={cfg.max_accel_km_per_min2} step="any" />
|
||||
<label for="" class="units">km/min²</label>
|
||||
</div>
|
||||
|
||||
<div class="pure-control-group" title="Maximum jerk used by gplan trajectory planning.">
|
||||
<label for="max_jerk_km_per_min3">max jerk</label>
|
||||
<input id="max_jerk_km_per_min3" type="number" bind:value={cfg.max_jerk_km_per_min3} step="any" />
|
||||
<label for="" class="units">km/min³</label>
|
||||
</div>
|
||||
|
||||
<div class="pure-control-group" title="Informational max feed; rate caps live on the ESP via step_max_sps.">
|
||||
<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 A-)</option>
|
||||
<option value="+">+ (toward A+)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="pure-control-group" title="Axis 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="tip">
|
||||
Changes are written to aux.json when you click the
|
||||
master <strong>Save</strong> button at the bottom of the
|
||||
settings rail. Homing rates and the limit polarity are
|
||||
pushed to the ESP immediately; any running motion is
|
||||
unaffected. Re-home the auxiliary axis after changing direction,
|
||||
sign, or step settings.
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.a-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>
|
||||
@@ -2,6 +2,10 @@
|
||||
import configTemplate from "../../../resources/config-template.json";
|
||||
import ScreenRotationDialog from "$dialogs/ScreenRotationDialog.svelte";
|
||||
import ConfigTemplatedInput from "./ConfigTemplatedInput.svelte";
|
||||
// AAxisSettings is mounted directly by the V09 settings shell at
|
||||
// #a-axis instead of being embedded here — see
|
||||
// src/pug/templates/a-axis-view.pug.
|
||||
// import AAxisSettings from "./AAxisSettings.svelte";
|
||||
import SetTimeDialog from "$dialogs/SetTimeDialog.svelte";
|
||||
import Button, { Label } from "@smui/button";
|
||||
|
||||
@@ -18,8 +22,8 @@
|
||||
<h1>Settings</h1>
|
||||
|
||||
<div class="pure-form pure-form-aligned">
|
||||
<h2>User Interface</h2>
|
||||
<fieldset>
|
||||
<h2 id="sec-display" data-sec="display">User Interface</h2>
|
||||
<fieldset data-sec="display">
|
||||
<div class="pure-control-group">
|
||||
<label for="screen-rotation" />
|
||||
<Button
|
||||
@@ -45,8 +49,8 @@
|
||||
</div> -->
|
||||
</fieldset>
|
||||
|
||||
<h2>Units</h2>
|
||||
<fieldset>
|
||||
<h2 id="sec-units" data-sec="display">Units</h2>
|
||||
<fieldset data-sec="display">
|
||||
<ConfigTemplatedInput key={`settings.units`} />
|
||||
<div class="tip">
|
||||
Note, units sets both the machine default units and the units used in motor configuration. GCode program-start,
|
||||
@@ -54,13 +58,13 @@
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<h2>Easy Adapter</h2>
|
||||
<fieldset>
|
||||
<h2 id="sec-easy-adapter" data-sec="display">Easy Adapter</h2>
|
||||
<fieldset data-sec="display">
|
||||
<ConfigTemplatedInput key={`settings.easy-adapter`} />
|
||||
</fieldset>
|
||||
|
||||
<h2>Probing</h2>
|
||||
<fieldset>
|
||||
<h2 id="sec-probing" data-sec="probing">Probing</h2>
|
||||
<fieldset data-sec="probing">
|
||||
<ConfigTemplatedInput key={`settings.probing-prompts`} />
|
||||
<div class="tip">
|
||||
Onefinity highly recommends that you keep the safety prompts
|
||||
@@ -87,15 +91,19 @@
|
||||
{/each}
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<h2>GCode</h2>
|
||||
<fieldset data-sec="gcode">
|
||||
<h2 id="sec-gcode" data-sec="gcode">GCode</h2>
|
||||
{#each Object.keys(configTemplate.gcode) as key}
|
||||
<ConfigTemplatedInput key={`gcode.${key}`} />
|
||||
{/each}
|
||||
</fieldset>
|
||||
|
||||
<h2>Path Accuracy</h2>
|
||||
<fieldset>
|
||||
<!-- W Axis (auxcnc) is now its own routed page in the V09
|
||||
settings shell (#a-axis). Keep the SettingsView free of
|
||||
that section so we don't render it twice. -->
|
||||
|
||||
<h2 id="sec-path-accuracy" data-sec="gcode">Path Accuracy</h2>
|
||||
<fieldset data-sec="gcode">
|
||||
<ConfigTemplatedInput key={`settings.max-deviation`} />
|
||||
|
||||
<div class="tip">
|
||||
@@ -118,8 +126,8 @@
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<h2>Cornering Speed (Advanced)</h2>
|
||||
<fieldset>
|
||||
<h2 id="sec-cornering" data-sec="gcode">Cornering Speed (Advanced)</h2>
|
||||
<fieldset data-sec="gcode">
|
||||
<ConfigTemplatedInput key={`settings.junction-accel`} />
|
||||
<div class="tip">
|
||||
Junction acceleration limits the cornering speed the planner
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
>
|
||||
<div slot="trailingIcon">
|
||||
{#if valid}
|
||||
<Icon class="fa fa-check-circle-o" style="color: green;" />
|
||||
<Icon class="fa fa-circle-check" style="color: green;" />
|
||||
{/if}
|
||||
</div>
|
||||
<HelperText persistent slot="helper">{helperText}</HelperText>
|
||||
|
||||
@@ -6,6 +6,7 @@ matchAll.shim();
|
||||
import AdminNetworkView from "$components/AdminNetworkView.svelte";
|
||||
import SettingsView from "$components/SettingsView.svelte";
|
||||
import HelpView from "$components/HelpView.svelte";
|
||||
import AAxisSettings from "$components/AAxisSettings.svelte";
|
||||
import DialogHost, { showDialog } from "$dialogs/DialogHost.svelte";
|
||||
import { handleConfigUpdate, setDisplayUnits } from "$lib/ConfigStore";
|
||||
import { handleControllerStateUpdate } from "$lib/ControllerState";
|
||||
@@ -22,6 +23,9 @@ export function createComponent(component: string, target: HTMLElement, props: R
|
||||
case "HelpView":
|
||||
return new HelpView({ target, props });
|
||||
|
||||
case "AAxisSettings":
|
||||
return new AAxisSettings({ target, props });
|
||||
|
||||
case "DialogHost":
|
||||
return new DialogHost({ target, props });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user