Compare commits
11 Commits
master
...
c7cf9483b3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7cf9483b3 | ||
| 54a15f9d12 | |||
| 704bc8d35c | |||
| 4d2d5fd88c | |||
| eab204b7be | |||
| e3c059eb9b | |||
| 7306464440 | |||
| 1625b768d8 | |||
| 5be7515a92 | |||
| 7d0755c55b | |||
| 7f8fd23615 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -27,8 +27,6 @@ __pycache__
|
||||
*.elf
|
||||
*.hex
|
||||
.idea/deployment.xml
|
||||
backup/*.img.gz
|
||||
backup/*.partial
|
||||
|
||||
# Demo mode artifacts
|
||||
bbctrl.log*
|
||||
|
||||
77
AGENTS.md
77
AGENTS.md
@@ -1,77 +0,0 @@
|
||||
# Onefinity firmware — agent guidelines
|
||||
|
||||
## Branch model
|
||||
|
||||
This fork lives on **two long-lived branches**:
|
||||
|
||||
- **`master`** — public-facing fork. General-use upgrades on top of
|
||||
upstream OneFinity firmware: V09 UX redesign, Font Awesome 6, faster
|
||||
cold boot, macOS dev/deploy tooling, build & flash docs, SD-card
|
||||
backup, `/api/diag/timing`, kiosk/tablet polish, and assorted
|
||||
bug-fixes. **No A-axis, ATC, hooks, or auxcnc/ESP content.** Aim for
|
||||
changes that benefit any Onefinity owner.
|
||||
|
||||
- **`private-mods`** — bespoke shop branch. Stacks on top of `master`
|
||||
and adds everything specific to the auxcnc-ESP-driven A axis and
|
||||
the ATC: `Hooks` (ATC IPC), `AuxAxis` (ESP serial driver),
|
||||
`ExternalAxis` (virtual A through gplan), `AuxPreprocessor` (M100-M103),
|
||||
Z-A coupling interlock, the A-axis UI surface, and the
|
||||
`/api/aux/*` endpoints.
|
||||
|
||||
Upstream:
|
||||
- `upstream` → `https://github.com/OneFinityCNC/onefinity-firmware.git`
|
||||
- `origin` → Gitea (`https://gitea.home.muehe.org/muehe/onefinity-firmware.git`)
|
||||
|
||||
`origin/pre-split-backup` is a tag preserving the pre-split master
|
||||
tip. Keep it indefinitely until further notice.
|
||||
|
||||
## Where does a change go?
|
||||
|
||||
| Change | Branch |
|
||||
|---|---|
|
||||
| UI polish, theme, layout that any user benefits from | `master` |
|
||||
| Build / install / boot performance | `master` |
|
||||
| Diagnostics, logging, generic Python / Tornado fixes | `master` |
|
||||
| Anything that touches `AuxAxis`, `ExternalAxis`, `Hooks`, `AuxPreprocessor` | `private-mods` |
|
||||
| Anything mentioning the auxcnc ESP, `/dev/ttyUSB0`, the M100-M103 ATC pneumatics, or motor index 4 | `private-mods` |
|
||||
| Z-A coupling interlock, ATC tool change sequencing | `private-mods` |
|
||||
| A-axis UI (DRO row, jog tile, settings page, A-axis routes) | `private-mods` |
|
||||
| W → A renames or aux.json migrations | `private-mods` |
|
||||
|
||||
When in doubt: ask "would this be useful on a stock Onefinity with no
|
||||
ESP attached?" If yes → `master`. If no → `private-mods`.
|
||||
|
||||
## Workflow
|
||||
|
||||
```bash
|
||||
# Day-to-day shop / hardware work (default)
|
||||
git checkout private-mods
|
||||
# … do work, commit …
|
||||
git push origin private-mods
|
||||
|
||||
# Generic improvement to master
|
||||
git checkout master
|
||||
# … do work, commit …
|
||||
git push origin master
|
||||
|
||||
# After landing on master, replay private-mods on top
|
||||
git checkout private-mods
|
||||
git rebase master
|
||||
git push --force-with-lease origin private-mods
|
||||
```
|
||||
|
||||
If a change accidentally lands on `master` but is bespoke (touches
|
||||
the file table above), move it: `git reset --hard <prev>` on master,
|
||||
cherry-pick onto `private-mods`, force-push master.
|
||||
|
||||
## Deploy
|
||||
|
||||
- `./deploy.sh local` — UI bundle on `localhost:8770` (tmux session
|
||||
`onefin-local`). No controller backend; A-axis row stays hidden.
|
||||
- `./deploy.sh hardware` — rsync to the Pi over SSH, restart
|
||||
`bbctrl.service`. Use the `private-mods` branch on the shop Pi.
|
||||
- `./deploy.sh prod` — bundle a release tarball.
|
||||
|
||||
See `.pi/BUILD.md` for the full build / flash / cross-compile flow.
|
||||
|
||||
## Commit before ending a turn; push after significant changes.
|
||||
61
CHANGELOG.md
61
CHANGELOG.md
@@ -1,67 +1,6 @@
|
||||
OneFinity CNC Controller Firmware Changelog
|
||||
===========================================
|
||||
|
||||
## Unreleased (community fork)
|
||||
|
||||
General-use additions on top of upstream OneFinity firmware.
|
||||
|
||||
### UI
|
||||
- V09 redesign: 4-tab top header (Control / Program / Console /
|
||||
Settings) replaces the legacy side menu.
|
||||
- Control: redesigned DRO with per-axis offset + zero + home
|
||||
actions, jog grid with consistent button sizing across kiosk
|
||||
and tablet, status strip with live state / velocity / spindle.
|
||||
- Program: dedicated tab for run / pause / stop, file browser,
|
||||
toolpath preview.
|
||||
- Console: MDI shell, message log, indicators.
|
||||
- Settings: rail-driven inner pages so each section is its own
|
||||
focused panel rather than one long scroll.
|
||||
- Tablet mode (`?tablet=1`) pins the UI to 1920x1080 and scales
|
||||
it to fit the actual viewport.
|
||||
- Kiosk mode (`?kiosk=1`, auto on localhost): tighter layout for
|
||||
the controller's onboard 1366x768 screen.
|
||||
- Font Awesome 6 throughout (replaces FA4).
|
||||
- Fix: stop clobbering motor settings while the user is editing
|
||||
them.
|
||||
- Fix: keep jog grid visible during jog/home/probe/MDI activity.
|
||||
- Fix: opaque dark canvas for path-viewer (no flash through page
|
||||
background).
|
||||
- Fix: OrbitControls now uses non-passive wheel/touch listeners so
|
||||
it can suppress page panning while interacting with the 3D
|
||||
viewer.
|
||||
- Fix: macros tab no longer renders placeholder color stripes for
|
||||
`#dedede`/`#fff`-only macros.
|
||||
- Fix: hide the X cursor in kiosk mode (touchscreen).
|
||||
- Fix: chromium 72 mime + flex-gap fallbacks (some kiosk Pis ship
|
||||
with that older browser build).
|
||||
- Fix: Vue 1 async batching disabled so reactive writes from
|
||||
`hashchange` listeners propagate synchronously.
|
||||
|
||||
### Boot / install
|
||||
- Cold-boot optimisations cutting bbctrl listen latency by ~8s on
|
||||
the Pi (mask sysstat, replace dphys-swapfile with an fstab swap
|
||||
entry, lazy-load `camotics.gplan`, `bbserial-rebind.service`
|
||||
with explicit `Before=bbctrl.service`).
|
||||
- `install.sh` now ships these with firmware updates.
|
||||
- `bbctrl.Trace` + `/api/diag/timing` for measuring startup, with
|
||||
a UI-side `restart-timing.js` client that POSTs browser marks.
|
||||
- `Camera.py` switched from deprecated `@web.asynchronous` to
|
||||
`async def` so the streaming endpoint works on newer Tornado.
|
||||
- `Log.py` tolerates missing rotated log files on startup
|
||||
(concurrent logrotate runs from `/etc/cron.reboot` no longer
|
||||
crash bbctrl).
|
||||
|
||||
### Build / tooling
|
||||
- `.pi/BUILD.md`: end-to-end macOS dev workflow, deploy paths,
|
||||
troubleshooting.
|
||||
- `.pi/Dockerfile.gplan` + `build-gplan.sh`: rebuild `gplan.so`
|
||||
from source on Raspbian Stretch (Bullseye is too new).
|
||||
- `deploy.sh` dispatcher with `local`, `hardware`, `prod` modes.
|
||||
- `backup/onefinity-backup.sh`: dd-based whole-card backup/restore
|
||||
with shrink/expand support.
|
||||
- `Makefile`: ensure trailing newlines between concatenated pug
|
||||
templates so Pug doesn't glue file boundaries together.
|
||||
|
||||
## v1.0.8
|
||||
- Fixed chatter and lost steps issues (most commonly seen by Fusion users), re-enabled support for G61, G61.1, G64.
|
||||
- Fixed 3d preview on Safari-based web browsers (MacOS & iOS)
|
||||
|
||||
6
Makefile
6
Makefile
@@ -68,11 +68,7 @@ update: pkg
|
||||
|
||||
build/templates.pug: $(TEMPLS)
|
||||
mkdir -p build
|
||||
# Use awk to ensure each template is followed by a newline so the
|
||||
# next file's first line never gets glued onto the previous file's
|
||||
# last line (some templates ship without a trailing newline, which
|
||||
# would produce subtle Pug parse failures).
|
||||
awk 'FNR==1 && NR>1 {print ""} {print} END{print ""}' $(TEMPLS) >$@
|
||||
cat $(TEMPLS) >$@
|
||||
|
||||
node_modules: package.json
|
||||
npm install && touch node_modules
|
||||
|
||||
111
README.md
111
README.md
@@ -1,110 +1 @@
|
||||
# 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.
|
||||
#OneFinity CNC Controller Firmware
|
||||
|
||||
@@ -1,278 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Shorthand for ./deploy.sh hardware
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
exec "$SCRIPT_DIR/deploy.sh" hardware "$@"
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Shorthand for ./deploy.sh local
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
exec "$SCRIPT_DIR/deploy.sh" local "$@"
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Shorthand for ./deploy.sh prod
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
exec "$SCRIPT_DIR/deploy.sh" prod "$@"
|
||||
52
deploy.sh
52
deploy.sh
@@ -1,52 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Onefinity firmware deploy script.
|
||||
#
|
||||
# ./deploy.sh local — build & static-serve the UI on macOS
|
||||
# (chrome only; no controller, shows
|
||||
# DISCONNECTED overlay)
|
||||
# ./deploy.sh hardware — fast iteration: rsync build/http/
|
||||
# contents to the running Pi at
|
||||
# onefinity.local, then restart bbctrl
|
||||
# ./deploy.sh prod — full firmware update via the Pi's
|
||||
# /api/firmware/update endpoint
|
||||
# (equivalent to `make update`)
|
||||
#
|
||||
# Notes:
|
||||
# * On macOS we cannot run the Python `bbctrl` controller directly
|
||||
# because it imports the ARM-only camotics gplan.so. For full UI
|
||||
# testing with live data, deploy to the Pi (hardware or prod).
|
||||
# * `prod` requires a clean working tree.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
CMD="${1:-}"
|
||||
|
||||
case "$CMD" in
|
||||
local) exec "$SCRIPT_DIR/scripts/deploy/local.sh" "$@" ;;
|
||||
hardware) exec "$SCRIPT_DIR/scripts/deploy/hardware.sh" "$@" ;;
|
||||
prod) exec "$SCRIPT_DIR/scripts/deploy/prod.sh" "$@" ;;
|
||||
*)
|
||||
cat <<USAGE
|
||||
usage: $0 {local | hardware | prod}
|
||||
|
||||
local Build the UI and static-serve build/http/ in a tmux session
|
||||
on macOS. Useful for iterating on the V09 chrome and routing.
|
||||
URL: http://localhost:8770/
|
||||
tmux: tmux attach -t onefin-local
|
||||
|
||||
hardware Fast iteration on the actual controller: rsync the freshly
|
||||
built build/http/ tree onto onefinity.local, then restart
|
||||
the bbctrl service. Requires SSH access as bbmc@onefinity.local.
|
||||
Defaults: HOST=onefinity.local PASSWORD=onefinity
|
||||
|
||||
prod Build a full firmware package (.tar.bz2) and PUT it through
|
||||
/api/firmware/update on the Pi. Equivalent to:
|
||||
make update HOST=onefinity.local PASSWORD=onefinity
|
||||
Requires a clean working tree.
|
||||
USAGE
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
144
docs/AUX_W_AXIS.md
Normal file
144
docs/AUX_W_AXIS.md
Normal file
@@ -0,0 +1,144 @@
|
||||
# W axis (auxcnc) integration
|
||||
|
||||
This adds a virtual `W` axis to the bbctrl controller, driven by the
|
||||
auxcnc ESP32 over USB serial (`/dev/ttyUSB0`). The ESP owns step-pulse
|
||||
generation, real-time limit-switch monitoring, and the homing dance.
|
||||
The Pi owns units (mm), soft limits, sequencing inside G-code jobs, and
|
||||
a small REST API for jogging / homing from the UI.
|
||||
|
||||
## How it works
|
||||
|
||||
The bbctrl planner (gplan) only understands `xyzabc`, so adding a true
|
||||
7th axis would require rebuilding gplan + the AVR firmware. We avoid
|
||||
that by treating W as a synchronous out-of-band axis: W moves run
|
||||
*between* G-code blocks, not blended with XYZ.
|
||||
|
||||
Pipeline:
|
||||
|
||||
1. User uploads a G-code file containing `W` words.
|
||||
2. `FileHandler` runs `AuxPreprocessor` on the upload, rewriting W
|
||||
tokens in place into `(MSG,HOOK:aux:<mm>)` etc. The original line
|
||||
minus the W word continues to drive XYZ.
|
||||
3. The planner sees only XYZ + message comments. When it reaches a
|
||||
message line, the message goes through `state.add_message` which
|
||||
`Hooks._on_state_change` watches for the `HOOK:` prefix.
|
||||
4. `Hooks._fire('custom', ...)` finds the registered internal handler
|
||||
for the event name (`aux`, `aux_rel`, `aux_home`, `aux_setzero`).
|
||||
5. The handler runs in a hook thread, gating `Mach.unpause` until done.
|
||||
While the handler is busy the machine is in HOLDING - no XYZ motion
|
||||
can resume until W finishes.
|
||||
6. The handler talks to the ESP over `/dev/ttyUSB0` via `AuxAxis`,
|
||||
blocking on a deterministic reply token (`[step] done`, `[home]
|
||||
done`, etc).
|
||||
|
||||
MDI commands containing `W` words are rewritten the same way at the
|
||||
`Mach.mdi()` boundary so manual jog and macros work too.
|
||||
|
||||
## G-code surface
|
||||
|
||||
```gcode
|
||||
G21 G90
|
||||
G28 W0 ; home W axis
|
||||
G1 W25 F300 ; move W to 25 mm absolute
|
||||
G1 X100 W12.5 ; mixed: W moves first, then XYZ (configurable)
|
||||
G91
|
||||
G1 W-2.5 ; relative W move
|
||||
G90
|
||||
G92 W0 ; set current W as zero (G92-style)
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- `G28` / `G28.2` with W only -> homing hook; the bare `G28` is NOT
|
||||
emitted to gplan (that would mean home-all).
|
||||
- `G28.2 X0 Y0 W0` -> emit hook, then keep `G28.2 X0 Y0` for XY homing.
|
||||
- A line with both W and XYZ axis words is split into two sequential
|
||||
blocks. Default order: W first, then XYZ. Toggle via the
|
||||
`w_first` constructor arg.
|
||||
- Lines inside parens or after `;` are passed through verbatim.
|
||||
|
||||
## Configuration
|
||||
|
||||
Per-controller config lives at `<ctrl_path>/aux.json` (created on first
|
||||
save via the API). Keys:
|
||||
|
||||
| Key | Default | Notes |
|
||||
|------------------------|----------------|------------------------------------|
|
||||
| `enabled` | `false` | Master switch |
|
||||
| `port` | `/dev/ttyUSB0` | Serial device |
|
||||
| `baud` | `115200` | |
|
||||
| `steps_per_mm` | `80.0` | Logical steps per mm |
|
||||
| `dir_sign` | `1` | +1 or -1: maps logical+ to motor+ |
|
||||
| `min_w`, `max_w` | `0`, `100` | Soft limits in mm |
|
||||
| `home_dir` | `'-'` | Direction toward limit switch |
|
||||
| `home_position_mm` | `0.0` | mm value assigned at home |
|
||||
| `home_fast_sps` | `4000` | Fast seek rate |
|
||||
| `home_slow_sps` | `400` | Slow re-seek rate |
|
||||
| `home_backoff_steps` | `200` | Backoff after touching limit |
|
||||
| `home_maxtravel_steps` | `200000` | Hard cap on phase 1 seek |
|
||||
| `step_max_sps` | `4000` | Cruise rate for STEPS |
|
||||
| `step_accel_sps2` | `16000` | Trapezoidal ramp accel |
|
||||
| `step_start_sps` | `200` | Ramp floor |
|
||||
| `limit_low` | `true` | Switch active low (closed = LOW) |
|
||||
|
||||
Most of these are pushed to the ESP via `HOMECFG` on connect and
|
||||
persisted there in NVS.
|
||||
|
||||
## REST API
|
||||
|
||||
| Verb | Path | Body | Effect |
|
||||
|------|----------------------------|-----------------------|------------------------|
|
||||
| GET | `/api/aux/config` | - | Current config |
|
||||
| PUT | `/api/aux/config/save` | `{key: val, ...}` | Save and re-push |
|
||||
| GET | `/api/aux/status` | - | `{enabled, present, homed, pos_mm}` |
|
||||
| PUT | `/api/aux/home` | - | Run home cycle (blocks)|
|
||||
| PUT | `/api/aux/abort` | - | Cancel running motion |
|
||||
| PUT | `/api/aux/jog` | `{mm: 1.5}` or `{steps: 200}` | Relative move |
|
||||
| PUT | `/api/aux/move` | `{mm: 12.5}` | Absolute move (mm) |
|
||||
| PUT | `/api/aux/set-zero` | `{mm: 0}` | Set current pos to mm |
|
||||
|
||||
Steps-mode jog ignores soft limits (use it to inch the axis to the
|
||||
limit switch when the axis isn't homed yet).
|
||||
|
||||
## State surface (UI)
|
||||
|
||||
These are pushed via `state.set` and visible in the websocket stream:
|
||||
|
||||
- `aux_enabled` - bool, axis is configured + enabled
|
||||
- `aux_present` - bool, ESP responding on serial
|
||||
- `aux_homed` - bool, has been homed since last ESP reset
|
||||
- `aux_pos` - float, current W in mm (4 decimals)
|
||||
|
||||
## Edge cases
|
||||
|
||||
- **ESP reboots mid-job**: `[boot] auxcnc v=N` banner -> `aux_homed`
|
||||
cleared, message added: "W axis controller restarted - re-home
|
||||
before use". Subsequent W moves still run; if you want a hard fail
|
||||
instead, that's a one-line change in `_require_present`.
|
||||
- **Limit switch closed at boot of HOME**: `[home] failed
|
||||
reason=already_at_limit` -> hook raises -> Mach surfaces error.
|
||||
- **Pause mid-W-move**: the hook is blocking, so feed-hold takes
|
||||
effect *after* the W move completes. For an immediate stop hit
|
||||
estop; the Hooks listener will call `aux.abort()` which sends
|
||||
`ABORT\n` to the ESP and the step-pulse loop exits.
|
||||
- **Connection loss**: if `/dev/ttyUSB0` can't be opened at startup,
|
||||
`aux_present=False` and any G-code with W will fail-fast at the
|
||||
hook handler with "Aux axis not connected".
|
||||
- **No home enforcement**: per design, manual jogs and W moves are
|
||||
allowed even without a successful home. Soft limits still apply
|
||||
unless you use the raw step jog endpoint.
|
||||
|
||||
## Files added/changed
|
||||
|
||||
- `src/py/bbctrl/AuxAxis.py` (new): serial worker + RPC layer
|
||||
- `src/py/bbctrl/AuxPreprocessor.py` (new): G-code rewriter
|
||||
- `src/py/bbctrl/Hooks.py`: register_internal(), fix the messages
|
||||
listener so `(MSG,HOOK:...)` actually fires
|
||||
- `src/py/bbctrl/Ctrl.py`: instantiate AuxAxis, register hooks
|
||||
- `src/py/bbctrl/Mach.py`: rewrite MDI commands containing W
|
||||
- `src/py/bbctrl/FileHandler.py`: rewrite uploads in place
|
||||
- `src/py/bbctrl/Web.py`: REST endpoints
|
||||
- `src/py/bbctrl/__init__.py`: export AuxAxis
|
||||
- `auxcnc/src/main.cpp`: new commands HOME, HOMECFG, WPOS, HOMED?,
|
||||
LIMIT?, ABORT-able STEPS with limit-aware abort, trapezoidal ramps,
|
||||
NVS-persisted config, `[boot]` banner, deterministic reply tokens
|
||||
@@ -1,24 +1,13 @@
|
||||
[Unit]
|
||||
Description=Buildbotics Controller
|
||||
# Note: bbctrl previously had `After=network.target`. That delays
|
||||
# start by ~5s on this Pi while dhcpcd brings up wlan0/eth0, but
|
||||
# bbctrl does not actually require network connectivity to come up
|
||||
# (the AVR is on a local serial port, the LCD on I2C). Dropping it
|
||||
# means the Pi shows the UI faster on cold boot. The wifi config UI
|
||||
# still works because it queries iw/dhcpcd lazily on demand.
|
||||
After=local-fs.target bbserial-rebind.service
|
||||
Wants=bbserial-rebind.service
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
User=root
|
||||
ExecStart=/usr/local/bin/bbctrl -l /var/log/bbctrl.log
|
||||
WorkingDirectory=/var/lib/bbctrl
|
||||
Restart=always
|
||||
# StandardOutput was 'null'. Set to 'journal' so TRACE lines emitted by
|
||||
# bbctrl.Trace are visible via `journalctl -u bbctrl`. Bbctrl still
|
||||
# writes its own log via -l above; this only affects stdout/stderr.
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
StandardOutput=null
|
||||
Nice=-10
|
||||
KillMode=process
|
||||
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
[Unit]
|
||||
Description=Unbind ttyAMA0 from pl011 and reload bbserial
|
||||
DefaultDependencies=no
|
||||
After=systemd-modules-load.service local-fs.target
|
||||
Before=bbctrl.service
|
||||
ConditionPathExists=/sys/bus/amba/drivers/uart-pl011
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
# Tolerate the device already being bound elsewhere or the module
|
||||
# already being loaded — the goal is the end state (bbserial owns
|
||||
# ttyAMA0), not running the steps.
|
||||
ExecStart=/bin/sh -c '\
|
||||
echo 3f201000.serial > /sys/bus/amba/drivers/uart-pl011/unbind 2>/dev/null || true; \
|
||||
/sbin/modprobe -r bbserial 2>/dev/null || true; \
|
||||
/sbin/modprobe bbserial \
|
||||
'
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,84 +0,0 @@
|
||||
#!/bin/bash
|
||||
# --- Hardware iteration (live Pi at onefinity.local) ---
|
||||
#
|
||||
# Rsyncs the freshly built static UI tree (build/http/) onto the Pi's
|
||||
# bbctrl egg directory and restarts bbctrl. This is much faster than
|
||||
# a full firmware update and is the fastest way to iterate on the V09
|
||||
# UI changes against real machine state (W axis, jog feedback, etc).
|
||||
#
|
||||
# Defaults:
|
||||
# HOST=onefinity.local
|
||||
# REMOTE_USER=bbmc
|
||||
# PASSWORD=onefinity (used for sudo on the Pi)
|
||||
#
|
||||
# Override:
|
||||
# HOST=10.1.10.55 ./deploy.sh hardware
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
HOST="${HOST:-onefinity.local}"
|
||||
REMOTE_USER="${REMOTE_USER:-bbmc}"
|
||||
PASSWORD="${PASSWORD:-onefinity}"
|
||||
|
||||
echo "Building UI bundle (HTML + resources)..."
|
||||
make build/http/index.html >/dev/null
|
||||
# Copy src/resources/* into build/http/. The Makefile's "all" target
|
||||
# also does this, but pulls in cross-compiled subprojects (avr/boot/
|
||||
# pwr/jig) we don't have toolchains for on macOS. This rsync mirrors
|
||||
# only the resource tree.
|
||||
rsync -a src/resources/ build/http/
|
||||
|
||||
echo "Locating bbctrl http/ directory on $HOST..."
|
||||
REMOTE_HTTP_DIR="$(ssh -o ConnectTimeout=5 "${REMOTE_USER}@${HOST}" \
|
||||
"ls -d /usr/local/lib/python*/dist-packages/bbctrl-*-py*.egg/bbctrl/http 2>/dev/null | head -1")"
|
||||
if [[ -z "$REMOTE_HTTP_DIR" ]]; then
|
||||
echo "ERROR: could not find bbctrl http/ directory on $HOST"
|
||||
exit 1
|
||||
fi
|
||||
echo " $REMOTE_HTTP_DIR"
|
||||
|
||||
echo "Rsyncing build/http/ -> $HOST:$REMOTE_HTTP_DIR/"
|
||||
# Stage to a tmp dir owned by $REMOTE_USER, then sudo-rsync into
|
||||
# place. This avoids needing root over rsync. We do NOT use --delete
|
||||
# anywhere -- the Pi's egg ships extra runtime files (config-template
|
||||
# .json, default machine JSON, buildbotics.nc, etc.) that come with
|
||||
# the bbctrl package and are not in this repo's src/resources. If
|
||||
# they were deleted the controller's API would 500 because Python
|
||||
# imports fail.
|
||||
REMOTE_TMP="/tmp/onefin_ui_$$"
|
||||
ssh -o ConnectTimeout=5 "${REMOTE_USER}@${HOST}" "mkdir -p '${REMOTE_TMP}'"
|
||||
rsync -avz \
|
||||
--exclude='hostinfo.txt' \
|
||||
-e "ssh -o ConnectTimeout=5" \
|
||||
build/http/ "${REMOTE_USER}@${HOST}:${REMOTE_TMP}/"
|
||||
|
||||
echo "Installing into ${REMOTE_HTTP_DIR}/ (sudo)..."
|
||||
ssh -o ConnectTimeout=5 "${REMOTE_USER}@${HOST}" \
|
||||
"echo '${PASSWORD}' | sudo -S bash -c '
|
||||
rsync -a --exclude=hostinfo.txt \"${REMOTE_TMP}/\" \"${REMOTE_HTTP_DIR}/\" \
|
||||
&& rm -rf \"${REMOTE_TMP}\"
|
||||
'" 2>&1 | tail -3
|
||||
|
||||
# Patch bbctrl Web.py so font files get the correct MIME type. The
|
||||
# Pi ships Python 3.5, whose `mimetypes` module doesn't know about
|
||||
# woff/woff2/ttf, so Tornado serves them as application/octet-stream
|
||||
# which Chromium 72 (the Pi's onboard browser) refuses to use as a
|
||||
# web font, leading to all FontAwesome icons rendering as empty
|
||||
# boxes in the kiosk UI. The patch is idempotent.
|
||||
echo "Patching bbctrl font MIME types (idempotent)..."
|
||||
scp -o ConnectTimeout=5 "$SCRIPT_DIR/scripts/deploy/patch_font_mime.py" \
|
||||
"${REMOTE_USER}@${HOST}:/tmp/patch_font_mime.py" >/dev/null
|
||||
ssh -o ConnectTimeout=5 "${REMOTE_USER}@${HOST}" \
|
||||
"echo '${PASSWORD}' | sudo -S python3 /tmp/patch_font_mime.py" 2>&1 | tail -3
|
||||
|
||||
echo "Restarting bbctrl service..."
|
||||
ssh -o ConnectTimeout=5 "${REMOTE_USER}@${HOST}" \
|
||||
"echo '${PASSWORD}' | sudo -S systemctl restart bbctrl" 2>&1 | tail -3
|
||||
|
||||
echo ""
|
||||
echo "Deployed to http://${HOST}/"
|
||||
echo " Logs: ssh ${REMOTE_USER}@${HOST} 'journalctl -u bbctrl -f'"
|
||||
echo " Open: open -a 'Google Chrome' http://${HOST}/"
|
||||
@@ -1,72 +0,0 @@
|
||||
#!/bin/bash
|
||||
# --- Local development (macOS) ---
|
||||
#
|
||||
# Builds the UI bundle and static-serves it on http://localhost:8770/.
|
||||
# Runs in a named tmux session so we can iterate (re-running this script
|
||||
# rebuilds and restarts the server in-place, you keep your browser tab).
|
||||
#
|
||||
# What you'll see:
|
||||
# * The full V09 chrome (header tabs, settings rail, jog grid, DRO
|
||||
# skeleton, status strip).
|
||||
# * A "DISCONNECTED" overlay because there's no controller backend.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
echo "🛠 Building UI bundle..."
|
||||
make build/http/index.html >/dev/null
|
||||
|
||||
PORT="${PORT:-8770}"
|
||||
SESSION="onefin-local"
|
||||
|
||||
ensure_tmux_window() {
|
||||
local session="$1"
|
||||
local window="${2:-}"
|
||||
local target="${session}${window:+:$window}"
|
||||
if tmux has-session -t "$session" 2>/dev/null; then
|
||||
if tmux send-keys -t "$target" "" 2>/dev/null; then
|
||||
echo "🔁 Reusing tmux session '$session'..."
|
||||
tmux send-keys -t "$target" C-c
|
||||
sleep 1
|
||||
return
|
||||
fi
|
||||
echo "⚠️ Dead pane in '$session', recreating..."
|
||||
tmux kill-session -t "$session" 2>/dev/null
|
||||
fi
|
||||
echo "🆕 Creating tmux session '$session'..."
|
||||
tmux new-session -d -s "$session"
|
||||
}
|
||||
|
||||
ensure_tmux_window "$SESSION"
|
||||
|
||||
# Free the port if a previous run is still listening.
|
||||
if lsof -iTCP:"$PORT" -sTCP:LISTEN >/dev/null 2>&1; then
|
||||
echo "⚠️ Port $PORT is busy; killing previous server..."
|
||||
lsof -tiTCP:"$PORT" -sTCP:LISTEN | xargs -r kill 2>/dev/null || true
|
||||
sleep 1
|
||||
fi
|
||||
|
||||
tmux send-keys -t "$SESSION" \
|
||||
"cd '$SCRIPT_DIR' && python3 -m http.server --directory build/http $PORT" \
|
||||
C-m
|
||||
|
||||
echo ""
|
||||
echo "✅ Static UI server started on http://localhost:$PORT/"
|
||||
echo ""
|
||||
echo " Routes to try:"
|
||||
echo " http://localhost:$PORT/#control"
|
||||
echo " http://localhost:$PORT/#program"
|
||||
echo " http://localhost:$PORT/#console"
|
||||
echo " http://localhost:$PORT/#settings (Display & Units)"
|
||||
echo " http://localhost:$PORT/#admin-network (WiFi / IP)"
|
||||
echo " http://localhost:$PORT/#motor:0 (Motor 0 settings)"
|
||||
echo ""
|
||||
echo " tmux: tmux attach -t $SESSION"
|
||||
echo " stop: tmux kill-session -t $SESSION"
|
||||
echo ""
|
||||
echo "ℹ️ No controller is running, so the page shows DISCONNECTED and"
|
||||
echo " axis values stay empty. For live data + W axis, run:"
|
||||
echo " ./deploy.sh hardware (fast: rsync build/http -> Pi)"
|
||||
echo " ./deploy.sh prod (full firmware update)"
|
||||
@@ -1,102 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Patch bbctrl Web.py so font files get the correct MIME type.
|
||||
|
||||
Background
|
||||
----------
|
||||
The Onefinity controller (Pi 3B running Raspbian stretch) ships Python
|
||||
3.5, whose ``mimetypes`` module does not recognize ``.woff``, ``.woff2``
|
||||
or ``.ttf``. Tornado's ``StaticFileHandler`` therefore falls back to
|
||||
``application/octet-stream`` for those, and Chromium 72 (the Pi's
|
||||
onboard kiosk browser) refuses to use such payloads as web fonts. The
|
||||
result is that every FontAwesome icon renders as an empty box on the
|
||||
kiosk display.
|
||||
|
||||
This patch monkey-patches ``StaticFileHandler.get_content_type`` to
|
||||
emit the right MIME types. It is idempotent: running it twice is a
|
||||
no-op. Run with ``sudo`` so it can rewrite the egg's Web.py.
|
||||
|
||||
Used by:
|
||||
scripts/deploy/hardware.sh
|
||||
"""
|
||||
|
||||
from __future__ import print_function
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def find_web_py():
|
||||
"""Return the absolute path to the bbctrl Web.py shipped in the egg."""
|
||||
base = "/usr/local/lib"
|
||||
for entry in os.listdir(base):
|
||||
if not entry.startswith("python"):
|
||||
continue
|
||||
candidate_dir = os.path.join(base, entry, "dist-packages")
|
||||
if not os.path.isdir(candidate_dir):
|
||||
continue
|
||||
for sub in os.listdir(candidate_dir):
|
||||
if sub.startswith("bbctrl-") and sub.endswith(".egg"):
|
||||
p = os.path.join(candidate_dir, sub, "bbctrl", "Web.py")
|
||||
if os.path.isfile(p):
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
OLD_BLOCK = (
|
||||
"class StaticFileHandler(tornado.web.StaticFileHandler):\n"
|
||||
" def set_extra_headers(self, path):\n"
|
||||
" self.set_header('Cache-Control',\n"
|
||||
" 'no-store, no-cache, must-revalidate, max-age=0')"
|
||||
)
|
||||
|
||||
NEW_BLOCK = (
|
||||
"class StaticFileHandler(tornado.web.StaticFileHandler):\n"
|
||||
" # FONT_MIME_FIX: Python 3.5's mimetypes module does not know\n"
|
||||
" # woff/woff2/ttf, so Tornado serves them as application/octet-\n"
|
||||
" # stream which Chromium 72 (the Pi's onboard kiosk browser)\n"
|
||||
" # refuses to use as web fonts. Set explicit types so the FA6\n"
|
||||
" # icon set actually renders on the kiosk display.\n"
|
||||
" def get_content_type(self):\n"
|
||||
" path = self.absolute_path or ''\n"
|
||||
" if path.endswith('.woff2'): return 'font/woff2'\n"
|
||||
" if path.endswith('.woff'): return 'font/woff'\n"
|
||||
" if path.endswith('.ttf'): return 'font/ttf'\n"
|
||||
" if path.endswith('.otf'): return 'font/otf'\n"
|
||||
" if path.endswith('.eot'): return 'application/vnd.ms-fontobject'\n"
|
||||
" return super().get_content_type()\n"
|
||||
"\n"
|
||||
" def set_extra_headers(self, path):\n"
|
||||
" self.set_header('Cache-Control',\n"
|
||||
" 'no-store, no-cache, must-revalidate, max-age=0')"
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
target = find_web_py()
|
||||
if target is None:
|
||||
print("ERROR: could not locate bbctrl Web.py under /usr/local/lib",
|
||||
file=sys.stderr)
|
||||
return 1
|
||||
|
||||
with open(target) as f:
|
||||
src = f.read()
|
||||
|
||||
if "FONT_MIME_FIX" in src:
|
||||
print("font mime: already patched ({})".format(target))
|
||||
return 0
|
||||
|
||||
if OLD_BLOCK not in src:
|
||||
print("font mime: expected block not found in {} -- skipping".format(target),
|
||||
file=sys.stderr)
|
||||
# Don't fail the deploy; just log and continue.
|
||||
return 0
|
||||
|
||||
new_src = src.replace(OLD_BLOCK, NEW_BLOCK, 1)
|
||||
with open(target, "w") as f:
|
||||
f.write(new_src)
|
||||
print("font mime: patched {}".format(target))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,42 +0,0 @@
|
||||
#!/bin/bash
|
||||
# --- Production firmware update (Pi at onefinity.local) ---
|
||||
#
|
||||
# Builds a full firmware package (.tar.bz2) and PUTs it through the Pi's
|
||||
# /api/firmware/update endpoint. This is the canonical OTA flow and goes
|
||||
# through the bbctrl Tornado server's update handler.
|
||||
#
|
||||
# Defaults:
|
||||
# HOST=onefinity.local
|
||||
# PASSWORD=onefinity
|
||||
#
|
||||
# Override:
|
||||
# HOST=10.1.10.55 PASSWORD=secret ./deploy.sh prod
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
HOST="${HOST:-onefinity.local}"
|
||||
PASSWORD="${PASSWORD:-onefinity}"
|
||||
|
||||
# Require a clean working tree.
|
||||
echo "🔍 Checking git state..."
|
||||
if ! git diff --quiet || ! git diff --cached --quiet \
|
||||
|| [[ -n "$(git ls-files --others --exclude-standard)" ]]; then
|
||||
echo "❌ Refusing to deploy: working tree has uncommitted changes."
|
||||
git status --short
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Working tree is clean."
|
||||
|
||||
echo "🛠 Building firmware package..."
|
||||
make pkg
|
||||
|
||||
echo "🚚 Uploading to http://${HOST}/api/firmware/update..."
|
||||
make update HOST="$HOST" PASSWORD="$PASSWORD"
|
||||
|
||||
echo ""
|
||||
echo "✅ Firmware update PUT to ${HOST}."
|
||||
echo " The Pi will reboot itself after applying the update."
|
||||
echo " Once it comes back, open: http://${HOST}/"
|
||||
@@ -19,17 +19,8 @@ if $UPDATE_PY; then
|
||||
# Update service
|
||||
rm -f /etc/init.d/bbctrl
|
||||
cp scripts/bbctrl.service /etc/systemd/system/
|
||||
|
||||
# Cold-boot fast path:
|
||||
# - bbserial-rebind.service replaces the bbserial unbind/reload
|
||||
# that used to live in rc.local AFTER bbctrl was already
|
||||
# listening on /dev/ttyAMA0. Doing it as a unit ordered
|
||||
# Before=bbctrl.service eliminates a full bbctrl restart
|
||||
# mid-boot (~5s saved).
|
||||
cp scripts/bbserial-rebind.service /etc/systemd/system/
|
||||
systemctl daemon-reload
|
||||
systemctl enable bbctrl
|
||||
systemctl enable bbserial-rebind.service
|
||||
fi
|
||||
|
||||
if $UPDATE_AVR; then
|
||||
@@ -127,50 +118,8 @@ if [ $? -ne 0 ]; then
|
||||
REBOOT=true
|
||||
fi
|
||||
|
||||
# Install rc.local. Use the slimmed "fast" variant if it exists in this
|
||||
# checkout (preferred); fall back to the legacy rc.local for older
|
||||
# firmware tarballs that don't ship rc.local.fast yet.
|
||||
if [ -f scripts/rc.local.fast ]; then
|
||||
cp scripts/rc.local.fast /etc/rc.local
|
||||
else
|
||||
cp scripts/rc.local /etc/rc.local
|
||||
fi
|
||||
chmod +x /etc/rc.local
|
||||
|
||||
# Cold-boot: mask units that contribute to userspace startup time but
|
||||
# do not benefit a deployed Onefinity Pi. Each is reversible with
|
||||
# `systemctl unmask <unit>`.
|
||||
# plymouth-read-write : 4s of work for a splash that rc.local kills
|
||||
# immediately with `plymouth quit`.
|
||||
# plymouth-quit-wait : holds graphical.target until the splash is
|
||||
# fully gone; redundant once the splash is
|
||||
# masked.
|
||||
# raspi-config : one-shot first-boot config; on a deployed
|
||||
# image it's a 2s no-op.
|
||||
# sysstat : sadc CPU/IO stats logger; not used.
|
||||
# Use --now so the change also applies to the running system; harmless
|
||||
# on a fresh install where the units are inactive.
|
||||
for unit in \
|
||||
plymouth-read-write.service \
|
||||
plymouth-quit-wait.service \
|
||||
raspi-config.service \
|
||||
sysstat.service; do
|
||||
systemctl mask --now "$unit" 2>/dev/null || true
|
||||
done
|
||||
|
||||
# Cold-boot: switch swap activation from dphys-swapfile (~4.3s LSB
|
||||
# wrapper that re-checks the swap file size on every boot) to a plain
|
||||
# fstab entry. The swap file itself is already created at
|
||||
# /var/swap by the previous boot; we only need to make sure it gets
|
||||
# `swapon`'d at local-fs.target instead.
|
||||
SWAPFILE=/var/swap
|
||||
if [ -f "$SWAPFILE" ]; then
|
||||
if ! grep -qE "^[^#]*${SWAPFILE//\//\\/}[[:space:]]+swap" /etc/fstab; then
|
||||
echo "$SWAPFILE none swap sw 0 0" >> /etc/fstab
|
||||
fi
|
||||
systemctl mask --now dphys-swapfile.service 2>/dev/null || true
|
||||
swapon -a 2>/dev/null || true
|
||||
fi
|
||||
# Install rc.local
|
||||
cp scripts/rc.local /etc/
|
||||
|
||||
# Ensure that the watchdog python library is installed
|
||||
pip3 list --format=columns | grep watchdog >/dev/null
|
||||
|
||||
@@ -28,4 +28,4 @@ plymouth quit
|
||||
|
||||
# Start X in /home/pi
|
||||
cd /home/pi
|
||||
sudo -u pi startx -- -nocursor
|
||||
sudo -u pi startx
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
#!/bin/bash
|
||||
# rc.local for the OneFinity Pi, "fast" variant.
|
||||
#
|
||||
# What changed vs. scripts/rc.local:
|
||||
# - bbserial unbind/rebind moved to bbserial-rebind.service (runs
|
||||
# once, before bbctrl, instead of after bbctrl is already
|
||||
# listening on the serial port).
|
||||
# - startx moved to kiosk.service so chromium starts in parallel
|
||||
# with bbctrl rather than blocking on rc.local.
|
||||
# - rc.local no longer keeps the Pi in 'starting' state forever,
|
||||
# which fixes systemd-analyze.
|
||||
|
||||
set -e
|
||||
|
||||
# Mount /boot read only
|
||||
mount -o remount,ro /boot 2>/dev/null || true
|
||||
|
||||
# Set SPI GPIO mode
|
||||
gpio mode 27 alt3 || true
|
||||
|
||||
# Create browser memory limited cgroup
|
||||
if [ -d /sys/fs/cgroup/memory ]; then
|
||||
CGROUP=/sys/fs/cgroup/memory/chrome
|
||||
[ -d "$CGROUP" ] || mkdir -p "$CGROUP"
|
||||
chown -R pi:pi "$CGROUP"
|
||||
echo 650000000 > "$CGROUP/memory.soft_limit_in_bytes"
|
||||
echo 750000000 > "$CGROUP/memory.limit_in_bytes"
|
||||
fi
|
||||
|
||||
# Stop boot splash; harmless if plymouth already gone.
|
||||
plymouth quit 2>/dev/null || true
|
||||
|
||||
# Start X (chromium kiosk) in the background so rc.local can exit and
|
||||
# late-boot units (bbctrl logrotate, etc.) don't block on it. Output
|
||||
# is redirected so the journal doesn't fill up with X warnings.
|
||||
cd /home/pi
|
||||
# `-- -nocursor` hides the X pointer; this is a touchscreen kiosk and
|
||||
# the mouse cursor only gets in the way.
|
||||
nohup sudo -u pi startx -- -nocursor >/var/log/onefin-x.log 2>&1 &
|
||||
disown
|
||||
|
||||
exit 0
|
||||
@@ -75,7 +75,7 @@ sed -i 's/^PARTUUID=.*\//\/dev\/mmcblk0p2 \//' /etc/fstab
|
||||
|
||||
# Enable browser in xorg
|
||||
sed -i 's/allowed_users=console/allowed_users=anybody/' /etc/X11/Xwrapper.config
|
||||
echo "sudo -u pi startx -- -nocursor" >> /etc/rc.local
|
||||
echo "sudo -u pi startx" >> /etc/rc.local
|
||||
cp /mnt/host/xinitrc /home/pi/.xinitrc
|
||||
cp /mnt/host/ratpoisonrc /home/pi/.ratpoisonrc
|
||||
cp /mnt/host/xorg.conf /etc/X11/
|
||||
|
||||
269
src/js/app.js
269
src/js/app.js
@@ -4,7 +4,6 @@ const api = require("./api");
|
||||
const cookie = require("./cookie")("bbctrl-");
|
||||
const Sock = require("./sock");
|
||||
const semverLt = require("semver/functions/lt");
|
||||
const restartTiming = require("./restart-timing");
|
||||
|
||||
if (document.getElementById("svelte-dialog-host") != undefined) {
|
||||
SvelteComponents.createComponent(
|
||||
@@ -104,17 +103,6 @@ module.exports = new Vue({
|
||||
return {
|
||||
status: "connecting",
|
||||
currentView: "loading",
|
||||
// Top-level shell tab. Mapped from the URL hash by parse_hash().
|
||||
// One of: control | program | console | settings
|
||||
top_tab: "control",
|
||||
// Sub-route when a tab has internal pages (e.g. console:mdi,
|
||||
// settings:admin-network, settings:motor:0). The settings sub
|
||||
// also drives which inner view is mounted.
|
||||
sub_tab: "",
|
||||
sys_open: false,
|
||||
has_camera: true,
|
||||
messages_log: [],
|
||||
messages_seen: 0,
|
||||
display_units: localStorage.getItem("display_units") || "METRIC",
|
||||
index: -1,
|
||||
modified: false,
|
||||
@@ -155,15 +143,22 @@ module.exports = new Vue({
|
||||
estop: { template: "#estop-template" },
|
||||
"loading-view": { template: "<h1>Loading...</h1>" },
|
||||
"control-view": require("./control-view"),
|
||||
"program-view": require("./program-view"),
|
||||
"console-view": require("./console-view"),
|
||||
|
||||
// The settings-shell renders the rail + an inner routed view.
|
||||
// All settings-family hashes (settings, admin-general,
|
||||
// admin-network, motor:N, tool, io, macros, help, cheat-sheet)
|
||||
// resolve to this same shell; parse_hash() sets sub_tab so the
|
||||
// shell knows which inner template to mount.
|
||||
"settings-shell-view": require("./settings-shell-view"),
|
||||
"settings-view": require("./settings-view"),
|
||||
"motor-view": require("./motor-view"),
|
||||
"tool-view": require("./tool-view"),
|
||||
"io-view": require("./io-view"),
|
||||
"admin-general-view": require("./admin-general-view"),
|
||||
"admin-network-view": require("./admin-network-view"),
|
||||
"macros-view": require('./macros'),
|
||||
"help-view": require("./help-view"),
|
||||
"cheat-sheet-view": {
|
||||
template: "#cheat-sheet-view-template",
|
||||
data: function() {
|
||||
return {
|
||||
showUnimplemented: false
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
@@ -171,25 +166,6 @@ module.exports = new Vue({
|
||||
localStorage.setItem("display_units", value);
|
||||
SvelteComponents.setDisplayUnits(value);
|
||||
},
|
||||
|
||||
// Mirror controller messages into a console log used by the
|
||||
// Console > Messages tab and the header badge counter.
|
||||
"state.messages": {
|
||||
handler: function(messages) {
|
||||
if (!Array.isArray(messages)) return;
|
||||
this.messages_log = messages.map(m => ({
|
||||
text: m.text,
|
||||
id: m.id,
|
||||
level: /^#/.test(m.text || "") ? "info" : "warning",
|
||||
ts: m.ts || Date.now(),
|
||||
}));
|
||||
if (this.top_tab === "console" && this.sub_tab === "messages") {
|
||||
this.messages_seen = this.messages_log.length;
|
||||
}
|
||||
},
|
||||
deep: true,
|
||||
immediate: true,
|
||||
},
|
||||
},
|
||||
|
||||
events: {
|
||||
@@ -251,19 +227,6 @@ module.exports = new Vue({
|
||||
},
|
||||
|
||||
computed: {
|
||||
// True when the UI is in kiosk mode — i.e. running on the
|
||||
// controller's own onboard browser (Pi 3B at 1366x768) or
|
||||
// explicitly forced via ?kiosk=1. Source-of-truth is the
|
||||
// `kiosk-mode` class added to <html> by the inline script
|
||||
// in index.pug, which already honors hostname + URL param +
|
||||
// localStorage. The Pi's VideoCore IV is too slow for the
|
||||
// three.js toolpath preview, so we suppress that panel in
|
||||
// kiosk mode and let the gcode listing take the full width.
|
||||
is_kiosk: function() {
|
||||
return typeof document !== "undefined"
|
||||
&& document.documentElement.classList.contains("kiosk-mode");
|
||||
},
|
||||
|
||||
popupMessages: function() {
|
||||
const msgs = [];
|
||||
|
||||
@@ -289,130 +252,18 @@ module.exports = new Vue({
|
||||
enable_rotary: function() {
|
||||
if(this.state["2an"] == 1 || this.state["2an"] == 3) return true;
|
||||
return false;
|
||||
},
|
||||
|
||||
// ---------------- header chrome helpers ----------------
|
||||
|
||||
// Underlying machine state from the controller. Mirrors
|
||||
// control-view's `mach_state` so the header has access without
|
||||
// depending on the routed component.
|
||||
mach_state: function() {
|
||||
const cycle = this.state.cycle;
|
||||
const xx = this.state.xx;
|
||||
|
||||
if (xx != "ESTOPPED" && (cycle == "jogging" || cycle == "homing")) {
|
||||
return cycle.toUpperCase();
|
||||
}
|
||||
|
||||
return xx || "";
|
||||
},
|
||||
|
||||
// Short text for the READY pill in the header.
|
||||
state_label: function() {
|
||||
const s = this.mach_state;
|
||||
if (!s) return "--";
|
||||
return s;
|
||||
},
|
||||
|
||||
// Class added to the READY pill (.state-badge) so styling can
|
||||
// reflect ready / running / holding / fault / estop.
|
||||
state_class: function() {
|
||||
const s = this.mach_state;
|
||||
if (s == "ESTOPPED" || s == "FAULT" || s == "SHUTDOWN") return "bad";
|
||||
if (s == "HOLDING" || s == "STOPPING") return "warn";
|
||||
if (s == "RUNNING" || s == "HOMING" || s == "JOGGING") return "busy";
|
||||
if (s == "READY") return "ok";
|
||||
return "unknown";
|
||||
},
|
||||
|
||||
mach_state_full: function() {
|
||||
const s = this.mach_state;
|
||||
if (s == "ESTOPPED") return "E-Stopped \u2014 release to clear";
|
||||
if (s == "HOLDING") return "Feed hold (" + (this.state.pr || "paused") + ")";
|
||||
if (s == "RUNNING") return "Running program";
|
||||
if (s == "HOMING") return "Homing axes";
|
||||
if (s == "JOGGING") return "Jogging";
|
||||
if (s == "READY") return "Ready";
|
||||
return s;
|
||||
},
|
||||
|
||||
// Pip color for the unified system pill.
|
||||
sys_class: function() {
|
||||
const wifi_off = !this.config.wifiName || this.config.wifiName == "not connected";
|
||||
const cam_off = !this.has_camera;
|
||||
const hot = this.state && 80 <= this.state.rpi_temp;
|
||||
if (hot) return "red";
|
||||
if (wifi_off || cam_off) return "amber";
|
||||
return "green";
|
||||
},
|
||||
|
||||
// Compact summary for the system pill.
|
||||
sys_summary: function() {
|
||||
const issues = [];
|
||||
if (!this.config.wifiName || this.config.wifiName == "not connected") {
|
||||
issues.push("WiFi off");
|
||||
}
|
||||
if (!this.has_camera) issues.push("Camera offline");
|
||||
if (this.state && 80 <= this.state.rpi_temp) issues.push("Pi hot");
|
||||
if (this.is_rotary_active) issues.push("Rotary");
|
||||
if (issues.length === 0) return "All systems";
|
||||
if (issues.length === 1) return issues[0];
|
||||
return issues.length + " notes";
|
||||
},
|
||||
|
||||
// Number of unread Console > Messages entries.
|
||||
messages_count: function() {
|
||||
return Math.max(0, this.messages_log.length - this.messages_seen);
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
ready: function() {
|
||||
window.onhashchange = () => this.parse_hash();
|
||||
|
||||
// Embedded Svelte subviews (A axis settings, etc.) signal
|
||||
// unsaved changes via this event. The master Save button
|
||||
// highlights when modified is true.
|
||||
window.addEventListener("onefin:dirty", () => {
|
||||
this.modified = true;
|
||||
});
|
||||
|
||||
|
||||
|
||||
// Resolve the initial route before the websocket connects so
|
||||
// the shell shows the right view even on a slow / offline
|
||||
// controller. update() will call parse_hash() again once the
|
||||
// first config is in. Skip routing into the Svelte settings
|
||||
// family before config has loaded — those components read
|
||||
// many config keys (settings.units, settings.probing-prompts,
|
||||
// motion.*, etc.) and would throw on first paint with the
|
||||
// empty placeholder config.
|
||||
const settingsFamily = [
|
||||
"settings", "probing", "gcode",
|
||||
"admin-general", "admin-network",
|
||||
"motor", "tool", "io", "macros",
|
||||
"help", "cheat-sheet",
|
||||
];
|
||||
const initialHead = (location.hash || "").replace(/^#/, "").split(":")[0];
|
||||
if (settingsFamily.indexOf(initialHead) === -1) {
|
||||
this.parse_hash();
|
||||
}
|
||||
// else: stay on "loading" until update() completes and calls
|
||||
// parse_hash() itself.
|
||||
|
||||
this.connect();
|
||||
|
||||
// Close the system popover when clicking anywhere else.
|
||||
document.addEventListener("click", () => {
|
||||
if (this.sys_open) this.sys_open = false;
|
||||
});
|
||||
|
||||
SvelteComponents.registerControllerMethods({
|
||||
dispatch: (...args) => this.$dispatch(...args)
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
|
||||
methods: {
|
||||
block_error_dialog: function() {
|
||||
this.errorTimeoutStart = Date.now();
|
||||
@@ -487,12 +338,6 @@ module.exports = new Vue({
|
||||
toggle_rotary: async function(isActive) {
|
||||
try {
|
||||
await api.put("rotary", {status: isActive});
|
||||
// The /api/rotary endpoint rewrites motors[1]/[2]
|
||||
// in config.json on the server. Refetch so the UI
|
||||
// reflects the new motor config (otherwise the
|
||||
// motor settings page keeps showing pre-toggle
|
||||
// values until the next page reload).
|
||||
await this.update();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert("Error occured");
|
||||
@@ -527,19 +372,11 @@ module.exports = new Vue({
|
||||
connect: function() {
|
||||
this.sock = new Sock(`//${location.host}/sockjs`);
|
||||
|
||||
let _gotFirstMsg = false;
|
||||
let _gotFirstState = false;
|
||||
|
||||
this.sock.onmessage = (e) => {
|
||||
if (typeof e.data != "object") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_gotFirstMsg) {
|
||||
_gotFirstMsg = true;
|
||||
restartTiming.onWsFirstMessage();
|
||||
}
|
||||
|
||||
if (e.data.log && e.data.log.msg !== "Switch not found") {
|
||||
this.$broadcast("log", e.data.log);
|
||||
|
||||
@@ -549,11 +386,6 @@ module.exports = new Vue({
|
||||
}
|
||||
}
|
||||
|
||||
if (!_gotFirstState) {
|
||||
_gotFirstState = true;
|
||||
restartTiming.onFirstState();
|
||||
}
|
||||
|
||||
// Check for session ID change on controller
|
||||
if ("sid" in e.data) {
|
||||
if (typeof this.sid == "undefined") {
|
||||
@@ -578,7 +410,6 @@ module.exports = new Vue({
|
||||
|
||||
this.sock.onopen = () => {
|
||||
this.status = "connected";
|
||||
restartTiming.onWsOpen();
|
||||
this.$emit(this.status);
|
||||
this.$broadcast(this.status);
|
||||
};
|
||||
@@ -590,21 +421,6 @@ module.exports = new Vue({
|
||||
};
|
||||
},
|
||||
|
||||
// Maps a URL hash to (currentView, top_tab, sub_tab, index).
|
||||
// Hash layouts supported (all kept for backward compat):
|
||||
// #control -> control tab
|
||||
// #program[:auto] -> program tab
|
||||
// #console[:mdi|messages|indicators]
|
||||
// -> console tab
|
||||
// #settings -> settings tab home
|
||||
// #admin-general -> settings tab, admin-general inside
|
||||
// #admin-network -> settings tab, admin-network inside
|
||||
// #motor:0..3 -> settings tab, motor 0..3
|
||||
// #tool -> settings tab, tool view
|
||||
// #io -> settings tab, io view
|
||||
// #macros -> settings tab, macros view
|
||||
// #help -> settings tab, help view
|
||||
// #cheat-sheet -> settings tab, cheat sheet view
|
||||
parse_hash: function() {
|
||||
const hash = location.hash.substr(1);
|
||||
|
||||
@@ -614,57 +430,12 @@ module.exports = new Vue({
|
||||
}
|
||||
|
||||
const parts = hash.split(":");
|
||||
const head = parts[0];
|
||||
|
||||
this.index = parts.length > 1 ? parts[1] : -1;
|
||||
|
||||
// Legacy / settings-managed views resolve under the
|
||||
// Settings tab while keeping their existing top-level
|
||||
// hash. This preserves all existing deep links.
|
||||
const settingsViews = [
|
||||
"settings", "probing", "gcode",
|
||||
"admin-general", "admin-network",
|
||||
"motor", "tool", "io", "macros",
|
||||
"help", "cheat-sheet",
|
||||
];
|
||||
|
||||
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";
|
||||
if (parts.length == 2) {
|
||||
this.index = parts[1];
|
||||
}
|
||||
|
||||
// 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;
|
||||
this.currentView = parts[0];
|
||||
},
|
||||
|
||||
save: async function() {
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
"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,6 +1,7 @@
|
||||
"use strict";
|
||||
|
||||
const api = require("./api");
|
||||
const utils = require("./utils");
|
||||
const cookie = require("./cookie")("bbctrl-");
|
||||
|
||||
module.exports = {
|
||||
@@ -11,7 +12,15 @@ module.exports = {
|
||||
return {
|
||||
current_time: "",
|
||||
mach_units: this.$root.state.metric ? "METRIC" : "IMPERIAL",
|
||||
mdi: "",
|
||||
last_file: undefined,
|
||||
last_file_time: undefined,
|
||||
toolpath: {},
|
||||
toolpath_progress: 0,
|
||||
axes: "xyzabc",
|
||||
history: [],
|
||||
speed_override: 1,
|
||||
feed_override: 1,
|
||||
jog_incr_amounts: {
|
||||
METRIC: {
|
||||
fine: 0.1,
|
||||
@@ -29,14 +38,34 @@ module.exports = {
|
||||
jog_incr: localStorage.getItem("jog_incr") || "small",
|
||||
jog_step: cookie.get_bool("jog-step"),
|
||||
jog_adjust: parseInt(cookie.get("jog-adjust", 2)),
|
||||
deleteGCode: false,
|
||||
tab: "auto",
|
||||
ask_home: true,
|
||||
folder_name: "",
|
||||
edited: false,
|
||||
uploading_files: false,
|
||||
confirmDelete: false,
|
||||
create_folder: false,
|
||||
showGcodeMessage: false,
|
||||
showNoGcodeMessage: false,
|
||||
macrosLoading: false,
|
||||
show_gcodes: false,
|
||||
GCodeNotFound: false,
|
||||
show_probe_dialog: false,
|
||||
overrides_open: false,
|
||||
filesUploaded: 0,
|
||||
totalFiles: 0,
|
||||
files_sortby: "By Upload Date",
|
||||
selected_items_to_delete: [],
|
||||
search_query: "",
|
||||
filtered_files: [],
|
||||
selected_folder_index: null,
|
||||
};
|
||||
},
|
||||
|
||||
components: {
|
||||
"axis-control": require("./axis-control"),
|
||||
"path-viewer": require("./path-viewer"),
|
||||
"gcode-viewer": require("./gcode-viewer"),
|
||||
},
|
||||
|
||||
watch: {
|
||||
@@ -51,6 +80,16 @@ module.exports = {
|
||||
immediate: true,
|
||||
},
|
||||
|
||||
"state.line": function () {
|
||||
if (this.mach_state != "HOMING") {
|
||||
this.$broadcast("gcode-line", this.state.line);
|
||||
}
|
||||
},
|
||||
|
||||
"state.selected_time": function () {
|
||||
this.load();
|
||||
},
|
||||
|
||||
jog_step: function () {
|
||||
cookie.set_bool("jog-step", this.jog_step);
|
||||
},
|
||||
@@ -88,16 +127,43 @@ module.exports = {
|
||||
return state || "";
|
||||
},
|
||||
|
||||
can_set_axis: function () {
|
||||
return this.state.cycle == "idle";
|
||||
pause_reason: function () {
|
||||
return this.state.pr;
|
||||
},
|
||||
|
||||
is_running: function () {
|
||||
return this.mach_state == "RUNNING" || this.mach_state == "HOMING";
|
||||
},
|
||||
|
||||
is_stopping: function () {
|
||||
return this.mach_state == "STOPPING";
|
||||
},
|
||||
|
||||
is_holding: function () {
|
||||
return this.mach_state == "HOLDING";
|
||||
},
|
||||
|
||||
is_ready: function () {
|
||||
return this.mach_state == "READY";
|
||||
},
|
||||
|
||||
is_idle: function () {
|
||||
return this.state.cycle == "idle";
|
||||
},
|
||||
|
||||
is_ready: function () {
|
||||
return this.mach_state == "READY";
|
||||
is_paused: function () {
|
||||
return this.is_holding && (this.pause_reason == "User pause" || this.pause_reason == "Program pause");
|
||||
},
|
||||
|
||||
can_mdi: function () {
|
||||
return this.is_idle || this.state.cycle == "mdi";
|
||||
},
|
||||
|
||||
can_set_axis: function () {
|
||||
return this.is_idle;
|
||||
|
||||
// TODO allow setting axis position during pause
|
||||
// return this.is_idle || this.is_paused;
|
||||
},
|
||||
|
||||
message: function () {
|
||||
@@ -125,21 +191,57 @@ module.exports = {
|
||||
},
|
||||
|
||||
plan_time_remaining: function () {
|
||||
const stopping = this.mach_state == "STOPPING";
|
||||
const running = this.mach_state == "RUNNING" || this.mach_state == "HOMING";
|
||||
const holding = this.mach_state == "HOLDING";
|
||||
if (!(stopping || running || holding)) return 0;
|
||||
const tp = this.$root && this.$root.toolpath ? this.$root.toolpath.time : 0;
|
||||
return (tp || 0) - this.plan_time;
|
||||
if (!(this.is_stopping || this.is_running || this.is_holding)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return this.toolpath.time - this.plan_time;
|
||||
},
|
||||
|
||||
state_kpi_class: function () {
|
||||
const s = this.mach_state;
|
||||
if (s == "ESTOPPED" || s == "FAULT" || s == "SHUTDOWN") return "bad";
|
||||
if (s == "HOLDING" || s == "STOPPING") return "warn";
|
||||
if (s == "RUNNING" || s == "HOMING" || s == "JOGGING") return "busy";
|
||||
if (s == "READY") return "ok";
|
||||
return "";
|
||||
eta: function () {
|
||||
if (this.mach_state != "RUNNING") {
|
||||
return "";
|
||||
}
|
||||
|
||||
const remaining = this.plan_time_remaining;
|
||||
const d = new Date();
|
||||
d.setSeconds(d.getSeconds() + remaining);
|
||||
return d.toLocaleString();
|
||||
},
|
||||
|
||||
progress: function () {
|
||||
if (!this.toolpath.time || this.is_ready) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const p = this.plan_time / this.toolpath.time;
|
||||
return Math.min(1, p);
|
||||
},
|
||||
gcode_files: function () {
|
||||
if (!this.state.folder) {
|
||||
return [];
|
||||
}
|
||||
const folder = this.state.gcode_list.find(item => item.name == this.state.folder);
|
||||
if (!folder) {
|
||||
return [];
|
||||
}
|
||||
const files = folder.files.filter(item => this.state.files.includes(item.file_name)).map(item => item.file_name);
|
||||
if (this.files_sortby == "A-Z") {
|
||||
return files.sort();
|
||||
} else if (this.files_sortby == "Z-A") {
|
||||
return files.sort().reverse();
|
||||
} else {
|
||||
return files;
|
||||
}
|
||||
},
|
||||
gcode_filtered_files: function () {
|
||||
return this.filtered_files.filter(file => file.toLowerCase().includes(this.search_query.toLowerCase()));
|
||||
},
|
||||
gcode_folders: function () {
|
||||
return this.state.gcode_list
|
||||
.map(item => item.name)
|
||||
.filter(element => element !== "default")
|
||||
.sort();
|
||||
},
|
||||
},
|
||||
|
||||
@@ -162,9 +264,14 @@ module.exports = {
|
||||
M72
|
||||
`);
|
||||
},
|
||||
folder_name_edited: function () {
|
||||
this.edited = true;
|
||||
},
|
||||
},
|
||||
|
||||
ready: function () {
|
||||
this.load();
|
||||
|
||||
setInterval(() => {
|
||||
this.current_time = new Date().toLocaleTimeString();
|
||||
}, 1000);
|
||||
@@ -180,37 +287,26 @@ module.exports = {
|
||||
},
|
||||
|
||||
methods: {
|
||||
save_config: async function (config) {
|
||||
try {
|
||||
await api.put("config/save", config);
|
||||
this.$dispatch("update");
|
||||
} catch (error) {
|
||||
console.error("Restore Failed: ", error);
|
||||
alert("Restore failed");
|
||||
}
|
||||
},
|
||||
|
||||
populateFiles(index) {
|
||||
this.selected_folder_index = index;
|
||||
this.filtered_files = this.state.gcode_list[index].files.map(item => item.file_name);
|
||||
},
|
||||
|
||||
getJogIncrStyle(value) {
|
||||
const weight = `font-weight:${this.jog_incr === value ? "bold" : "normal"}`;
|
||||
const color = this.jog_incr === value ? "color:#0078e7" : "";
|
||||
return [weight, color].join(";");
|
||||
},
|
||||
|
||||
// Should the macro row render a colored left stripe for this
|
||||
// macro? Only when the user has explicitly picked a color. The
|
||||
// controller seeds new macros with default placeholders like
|
||||
// "#ffffff" or "#dedede"; treat anything that close to white as
|
||||
// "no color".
|
||||
has_macro_color(macros) {
|
||||
if (!macros || typeof macros.color !== "string") return false;
|
||||
const c = macros.color.trim().toLowerCase();
|
||||
if (!c) return false;
|
||||
const defaults = [
|
||||
"#fff", "#ffffff", "#fefefe", "#fdfdfd", "#fcfcfc",
|
||||
"#dedede", "#dddddd", "#cccccc",
|
||||
];
|
||||
if (defaults.indexOf(c) !== -1) return false;
|
||||
// Fallback: if the color is very close to white (sum of RGB
|
||||
// > 690), suppress the stripe.
|
||||
const m = c.match(/^#([0-9a-f]{6})$/);
|
||||
if (m) {
|
||||
const v = parseInt(m[1], 16);
|
||||
const r = (v >> 16) & 0xff;
|
||||
const g = (v >> 8) & 0xff;
|
||||
const b = v & 0xff;
|
||||
if (r + g + b > 690) return false;
|
||||
}
|
||||
return true;
|
||||
return [weight, color].join(";");
|
||||
},
|
||||
|
||||
jog_fn: function (x_jog, y_jog, z_jog, a_jog) {
|
||||
@@ -228,6 +324,426 @@ module.exports = {
|
||||
`);
|
||||
},
|
||||
|
||||
send: function (msg) {
|
||||
this.$dispatch("send", msg);
|
||||
},
|
||||
|
||||
toggle_sorting: function () {
|
||||
if (this.files_sortby === "By Upload Date") {
|
||||
this.files_sortby = "A-Z";
|
||||
} else if (this.files_sortby === "A-Z") {
|
||||
this.files_sortby = "Z-A";
|
||||
} else if (this.files_sortby === "Z-A") {
|
||||
this.files_sortby = "By Upload Date";
|
||||
}
|
||||
},
|
||||
|
||||
load: function () {
|
||||
const file_time = this.state.selected_time;
|
||||
const file = this.state.selected;
|
||||
if (this.last_file == file && this.last_file_time == file_time) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.state.selected && !this.state.files.includes(this.state.selected)) {
|
||||
this.GCodeNotFound = true;
|
||||
return;
|
||||
}
|
||||
|
||||
this.last_file = file;
|
||||
this.last_file_time = file_time;
|
||||
|
||||
this.$broadcast("gcode-load", file);
|
||||
this.$broadcast("gcode-line", this.state.line);
|
||||
this.toolpath_progress = 0;
|
||||
this.load_toolpath(file, file_time);
|
||||
},
|
||||
|
||||
load_toolpath: async function (file, file_time) {
|
||||
this.toolpath = {};
|
||||
|
||||
if (!file || this.last_file_time != file_time) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.showGcodeMessage = true;
|
||||
|
||||
while (this.showGcodeMessage) {
|
||||
try {
|
||||
const toolpath = await api.get(`path/${file}`);
|
||||
this.toolpath_progress = toolpath.progress;
|
||||
|
||||
if (toolpath.progress === 1 || typeof toolpath.progress == "undefined") {
|
||||
this.showGcodeMessage = false;
|
||||
|
||||
if (toolpath.bounds) {
|
||||
toolpath.filename = file;
|
||||
this.toolpath_progress = 1;
|
||||
this.toolpath = toolpath;
|
||||
|
||||
const state = this.$root.state;
|
||||
for (const axis of "xyzabc") {
|
||||
Vue.set(state, `path_min_${axis}`, toolpath.bounds.min[axis]);
|
||||
Vue.set(state, `path_max_${axis}`, toolpath.bounds.max[axis]);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
submit_mdi: function () {
|
||||
this.send(this.mdi);
|
||||
|
||||
if (!this.history.length || this.history[0] != this.mdi) {
|
||||
this.history.unshift(this.mdi);
|
||||
}
|
||||
|
||||
this.mdi = "";
|
||||
},
|
||||
|
||||
mdi_start_pause: function () {
|
||||
if (this.state.xx == "RUNNING") {
|
||||
this.pause();
|
||||
} else if (this.state.xx == "STOPPING" || this.state.xx == "HOLDING") {
|
||||
this.unpause();
|
||||
} else {
|
||||
this.submit_mdi();
|
||||
}
|
||||
},
|
||||
|
||||
load_history: function (index) {
|
||||
this.mdi = this.history[index];
|
||||
},
|
||||
|
||||
open_file: function () {
|
||||
utils.clickFileInput("gcode-file-input");
|
||||
},
|
||||
|
||||
open_folder: function () {
|
||||
utils.clickFileInput("gcode-folder-input");
|
||||
},
|
||||
|
||||
edited_folder_name: function (event) {
|
||||
if (event.target.value.trim() != "") {
|
||||
this.$dispatch("folder_name_edited");
|
||||
}
|
||||
},
|
||||
|
||||
update_config: function () {
|
||||
this.config.gcode_list = [...this.state.gcode_list];
|
||||
this.config.non_macros_list = [...this.state.non_macros_list];
|
||||
this.config.macros_list = [...this.state.macros_list];
|
||||
this.config.macros = [...this.state.macros];
|
||||
},
|
||||
|
||||
reset_gcode: function () {
|
||||
this.state.selected = "";
|
||||
this.last_file = "";
|
||||
this.$broadcast("gcode-load", "");
|
||||
},
|
||||
|
||||
upload_gcode: async function (filename, file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
|
||||
this.filesUploaded++;
|
||||
if (this.filesUploaded == this.totalFiles) {
|
||||
this.uploading_files = false;
|
||||
}
|
||||
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve("file uploaded");
|
||||
} else {
|
||||
console.error("File upload failed:", xhr.statusText);
|
||||
reject("upload failed");
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = () => {
|
||||
alert("Upload failed.");
|
||||
reject("upload failed");
|
||||
};
|
||||
|
||||
xhr.open("PUT", `/api/file/${encodeURIComponent(filename)}`, true);
|
||||
xhr.send(file);
|
||||
});
|
||||
},
|
||||
|
||||
readFile: function (file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = () => {
|
||||
resolve(reader.result);
|
||||
};
|
||||
|
||||
reader.onerror = error => {
|
||||
reject(error);
|
||||
};
|
||||
|
||||
reader.readAsText(file, "utf-8");
|
||||
});
|
||||
},
|
||||
|
||||
validateFiles: async function (files) {
|
||||
const validFiles = [];
|
||||
for (const file of files) {
|
||||
const extension = file.name.split(".").pop().toLowerCase();
|
||||
const validExtensions = ["nc", "ngc", "gcode", "gc"];
|
||||
|
||||
if (validExtensions.includes(extension)) {
|
||||
validFiles.push(file);
|
||||
} else {
|
||||
alert(`Unsupported file : ${file.name}`);
|
||||
this.filesUploaded++;
|
||||
if (this.filesUploaded == this.totalFiles) {
|
||||
this.uploadFiles = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return validFiles;
|
||||
},
|
||||
|
||||
uploadValidFiles: async function (files, folderName) {
|
||||
const updatedConfig = { ...this.config };
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const gcode = await this.readFile(file);
|
||||
await this.upload_gcode(file.name, gcode);
|
||||
|
||||
const isAlreadyPresent = updatedConfig.non_macros_list.some(element => element.file_name === file.name);
|
||||
|
||||
if (!isAlreadyPresent) {
|
||||
updatedConfig.non_macros_list.push({ file_name: file.name });
|
||||
}
|
||||
|
||||
if (folderName) {
|
||||
const folder = updatedConfig.gcode_list.find(item => item.type == "folder" && item.name == folderName);
|
||||
if (folder) {
|
||||
if (!folder.files.map(item => item.file_name).includes(file.name)) {
|
||||
folder.files.push({ file_name: file.name });
|
||||
}
|
||||
} else {
|
||||
updatedConfig.gcode_list.push({
|
||||
name: folderName,
|
||||
type: "folder",
|
||||
files: [
|
||||
{
|
||||
file_name: file.name,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
} else {
|
||||
var folder_to_add = updatedConfig.gcode_list.find(
|
||||
item => item.type == "folder" && item.name == this.state.folder,
|
||||
);
|
||||
if (!folder_to_add) {
|
||||
folder_to_add = updatedConfig.gcode_list.unshift({
|
||||
name: this.state.folder,
|
||||
type: "folder",
|
||||
files: [
|
||||
{
|
||||
file_name: file.name,
|
||||
},
|
||||
],
|
||||
});
|
||||
folder_to_add = updatedConfig.gcode_list[0];
|
||||
}
|
||||
if (!folder_to_add.files.find(item => item.file_name == file.name)) {
|
||||
folder_to_add.files.push({ file_name: file.name });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`error uploading file : `, error);
|
||||
}
|
||||
}
|
||||
return updatedConfig;
|
||||
},
|
||||
|
||||
upload_files: async function (files, folderName) {
|
||||
this.update_config();
|
||||
|
||||
const validFiles = await this.validateFiles(files);
|
||||
const updatedConfig = await this.uploadValidFiles(validFiles, folderName);
|
||||
|
||||
await this.save_config(updatedConfig);
|
||||
},
|
||||
|
||||
upload_file: async function (e) {
|
||||
this.uploading_files = true;
|
||||
this.filesUploaded = 0;
|
||||
|
||||
const files = e.target.files || e.dataTransfer.files;
|
||||
if (!files.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.totalFiles = files.length;
|
||||
|
||||
await this.upload_files(files);
|
||||
},
|
||||
|
||||
create_new_folder: async function () {
|
||||
const folder_name = this.folder_name.trim();
|
||||
if (folder_name != "") {
|
||||
if (this.state.gcode_list.find(item => item.type == "folder" && item.name == folder_name)) {
|
||||
alert("Folder with the same name already exists!");
|
||||
return;
|
||||
} else {
|
||||
this.update_config();
|
||||
this.config.gcode_list.push({
|
||||
name: folder_name,
|
||||
type: "folder",
|
||||
files: [],
|
||||
});
|
||||
}
|
||||
this.state.folder = folder_name;
|
||||
this.edited = false;
|
||||
this.create_folder = false;
|
||||
this.folder_name = "";
|
||||
this.save_config(this.config);
|
||||
}
|
||||
},
|
||||
|
||||
cancel_new_folder: function () {
|
||||
this.create_folder = false;
|
||||
this.folder_name = "";
|
||||
},
|
||||
|
||||
upload_folder: async function (e) {
|
||||
this.uploading_files = true;
|
||||
this.filesUploaded = 0;
|
||||
|
||||
const files = e.target.files || e.dataTransfer.files;
|
||||
if (!files.length) {
|
||||
return;
|
||||
}
|
||||
this.totalFiles = files.length;
|
||||
const folderName = files[0].webkitRelativePath.split("/")[0];
|
||||
|
||||
this.upload_files(files, folderName);
|
||||
},
|
||||
|
||||
delete_current: async function () {
|
||||
if (!this.state.selected) {
|
||||
this.deleteGCode = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.update_config();
|
||||
|
||||
this.config.non_macros_list = this.config.non_macros_list.filter(
|
||||
item => !this.selected_items_to_delete.includes(item.file_name),
|
||||
);
|
||||
const folder_to_update = this.config.gcode_list.find(
|
||||
item => item.name == this.config.gcode_list[this.selected_folder_index].name && item.type == "folder",
|
||||
);
|
||||
folder_to_update.files = folder_to_update.files.filter(
|
||||
item => !this.selected_items_to_delete.includes(item.file_name),
|
||||
);
|
||||
|
||||
const exception_list = this.state.macros_list.map(item => item.file_name);
|
||||
let files_to_delete = this.selected_items_to_delete.filter(item => !exception_list.includes(item));
|
||||
|
||||
await api.delete(`file/DINCAIQABiDARixAxiABDIHCAMQABiABDIHCAQQABiABDIH${files_to_delete.toString()}`);
|
||||
|
||||
this.save_config(this.config);
|
||||
this.filtered_files = [];
|
||||
this.search_query = "";
|
||||
this.selected_folder_index = null;
|
||||
this.selected_items_to_delete = [];
|
||||
this.deleteGCode = false;
|
||||
},
|
||||
|
||||
cancel_delete: function () {
|
||||
this.filtered_files = [];
|
||||
this.search_query = "";
|
||||
this.selected_folder_index = null;
|
||||
this.selected_items_to_delete = [];
|
||||
this.deleteGCode = false;
|
||||
},
|
||||
|
||||
delete_all: function () {
|
||||
api.delete("file");
|
||||
this.deleteGCode = false;
|
||||
},
|
||||
|
||||
delete_all_except_macros: async function () {
|
||||
this.update_config();
|
||||
const macrosList = this.state.macros_list.map(item => item.file_name).toString();
|
||||
api.delete(`file/EgZjaHJvbWUqCggBEAAYsQMYgAQyBggAEEUYOTIKCAE${macrosList}`);
|
||||
this.config.non_macros_list = [];
|
||||
this.config.gcode_list = [
|
||||
{
|
||||
name: "default",
|
||||
type: "folder",
|
||||
files: [],
|
||||
},
|
||||
];
|
||||
|
||||
this.save_config(this.config);
|
||||
this.state.folder = "default";
|
||||
this.state.selected = "";
|
||||
this.selected_items_to_delete = [];
|
||||
this.deleteGCode = false;
|
||||
},
|
||||
|
||||
delete_folder: async function () {
|
||||
this.update_config();
|
||||
if (this.state.folder && this.state.folder != "default") {
|
||||
const files_to_move = this.config.gcode_list.find(
|
||||
item => item.type == "folder" && item.name == this.state.folder,
|
||||
);
|
||||
if (files_to_move) {
|
||||
const default_folder = this.config.gcode_list.find(item => item.name == "default");
|
||||
default_folder.files = [...default_folder.files, ...files_to_move.files].sort();
|
||||
this.config.gcode_list = this.config.gcode_list.filter(item => item.name != this.state.folder);
|
||||
this.save_config(this.config);
|
||||
}
|
||||
}
|
||||
this.state.folder = "default";
|
||||
this.confirmDelete = false;
|
||||
},
|
||||
delete_folder_and_files: async function () {
|
||||
if (!this.state.folder) {
|
||||
this.confirmDelete = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.update_config();
|
||||
|
||||
const selected_folder = this.config.gcode_list.find(
|
||||
item => item.type == "folder" && item.name == this.state.folder,
|
||||
);
|
||||
if (!selected_folder) {
|
||||
return;
|
||||
}
|
||||
const macrosList = this.state.macros_list.map(item => item.file_name);
|
||||
var files_to_delete = selected_folder.files
|
||||
.map(item => item.file_name)
|
||||
.filter(item => !macrosList.includes(item));
|
||||
if (selected_folder.name != "default") {
|
||||
this.config.gcode_list = this.config.gcode_list.filter(item => item.name != this.state.folder);
|
||||
} else {
|
||||
selected_folder.files = [];
|
||||
}
|
||||
|
||||
await api.delete(`file/DINCAIQABiDARixAxiABDIHCAMQABiABDIHCAQQABiABDIH${files_to_delete.toString()}`);
|
||||
this.config.non_macros_list = this.config.non_macros_list.filter(
|
||||
item => !files_to_delete.includes(item.file_name),
|
||||
);
|
||||
this.save_config(this.config);
|
||||
this.state.folder = "default";
|
||||
this.confirmDelete = false;
|
||||
},
|
||||
|
||||
home: function (axis) {
|
||||
this.ask_home = false;
|
||||
|
||||
@@ -249,15 +765,6 @@ module.exports = {
|
||||
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) {
|
||||
SvelteComponents.showDialog("SetAxisPosition", { axis });
|
||||
},
|
||||
@@ -283,20 +790,93 @@ module.exports = {
|
||||
},
|
||||
|
||||
zero: function (axis) {
|
||||
if (typeof axis == "undefined") this.zero_all();
|
||||
else this.set_position(axis, 0);
|
||||
if (typeof axis == "undefined") {
|
||||
this.zero_all();
|
||||
} else {
|
||||
this.set_position(axis, 0);
|
||||
}
|
||||
},
|
||||
|
||||
start_pause: function () {
|
||||
this.macrosLoading = false;
|
||||
if (this.state.xx == "RUNNING") {
|
||||
this.pause();
|
||||
} else if (this.state.xx == "STOPPING" || this.state.xx == "HOLDING") {
|
||||
this.unpause();
|
||||
} else {
|
||||
this.start();
|
||||
}
|
||||
},
|
||||
|
||||
start: function () {
|
||||
api.put("start");
|
||||
},
|
||||
|
||||
pause: function () {
|
||||
api.put("pause");
|
||||
},
|
||||
|
||||
unpause: function () {
|
||||
api.put("unpause");
|
||||
},
|
||||
|
||||
optional_pause: function () {
|
||||
api.put("pause/optional");
|
||||
},
|
||||
|
||||
stop: function () {
|
||||
api.put("stop");
|
||||
},
|
||||
|
||||
step: function () {
|
||||
api.put("step");
|
||||
},
|
||||
|
||||
override_feed: function () {
|
||||
api.put(`override/feed/${this.feed_override}`);
|
||||
},
|
||||
|
||||
override_speed: function () {
|
||||
api.put(`override/speed/${this.speed_override}`);
|
||||
},
|
||||
|
||||
current: function (axis, value) {
|
||||
const x = value / 32.0;
|
||||
if (this.state[`${axis}pl`] == x) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = {};
|
||||
data[`${axis}pl`] = x;
|
||||
this.send(JSON.stringify(data));
|
||||
},
|
||||
|
||||
showProbeDialog: function (probeType) {
|
||||
if (this.show_probe_dialog) {
|
||||
if(this.show_probe_dialog){
|
||||
this.show_probe_dialog = false;
|
||||
}
|
||||
SvelteComponents.showDialog("Probe", {
|
||||
probeType,
|
||||
isRotaryActive: this.state["2an"] == 3,
|
||||
});
|
||||
SvelteComponents.showDialog("Probe", { probeType, isRotaryActive: this.state["2an"] == 3 });
|
||||
},
|
||||
run_macro: function (id) {
|
||||
if (this.state.macros[id].file_name == "default") {
|
||||
this.showNoGcodeMessage = true;
|
||||
} else {
|
||||
if (this.state.macros[id].file_name != this.state.selected) {
|
||||
this.state.selected = this.state.macros[id].file_name;
|
||||
}
|
||||
try {
|
||||
this.load();
|
||||
if (this.state.macros[id].alert == true) {
|
||||
this.macrosLoading = true;
|
||||
} else {
|
||||
setImmediate(() => this.start_pause());
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Error running program: ", error);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
mixins: [require("./program-mixin"), require("./axis-vars")],
|
||||
mixins: [require("./axis-vars")],
|
||||
};
|
||||
|
||||
@@ -49,17 +49,14 @@ module.exports = {
|
||||
methods: {
|
||||
get_io_state_class: function(active, state) {
|
||||
if (typeof active == "undefined" || typeof state == "undefined") {
|
||||
return "fa-triangle-exclamation warn";
|
||||
return "fa-exclamation-triangle warn";
|
||||
}
|
||||
|
||||
// Tristated: render as the regular (outline) circle to
|
||||
// distinguish from active/inactive solid circles. Adding
|
||||
// `far` switches to the FA6 regular family.
|
||||
if (state == 2) {
|
||||
return "far fa-circle";
|
||||
return "fa-circle-o";
|
||||
}
|
||||
|
||||
const icon = state ? "fa-circle-plus" : "fa-circle-minus";
|
||||
const icon = state ? "fa-plus-circle" : "fa-minus-circle";
|
||||
return `${icon} ${active ? "active" : "inactive"}`;
|
||||
},
|
||||
|
||||
|
||||
@@ -44,16 +44,6 @@ window.onload = function() {
|
||||
cookie_set("client-id", uuid(), 10000);
|
||||
}
|
||||
|
||||
// Vue 1's async queue can drop dependent watcher updates when
|
||||
// data props are mutated outside the normal event flow (e.g. from
|
||||
// a `hashchange` listener that fires before Vue's tick scheduler
|
||||
// has caught up). Disable async batching so every reactive write
|
||||
// synchronously re-evaluates dependents — this matches Vue 1's
|
||||
// older default and is what the legacy UI implicitly relied on.
|
||||
if (Vue.config) {
|
||||
Vue.config.async = false;
|
||||
}
|
||||
|
||||
// Register global components
|
||||
Vue.component("templated-input", require("./templated-input"));
|
||||
Vue.component("message", require("./message"));
|
||||
|
||||
@@ -87,16 +87,100 @@ module.exports = {
|
||||
return this.stallRPM * this.stepsPerRev * ustep / 60;
|
||||
},
|
||||
|
||||
// NOTE: do not add `current_xxx` computed props that mirror
|
||||
// controller state vars (`<idx>vm`, `<idx>am`, …) and pair
|
||||
// them with watchers that copy state -> motor config. The
|
||||
// controller streams those vars continuously over the WS;
|
||||
// any watcher that writes them back into
|
||||
// `config.motors[index]` will clobber whatever the user is
|
||||
// typing into the form between websocket ticks. The form
|
||||
// edits config directly; Save (app.js) PUTs it to the
|
||||
// server. The server-side rotary toggle is handled by
|
||||
// refetching config after the PUT, not by watching state.
|
||||
current_axis: function() {
|
||||
return this.state[this.index + 'an'];
|
||||
},
|
||||
|
||||
current_max_velocity: function() {
|
||||
return this.state[this.index + 'vm'];
|
||||
},
|
||||
|
||||
current_max_soft_limit: function() {
|
||||
return this.state[this.index + 'tm'];
|
||||
},
|
||||
|
||||
current_min_soft_limit: function() {
|
||||
return this.state[this.index + 'tn'];
|
||||
},
|
||||
current_max_accel: function() {
|
||||
return this.state[this.index + 'am'];
|
||||
},
|
||||
current_max_jerk: function() {
|
||||
return this.state[this.index + 'jm'];
|
||||
},
|
||||
current_step_angle: function() {
|
||||
return this.state[this.index + 'sa'];
|
||||
},
|
||||
current_travel_per_rev: function() {
|
||||
return this.state[this.index + 'tr'];
|
||||
},
|
||||
current_microsteps: function() {
|
||||
return this.state[this.index + 'mi'];
|
||||
}
|
||||
},
|
||||
|
||||
attached: function() {
|
||||
// Sync all state values with motor config when component is ready
|
||||
// This ensures UI shows correct values when component is first loaded
|
||||
console.log("Syncing state to motor config for motor index ",this.index);
|
||||
this.syncStateToConfig();
|
||||
},
|
||||
|
||||
watch: {
|
||||
current_axis(new_value) {
|
||||
const motor_axes = ["X", "Y", "Z", "A", "B", "C"]
|
||||
if(motor_axes[new_value] != this.motor['axis']){
|
||||
this.motor['axis'] = motor_axes[new_value];
|
||||
}
|
||||
},
|
||||
|
||||
current_max_velocity(new_value) {
|
||||
if(new_value != this.motor['max-velocity']) {
|
||||
this.motor['max-velocity'] = new_value;
|
||||
}
|
||||
},
|
||||
|
||||
current_max_soft_limit(new_value) {
|
||||
if(new_value != this.motor['max-soft-limit']) {
|
||||
this.motor['max-soft-limit'] = new_value;
|
||||
}
|
||||
},
|
||||
|
||||
current_min_soft_limit(new_value) {
|
||||
if(new_value != this.motor['min-soft-limit']) {
|
||||
this.motor['min-soft-limit'] = new_value;
|
||||
}
|
||||
},
|
||||
|
||||
current_max_accel(new_value) {
|
||||
if(new_value != this.motor['max-accel']) {
|
||||
this.motor['max-accel'] = new_value;
|
||||
}
|
||||
},
|
||||
|
||||
current_max_jerk(new_value) {
|
||||
if(new_value != this.motor['max-jerk']) {
|
||||
this.motor['max-jerk'] = new_value;
|
||||
}
|
||||
},
|
||||
|
||||
current_step_angle(new_value) {
|
||||
if(new_value != this.motor['step-angle']) {
|
||||
this.motor['step-angle'] = new_value;
|
||||
}
|
||||
},
|
||||
|
||||
current_travel_per_rev(new_value) {
|
||||
if(new_value != this.motor['travel-per-rev']) {
|
||||
this.motor['travel-per-rev'] = new_value;
|
||||
}
|
||||
},
|
||||
|
||||
current_microsteps(new_value) {
|
||||
if(new_value != this.motor['microsteps']) {
|
||||
this.motor['microsteps'] = new_value;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
events: {
|
||||
@@ -126,6 +210,45 @@ module.exports = {
|
||||
}
|
||||
|
||||
return templ.hmodes.indexOf(this.motor["homing-mode"]) != -1;
|
||||
},
|
||||
|
||||
syncStateToConfig: function() {
|
||||
// Force sync all state values to motor config
|
||||
// This ensures the UI reflects the current state even if changes happened while component was unmounted
|
||||
|
||||
if(this.state == undefined) {
|
||||
console.log("State is undefined");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.state[this.index + 'an'] != this.motor['axis']) {
|
||||
const motor_axes = ["X", "Y", "Z", "A", "B", "C"];
|
||||
this.$set('motor["axis"]', motor_axes[this.state[this.index + 'an']]);
|
||||
}
|
||||
if (this.state[this.index + 'vm'] != this.motor['max-velocity']) {
|
||||
this.$set('motor["max-velocity"]', this.state[this.index + 'vm']);
|
||||
}
|
||||
if (this.state[this.index + 'tm'] != this.motor['max-soft-limit']) {
|
||||
this.$set('motor["max-soft-limit"]', this.state[this.index + 'tm']);
|
||||
}
|
||||
if (this.state[this.index + 'tn'] != this.motor['min-soft-limit']) {
|
||||
this.$set('motor["min-soft-limit"]', this.state[this.index + 'tn']);
|
||||
}
|
||||
if (this.state[this.index + 'am'] != this.motor['max-accel']) {
|
||||
this.$set('motor["max-accel"]', this.state[this.index + 'am']);
|
||||
}
|
||||
if (this.state[this.index + 'jm'] != this.motor['max-jerk']) {
|
||||
this.$set('motor["max-jerk"]', this.state[this.index + 'jm']);
|
||||
}
|
||||
if (this.state[this.index + 'sa'] != this.motor['step-angle']) {
|
||||
this.$set('motor["step-angle"]', this.state[this.index + 'sa']);
|
||||
}
|
||||
if (this.state[this.index + 'tr'] != this.motor['travel-per-rev']) {
|
||||
this.$set('motor["travel-per-rev"]', this.state[this.index + 'tr']);
|
||||
}
|
||||
if (this.state[this.index + 'mi'] != this.motor['microsteps']) {
|
||||
this.$set('motor["microsteps"]', this.state[this.index + 'mi']);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -683,16 +683,12 @@ const OrbitControls = function(object, domElement) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
// Chrome treats touch/wheel listeners as passive by default,
|
||||
// which prevents OrbitControls.preventDefault() from suppressing
|
||||
// page panning while interacting with the 3D viewer. Pass
|
||||
// {passive: false} on the events that need to call preventDefault.
|
||||
scope.domElement.addEventListener("contextmenu", onContextMenu, false);
|
||||
scope.domElement.addEventListener("mousedown", onMouseDown, false);
|
||||
scope.domElement.addEventListener("wheel", onMouseWheel, { passive: false });
|
||||
scope.domElement.addEventListener("touchstart", onTouchStart, { passive: false });
|
||||
scope.domElement.addEventListener("wheel", onMouseWheel, false);
|
||||
scope.domElement.addEventListener("touchstart", onTouchStart, false);
|
||||
scope.domElement.addEventListener("touchend", onTouchEnd, false);
|
||||
scope.domElement.addEventListener("touchmove", onTouchMove, { passive: false });
|
||||
scope.domElement.addEventListener("touchmove", onTouchMove, false);
|
||||
window.addEventListener("keydown", onKeyDown, false);
|
||||
|
||||
this.update(); // force an update at start
|
||||
|
||||
@@ -101,13 +101,6 @@ module.exports = {
|
||||
Vue.nextTick(this.update);
|
||||
},
|
||||
|
||||
beforeDestroy: function() {
|
||||
if (this._sizeWatcher) {
|
||||
this._sizeWatcher.disconnect();
|
||||
this._sizeWatcher = null;
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
update: async function() {
|
||||
if (!this.webglAvailable) {
|
||||
@@ -208,12 +201,6 @@ module.exports = {
|
||||
}
|
||||
|
||||
const dims = this.get_dims();
|
||||
// Skip layouts where the target has no measurable size.
|
||||
// The render loop guard below will not draw frames until
|
||||
// a real size has been observed at least once.
|
||||
if (!(dims.width > 0 && dims.height > 0)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.camera.aspect = dims.width / dims.height;
|
||||
this.camera.updateProjectionMatrix();
|
||||
@@ -287,23 +274,12 @@ module.exports = {
|
||||
}
|
||||
|
||||
try {
|
||||
// Renderer. Use an opaque canvas with a clear color
|
||||
// that matches the page-side gradient so the moment
|
||||
// the canvas is appended (and before the first 3D
|
||||
// frame is drawn) the user does not see a flash from
|
||||
// the page background through transparency.
|
||||
this.renderer = new THREE.WebGLRenderer({
|
||||
antialias: true,
|
||||
alpha: false,
|
||||
});
|
||||
// Renderer
|
||||
this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
||||
this.renderer.setPixelRatio(window.devicePixelRatio);
|
||||
this.renderer.setClearColor(0x222222, 1);
|
||||
// Same color on the DOM element itself so the very
|
||||
// first paint (before the WebGL context has cleared)
|
||||
// is dark too.
|
||||
this.renderer.domElement.style.background = "#222222";
|
||||
this.renderer.domElement.style.display = "block";
|
||||
this.renderer.setClearColor(0, 0);
|
||||
this.target.appendChild(this.renderer.domElement);
|
||||
|
||||
} catch (e) {
|
||||
console.log("WebGL not supported: ", e);
|
||||
return;
|
||||
@@ -357,46 +333,8 @@ module.exports = {
|
||||
// Events
|
||||
window.addEventListener("resize", this.update_view, false);
|
||||
|
||||
// Start the render loop only after the target has a real,
|
||||
// stable size. Without this, the first frame paints into
|
||||
// a 0×0 / collapsed-flex canvas and a second frame paints
|
||||
// again at the right size — visible as a flash on the
|
||||
// very first mount of the Program tab.
|
||||
const startRendering = () => {
|
||||
if (this._rendering) return;
|
||||
this._rendering = true;
|
||||
this.update_view();
|
||||
this.render();
|
||||
};
|
||||
|
||||
const dims = this.get_dims();
|
||||
if (dims.width > 0 && dims.height > 0) {
|
||||
startRendering();
|
||||
} else if (typeof ResizeObserver !== "undefined") {
|
||||
this._sizeWatcher = new ResizeObserver(entries => {
|
||||
for (const entry of entries) {
|
||||
const r = entry.contentRect;
|
||||
if (r.width > 0 && r.height > 0) {
|
||||
this._sizeWatcher.disconnect();
|
||||
this._sizeWatcher = null;
|
||||
startRendering();
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
this._sizeWatcher.observe(this.target);
|
||||
} else {
|
||||
// Old browser fallback: poll for a non-zero size.
|
||||
const tick = () => {
|
||||
const d = this.get_dims();
|
||||
if (d.width > 0 && d.height > 0) {
|
||||
startRendering();
|
||||
} else {
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
// Start it
|
||||
this.render();
|
||||
},
|
||||
|
||||
create_surface_material: function() {
|
||||
@@ -708,14 +646,6 @@ module.exports = {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't paint frames while the target has no size; this
|
||||
// prevents an initial single-frame clear from painting
|
||||
// before the layout has settled (visible as a dark flash).
|
||||
const dims = this.get_dims();
|
||||
if (!(dims.width > 0 && dims.height > 0)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.controls.update() || this.dirty) {
|
||||
this.dirty = false;
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
|
||||
@@ -1,607 +0,0 @@
|
||||
"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);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,62 +0,0 @@
|
||||
"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")],
|
||||
};
|
||||
@@ -1,80 +0,0 @@
|
||||
// Lightweight UI-side restart/cold-load timing.
|
||||
//
|
||||
// Records a few key marks using performance.now(), then POSTs them to
|
||||
// /api/diag/timing/ui once 'ui.first_state' has fired. Disabled by
|
||||
// setting window.BBCTRL_TRACE = false before this module is loaded.
|
||||
//
|
||||
// Marks collected:
|
||||
// script.load -- this module evaluated
|
||||
// ws.open -- websocket onopen
|
||||
// ws.first_msg -- first message from controller
|
||||
// ui.first_state -- first message that contained controller state
|
||||
// window.load -- window 'load' event
|
||||
//
|
||||
// Aligning these with /api/diag/timing on the server gives the full
|
||||
// picture from systemd start -> bbctrl up -> WS open -> UI rendered.
|
||||
"use strict";
|
||||
|
||||
const _enabled = typeof window !== "undefined" && window.BBCTRL_TRACE !== false;
|
||||
const _t0 = (typeof performance !== "undefined" && performance.now)
|
||||
? performance.now()
|
||||
: Date.now();
|
||||
const _navStart = (typeof performance !== "undefined" && performance.timeOrigin)
|
||||
? performance.timeOrigin
|
||||
: Date.now();
|
||||
|
||||
const marks = [];
|
||||
let posted = false;
|
||||
|
||||
function _now() {
|
||||
return (typeof performance !== "undefined" && performance.now)
|
||||
? performance.now() - _t0
|
||||
: Date.now() - _t0;
|
||||
}
|
||||
|
||||
function mark(name, fields) {
|
||||
if (!_enabled) return;
|
||||
marks.push(Object.assign({ n: name, t: Math.round(_now()) }, fields || {}));
|
||||
}
|
||||
|
||||
function _post() {
|
||||
if (!_enabled || posted) return;
|
||||
posted = true;
|
||||
const body = JSON.stringify({
|
||||
navStart: _navStart,
|
||||
t0_perf: _t0,
|
||||
href: typeof location !== "undefined" ? location.href : "",
|
||||
ua: typeof navigator !== "undefined" ? navigator.userAgent : "",
|
||||
marks: marks,
|
||||
});
|
||||
try {
|
||||
if (typeof fetch === "function") {
|
||||
fetch("/api/diag/timing/ui", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: body,
|
||||
keepalive: true,
|
||||
}).catch(() => {});
|
||||
}
|
||||
} catch (e) { /* swallow */ }
|
||||
}
|
||||
|
||||
// Record window load too; doesn't block posting.
|
||||
if (_enabled && typeof window !== "undefined") {
|
||||
window.addEventListener("load", () => mark("window.load"));
|
||||
}
|
||||
|
||||
mark("script.load");
|
||||
|
||||
module.exports = {
|
||||
enabled: _enabled,
|
||||
mark: mark,
|
||||
onWsOpen: () => mark("ws.open"),
|
||||
onWsFirstMessage: () => mark("ws.first_msg"),
|
||||
onFirstState: () => {
|
||||
mark("ui.first_state");
|
||||
// Defer slightly so any synchronous render finishes first.
|
||||
setTimeout(_post, 100);
|
||||
},
|
||||
flush: _post,
|
||||
};
|
||||
@@ -1,169 +0,0 @@
|
||||
"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,60 +1,14 @@
|
||||
// V09 wraps the legacy Svelte SettingsView and filters its big page
|
||||
// down to a single rail section so each rail item shows only the
|
||||
// relevant controls. The Svelte component is left untouched (it is
|
||||
// shared with the legacy UI) — we just hide the `<h2>` and `<fieldset>`
|
||||
// elements whose `data-sec` does not match the active section.
|
||||
|
||||
module.exports = {
|
||||
template: "#settings-view-template",
|
||||
|
||||
props: {
|
||||
// "display" | "probing" | "gcode". Default is "display" which
|
||||
// keeps the rail's "Display & Units" item working unchanged.
|
||||
section: { default: "display" },
|
||||
},
|
||||
|
||||
attached: function () {
|
||||
attached: function() {
|
||||
this.svelteComponent = SvelteComponents.createComponent(
|
||||
"SettingsView",
|
||||
document.getElementById("settings")
|
||||
);
|
||||
// Defer one tick so Svelte has rendered the section markup.
|
||||
setTimeout(() => this.apply_section_filter(), 0);
|
||||
},
|
||||
|
||||
detached: function () {
|
||||
if (this.svelteComponent) this.svelteComponent.$destroy();
|
||||
},
|
||||
|
||||
watch: {
|
||||
section: function () {
|
||||
this.apply_section_filter();
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
apply_section_filter: function () {
|
||||
const root = document.getElementById("settings");
|
||||
if (!root) return;
|
||||
const want = this.section || "display";
|
||||
// Hide every section block that does not match.
|
||||
root.querySelectorAll("[data-sec]").forEach(el => {
|
||||
el.style.display = el.dataset.sec === want ? "" : "none";
|
||||
});
|
||||
// Hide the global <h1>Settings</h1> on subsections so the
|
||||
// page reads as a focused panel.
|
||||
const h1 = root.querySelector(".settings-view > h1");
|
||||
if (h1) {
|
||||
if (want === "display") {
|
||||
h1.textContent = "Display & Units";
|
||||
} else if (want === "probing") {
|
||||
h1.textContent = "Probing";
|
||||
} else if (want === "gcode") {
|
||||
h1.textContent = "G-code & Motion";
|
||||
} else {
|
||||
h1.textContent = "Settings";
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
detached: function() {
|
||||
this.svelteComponent.$destroy();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -8,8 +8,9 @@ html(lang="en")
|
||||
|
||||
|
||||
style: include ../static/css/pure-min.css
|
||||
style: include ../static/css/side-menu.css
|
||||
|
||||
style: include ../static/css/fa6.min.css
|
||||
style: include ../static/css/font-awesome.min.css
|
||||
style: include ../static/css/Audiowide.css
|
||||
style: include ../static/css/clusterize.css
|
||||
style: include ../svelte-components/node_modules/svelte-material-ui/bare.css
|
||||
@@ -18,171 +19,103 @@ html(lang="en")
|
||||
style: include:stylus ../stylus/style.styl
|
||||
|
||||
body(v-cloak)
|
||||
// Tablet (kiosk) mode — pins the .app-shell to 1920x1080 and
|
||||
// scales it to fit the actual viewport so the UI always looks
|
||||
// exactly like the 10.8" 1920x1080 portable monitor.
|
||||
//
|
||||
// Toggle: ?tablet=1 to enable
|
||||
// ?tablet=0 to disable
|
||||
// Sticky in localStorage; once set, no querystring is needed.
|
||||
script.
|
||||
(function () {
|
||||
try {
|
||||
var p = new URLSearchParams(location.search);
|
||||
if (p.has("tablet")) {
|
||||
var on = p.get("tablet") !== "0" && p.get("tablet") !== "false";
|
||||
localStorage.setItem("ui-tablet-mode", on ? "1" : "0");
|
||||
}
|
||||
if (localStorage.getItem("ui-tablet-mode") === "1") {
|
||||
document.documentElement.classList.add("tablet-mode");
|
||||
}
|
||||
function fit() {
|
||||
if (!document.documentElement.classList.contains("tablet-mode")) return;
|
||||
var s = Math.min(window.innerWidth / 1920, window.innerHeight / 1080);
|
||||
document.documentElement.style.setProperty("--tablet-scale", s);
|
||||
}
|
||||
fit();
|
||||
window.addEventListener("resize", fit);
|
||||
|
||||
// Kiosk mode: when the UI is loaded by the controller's
|
||||
// own onboard browser (Chromium pointing at localhost on
|
||||
// the Pi 3B at 1366x768), apply a tighter layout that
|
||||
// packs the V09 UI into the smaller, slower display.
|
||||
// Override with ?kiosk=0 to force the desktop layout.
|
||||
if (p.has("kiosk")) {
|
||||
var k = p.get("kiosk") !== "0" && p.get("kiosk") !== "false";
|
||||
localStorage.setItem("ui-kiosk-mode", k ? "1" : "0");
|
||||
}
|
||||
var stored = localStorage.getItem("ui-kiosk-mode");
|
||||
var auto = location.hostname === "localhost"
|
||||
|| location.hostname === "127.0.0.1"
|
||||
|| location.hostname === "::1";
|
||||
if (stored === "1" || (stored !== "0" && auto)) {
|
||||
document.documentElement.classList.add("kiosk-mode");
|
||||
}
|
||||
} catch (_e) {}
|
||||
})();
|
||||
|
||||
#svelte-dialog-host
|
||||
|
||||
#overlay(v-if="status != 'connected'")
|
||||
span {{status}}
|
||||
|
||||
.app-shell
|
||||
header.app-head
|
||||
.brand-blk
|
||||
.brand-logo
|
||||
.brand-name ONEFINITY
|
||||
#layout
|
||||
a#menuLink.menu-link(href="#menu"): span
|
||||
|
||||
nav.tabs-host(role="tablist")
|
||||
a.ktab(:class="{active: top_tab === 'control'}", href="#control",
|
||||
title="Jog, DRO, macros")
|
||||
.fa.fa-gamepad
|
||||
span Control
|
||||
a.ktab(:class="{active: top_tab === 'program'}", href="#program",
|
||||
title="Run programs, files, toolpath preview")
|
||||
.fa.fa-list-ol
|
||||
span Program
|
||||
a.ktab(:class="{active: top_tab === 'console'}", href="#console",
|
||||
title="MDI, messages, indicators")
|
||||
.fa.fa-terminal
|
||||
span Console
|
||||
span.ktab-badge(v-if="messages_count") {{messages_count}}
|
||||
a.ktab(:class="{active: top_tab === 'settings'}", href="#settings",
|
||||
title="Configuration, network, macros")
|
||||
.fa.fa-sliders
|
||||
span Settings
|
||||
#menu
|
||||
button.save.pure-button.button-success(:disabled="!modified",
|
||||
@click="save") Save
|
||||
|
||||
.head-spacer
|
||||
.pure-menu
|
||||
ul.pure-menu-list
|
||||
li.pure-menu-heading
|
||||
a.pure-menu-link(href="#control") Control
|
||||
|
||||
.sys-btn(@click.stop="toggle_sys_popover", :class="{open: sys_open}")
|
||||
span.pip(:class="sys_class")
|
||||
span.sys-text {{sys_summary}}
|
||||
.fa.fa-chevron-down
|
||||
li.pure-menu-heading
|
||||
a.pure-menu-link(href="#macros") Macros
|
||||
|
||||
.pi-temp-warning(v-if="80 <= state.rpi_temp",
|
||||
title="Raspberry Pi temperature too high.")
|
||||
.fa.fa-temperature-full
|
||||
li.pure-menu-heading
|
||||
a.pure-menu-link(href="#settings") Settings
|
||||
|
||||
span.state-badge(:class="state_class", :title="mach_state_full")
|
||||
span.dot
|
||||
span {{state_label}}
|
||||
li.pure-menu-heading
|
||||
a.pure-menu-link(href="#motor:0") Motors
|
||||
|
||||
.estop(:class="{active: state.es}")
|
||||
estop(@click="estop")
|
||||
li.pure-menu-item(v-for="motor in config.motors")
|
||||
a.pure-menu-link(:href="'#motor:' + $index") Motor {{$index}}
|
||||
|
||||
// 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")
|
||||
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.")
|
||||
|
||||
.easy-adapter(v-if="is_easy_adapter_active")
|
||||
.round-dot
|
||||
div.easy-adapter-text Easy Adapter
|
||||
|
||||
.whitespace
|
||||
|
||||
div
|
||||
button.rotary-button(:disabled="!enable_rotary", :class="is_rotary_active && 'active'", @click="showSwitchRotaryModeDialog")
|
||||
img(src="/images/rotary.svg", alt="rotary", :style="is_rotary_active ? 'width:90%;' : 'width:85%;'")
|
||||
div.rotary-text Rotary
|
||||
|
||||
.video(title="Plug camera into USB.\n" +
|
||||
"Left click to toggle video size.\n" +
|
||||
"Right click to toggle crosshair.", @click="toggle_video",
|
||||
@contextmenu="toggle_crosshair", :class="video_size")
|
||||
.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 ? '*' : ''}}
|
||||
img(src="/api/video")
|
||||
|
||||
// Routed view. We keep instances alive across tab swaps so:
|
||||
// - The Program tab's WebGL <path-viewer> canvas does not
|
||||
// get destroyed and recreated each time (which caused a
|
||||
// dark flash as the GL context cleared the new canvas
|
||||
// before its first frame).
|
||||
// - The Program tab's clusterize.js gcode list does not
|
||||
// re-virtualize from scratch on every visit.
|
||||
// - The Settings shell's child Svelte components stay
|
||||
// mounted, preserving any in-flight form state.
|
||||
// The settings-shell handles its own inner v-if cascade so
|
||||
// the Vue 1 reactivity quirk that motivated removing
|
||||
// keep-alive earlier no longer applies here.
|
||||
.app-body
|
||||
component(:is="currentView + '-view'", :index="index",
|
||||
:config="config", :template="template", :state="state",
|
||||
:sub-tab="sub_tab", keep-alive)
|
||||
.estop(:class="{active: state.es}")
|
||||
estop(@click="estop")
|
||||
|
||||
.content(class="{{currentView}}-view")
|
||||
component(:is="currentView + '-view'", :index="index",
|
||||
:config="config", :template="template", :state="state", keep-alive)
|
||||
|
||||
message.error-message(:show.sync="errorShow")
|
||||
div(slot="header")
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
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,296 +1,475 @@
|
||||
script#control-view-template(type="text/x-template")
|
||||
.control-page
|
||||
// ----- Modal dialogs (kept verbatim from legacy) -----
|
||||
#control
|
||||
message(:show.sync="showGcodeMessage")
|
||||
h3(slot="header") Processing New File
|
||||
|
||||
div(slot="body")
|
||||
h3 Please wait..
|
||||
p Simulating GCode to check for errors, calculate ETA and generate 3D view.
|
||||
|
||||
div(slot="footer")
|
||||
label Simulating {{(toolpath_progress || 0) | percent}}
|
||||
|
||||
message(:show.sync="showNoGcodeMessage")
|
||||
h3(slot="header") GCode Not Set
|
||||
div(slot="body")
|
||||
p Configure the GCode for the selected macro to use it
|
||||
div(slot="footer")
|
||||
button.pure-button(@click="showNoGcodeMessage=false") OK
|
||||
h3(slot="header") GCode Not Set
|
||||
div(slot="body")
|
||||
p Configure the GCode for the selected macro to use it
|
||||
|
||||
div(slot="footer")
|
||||
button.pure-button(@click="showNoGcodeMessage=false") OK
|
||||
|
||||
message(:show.sync="macrosLoading")
|
||||
h3(slot="header") Run Macro?
|
||||
div(slot="body")
|
||||
p
|
||||
| The macro file
|
||||
strong {{state.selected}}
|
||||
| is being loaded.
|
||||
div(slot="footer")
|
||||
button.pure-button(@click="macrosLoading=false") Cancel
|
||||
button.pure-button.pure-button-primary(@click="start_pause") Run
|
||||
h3(slot="header") Run Macro?
|
||||
div(slot="body")
|
||||
p
|
||||
| The macro file
|
||||
strong {{state.selected}}
|
||||
| is being loaded.
|
||||
|
||||
div(slot="footer")
|
||||
button.pure-button(@click="macrosLoading=false") Cancel
|
||||
button.pure-button.pure-button-primary(@click="start_pause") Run
|
||||
|
||||
message(:show.sync="GCodeNotFound")
|
||||
h3(slot="header") File not found
|
||||
div(slot="body")
|
||||
p It seems like the file you selected cannot be found. Try uploading again.
|
||||
p It seems like the file you selected cannot be found. Try uploading again.
|
||||
div(slot="footer")
|
||||
button.pure-button.button-error(@click="GCodeNotFound=false") OK
|
||||
button.pure-button.button-error(@click="GCodeNotFound=false")
|
||||
| OK
|
||||
|
||||
message(:show.sync="show_probe_dialog")
|
||||
h3(slot="header") Choose probe type
|
||||
h3(slot="header") Probe Rotary
|
||||
div(slot="body")
|
||||
p Pick which probe routine to run.
|
||||
button.pure-button(:class="state['pw'] ? '' : 'load-on'", @click="showProbeDialog('xyz')") Probe XYZ
|
||||
button.pure-button(:class="state['pw'] ? '' : 'load-on'", @click="showProbeDialog('z')") Probe Z
|
||||
div(slot="footer")
|
||||
button.pure-button(@click="show_probe_dialog=false") Cancel
|
||||
|
||||
// ----- Main grid: jog | (DRO + status strip) -----
|
||||
.control-grid
|
||||
|
||||
// ===== JOG =====
|
||||
// Hidden only while a G-code program is running / paused /
|
||||
// stopping. Jogging / homing / MDI moves do not hide it.
|
||||
.jog-card(v-if="!is_program_executing")
|
||||
.jog-head
|
||||
.jog-title
|
||||
| Jog
|
||||
span.step-pre · step
|
||||
span.step {{jog_incr_amounts[display_units][jog_incr]}}#[span.unit {{metric ? 'mm' : 'in'}}]
|
||||
.step-seg
|
||||
button(:class="{active: jog_incr === 'fine'}", @click="jog_incr = 'fine'")
|
||||
| {{jog_incr_amounts[display_units].fine}}
|
||||
button(:class="{active: jog_incr === 'small'}", @click="jog_incr = 'small'")
|
||||
| {{jog_incr_amounts[display_units].small}}
|
||||
button(:class="{active: jog_incr === 'medium'}", @click="jog_incr = 'medium'")
|
||||
| {{jog_incr_amounts[display_units].medium}}
|
||||
button(:class="{active: jog_incr === 'large'}", @click="jog_incr = 'large'")
|
||||
| {{jog_incr_amounts[display_units].large}}
|
||||
table(style="table-layout: fixed; width: 100%;")
|
||||
tr(style="height: fit-content;")
|
||||
td(style="white-space: nowrap; width: 410px;", rowspan="2")
|
||||
table.control-buttons(table-layout="fixed")
|
||||
colgroup
|
||||
col(style="width:100px")
|
||||
col(style="width:100px")
|
||||
col(style="width:100px")
|
||||
col(style="width:100px")
|
||||
tr
|
||||
td(style="height:100px",align="center")
|
||||
button(@click="jog_fn(-1,1,0,0)")
|
||||
.fa.fa-arrow-right(style="transform: rotate(-135deg);")
|
||||
td(style="height:100px",align="center")
|
||||
button(@click="jog_fn(0,1,0,0)") Y+
|
||||
td(style="height:100px",align="center")
|
||||
button(@click="jog_fn(1,1,0,0)")
|
||||
.fa.fa-arrow-right(style="transform: rotate(-45deg);")
|
||||
td(style="height:100px",align="center")
|
||||
button(,@click="jog_fn(0,0,1,0)") Z+
|
||||
tr
|
||||
td(style="height:100px",align="center")
|
||||
button(@click="jog_fn(-1,0,0,0)") X-
|
||||
td(style="height:100px",align="center")
|
||||
button(@click="showMoveToZeroDialog('xy')")
|
||||
| XY
|
||||
br
|
||||
| Origin
|
||||
td(style="height:100px",align="center")
|
||||
button(@click="jog_fn(1,0,0,0)") X+
|
||||
td(style="height:100px",align="center")
|
||||
button(@click="showMoveToZeroDialog('z')")
|
||||
| Z
|
||||
br
|
||||
| Origin
|
||||
tr
|
||||
td(style="height:100px",align="center")
|
||||
button(@click="jog_fn(-1,-1,0,0)")
|
||||
.fa.fa-arrow-right(style="transform: rotate(135deg);")
|
||||
td(style="height:100px",align="center")
|
||||
button(@click="jog_fn(0,-1,0,0)") Y-
|
||||
td(style="height:100px",align="center")
|
||||
button(@click="jog_fn(1,-1,0,0)")
|
||||
.fa.fa-arrow-right(style="transform: rotate(45deg);")
|
||||
td(style="height:100px",align="center")
|
||||
button(@click="jog_fn(0,0,-1,0)") Z-
|
||||
tr
|
||||
td(style="height:100px",align="center")
|
||||
button(:style="getJogIncrStyle('fine')", @click="jog_incr = 'fine'")
|
||||
span {{jog_incr_amounts[display_units].fine}}#[span.jog-units {{metric ? 'mm' : 'in'}}]
|
||||
td(style="height:100px",align="center")
|
||||
button(:style="getJogIncrStyle('small')", @click="jog_incr = 'small'")
|
||||
span {{jog_incr_amounts[display_units].small}}#[span.jog-units {{metric ? 'mm' : 'in'}}]
|
||||
td(style="height:100px",align="center")
|
||||
button(:style="getJogIncrStyle('medium')", @click="jog_incr = 'medium'")
|
||||
span {{jog_incr_amounts[display_units].medium}}#[span.jog-units {{metric ? 'mm' : 'in'}}]
|
||||
td(style="height:100px",align="center")
|
||||
button(:style="getJogIncrStyle('large')", @click="jog_incr = 'large'")
|
||||
span {{jog_incr_amounts[display_units].large}}#[span.jog-units {{metric ? 'mm' : 'in'}}]
|
||||
|
||||
.jog-grid
|
||||
// Row 1
|
||||
button.jbtn.dir(@click="jog_fn(-1, 1, 0, 0)", title="X- Y+")
|
||||
.fa.fa-arrow-up.ico(style="transform: rotate(-45deg)")
|
||||
button.jbtn(@click="jog_fn(0, 1, 0, 0)") Y+
|
||||
button.jbtn.dir(@click="jog_fn(1, 1, 0, 0)", title="X+ Y+")
|
||||
.fa.fa-arrow-up.ico(style="transform: rotate(45deg)")
|
||||
button.jbtn(@click="jog_fn(0, 0, 1, 0)") Z+
|
||||
tr(v-if="state['2an'] == 3")
|
||||
td(style="height:100px", align="center", colspan="1")
|
||||
button(@click="show_probe_dialog=true")
|
||||
| Probe
|
||||
br
|
||||
| Rotary
|
||||
|
||||
// Row 2
|
||||
button.jbtn(@click="jog_fn(-1, 0, 0, 0)") X−
|
||||
button.jbtn(@click="showMoveToZeroDialog('xy')")
|
||||
span.lbl XY
|
||||
span Origin
|
||||
button.jbtn(@click="jog_fn(1, 0, 0, 0)") X+
|
||||
button.jbtn(@click="showMoveToZeroDialog('z')")
|
||||
span.lbl Z
|
||||
span Origin
|
||||
td(style="height:100px", align="center", colspan="1")
|
||||
button(@click="jog_fn(0,0,0,-1)", style="display: grid;justify-content: center;align-items: center;padding: 14px;")
|
||||
| A-
|
||||
.fa.fa-rotate-left
|
||||
|
||||
// Row 3
|
||||
button.jbtn.dir(@click="jog_fn(-1, -1, 0, 0)", title="X- Y-")
|
||||
.fa.fa-arrow-down.ico(style="transform: rotate(45deg)")
|
||||
button.jbtn(@click="jog_fn(0, -1, 0, 0)") Y−
|
||||
button.jbtn.dir(@click="jog_fn(1, -1, 0, 0)", title="X+ Y-")
|
||||
.fa.fa-arrow-down.ico(style="transform: rotate(-45deg)")
|
||||
button.jbtn(@click="jog_fn(0, 0, -1, 0)") Z−
|
||||
td(style="height:100px", align="center", colspan="1")
|
||||
button(@click="showMoveToZeroDialog('a')")
|
||||
| A
|
||||
br
|
||||
| Origin
|
||||
|
||||
// Row 4 — A axis (rotary) when rotary is enabled.
|
||||
template(v-if="state['2an'] == 3")
|
||||
button.jbtn.dir(@click="jog_fn(0, 0, 0, -1)")
|
||||
.fa.fa-rotate-left.ico
|
||||
span.lbl A−
|
||||
button.jbtn.ghost(@click="showMoveToZeroDialog('a')")
|
||||
span.lbl A
|
||||
span Origin
|
||||
button.jbtn.dir(@click="jog_fn(0, 0, 0, 1)")
|
||||
.fa.fa-rotate-right.ico
|
||||
span.lbl A+
|
||||
button.jbtn(@click="show_probe_dialog=true",
|
||||
:class="{'load-on': !state['pw']}")
|
||||
.fa.fa-bullseye.ico
|
||||
span.lbl Probe
|
||||
td(style="height:100px", align="center", colspan="1")
|
||||
button(@click="jog_fn(0,0,0,1)", style="display: grid;justify-content: center;align-items: center;padding: 14px;")
|
||||
| A+
|
||||
.fa.fa-rotate-right
|
||||
|
||||
// Row 4 — fallback probe / zero / home shortcuts
|
||||
template(v-if="state['2an'] != 3")
|
||||
button.jbtn(@click="showProbeDialog('xyz')",
|
||||
:class="{'load-on': !state['pw']}")
|
||||
.fa.fa-bullseye.ico
|
||||
span.lbl Probe XYZ
|
||||
button.jbtn.ghost(@click="zero()", :disabled="!can_set_axis")
|
||||
.fa.fa-location-dot.ico
|
||||
span.lbl Zero all
|
||||
button.jbtn(@click="showProbeDialog('z')",
|
||||
:class="{'load-on': !state['pw']}")
|
||||
.fa.fa-bullseye.ico
|
||||
span.lbl Probe Z
|
||||
button.jbtn.ghost(@click="home()")
|
||||
.fa.fa-home.ico
|
||||
span.lbl Home all
|
||||
tr(v-else)
|
||||
td(style="height:100px", align="center", colspan="2")
|
||||
button(:class="state['pw'] ? '' : 'load-on'",
|
||||
style="height:100px;width:200px",
|
||||
@click="showProbeDialog('xyz')")
|
||||
| Probe XYZ
|
||||
|
||||
// ===== NOW RUNNING (replaces jog grid only while a G-code
|
||||
// program is actually executing). Jogging is excluded.
|
||||
.running-panel(v-if="is_program_executing")
|
||||
.running-top
|
||||
div
|
||||
.running-file
|
||||
.fa.fa-file-code
|
||||
span(v-if="state.selected") {{state.selected}}
|
||||
span(v-else) {{(mach_state || 'BUSY').toLowerCase()}}
|
||||
.running-meta
|
||||
span(v-if="is_running") {{ (mach_state || 'RUNNING').toLowerCase() }}
|
||||
span(v-if="is_holding") paused
|
||||
span(v-if="is_holding && pause_reason") · {{pause_reason}}
|
||||
span(v-if="is_stopping") stopping
|
||||
span(v-if="toolpath.lines") · line {{state.line || 0 | number}} / {{toolpath.lines | number}}
|
||||
span(v-if="plan_time_remaining") · ETA {{plan_time_remaining | time}}
|
||||
.running-pct
|
||||
| {{((progress || 0) * 100) | fixed 0}}
|
||||
span %
|
||||
.running-progress
|
||||
div(:style="'width:' + ((progress || 0) * 100) + '%'")
|
||||
.running-stats
|
||||
.running-stat
|
||||
.lbl Velocity
|
||||
.val
|
||||
unit-value(:value="state.v", precision="2", unit="", iunit="", scale="0.0254")
|
||||
| {{metric ? 'm/min' : 'IPM'}}
|
||||
.running-stat
|
||||
.lbl Feed
|
||||
.val
|
||||
unit-value(:value="state.feed", precision="0", unit="", iunit="")
|
||||
| {{metric ? 'mm/min' : 'IPM'}}
|
||||
.running-stat
|
||||
.lbl Spindle
|
||||
.val
|
||||
| {{(state.speed || 0) | fixed 0}}
|
||||
span(v-if="state.s != null && !isNaN(state.s)") ({{state.s | fixed 0}})
|
||||
| RPM
|
||||
.running-stat
|
||||
.lbl Tool
|
||||
.val T{{state.tool || 0}}
|
||||
.running-row
|
||||
// While RUNNING the primary action is Pause; while HOLDING / STOPPING it's Resume.
|
||||
button.tx-btn.pause(v-if="is_running", @click="pause()")
|
||||
.fa.fa-pause
|
||||
span.lbl PAUSE
|
||||
button.tx-btn.run(v-if="is_holding || is_stopping", @click="unpause()")
|
||||
.fa.fa-play
|
||||
span.lbl RESUME
|
||||
button.tx-btn.stop(@click="stop()")
|
||||
.fa.fa-stop
|
||||
span.lbl STOP
|
||||
button.tx-btn.step(v-if="is_holding", @click="step()")
|
||||
.fa.fa-forward-step
|
||||
span.lbl STEP
|
||||
td(style="height:100px", align="center", colspan="2")
|
||||
button(:class="state['pw'] ? '' : 'load-on'",
|
||||
style="height:100px;width:200px",
|
||||
@click="showProbeDialog('z')")
|
||||
| Probe Z
|
||||
|
||||
// ===== DRO + status strip =====
|
||||
.right-col
|
||||
td(style="vertical-align: top;")
|
||||
table.axes
|
||||
tr(:class="axes.klass")
|
||||
th.name Axis
|
||||
th.position Position
|
||||
th.absolute Absolute
|
||||
th.offset Offset
|
||||
th.state State
|
||||
th.tstate Toolpath
|
||||
th.actions
|
||||
button.pure-button(disabled, style="height:60px;width:60px;display:none;")
|
||||
|
||||
.dro-card
|
||||
.dro-head
|
||||
div Axis
|
||||
div Position
|
||||
div Absolute
|
||||
div Offset
|
||||
.actions-cell
|
||||
// Master Home All. Each row's Actions cell has a per-axis
|
||||
// home button; this header-level button homes every
|
||||
// enabled axis (legacy Onefinity behavior).
|
||||
button.icon-btn(:disabled="!is_idle",
|
||||
title="Home all axes.", @click="home_all()")
|
||||
.fa.fa-house-chimney
|
||||
button.pure-button(:disabled="!can_set_axis",
|
||||
title="Zero all axis offsets.", @click="zero()",style="height:60px;width:60px")
|
||||
.fa.fa-map-marker
|
||||
|
||||
// Per-axis rows — keep unit-value + bindings from axis-vars
|
||||
each axis in 'xyzabc'
|
||||
.dro-row(:class=`${axis}.klass + ' ' + ${axis}.tklass`,
|
||||
v-if=`${axis}.enabled`,
|
||||
:title=`${axis}.toolmsg ? (${axis}.title + ' — ' + ${axis}.toolmsg) : ${axis}.title`)
|
||||
.dro-axis(:class=`'axis-' + '${axis}'`)= axis.toUpperCase()
|
||||
.dro-pos: unit-value(:value=`${axis}.pos`, precision=4)
|
||||
.dro-sec: unit-value(:value=`${axis}.abs`, precision=3)
|
||||
.dro-sec: unit-value(:value=`${axis}.off`, precision=3)
|
||||
.actions-cell
|
||||
button.icon-btn(:disabled="!can_set_axis",
|
||||
:title=`'Set ${axis.toUpperCase()} axis position.'`,
|
||||
@click=`show_set_position('${axis}')`)
|
||||
.fa.fa-gear
|
||||
button.icon-btn(:class=`${axis}.tklass.indexOf('error') !== -1 ? 'state-red' : (${axis}.tklass.indexOf('warn') !== -1 ? 'state-amber' : 'state-green')`,
|
||||
:disabled="!can_set_axis",
|
||||
:title=`${axis}.toolmsg || ('Zero ${axis.toUpperCase()} axis offset.')`,
|
||||
@click=`zero('${axis}')`)
|
||||
.fa.fa-location-dot
|
||||
button.icon-btn(:class=`${axis}.klass.indexOf('error') !== -1 ? 'state-red' : (${axis}.homed ? 'state-green' : 'state-amber')`,
|
||||
:disabled="!is_idle",
|
||||
:title=`${axis}.title`,
|
||||
@click=`home('${axis}')`)
|
||||
button.pure-button(title="Home all axes.", @click="home()",
|
||||
:disabled="!is_idle",style="height:60px;width:60px")
|
||||
.fa.fa-home
|
||||
|
||||
// ----- Status strip -----
|
||||
.status-strip
|
||||
.stat-card
|
||||
.stat-label State
|
||||
.stat-val(:class="state_kpi_class") {{mach_state || '--'}}
|
||||
.stat-sub(v-if="message") {{message.replace(/^#/, '')}}
|
||||
.stat-sub(v-else) No alerts
|
||||
each axis in 'xyzabc'
|
||||
tr.axis(:class=`${axis}.klass`, v-if=`${axis}.enabled`,
|
||||
:title=`${axis}.title`)
|
||||
th.name= axis
|
||||
td.position: unit-value(:value=`${axis}.pos`, precision=4)
|
||||
td.absolute: unit-value(:value=`${axis}.abs`, precision=3)
|
||||
td.offset: unit-value(:value=`${axis}.off`, precision=3)
|
||||
td.state
|
||||
.fa(:class=`'fa-' + ${axis}.icon`)
|
||||
| {{#{axis}.state}}
|
||||
td.tstate(:class=`${axis}.tklass`, :title=`${axis}.toolmsg`, @click=`showToolpathMessageDialog('${axis}')`)
|
||||
.fa(:class=`'fa-' + ${axis}.ticon`)
|
||||
| {{#{axis}.tstate}}
|
||||
|
||||
.stat-card
|
||||
.stat-label Velocity / Feed
|
||||
.stat-val
|
||||
unit-value(:value="state.v", precision="2", unit="", iunit="",
|
||||
scale="0.0254")
|
||||
| ·
|
||||
unit-value(:value="state.feed", precision="0", unit="", iunit="")
|
||||
.stat-sub {{metric ? 'm/min · mm/min' : 'IPM · IPM'}}
|
||||
th.actions
|
||||
button.pure-button(:disabled="!can_set_axis",
|
||||
title=`Set {{'${axis}' | upper}} axis position.`,
|
||||
@click=`show_set_position('${axis}')`, style="height:60px;width:60px")
|
||||
.fa.fa-cog
|
||||
|
||||
.stat-card.stat-tappable(@click="overrides_open = !overrides_open",
|
||||
:class="{open: overrides_open}", title="Tap to adjust feed/spindle override")
|
||||
.stat-label Spindle
|
||||
.stat-val
|
||||
| {{(state.speed || 0) | fixed 0}}
|
||||
span(v-if="state.s != null && !isNaN(state.s)") ({{state.s | fixed 0}})
|
||||
.stat-sub
|
||||
| RPM (commanded / actual)
|
||||
.fa.fa-sliders.tap-hint(title="Open override drawer")
|
||||
button.pure-button(:disabled="!can_set_axis",
|
||||
title=`Zero {{'${axis}' | upper}} axis offset.`,
|
||||
@click=`zero('${axis}')`, style="height:60px;width:60px")
|
||||
.fa.fa-map-marker
|
||||
|
||||
.stat-card
|
||||
.stat-label Job
|
||||
.stat-val
|
||||
| {{0 <= state.line ? state.line : 0 | number}}
|
||||
span(v-if="toolpath.lines")
|
||||
| / {{toolpath.lines | number}}
|
||||
.stat-sub(v-if="plan_time_remaining || toolpath.time")
|
||||
| Line · {{plan_time_remaining ? (plan_time_remaining | time) : (toolpath.time | time)}} remaining
|
||||
.stat-sub(v-else) Line · ETA --
|
||||
button.pure-button(:disabled="!is_idle", @click=`home('${axis}')`,
|
||||
title=`Home {{'${axis}' | upper}} axis.`, style="height:60px;width:60px")
|
||||
.fa.fa-home
|
||||
|
||||
tr(style="vertical-align: top;")
|
||||
td
|
||||
table(width="100%")
|
||||
tr
|
||||
td(style="text-align:center")
|
||||
table.info
|
||||
tr
|
||||
th State
|
||||
td(:class="{attention: highlight_state}") {{mach_state}}
|
||||
|
||||
tr
|
||||
th Message
|
||||
td.message(:class="{attention: highlight_state}")
|
||||
| {{message.replace(/^#/, '')}}
|
||||
|
||||
tr
|
||||
th Display Units
|
||||
td.units
|
||||
select(v-model="display_units")
|
||||
option(value="METRIC") METRIC
|
||||
option(value="IMPERIAL") IMPERIAL
|
||||
|
||||
tr(title="Active tool")
|
||||
th Tool
|
||||
td {{state.tool || 0}}
|
||||
|
||||
td
|
||||
table.info
|
||||
tr(
|
||||
title="Current velocity in {{metric ? 'meters' : 'inches'}} per minute")
|
||||
th Velocity
|
||||
td
|
||||
unit-value(:value="state.v", precision="2", unit="", iunit="",
|
||||
scale="0.0254")
|
||||
| {{metric ? ' m/min' : ' IPM'}}
|
||||
|
||||
tr(title="Programmed feed rate.")
|
||||
th Feed
|
||||
td
|
||||
unit-value(:value="state.feed", precision="2", unit="", iunit="")
|
||||
| {{metric ? ' mm/min' : ' IPM'}}
|
||||
|
||||
tr(title="Programed and actual speed.")
|
||||
th Speed
|
||||
td
|
||||
| {{state.speed || 0 | fixed 0}}
|
||||
span(v-if="!isNaN(state.s)") ({{state.s | fixed 0}})
|
||||
= ' RPM'
|
||||
|
||||
tr(title="Load switch states.")
|
||||
th Loads
|
||||
td
|
||||
span(:class="state['1oa'] ? 'load-on' : ''")
|
||||
| 1:{{state['1oa'] ? 'On' : 'Off'}}
|
||||
|
|
||||
span(:class="state['2oa'] ? 'load-on' : ''")
|
||||
| 2:{{state['2oa'] ? 'On' : 'Off'}}
|
||||
|
||||
td
|
||||
table.info
|
||||
tr
|
||||
th Remaining
|
||||
td(title="Total run time (days:hours:mins:secs)").
|
||||
#[span(v-if="plan_time_remaining") {{plan_time_remaining | time}} of]
|
||||
{{toolpath.time | time}}
|
||||
|
||||
tr
|
||||
th ETA
|
||||
td.eta {{eta}}
|
||||
|
||||
tr
|
||||
th Line
|
||||
td
|
||||
| {{0 <= state.line ? state.line : 0 | number}}
|
||||
span(v-if="toolpath.lines")
|
||||
| of {{toolpath.lines | number}}
|
||||
|
||||
tr
|
||||
th Progress
|
||||
td.progress
|
||||
label {{(progress || 0) | percent}}
|
||||
.bar(:style="'width:' + (progress || 0) * 100 + '%'")
|
||||
|
||||
.macros-div(class="present")
|
||||
button.macros-button(title="Click to run Macros",v-for="(index,macros) in state.macros",
|
||||
@click="run_macro(index)",:disabled="!is_ready",v-bind:style="{ backgroundColor: macros.color }") {{macros.name}}
|
||||
|
||||
.tabs
|
||||
|
||||
input#tab1(type="radio", name="tabs",checked="" @click="tab = 'auto'")
|
||||
label(for="tab1", title="Run GCode programs",style="height:50px;width:100px") Auto
|
||||
|
||||
input#tab2(type="radio", name="tabs", @click="tab = 'mdi'")
|
||||
label(for="tab2", title="Manual GCode entry",style="height:50px;width:100px") MDI
|
||||
|
||||
input#tab3(type="radio", name="tabs", @click="tab = 'messages'")
|
||||
label(for="tab3",style="height:50px;width:100px") Messages
|
||||
|
||||
input#tab4(type="radio", name="tabs", @click="tab = 'indicators'")
|
||||
label(for="tab4",style="height:50px;width:100px") Indicators
|
||||
|
||||
|
||||
|
||||
|
||||
section#content1.tab-content.pure-form
|
||||
.toolbar.pure-control-group
|
||||
button.pure-button(:class="{'attention': is_holding}",
|
||||
title="{{is_running ? 'Pause' : 'Start'}} program.",
|
||||
@click="start_pause", :disabled="!state.selected",
|
||||
style="height:100px;width:100px;font-weight:normal")
|
||||
img(v-if="is_running" src="images/pause_gcode.png" style="height: 55px;")
|
||||
img(v-else src="images/play_gcode.png" style="height: 55px;")
|
||||
|
||||
button.pure-button(title="Stop program.", @click="stop", style="height:100px;width:100px;font-weight:normal")
|
||||
img(src="images/stop.png" style="height: 55px;")
|
||||
|
||||
button.pure-button(title="Pause program at next optional stop (M1).",
|
||||
@click="optional_pause", v-if="false", style="height:100px;width:100px;font-weight:normal")
|
||||
.fa.fa-stop-circle-o
|
||||
|
||||
message(:show.sync="uploading_files")
|
||||
h3(slot="header") Files uploading
|
||||
div(slot="body")
|
||||
h3 Please wait...
|
||||
p
|
||||
p The files are currently being uploaded.
|
||||
p Do not close the window.
|
||||
div(slot="footer")
|
||||
|
||||
button.pure-button(title="Execute one program step.", @click="step",
|
||||
:disabled="(!is_ready && !is_holding) || !state.selected",
|
||||
v-if="false", style="height:100px;width:100px;font-weight:normal")
|
||||
.fa.fa-step-forward
|
||||
|
||||
button.pure-button(title="Upload a new GCode folder.", @click="open_folder",
|
||||
:disabled="!is_ready",style="height:100px;width:100px;font-weight:normal")
|
||||
img(src="images/upload_folder.png" style="height: 65px;")
|
||||
|
||||
form.gcode-folder-input.file-upload
|
||||
input#folderInput(type="file", @change="upload_folder", :disabled="!is_ready",
|
||||
webkitdirectory, directory)
|
||||
|
||||
button.pure-button(title="Upload a new GCode program.", @click="open_file",
|
||||
:disabled="!is_ready",style="height:100px;width:100px;font-weight:normal")
|
||||
img(src="images/upload_gcode.png" style="height: 65px;")
|
||||
|
||||
form.gcode-file-input.file-upload
|
||||
input(type="file", @change="upload_file", :disabled="!is_ready",
|
||||
accept=".nc,.ngc,.gcode,.gc", multiple)
|
||||
|
||||
a(:disabled="!state.selected", download,
|
||||
:href="'/api/file/' + state.selected",
|
||||
title="Download the selected GCode program.")
|
||||
button.pure-button(:disabled="!state.selected", style="height:100px;width:100px")
|
||||
img(src="images/download_gcode.png" style="height: 65px;")
|
||||
|
||||
button.pure-button(title="Delete current GCode program.",
|
||||
@click="deleteGCode = true",
|
||||
:disabled="!state.selected || !is_ready",style="height:100px;width:100px;font-weight:normal")
|
||||
img(src="images/delete_gcode.png" style="height: 55px;")
|
||||
|
||||
message.error-message(:show.sync="deleteGCode")
|
||||
h3(slot="header") Select files to delete:
|
||||
div(slot="body")
|
||||
input.search-bar(type="text", v-model="search_query", placeholder="Search Files...")
|
||||
.container
|
||||
.folders
|
||||
h3 Folders
|
||||
div(v-for="(index, folder) in state.gcode_list", :key="index", @click="populateFiles(index)",
|
||||
class="folder-item", :class="{ selected: index === selected_folder_index }") {{ folder.name }}
|
||||
.files
|
||||
h3 Files
|
||||
label.file-item(v-for="item in gcode_filtered_files" :key="item")
|
||||
input(type="checkbox" :value="item" v-model="selected_items_to_delete")
|
||||
| {{ item }}
|
||||
div(slot="footer")
|
||||
button.pure-button(@click="cancel_delete",style="height:50px") Cancel
|
||||
//- button.pure-button.button-error(@click="delete_all_except_macros")
|
||||
//- .fa.fa-trash
|
||||
//- | All
|
||||
button.pure-button.button-success(@click="delete_current",style="height:50px")
|
||||
.fa.fa-trash
|
||||
| Selected
|
||||
|
||||
.drop-down-container
|
||||
message(:show.sync="create_folder")
|
||||
h3(slot="header") Enter folder name:
|
||||
div(slot="body")
|
||||
input.input-name(type="text",minlength='1',maxlength='15',style ="margin-top:1rem;margin-bottom:2rem;",
|
||||
id="folder-name" ,v-model="folder_name",@keypress="edited_folder_name")
|
||||
|
||||
div(slot="footer")
|
||||
button.pure-button(@click="cancel_new_folder") Cancel
|
||||
button.pure-button.button-success(@click="create_new_folder",:disabled="!edited")
|
||||
| Create
|
||||
|
||||
message(:show.sync="confirmDelete")
|
||||
h3(slot="header") Delete Folder?
|
||||
div(slot="body")
|
||||
p Are you sure to delete the folder?
|
||||
|
||||
div(slot="footer")
|
||||
button.pure-button(@click="confirmDelete=false") Cancel
|
||||
button.pure-button.button-error(@click="delete_folder") Folder only
|
||||
button.pure-button.button-success(@click="delete_folder_and_files") Folder and files
|
||||
|
||||
button.pure-button(title="Create a new folder.", @click="create_folder=true",
|
||||
:disabled="!is_ready",style="height:100%")
|
||||
| Create Folder
|
||||
|
||||
button.pure-button(title="Delete a folder.", @click="confirmDelete=true",
|
||||
:disabled="!is_ready",style="height:100%;margin-left:5px")
|
||||
| Delete Folder
|
||||
|
||||
select(title="Select previously uploaded GCode folder.",
|
||||
v-model="state.folder", @change="reset_gcode", :disabled="!is_ready",
|
||||
style="max-width:100%;margin-left:5px")
|
||||
option( selected='' value='default') Default folder
|
||||
option(v-for="file in gcode_folders", :value="file") {{file}}
|
||||
|
||||
select(title="Select previously uploaded GCode programs.",
|
||||
v-model="state.selected", @change="load", :disabled="!is_ready",
|
||||
style="max-width:300px;margin-left:5px")
|
||||
option(v-for="file in gcode_files", :value="file") {{file}}
|
||||
|
||||
button.pure-button(@click="toggle_sorting", :disabled="!is_ready",
|
||||
style="height:75%")
|
||||
| {{files_sortby}}
|
||||
|
||||
.progress(v-if="toolpath_progress && toolpath_progress < 1",
|
||||
title="Simulating GCode to check for errors, calculate ETA and " +
|
||||
"generate 3D view. You can run GCode before the simulation " +
|
||||
"finishes.")
|
||||
div(:style="'width:' + (toolpath_progress || 0) * 100 + '%'")
|
||||
label Simulating {{(toolpath_progress || 0) | percent}}
|
||||
|
||||
path-viewer(:toolpath="toolpath", :state="state", :config="config")
|
||||
gcode-viewer
|
||||
|
||||
section#content2.tab-content
|
||||
.mdi.pure-form(title="Manual GCode entry.")
|
||||
button.pure-button(:disabled="!can_mdi",
|
||||
:class="{'attention': is_holding}",
|
||||
title="{{is_running ? 'Pause' : 'Start'}} command.",
|
||||
@click="mdi_start_pause",style="height:100px;width:100px")
|
||||
.fa(:class="is_running ? 'fa-pause' : 'fa-play'")
|
||||
|
||||
button.pure-button(title="Stop command.", @click="stop",style="height:100px;width:100px")
|
||||
.fa.fa-stop
|
||||
|
||||
input(v-model="mdi", :disabled="!can_mdi", @keyup.enter="submit_mdi")
|
||||
|
||||
div
|
||||
em The machine is currently operating in #[strong {{mach_units}}] units. Use G20/G21 to switch units.
|
||||
|
||||
.history(:class="{placeholder: !history}")
|
||||
span(v-if="!history.length") MDI history displays here.
|
||||
ul
|
||||
li(v-for="item in history", @click="load_history($index)",
|
||||
track-by="$index")
|
||||
| {{item}}
|
||||
|
||||
section#content3.tab-content
|
||||
console
|
||||
|
||||
section#content4.tab-content
|
||||
indicators(:state="state", :template="template")
|
||||
|
||||
.override(title="Feed rate override.")
|
||||
label Feed
|
||||
input(type="range", min="0", max="2", step="0.01",
|
||||
v-model="feed_override", @change="override_feed")
|
||||
span.percent {{feed_override | percent 0}}
|
||||
|
||||
.override(title="Spindle speed override.")
|
||||
label Speed
|
||||
input(type="range", min="0", max="2", step="0.01",
|
||||
v-model="speed_override", @change="override_speed")
|
||||
span.percent {{speed_override | percent 0}}
|
||||
|
||||
// ----- Macro row (slice 0..7); full list lives in Settings → Macros -----
|
||||
// The colored left stripe (.has-color) is suppressed for white,
|
||||
// near-white and other default placeholder colors so unconfigured
|
||||
// macros render as clean slate tiles instead of looking lopsided.
|
||||
.macro-row(v-if="state.macros && state.macros.length")
|
||||
button.macro-btn(v-for="(index, macros) in state.macros.slice(0, 8)",
|
||||
title="Click to run macro",
|
||||
@click="run_macro(index)",
|
||||
:disabled="!is_ready",
|
||||
:class="{'has-color': has_macro_color(macros)}",
|
||||
:style="has_macro_color(macros) ? {borderLeftColor: macros.color} : {}")
|
||||
span.mnum {{index + 1}}
|
||||
span.mname {{macros.name || ('Macro ' + (index + 1))}}
|
||||
|
||||
// ----- Override drawer (anchored to bottom; toggled by Spindle KPI tile) -----
|
||||
.override-drawer(:class="{open: overrides_open}")
|
||||
.od-head
|
||||
.od-title
|
||||
.fa.fa-sliders
|
||||
| Overrides
|
||||
button.od-close(@click="overrides_open = false") ✕
|
||||
.od-body
|
||||
.od-row
|
||||
label Feed
|
||||
input(type="range", min="0", max="2", step="0.01",
|
||||
v-model="feed_override", @change="override_feed")
|
||||
.od-val {{feed_override | percent 0}}
|
||||
button.od-reset(@click="feed_override = 1; override_feed()") Reset 100%
|
||||
.od-row
|
||||
label Spindle
|
||||
input(type="range", min="0", max="2", step="0.01",
|
||||
v-model="speed_override", @change="override_speed")
|
||||
.od-val {{speed_override | percent 0}}
|
||||
button.od-reset(@click="speed_override = 1; override_speed()") Reset 100%
|
||||
|
||||
@@ -2,8 +2,6 @@ script#estop-template(type="text/x-template")
|
||||
svg(version="1.1", xmlns:svg="http://www.w3.org/2000/svg",
|
||||
xmlns="http://www.w3.org/2000/svg",
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink",
|
||||
viewBox="0 0 130 130",
|
||||
preserveAspectRatio="xMidYMid meet",
|
||||
width="130", height="130")
|
||||
defs
|
||||
path#text-path-1(d="m 73.735,673.129 c 0,55.107 44.673,99.780 99.780,99.780 55.107,0 99.780,-44.673 99.780,-99.780 0,-55.107 -44.673,-99.780 -99.780,-99.780 -55.107,0 -99.780,44.673 -99.780,99.780 z")
|
||||
|
||||
@@ -6,11 +6,11 @@ script#indicators-template(type="text/x-template")
|
||||
|
||||
tr
|
||||
td
|
||||
.fa.fa-circle-plus.io
|
||||
.fa.fa-plus-circle.io
|
||||
th Hi/+3.3v
|
||||
th.separator
|
||||
td
|
||||
.fa.fa-circle-minus.io
|
||||
.fa.fa-minus-circle.io
|
||||
th Lo/Gnd
|
||||
th.separator
|
||||
td
|
||||
@@ -22,7 +22,7 @@ script#indicators-template(type="text/x-template")
|
||||
th Inactive
|
||||
th.separator
|
||||
td
|
||||
.far.fa-circle.io
|
||||
.fa.fa-circle-o.io
|
||||
th Tristated/Disabled
|
||||
|
||||
table.inputs
|
||||
@@ -169,14 +169,14 @@ script#indicators-template(type="text/x-template")
|
||||
|
||||
tr
|
||||
th Motor
|
||||
th(title="Overtemperature fault"): .fa.fa-temperature-full
|
||||
th(title="Overtemperature fault"): .fa.fa-thermometer-full
|
||||
th(title="Overcurrent motor channel A") A #[.fa.fa-bolt]
|
||||
th(title="Predriver fault motor channel A")
|
||||
| A #[.fa.fa-triangle-exclamation]
|
||||
| A #[.fa.fa-exclamation-triangle]
|
||||
th(title="Overcurrent motor channel B") B #[.fa.fa-bolt]
|
||||
th(title="Predriver fault motor channel B")
|
||||
| B #[.fa.fa-triangle-exclamation]
|
||||
th(title="Driver communication failure"): .fa.fa-handshake
|
||||
| B #[.fa.fa-exclamation-triangle]
|
||||
th(title="Driver communication failure"): .fa.fa-handshake-o
|
||||
th(title="Reset all motor flags")
|
||||
.fa.fa-eraser(@click="motor_reset()")
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ script#path-viewer-template(type="text/x-template")
|
||||
.path-viewer-toolbar
|
||||
.tool-button(title="Toggle path view size.",
|
||||
@click="small = !small", :class="{active: !small}")
|
||||
.fa.fa-up-down-left-right
|
||||
.fa.fa-arrows-alt
|
||||
|
||||
.tool-button(@click="showTool = !showTool", :class="{active: showTool}",
|
||||
title="Show/hide tool.")
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
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}}
|
||||
@@ -1,56 +0,0 @@
|
||||
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…
|
||||
477
src/py/bbctrl/AuxAxis.py
Normal file
477
src/py/bbctrl/AuxAxis.py
Normal file
@@ -0,0 +1,477 @@
|
||||
################################################################################
|
||||
#
|
||||
# AuxAxis - W-axis serial driver for the auxcnc ESP32 controller
|
||||
#
|
||||
# Owns /dev/ttyUSB0 (or whatever serial.port is configured to). Provides
|
||||
# blocking RPCs for use from a hook thread. Maintains:
|
||||
#
|
||||
# - aux_present : True if serial is open and we've seen a boot banner
|
||||
# - aux_homed : True if we've successfully run HOME since last reset
|
||||
# - aux_pos : current logical position in mm (from ESP step counter
|
||||
# * (1 / steps_per_mm * dir_sign))
|
||||
#
|
||||
# Real-time decisions (limit switch monitoring, step pulse generation) live
|
||||
# on the ESP. The host is responsible for units, soft limits, and tracking
|
||||
# whether we've ever boot-cycled the ESP since last home.
|
||||
#
|
||||
################################################################################
|
||||
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import threading
|
||||
import traceback
|
||||
|
||||
try:
|
||||
import serial
|
||||
except ImportError:
|
||||
serial = None
|
||||
|
||||
|
||||
# Default config; overridden by ./aux.json or ctrl.config.
|
||||
DEFAULTS = {
|
||||
'enabled': False,
|
||||
'port': '/dev/ttyUSB0',
|
||||
'baud': 115200,
|
||||
'steps_per_mm': 80.0, # logical steps per mm of W travel
|
||||
'dir_sign': 1, # +1 or -1: maps logical+ to motor+ steps
|
||||
'min_w': 0.0, # soft limit min (mm)
|
||||
'max_w': 100.0, # soft limit max (mm)
|
||||
'max_feed_mm_min': 600.0, # informational; rate caps are on the ESP
|
||||
'home_dir': '-', # which direction is "toward limit" (host's view)
|
||||
'home_position_mm': 0.0, # mm value to assign at home
|
||||
# ESP-side homing rates (steps/sec). Pushed via HOMECFG on connect.
|
||||
'home_fast_sps': 4000,
|
||||
'home_slow_sps': 400,
|
||||
'home_backoff_steps': 200,
|
||||
'home_maxtravel_steps': 200000,
|
||||
'step_max_sps': 4000,
|
||||
'step_accel_sps2': 16000,
|
||||
'step_start_sps': 200,
|
||||
'limit_low': True,
|
||||
}
|
||||
|
||||
|
||||
class AuxAxisError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class AuxAxis(object):
|
||||
def __init__(self, ctrl):
|
||||
self.ctrl = ctrl
|
||||
self.log = ctrl.log.get('AuxAxis')
|
||||
|
||||
self._cfg = dict(DEFAULTS)
|
||||
self._load_config()
|
||||
|
||||
self._sp = None
|
||||
self._sp_lock = threading.Lock() # serial write/RPC serialization
|
||||
self._rx_lock = threading.Lock() # read-line buffer access
|
||||
self._reader_thread = None
|
||||
self._stop = threading.Event()
|
||||
|
||||
# Pending replies waiting for a [topic] line. Single-slot since we
|
||||
# serialize RPCs via _sp_lock.
|
||||
self._pending_topics = []
|
||||
self._pending_replies = []
|
||||
self._pending_cv = threading.Condition()
|
||||
|
||||
# Async lines that aren't replies (e.g. logs) are simply logged.
|
||||
self._present = False
|
||||
self._homed = False
|
||||
self._pos_steps = 0 # ESP step counter mirror
|
||||
|
||||
# Publish initial state
|
||||
self._publish_state()
|
||||
|
||||
if not self._cfg['enabled']:
|
||||
self.log.info('Aux axis disabled in config')
|
||||
return
|
||||
|
||||
if serial is None:
|
||||
self.log.error('pyserial not available; aux axis disabled')
|
||||
return
|
||||
|
||||
self._open()
|
||||
|
||||
# ------------------------------------------------------------------ config
|
||||
|
||||
def _config_path(self):
|
||||
return self.ctrl.get_path(filename='aux.json')
|
||||
|
||||
def _load_config(self):
|
||||
path = self._config_path()
|
||||
if os.path.exists(path):
|
||||
try:
|
||||
with open(path) as f:
|
||||
user = json.load(f)
|
||||
# Be permissive; ignore unknown keys.
|
||||
for k, v in user.items():
|
||||
if k in self._cfg:
|
||||
self._cfg[k] = v
|
||||
self.log.info('Loaded aux config from %s' % path)
|
||||
except Exception:
|
||||
self.log.error('Failed to read aux.json: %s'
|
||||
% traceback.format_exc())
|
||||
|
||||
def save_config(self, cfg):
|
||||
merged = dict(DEFAULTS)
|
||||
for k, v in cfg.items():
|
||||
if k in DEFAULTS:
|
||||
merged[k] = v
|
||||
path = self._config_path()
|
||||
with open(path, 'w') as f:
|
||||
json.dump(merged, f, indent=2)
|
||||
self._cfg = merged
|
||||
self.log.info('Saved aux config')
|
||||
# Push the relevant pieces to the ESP if connected.
|
||||
if self._present:
|
||||
try:
|
||||
self._push_homecfg()
|
||||
except Exception as e:
|
||||
self.log.warning('Could not push HOMECFG after save: %s' % e)
|
||||
|
||||
def get_config(self):
|
||||
return dict(self._cfg)
|
||||
|
||||
# ------------------------------------------------------------------ public
|
||||
|
||||
@property
|
||||
def enabled(self):
|
||||
return bool(self._cfg.get('enabled', False))
|
||||
|
||||
@property
|
||||
def present(self):
|
||||
return self._present
|
||||
|
||||
@property
|
||||
def homed(self):
|
||||
return self._homed
|
||||
|
||||
@property
|
||||
def position_mm(self):
|
||||
return self._steps_to_mm(self._pos_steps)
|
||||
|
||||
def home(self):
|
||||
"""Run the homing cycle on the ESP. Blocks until done. Raises on
|
||||
failure. Updates aux_homed and aux_pos."""
|
||||
self._require_present()
|
||||
line = self._rpc('HOME', topic='home', timeout=120.0)
|
||||
# line is the body after '[home] '
|
||||
if line.startswith('done'):
|
||||
# ESP set its counter to home_zero; mirror that.
|
||||
new_pos = self._parse_kv_int(line, 'pos', 0)
|
||||
self._pos_steps = new_pos
|
||||
self._homed = True
|
||||
# Translate to home_position_mm. Conceptually the host says
|
||||
# "after homing, W is here in mm". We achieve that by setting
|
||||
# the ESP counter (WPOS) so the mm conversion works out.
|
||||
target_pos = self._mm_to_steps(self._cfg['home_position_mm'])
|
||||
if target_pos != new_pos:
|
||||
self._rpc('WPOS %d' % target_pos, topic='ok', timeout=2.0)
|
||||
self._pos_steps = target_pos
|
||||
self._publish_state()
|
||||
return
|
||||
# failure
|
||||
reason = line.split('reason=', 1)[1] if 'reason=' in line else line
|
||||
raise AuxAxisError('Homing failed: %s' % reason)
|
||||
|
||||
def move_abs_mm(self, target_mm):
|
||||
"""Move to absolute logical W position (mm). Blocks until done."""
|
||||
self._require_present()
|
||||
self._check_limits(target_mm)
|
||||
target_steps = self._mm_to_steps(target_mm)
|
||||
delta = target_steps - self._pos_steps
|
||||
if delta == 0:
|
||||
return
|
||||
self._do_steps(delta)
|
||||
|
||||
def move_rel_mm(self, delta_mm):
|
||||
"""Move by delta mm relative to current position. Blocks until done."""
|
||||
self._require_present()
|
||||
target_mm = self.position_mm + delta_mm
|
||||
self._check_limits(target_mm)
|
||||
target_steps = self._mm_to_steps(target_mm)
|
||||
delta = target_steps - self._pos_steps
|
||||
if delta == 0:
|
||||
return
|
||||
self._do_steps(delta)
|
||||
|
||||
def set_position_mm(self, mm):
|
||||
"""Set current W to <mm> without moving (G92-style for W)."""
|
||||
self._require_present()
|
||||
steps = self._mm_to_steps(mm)
|
||||
self._rpc('WPOS %d' % steps, topic='ok', timeout=2.0)
|
||||
self._pos_steps = steps
|
||||
# WPOS clears homed on the ESP; mirror it.
|
||||
self._homed = False
|
||||
self._publish_state()
|
||||
|
||||
def jog_steps(self, steps):
|
||||
"""Raw step move bypassing mm conversion and soft limits.
|
||||
Used by manual jog UI when axis isn't homed yet."""
|
||||
self._require_present()
|
||||
if steps == 0:
|
||||
return
|
||||
self._do_steps(int(steps), ignore_limits=True)
|
||||
|
||||
def abort(self):
|
||||
"""Cancel any running ESP motion immediately."""
|
||||
if not self._present:
|
||||
return
|
||||
try:
|
||||
# Don't take the RPC lock; ABORT must be able to interrupt.
|
||||
self._send_raw('ABORT')
|
||||
except Exception as e:
|
||||
self.log.warning('ABORT send failed: %s' % e)
|
||||
|
||||
def close(self):
|
||||
self._stop.set()
|
||||
try:
|
||||
if self._sp is not None:
|
||||
self._sp.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ------------------------------------------------------------------ guts
|
||||
|
||||
def _require_present(self):
|
||||
if not self.enabled:
|
||||
raise AuxAxisError('Aux axis disabled')
|
||||
if not self._present:
|
||||
raise AuxAxisError('Aux axis not connected')
|
||||
|
||||
def _check_limits(self, target_mm):
|
||||
lo = float(self._cfg['min_w'])
|
||||
hi = float(self._cfg['max_w'])
|
||||
if hi <= lo:
|
||||
return # no limits
|
||||
if target_mm < lo - 1e-6 or target_mm > hi + 1e-6:
|
||||
raise AuxAxisError(
|
||||
'W=%.3f out of soft limits [%.3f, %.3f]' % (target_mm, lo, hi))
|
||||
|
||||
def _mm_to_steps(self, mm):
|
||||
spm = float(self._cfg['steps_per_mm'])
|
||||
sign = 1 if int(self._cfg.get('dir_sign', 1)) >= 0 else -1
|
||||
return int(round(mm * spm * sign))
|
||||
|
||||
def _steps_to_mm(self, steps):
|
||||
spm = float(self._cfg['steps_per_mm']) or 1.0
|
||||
sign = 1 if int(self._cfg.get('dir_sign', 1)) >= 0 else -1
|
||||
return (steps / spm) * sign
|
||||
|
||||
def _do_steps(self, signed_count, ignore_limits=False):
|
||||
max_rate = int(self._cfg['step_max_sps'])
|
||||
accel = int(self._cfg['step_accel_sps2'])
|
||||
safe_flag = 0 if ignore_limits else 1
|
||||
cmd = 'STEPS %d maxrate=%d accel=%d safe=%d' % (
|
||||
signed_count, max_rate, accel, safe_flag)
|
||||
line = self._rpc(cmd, topic='step', timeout=300.0)
|
||||
# line: "done count=N pos=P limit=L" or "aborted count=N pos=P [reason=...]"
|
||||
if line.startswith('done'):
|
||||
self._pos_steps = self._parse_kv_int(line, 'pos', self._pos_steps)
|
||||
self._publish_state()
|
||||
return
|
||||
# aborted
|
||||
self._pos_steps = self._parse_kv_int(line, 'pos', self._pos_steps)
|
||||
self._publish_state()
|
||||
reason = self._parse_kv_str(line, 'reason')
|
||||
if reason == 'limit':
|
||||
self._homed = False
|
||||
raise AuxAxisError('W move aborted by limit switch')
|
||||
raise AuxAxisError('W move aborted: %s' % line)
|
||||
|
||||
# ------------------------------------------------------------ serial I/O
|
||||
|
||||
def _open(self):
|
||||
port = self._cfg['port']
|
||||
baud = int(self._cfg['baud'])
|
||||
try:
|
||||
self._sp = serial.Serial(port, baud, timeout=0.2)
|
||||
except Exception as e:
|
||||
self.log.error('Could not open %s: %s' % (port, e))
|
||||
self._sp = None
|
||||
return
|
||||
|
||||
self.log.info('Opened %s @ %d' % (port, baud))
|
||||
self._reader_thread = threading.Thread(
|
||||
target=self._reader_loop, name='AuxAxis-rx', daemon=True)
|
||||
self._reader_thread.start()
|
||||
|
||||
# Give the ESP a moment to settle, then push HOMECFG and query state.
|
||||
# This runs in a background thread to avoid blocking startup.
|
||||
threading.Thread(target=self._on_connect, daemon=True).start()
|
||||
|
||||
def _on_connect(self):
|
||||
time.sleep(0.5)
|
||||
try:
|
||||
self._push_homecfg()
|
||||
self._refresh_state()
|
||||
except Exception as e:
|
||||
self.log.warning('Aux post-connect setup failed: %s' % e)
|
||||
|
||||
def _push_homecfg(self):
|
||||
c = self._cfg
|
||||
cmd = ('HOMECFG dir=%s fast=%d slow=%d backoff=%d maxtravel=%d '
|
||||
'zero=0 accel=%d step_max=%d step_start=%d limit_low=%d') % (
|
||||
c['home_dir'],
|
||||
int(c['home_fast_sps']),
|
||||
int(c['home_slow_sps']),
|
||||
int(c['home_backoff_steps']),
|
||||
int(c['home_maxtravel_steps']),
|
||||
int(c['step_accel_sps2']),
|
||||
int(c['step_max_sps']),
|
||||
int(c['step_start_sps']),
|
||||
1 if c['limit_low'] else 0,
|
||||
)
|
||||
self._rpc(cmd, topic='homecfg', timeout=3.0)
|
||||
|
||||
def _refresh_state(self):
|
||||
try:
|
||||
r = self._rpc('WPOS?', topic='wpos', timeout=2.0)
|
||||
self._pos_steps = int(r.strip())
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
r = self._rpc('HOMED?', topic='homed', timeout=2.0)
|
||||
self._homed = (r.strip() == '1')
|
||||
except Exception:
|
||||
pass
|
||||
self._publish_state()
|
||||
|
||||
def _reader_loop(self):
|
||||
buf = b''
|
||||
while not self._stop.is_set():
|
||||
sp = self._sp
|
||||
if sp is None:
|
||||
time.sleep(0.5)
|
||||
continue
|
||||
try:
|
||||
chunk = sp.read(256)
|
||||
except Exception as e:
|
||||
self.log.warning('Aux serial read error: %s' % e)
|
||||
time.sleep(0.5)
|
||||
continue
|
||||
if not chunk:
|
||||
continue
|
||||
buf += chunk
|
||||
while True:
|
||||
nl = buf.find(b'\n')
|
||||
if nl < 0:
|
||||
break
|
||||
line = buf[:nl].rstrip(b'\r').decode('utf-8', errors='replace')
|
||||
buf = buf[nl+1:]
|
||||
self._on_line(line)
|
||||
|
||||
def _on_line(self, line):
|
||||
if not line:
|
||||
return
|
||||
# Boot banner -> reset homed flag.
|
||||
if line.startswith('[boot]'):
|
||||
self.log.warning('Aux ESP booted: %s' % line)
|
||||
self._homed = False
|
||||
self._present = True
|
||||
self._publish_state()
|
||||
self.ctrl.state.add_message(
|
||||
'W axis controller restarted - re-home before use')
|
||||
return
|
||||
|
||||
# Topic dispatch: "[topic] body..."
|
||||
if line.startswith('[') and ']' in line:
|
||||
rb = line.index(']')
|
||||
topic = line[1:rb]
|
||||
body = line[rb+1:].lstrip()
|
||||
# Mark present on first known topic.
|
||||
if not self._present:
|
||||
self._present = True
|
||||
self._publish_state()
|
||||
# Match against the head of the pending queue.
|
||||
with self._pending_cv:
|
||||
if (self._pending_topics
|
||||
and topic in self._pending_topics[0]):
|
||||
# Pop and deliver
|
||||
self._pending_topics.pop(0)
|
||||
self._pending_replies.append(body)
|
||||
self._pending_cv.notify_all()
|
||||
return
|
||||
# Async informational line; just log.
|
||||
self.log.info('aux: %s' % line)
|
||||
else:
|
||||
self.log.info('aux: %s' % line)
|
||||
|
||||
def _send_raw(self, cmd):
|
||||
sp = self._sp
|
||||
if sp is None:
|
||||
raise AuxAxisError('Serial not open')
|
||||
if not cmd.endswith('\n'):
|
||||
cmd = cmd + '\n'
|
||||
sp.write(cmd.encode('utf-8'))
|
||||
sp.flush()
|
||||
|
||||
def _rpc(self, cmd, topic, timeout=5.0):
|
||||
"""Send `cmd`, wait for a reply line whose topic is in `topic`.
|
||||
topic may be a single string or a tuple/list of acceptable topics
|
||||
(e.g. ('home', 'err'))."""
|
||||
if isinstance(topic, str):
|
||||
topics = (topic, 'err')
|
||||
else:
|
||||
topics = tuple(topic) + ('err',)
|
||||
|
||||
with self._sp_lock:
|
||||
with self._pending_cv:
|
||||
self._pending_topics.append(topics)
|
||||
self._pending_replies = [] # reset
|
||||
self.log.info('aux >> %s' % cmd.strip())
|
||||
self._send_raw(cmd)
|
||||
|
||||
deadline = time.time() + timeout
|
||||
with self._pending_cv:
|
||||
while not self._pending_replies:
|
||||
remaining = deadline - time.time()
|
||||
if remaining <= 0:
|
||||
# Drop the pending slot so we don't capture a
|
||||
# late reply meant for the next caller.
|
||||
try:
|
||||
self._pending_topics.remove(topics)
|
||||
except ValueError:
|
||||
pass
|
||||
raise AuxAxisError(
|
||||
'Timeout waiting for %s reply to "%s"'
|
||||
% (topics, cmd.strip()))
|
||||
self._pending_cv.wait(timeout=remaining)
|
||||
reply = self._pending_replies.pop(0)
|
||||
self.log.info('aux << %s' % reply)
|
||||
if reply.startswith('err') or reply.startswith('error'):
|
||||
raise AuxAxisError('ESP error: %s' % reply)
|
||||
return reply
|
||||
|
||||
@staticmethod
|
||||
def _parse_kv_int(line, key, default=0):
|
||||
# Parse "key=N" (signed integer) out of a line.
|
||||
for tok in line.split():
|
||||
if tok.startswith(key + '='):
|
||||
try:
|
||||
return int(tok.split('=', 1)[1])
|
||||
except ValueError:
|
||||
return default
|
||||
return default
|
||||
|
||||
@staticmethod
|
||||
def _parse_kv_str(line, key, default=''):
|
||||
for tok in line.split():
|
||||
if tok.startswith(key + '='):
|
||||
return tok.split('=', 1)[1]
|
||||
return default
|
||||
|
||||
# ------------------------------------------------------------ state push
|
||||
|
||||
def _publish_state(self):
|
||||
st = self.ctrl.state
|
||||
try:
|
||||
st.set('aux_present', bool(self._present))
|
||||
st.set('aux_homed', bool(self._homed))
|
||||
st.set('aux_pos', round(self.position_mm, 4))
|
||||
st.set('aux_enabled', bool(self.enabled))
|
||||
except Exception:
|
||||
# During very early startup, state may not be ready.
|
||||
pass
|
||||
237
src/py/bbctrl/AuxPreprocessor.py
Normal file
237
src/py/bbctrl/AuxPreprocessor.py
Normal file
@@ -0,0 +1,237 @@
|
||||
################################################################################
|
||||
#
|
||||
# AuxPreprocessor - rewrite W-axis G-code into hook calls
|
||||
#
|
||||
# The bbctrl planner only understands xyzabc. We expose a virtual W axis by
|
||||
# rewriting the G-code file *before* it is fed to gplan, replacing each W
|
||||
# move with a (MSG,HOOK:aux:...) line that the host's hook handler turns
|
||||
# into a STEPS or HOME command on the ESP.
|
||||
#
|
||||
# Rules:
|
||||
# - Mixed-axis blocks (W together with XYZABC) are split into two
|
||||
# sequential blocks. By default the W move runs first; configurable.
|
||||
# - G90/G91/G20/G21 modal state is tracked so we can convert relative-W
|
||||
# and inch-W into the absolute mm value the hook handler expects.
|
||||
# - G28 W0 / G28.2 W0 -> HOOK:aux_home
|
||||
# - G92 Wx -> HOOK:aux_setzero:<mm>
|
||||
# - G53 + W not specially handled (W only knows machine coords)
|
||||
# - Lines inside parentheses or after `;` are passed through.
|
||||
#
|
||||
# The preprocessor is intentionally conservative: anything it doesn't
|
||||
# understand involving W is left alone with a warning, so motion lands in
|
||||
# gplan which will complain loudly rather than silently misbehaving.
|
||||
#
|
||||
################################################################################
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
|
||||
# Match a word like "W12.5" or "W-3" or "w0". Also matches inside the same
|
||||
# line as XYZ words. We pull W out specifically.
|
||||
_W_TOKEN_RE = re.compile(r'(?<![A-Za-z_0-9])[Ww]\s*([-+]?\d*\.?\d+)')
|
||||
|
||||
# Detect any axis-bearing word (so we can tell mixed-axis lines apart).
|
||||
_AXIS_WORD_RE = re.compile(r'(?<![A-Za-z_0-9])[XYZABCxyzabc]\s*[-+]?\d*\.?\d+')
|
||||
|
||||
# Strip line comments so we don't get fooled by "(W axis)".
|
||||
_PAREN_COMMENT_RE = re.compile(r'\([^)]*\)')
|
||||
|
||||
# Modal G-code groups we care about.
|
||||
_MODAL_RE = re.compile(r'(?<![A-Za-z_0-9])[Gg]\s*0*(\d+(?:\.\d+)?)')
|
||||
|
||||
|
||||
class AuxPreprocessorError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class AuxPreprocessor(object):
|
||||
def __init__(self, log=None, w_first=True):
|
||||
self.log = log
|
||||
# If True, on a mixed-axis line (e.g. G1 X10 W5), emit the W move
|
||||
# first, then the XYZ move. Set False to invert.
|
||||
self.w_first = w_first
|
||||
|
||||
def _info(self, msg):
|
||||
if self.log:
|
||||
self.log.info(msg)
|
||||
|
||||
def _warn(self, msg):
|
||||
if self.log:
|
||||
self.log.warning(msg)
|
||||
|
||||
# ------------------------------------------------------------------ scan
|
||||
|
||||
@staticmethod
|
||||
def file_uses_w(path):
|
||||
"""Quick check: does this file contain any W-axis word? Used to skip
|
||||
preprocessing entirely for files that don't care about W."""
|
||||
try:
|
||||
with open(path, 'r', encoding='utf-8', errors='replace') as f:
|
||||
for line in f:
|
||||
code = _PAREN_COMMENT_RE.sub('', line)
|
||||
code = code.split(';', 1)[0]
|
||||
if _W_TOKEN_RE.search(code):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------ core
|
||||
|
||||
def _strip_w(self, line):
|
||||
"""Return (line_without_w, w_value_str_or_None). Only first W kept."""
|
||||
m = _W_TOKEN_RE.search(line)
|
||||
if m is None:
|
||||
return line, None
|
||||
# Remove just the matched W<num> token, preserving surrounding spaces.
|
||||
rewritten = line[:m.start()] + line[m.end():]
|
||||
return rewritten, m.group(1)
|
||||
|
||||
def _has_other_axis(self, code_no_w):
|
||||
return _AXIS_WORD_RE.search(code_no_w) is not None
|
||||
|
||||
def _detect_modals(self, code, modal):
|
||||
"""Update modal dict in-place from G-codes on this line."""
|
||||
for mm in _MODAL_RE.finditer(code):
|
||||
try:
|
||||
g = float(mm.group(1))
|
||||
except ValueError:
|
||||
continue
|
||||
if g == 90: modal['abs'] = True
|
||||
elif g == 91: modal['abs'] = False
|
||||
elif g == 20: modal['inch'] = True
|
||||
elif g == 21: modal['inch'] = False
|
||||
# G28 / G28.2 / G92 are detected case-by-case below.
|
||||
|
||||
@staticmethod
|
||||
def _is_g28_like(code):
|
||||
# Match G28 or G28.2 (homing).
|
||||
return bool(re.search(r'(?<![A-Za-z_0-9])[Gg]\s*0*28(?:\.2)?(?![\w.])', code))
|
||||
|
||||
@staticmethod
|
||||
def _is_g92(code):
|
||||
return bool(re.search(r'(?<![A-Za-z_0-9])[Gg]\s*0*92(?![\w.])', code))
|
||||
|
||||
# ------------------------------------------------------------------ run
|
||||
|
||||
def process(self, src_path, dst_path):
|
||||
"""Read src_path, write rewritten G-code to dst_path. Returns True
|
||||
if any rewrite happened."""
|
||||
modal = {'abs': True, 'inch': False} # G90 G21 are common defaults
|
||||
rewrote_any = False
|
||||
|
||||
with open(src_path, 'r', encoding='utf-8', errors='replace') as fin, \
|
||||
open(dst_path, 'w', encoding='utf-8') as fout:
|
||||
for raw in fin:
|
||||
line = raw.rstrip('\n')
|
||||
|
||||
# Comment-only or blank lines pass through verbatim.
|
||||
code = _PAREN_COMMENT_RE.sub('', line)
|
||||
code = code.split(';', 1)[0]
|
||||
if not code.strip():
|
||||
fout.write(raw)
|
||||
continue
|
||||
|
||||
# Update modal from G-codes on this line first (so absolute
|
||||
# vs incremental matches what the planner sees for XYZ).
|
||||
self._detect_modals(code, modal)
|
||||
|
||||
if not _W_TOKEN_RE.search(code):
|
||||
fout.write(raw)
|
||||
continue
|
||||
|
||||
rewrote_any = True
|
||||
|
||||
# G28[.2] W... -> aux_home (W value is ignored except as
|
||||
# a flag that W is being homed).
|
||||
if self._is_g28_like(code):
|
||||
code_no_w, _ = self._strip_w(line)
|
||||
fout.write('(MSG,HOOK:aux_home:)\n')
|
||||
# Only keep the residual line if other axes were also
|
||||
# present (e.g. G28.2 X0 Y0 W0 still homes X+Y). A bare
|
||||
# "G28" without axis args means "home all" in gcode
|
||||
# which we explicitly DON'T want to trigger from a
|
||||
# W-only home command.
|
||||
rest_code = _PAREN_COMMENT_RE.sub('', code_no_w)
|
||||
rest_code = rest_code.split(';', 1)[0]
|
||||
if self._has_other_axis(rest_code):
|
||||
fout.write(code_no_w + '\n')
|
||||
continue
|
||||
|
||||
# G92 W... -> set W zero (or other value) without motion.
|
||||
if self._is_g92(code):
|
||||
line_no_w, w_val = self._strip_w(line)
|
||||
target_mm = self._w_to_mm(w_val, modal, set_pos=True)
|
||||
fout.write('(MSG,HOOK:aux_setzero:%g)\n' % target_mm)
|
||||
rest_code = _PAREN_COMMENT_RE.sub('', line_no_w)
|
||||
rest_code = rest_code.split(';', 1)[0]
|
||||
if self._has_other_axis(rest_code):
|
||||
fout.write(line_no_w + '\n')
|
||||
continue
|
||||
|
||||
# Plain motion: G0/G1 etc with W word.
|
||||
line_no_w, w_val = self._strip_w(line)
|
||||
target_mm = self._w_to_mm(w_val, modal, set_pos=False)
|
||||
# Distinguish absolute vs relative: encode both, the hook
|
||||
# handler will pick the right operation.
|
||||
if modal['abs']:
|
||||
hook_line = '(MSG,HOOK:aux:%g)' % target_mm
|
||||
else:
|
||||
hook_line = '(MSG,HOOK:aux_rel:%g)' % target_mm
|
||||
|
||||
rest_code = _PAREN_COMMENT_RE.sub('', line_no_w)
|
||||
rest_code = rest_code.split(';', 1)[0]
|
||||
has_xyz = self._has_other_axis(rest_code)
|
||||
|
||||
if not has_xyz:
|
||||
# Pure W move; drop the (now-empty) original line.
|
||||
fout.write(hook_line + '\n')
|
||||
continue
|
||||
|
||||
# Mixed-axis: split. Default order is W first.
|
||||
if self.w_first:
|
||||
fout.write(hook_line + '\n')
|
||||
fout.write(line_no_w + '\n')
|
||||
else:
|
||||
fout.write(line_no_w + '\n')
|
||||
fout.write(hook_line + '\n')
|
||||
|
||||
return rewrote_any
|
||||
|
||||
# ------------------------------------------------------------ unit conv
|
||||
|
||||
def _w_to_mm(self, w_str, modal, set_pos):
|
||||
try:
|
||||
v = float(w_str)
|
||||
except (TypeError, ValueError):
|
||||
raise AuxPreprocessorError('Invalid W value: %r' % w_str)
|
||||
if modal['inch']:
|
||||
v *= 25.4
|
||||
return v
|
||||
|
||||
|
||||
def preprocess_file(src_path, log=None, w_first=True):
|
||||
"""Convenience: rewrite src_path in place if it uses W.
|
||||
Returns True if the file was rewritten."""
|
||||
if not AuxPreprocessor.file_uses_w(src_path):
|
||||
return False
|
||||
pre = AuxPreprocessor(log=log, w_first=w_first)
|
||||
fd, tmp = tempfile.mkstemp(prefix='auxpre_', suffix='.nc',
|
||||
dir=os.path.dirname(src_path) or None)
|
||||
os.close(fd)
|
||||
try:
|
||||
rewrote = pre.process(src_path, tmp)
|
||||
if rewrote:
|
||||
shutil.move(tmp, src_path)
|
||||
return True
|
||||
os.unlink(tmp)
|
||||
return False
|
||||
except Exception:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
@@ -223,10 +223,6 @@ class Comm(object):
|
||||
self.ctrl.mach.process_log(msg)
|
||||
elif 'firmware' in msg:
|
||||
self.log.info('AVR firmware rebooted')
|
||||
try:
|
||||
import bbctrl.Trace as _T
|
||||
_T.mark('avr.firmware_rebooted')
|
||||
except Exception: pass
|
||||
self.connect()
|
||||
else:
|
||||
self._update_state(msg)
|
||||
|
||||
@@ -28,12 +28,10 @@
|
||||
import os
|
||||
import time
|
||||
import bbctrl
|
||||
import bbctrl.Trace as Trace
|
||||
|
||||
|
||||
class Ctrl(object):
|
||||
def __init__(self, args, ioloop, id):
|
||||
Trace.mark('ctrl.init.start', id=id or '<default>')
|
||||
self.args = args
|
||||
self.ioloop = bbctrl.IOLoop(ioloop)
|
||||
self.id = id
|
||||
@@ -45,47 +43,34 @@ class Ctrl(object):
|
||||
if args.demo: log_path = self.get_path(filename = 'bbctrl.log')
|
||||
else: log_path = args.log
|
||||
self.log = bbctrl.log.Log(args, self.ioloop, log_path)
|
||||
Trace.mark('ctrl.log_open')
|
||||
|
||||
self.state = bbctrl.State(self)
|
||||
self.config = bbctrl.Config(self)
|
||||
Trace.mark('ctrl.state_config')
|
||||
|
||||
self.log.get('Ctrl').info('Starting %s' % self.id)
|
||||
|
||||
try:
|
||||
with Trace.span('ctrl.avr'):
|
||||
if args.demo: self.avr = bbctrl.AVREmu(self)
|
||||
else: self.avr = bbctrl.AVR(self)
|
||||
if args.demo: self.avr = bbctrl.AVREmu(self)
|
||||
else: self.avr = bbctrl.AVR(self)
|
||||
|
||||
with Trace.span('ctrl.i2c'):
|
||||
self.i2c = bbctrl.I2C(args.i2c_port, args.demo)
|
||||
with Trace.span('ctrl.lcd'):
|
||||
self.lcd = bbctrl.LCD(self)
|
||||
with Trace.span('ctrl.mach'):
|
||||
self.mach = bbctrl.Mach(self, self.avr)
|
||||
with Trace.span('ctrl.preplanner'):
|
||||
self.preplanner = bbctrl.Preplanner(self)
|
||||
if not args.demo:
|
||||
with Trace.span('ctrl.jog'):
|
||||
self.jog = bbctrl.Jog(self)
|
||||
with Trace.span('ctrl.pwr'):
|
||||
self.pwr = bbctrl.Pwr(self)
|
||||
self.i2c = bbctrl.I2C(args.i2c_port, args.demo)
|
||||
self.lcd = bbctrl.LCD(self)
|
||||
self.mach = bbctrl.Mach(self, self.avr)
|
||||
self.preplanner = bbctrl.Preplanner(self)
|
||||
if not args.demo: self.jog = bbctrl.Jog(self)
|
||||
self.pwr = bbctrl.Pwr(self)
|
||||
self.hooks = bbctrl.Hooks(self)
|
||||
self.aux = bbctrl.AuxAxis(self)
|
||||
self._register_aux_hooks()
|
||||
|
||||
with Trace.span('ctrl.mach.connect'):
|
||||
self.mach.connect()
|
||||
self.mach.connect()
|
||||
|
||||
self.lcd.add_new_page(bbctrl.MainLCDPage(self))
|
||||
self.lcd.add_new_page(bbctrl.IPLCDPage(self.lcd))
|
||||
|
||||
os.environ['GCODE_SCRIPT_PATH'] = self.get_upload()
|
||||
|
||||
Trace.mark('ctrl.init.end')
|
||||
Trace.sd_notify('STATUS=ctrl initialized\n')
|
||||
|
||||
except Exception:
|
||||
Trace.mark('ctrl.init.error')
|
||||
self.log.get('Ctrl').exception('Internal error: Control initialization failed')
|
||||
except Exception: self.log.get('Ctrl').exception('Internal error: Control initialization failed')
|
||||
|
||||
|
||||
def __del__(self): print('Ctrl deleted')
|
||||
@@ -127,8 +112,46 @@ class Ctrl(object):
|
||||
self.preplanner.start()
|
||||
|
||||
|
||||
def _register_aux_hooks(self):
|
||||
"""Wire up the auxcnc HOOK: events to AuxAxis methods."""
|
||||
log = self.log.get('AuxAxis')
|
||||
|
||||
def _hook_move(ctx):
|
||||
data = (ctx.get('data') or '').strip()
|
||||
if not data:
|
||||
raise Exception('aux hook missing target')
|
||||
self.aux.move_abs_mm(float(data))
|
||||
|
||||
def _hook_move_rel(ctx):
|
||||
data = (ctx.get('data') or '').strip()
|
||||
if not data:
|
||||
raise Exception('aux_rel hook missing delta')
|
||||
self.aux.move_rel_mm(float(data))
|
||||
|
||||
def _hook_home(ctx):
|
||||
self.aux.home()
|
||||
|
||||
def _hook_setzero(ctx):
|
||||
data = (ctx.get('data') or '').strip()
|
||||
mm = float(data) if data else 0.0
|
||||
self.aux.set_position_mm(mm)
|
||||
|
||||
self.hooks.register_internal('aux', _hook_move,
|
||||
block_unpause=True, auto_resume=True)
|
||||
self.hooks.register_internal('aux_rel', _hook_move_rel,
|
||||
block_unpause=True, auto_resume=True)
|
||||
self.hooks.register_internal('aux_home', _hook_home,
|
||||
block_unpause=True, auto_resume=True,
|
||||
timeout=180)
|
||||
self.hooks.register_internal('aux_setzero', _hook_setzero,
|
||||
block_unpause=True, auto_resume=True)
|
||||
log.info('Aux hooks registered')
|
||||
|
||||
|
||||
def close(self):
|
||||
self.log.get('Ctrl').info('Closing %s' % self.id)
|
||||
self.ioloop.close()
|
||||
self.avr.close()
|
||||
self.mach.planner.close()
|
||||
try: self.aux.close()
|
||||
except Exception: pass
|
||||
|
||||
@@ -99,6 +99,19 @@ class FileHandler(bbctrl.APIHandler):
|
||||
|
||||
del (self.uploadFile)
|
||||
|
||||
# If the uploaded G-code uses the virtual W axis, rewrite the
|
||||
# file in place so the planner sees (MSG,HOOK:aux:*) lines
|
||||
# instead of W tokens it can't parse.
|
||||
try:
|
||||
from bbctrl.AuxPreprocessor import preprocess_file
|
||||
log = self.get_log('AuxPreprocessor')
|
||||
if preprocess_file(filename.decode('utf8'), log=log):
|
||||
log.info('Rewrote W-axis tokens in %s' %
|
||||
self.uploadFilename)
|
||||
except Exception:
|
||||
self.get_log('AuxPreprocessor').exception(
|
||||
'W-axis preprocess failed; uploading unchanged')
|
||||
|
||||
self.get_ctrl().preplanner.invalidate(self.uploadFilename)
|
||||
self.get_ctrl().state.add_file(self.uploadFilename)
|
||||
|
||||
|
||||
429
src/py/bbctrl/Hooks.py
Normal file
429
src/py/bbctrl/Hooks.py
Normal file
@@ -0,0 +1,429 @@
|
||||
################################################################################
|
||||
#
|
||||
# Hooks - External event triggers during G-code execution
|
||||
#
|
||||
# Integrates with the controller's pause/unpause cycle to run external
|
||||
# actions (webhooks, scripts) at specific points during G-code execution.
|
||||
#
|
||||
# ## How tool-change hooks work (the important one):
|
||||
#
|
||||
# G-code: T5 M6
|
||||
#
|
||||
# 1. Planner replaces M6 with tool-change override G-code (configurable).
|
||||
# Default: "M0 M6 (MSG, Change tool)"
|
||||
#
|
||||
# 2. Planner emits: set(tool,5), pause(program), message("Change tool")
|
||||
# These are sent to the AVR as serial commands.
|
||||
#
|
||||
# 3. AVR finishes current move, enters HOLDING state.
|
||||
# Reports back: xx=HOLDING, pr="Program pause"
|
||||
#
|
||||
# 4. Pi: Mach._update() sees HOLDING, flushes CommandQueue.
|
||||
# CommandQueue executes callbacks: state.set('tool', 5) fires.
|
||||
#
|
||||
# 5. Hooks._on_state_change() sees tool changed.
|
||||
# Sets self._hook_busy = True, runs the hook in a thread.
|
||||
# While _hook_busy, Mach.unpause() is blocked via can_unpause().
|
||||
#
|
||||
# 6. Machine sits in HOLDING. UI shows "Change tool" message.
|
||||
# User cannot resume yet (unpause is gated).
|
||||
#
|
||||
# 7. Hook thread finishes (toolchanger done). Sets _hook_busy = False.
|
||||
# If auto_resume is set, calls unpause automatically.
|
||||
# Otherwise user clicks Continue in UI.
|
||||
#
|
||||
# 8. Mach.unpause() → planner.restart() → AVR UNPAUSE → motion resumes.
|
||||
#
|
||||
# ## Configuration (hooks.json):
|
||||
#
|
||||
# {
|
||||
# "tool-change": {
|
||||
# "type": "webhook",
|
||||
# "url": "http://toolchanger.local/api/change",
|
||||
# "method": "POST",
|
||||
# "timeout": 120,
|
||||
# "block_unpause": true,
|
||||
# "auto_resume": true
|
||||
# },
|
||||
# "program-start": {
|
||||
# "type": "script",
|
||||
# "command": "/usr/local/bin/dust-collector on",
|
||||
# "block_unpause": false
|
||||
# }
|
||||
# }
|
||||
#
|
||||
# block_unpause: if true, unpause is blocked until hook completes
|
||||
# auto_resume: if true AND block_unpause, auto-unpause after hook done
|
||||
#
|
||||
################################################################################
|
||||
|
||||
import os
|
||||
import json
|
||||
import subprocess
|
||||
import threading
|
||||
import traceback
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.error import URLError
|
||||
|
||||
|
||||
# Events that can be hooked
|
||||
HOOK_EVENTS = [
|
||||
'tool-change', # M6 - tool change requested
|
||||
'program-start', # Program begins running
|
||||
'program-end', # M2/M30 - program ends
|
||||
'pause', # M0/M1 - program pause
|
||||
'estop', # Emergency stop triggered
|
||||
'homing-start', # Homing cycle begins
|
||||
'homing-end', # Homing cycle completes
|
||||
'custom', # Triggered by (MSG,HOOK:name:data) comments
|
||||
]
|
||||
|
||||
|
||||
class Hooks:
|
||||
def __init__(self, ctrl):
|
||||
self.ctrl = ctrl
|
||||
self.log = ctrl.log.get('Hooks')
|
||||
self.hooks = {}
|
||||
|
||||
# Hook execution state
|
||||
self._hook_busy = False # True while a blocking hook runs
|
||||
self._hook_busy_event = None # Which event is blocking
|
||||
self._hook_error = None # Error from last hook, if any
|
||||
self._hook_thread = None
|
||||
|
||||
# In-process hook handlers registered by Python modules. Keyed by
|
||||
# event name (matches what the G-code emits as HOOK:<event>).
|
||||
# Take precedence over hooks.json entries with the same name.
|
||||
self._internal = {}
|
||||
|
||||
# Track state for edge detection — must be set before add_listener
|
||||
# because add_listener fires immediately with current state
|
||||
self._last_cycle = ctrl.state.get('cycle', 'idle')
|
||||
self._last_state = ctrl.state.get('xx', '')
|
||||
self._last_tool = ctrl.state.get('tool', 0)
|
||||
self._last_pause_reason = ctrl.state.get('pr', '')
|
||||
# Highest message id we've already inspected for HOOK: lines.
|
||||
self._last_msg_id = -1
|
||||
self._initialized = False
|
||||
|
||||
self._load_config()
|
||||
|
||||
# Listen for state changes
|
||||
ctrl.state.add_listener(self._on_state_change)
|
||||
self._initialized = True
|
||||
|
||||
# -- Config management --
|
||||
|
||||
def _get_config_path(self):
|
||||
return self.ctrl.get_path(filename='hooks.json')
|
||||
|
||||
def _load_config(self):
|
||||
path = self._get_config_path()
|
||||
if os.path.exists(path):
|
||||
try:
|
||||
with open(path) as f:
|
||||
self.hooks = json.load(f)
|
||||
self.log.info('Loaded %d hook(s) from %s' %
|
||||
(len(self.hooks), path))
|
||||
except Exception:
|
||||
self.log.error('Failed to load hooks.json: %s' %
|
||||
traceback.format_exc())
|
||||
else:
|
||||
self.log.info('No hooks.json found, hooks disabled')
|
||||
|
||||
def save_config(self, config):
|
||||
"""Save hook configuration (called from API)."""
|
||||
path = self._get_config_path()
|
||||
with open(path, 'w') as f:
|
||||
json.dump(config, f, indent=2)
|
||||
self.hooks = config
|
||||
self.log.info('Saved %d hook(s)' % len(config))
|
||||
|
||||
def get_config(self):
|
||||
return self.hooks
|
||||
|
||||
# -- Unpause gating (called from Mach) --
|
||||
|
||||
def can_unpause(self):
|
||||
"""Returns True if no blocking hook is running.
|
||||
Called by Mach.unpause() to gate resume."""
|
||||
if self._hook_busy:
|
||||
self.log.info('Unpause blocked: hook "%s" still running' %
|
||||
self._hook_busy_event)
|
||||
return False
|
||||
return True
|
||||
|
||||
def get_status(self):
|
||||
"""Return current hook execution status for the UI."""
|
||||
return {
|
||||
'busy': self._hook_busy,
|
||||
'event': self._hook_busy_event,
|
||||
'error': self._hook_error,
|
||||
}
|
||||
|
||||
# -- State change listener --
|
||||
|
||||
def _on_state_change(self, update):
|
||||
"""Called on every state update from the controller."""
|
||||
if not self._initialized:
|
||||
return
|
||||
state = self.ctrl.state
|
||||
|
||||
# Detect tool change (tool number changed while HOLDING)
|
||||
if 'tool' in update:
|
||||
new_tool = update['tool']
|
||||
if new_tool != self._last_tool:
|
||||
self._fire('tool-change', {
|
||||
'old_tool': self._last_tool,
|
||||
'new_tool': new_tool,
|
||||
})
|
||||
self._last_tool = new_tool
|
||||
|
||||
# Detect cycle changes
|
||||
if 'cycle' in update:
|
||||
new_cycle = update['cycle']
|
||||
if new_cycle != self._last_cycle:
|
||||
if new_cycle == 'running' and self._last_cycle == 'idle':
|
||||
self._fire('program-start', {})
|
||||
elif new_cycle == 'idle' and self._last_cycle == 'running':
|
||||
self._fire('program-end', {})
|
||||
elif new_cycle == 'homing':
|
||||
self._fire('homing-start', {})
|
||||
elif self._last_cycle == 'homing' and new_cycle == 'idle':
|
||||
self._fire('homing-end', {})
|
||||
self._last_cycle = new_cycle
|
||||
|
||||
# Detect AVR state changes
|
||||
if 'xc' in update or 'xx' in update:
|
||||
new_state = state.get('xx', '')
|
||||
if new_state != self._last_state:
|
||||
if new_state == 'ESTOPPED':
|
||||
# Cancel any running hook on estop. The hook thread
|
||||
# cannot be killed from Python, but we can ask the
|
||||
# AuxAxis to send ABORT to the ESP so its in-flight
|
||||
# motion stops.
|
||||
if self._hook_busy:
|
||||
self.log.warning('E-stop: cancelling hook "%s"' %
|
||||
self._hook_busy_event)
|
||||
try:
|
||||
aux = getattr(self.ctrl, 'aux', None)
|
||||
if aux is not None:
|
||||
aux.abort()
|
||||
except Exception:
|
||||
pass
|
||||
self._hook_busy = False
|
||||
self._hook_busy_event = None
|
||||
self._fire('estop', {})
|
||||
self._last_state = new_state
|
||||
|
||||
# Detect pause
|
||||
if 'pr' in update:
|
||||
pr = update['pr']
|
||||
if pr and pr != self._last_pause_reason:
|
||||
self._fire('pause', {'reason': pr})
|
||||
self._last_pause_reason = pr
|
||||
|
||||
# Detect custom hook messages emitted via (MSG,HOOK:event_name:data)
|
||||
# gcode comments. State stores them as a list under 'messages'
|
||||
# ([{'id': N, 'text': '...'}, ...]); fire only on new ids.
|
||||
if 'messages' in update:
|
||||
msgs = update['messages']
|
||||
if isinstance(msgs, list):
|
||||
for m in msgs:
|
||||
try:
|
||||
mid = m.get('id', -1)
|
||||
text = m.get('text', '')
|
||||
except AttributeError:
|
||||
continue
|
||||
if mid <= self._last_msg_id:
|
||||
continue
|
||||
self._last_msg_id = mid
|
||||
if isinstance(text, str) and text.startswith('HOOK:'):
|
||||
parts = text[5:].split(':', 1)
|
||||
event = parts[0]
|
||||
data = parts[1] if len(parts) > 1 else ''
|
||||
self._fire('custom', {
|
||||
'event': event,
|
||||
'data': data,
|
||||
}, custom_name=event)
|
||||
|
||||
# -- Hook execution --
|
||||
|
||||
def register_internal(self, name, fn, block_unpause=True,
|
||||
auto_resume=True, timeout=120):
|
||||
"""Register an in-process handler for HOOK:<name> events.
|
||||
|
||||
fn(context) -> None. May raise. Runs synchronously in the hook
|
||||
thread; while it runs and block_unpause=True, Mach.unpause is
|
||||
gated."""
|
||||
self._internal[name] = {
|
||||
'type': 'internal',
|
||||
'fn': fn,
|
||||
'block_unpause': block_unpause,
|
||||
'auto_resume': auto_resume,
|
||||
'timeout': timeout,
|
||||
}
|
||||
self.log.info('Registered internal hook: %s' % name)
|
||||
|
||||
def _fire(self, event, context, custom_name=None):
|
||||
"""Fire a hook event."""
|
||||
# Internal handlers win over hooks.json entries.
|
||||
hook = None
|
||||
if custom_name:
|
||||
hook = self._internal.get(custom_name)
|
||||
if not hook:
|
||||
hook = self._internal.get(event)
|
||||
if not hook:
|
||||
hook = self.hooks.get(event)
|
||||
if custom_name and not hook:
|
||||
hook = self.hooks.get(custom_name)
|
||||
if not hook:
|
||||
return
|
||||
|
||||
self.log.info('Hook firing: %s %s' % (event, json.dumps(context)))
|
||||
|
||||
# Add standard context
|
||||
state = self.ctrl.state
|
||||
context.update({
|
||||
'event': event,
|
||||
'position': (state.get_position()
|
||||
if hasattr(state, 'get_position') else {}),
|
||||
'state': state.get('xx', ''),
|
||||
'cycle': state.get('cycle', 'idle'),
|
||||
})
|
||||
|
||||
block_unpause = hook.get('block_unpause', event == 'tool-change')
|
||||
auto_resume = hook.get('auto_resume', False)
|
||||
|
||||
if block_unpause:
|
||||
# Run in thread, block unpause until done
|
||||
self._hook_busy = True
|
||||
self._hook_busy_event = event
|
||||
self._hook_error = None
|
||||
|
||||
# Update UI state so frontend knows we're busy
|
||||
self.ctrl.state.set('hook_busy', True)
|
||||
self.ctrl.state.set('hook_event', event)
|
||||
|
||||
self._hook_thread = threading.Thread(
|
||||
target=self._run_hook_blocking,
|
||||
args=(hook, event, context, auto_resume),
|
||||
daemon=True
|
||||
)
|
||||
self._hook_thread.start()
|
||||
else:
|
||||
# Fire and forget (non-blocking)
|
||||
self._execute_hook(hook, context)
|
||||
|
||||
def _run_hook_blocking(self, hook, event, context, auto_resume):
|
||||
"""Runs in a background thread. Blocks unpause until complete."""
|
||||
try:
|
||||
self._execute_hook(hook, context)
|
||||
self.log.info('Hook "%s" completed successfully' % event)
|
||||
except Exception as e:
|
||||
self._hook_error = str(e)
|
||||
self.log.error('Hook "%s" failed: %s' % (event, e))
|
||||
finally:
|
||||
self._hook_busy = False
|
||||
self._hook_busy_event = None
|
||||
|
||||
# Schedule UI update on the ioloop thread
|
||||
self.ctrl.ioloop.call_later(0, self._hook_finished, auto_resume)
|
||||
|
||||
def _hook_finished(self, auto_resume):
|
||||
"""Called on the ioloop after a blocking hook completes."""
|
||||
self.ctrl.state.set('hook_busy', False)
|
||||
self.ctrl.state.set('hook_event', '')
|
||||
|
||||
if self._hook_error:
|
||||
self.ctrl.state.set('hook_error', self._hook_error)
|
||||
self.log.error('Hook error: %s' % self._hook_error)
|
||||
else:
|
||||
self.ctrl.state.set('hook_error', '')
|
||||
|
||||
if auto_resume and not self._hook_error:
|
||||
self.log.info('Hook done, auto-resuming')
|
||||
try:
|
||||
self.ctrl.mach.unpause()
|
||||
except Exception as e:
|
||||
self.log.error('Auto-resume failed: %s' % e)
|
||||
|
||||
def _execute_hook(self, hook, context):
|
||||
"""Execute a single hook (webhook, script, or internal). May block."""
|
||||
hook_type = hook.get('type', 'webhook')
|
||||
|
||||
if hook_type == 'webhook':
|
||||
self._fire_webhook(hook, context)
|
||||
elif hook_type == 'script':
|
||||
self._fire_script(hook, context)
|
||||
elif hook_type == 'internal':
|
||||
fn = hook.get('fn')
|
||||
if fn is None:
|
||||
raise Exception('Internal hook missing fn')
|
||||
fn(context)
|
||||
else:
|
||||
raise Exception('Unknown hook type: %s' % hook_type)
|
||||
|
||||
def _fire_webhook(self, hook, context):
|
||||
"""Fire a webhook HTTP request."""
|
||||
url = hook.get('url')
|
||||
if not url:
|
||||
raise Exception('Webhook missing url')
|
||||
|
||||
method = hook.get('method', 'POST').upper()
|
||||
timeout = hook.get('timeout', 30)
|
||||
headers = dict(hook.get('headers', {}))
|
||||
body = dict(hook.get('body', {}))
|
||||
|
||||
# Merge context into body
|
||||
body['_context'] = context
|
||||
|
||||
data = json.dumps(body).encode('utf-8')
|
||||
headers['Content-Type'] = 'application/json'
|
||||
|
||||
req = Request(url, data=data, headers=headers, method=method)
|
||||
self.log.info('Webhook %s %s' % (method, url))
|
||||
|
||||
resp = urlopen(req, timeout=timeout)
|
||||
self.log.info('Webhook response: %d' % resp.status)
|
||||
|
||||
if resp.status >= 400:
|
||||
raise Exception('Webhook returned %d' % resp.status)
|
||||
|
||||
def _fire_script(self, hook, context):
|
||||
"""Fire a local script/command. Blocks until complete."""
|
||||
command = hook.get('command')
|
||||
if not command:
|
||||
raise Exception('Script hook missing command')
|
||||
|
||||
timeout = hook.get('timeout', 120)
|
||||
|
||||
# Pass context as environment variables
|
||||
env = os.environ.copy()
|
||||
env['HOOK_EVENT'] = context.get('event', '')
|
||||
env['HOOK_STATE'] = context.get('state', '')
|
||||
env['HOOK_CYCLE'] = context.get('cycle', '')
|
||||
env['HOOK_DATA'] = json.dumps(context)
|
||||
|
||||
if 'old_tool' in context:
|
||||
env['HOOK_OLD_TOOL'] = str(context['old_tool'])
|
||||
if 'new_tool' in context:
|
||||
env['HOOK_NEW_TOOL'] = str(context['new_tool'])
|
||||
|
||||
self.log.info('Script: %s' % command)
|
||||
|
||||
result = subprocess.run(
|
||||
command, shell=True, env=env,
|
||||
timeout=timeout,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE
|
||||
)
|
||||
|
||||
stdout = result.stdout.decode('utf-8', errors='replace').strip()
|
||||
stderr = result.stderr.decode('utf-8', errors='replace').strip()
|
||||
|
||||
if stdout:
|
||||
self.log.info('Script stdout: %s' % stdout)
|
||||
|
||||
if result.returncode != 0:
|
||||
raise Exception('Script failed (%d): %s' %
|
||||
(result.returncode, stderr or 'non-zero exit'))
|
||||
@@ -182,11 +182,4 @@ class Log(object):
|
||||
if n == 16: os.unlink(fullpath)
|
||||
else: self._rotate(path, nextN)
|
||||
|
||||
# The recursive call may have unlinked or rotated this
|
||||
# path; tolerate a missing source rather than crashing
|
||||
# bbctrl on startup. This also tolerates concurrent
|
||||
# logrotate runs from /etc/cron.reboot.
|
||||
try:
|
||||
os.rename(fullpath, '%s.%d' % (path, nextN))
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
os.rename(fullpath, '%s.%d' % (path, nextN))
|
||||
|
||||
@@ -256,6 +256,9 @@ class Mach(Comm):
|
||||
if cmd[0] == '$': self._query_var(cmd)
|
||||
elif cmd[0] == '\\': super().queue_command(cmd[1:])
|
||||
else:
|
||||
# Rewrite W-axis tokens in MDI input the same way the
|
||||
# FileHandler rewrites uploaded files.
|
||||
cmd = self._rewrite_w_mdi(cmd)
|
||||
self._begin_cycle('mdi')
|
||||
self.planner.mdi(cmd, with_limits)
|
||||
super().resume()
|
||||
@@ -263,6 +266,35 @@ class Mach(Comm):
|
||||
self.mlog.info("Exception during MDI: %s" % err)
|
||||
pass
|
||||
|
||||
def _rewrite_w_mdi(self, cmd):
|
||||
"""Apply the W-axis preprocessor to a single MDI line. Returns
|
||||
possibly-multi-line G-code with HOOK: comments inserted."""
|
||||
try:
|
||||
from bbctrl.AuxPreprocessor import AuxPreprocessor, _W_TOKEN_RE
|
||||
if not _W_TOKEN_RE.search(cmd):
|
||||
return cmd
|
||||
import io, tempfile, os
|
||||
# AuxPreprocessor.process is file-based; route through
|
||||
# tempfiles so we don't fork the regex/state logic.
|
||||
pre = AuxPreprocessor(log=self.mlog)
|
||||
with tempfile.NamedTemporaryFile('w', suffix='.nc',
|
||||
delete=False) as fi:
|
||||
fi.write(cmd if cmd.endswith('\n') else cmd + '\n')
|
||||
ipath = fi.name
|
||||
opath = ipath + '.out'
|
||||
try:
|
||||
pre.process(ipath, opath)
|
||||
rewritten = open(opath).read()
|
||||
finally:
|
||||
try: os.unlink(ipath)
|
||||
except OSError: pass
|
||||
try: os.unlink(opath)
|
||||
except OSError: pass
|
||||
return rewritten
|
||||
except Exception as e:
|
||||
self.mlog.warning('W-axis MDI rewrite failed: %s' % e)
|
||||
return cmd
|
||||
|
||||
def set(self, code, value):
|
||||
super().queue_command('${}={}'.format(code, value))
|
||||
|
||||
@@ -349,6 +381,10 @@ class Mach(Comm):
|
||||
|
||||
def unpause(self):
|
||||
if self._is_paused():
|
||||
# Gate unpause on hook completion
|
||||
if hasattr(self.ctrl, 'hooks') and \
|
||||
not self.ctrl.hooks.can_unpause():
|
||||
return
|
||||
self.ctrl.state.set('optional_pause', False)
|
||||
self._unpause()
|
||||
|
||||
|
||||
@@ -30,22 +30,7 @@ import math
|
||||
import re
|
||||
import time
|
||||
from collections import deque
|
||||
# camotics.gplan is heavy (loads a C++ extension that pulls in libstdc++,
|
||||
# boost::python, etc.). Defer it: bbctrl can listen on HTTP and serve
|
||||
# the UI without ever touching the planner. Lazy-load the first time
|
||||
# Planner.init() runs, which is when the user actually queues motion.
|
||||
gplan = None
|
||||
def _load_gplan():
|
||||
global gplan
|
||||
if gplan is None:
|
||||
try:
|
||||
import bbctrl.Trace as _T
|
||||
with _T.span('imports.camotics_gplan'):
|
||||
import camotics.gplan as _gplan # pylint: disable=no-name-in-module,import-error
|
||||
except Exception:
|
||||
import camotics.gplan as _gplan # pylint: disable=no-name-in-module,import-error
|
||||
gplan = _gplan
|
||||
return gplan
|
||||
import camotics.gplan as gplan # pylint: disable=no-name-in-module,import-error
|
||||
import bbctrl.Cmd as Cmd
|
||||
from bbctrl.CommandQueue import CommandQueue
|
||||
|
||||
@@ -344,7 +329,7 @@ class Planner():
|
||||
if stop:
|
||||
self.ctrl.mach.stop()
|
||||
|
||||
self.planner = _load_gplan().Planner()
|
||||
self.planner = gplan.Planner()
|
||||
self.planner.set_resolver(self._get_var_cb)
|
||||
# TODO logger is global and will not work correctly in demo mode
|
||||
self.planner.set_logger(self._log_cb, 1, 'LinePlanner:3')
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
################################################################################
|
||||
# #
|
||||
# Lightweight phase tracing for bbctrl restart / boot timing. #
|
||||
# #
|
||||
# Anchored at module import time. All timestamps are seconds since the #
|
||||
# process anchor (monotonic). A wall-clock anchor is captured once so the #
|
||||
# timeline can be aligned with journalctl / systemd-analyze. #
|
||||
# #
|
||||
# Set BBCTRL_TRACE=0 in the environment to disable all marks (no-op). #
|
||||
# #
|
||||
# Exposed by /api/diag/timing as JSON. #
|
||||
# #
|
||||
################################################################################
|
||||
"""Bbctrl restart / startup tracing.
|
||||
|
||||
Usage:
|
||||
import bbctrl.Trace as T
|
||||
T.mark('proc.start')
|
||||
with T.span('ctrl.avr.init'):
|
||||
...
|
||||
|
||||
The timeline is also dumped on demand via /api/diag/timing.
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
import json
|
||||
import threading
|
||||
|
||||
|
||||
_ENABLED = os.environ.get('BBCTRL_TRACE', '1') != '0'
|
||||
|
||||
_t0_monotonic = time.monotonic()
|
||||
_t0_wall = time.time()
|
||||
_lock = threading.Lock()
|
||||
_events = [] # list of dicts: {t, name, fields}
|
||||
_ui_timing = None # last timeline POSTed by the browser
|
||||
|
||||
|
||||
def _read_kernel_anchors():
|
||||
"""Return (btime_wall, uptime_at_anchor) so we can express bbctrl events
|
||||
in seconds since kernel boot.
|
||||
|
||||
btime_wall: wall-clock epoch seconds when the kernel booted (from
|
||||
/proc/stat 'btime').
|
||||
uptime_at_anchor: monotonic offset (seconds since kernel boot) at the
|
||||
moment Trace was imported. Equivalent to (Trace anchor) - btime
|
||||
in wall time, but read directly from /proc/uptime so it isn't
|
||||
sensitive to wall-clock skew.
|
||||
"""
|
||||
btime = None
|
||||
uptime_at_anchor = None
|
||||
try:
|
||||
with open('/proc/stat') as f:
|
||||
for line in f:
|
||||
if line.startswith('btime '):
|
||||
btime = int(line.split()[1])
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
with open('/proc/uptime') as f:
|
||||
uptime_at_anchor = float(f.read().split()[0])
|
||||
except Exception:
|
||||
pass
|
||||
return btime, uptime_at_anchor
|
||||
|
||||
|
||||
_btime_wall, _uptime_at_anchor = _read_kernel_anchors()
|
||||
|
||||
|
||||
def now():
|
||||
return time.monotonic() - _t0_monotonic
|
||||
|
||||
|
||||
def mark(name, **fields):
|
||||
"""Record a single named event at the current monotonic time."""
|
||||
if not _ENABLED:
|
||||
return
|
||||
t = now()
|
||||
ev = {'t': round(t, 4), 'name': name}
|
||||
if fields:
|
||||
ev['fields'] = fields
|
||||
with _lock:
|
||||
_events.append(ev)
|
||||
# Also surface in the regular log stream so journalctl shows it.
|
||||
try:
|
||||
extras = ''
|
||||
if fields:
|
||||
extras = ' ' + ' '.join('%s=%s' % (k, v) for k, v in fields.items())
|
||||
print('TRACE +%.3fs %s%s' % (t, name, extras), flush=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class span(object):
|
||||
"""Context manager that emits <name>.start / <name>.end with duration."""
|
||||
|
||||
def __init__(self, name, **fields):
|
||||
self.name = name
|
||||
self.fields = fields
|
||||
self._t = None
|
||||
|
||||
def __enter__(self):
|
||||
if _ENABLED:
|
||||
self._t = time.monotonic()
|
||||
mark(self.name + '.start', **self.fields)
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
if _ENABLED and self._t is not None:
|
||||
dur_ms = int((time.monotonic() - self._t) * 1000)
|
||||
extra = dict(self.fields)
|
||||
extra['dur_ms'] = dur_ms
|
||||
if exc_type is not None:
|
||||
extra['error'] = exc_type.__name__
|
||||
mark(self.name + '.end', **extra)
|
||||
return False
|
||||
|
||||
|
||||
def set_ui_timing(data):
|
||||
global _ui_timing
|
||||
_ui_timing = data
|
||||
|
||||
|
||||
def _current_uptime():
|
||||
try:
|
||||
with open('/proc/uptime') as f:
|
||||
return float(f.read().split()[0])
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def timeline():
|
||||
with _lock:
|
||||
events = list(_events)
|
||||
return {
|
||||
'enabled': _ENABLED,
|
||||
't0_wall': _t0_wall,
|
||||
't0_iso': time.strftime('%Y-%m-%dT%H:%M:%S', time.localtime(_t0_wall)),
|
||||
'now': now(),
|
||||
'pid': os.getpid(),
|
||||
'events': events,
|
||||
'ui': _ui_timing,
|
||||
# Kernel-boot anchors so the timeline can be expressed in
|
||||
# "seconds since power on".
|
||||
'btime_wall': _btime_wall,
|
||||
'uptime_at_anchor': _uptime_at_anchor,
|
||||
'uptime_now': _current_uptime(),
|
||||
}
|
||||
|
||||
|
||||
def dump(path):
|
||||
try:
|
||||
with open(path, 'w') as f:
|
||||
json.dump(timeline(), f, indent=2)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# Sd_notify helper -------------------------------------------------------------
|
||||
#
|
||||
# Allows bbctrl to tell systemd "I am ready" / "current status is X" so
|
||||
# `systemctl status bbctrl` and `systemd-analyze critical-chain` reflect the
|
||||
# actual application state instead of just exec start.
|
||||
def sd_notify(state):
|
||||
"""Send a status line to systemd. Safe no-op when not under systemd."""
|
||||
addr = os.environ.get('NOTIFY_SOCKET')
|
||||
if not addr:
|
||||
return
|
||||
try:
|
||||
import socket
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
|
||||
try:
|
||||
# Abstract socket if it starts with '@'
|
||||
target = '\0' + addr[1:] if addr.startswith('@') else addr
|
||||
sock.sendto(state.encode('utf-8'), target)
|
||||
finally:
|
||||
sock.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# Mark module-import time so even importing bbctrl shows up.
|
||||
mark('trace.import')
|
||||
@@ -766,6 +766,93 @@ class RotaryHandler(bbctrl.APIHandler):
|
||||
log.error('Unexpected error: {}'.format(e))
|
||||
|
||||
|
||||
class HooksGetHandler(bbctrl.APIHandler):
|
||||
def get(self):
|
||||
self.write_json(self.get_ctrl().hooks.get_config())
|
||||
|
||||
|
||||
class HooksSaveHandler(bbctrl.APIHandler):
|
||||
def put_ok(self):
|
||||
self.get_ctrl().hooks.save_config(self.json)
|
||||
|
||||
|
||||
class HooksStatusHandler(bbctrl.APIHandler):
|
||||
def get(self):
|
||||
self.write_json(self.get_ctrl().hooks.get_status())
|
||||
|
||||
|
||||
class HooksFireHandler(bbctrl.APIHandler):
|
||||
def put_ok(self, event):
|
||||
data = self.json if hasattr(self, 'json') and self.json else {}
|
||||
self.get_ctrl().hooks._fire(event, data)
|
||||
|
||||
|
||||
# ----- W axis (auxcnc) endpoints --------------------------------------------
|
||||
|
||||
class AuxConfigGetHandler(bbctrl.APIHandler):
|
||||
def get(self):
|
||||
self.write_json(self.get_ctrl().aux.get_config())
|
||||
|
||||
|
||||
class AuxConfigSaveHandler(bbctrl.APIHandler):
|
||||
def put_ok(self):
|
||||
self.get_ctrl().aux.save_config(self.json or {})
|
||||
|
||||
|
||||
class AuxStatusHandler(bbctrl.APIHandler):
|
||||
def get(self):
|
||||
aux = self.get_ctrl().aux
|
||||
self.write_json({
|
||||
'enabled': aux.enabled,
|
||||
'present': aux.present,
|
||||
'homed': aux.homed,
|
||||
'pos_mm': aux.position_mm,
|
||||
})
|
||||
|
||||
|
||||
class AuxHomeHandler(bbctrl.APIHandler):
|
||||
def put_ok(self):
|
||||
# Run synchronously via the AuxAxis' own RPC; this blocks the
|
||||
# request. Fine because the UI shows a spinner.
|
||||
self.get_ctrl().aux.home()
|
||||
|
||||
|
||||
class AuxAbortHandler(bbctrl.APIHandler):
|
||||
def put_ok(self):
|
||||
self.get_ctrl().aux.abort()
|
||||
|
||||
|
||||
class AuxJogHandler(bbctrl.APIHandler):
|
||||
"""Body: {"mm": 1.5} for relative-mm move,
|
||||
{"steps": 200} for raw step move (bypasses soft limits)."""
|
||||
def put_ok(self):
|
||||
body = self.json or {}
|
||||
aux = self.get_ctrl().aux
|
||||
if 'mm' in body:
|
||||
aux.move_rel_mm(float(body['mm']))
|
||||
elif 'steps' in body:
|
||||
aux.jog_steps(int(body['steps']))
|
||||
else:
|
||||
raise HTTPError(400, 'mm or steps required')
|
||||
|
||||
|
||||
class AuxMoveHandler(bbctrl.APIHandler):
|
||||
"""Body: {"mm": 12.5} absolute move in mm."""
|
||||
def put_ok(self):
|
||||
body = self.json or {}
|
||||
if 'mm' not in body:
|
||||
raise HTTPError(400, 'mm required')
|
||||
self.get_ctrl().aux.move_abs_mm(float(body['mm']))
|
||||
|
||||
|
||||
class AuxSetZeroHandler(bbctrl.APIHandler):
|
||||
"""Body: {"mm": 0} set current position to <mm>."""
|
||||
def put_ok(self):
|
||||
body = self.json or {}
|
||||
mm = float(body.get('mm', 0.0))
|
||||
self.get_ctrl().aux.set_position_mm(mm)
|
||||
|
||||
|
||||
class RemoteDiagnosticsHandler(bbctrl.APIHandler):
|
||||
|
||||
def get(self):
|
||||
@@ -798,32 +885,6 @@ class RemoteDiagnosticsHandler(bbctrl.APIHandler):
|
||||
'message': e.reason or "Unknown"
|
||||
})
|
||||
|
||||
|
||||
class TimingHandler(bbctrl.APIHandler):
|
||||
"""Return the bbctrl process startup timeline as JSON.
|
||||
|
||||
Includes monotonic-anchored events from bbctrl.Trace, the wall
|
||||
clock anchor (so the timeline can be aligned with journalctl /
|
||||
systemd-analyze output), and the most recent UI-side timing
|
||||
payload posted by the browser.
|
||||
"""
|
||||
def get(self):
|
||||
import bbctrl.Trace as _T
|
||||
self.write_json(_T.timeline())
|
||||
|
||||
|
||||
class UITimingHandler(bbctrl.APIHandler):
|
||||
"""Browser posts its performance.now() marks here once per load."""
|
||||
def put_ok(self):
|
||||
import bbctrl.Trace as _T
|
||||
# self.json is parsed in APIHandler.prepare()
|
||||
try:
|
||||
_T.set_ui_timing(self.json)
|
||||
_T.mark('ui.posted_timing',
|
||||
marks=len(self.json.get('marks', []) or []))
|
||||
except Exception: pass
|
||||
|
||||
|
||||
# Base class for Web Socket connections
|
||||
class ClientConnection(object):
|
||||
def __init__(self, app):
|
||||
@@ -899,12 +960,6 @@ class SockJSConnection(ClientConnection, sockjs.tornado.SockJSConnection):
|
||||
ip = info.ip
|
||||
if 'X-Real-IP' in info.headers: ip = info.headers['X-Real-IP']
|
||||
self.app.get_ctrl(id).log.get('Web').info('Connection from %s' % ip)
|
||||
try:
|
||||
if not getattr(self.app, '_first_ws', False):
|
||||
self.app._first_ws = True
|
||||
import bbctrl.Trace as _T
|
||||
_T.mark('ws.first_open', ip=ip)
|
||||
except Exception: pass
|
||||
super().on_open(id)
|
||||
|
||||
|
||||
@@ -913,23 +968,6 @@ class StaticFileHandler(tornado.web.StaticFileHandler):
|
||||
self.set_header('Cache-Control',
|
||||
'no-store, no-cache, must-revalidate, max-age=0')
|
||||
|
||||
def prepare(self):
|
||||
# Mark the first request for the index page so we can see when
|
||||
# chromium actually started fetching the UI on cold boot.
|
||||
try:
|
||||
app = self.application
|
||||
if not getattr(app, '_first_root_get', False):
|
||||
# Treat any GET '/' or '/index.html' as the root fetch.
|
||||
p = self.request.path
|
||||
if p in ('/', '/index.html', ''):
|
||||
app._first_root_get = True
|
||||
import bbctrl.Trace as _T
|
||||
_T.mark('web.first_root_get',
|
||||
ip=self.request.remote_ip,
|
||||
ua=(self.request.headers.get('User-Agent') or '')[:60])
|
||||
except Exception: pass
|
||||
return super().prepare()
|
||||
|
||||
class Web(tornado.web.Application):
|
||||
def __init__(self, args, ioloop):
|
||||
self.args = args
|
||||
@@ -951,8 +989,6 @@ class Web(tornado.web.Application):
|
||||
|
||||
handlers = [
|
||||
(r'/websocket', WSConnection),
|
||||
(r'/api/diag/timing', TimingHandler),
|
||||
(r'/api/diag/timing/ui', UITimingHandler),
|
||||
(r'/api/log', LogHandler),
|
||||
(r'/api/message/(\d+)/ack', MessageAckHandler),
|
||||
(r'/api/bugreport', BugReportHandler),
|
||||
@@ -992,6 +1028,18 @@ class Web(tornado.web.Application):
|
||||
(r'/api/time', TimeHandler),
|
||||
(r'/api/rotary', RotaryHandler),
|
||||
(r'/api/remote-diagnostics', RemoteDiagnosticsHandler),
|
||||
(r'/api/hooks', HooksGetHandler),
|
||||
(r'/api/hooks/save', HooksSaveHandler),
|
||||
(r'/api/hooks/status', HooksStatusHandler),
|
||||
(r'/api/hooks/fire/([\w-]+)', HooksFireHandler),
|
||||
(r'/api/aux/config', AuxConfigGetHandler),
|
||||
(r'/api/aux/config/save', AuxConfigSaveHandler),
|
||||
(r'/api/aux/status', AuxStatusHandler),
|
||||
(r'/api/aux/home', AuxHomeHandler),
|
||||
(r'/api/aux/abort', AuxAbortHandler),
|
||||
(r'/api/aux/jog', AuxJogHandler),
|
||||
(r'/api/aux/move', AuxMoveHandler),
|
||||
(r'/api/aux/set-zero', AuxSetZeroHandler),
|
||||
(r'/(.*)', StaticFileHandler,
|
||||
{'path': bbctrl.get_resource('http/'),
|
||||
'default_filename': 'index.html'}),
|
||||
|
||||
@@ -36,13 +36,6 @@ import datetime
|
||||
|
||||
from pkg_resources import Requirement, resource_filename
|
||||
|
||||
# Trace must be imported before the rest of bbctrl so its monotonic
|
||||
# anchor is the earliest reasonable point and so import-time costs of
|
||||
# heavy submodules (camotics gplan.so, sockjs, tornado, etc.) are
|
||||
# attributable in /api/diag/timing.
|
||||
import bbctrl.Trace as Trace
|
||||
Trace.mark('imports.bbctrl.start')
|
||||
|
||||
from bbctrl.RequestHandler import RequestHandler
|
||||
from bbctrl.APIHandler import APIHandler
|
||||
from bbctrl.FileHandler import FileHandler
|
||||
@@ -66,13 +59,13 @@ from bbctrl.AVR import AVR
|
||||
from bbctrl.AVREmu import AVREmu
|
||||
from bbctrl.IOLoop import IOLoop
|
||||
from bbctrl.MonitorTemp import MonitorTemp
|
||||
from bbctrl.Hooks import Hooks
|
||||
from bbctrl.AuxAxis import AuxAxis
|
||||
import bbctrl.Cmd as Cmd
|
||||
import bbctrl.v4l2 as v4l2
|
||||
import bbctrl.Log as log
|
||||
import bbctrl.ObjGraph as ObjGraph
|
||||
|
||||
Trace.mark('imports.bbctrl.end')
|
||||
|
||||
|
||||
ctrl = None
|
||||
|
||||
@@ -176,28 +169,19 @@ def parse_args():
|
||||
def run():
|
||||
global ctrl
|
||||
|
||||
Trace.mark('run.enter')
|
||||
args = parse_args()
|
||||
Trace.mark('args.parsed')
|
||||
|
||||
# Set signal handler
|
||||
signal.signal(signal.SIGTERM, on_exit)
|
||||
|
||||
# Create ioloop
|
||||
ioloop = tornado.ioloop.IOLoop.current()
|
||||
Trace.mark('ioloop.created')
|
||||
|
||||
# Set ObjGraph signal handler
|
||||
if args.debug: Debugger(ioloop, args.debug)
|
||||
|
||||
# Start server
|
||||
with Trace.span('web.init'):
|
||||
web = Web(args, ioloop)
|
||||
Trace.mark('listen', port=args.port, addr=args.addr)
|
||||
|
||||
# Notify systemd we are ready (no-op when not under systemd).
|
||||
Trace.sd_notify('READY=1\nSTATUS=listening on %s:%d\n' %
|
||||
(args.addr, args.port))
|
||||
web = Web(args, ioloop)
|
||||
|
||||
try:
|
||||
ioloop.start()
|
||||
|
||||
BIN
src/resources/fonts/fontawesome-webfont.ttf
Normal file
BIN
src/resources/fonts/fontawesome-webfont.ttf
Normal file
Binary file not shown.
BIN
src/resources/fonts/fontawesome-webfont.woff
Normal file
BIN
src/resources/fonts/fontawesome-webfont.woff
Normal file
Binary file not shown.
BIN
src/resources/fonts/fontawesome-webfont.woff2
Normal file
BIN
src/resources/fonts/fontawesome-webfont.woff2
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -2,5 +2,5 @@
|
||||
font-family: 'Audiowide';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: local('Audiowide'), local('Audiowide-Regular'), url(https://fonts.gstatic.com/s/audiowide/v4/8XtYtNKEyyZh481XVWfVOqCWcynf_cDxXwCLxiixG1c.ttf) format('truetype');
|
||||
src: local('Audiowide'), local('Audiowide-Regular'), url(http://fonts.gstatic.com/s/audiowide/v4/8XtYtNKEyyZh481XVWfVOqCWcynf_cDxXwCLxiixG1c.ttf) format('truetype');
|
||||
}
|
||||
|
||||
9
src/static/css/fa6.min.css
vendored
9
src/static/css/fa6.min.css
vendored
File diff suppressed because one or more lines are too long
4
src/static/css/font-awesome.min.css
vendored
Normal file
4
src/static/css/font-awesome.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -1,37 +1,35 @@
|
||||
// V09 redesign: the legacy side menu was removed. Keep this file
|
||||
// shipped in case anything still references it, but no-op the click
|
||||
// handler that used to wire up the burger menu so it does not throw
|
||||
// "Cannot set properties of null" on the Settings tab.
|
||||
(function (window, document) {
|
||||
var menuLink = document.getElementById("menuLink");
|
||||
if (!menuLink) {
|
||||
return;
|
||||
}
|
||||
|
||||
var layout = document.getElementById("layout");
|
||||
var menu = document.getElementById("menu");
|
||||
var layout = document.getElementById('layout'),
|
||||
menu = document.getElementById('menu'),
|
||||
menuLink = document.getElementById('menuLink');
|
||||
|
||||
function toggleClass(element, className) {
|
||||
if (!element) return;
|
||||
var classes = element.className.split(/\s+/);
|
||||
var i;
|
||||
for (i = 0; i < classes.length; i++) {
|
||||
if (classes[i] === className) {
|
||||
classes.splice(i, 1);
|
||||
break;
|
||||
}
|
||||
var classes = element.className.split(/\s+/),
|
||||
length = classes.length,
|
||||
i = 0;
|
||||
|
||||
for(; i < length; i++) {
|
||||
if (classes[i] === className) {
|
||||
classes.splice(i, 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (i === classes.length) {
|
||||
// The className is not found
|
||||
if (length === classes.length) {
|
||||
classes.push(className);
|
||||
}
|
||||
element.className = classes.join(" ");
|
||||
|
||||
element.className = classes.join(' ');
|
||||
}
|
||||
|
||||
menuLink.onclick = function (e) {
|
||||
var active = "active";
|
||||
var active = 'active';
|
||||
|
||||
e.preventDefault();
|
||||
toggleClass(layout, active);
|
||||
toggleClass(menu, active);
|
||||
toggleClass(menuLink, active);
|
||||
};
|
||||
|
||||
}(this, this.document));
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,8 +18,8 @@
|
||||
<h1>Settings</h1>
|
||||
|
||||
<div class="pure-form pure-form-aligned">
|
||||
<h2 id="sec-display" data-sec="display">User Interface</h2>
|
||||
<fieldset data-sec="display">
|
||||
<h2>User Interface</h2>
|
||||
<fieldset>
|
||||
<div class="pure-control-group">
|
||||
<label for="screen-rotation" />
|
||||
<Button
|
||||
@@ -45,8 +45,8 @@
|
||||
</div> -->
|
||||
</fieldset>
|
||||
|
||||
<h2 id="sec-units" data-sec="display">Units</h2>
|
||||
<fieldset data-sec="display">
|
||||
<h2>Units</h2>
|
||||
<fieldset>
|
||||
<ConfigTemplatedInput key={`settings.units`} />
|
||||
<div class="tip">
|
||||
Note, units sets both the machine default units and the units used in motor configuration. GCode program-start,
|
||||
@@ -54,13 +54,13 @@
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<h2 id="sec-easy-adapter" data-sec="display">Easy Adapter</h2>
|
||||
<fieldset data-sec="display">
|
||||
<h2>Easy Adapter</h2>
|
||||
<fieldset>
|
||||
<ConfigTemplatedInput key={`settings.easy-adapter`} />
|
||||
</fieldset>
|
||||
|
||||
<h2 id="sec-probing" data-sec="probing">Probing</h2>
|
||||
<fieldset data-sec="probing">
|
||||
<h2>Probing</h2>
|
||||
<fieldset>
|
||||
<ConfigTemplatedInput key={`settings.probing-prompts`} />
|
||||
<div class="tip">
|
||||
Onefinity highly recommends that you keep the safety prompts
|
||||
@@ -87,15 +87,15 @@
|
||||
{/each}
|
||||
</fieldset>
|
||||
|
||||
<fieldset data-sec="gcode">
|
||||
<h2 id="sec-gcode" data-sec="gcode">GCode</h2>
|
||||
<fieldset>
|
||||
<h2>GCode</h2>
|
||||
{#each Object.keys(configTemplate.gcode) as key}
|
||||
<ConfigTemplatedInput key={`gcode.${key}`} />
|
||||
{/each}
|
||||
</fieldset>
|
||||
|
||||
<h2 id="sec-path-accuracy" data-sec="gcode">Path Accuracy</h2>
|
||||
<fieldset data-sec="gcode">
|
||||
<h2>Path Accuracy</h2>
|
||||
<fieldset>
|
||||
<ConfigTemplatedInput key={`settings.max-deviation`} />
|
||||
|
||||
<div class="tip">
|
||||
@@ -118,8 +118,8 @@
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<h2 id="sec-cornering" data-sec="gcode">Cornering Speed (Advanced)</h2>
|
||||
<fieldset data-sec="gcode">
|
||||
<h2>Cornering Speed (Advanced)</h2>
|
||||
<fieldset>
|
||||
<ConfigTemplatedInput key={`settings.junction-accel`} />
|
||||
<div class="tip">
|
||||
Junction acceleration limits the cornering speed the planner
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
>
|
||||
<div slot="trailingIcon">
|
||||
{#if valid}
|
||||
<Icon class="fa fa-circle-check" style="color: green;" />
|
||||
<Icon class="fa fa-check-circle-o" style="color: green;" />
|
||||
{/if}
|
||||
</div>
|
||||
<HelperText persistent slot="helper">{helperText}</HelperText>
|
||||
|
||||
Reference in New Issue
Block a user