Compare commits
15 Commits
4470fcee0a
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| f0a37828a4 | |||
| 2b949c4f00 | |||
| 72c69d3000 | |||
| 94072253d4 | |||
| c10f5c053a | |||
| b9e880448e | |||
| 8224ab8f97 | |||
| 0b5ab2ff3b | |||
| 94270e7725 | |||
| 7a6e2cd00b | |||
| 785dafc3bc | |||
| 0d5370a724 | |||
| f170002c8b | |||
| 24215a8b36 | |||
| 3ca19ea875 |
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
|
*.elf
|
||||||
*.hex
|
*.hex
|
||||||
.idea/deployment.xml
|
.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"
|
||||||
77
AGENTS.md
Normal file
77
AGENTS.md
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# Onefinity firmware — agent guidelines
|
||||||
|
|
||||||
|
## Branch model
|
||||||
|
|
||||||
|
This fork lives on **two long-lived branches**:
|
||||||
|
|
||||||
|
- **`master`** — public-facing fork. General-use upgrades on top of
|
||||||
|
upstream OneFinity firmware: V09 UX redesign, Font Awesome 6, faster
|
||||||
|
cold boot, macOS dev/deploy tooling, build & flash docs, SD-card
|
||||||
|
backup, `/api/diag/timing`, kiosk/tablet polish, and assorted
|
||||||
|
bug-fixes. **No A-axis, ATC, hooks, or auxcnc/ESP content.** Aim for
|
||||||
|
changes that benefit any Onefinity owner.
|
||||||
|
|
||||||
|
- **`private-mods`** — bespoke shop branch. Stacks on top of `master`
|
||||||
|
and adds everything specific to the auxcnc-ESP-driven A axis and
|
||||||
|
the ATC: `Hooks` (ATC IPC), `AuxAxis` (ESP serial driver),
|
||||||
|
`ExternalAxis` (virtual A through gplan), `AuxPreprocessor` (M100-M103),
|
||||||
|
Z-A coupling interlock, the A-axis UI surface, and the
|
||||||
|
`/api/aux/*` endpoints.
|
||||||
|
|
||||||
|
Upstream:
|
||||||
|
- `upstream` → `https://github.com/OneFinityCNC/onefinity-firmware.git`
|
||||||
|
- `origin` → Gitea (`https://gitea.home.muehe.org/muehe/onefinity-firmware.git`)
|
||||||
|
|
||||||
|
`origin/pre-split-backup` is a tag preserving the pre-split master
|
||||||
|
tip. Keep it indefinitely until further notice.
|
||||||
|
|
||||||
|
## Where does a change go?
|
||||||
|
|
||||||
|
| Change | Branch |
|
||||||
|
|---|---|
|
||||||
|
| UI polish, theme, layout that any user benefits from | `master` |
|
||||||
|
| Build / install / boot performance | `master` |
|
||||||
|
| Diagnostics, logging, generic Python / Tornado fixes | `master` |
|
||||||
|
| Anything that touches `AuxAxis`, `ExternalAxis`, `Hooks`, `AuxPreprocessor` | `private-mods` |
|
||||||
|
| Anything mentioning the auxcnc ESP, `/dev/ttyUSB0`, the M100-M103 ATC pneumatics, or motor index 4 | `private-mods` |
|
||||||
|
| Z-A coupling interlock, ATC tool change sequencing | `private-mods` |
|
||||||
|
| A-axis UI (DRO row, jog tile, settings page, A-axis routes) | `private-mods` |
|
||||||
|
| W → A renames or aux.json migrations | `private-mods` |
|
||||||
|
|
||||||
|
When in doubt: ask "would this be useful on a stock Onefinity with no
|
||||||
|
ESP attached?" If yes → `master`. If no → `private-mods`.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Day-to-day shop / hardware work (default)
|
||||||
|
git checkout private-mods
|
||||||
|
# … do work, commit …
|
||||||
|
git push origin private-mods
|
||||||
|
|
||||||
|
# Generic improvement to master
|
||||||
|
git checkout master
|
||||||
|
# … do work, commit …
|
||||||
|
git push origin master
|
||||||
|
|
||||||
|
# After landing on master, replay private-mods on top
|
||||||
|
git checkout private-mods
|
||||||
|
git rebase master
|
||||||
|
git push --force-with-lease origin private-mods
|
||||||
|
```
|
||||||
|
|
||||||
|
If a change accidentally lands on `master` but is bespoke (touches
|
||||||
|
the file table above), move it: `git reset --hard <prev>` on master,
|
||||||
|
cherry-pick onto `private-mods`, force-push master.
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
- `./deploy.sh local` — UI bundle on `localhost:8770` (tmux session
|
||||||
|
`onefin-local`). No controller backend; A-axis row stays hidden.
|
||||||
|
- `./deploy.sh hardware` — rsync to the Pi over SSH, restart
|
||||||
|
`bbctrl.service`. Use the `private-mods` branch on the shop Pi.
|
||||||
|
- `./deploy.sh prod` — bundle a release tarball.
|
||||||
|
|
||||||
|
See `.pi/BUILD.md` for the full build / flash / cross-compile flow.
|
||||||
|
|
||||||
|
## Commit before ending a turn; push after significant changes.
|
||||||
61
CHANGELOG.md
61
CHANGELOG.md
@@ -1,6 +1,67 @@
|
|||||||
OneFinity CNC Controller Firmware Changelog
|
OneFinity CNC Controller Firmware Changelog
|
||||||
===========================================
|
===========================================
|
||||||
|
|
||||||
|
## Unreleased (community fork)
|
||||||
|
|
||||||
|
General-use additions on top of upstream OneFinity firmware.
|
||||||
|
|
||||||
|
### UI
|
||||||
|
- V09 redesign: 4-tab top header (Control / Program / Console /
|
||||||
|
Settings) replaces the legacy side menu.
|
||||||
|
- Control: redesigned DRO with per-axis offset + zero + home
|
||||||
|
actions, jog grid with consistent button sizing across kiosk
|
||||||
|
and tablet, status strip with live state / velocity / spindle.
|
||||||
|
- Program: dedicated tab for run / pause / stop, file browser,
|
||||||
|
toolpath preview.
|
||||||
|
- Console: MDI shell, message log, indicators.
|
||||||
|
- Settings: rail-driven inner pages so each section is its own
|
||||||
|
focused panel rather than one long scroll.
|
||||||
|
- Tablet mode (`?tablet=1`) pins the UI to 1920x1080 and scales
|
||||||
|
it to fit the actual viewport.
|
||||||
|
- Kiosk mode (`?kiosk=1`, auto on localhost): tighter layout for
|
||||||
|
the controller's onboard 1366x768 screen.
|
||||||
|
- Font Awesome 6 throughout (replaces FA4).
|
||||||
|
- Fix: stop clobbering motor settings while the user is editing
|
||||||
|
them.
|
||||||
|
- Fix: keep jog grid visible during jog/home/probe/MDI activity.
|
||||||
|
- Fix: opaque dark canvas for path-viewer (no flash through page
|
||||||
|
background).
|
||||||
|
- Fix: OrbitControls now uses non-passive wheel/touch listeners so
|
||||||
|
it can suppress page panning while interacting with the 3D
|
||||||
|
viewer.
|
||||||
|
- Fix: macros tab no longer renders placeholder color stripes for
|
||||||
|
`#dedede`/`#fff`-only macros.
|
||||||
|
- Fix: hide the X cursor in kiosk mode (touchscreen).
|
||||||
|
- Fix: chromium 72 mime + flex-gap fallbacks (some kiosk Pis ship
|
||||||
|
with that older browser build).
|
||||||
|
- Fix: Vue 1 async batching disabled so reactive writes from
|
||||||
|
`hashchange` listeners propagate synchronously.
|
||||||
|
|
||||||
|
### Boot / install
|
||||||
|
- Cold-boot optimisations cutting bbctrl listen latency by ~8s on
|
||||||
|
the Pi (mask sysstat, replace dphys-swapfile with an fstab swap
|
||||||
|
entry, lazy-load `camotics.gplan`, `bbserial-rebind.service`
|
||||||
|
with explicit `Before=bbctrl.service`).
|
||||||
|
- `install.sh` now ships these with firmware updates.
|
||||||
|
- `bbctrl.Trace` + `/api/diag/timing` for measuring startup, with
|
||||||
|
a UI-side `restart-timing.js` client that POSTs browser marks.
|
||||||
|
- `Camera.py` switched from deprecated `@web.asynchronous` to
|
||||||
|
`async def` so the streaming endpoint works on newer Tornado.
|
||||||
|
- `Log.py` tolerates missing rotated log files on startup
|
||||||
|
(concurrent logrotate runs from `/etc/cron.reboot` no longer
|
||||||
|
crash bbctrl).
|
||||||
|
|
||||||
|
### Build / tooling
|
||||||
|
- `.pi/BUILD.md`: end-to-end macOS dev workflow, deploy paths,
|
||||||
|
troubleshooting.
|
||||||
|
- `.pi/Dockerfile.gplan` + `build-gplan.sh`: rebuild `gplan.so`
|
||||||
|
from source on Raspbian Stretch (Bullseye is too new).
|
||||||
|
- `deploy.sh` dispatcher with `local`, `hardware`, `prod` modes.
|
||||||
|
- `backup/onefinity-backup.sh`: dd-based whole-card backup/restore
|
||||||
|
with shrink/expand support.
|
||||||
|
- `Makefile`: ensure trailing newlines between concatenated pug
|
||||||
|
templates so Pug doesn't glue file boundaries together.
|
||||||
|
|
||||||
## v1.0.8
|
## v1.0.8
|
||||||
- Fixed chatter and lost steps issues (most commonly seen by Fusion users), re-enabled support for G61, G61.1, G64.
|
- Fixed chatter and lost steps issues (most commonly seen by Fusion users), re-enabled support for G61, G61.1, G64.
|
||||||
- Fixed 3d preview on Safari-based web browsers (MacOS & iOS)
|
- Fixed 3d preview on Safari-based web browsers (MacOS & iOS)
|
||||||
|
|||||||
6
Makefile
6
Makefile
@@ -68,7 +68,11 @@ update: pkg
|
|||||||
|
|
||||||
build/templates.pug: $(TEMPLS)
|
build/templates.pug: $(TEMPLS)
|
||||||
mkdir -p build
|
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
|
node_modules: package.json
|
||||||
npm install && touch node_modules
|
npm install && touch node_modules
|
||||||
|
|||||||
111
README.md
111
README.md
@@ -1 +1,110 @@
|
|||||||
#OneFinity CNC Controller Firmware
|
# OneFinity CNC Controller Firmware (community fork)
|
||||||
|
|
||||||
|
This is the OneFinity / Buildbotics bbctrl firmware with a redesigned
|
||||||
|
UI (V09), Font Awesome 6, faster cold boot, and a streamlined macOS
|
||||||
|
dev / deploy workflow.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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/diag/timing | head
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|||||||
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
|
||||||
@@ -1,13 +1,24 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=Buildbotics Controller
|
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]
|
[Service]
|
||||||
User=root
|
User=root
|
||||||
ExecStart=/usr/local/bin/bbctrl -l /var/log/bbctrl.log
|
ExecStart=/usr/local/bin/bbctrl -l /var/log/bbctrl.log
|
||||||
WorkingDirectory=/var/lib/bbctrl
|
WorkingDirectory=/var/lib/bbctrl
|
||||||
Restart=always
|
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
|
Nice=-10
|
||||||
KillMode=process
|
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}/"
|
||||||
72
scripts/deploy/local.sh
Executable file
72
scripts/deploy/local.sh
Executable file
@@ -0,0 +1,72 @@
|
|||||||
|
#!/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.
|
||||||
|
|
||||||
|
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
|
# Update service
|
||||||
rm -f /etc/init.d/bbctrl
|
rm -f /etc/init.d/bbctrl
|
||||||
cp scripts/bbctrl.service /etc/systemd/system/
|
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 daemon-reload
|
||||||
systemctl enable bbctrl
|
systemctl enable bbctrl
|
||||||
|
systemctl enable bbserial-rebind.service
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if $UPDATE_AVR; then
|
if $UPDATE_AVR; then
|
||||||
@@ -118,8 +127,50 @@ if [ $? -ne 0 ]; then
|
|||||||
REBOOT=true
|
REBOOT=true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Install rc.local
|
# Install rc.local. Use the slimmed "fast" variant if it exists in this
|
||||||
cp scripts/rc.local /etc/
|
# 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
|
# Ensure that the watchdog python library is installed
|
||||||
pip3 list --format=columns | grep watchdog >/dev/null
|
pip3 list --format=columns | grep watchdog >/dev/null
|
||||||
|
|||||||
@@ -28,4 +28,4 @@ plymouth quit
|
|||||||
|
|
||||||
# Start X in /home/pi
|
# Start X in /home/pi
|
||||||
cd /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
|
# Enable browser in xorg
|
||||||
sed -i 's/allowed_users=console/allowed_users=anybody/' /etc/X11/Xwrapper.config
|
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/xinitrc /home/pi/.xinitrc
|
||||||
cp /mnt/host/ratpoisonrc /home/pi/.ratpoisonrc
|
cp /mnt/host/ratpoisonrc /home/pi/.ratpoisonrc
|
||||||
cp /mnt/host/xorg.conf /etc/X11/
|
cp /mnt/host/xorg.conf /etc/X11/
|
||||||
|
|||||||
2
setup.py
2
setup.py
@@ -17,7 +17,7 @@ setup(
|
|||||||
license = pkg['license'],
|
license = pkg['license'],
|
||||||
url = pkg['homepage'],
|
url = pkg['homepage'],
|
||||||
package_dir = {'': 'src/py'},
|
package_dir = {'': 'src/py'},
|
||||||
packages = ['bbctrl', 'inevent', 'lcd', 'camotics','iw_parse'],
|
packages = ['bbctrl', 'inevent', 'lcd', 'camotics', 'iw_parse'],
|
||||||
include_package_data = True,
|
include_package_data = True,
|
||||||
entry_points = {
|
entry_points = {
|
||||||
'console_scripts': [
|
'console_scripts': [
|
||||||
|
|||||||
267
src/js/app.js
267
src/js/app.js
@@ -4,6 +4,7 @@ const api = require("./api");
|
|||||||
const cookie = require("./cookie")("bbctrl-");
|
const cookie = require("./cookie")("bbctrl-");
|
||||||
const Sock = require("./sock");
|
const Sock = require("./sock");
|
||||||
const semverLt = require("semver/functions/lt");
|
const semverLt = require("semver/functions/lt");
|
||||||
|
const restartTiming = require("./restart-timing");
|
||||||
|
|
||||||
if (document.getElementById("svelte-dialog-host") != undefined) {
|
if (document.getElementById("svelte-dialog-host") != undefined) {
|
||||||
SvelteComponents.createComponent(
|
SvelteComponents.createComponent(
|
||||||
@@ -103,6 +104,17 @@ module.exports = new Vue({
|
|||||||
return {
|
return {
|
||||||
status: "connecting",
|
status: "connecting",
|
||||||
currentView: "loading",
|
currentView: "loading",
|
||||||
|
// Top-level shell tab. Mapped from the URL hash by parse_hash().
|
||||||
|
// One of: control | program | console | settings
|
||||||
|
top_tab: "control",
|
||||||
|
// Sub-route when a tab has internal pages (e.g. console:mdi,
|
||||||
|
// settings:admin-network, settings:motor:0). The settings sub
|
||||||
|
// also drives which inner view is mounted.
|
||||||
|
sub_tab: "",
|
||||||
|
sys_open: false,
|
||||||
|
has_camera: true,
|
||||||
|
messages_log: [],
|
||||||
|
messages_seen: 0,
|
||||||
display_units: localStorage.getItem("display_units") || "METRIC",
|
display_units: localStorage.getItem("display_units") || "METRIC",
|
||||||
index: -1,
|
index: -1,
|
||||||
modified: false,
|
modified: false,
|
||||||
@@ -143,22 +155,15 @@ module.exports = new Vue({
|
|||||||
estop: { template: "#estop-template" },
|
estop: { template: "#estop-template" },
|
||||||
"loading-view": { template: "<h1>Loading...</h1>" },
|
"loading-view": { template: "<h1>Loading...</h1>" },
|
||||||
"control-view": require("./control-view"),
|
"control-view": require("./control-view"),
|
||||||
"settings-view": require("./settings-view"),
|
"program-view": require("./program-view"),
|
||||||
"motor-view": require("./motor-view"),
|
"console-view": require("./console-view"),
|
||||||
"tool-view": require("./tool-view"),
|
|
||||||
"io-view": require("./io-view"),
|
// The settings-shell renders the rail + an inner routed view.
|
||||||
"admin-general-view": require("./admin-general-view"),
|
// All settings-family hashes (settings, admin-general,
|
||||||
"admin-network-view": require("./admin-network-view"),
|
// admin-network, motor:N, tool, io, macros, help, cheat-sheet)
|
||||||
"macros-view": require('./macros'),
|
// resolve to this same shell; parse_hash() sets sub_tab so the
|
||||||
"help-view": require("./help-view"),
|
// shell knows which inner template to mount.
|
||||||
"cheat-sheet-view": {
|
"settings-shell-view": require("./settings-shell-view"),
|
||||||
template: "#cheat-sheet-view-template",
|
|
||||||
data: function() {
|
|
||||||
return {
|
|
||||||
showUnimplemented: false
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
|
||||||
watch: {
|
watch: {
|
||||||
@@ -166,6 +171,25 @@ module.exports = new Vue({
|
|||||||
localStorage.setItem("display_units", value);
|
localStorage.setItem("display_units", value);
|
||||||
SvelteComponents.setDisplayUnits(value);
|
SvelteComponents.setDisplayUnits(value);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Mirror controller messages into a console log used by the
|
||||||
|
// Console > Messages tab and the header badge counter.
|
||||||
|
"state.messages": {
|
||||||
|
handler: function(messages) {
|
||||||
|
if (!Array.isArray(messages)) return;
|
||||||
|
this.messages_log = messages.map(m => ({
|
||||||
|
text: m.text,
|
||||||
|
id: m.id,
|
||||||
|
level: /^#/.test(m.text || "") ? "info" : "warning",
|
||||||
|
ts: m.ts || Date.now(),
|
||||||
|
}));
|
||||||
|
if (this.top_tab === "console" && this.sub_tab === "messages") {
|
||||||
|
this.messages_seen = this.messages_log.length;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
deep: true,
|
||||||
|
immediate: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
events: {
|
events: {
|
||||||
@@ -227,6 +251,19 @@ module.exports = new Vue({
|
|||||||
},
|
},
|
||||||
|
|
||||||
computed: {
|
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() {
|
popupMessages: function() {
|
||||||
const msgs = [];
|
const msgs = [];
|
||||||
|
|
||||||
@@ -252,18 +289,130 @@ module.exports = new Vue({
|
|||||||
enable_rotary: function() {
|
enable_rotary: function() {
|
||||||
if(this.state["2an"] == 1 || this.state["2an"] == 3) return true;
|
if(this.state["2an"] == 1 || this.state["2an"] == 3) return true;
|
||||||
return false;
|
return false;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------------- header chrome helpers ----------------
|
||||||
|
|
||||||
|
// Underlying machine state from the controller. Mirrors
|
||||||
|
// control-view's `mach_state` so the header has access without
|
||||||
|
// depending on the routed component.
|
||||||
|
mach_state: function() {
|
||||||
|
const cycle = this.state.cycle;
|
||||||
|
const xx = this.state.xx;
|
||||||
|
|
||||||
|
if (xx != "ESTOPPED" && (cycle == "jogging" || cycle == "homing")) {
|
||||||
|
return cycle.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return xx || "";
|
||||||
|
},
|
||||||
|
|
||||||
|
// Short text for the READY pill in the header.
|
||||||
|
state_label: function() {
|
||||||
|
const s = this.mach_state;
|
||||||
|
if (!s) return "--";
|
||||||
|
return s;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Class added to the READY pill (.state-badge) so styling can
|
||||||
|
// reflect ready / running / holding / fault / estop.
|
||||||
|
state_class: function() {
|
||||||
|
const s = this.mach_state;
|
||||||
|
if (s == "ESTOPPED" || s == "FAULT" || s == "SHUTDOWN") return "bad";
|
||||||
|
if (s == "HOLDING" || s == "STOPPING") return "warn";
|
||||||
|
if (s == "RUNNING" || s == "HOMING" || s == "JOGGING") return "busy";
|
||||||
|
if (s == "READY") return "ok";
|
||||||
|
return "unknown";
|
||||||
|
},
|
||||||
|
|
||||||
|
mach_state_full: function() {
|
||||||
|
const s = this.mach_state;
|
||||||
|
if (s == "ESTOPPED") return "E-Stopped \u2014 release to clear";
|
||||||
|
if (s == "HOLDING") return "Feed hold (" + (this.state.pr || "paused") + ")";
|
||||||
|
if (s == "RUNNING") return "Running program";
|
||||||
|
if (s == "HOMING") return "Homing axes";
|
||||||
|
if (s == "JOGGING") return "Jogging";
|
||||||
|
if (s == "READY") return "Ready";
|
||||||
|
return s;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Pip color for the unified system pill.
|
||||||
|
sys_class: function() {
|
||||||
|
const wifi_off = !this.config.wifiName || this.config.wifiName == "not connected";
|
||||||
|
const cam_off = !this.has_camera;
|
||||||
|
const hot = this.state && 80 <= this.state.rpi_temp;
|
||||||
|
if (hot) return "red";
|
||||||
|
if (wifi_off || cam_off) return "amber";
|
||||||
|
return "green";
|
||||||
|
},
|
||||||
|
|
||||||
|
// Compact summary for the system pill.
|
||||||
|
sys_summary: function() {
|
||||||
|
const issues = [];
|
||||||
|
if (!this.config.wifiName || this.config.wifiName == "not connected") {
|
||||||
|
issues.push("WiFi off");
|
||||||
|
}
|
||||||
|
if (!this.has_camera) issues.push("Camera offline");
|
||||||
|
if (this.state && 80 <= this.state.rpi_temp) issues.push("Pi hot");
|
||||||
|
if (this.is_rotary_active) issues.push("Rotary");
|
||||||
|
if (issues.length === 0) return "All systems";
|
||||||
|
if (issues.length === 1) return issues[0];
|
||||||
|
return issues.length + " notes";
|
||||||
|
},
|
||||||
|
|
||||||
|
// Number of unread Console > Messages entries.
|
||||||
|
messages_count: function() {
|
||||||
|
return Math.max(0, this.messages_log.length - this.messages_seen);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
ready: function() {
|
ready: function() {
|
||||||
window.onhashchange = () => this.parse_hash();
|
window.onhashchange = () => this.parse_hash();
|
||||||
|
|
||||||
|
// 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",
|
||||||
|
];
|
||||||
|
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();
|
this.connect();
|
||||||
|
|
||||||
|
// Close the system popover when clicking anywhere else.
|
||||||
|
document.addEventListener("click", () => {
|
||||||
|
if (this.sys_open) this.sys_open = false;
|
||||||
|
});
|
||||||
|
|
||||||
SvelteComponents.registerControllerMethods({
|
SvelteComponents.registerControllerMethods({
|
||||||
dispatch: (...args) => this.$dispatch(...args)
|
dispatch: (...args) => this.$dispatch(...args)
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
block_error_dialog: function() {
|
block_error_dialog: function() {
|
||||||
this.errorTimeoutStart = Date.now();
|
this.errorTimeoutStart = Date.now();
|
||||||
@@ -338,6 +487,12 @@ module.exports = new Vue({
|
|||||||
toggle_rotary: async function(isActive) {
|
toggle_rotary: async function(isActive) {
|
||||||
try {
|
try {
|
||||||
await api.put("rotary", {status: isActive});
|
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) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
alert("Error occured");
|
alert("Error occured");
|
||||||
@@ -372,11 +527,19 @@ module.exports = new Vue({
|
|||||||
connect: function() {
|
connect: function() {
|
||||||
this.sock = new Sock(`//${location.host}/sockjs`);
|
this.sock = new Sock(`//${location.host}/sockjs`);
|
||||||
|
|
||||||
|
let _gotFirstMsg = false;
|
||||||
|
let _gotFirstState = false;
|
||||||
|
|
||||||
this.sock.onmessage = (e) => {
|
this.sock.onmessage = (e) => {
|
||||||
if (typeof e.data != "object") {
|
if (typeof e.data != "object") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!_gotFirstMsg) {
|
||||||
|
_gotFirstMsg = true;
|
||||||
|
restartTiming.onWsFirstMessage();
|
||||||
|
}
|
||||||
|
|
||||||
if (e.data.log && e.data.log.msg !== "Switch not found") {
|
if (e.data.log && e.data.log.msg !== "Switch not found") {
|
||||||
this.$broadcast("log", e.data.log);
|
this.$broadcast("log", e.data.log);
|
||||||
|
|
||||||
@@ -386,6 +549,11 @@ module.exports = new Vue({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!_gotFirstState) {
|
||||||
|
_gotFirstState = true;
|
||||||
|
restartTiming.onFirstState();
|
||||||
|
}
|
||||||
|
|
||||||
// Check for session ID change on controller
|
// Check for session ID change on controller
|
||||||
if ("sid" in e.data) {
|
if ("sid" in e.data) {
|
||||||
if (typeof this.sid == "undefined") {
|
if (typeof this.sid == "undefined") {
|
||||||
@@ -410,6 +578,7 @@ module.exports = new Vue({
|
|||||||
|
|
||||||
this.sock.onopen = () => {
|
this.sock.onopen = () => {
|
||||||
this.status = "connected";
|
this.status = "connected";
|
||||||
|
restartTiming.onWsOpen();
|
||||||
this.$emit(this.status);
|
this.$emit(this.status);
|
||||||
this.$broadcast(this.status);
|
this.$broadcast(this.status);
|
||||||
};
|
};
|
||||||
@@ -421,6 +590,21 @@ module.exports = new Vue({
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Maps a URL hash to (currentView, top_tab, sub_tab, index).
|
||||||
|
// Hash layouts supported (all kept for backward compat):
|
||||||
|
// #control -> control tab
|
||||||
|
// #program[:auto] -> program tab
|
||||||
|
// #console[:mdi|messages|indicators]
|
||||||
|
// -> console tab
|
||||||
|
// #settings -> settings tab home
|
||||||
|
// #admin-general -> settings tab, admin-general inside
|
||||||
|
// #admin-network -> settings tab, admin-network inside
|
||||||
|
// #motor:0..3 -> settings tab, motor 0..3
|
||||||
|
// #tool -> settings tab, tool view
|
||||||
|
// #io -> settings tab, io view
|
||||||
|
// #macros -> settings tab, macros view
|
||||||
|
// #help -> settings tab, help view
|
||||||
|
// #cheat-sheet -> settings tab, cheat sheet view
|
||||||
parse_hash: function() {
|
parse_hash: function() {
|
||||||
const hash = location.hash.substr(1);
|
const hash = location.hash.substr(1);
|
||||||
|
|
||||||
@@ -430,12 +614,57 @@ module.exports = new Vue({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const parts = hash.split(":");
|
const parts = hash.split(":");
|
||||||
|
const head = parts[0];
|
||||||
|
|
||||||
if (parts.length == 2) {
|
this.index = parts.length > 1 ? parts[1] : -1;
|
||||||
this.index = parts[1];
|
|
||||||
|
// Legacy / settings-managed views resolve under the
|
||||||
|
// Settings tab while keeping their existing top-level
|
||||||
|
// hash. This preserves all existing deep links.
|
||||||
|
const settingsViews = [
|
||||||
|
"settings", "probing", "gcode",
|
||||||
|
"admin-general", "admin-network",
|
||||||
|
"motor", "tool", "io", "macros",
|
||||||
|
"help", "cheat-sheet",
|
||||||
|
];
|
||||||
|
|
||||||
|
if (head == "control") {
|
||||||
|
this.top_tab = "control";
|
||||||
|
this.sub_tab = "";
|
||||||
|
this.currentView = "control";
|
||||||
|
} else if (head == "program") {
|
||||||
|
this.top_tab = "program";
|
||||||
|
this.sub_tab = parts[1] || "auto";
|
||||||
|
this.currentView = "program";
|
||||||
|
} else if (head == "console") {
|
||||||
|
this.top_tab = "console";
|
||||||
|
this.sub_tab = parts[1] || "mdi";
|
||||||
|
this.currentView = "console";
|
||||||
|
} else if (settingsViews.indexOf(head) !== -1) {
|
||||||
|
this.top_tab = "settings";
|
||||||
|
this.sub_tab = head;
|
||||||
|
// All settings-family routes mount the same shell;
|
||||||
|
// shell picks inner view from sub_tab. Vary the
|
||||||
|
// currentView token so Vue 1 fully remounts the
|
||||||
|
// shell on every navigation — this avoids stale :class
|
||||||
|
// bindings against the local `sub` data prop.
|
||||||
|
this.currentView = "settings-shell";
|
||||||
|
} else {
|
||||||
|
// Unknown hash: route to settings shell anyway so we
|
||||||
|
// never end up rendering a bare loading screen.
|
||||||
|
this.top_tab = "settings";
|
||||||
|
this.sub_tab = head;
|
||||||
|
this.currentView = "settings-shell";
|
||||||
}
|
}
|
||||||
|
|
||||||
this.currentView = parts[0];
|
// Mark Console messages as seen when we enter that tab.
|
||||||
|
if (this.top_tab == "console" && this.sub_tab == "messages") {
|
||||||
|
this.messages_seen = this.messages_log.length;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
toggle_sys_popover: function() {
|
||||||
|
this.sys_open = !this.sys_open;
|
||||||
},
|
},
|
||||||
|
|
||||||
save: async function() {
|
save: async function() {
|
||||||
|
|||||||
125
src/js/console-view.js
Normal file
125
src/js/console-view.js
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const api = require("./api");
|
||||||
|
|
||||||
|
// Console tab — MDI command input, message log and live indicators.
|
||||||
|
// Sub-tab state syncs with the URL hash (#console:mdi |
|
||||||
|
// #console:messages | #console:indicators) so deep links work.
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
template: "#console-view-template",
|
||||||
|
props: ["config", "template", "state"],
|
||||||
|
|
||||||
|
data: function () {
|
||||||
|
return {
|
||||||
|
mdi: "",
|
||||||
|
history: [],
|
||||||
|
sub: "mdi",
|
||||||
|
// Local mirror of $root.messages_count so Vue 1 reactivity works.
|
||||||
|
unread_messages_local: 0,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
watch: {
|
||||||
|
sub: function () {
|
||||||
|
// Switching to messages marks them as seen so the header badge
|
||||||
|
// clears.
|
||||||
|
if (this.sub === "messages") {
|
||||||
|
this.$root.messages_seen = this.$root.messages_log.length;
|
||||||
|
this.unread_messages_local = 0;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
unread_messages: function () {
|
||||||
|
return this.unread_messages_local;
|
||||||
|
},
|
||||||
|
|
||||||
|
mach_state: function () {
|
||||||
|
const cycle = this.state.cycle;
|
||||||
|
const xx = this.state.xx;
|
||||||
|
if (xx != "ESTOPPED" && (cycle == "jogging" || cycle == "homing")) {
|
||||||
|
return cycle.toUpperCase();
|
||||||
|
}
|
||||||
|
return xx || "";
|
||||||
|
},
|
||||||
|
|
||||||
|
is_idle: function () { return this.state.cycle == "idle"; },
|
||||||
|
|
||||||
|
can_mdi: function () {
|
||||||
|
return this.is_idle || this.state.cycle == "mdi";
|
||||||
|
},
|
||||||
|
|
||||||
|
mach_units: function () {
|
||||||
|
return this.$root.display_units;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
ready: function () {
|
||||||
|
this._onHash = () => this.refresh_from_hash();
|
||||||
|
window.addEventListener("hashchange", this._onHash);
|
||||||
|
this.refresh_from_hash();
|
||||||
|
this._poll = setInterval(() => {
|
||||||
|
// Cheap re-poll for unread message count; Vue 1 cannot observe
|
||||||
|
// `$root.messages_count` directly so we mirror it here.
|
||||||
|
const c = this.$root && this.$root.messages_count;
|
||||||
|
if (typeof c === "number" && c !== this.unread_messages_local) {
|
||||||
|
this.unread_messages_local = c;
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
},
|
||||||
|
|
||||||
|
beforeDestroy: function () {
|
||||||
|
if (this._onHash) window.removeEventListener("hashchange", this._onHash);
|
||||||
|
if (this._poll) clearInterval(this._poll);
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
refresh_from_hash: function () {
|
||||||
|
const hash = location.hash.substr(1);
|
||||||
|
const parts = hash.split(":");
|
||||||
|
const sub = parts[0] === "console" ? (parts[1] || "mdi") : "mdi";
|
||||||
|
this.sub = sub;
|
||||||
|
if (sub === "messages" && this.$root) {
|
||||||
|
this.$root.messages_seen = this.$root.messages_log.length;
|
||||||
|
this.unread_messages_local = 0;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
select_sub: function (name) {
|
||||||
|
this.sub = name;
|
||||||
|
// Update URL hash for deep links / back-button.
|
||||||
|
const h = "#console" + (name && name !== "mdi" ? ":" + name : "");
|
||||||
|
if (location.hash !== h) {
|
||||||
|
history.replaceState(null, "", h);
|
||||||
|
}
|
||||||
|
if (name === "messages") {
|
||||||
|
this.$root.messages_seen = this.$root.messages_log.length;
|
||||||
|
this.unread_messages_local = 0;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
prepend: function (token) {
|
||||||
|
this.mdi = token + this.mdi.trimStart();
|
||||||
|
},
|
||||||
|
|
||||||
|
append: function (token) {
|
||||||
|
const tail = this.mdi.endsWith(" ") || !this.mdi ? "" : " ";
|
||||||
|
this.mdi = this.mdi + tail + token;
|
||||||
|
},
|
||||||
|
|
||||||
|
submit_mdi: function () {
|
||||||
|
if (!this.mdi) return;
|
||||||
|
this.$dispatch("send", this.mdi);
|
||||||
|
if (!this.history.length || this.history[0] != this.mdi) {
|
||||||
|
this.history.unshift(this.mdi);
|
||||||
|
}
|
||||||
|
this.mdi = "";
|
||||||
|
},
|
||||||
|
|
||||||
|
load_history: function (index) {
|
||||||
|
this.mdi = this.history[index];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
const api = require("./api");
|
const api = require("./api");
|
||||||
const utils = require("./utils");
|
|
||||||
const cookie = require("./cookie")("bbctrl-");
|
const cookie = require("./cookie")("bbctrl-");
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
@@ -12,15 +11,7 @@ module.exports = {
|
|||||||
return {
|
return {
|
||||||
current_time: "",
|
current_time: "",
|
||||||
mach_units: this.$root.state.metric ? "METRIC" : "IMPERIAL",
|
mach_units: this.$root.state.metric ? "METRIC" : "IMPERIAL",
|
||||||
mdi: "",
|
|
||||||
last_file: undefined,
|
|
||||||
last_file_time: undefined,
|
|
||||||
toolpath: {},
|
|
||||||
toolpath_progress: 0,
|
|
||||||
axes: "xyzabc",
|
axes: "xyzabc",
|
||||||
history: [],
|
|
||||||
speed_override: 1,
|
|
||||||
feed_override: 1,
|
|
||||||
jog_incr_amounts: {
|
jog_incr_amounts: {
|
||||||
METRIC: {
|
METRIC: {
|
||||||
fine: 0.1,
|
fine: 0.1,
|
||||||
@@ -38,34 +29,14 @@ module.exports = {
|
|||||||
jog_incr: localStorage.getItem("jog_incr") || "small",
|
jog_incr: localStorage.getItem("jog_incr") || "small",
|
||||||
jog_step: cookie.get_bool("jog-step"),
|
jog_step: cookie.get_bool("jog-step"),
|
||||||
jog_adjust: parseInt(cookie.get("jog-adjust", 2)),
|
jog_adjust: parseInt(cookie.get("jog-adjust", 2)),
|
||||||
deleteGCode: false,
|
|
||||||
tab: "auto",
|
|
||||||
ask_home: true,
|
ask_home: true,
|
||||||
folder_name: "",
|
|
||||||
edited: false,
|
|
||||||
uploading_files: false,
|
|
||||||
confirmDelete: false,
|
|
||||||
create_folder: false,
|
|
||||||
showGcodeMessage: false,
|
|
||||||
showNoGcodeMessage: false,
|
|
||||||
macrosLoading: false,
|
|
||||||
show_gcodes: false,
|
|
||||||
GCodeNotFound: false,
|
|
||||||
show_probe_dialog: false,
|
show_probe_dialog: false,
|
||||||
filesUploaded: 0,
|
overrides_open: false,
|
||||||
totalFiles: 0,
|
|
||||||
files_sortby: "By Upload Date",
|
|
||||||
selected_items_to_delete: [],
|
|
||||||
search_query: "",
|
|
||||||
filtered_files: [],
|
|
||||||
selected_folder_index: null,
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
components: {
|
components: {
|
||||||
"axis-control": require("./axis-control"),
|
"axis-control": require("./axis-control"),
|
||||||
"path-viewer": require("./path-viewer"),
|
|
||||||
"gcode-viewer": require("./gcode-viewer"),
|
|
||||||
},
|
},
|
||||||
|
|
||||||
watch: {
|
watch: {
|
||||||
@@ -80,16 +51,6 @@ module.exports = {
|
|||||||
immediate: true,
|
immediate: true,
|
||||||
},
|
},
|
||||||
|
|
||||||
"state.line": function () {
|
|
||||||
if (this.mach_state != "HOMING") {
|
|
||||||
this.$broadcast("gcode-line", this.state.line);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
"state.selected_time": function () {
|
|
||||||
this.load();
|
|
||||||
},
|
|
||||||
|
|
||||||
jog_step: function () {
|
jog_step: function () {
|
||||||
cookie.set_bool("jog-step", this.jog_step);
|
cookie.set_bool("jog-step", this.jog_step);
|
||||||
},
|
},
|
||||||
@@ -127,43 +88,16 @@ module.exports = {
|
|||||||
return state || "";
|
return state || "";
|
||||||
},
|
},
|
||||||
|
|
||||||
pause_reason: function () {
|
can_set_axis: function () {
|
||||||
return this.state.pr;
|
return this.state.cycle == "idle";
|
||||||
},
|
|
||||||
|
|
||||||
is_running: function () {
|
|
||||||
return this.mach_state == "RUNNING" || this.mach_state == "HOMING";
|
|
||||||
},
|
|
||||||
|
|
||||||
is_stopping: function () {
|
|
||||||
return this.mach_state == "STOPPING";
|
|
||||||
},
|
|
||||||
|
|
||||||
is_holding: function () {
|
|
||||||
return this.mach_state == "HOLDING";
|
|
||||||
},
|
|
||||||
|
|
||||||
is_ready: function () {
|
|
||||||
return this.mach_state == "READY";
|
|
||||||
},
|
},
|
||||||
|
|
||||||
is_idle: function () {
|
is_idle: function () {
|
||||||
return this.state.cycle == "idle";
|
return this.state.cycle == "idle";
|
||||||
},
|
},
|
||||||
|
|
||||||
is_paused: function () {
|
is_ready: function () {
|
||||||
return this.is_holding && (this.pause_reason == "User pause" || this.pause_reason == "Program pause");
|
return this.mach_state == "READY";
|
||||||
},
|
|
||||||
|
|
||||||
can_mdi: function () {
|
|
||||||
return this.is_idle || this.state.cycle == "mdi";
|
|
||||||
},
|
|
||||||
|
|
||||||
can_set_axis: function () {
|
|
||||||
return this.is_idle;
|
|
||||||
|
|
||||||
// TODO allow setting axis position during pause
|
|
||||||
// return this.is_idle || this.is_paused;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
message: function () {
|
message: function () {
|
||||||
@@ -191,57 +125,21 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
plan_time_remaining: function () {
|
plan_time_remaining: function () {
|
||||||
if (!(this.is_stopping || this.is_running || this.is_holding)) {
|
const stopping = this.mach_state == "STOPPING";
|
||||||
return 0;
|
const running = this.mach_state == "RUNNING" || this.mach_state == "HOMING";
|
||||||
}
|
const holding = this.mach_state == "HOLDING";
|
||||||
|
if (!(stopping || running || holding)) return 0;
|
||||||
return this.toolpath.time - this.plan_time;
|
const tp = this.$root && this.$root.toolpath ? this.$root.toolpath.time : 0;
|
||||||
|
return (tp || 0) - this.plan_time;
|
||||||
},
|
},
|
||||||
|
|
||||||
eta: function () {
|
state_kpi_class: function () {
|
||||||
if (this.mach_state != "RUNNING") {
|
const s = this.mach_state;
|
||||||
|
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 "";
|
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();
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -264,14 +162,9 @@ module.exports = {
|
|||||||
M72
|
M72
|
||||||
`);
|
`);
|
||||||
},
|
},
|
||||||
folder_name_edited: function () {
|
|
||||||
this.edited = true;
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
|
||||||
ready: function () {
|
ready: function () {
|
||||||
this.load();
|
|
||||||
|
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
this.current_time = new Date().toLocaleTimeString();
|
this.current_time = new Date().toLocaleTimeString();
|
||||||
}, 1000);
|
}, 1000);
|
||||||
@@ -287,28 +180,39 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
save_config: async function (config) {
|
|
||||||
try {
|
|
||||||
await api.put("config/save", config);
|
|
||||||
this.$dispatch("update");
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Restore Failed: ", error);
|
|
||||||
alert("Restore failed");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
populateFiles(index) {
|
|
||||||
this.selected_folder_index = index;
|
|
||||||
this.filtered_files = this.state.gcode_list[index].files.map(item => item.file_name);
|
|
||||||
},
|
|
||||||
|
|
||||||
getJogIncrStyle(value) {
|
getJogIncrStyle(value) {
|
||||||
const weight = `font-weight:${this.jog_incr === value ? "bold" : "normal"}`;
|
const weight = `font-weight:${this.jog_incr === value ? "bold" : "normal"}`;
|
||||||
const color = this.jog_incr === value ? "color:#0078e7" : "";
|
const color = this.jog_incr === value ? "color:#0078e7" : "";
|
||||||
|
|
||||||
return [weight, color].join(";");
|
return [weight, color].join(";");
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 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) {
|
jog_fn: function (x_jog, y_jog, z_jog, a_jog) {
|
||||||
const amount = this.jog_incr_amounts[this.display_units][this.jog_incr];
|
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) {
|
home: function (axis) {
|
||||||
this.ask_home = false;
|
this.ask_home = false;
|
||||||
|
|
||||||
@@ -765,6 +249,15 @@ module.exports = {
|
|||||||
api.put(`home/${axis}/clear`);
|
api.put(`home/${axis}/clear`);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
home_all: async function () {
|
||||||
|
this.ask_home = false;
|
||||||
|
try {
|
||||||
|
await api.put("home");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Home all failed:", e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
show_set_position: function (axis) {
|
show_set_position: function (axis) {
|
||||||
SvelteComponents.showDialog("SetAxisPosition", { axis });
|
SvelteComponents.showDialog("SetAxisPosition", { axis });
|
||||||
},
|
},
|
||||||
@@ -790,93 +283,20 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
zero: function (axis) {
|
zero: function (axis) {
|
||||||
if (typeof axis == "undefined") {
|
if (typeof axis == "undefined") this.zero_all();
|
||||||
this.zero_all();
|
else this.set_position(axis, 0);
|
||||||
} else {
|
|
||||||
this.set_position(axis, 0);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
start_pause: function () {
|
|
||||||
this.macrosLoading = false;
|
|
||||||
if (this.state.xx == "RUNNING") {
|
|
||||||
this.pause();
|
|
||||||
} else if (this.state.xx == "STOPPING" || this.state.xx == "HOLDING") {
|
|
||||||
this.unpause();
|
|
||||||
} else {
|
|
||||||
this.start();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
start: function () {
|
|
||||||
api.put("start");
|
|
||||||
},
|
|
||||||
|
|
||||||
pause: function () {
|
|
||||||
api.put("pause");
|
|
||||||
},
|
|
||||||
|
|
||||||
unpause: function () {
|
|
||||||
api.put("unpause");
|
|
||||||
},
|
|
||||||
|
|
||||||
optional_pause: function () {
|
|
||||||
api.put("pause/optional");
|
|
||||||
},
|
|
||||||
|
|
||||||
stop: function () {
|
|
||||||
api.put("stop");
|
|
||||||
},
|
|
||||||
|
|
||||||
step: function () {
|
|
||||||
api.put("step");
|
|
||||||
},
|
|
||||||
|
|
||||||
override_feed: function () {
|
|
||||||
api.put(`override/feed/${this.feed_override}`);
|
|
||||||
},
|
|
||||||
|
|
||||||
override_speed: function () {
|
|
||||||
api.put(`override/speed/${this.speed_override}`);
|
|
||||||
},
|
|
||||||
|
|
||||||
current: function (axis, value) {
|
|
||||||
const x = value / 32.0;
|
|
||||||
if (this.state[`${axis}pl`] == x) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = {};
|
|
||||||
data[`${axis}pl`] = x;
|
|
||||||
this.send(JSON.stringify(data));
|
|
||||||
},
|
},
|
||||||
|
|
||||||
showProbeDialog: function (probeType) {
|
showProbeDialog: function (probeType) {
|
||||||
if(this.show_probe_dialog){
|
if (this.show_probe_dialog) {
|
||||||
this.show_probe_dialog = false;
|
this.show_probe_dialog = false;
|
||||||
}
|
}
|
||||||
SvelteComponents.showDialog("Probe", { probeType, isRotaryActive: this.state["2an"] == 3 });
|
SvelteComponents.showDialog("Probe", {
|
||||||
},
|
probeType,
|
||||||
run_macro: function (id) {
|
isRotaryActive: this.state["2an"] == 3,
|
||||||
if (this.state.macros[id].file_name == "default") {
|
});
|
||||||
this.showNoGcodeMessage = true;
|
|
||||||
} else {
|
|
||||||
if (this.state.macros[id].file_name != this.state.selected) {
|
|
||||||
this.state.selected = this.state.macros[id].file_name;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
this.load();
|
|
||||||
if (this.state.macros[id].alert == true) {
|
|
||||||
this.macrosLoading = true;
|
|
||||||
} else {
|
|
||||||
setImmediate(() => this.start_pause());
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn("Error running program: ", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
mixins: [require("./axis-vars")],
|
mixins: [require("./program-mixin"), require("./axis-vars")],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -49,14 +49,17 @@ module.exports = {
|
|||||||
methods: {
|
methods: {
|
||||||
get_io_state_class: function(active, state) {
|
get_io_state_class: function(active, state) {
|
||||||
if (typeof active == "undefined" || typeof state == "undefined") {
|
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) {
|
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"}`;
|
return `${icon} ${active ? "active" : "inactive"}`;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,16 @@ window.onload = function() {
|
|||||||
cookie_set("client-id", uuid(), 10000);
|
cookie_set("client-id", uuid(), 10000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Vue 1's async queue can drop dependent watcher updates when
|
||||||
|
// data props are mutated outside the normal event flow (e.g. from
|
||||||
|
// a `hashchange` listener that fires before Vue's tick scheduler
|
||||||
|
// has caught up). Disable async batching so every reactive write
|
||||||
|
// synchronously re-evaluates dependents — this matches Vue 1's
|
||||||
|
// older default and is what the legacy UI implicitly relied on.
|
||||||
|
if (Vue.config) {
|
||||||
|
Vue.config.async = false;
|
||||||
|
}
|
||||||
|
|
||||||
// Register global components
|
// Register global components
|
||||||
Vue.component("templated-input", require("./templated-input"));
|
Vue.component("templated-input", require("./templated-input"));
|
||||||
Vue.component("message", require("./message"));
|
Vue.component("message", require("./message"));
|
||||||
|
|||||||
@@ -87,100 +87,16 @@ module.exports = {
|
|||||||
return this.stallRPM * this.stepsPerRev * ustep / 60;
|
return this.stallRPM * this.stepsPerRev * ustep / 60;
|
||||||
},
|
},
|
||||||
|
|
||||||
current_axis: function() {
|
// NOTE: do not add `current_xxx` computed props that mirror
|
||||||
return this.state[this.index + 'an'];
|
// 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;
|
||||||
current_max_velocity: function() {
|
// any watcher that writes them back into
|
||||||
return this.state[this.index + 'vm'];
|
// `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
|
||||||
current_max_soft_limit: function() {
|
// server. The server-side rotary toggle is handled by
|
||||||
return this.state[this.index + 'tm'];
|
// refetching config after the PUT, not by watching state.
|
||||||
},
|
|
||||||
|
|
||||||
current_min_soft_limit: function() {
|
|
||||||
return this.state[this.index + 'tn'];
|
|
||||||
},
|
|
||||||
current_max_accel: function() {
|
|
||||||
return this.state[this.index + 'am'];
|
|
||||||
},
|
|
||||||
current_max_jerk: function() {
|
|
||||||
return this.state[this.index + 'jm'];
|
|
||||||
},
|
|
||||||
current_step_angle: function() {
|
|
||||||
return this.state[this.index + 'sa'];
|
|
||||||
},
|
|
||||||
current_travel_per_rev: function() {
|
|
||||||
return this.state[this.index + 'tr'];
|
|
||||||
},
|
|
||||||
current_microsteps: function() {
|
|
||||||
return this.state[this.index + 'mi'];
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
attached: function() {
|
|
||||||
// Sync all state values with motor config when component is ready
|
|
||||||
// This ensures UI shows correct values when component is first loaded
|
|
||||||
console.log("Syncing state to motor config for motor index ",this.index);
|
|
||||||
this.syncStateToConfig();
|
|
||||||
},
|
|
||||||
|
|
||||||
watch: {
|
|
||||||
current_axis(new_value) {
|
|
||||||
const motor_axes = ["X", "Y", "Z", "A", "B", "C"]
|
|
||||||
if(motor_axes[new_value] != this.motor['axis']){
|
|
||||||
this.motor['axis'] = motor_axes[new_value];
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
current_max_velocity(new_value) {
|
|
||||||
if(new_value != this.motor['max-velocity']) {
|
|
||||||
this.motor['max-velocity'] = new_value;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
current_max_soft_limit(new_value) {
|
|
||||||
if(new_value != this.motor['max-soft-limit']) {
|
|
||||||
this.motor['max-soft-limit'] = new_value;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
current_min_soft_limit(new_value) {
|
|
||||||
if(new_value != this.motor['min-soft-limit']) {
|
|
||||||
this.motor['min-soft-limit'] = new_value;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
current_max_accel(new_value) {
|
|
||||||
if(new_value != this.motor['max-accel']) {
|
|
||||||
this.motor['max-accel'] = new_value;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
current_max_jerk(new_value) {
|
|
||||||
if(new_value != this.motor['max-jerk']) {
|
|
||||||
this.motor['max-jerk'] = new_value;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
current_step_angle(new_value) {
|
|
||||||
if(new_value != this.motor['step-angle']) {
|
|
||||||
this.motor['step-angle'] = new_value;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
current_travel_per_rev(new_value) {
|
|
||||||
if(new_value != this.motor['travel-per-rev']) {
|
|
||||||
this.motor['travel-per-rev'] = new_value;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
current_microsteps(new_value) {
|
|
||||||
if(new_value != this.motor['microsteps']) {
|
|
||||||
this.motor['microsteps'] = new_value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
events: {
|
events: {
|
||||||
@@ -210,45 +126,6 @@ module.exports = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return templ.hmodes.indexOf(this.motor["homing-mode"]) != -1;
|
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();
|
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("contextmenu", onContextMenu, false);
|
||||||
scope.domElement.addEventListener("mousedown", onMouseDown, false);
|
scope.domElement.addEventListener("mousedown", onMouseDown, false);
|
||||||
scope.domElement.addEventListener("wheel", onMouseWheel, false);
|
scope.domElement.addEventListener("wheel", onMouseWheel, { passive: false });
|
||||||
scope.domElement.addEventListener("touchstart", onTouchStart, false);
|
scope.domElement.addEventListener("touchstart", onTouchStart, { passive: false });
|
||||||
scope.domElement.addEventListener("touchend", onTouchEnd, 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);
|
window.addEventListener("keydown", onKeyDown, false);
|
||||||
|
|
||||||
this.update(); // force an update at start
|
this.update(); // force an update at start
|
||||||
|
|||||||
@@ -101,6 +101,13 @@ module.exports = {
|
|||||||
Vue.nextTick(this.update);
|
Vue.nextTick(this.update);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
beforeDestroy: function() {
|
||||||
|
if (this._sizeWatcher) {
|
||||||
|
this._sizeWatcher.disconnect();
|
||||||
|
this._sizeWatcher = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
update: async function() {
|
update: async function() {
|
||||||
if (!this.webglAvailable) {
|
if (!this.webglAvailable) {
|
||||||
@@ -201,6 +208,12 @@ module.exports = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const dims = this.get_dims();
|
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.aspect = dims.width / dims.height;
|
||||||
this.camera.updateProjectionMatrix();
|
this.camera.updateProjectionMatrix();
|
||||||
@@ -274,12 +287,23 @@ module.exports = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Renderer
|
// Renderer. Use an opaque canvas with a clear color
|
||||||
this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
// 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.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);
|
this.target.appendChild(this.renderer.domElement);
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log("WebGL not supported: ", e);
|
console.log("WebGL not supported: ", e);
|
||||||
return;
|
return;
|
||||||
@@ -333,8 +357,46 @@ module.exports = {
|
|||||||
// Events
|
// Events
|
||||||
window.addEventListener("resize", this.update_view, false);
|
window.addEventListener("resize", this.update_view, false);
|
||||||
|
|
||||||
// Start it
|
// 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();
|
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() {
|
create_surface_material: function() {
|
||||||
@@ -646,6 +708,14 @@ module.exports = {
|
|||||||
return;
|
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) {
|
if (this.controls.update() || this.dirty) {
|
||||||
this.dirty = false;
|
this.dirty = false;
|
||||||
this.renderer.render(this.scene, this.camera);
|
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,
|
||||||
|
};
|
||||||
169
src/js/settings-shell-view.js
Normal file
169
src/js/settings-shell-view.js
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
// Wrapper that adds a left-rail navigator around the settings family
|
||||||
|
// of views (Settings, Admin General, Admin Network, Tool, IO, Motor,
|
||||||
|
// Macros, Help, Cheat Sheet). The inner view is selected by the URL
|
||||||
|
// hash (parsed in app.js) and exposed as $root.sub_tab.
|
||||||
|
|
||||||
|
// Vue 1 has trouble making child components reactive to `$root.sub_tab`
|
||||||
|
// changes (whether via computed, watch, or prop binding through
|
||||||
|
// `<component :is>`). The shell instead listens to `hashchange`
|
||||||
|
// directly and parses the hash itself, mirroring app.js's logic, then
|
||||||
|
// keeps a local data prop `sub` that the template binds to. This is
|
||||||
|
// the only path that updates the rail's `:class` reactively.
|
||||||
|
module.exports = {
|
||||||
|
template: "#settings-shell-view-template",
|
||||||
|
props: ["config", "template", "state", "index"],
|
||||||
|
|
||||||
|
components: {
|
||||||
|
"settings-view-inner": require("./settings-view"),
|
||||||
|
"admin-general-view": require("./admin-general-view"),
|
||||||
|
"admin-network-view": require("./admin-network-view"),
|
||||||
|
"motor-view": require("./motor-view"),
|
||||||
|
"tool-view": require("./tool-view"),
|
||||||
|
"io-view": require("./io-view"),
|
||||||
|
"macros-view": require("./macros"),
|
||||||
|
"help-view": require("./help-view"),
|
||||||
|
"cheat-sheet-view": {
|
||||||
|
template: "#cheat-sheet-view-template",
|
||||||
|
data: function () {
|
||||||
|
return { showUnimplemented: false };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
data: function () {
|
||||||
|
return {
|
||||||
|
sub: this.$root.sub_tab || "settings",
|
||||||
|
ridx: this.$root.index, // local copy of the motor index
|
||||||
|
// 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" },
|
||||||
|
{ 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;
|
||||||
|
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 {
|
||||||
|
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 = {
|
module.exports = {
|
||||||
template: "#settings-view-template",
|
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(
|
this.svelteComponent = SvelteComponents.createComponent(
|
||||||
"SettingsView",
|
"SettingsView",
|
||||||
document.getElementById("settings")
|
document.getElementById("settings")
|
||||||
);
|
);
|
||||||
|
// Defer one tick so Svelte has rendered the section markup.
|
||||||
|
setTimeout(() => this.apply_section_filter(), 0);
|
||||||
},
|
},
|
||||||
|
|
||||||
detached: function() {
|
detached: function () {
|
||||||
this.svelteComponent.$destroy();
|
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/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/Audiowide.css
|
||||||
style: include ../static/css/clusterize.css
|
style: include ../static/css/clusterize.css
|
||||||
style: include ../svelte-components/node_modules/svelte-material-ui/bare.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
|
style: include:stylus ../stylus/style.styl
|
||||||
|
|
||||||
body(v-cloak)
|
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
|
#svelte-dialog-host
|
||||||
|
|
||||||
#overlay(v-if="status != 'connected'")
|
#overlay(v-if="status != 'connected'")
|
||||||
span {{status}}
|
span {{status}}
|
||||||
|
|
||||||
#layout
|
.app-shell
|
||||||
a#menuLink.menu-link(href="#menu"): span
|
header.app-head
|
||||||
|
.brand-blk
|
||||||
|
.brand-logo
|
||||||
|
.brand-name ONEFINITY
|
||||||
|
|
||||||
#menu
|
nav.tabs-host(role="tablist")
|
||||||
button.save.pure-button.button-success(:disabled="!modified",
|
a.ktab(:class="{active: top_tab === 'control'}", href="#control",
|
||||||
@click="save") Save
|
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
|
||||||
|
|
||||||
.pure-menu
|
.head-spacer
|
||||||
ul.pure-menu-list
|
|
||||||
li.pure-menu-heading
|
|
||||||
a.pure-menu-link(href="#control") Control
|
|
||||||
|
|
||||||
li.pure-menu-heading
|
.sys-btn(@click.stop="toggle_sys_popover", :class="{open: sys_open}")
|
||||||
a.pure-menu-link(href="#macros") Macros
|
span.pip(:class="sys_class")
|
||||||
|
span.sys-text {{sys_summary}}
|
||||||
|
.fa.fa-chevron-down
|
||||||
|
|
||||||
li.pure-menu-heading
|
.pi-temp-warning(v-if="80 <= state.rpi_temp",
|
||||||
a.pure-menu-link(href="#settings") Settings
|
|
||||||
|
|
||||||
li.pure-menu-heading
|
|
||||||
a.pure-menu-link(href="#motor:0") Motors
|
|
||||||
|
|
||||||
li.pure-menu-item(v-for="motor in config.motors")
|
|
||||||
a.pure-menu-link(:href="'#motor:' + $index") Motor {{$index}}
|
|
||||||
|
|
||||||
li.pure-menu-heading
|
|
||||||
a.pure-menu-link(href="#tool") Tool
|
|
||||||
|
|
||||||
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.")
|
title="Raspberry Pi temperature too high.")
|
||||||
|
.fa.fa-temperature-full
|
||||||
|
|
||||||
.easy-adapter(v-if="is_easy_adapter_active")
|
span.state-badge(:class="state_class", :title="mach_state_full")
|
||||||
.round-dot
|
span.dot
|
||||||
div.easy-adapter-text Easy Adapter
|
span {{state_label}}
|
||||||
|
|
||||||
.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")
|
|
||||||
.crosshair(v-if="crosshair")
|
|
||||||
.vertical
|
|
||||||
.horizontal
|
|
||||||
.box
|
|
||||||
img(src="/api/video")
|
|
||||||
|
|
||||||
.estop(:class="{active: state.es}")
|
.estop(:class="{active: state.es}")
|
||||||
estop(@click="estop")
|
estop(@click="estop")
|
||||||
|
|
||||||
.content(class="{{currentView}}-view")
|
// 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", @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 ? '*' : ''}}
|
||||||
|
|
||||||
|
// 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",
|
component(:is="currentView + '-view'", :index="index",
|
||||||
:config="config", :template="template", :state="state", keep-alive)
|
:config="config", :template="template", :state="state",
|
||||||
|
:sub-tab="sub_tab", keep-alive)
|
||||||
|
|
||||||
message.error-message(:show.sync="errorShow")
|
message.error-message(:show.sync="errorShow")
|
||||||
div(slot="header")
|
div(slot="header")
|
||||||
|
|||||||
67
src/pug/templates/console-view.pug
Normal file
67
src/pug/templates/console-view.pug
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
script#console-view-template(type="text/x-template")
|
||||||
|
.console-page
|
||||||
|
.console-card
|
||||||
|
.ptab-bar
|
||||||
|
button.ptab(:class="{active: sub === 'mdi'}", @click="select_sub('mdi')")
|
||||||
|
.fa.fa-keyboard
|
||||||
|
| MDI
|
||||||
|
button.ptab(:class="{active: sub === 'messages'}", @click="select_sub('messages')")
|
||||||
|
.fa.fa-comment-dots
|
||||||
|
| Messages
|
||||||
|
span.ptab-badge(v-if="unread_messages") {{unread_messages}}
|
||||||
|
button.ptab(:class="{active: sub === 'indicators'}", @click="select_sub('indicators')")
|
||||||
|
.fa.fa-bell
|
||||||
|
| Indicators
|
||||||
|
|
||||||
|
// ----- MDI -----
|
||||||
|
.mdi-pane(v-show="sub === 'mdi'")
|
||||||
|
.mdi-input
|
||||||
|
span.prompt G>
|
||||||
|
input(type="text", v-model="mdi", :disabled="!can_mdi",
|
||||||
|
@keyup.enter="submit_mdi", placeholder="enter a G-code command…")
|
||||||
|
button.mdi-send(:disabled="!can_mdi || !mdi", @click="submit_mdi")
|
||||||
|
.fa.fa-paper-plane
|
||||||
|
| SEND
|
||||||
|
.mdi-keys
|
||||||
|
button.mkey(@click="prepend('G0 ')") G0
|
||||||
|
button.mkey(@click="prepend('G1 ')") G1
|
||||||
|
button.mkey(@click="prepend('G2 ')") G2
|
||||||
|
button.mkey(@click="prepend('G3 ')") G3
|
||||||
|
button.mkey(@click="prepend('G28 ')") G28
|
||||||
|
button.mkey(@click="prepend('G92 ')") G92
|
||||||
|
button.mkey(@click="prepend('M3 ')") M3
|
||||||
|
button.mkey(@click="prepend('M5 ')") M5
|
||||||
|
button.mkey(@click="append('X')") X
|
||||||
|
button.mkey(@click="append('Y')") Y
|
||||||
|
button.mkey(@click="append('Z')") Z
|
||||||
|
button.mkey(@click="append('W')") W
|
||||||
|
button.mkey(@click="append('F')") F
|
||||||
|
button.mkey(@click="append('S')") S
|
||||||
|
button.mkey.clear(@click="mdi = ''") CLEAR
|
||||||
|
button.mkey.send(:disabled="!can_mdi || !mdi", @click="submit_mdi") SEND ↵
|
||||||
|
|
||||||
|
em Machine units: #[strong {{mach_units}}]. G20/G21 to switch.
|
||||||
|
|
||||||
|
.mdi-history(:class="{placeholder: !history.length}")
|
||||||
|
span.mdi-empty(v-if="!history.length") MDI history will display here.
|
||||||
|
.h-row(v-for="item in history", @click="load_history($index)",
|
||||||
|
track-by="$index")
|
||||||
|
span.h-cmd {{item}}
|
||||||
|
span.h-status ↻
|
||||||
|
|
||||||
|
// ----- Messages -----
|
||||||
|
.messages-pane(v-show="sub === 'messages'")
|
||||||
|
.msg-empty(v-if="!$root.messages_log.length")
|
||||||
|
.fa.fa-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,12 +1,11 @@
|
|||||||
script#control-view-template(type="text/x-template")
|
script#control-view-template(type="text/x-template")
|
||||||
#control
|
.control-page
|
||||||
|
// ----- Modal dialogs (kept verbatim from legacy) -----
|
||||||
message(:show.sync="showGcodeMessage")
|
message(:show.sync="showGcodeMessage")
|
||||||
h3(slot="header") Processing New File
|
h3(slot="header") Processing New File
|
||||||
|
|
||||||
div(slot="body")
|
div(slot="body")
|
||||||
h3 Please wait..
|
h3 Please wait..
|
||||||
p Simulating GCode to check for errors, calculate ETA and generate 3D view.
|
p Simulating GCode to check for errors, calculate ETA and generate 3D view.
|
||||||
|
|
||||||
div(slot="footer")
|
div(slot="footer")
|
||||||
label Simulating {{(toolpath_progress || 0) | percent}}
|
label Simulating {{(toolpath_progress || 0) | percent}}
|
||||||
|
|
||||||
@@ -14,7 +13,6 @@ script#control-view-template(type="text/x-template")
|
|||||||
h3(slot="header") GCode Not Set
|
h3(slot="header") GCode Not Set
|
||||||
div(slot="body")
|
div(slot="body")
|
||||||
p Configure the GCode for the selected macro to use it
|
p Configure the GCode for the selected macro to use it
|
||||||
|
|
||||||
div(slot="footer")
|
div(slot="footer")
|
||||||
button.pure-button(@click="showNoGcodeMessage=false") OK
|
button.pure-button(@click="showNoGcodeMessage=false") OK
|
||||||
|
|
||||||
@@ -25,7 +23,6 @@ script#control-view-template(type="text/x-template")
|
|||||||
| The macro file
|
| The macro file
|
||||||
strong {{state.selected}}
|
strong {{state.selected}}
|
||||||
| is being loaded.
|
| is being loaded.
|
||||||
|
|
||||||
div(slot="footer")
|
div(slot="footer")
|
||||||
button.pure-button(@click="macrosLoading=false") Cancel
|
button.pure-button(@click="macrosLoading=false") Cancel
|
||||||
button.pure-button.pure-button-primary(@click="start_pause") Run
|
button.pure-button.pure-button-primary(@click="start_pause") Run
|
||||||
@@ -35,441 +32,265 @@ script#control-view-template(type="text/x-template")
|
|||||||
div(slot="body")
|
div(slot="body")
|
||||||
p It seems like the file you selected cannot be found. Try uploading again.
|
p It seems like the file you selected cannot be found. Try uploading again.
|
||||||
div(slot="footer")
|
div(slot="footer")
|
||||||
button.pure-button.button-error(@click="GCodeNotFound=false")
|
button.pure-button.button-error(@click="GCodeNotFound=false") OK
|
||||||
| OK
|
|
||||||
|
|
||||||
message(:show.sync="show_probe_dialog")
|
message(:show.sync="show_probe_dialog")
|
||||||
h3(slot="header") Probe Rotary
|
h3(slot="header") Choose probe type
|
||||||
div(slot="body")
|
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('xyz')") Probe XYZ
|
||||||
button.pure-button(:class="state['pw'] ? '' : 'load-on'", @click="showProbeDialog('z')") Probe Z
|
button.pure-button(:class="state['pw'] ? '' : 'load-on'", @click="showProbeDialog('z')") Probe Z
|
||||||
div(slot="footer")
|
div(slot="footer")
|
||||||
button.pure-button(@click="show_probe_dialog=false") Cancel
|
button.pure-button(@click="show_probe_dialog=false") Cancel
|
||||||
|
|
||||||
|
// ----- Main grid: jog | (DRO + status strip) -----
|
||||||
|
.control-grid
|
||||||
|
|
||||||
table(style="table-layout: fixed; width: 100%;")
|
// ===== JOG =====
|
||||||
tr(style="height: fit-content;")
|
// Hidden only while a G-code program is running / paused /
|
||||||
td(style="white-space: nowrap; width: 410px;", rowspan="2")
|
// stopping. Jogging / homing / MDI moves do not hide it.
|
||||||
table.control-buttons(table-layout="fixed")
|
.jog-card(v-if="!is_program_executing")
|
||||||
colgroup
|
.jog-head
|
||||||
col(style="width:100px")
|
.jog-title
|
||||||
col(style="width:100px")
|
| Jog
|
||||||
col(style="width:100px")
|
span.step-pre · step
|
||||||
col(style="width:100px")
|
span.step {{jog_incr_amounts[display_units][jog_incr]}}#[span.unit {{metric ? 'mm' : 'in'}}]
|
||||||
tr
|
.step-seg
|
||||||
td(style="height:100px",align="center")
|
button(:class="{active: jog_incr === 'fine'}", @click="jog_incr = 'fine'")
|
||||||
button(@click="jog_fn(-1,1,0,0)")
|
| {{jog_incr_amounts[display_units].fine}}
|
||||||
.fa.fa-arrow-right(style="transform: rotate(-135deg);")
|
button(:class="{active: jog_incr === 'small'}", @click="jog_incr = 'small'")
|
||||||
td(style="height:100px",align="center")
|
| {{jog_incr_amounts[display_units].small}}
|
||||||
button(@click="jog_fn(0,1,0,0)") Y+
|
button(:class="{active: jog_incr === 'medium'}", @click="jog_incr = 'medium'")
|
||||||
td(style="height:100px",align="center")
|
| {{jog_incr_amounts[display_units].medium}}
|
||||||
button(@click="jog_fn(1,1,0,0)")
|
button(:class="{active: jog_incr === 'large'}", @click="jog_incr = 'large'")
|
||||||
.fa.fa-arrow-right(style="transform: rotate(-45deg);")
|
| {{jog_incr_amounts[display_units].large}}
|
||||||
td(style="height:100px",align="center")
|
|
||||||
button(,@click="jog_fn(0,0,1,0)") Z+
|
|
||||||
tr
|
|
||||||
td(style="height:100px",align="center")
|
|
||||||
button(@click="jog_fn(-1,0,0,0)") X-
|
|
||||||
td(style="height:100px",align="center")
|
|
||||||
button(@click="showMoveToZeroDialog('xy')")
|
|
||||||
| XY
|
|
||||||
br
|
|
||||||
| Origin
|
|
||||||
td(style="height:100px",align="center")
|
|
||||||
button(@click="jog_fn(1,0,0,0)") X+
|
|
||||||
td(style="height:100px",align="center")
|
|
||||||
button(@click="showMoveToZeroDialog('z')")
|
|
||||||
| Z
|
|
||||||
br
|
|
||||||
| Origin
|
|
||||||
tr
|
|
||||||
td(style="height:100px",align="center")
|
|
||||||
button(@click="jog_fn(-1,-1,0,0)")
|
|
||||||
.fa.fa-arrow-right(style="transform: rotate(135deg);")
|
|
||||||
td(style="height:100px",align="center")
|
|
||||||
button(@click="jog_fn(0,-1,0,0)") Y-
|
|
||||||
td(style="height:100px",align="center")
|
|
||||||
button(@click="jog_fn(1,-1,0,0)")
|
|
||||||
.fa.fa-arrow-right(style="transform: rotate(45deg);")
|
|
||||||
td(style="height:100px",align="center")
|
|
||||||
button(@click="jog_fn(0,0,-1,0)") Z-
|
|
||||||
tr
|
|
||||||
td(style="height:100px",align="center")
|
|
||||||
button(:style="getJogIncrStyle('fine')", @click="jog_incr = 'fine'")
|
|
||||||
span {{jog_incr_amounts[display_units].fine}}#[span.jog-units {{metric ? 'mm' : 'in'}}]
|
|
||||||
td(style="height:100px",align="center")
|
|
||||||
button(:style="getJogIncrStyle('small')", @click="jog_incr = 'small'")
|
|
||||||
span {{jog_incr_amounts[display_units].small}}#[span.jog-units {{metric ? 'mm' : 'in'}}]
|
|
||||||
td(style="height:100px",align="center")
|
|
||||||
button(:style="getJogIncrStyle('medium')", @click="jog_incr = 'medium'")
|
|
||||||
span {{jog_incr_amounts[display_units].medium}}#[span.jog-units {{metric ? 'mm' : 'in'}}]
|
|
||||||
td(style="height:100px",align="center")
|
|
||||||
button(:style="getJogIncrStyle('large')", @click="jog_incr = 'large'")
|
|
||||||
span {{jog_incr_amounts[display_units].large}}#[span.jog-units {{metric ? 'mm' : 'in'}}]
|
|
||||||
|
|
||||||
tr(v-if="state['2an'] == 3")
|
.jog-grid
|
||||||
td(style="height:100px", align="center", colspan="1")
|
// Row 1
|
||||||
button(@click="show_probe_dialog=true")
|
button.jbtn.dir(@click="jog_fn(-1, 1, 0, 0)", title="X- Y+")
|
||||||
| Probe
|
.fa.fa-arrow-up.ico(style="transform: rotate(-45deg)")
|
||||||
br
|
button.jbtn(@click="jog_fn(0, 1, 0, 0)") Y+
|
||||||
| Rotary
|
button.jbtn.dir(@click="jog_fn(1, 1, 0, 0)", title="X+ Y+")
|
||||||
|
.fa.fa-arrow-up.ico(style="transform: rotate(45deg)")
|
||||||
|
button.jbtn(@click="jog_fn(0, 0, 1, 0)") Z+
|
||||||
|
|
||||||
td(style="height:100px", align="center", colspan="1")
|
// Row 2
|
||||||
button(@click="jog_fn(0,0,0,-1)", style="display: grid;justify-content: center;align-items: center;padding: 14px;")
|
button.jbtn(@click="jog_fn(-1, 0, 0, 0)") X−
|
||||||
| A-
|
button.jbtn(@click="showMoveToZeroDialog('xy')")
|
||||||
.fa.fa-rotate-left
|
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")
|
// Row 3
|
||||||
button(@click="showMoveToZeroDialog('a')")
|
button.jbtn.dir(@click="jog_fn(-1, -1, 0, 0)", title="X- Y-")
|
||||||
| A
|
.fa.fa-arrow-down.ico(style="transform: rotate(45deg)")
|
||||||
br
|
button.jbtn(@click="jog_fn(0, -1, 0, 0)") Y−
|
||||||
| Origin
|
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−
|
||||||
|
|
||||||
td(style="height:100px", align="center", colspan="1")
|
// Row 4 — A axis (rotary) when rotary is enabled.
|
||||||
button(@click="jog_fn(0,0,0,1)", style="display: grid;justify-content: center;align-items: center;padding: 14px;")
|
template(v-if="state['2an'] == 3")
|
||||||
| A+
|
button.jbtn.dir(@click="jog_fn(0, 0, 0, -1)")
|
||||||
.fa.fa-rotate-right
|
.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
|
||||||
|
|
||||||
tr(v-else)
|
// Row 4 — fallback probe / zero / home shortcuts
|
||||||
td(style="height:100px", align="center", colspan="2")
|
template(v-if="state['2an'] != 3")
|
||||||
button(:class="state['pw'] ? '' : 'load-on'",
|
button.jbtn(@click="showProbeDialog('xyz')",
|
||||||
style="height:100px;width:200px",
|
:class="{'load-on': !state['pw']}")
|
||||||
@click="showProbeDialog('xyz')")
|
.fa.fa-bullseye.ico
|
||||||
| Probe XYZ
|
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
|
||||||
|
|
||||||
td(style="height:100px", align="center", colspan="2")
|
// ===== NOW RUNNING (replaces jog grid only while a G-code
|
||||||
button(:class="state['pw'] ? '' : 'load-on'",
|
// program is actually executing). Jogging is excluded.
|
||||||
style="height:100px;width:200px",
|
.running-panel(v-if="is_program_executing")
|
||||||
@click="showProbeDialog('z')")
|
.running-top
|
||||||
| Probe Z
|
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
|
||||||
|
|
||||||
td(style="vertical-align: top;")
|
// ===== DRO + status strip =====
|
||||||
table.axes
|
.right-col
|
||||||
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;")
|
|
||||||
|
|
||||||
button.pure-button(:disabled="!can_set_axis",
|
.dro-card
|
||||||
title="Zero all axis offsets.", @click="zero()",style="height:60px;width:60px")
|
.dro-head
|
||||||
.fa.fa-map-marker
|
div Axis
|
||||||
|
div Position
|
||||||
button.pure-button(title="Home all axes.", @click="home()",
|
div Absolute
|
||||||
:disabled="!is_idle",style="height:60px;width:60px")
|
div Offset
|
||||||
.fa.fa-home
|
.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).
|
||||||
|
button.icon-btn(:disabled="!is_idle",
|
||||||
|
title="Home all axes.", @click="home_all()")
|
||||||
|
.fa.fa-house-chimney
|
||||||
|
|
||||||
|
// Per-axis rows — keep unit-value + bindings from axis-vars
|
||||||
each axis in 'xyzabc'
|
each axis in 'xyzabc'
|
||||||
tr.axis(:class=`${axis}.klass`, v-if=`${axis}.enabled`,
|
.dro-row(:class=`${axis}.klass + ' ' + ${axis}.tklass`,
|
||||||
:title=`${axis}.title`)
|
v-if=`${axis}.enabled`,
|
||||||
th.name= axis
|
:title=`${axis}.toolmsg ? (${axis}.title + ' — ' + ${axis}.toolmsg) : ${axis}.title`)
|
||||||
td.position: unit-value(:value=`${axis}.pos`, precision=4)
|
.dro-axis(:class=`'axis-' + '${axis}'`)= axis.toUpperCase()
|
||||||
td.absolute: unit-value(:value=`${axis}.abs`, precision=3)
|
.dro-pos: unit-value(:value=`${axis}.pos`, precision=4)
|
||||||
td.offset: unit-value(:value=`${axis}.off`, precision=3)
|
.dro-sec: unit-value(:value=`${axis}.abs`, precision=3)
|
||||||
td.state
|
.dro-sec: unit-value(:value=`${axis}.off`, precision=3)
|
||||||
.fa(:class=`'fa-' + ${axis}.icon`)
|
.actions-cell
|
||||||
| {{#{axis}.state}}
|
button.icon-btn(:disabled="!can_set_axis",
|
||||||
td.tstate(:class=`${axis}.tklass`, :title=`${axis}.toolmsg`, @click=`showToolpathMessageDialog('${axis}')`)
|
:title=`'Set ${axis.toUpperCase()} axis position.'`,
|
||||||
.fa(:class=`'fa-' + ${axis}.ticon`)
|
@click=`show_set_position('${axis}')`)
|
||||||
| {{#{axis}.tstate}}
|
.fa.fa-gear
|
||||||
|
button.icon-btn(:class=`${axis}.tklass.indexOf('error') !== -1 ? 'state-red' : (${axis}.tklass.indexOf('warn') !== -1 ? 'state-amber' : 'state-green')`,
|
||||||
th.actions
|
:disabled="!can_set_axis",
|
||||||
button.pure-button(:disabled="!can_set_axis",
|
:title=`${axis}.toolmsg || ('Zero ${axis.toUpperCase()} axis offset.')`,
|
||||||
title=`Set {{'${axis}' | upper}} axis position.`,
|
@click=`zero('${axis}')`)
|
||||||
@click=`show_set_position('${axis}')`, style="height:60px;width:60px")
|
.fa.fa-location-dot
|
||||||
.fa.fa-cog
|
button.icon-btn(:class=`${axis}.klass.indexOf('error') !== -1 ? 'state-red' : (${axis}.homed ? 'state-green' : 'state-amber')`,
|
||||||
|
:disabled="!is_idle",
|
||||||
button.pure-button(:disabled="!can_set_axis",
|
:title=`${axis}.title`,
|
||||||
title=`Zero {{'${axis}' | upper}} axis offset.`,
|
@click=`home('${axis}')`)
|
||||||
@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
|
.fa.fa-home
|
||||||
|
|
||||||
tr(style="vertical-align: top;")
|
// ----- Status strip -----
|
||||||
td
|
.status-strip
|
||||||
table(width="100%")
|
.stat-card
|
||||||
tr
|
.stat-label State
|
||||||
td(style="text-align:center")
|
.stat-val(:class="state_kpi_class") {{mach_state || '--'}}
|
||||||
table.info
|
.stat-sub(v-if="message") {{message.replace(/^#/, '')}}
|
||||||
tr
|
.stat-sub(v-else) No alerts
|
||||||
th State
|
|
||||||
td(:class="{attention: highlight_state}") {{mach_state}}
|
|
||||||
|
|
||||||
tr
|
.stat-card
|
||||||
th Message
|
.stat-label Velocity / Feed
|
||||||
td.message(:class="{attention: highlight_state}")
|
.stat-val
|
||||||
| {{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="",
|
unit-value(:value="state.v", precision="2", unit="", iunit="",
|
||||||
scale="0.0254")
|
scale="0.0254")
|
||||||
| {{metric ? ' m/min' : ' IPM'}}
|
| ·
|
||||||
|
unit-value(:value="state.feed", precision="0", unit="", iunit="")
|
||||||
|
.stat-sub {{metric ? 'm/min · mm/min' : 'IPM · IPM'}}
|
||||||
|
|
||||||
tr(title="Programmed feed rate.")
|
.stat-card.stat-tappable(@click="overrides_open = !overrides_open",
|
||||||
th Feed
|
:class="{open: overrides_open}", title="Tap to adjust feed/spindle override")
|
||||||
td
|
.stat-label Spindle
|
||||||
unit-value(:value="state.feed", precision="2", unit="", iunit="")
|
.stat-val
|
||||||
| {{metric ? ' mm/min' : ' IPM'}}
|
| {{(state.speed || 0) | fixed 0}}
|
||||||
|
span(v-if="state.s != null && !isNaN(state.s)") ({{state.s | fixed 0}})
|
||||||
|
.stat-sub
|
||||||
|
| RPM (commanded / actual)
|
||||||
|
.fa.fa-sliders.tap-hint(title="Open override drawer")
|
||||||
|
|
||||||
tr(title="Programed and actual speed.")
|
.stat-card
|
||||||
th Speed
|
.stat-label Job
|
||||||
td
|
.stat-val
|
||||||
| {{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}}
|
| {{0 <= state.line ? state.line : 0 | number}}
|
||||||
span(v-if="toolpath.lines")
|
span(v-if="toolpath.lines")
|
||||||
| of {{toolpath.lines | number}}
|
| / {{toolpath.lines | number}}
|
||||||
|
.stat-sub(v-if="plan_time_remaining || toolpath.time")
|
||||||
|
| Line · {{plan_time_remaining ? (plan_time_remaining | time) : (toolpath.time | time)}} remaining
|
||||||
|
.stat-sub(v-else) Line · ETA --
|
||||||
|
|
||||||
tr
|
// ----- Macro row (slice 0..7); full list lives in Settings → Macros -----
|
||||||
th Progress
|
// The colored left stripe (.has-color) is suppressed for white,
|
||||||
td.progress
|
// near-white and other default placeholder colors so unconfigured
|
||||||
label {{(progress || 0) | percent}}
|
// macros render as clean slate tiles instead of looking lopsided.
|
||||||
.bar(:style="'width:' + (progress || 0) * 100 + '%'")
|
.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))}}
|
||||||
|
|
||||||
.macros-div(class="present")
|
// ----- Override drawer (anchored to bottom; toggled by Spindle KPI tile) -----
|
||||||
button.macros-button(title="Click to run Macros",v-for="(index,macros) in state.macros",
|
.override-drawer(:class="{open: overrides_open}")
|
||||||
@click="run_macro(index)",:disabled="!is_ready",v-bind:style="{ backgroundColor: macros.color }") {{macros.name}}
|
.od-head
|
||||||
|
.od-title
|
||||||
.tabs
|
.fa.fa-sliders
|
||||||
|
| Overrides
|
||||||
input#tab1(type="radio", name="tabs",checked="" @click="tab = 'auto'")
|
button.od-close(@click="overrides_open = false") ✕
|
||||||
label(for="tab1", title="Run GCode programs",style="height:50px;width:100px") Auto
|
.od-body
|
||||||
|
.od-row
|
||||||
input#tab2(type="radio", name="tabs", @click="tab = 'mdi'")
|
|
||||||
label(for="tab2", title="Manual GCode entry",style="height:50px;width:100px") MDI
|
|
||||||
|
|
||||||
input#tab3(type="radio", name="tabs", @click="tab = 'messages'")
|
|
||||||
label(for="tab3",style="height:50px;width:100px") Messages
|
|
||||||
|
|
||||||
input#tab4(type="radio", name="tabs", @click="tab = 'indicators'")
|
|
||||||
label(for="tab4",style="height:50px;width:100px") Indicators
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
section#content1.tab-content.pure-form
|
|
||||||
.toolbar.pure-control-group
|
|
||||||
button.pure-button(:class="{'attention': is_holding}",
|
|
||||||
title="{{is_running ? 'Pause' : 'Start'}} program.",
|
|
||||||
@click="start_pause", :disabled="!state.selected",
|
|
||||||
style="height:100px;width:100px;font-weight:normal")
|
|
||||||
img(v-if="is_running" src="images/pause_gcode.png" style="height: 55px;")
|
|
||||||
img(v-else src="images/play_gcode.png" style="height: 55px;")
|
|
||||||
|
|
||||||
button.pure-button(title="Stop program.", @click="stop", style="height:100px;width:100px;font-weight:normal")
|
|
||||||
img(src="images/stop.png" style="height: 55px;")
|
|
||||||
|
|
||||||
button.pure-button(title="Pause program at next optional stop (M1).",
|
|
||||||
@click="optional_pause", v-if="false", style="height:100px;width:100px;font-weight:normal")
|
|
||||||
.fa.fa-stop-circle-o
|
|
||||||
|
|
||||||
message(:show.sync="uploading_files")
|
|
||||||
h3(slot="header") Files uploading
|
|
||||||
div(slot="body")
|
|
||||||
h3 Please wait...
|
|
||||||
p
|
|
||||||
p The files are currently being uploaded.
|
|
||||||
p Do not close the window.
|
|
||||||
div(slot="footer")
|
|
||||||
|
|
||||||
button.pure-button(title="Execute one program step.", @click="step",
|
|
||||||
:disabled="(!is_ready && !is_holding) || !state.selected",
|
|
||||||
v-if="false", style="height:100px;width:100px;font-weight:normal")
|
|
||||||
.fa.fa-step-forward
|
|
||||||
|
|
||||||
button.pure-button(title="Upload a new GCode folder.", @click="open_folder",
|
|
||||||
:disabled="!is_ready",style="height:100px;width:100px;font-weight:normal")
|
|
||||||
img(src="images/upload_folder.png" style="height: 65px;")
|
|
||||||
|
|
||||||
form.gcode-folder-input.file-upload
|
|
||||||
input#folderInput(type="file", @change="upload_folder", :disabled="!is_ready",
|
|
||||||
webkitdirectory, directory)
|
|
||||||
|
|
||||||
button.pure-button(title="Upload a new GCode program.", @click="open_file",
|
|
||||||
:disabled="!is_ready",style="height:100px;width:100px;font-weight:normal")
|
|
||||||
img(src="images/upload_gcode.png" style="height: 65px;")
|
|
||||||
|
|
||||||
form.gcode-file-input.file-upload
|
|
||||||
input(type="file", @change="upload_file", :disabled="!is_ready",
|
|
||||||
accept=".nc,.ngc,.gcode,.gc", multiple)
|
|
||||||
|
|
||||||
a(:disabled="!state.selected", download,
|
|
||||||
:href="'/api/file/' + state.selected",
|
|
||||||
title="Download the selected GCode program.")
|
|
||||||
button.pure-button(:disabled="!state.selected", style="height:100px;width:100px")
|
|
||||||
img(src="images/download_gcode.png" style="height: 65px;")
|
|
||||||
|
|
||||||
button.pure-button(title="Delete current GCode program.",
|
|
||||||
@click="deleteGCode = true",
|
|
||||||
:disabled="!state.selected || !is_ready",style="height:100px;width:100px;font-weight:normal")
|
|
||||||
img(src="images/delete_gcode.png" style="height: 55px;")
|
|
||||||
|
|
||||||
message.error-message(:show.sync="deleteGCode")
|
|
||||||
h3(slot="header") Select files to delete:
|
|
||||||
div(slot="body")
|
|
||||||
input.search-bar(type="text", v-model="search_query", placeholder="Search Files...")
|
|
||||||
.container
|
|
||||||
.folders
|
|
||||||
h3 Folders
|
|
||||||
div(v-for="(index, folder) in state.gcode_list", :key="index", @click="populateFiles(index)",
|
|
||||||
class="folder-item", :class="{ selected: index === selected_folder_index }") {{ folder.name }}
|
|
||||||
.files
|
|
||||||
h3 Files
|
|
||||||
label.file-item(v-for="item in gcode_filtered_files" :key="item")
|
|
||||||
input(type="checkbox" :value="item" v-model="selected_items_to_delete")
|
|
||||||
| {{ item }}
|
|
||||||
div(slot="footer")
|
|
||||||
button.pure-button(@click="cancel_delete",style="height:50px") Cancel
|
|
||||||
//- button.pure-button.button-error(@click="delete_all_except_macros")
|
|
||||||
//- .fa.fa-trash
|
|
||||||
//- | All
|
|
||||||
button.pure-button.button-success(@click="delete_current",style="height:50px")
|
|
||||||
.fa.fa-trash
|
|
||||||
| Selected
|
|
||||||
|
|
||||||
.drop-down-container
|
|
||||||
message(:show.sync="create_folder")
|
|
||||||
h3(slot="header") Enter folder name:
|
|
||||||
div(slot="body")
|
|
||||||
input.input-name(type="text",minlength='1',maxlength='15',style ="margin-top:1rem;margin-bottom:2rem;",
|
|
||||||
id="folder-name" ,v-model="folder_name",@keypress="edited_folder_name")
|
|
||||||
|
|
||||||
div(slot="footer")
|
|
||||||
button.pure-button(@click="cancel_new_folder") Cancel
|
|
||||||
button.pure-button.button-success(@click="create_new_folder",:disabled="!edited")
|
|
||||||
| Create
|
|
||||||
|
|
||||||
message(:show.sync="confirmDelete")
|
|
||||||
h3(slot="header") Delete Folder?
|
|
||||||
div(slot="body")
|
|
||||||
p Are you sure to delete the folder?
|
|
||||||
|
|
||||||
div(slot="footer")
|
|
||||||
button.pure-button(@click="confirmDelete=false") Cancel
|
|
||||||
button.pure-button.button-error(@click="delete_folder") Folder only
|
|
||||||
button.pure-button.button-success(@click="delete_folder_and_files") Folder and files
|
|
||||||
|
|
||||||
button.pure-button(title="Create a new folder.", @click="create_folder=true",
|
|
||||||
:disabled="!is_ready",style="height:100%")
|
|
||||||
| Create Folder
|
|
||||||
|
|
||||||
button.pure-button(title="Delete a folder.", @click="confirmDelete=true",
|
|
||||||
:disabled="!is_ready",style="height:100%;margin-left:5px")
|
|
||||||
| Delete Folder
|
|
||||||
|
|
||||||
select(title="Select previously uploaded GCode folder.",
|
|
||||||
v-model="state.folder", @change="reset_gcode", :disabled="!is_ready",
|
|
||||||
style="max-width:100%;margin-left:5px")
|
|
||||||
option( selected='' value='default') Default folder
|
|
||||||
option(v-for="file in gcode_folders", :value="file") {{file}}
|
|
||||||
|
|
||||||
select(title="Select previously uploaded GCode programs.",
|
|
||||||
v-model="state.selected", @change="load", :disabled="!is_ready",
|
|
||||||
style="max-width:300px;margin-left:5px")
|
|
||||||
option(v-for="file in gcode_files", :value="file") {{file}}
|
|
||||||
|
|
||||||
button.pure-button(@click="toggle_sorting", :disabled="!is_ready",
|
|
||||||
style="height:75%")
|
|
||||||
| {{files_sortby}}
|
|
||||||
|
|
||||||
.progress(v-if="toolpath_progress && toolpath_progress < 1",
|
|
||||||
title="Simulating GCode to check for errors, calculate ETA and " +
|
|
||||||
"generate 3D view. You can run GCode before the simulation " +
|
|
||||||
"finishes.")
|
|
||||||
div(:style="'width:' + (toolpath_progress || 0) * 100 + '%'")
|
|
||||||
label Simulating {{(toolpath_progress || 0) | percent}}
|
|
||||||
|
|
||||||
path-viewer(:toolpath="toolpath", :state="state", :config="config")
|
|
||||||
gcode-viewer
|
|
||||||
|
|
||||||
section#content2.tab-content
|
|
||||||
.mdi.pure-form(title="Manual GCode entry.")
|
|
||||||
button.pure-button(:disabled="!can_mdi",
|
|
||||||
:class="{'attention': is_holding}",
|
|
||||||
title="{{is_running ? 'Pause' : 'Start'}} command.",
|
|
||||||
@click="mdi_start_pause",style="height:100px;width:100px")
|
|
||||||
.fa(:class="is_running ? 'fa-pause' : 'fa-play'")
|
|
||||||
|
|
||||||
button.pure-button(title="Stop command.", @click="stop",style="height:100px;width:100px")
|
|
||||||
.fa.fa-stop
|
|
||||||
|
|
||||||
input(v-model="mdi", :disabled="!can_mdi", @keyup.enter="submit_mdi")
|
|
||||||
|
|
||||||
div
|
|
||||||
em The machine is currently operating in #[strong {{mach_units}}] units. Use G20/G21 to switch units.
|
|
||||||
|
|
||||||
.history(:class="{placeholder: !history}")
|
|
||||||
span(v-if="!history.length") MDI history displays here.
|
|
||||||
ul
|
|
||||||
li(v-for="item in history", @click="load_history($index)",
|
|
||||||
track-by="$index")
|
|
||||||
| {{item}}
|
|
||||||
|
|
||||||
section#content3.tab-content
|
|
||||||
console
|
|
||||||
|
|
||||||
section#content4.tab-content
|
|
||||||
indicators(:state="state", :template="template")
|
|
||||||
|
|
||||||
.override(title="Feed rate override.")
|
|
||||||
label Feed
|
label Feed
|
||||||
input(type="range", min="0", max="2", step="0.01",
|
input(type="range", min="0", max="2", step="0.01",
|
||||||
v-model="feed_override", @change="override_feed")
|
v-model="feed_override", @change="override_feed")
|
||||||
span.percent {{feed_override | percent 0}}
|
.od-val {{feed_override | percent 0}}
|
||||||
|
button.od-reset(@click="feed_override = 1; override_feed()") Reset 100%
|
||||||
.override(title="Spindle speed override.")
|
.od-row
|
||||||
label Speed
|
label Spindle
|
||||||
input(type="range", min="0", max="2", step="0.01",
|
input(type="range", min="0", max="2", step="0.01",
|
||||||
v-model="speed_override", @change="override_speed")
|
v-model="speed_override", @change="override_speed")
|
||||||
span.percent {{speed_override | percent 0}}
|
.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",
|
svg(version="1.1", xmlns:svg="http://www.w3.org/2000/svg",
|
||||||
xmlns="http://www.w3.org/2000/svg",
|
xmlns="http://www.w3.org/2000/svg",
|
||||||
xmlns:xlink="http://www.w3.org/1999/xlink",
|
xmlns:xlink="http://www.w3.org/1999/xlink",
|
||||||
|
viewBox="0 0 130 130",
|
||||||
|
preserveAspectRatio="xMidYMid meet",
|
||||||
width="130", height="130")
|
width="130", height="130")
|
||||||
defs
|
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")
|
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
|
tr
|
||||||
td
|
td
|
||||||
.fa.fa-plus-circle.io
|
.fa.fa-circle-plus.io
|
||||||
th Hi/+3.3v
|
th Hi/+3.3v
|
||||||
th.separator
|
th.separator
|
||||||
td
|
td
|
||||||
.fa.fa-minus-circle.io
|
.fa.fa-circle-minus.io
|
||||||
th Lo/Gnd
|
th Lo/Gnd
|
||||||
th.separator
|
th.separator
|
||||||
td
|
td
|
||||||
@@ -22,7 +22,7 @@ script#indicators-template(type="text/x-template")
|
|||||||
th Inactive
|
th Inactive
|
||||||
th.separator
|
th.separator
|
||||||
td
|
td
|
||||||
.fa.fa-circle-o.io
|
.far.fa-circle.io
|
||||||
th Tristated/Disabled
|
th Tristated/Disabled
|
||||||
|
|
||||||
table.inputs
|
table.inputs
|
||||||
@@ -169,14 +169,14 @@ script#indicators-template(type="text/x-template")
|
|||||||
|
|
||||||
tr
|
tr
|
||||||
th Motor
|
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="Overcurrent motor channel A") A #[.fa.fa-bolt]
|
||||||
th(title="Predriver fault motor channel A")
|
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="Overcurrent motor channel B") B #[.fa.fa-bolt]
|
||||||
th(title="Predriver fault motor channel B")
|
th(title="Predriver fault motor channel B")
|
||||||
| B #[.fa.fa-exclamation-triangle]
|
| B #[.fa.fa-triangle-exclamation]
|
||||||
th(title="Driver communication failure"): .fa.fa-handshake-o
|
th(title="Driver communication failure"): .fa.fa-handshake
|
||||||
th(title="Reset all motor flags")
|
th(title="Reset all motor flags")
|
||||||
.fa.fa-eraser(@click="motor_reset()")
|
.fa.fa-eraser(@click="motor_reset()")
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ script#path-viewer-template(type="text/x-template")
|
|||||||
.path-viewer-toolbar
|
.path-viewer-toolbar
|
||||||
.tool-button(title="Toggle path view size.",
|
.tool-button(title="Toggle path view size.",
|
||||||
@click="small = !small", :class="{active: !small}")
|
@click="small = !small", :class="{active: !small}")
|
||||||
.fa.fa-arrows-alt
|
.fa.fa-up-down-left-right
|
||||||
|
|
||||||
.tool-button(@click="showTool = !showTool", :class="{active: showTool}",
|
.tool-button(@click="showTool = !showTool", :class="{active: showTool}",
|
||||||
title="Show/hide tool.")
|
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}}
|
||||||
56
src/pug/templates/settings-shell.pug
Normal file
56
src/pug/templates/settings-shell.pug
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
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")
|
||||||
|
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…
|
||||||
@@ -468,8 +468,7 @@ class VideoHandler(web.RequestHandler):
|
|||||||
self.camera = app.camera
|
self.camera = app.camera
|
||||||
|
|
||||||
|
|
||||||
@web.asynchronous
|
async def get(self):
|
||||||
def get(self):
|
|
||||||
self.request.connection.stream.max_write_buffer_size = 10000
|
self.request.connection.stream.max_write_buffer_size = 10000
|
||||||
|
|
||||||
self.set_header('Cache-Control', 'no-store, no-cache, must-revalidate, '
|
self.set_header('Cache-Control', 'no-store, no-cache, must-revalidate, '
|
||||||
|
|||||||
@@ -223,6 +223,10 @@ class Comm(object):
|
|||||||
self.ctrl.mach.process_log(msg)
|
self.ctrl.mach.process_log(msg)
|
||||||
elif 'firmware' in msg:
|
elif 'firmware' in msg:
|
||||||
self.log.info('AVR firmware rebooted')
|
self.log.info('AVR firmware rebooted')
|
||||||
|
try:
|
||||||
|
import bbctrl.Trace as _T
|
||||||
|
_T.mark('avr.firmware_rebooted')
|
||||||
|
except Exception: pass
|
||||||
self.connect()
|
self.connect()
|
||||||
else:
|
else:
|
||||||
self._update_state(msg)
|
self._update_state(msg)
|
||||||
|
|||||||
@@ -28,10 +28,12 @@
|
|||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
import bbctrl
|
import bbctrl
|
||||||
|
import bbctrl.Trace as Trace
|
||||||
|
|
||||||
|
|
||||||
class Ctrl(object):
|
class Ctrl(object):
|
||||||
def __init__(self, args, ioloop, id):
|
def __init__(self, args, ioloop, id):
|
||||||
|
Trace.mark('ctrl.init.start', id=id or '<default>')
|
||||||
self.args = args
|
self.args = args
|
||||||
self.ioloop = bbctrl.IOLoop(ioloop)
|
self.ioloop = bbctrl.IOLoop(ioloop)
|
||||||
self.id = id
|
self.id = id
|
||||||
@@ -43,23 +45,34 @@ class Ctrl(object):
|
|||||||
if args.demo: log_path = self.get_path(filename = 'bbctrl.log')
|
if args.demo: log_path = self.get_path(filename = 'bbctrl.log')
|
||||||
else: log_path = args.log
|
else: log_path = args.log
|
||||||
self.log = bbctrl.log.Log(args, self.ioloop, log_path)
|
self.log = bbctrl.log.Log(args, self.ioloop, log_path)
|
||||||
|
Trace.mark('ctrl.log_open')
|
||||||
|
|
||||||
self.state = bbctrl.State(self)
|
self.state = bbctrl.State(self)
|
||||||
self.config = bbctrl.Config(self)
|
self.config = bbctrl.Config(self)
|
||||||
|
Trace.mark('ctrl.state_config')
|
||||||
|
|
||||||
self.log.get('Ctrl').info('Starting %s' % self.id)
|
self.log.get('Ctrl').info('Starting %s' % self.id)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
with Trace.span('ctrl.avr'):
|
||||||
if args.demo: self.avr = bbctrl.AVREmu(self)
|
if args.demo: self.avr = bbctrl.AVREmu(self)
|
||||||
else: self.avr = bbctrl.AVR(self)
|
else: self.avr = bbctrl.AVR(self)
|
||||||
|
|
||||||
|
with Trace.span('ctrl.i2c'):
|
||||||
self.i2c = bbctrl.I2C(args.i2c_port, args.demo)
|
self.i2c = bbctrl.I2C(args.i2c_port, args.demo)
|
||||||
|
with Trace.span('ctrl.lcd'):
|
||||||
self.lcd = bbctrl.LCD(self)
|
self.lcd = bbctrl.LCD(self)
|
||||||
|
with Trace.span('ctrl.mach'):
|
||||||
self.mach = bbctrl.Mach(self, self.avr)
|
self.mach = bbctrl.Mach(self, self.avr)
|
||||||
|
with Trace.span('ctrl.preplanner'):
|
||||||
self.preplanner = bbctrl.Preplanner(self)
|
self.preplanner = bbctrl.Preplanner(self)
|
||||||
if not args.demo: self.jog = bbctrl.Jog(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)
|
self.pwr = bbctrl.Pwr(self)
|
||||||
|
|
||||||
|
with Trace.span('ctrl.mach.connect'):
|
||||||
self.mach.connect()
|
self.mach.connect()
|
||||||
|
|
||||||
self.lcd.add_new_page(bbctrl.MainLCDPage(self))
|
self.lcd.add_new_page(bbctrl.MainLCDPage(self))
|
||||||
@@ -67,7 +80,12 @@ class Ctrl(object):
|
|||||||
|
|
||||||
os.environ['GCODE_SCRIPT_PATH'] = self.get_upload()
|
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')
|
def __del__(self): print('Ctrl deleted')
|
||||||
|
|||||||
@@ -182,4 +182,11 @@ class Log(object):
|
|||||||
if n == 16: os.unlink(fullpath)
|
if n == 16: os.unlink(fullpath)
|
||||||
else: self._rotate(path, nextN)
|
else: self._rotate(path, nextN)
|
||||||
|
|
||||||
|
# The recursive call may have unlinked or rotated this
|
||||||
|
# path; tolerate a missing source rather than crashing
|
||||||
|
# bbctrl on startup. This also tolerates concurrent
|
||||||
|
# logrotate runs from /etc/cron.reboot.
|
||||||
|
try:
|
||||||
os.rename(fullpath, '%s.%d' % (path, nextN))
|
os.rename(fullpath, '%s.%d' % (path, nextN))
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|||||||
@@ -30,7 +30,22 @@ import math
|
|||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
from collections import deque
|
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
|
import bbctrl.Cmd as Cmd
|
||||||
from bbctrl.CommandQueue import CommandQueue
|
from bbctrl.CommandQueue import CommandQueue
|
||||||
|
|
||||||
@@ -329,7 +344,7 @@ class Planner():
|
|||||||
if stop:
|
if stop:
|
||||||
self.ctrl.mach.stop()
|
self.ctrl.mach.stop()
|
||||||
|
|
||||||
self.planner = gplan.Planner()
|
self.planner = _load_gplan().Planner()
|
||||||
self.planner.set_resolver(self._get_var_cb)
|
self.planner.set_resolver(self._get_var_cb)
|
||||||
# TODO logger is global and will not work correctly in demo mode
|
# TODO logger is global and will not work correctly in demo mode
|
||||||
self.planner.set_logger(self._log_cb, 1, 'LinePlanner:3')
|
self.planner.set_logger(self._log_cb, 1, 'LinePlanner:3')
|
||||||
|
|||||||
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')
|
||||||
@@ -798,6 +798,32 @@ class RemoteDiagnosticsHandler(bbctrl.APIHandler):
|
|||||||
'message': e.reason or "Unknown"
|
'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
|
# Base class for Web Socket connections
|
||||||
class ClientConnection(object):
|
class ClientConnection(object):
|
||||||
def __init__(self, app):
|
def __init__(self, app):
|
||||||
@@ -873,6 +899,12 @@ class SockJSConnection(ClientConnection, sockjs.tornado.SockJSConnection):
|
|||||||
ip = info.ip
|
ip = info.ip
|
||||||
if 'X-Real-IP' in info.headers: ip = info.headers['X-Real-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)
|
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)
|
super().on_open(id)
|
||||||
|
|
||||||
|
|
||||||
@@ -881,6 +913,23 @@ class StaticFileHandler(tornado.web.StaticFileHandler):
|
|||||||
self.set_header('Cache-Control',
|
self.set_header('Cache-Control',
|
||||||
'no-store, no-cache, must-revalidate, max-age=0')
|
'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):
|
class Web(tornado.web.Application):
|
||||||
def __init__(self, args, ioloop):
|
def __init__(self, args, ioloop):
|
||||||
self.args = args
|
self.args = args
|
||||||
@@ -902,6 +951,8 @@ class Web(tornado.web.Application):
|
|||||||
|
|
||||||
handlers = [
|
handlers = [
|
||||||
(r'/websocket', WSConnection),
|
(r'/websocket', WSConnection),
|
||||||
|
(r'/api/diag/timing', TimingHandler),
|
||||||
|
(r'/api/diag/timing/ui', UITimingHandler),
|
||||||
(r'/api/log', LogHandler),
|
(r'/api/log', LogHandler),
|
||||||
(r'/api/message/(\d+)/ack', MessageAckHandler),
|
(r'/api/message/(\d+)/ack', MessageAckHandler),
|
||||||
(r'/api/bugreport', BugReportHandler),
|
(r'/api/bugreport', BugReportHandler),
|
||||||
|
|||||||
@@ -36,6 +36,13 @@ import datetime
|
|||||||
|
|
||||||
from pkg_resources import Requirement, resource_filename
|
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.RequestHandler import RequestHandler
|
||||||
from bbctrl.APIHandler import APIHandler
|
from bbctrl.APIHandler import APIHandler
|
||||||
from bbctrl.FileHandler import FileHandler
|
from bbctrl.FileHandler import FileHandler
|
||||||
@@ -64,6 +71,8 @@ import bbctrl.v4l2 as v4l2
|
|||||||
import bbctrl.Log as log
|
import bbctrl.Log as log
|
||||||
import bbctrl.ObjGraph as ObjGraph
|
import bbctrl.ObjGraph as ObjGraph
|
||||||
|
|
||||||
|
Trace.mark('imports.bbctrl.end')
|
||||||
|
|
||||||
|
|
||||||
ctrl = None
|
ctrl = None
|
||||||
|
|
||||||
@@ -167,19 +176,28 @@ def parse_args():
|
|||||||
def run():
|
def run():
|
||||||
global ctrl
|
global ctrl
|
||||||
|
|
||||||
|
Trace.mark('run.enter')
|
||||||
args = parse_args()
|
args = parse_args()
|
||||||
|
Trace.mark('args.parsed')
|
||||||
|
|
||||||
# Set signal handler
|
# Set signal handler
|
||||||
signal.signal(signal.SIGTERM, on_exit)
|
signal.signal(signal.SIGTERM, on_exit)
|
||||||
|
|
||||||
# Create ioloop
|
# Create ioloop
|
||||||
ioloop = tornado.ioloop.IOLoop.current()
|
ioloop = tornado.ioloop.IOLoop.current()
|
||||||
|
Trace.mark('ioloop.created')
|
||||||
|
|
||||||
# Set ObjGraph signal handler
|
# Set ObjGraph signal handler
|
||||||
if args.debug: Debugger(ioloop, args.debug)
|
if args.debug: Debugger(ioloop, args.debug)
|
||||||
|
|
||||||
# Start server
|
# Start server
|
||||||
|
with Trace.span('web.init'):
|
||||||
web = Web(args, ioloop)
|
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:
|
try:
|
||||||
ioloop.start()
|
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-family: 'Audiowide';
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
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) {
|
(function (window, document) {
|
||||||
|
var menuLink = document.getElementById("menuLink");
|
||||||
|
if (!menuLink) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var layout = document.getElementById('layout'),
|
var layout = document.getElementById("layout");
|
||||||
menu = document.getElementById('menu'),
|
var menu = document.getElementById("menu");
|
||||||
menuLink = document.getElementById('menuLink');
|
|
||||||
|
|
||||||
function toggleClass(element, className) {
|
function toggleClass(element, className) {
|
||||||
var classes = element.className.split(/\s+/),
|
if (!element) return;
|
||||||
length = classes.length,
|
var classes = element.className.split(/\s+/);
|
||||||
i = 0;
|
var i;
|
||||||
|
for (i = 0; i < classes.length; i++) {
|
||||||
for(; i < length; i++) {
|
|
||||||
if (classes[i] === className) {
|
if (classes[i] === className) {
|
||||||
classes.splice(i, 1);
|
classes.splice(i, 1);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The className is not found
|
if (i === classes.length) {
|
||||||
if (length === classes.length) {
|
|
||||||
classes.push(className);
|
classes.push(className);
|
||||||
}
|
}
|
||||||
|
element.className = classes.join(" ");
|
||||||
element.className = classes.join(' ');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
menuLink.onclick = function (e) {
|
menuLink.onclick = function (e) {
|
||||||
var active = 'active';
|
var active = "active";
|
||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
toggleClass(layout, active);
|
toggleClass(layout, active);
|
||||||
toggleClass(menu, active);
|
toggleClass(menu, active);
|
||||||
toggleClass(menuLink, active);
|
toggleClass(menuLink, active);
|
||||||
};
|
};
|
||||||
|
|
||||||
}(this, this.document));
|
}(this, this.document));
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -18,8 +18,8 @@
|
|||||||
<h1>Settings</h1>
|
<h1>Settings</h1>
|
||||||
|
|
||||||
<div class="pure-form pure-form-aligned">
|
<div class="pure-form pure-form-aligned">
|
||||||
<h2>User Interface</h2>
|
<h2 id="sec-display" data-sec="display">User Interface</h2>
|
||||||
<fieldset>
|
<fieldset data-sec="display">
|
||||||
<div class="pure-control-group">
|
<div class="pure-control-group">
|
||||||
<label for="screen-rotation" />
|
<label for="screen-rotation" />
|
||||||
<Button
|
<Button
|
||||||
@@ -45,8 +45,8 @@
|
|||||||
</div> -->
|
</div> -->
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<h2>Units</h2>
|
<h2 id="sec-units" data-sec="display">Units</h2>
|
||||||
<fieldset>
|
<fieldset data-sec="display">
|
||||||
<ConfigTemplatedInput key={`settings.units`} />
|
<ConfigTemplatedInput key={`settings.units`} />
|
||||||
<div class="tip">
|
<div class="tip">
|
||||||
Note, units sets both the machine default units and the units used in motor configuration. GCode program-start,
|
Note, units sets both the machine default units and the units used in motor configuration. GCode program-start,
|
||||||
@@ -54,13 +54,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<h2>Easy Adapter</h2>
|
<h2 id="sec-easy-adapter" data-sec="display">Easy Adapter</h2>
|
||||||
<fieldset>
|
<fieldset data-sec="display">
|
||||||
<ConfigTemplatedInput key={`settings.easy-adapter`} />
|
<ConfigTemplatedInput key={`settings.easy-adapter`} />
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<h2>Probing</h2>
|
<h2 id="sec-probing" data-sec="probing">Probing</h2>
|
||||||
<fieldset>
|
<fieldset data-sec="probing">
|
||||||
<ConfigTemplatedInput key={`settings.probing-prompts`} />
|
<ConfigTemplatedInput key={`settings.probing-prompts`} />
|
||||||
<div class="tip">
|
<div class="tip">
|
||||||
Onefinity highly recommends that you keep the safety prompts
|
Onefinity highly recommends that you keep the safety prompts
|
||||||
@@ -87,15 +87,15 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset>
|
<fieldset data-sec="gcode">
|
||||||
<h2>GCode</h2>
|
<h2 id="sec-gcode" data-sec="gcode">GCode</h2>
|
||||||
{#each Object.keys(configTemplate.gcode) as key}
|
{#each Object.keys(configTemplate.gcode) as key}
|
||||||
<ConfigTemplatedInput key={`gcode.${key}`} />
|
<ConfigTemplatedInput key={`gcode.${key}`} />
|
||||||
{/each}
|
{/each}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<h2>Path Accuracy</h2>
|
<h2 id="sec-path-accuracy" data-sec="gcode">Path Accuracy</h2>
|
||||||
<fieldset>
|
<fieldset data-sec="gcode">
|
||||||
<ConfigTemplatedInput key={`settings.max-deviation`} />
|
<ConfigTemplatedInput key={`settings.max-deviation`} />
|
||||||
|
|
||||||
<div class="tip">
|
<div class="tip">
|
||||||
@@ -118,8 +118,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<h2>Cornering Speed (Advanced)</h2>
|
<h2 id="sec-cornering" data-sec="gcode">Cornering Speed (Advanced)</h2>
|
||||||
<fieldset>
|
<fieldset data-sec="gcode">
|
||||||
<ConfigTemplatedInput key={`settings.junction-accel`} />
|
<ConfigTemplatedInput key={`settings.junction-accel`} />
|
||||||
<div class="tip">
|
<div class="tip">
|
||||||
Junction acceleration limits the cornering speed the planner
|
Junction acceleration limits the cornering speed the planner
|
||||||
|
|||||||
@@ -51,7 +51,7 @@
|
|||||||
>
|
>
|
||||||
<div slot="trailingIcon">
|
<div slot="trailingIcon">
|
||||||
{#if valid}
|
{#if valid}
|
||||||
<Icon class="fa fa-check-circle-o" style="color: green;" />
|
<Icon class="fa fa-circle-check" style="color: green;" />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<HelperText persistent slot="helper">{helperText}</HelperText>
|
<HelperText persistent slot="helper">{helperText}</HelperText>
|
||||||
|
|||||||
Reference in New Issue
Block a user