15 Commits

Author SHA1 Message Date
f0a37828a4 docs: rename esp-a-axis branch to private-mods in AGENTS.md 2026-05-03 15:14:21 +02:00
2b949c4f00 docs: AGENTS.md - branch model and where-does-it-go guide 2026-05-03 15:10:25 +02:00
72c69d3000 docs: CHANGELOG entry summarising the community-fork additions 2026-05-03 14:12:18 +02:00
94072253d4 ui: V09 redesign - Control/Program/Console/Settings shell
Replaces the legacy side-menu chrome with a 4-tab top header.

- index.pug: tablet/kiosk fit-to-viewport script, header tab nav,
  estop/state badges in header.
- app.js: route hash to (control|program|console|<settings-family>),
  multi-section settings shell.
- control-view: header DRO, jog grid, MDI/probe/macros panels.
- program-view + program-mixin: file browser + toolpath preview +
  run/pause/stop, replaces the legacy 'macros' tab content.
- console-view: MDI shell, message log, indicators.
- settings-shell-view: rail-driven inner pages (Display & Units,
  Probing, G-code & Motion, Macros, Network, etc.).
- settings-view: filter Svelte SettingsView to one rail section.
- SettingsView.svelte: tag every section with data-sec=… so the
  filter above can hide non-matching ones.
- style.styl: ~2700 lines of V09 layout, DRO, jog grid, status
  strip, and tablet/kiosk variants.

No A-axis / auxiliary-axis content lives on this branch.
2026-05-03 14:11:29 +02:00
c10f5c053a ui: assorted polish - Vue async fix, OrbitControls passive listeners, path-viewer + motor-view + indicators
- main.js: disable Vue async batching so reactive writes from
  hashchange listeners propagate synchronously (matches Vue 1's older
  default; avoids dropped DRO updates).
- orbit.js: pass {passive:false} to wheel/touch listeners so
  OrbitControls.preventDefault() actually suppresses page panning.
- path-viewer: opaque dark canvas (no flash from page background),
  zero-size guard, ResizeObserver cleanup on destroy.
- motor-view: stop clobbering user edits with controller state.
- estop/indicators/tool-view/path-viewer pug: rename FA4 icons to FA6,
  add viewBox to estop SVG, fix tool-view trailing newline.
2026-05-03 14:07:35 +02:00
b9e880448e ui: upgrade to FontAwesome 6, harden burger-menu shim
- Drop FA4 font files and font-awesome.min.css.
- Ship FA6 webfonts (solid, regular, brands) and fa6.min.css.
- io-indicator: use FA6 names (fa-circle-plus / -minus / -exclamation).
- static/js/ui.js: no-op the legacy side-menu click handler when menu
  links are not present (V09 chrome removes them) so the Settings tab
  no longer logs 'cannot set properties of null'.
2026-05-03 14:07:06 +02:00
8224ab8f97 boot: cold-boot optimisations cutting bbctrl listen by ~8s on the Pi
- scripts/rc.local.fast: minimal rc.local that defers the heavy bits.
- scripts/bbserial-rebind.service: oneshot unit that unbinds ttyAMA0
  from pl011 and (re)loads bbserial before bbctrl.service.
- scripts/bbctrl.service: declare the After/Wants on bbserial-rebind
  so we can rely on it rather than racing rc.local.
- scripts/install.sh: ship the cold-boot bits with firmware updates
  (mask sysstat, replace dphys-swapfile with an fstab swap entry).
- scripts/rc.local + setup_rpi.sh + setup.py: wire updated paths.
2026-05-03 14:06:44 +02:00
0b5ab2ff3b diag: add startup-timing trace and /api/diag/timing endpoint
bbctrl.Trace records monotonic-anchored events from process start.
Ctrl, Comm, the Web layer and __init__ are instrumented so a single
GET /api/diag/timing returns a full timeline of import, controller
init, AVR connection, first websocket, and first GET /. The
restart-timing.js client posts performance.now() marks back so the
browser side can be aligned in the same view.

Used to drive the cold-boot optimisations that reduce listen latency
on the Pi by ~8s.
2026-05-03 14:06:17 +02:00
94270e7725 Planner: lazy-load camotics.gplan so HTTP listener comes up first
Importing camotics.gplan pulls in a C++ extension (libstdc++,
boost::python, etc.) which adds several seconds to bbctrl startup
on the Pi. Defer it to Planner.init() — bbctrl can serve the UI
and accept connections without ever touching the planner, and the
penalty is paid only the first time motion is queued.
2026-05-03 14:04:30 +02:00
7a6e2cd00b Camera: replace deprecated @web.asynchronous with async def
Tornado removed @web.asynchronous in 6.x; bbctrl on the Pi runs an
older but compatible async-aware build. Switching to coroutine syntax
keeps the streaming endpoint working across Tornado 5/6.
2026-05-03 14:04:03 +02:00
785dafc3bc Log: tolerate missing rotated log files on startup
Recursive _rotate() may have already moved or unlinked the source path
by the time we try to rename it (also tolerates concurrent logrotate
runs from /etc/cron.reboot). Catch FileNotFoundError instead of
crashing bbctrl on startup.
2026-05-03 14:03:58 +02:00
0d5370a724 deploy: add macOS-friendly deploy scripts (local / hardware / prod)
- deploy.sh dispatcher + thin shims (deploy-local.sh / -hardware.sh / -prod.sh).
- scripts/deploy/local.sh: build UI bundle and serve via tmux session on :8770 for offline iteration.
- scripts/deploy/hardware.sh: rsync-based push to a Pi over SSH and restart bbctrl.service.
- scripts/deploy/prod.sh: bundle release tarball.
- scripts/deploy/patch_font_mime.py: hot-patches Chromium 72's broken WOFF2 mime handling on the kiosk Pi.
2026-05-03 14:03:50 +02:00
f170002c8b tools: SD card backup/restore script
backup/onefinity-backup.sh: dd-based whole-card backup/restore with
shrink/expand support so a Pi image can be moved between SD cards
of different sizes.
2026-05-03 14:03:24 +02:00
24215a8b36 build: document Pi firmware build/flash + gplan.so cross-build via Stretch Docker
- .pi/BUILD.md: end-to-end macOS dev workflow, deploy paths, dphys-swapfile vs fstab, troubleshooting.
- .pi/Dockerfile.gplan + build-gplan.sh: rebuild gplan.so from source on Raspbian Stretch (Bullseye is too new for the toolchain).
- Makefile: ensure trailing newline between concatenated pug templates so Pug doesn't glue file boundaries together.
2026-05-03 14:03:20 +02:00
3ca19ea875 chore: ignore build/log/scratch artefacts and dev-container helper 2026-05-03 14:03:08 +02:00
71 changed files with 5969 additions and 2078 deletions

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

16
.gitignore vendored
View File

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

249
.pi/BUILD.md Normal file
View File

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

48
.pi/Dockerfile.gplan Normal file
View File

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

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

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

77
AGENTS.md Normal file
View File

@@ -0,0 +1,77 @@
# Onefinity firmware — agent guidelines
## Branch model
This fork lives on **two long-lived branches**:
- **`master`** — public-facing fork. General-use upgrades on top of
upstream OneFinity firmware: V09 UX redesign, Font Awesome 6, faster
cold boot, macOS dev/deploy tooling, build & flash docs, SD-card
backup, `/api/diag/timing`, kiosk/tablet polish, and assorted
bug-fixes. **No A-axis, ATC, hooks, or auxcnc/ESP content.** Aim for
changes that benefit any Onefinity owner.
- **`private-mods`** — bespoke shop branch. Stacks on top of `master`
and adds everything specific to the auxcnc-ESP-driven A axis and
the ATC: `Hooks` (ATC IPC), `AuxAxis` (ESP serial driver),
`ExternalAxis` (virtual A through gplan), `AuxPreprocessor` (M100-M103),
Z-A coupling interlock, the A-axis UI surface, and the
`/api/aux/*` endpoints.
Upstream:
- `upstream``https://github.com/OneFinityCNC/onefinity-firmware.git`
- `origin` → Gitea (`https://gitea.home.muehe.org/muehe/onefinity-firmware.git`)
`origin/pre-split-backup` is a tag preserving the pre-split master
tip. Keep it indefinitely until further notice.
## Where does a change go?
| Change | Branch |
|---|---|
| UI polish, theme, layout that any user benefits from | `master` |
| Build / install / boot performance | `master` |
| Diagnostics, logging, generic Python / Tornado fixes | `master` |
| Anything that touches `AuxAxis`, `ExternalAxis`, `Hooks`, `AuxPreprocessor` | `private-mods` |
| Anything mentioning the auxcnc ESP, `/dev/ttyUSB0`, the M100-M103 ATC pneumatics, or motor index 4 | `private-mods` |
| Z-A coupling interlock, ATC tool change sequencing | `private-mods` |
| A-axis UI (DRO row, jog tile, settings page, A-axis routes) | `private-mods` |
| W → A renames or aux.json migrations | `private-mods` |
When in doubt: ask "would this be useful on a stock Onefinity with no
ESP attached?" If yes → `master`. If no → `private-mods`.
## Workflow
```bash
# Day-to-day shop / hardware work (default)
git checkout private-mods
# … do work, commit …
git push origin private-mods
# Generic improvement to master
git checkout master
# … do work, commit …
git push origin master
# After landing on master, replay private-mods on top
git checkout private-mods
git rebase master
git push --force-with-lease origin private-mods
```
If a change accidentally lands on `master` but is bespoke (touches
the file table above), move it: `git reset --hard <prev>` on master,
cherry-pick onto `private-mods`, force-push master.
## Deploy
- `./deploy.sh local` — UI bundle on `localhost:8770` (tmux session
`onefin-local`). No controller backend; A-axis row stays hidden.
- `./deploy.sh hardware` — rsync to the Pi over SSH, restart
`bbctrl.service`. Use the `private-mods` branch on the shop Pi.
- `./deploy.sh prod` — bundle a release tarball.
See `.pi/BUILD.md` for the full build / flash / cross-compile flow.
## Commit before ending a turn; push after significant changes.

View File

@@ -1,6 +1,67 @@
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)

View File

@@ -68,7 +68,11 @@ update: pkg
build/templates.pug: $(TEMPLS)
mkdir -p build
cat $(TEMPLS) >$@
# Use awk to ensure each template is followed by a newline so the
# next file's first line never gets glued onto the previous file's
# last line (some templates ship without a trailing newline, which
# would produce subtle Pug parse failures).
awk 'FNR==1 && NR>1 {print ""} {print} END{print ""}' $(TEMPLS) >$@
node_modules: package.json
npm install && touch node_modules

111
README.md
View File

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

278
backup/onefinity-backup.sh Executable file
View File

@@ -0,0 +1,278 @@
#!/usr/bin/env bash
set -euo pipefail
# Onefinity CNC Controller - SD Card Backup & Restore
#
# Backs up the Raspberry Pi's SD card over SSH as a compressed image.
# Compression runs on the local machine (fast), raw bytes stream from the Pi.
#
# Usage:
# ./onefinity-backup.sh backup # backup with defaults
# ./onefinity-backup.sh backup -o myfile.gz # custom output file
# ./onefinity-backup.sh restore image.gz # restore to SD card
# ./onefinity-backup.sh verify image.gz # verify image integrity
#
# Environment:
# ONEFINITY_HOST - Pi IP/hostname (default: 10.1.10.55)
# ONEFINITY_USER - SSH user (default: bbmc)
# ONEFINITY_PASS - sudo password (default: onefinity)
HOST="${ONEFINITY_HOST:-10.1.10.55}"
USER="${ONEFINITY_USER:-bbmc}"
PASS="${ONEFINITY_PASS:-onefinity}"
BACKUP_DIR="$(cd "$(dirname "$0")" && pwd)"
DEVICE="/dev/mmcblk0"
ssh_cmd() {
ssh -o ConnectTimeout=10 -o StrictHostKeyChecking=no -o LogLevel=ERROR "$USER@$HOST" "$@"
}
sudo_ssh() {
ssh_cmd "echo '$PASS' | sudo -S bash -c '$1' 2>/dev/null"
}
die() { echo "ERROR: $*" >&2; exit 1; }
# ── Backup ──────────────────────────────────────────────────────────────────
do_backup() {
local outfile=""
while [[ $# -gt 0 ]]; do
case "$1" in
-o|--output) outfile="$2"; shift 2 ;;
*) die "Unknown option: $1" ;;
esac
done
if [[ -z "$outfile" ]]; then
outfile="$BACKUP_DIR/onefinity-$(date +%Y%m%d-%H%M).img.gz"
fi
echo "╔══════════════════════════════════════════════════════╗"
echo "║ Onefinity CNC Controller - SD Card Backup ║"
echo "╚══════════════════════════════════════════════════════╝"
echo ""
echo " Host: $USER@$HOST"
echo " Device: $DEVICE"
echo " Output: $outfile"
echo ""
# Check connectivity
echo "→ Checking SSH connection..."
ssh_cmd 'hostname' >/dev/null 2>&1 || die "Cannot SSH to $USER@$HOST"
# Get card size
local card_bytes
card_bytes=$(sudo_ssh "blockdev --getsize64 $DEVICE")
local card_gb=$(echo "scale=1; $card_bytes / 1073741824" | bc)
echo " SD card: ${card_gb}GB ($card_bytes bytes)"
echo ""
# Check for enough local disk space (compressed is ~4% of raw)
local avail_bytes
avail_bytes=$(df -P "$(dirname "$outfile")" | tail -1 | awk '{print $4 * 1024}')
local need_bytes=$((card_bytes / 10)) # conservative: assume 10% compressed
if (( avail_bytes < need_bytes )); then
die "Not enough local disk space. Need ~$(echo "scale=1; $need_bytes/1073741824" | bc)GB, have $(echo "scale=1; $avail_bytes/1073741824" | bc)GB"
fi
# Stream raw dd from Pi, compress locally with gzip
# The Pi's SD card reads at ~20MB/s which is the bottleneck.
# Compressing locally on a fast machine is much better than on the ARM.
echo "→ Streaming SD card image (this takes ~20-50 minutes)..."
echo " Pi: dd → SSH → local gzip → $outfile"
echo ""
local start_time=$SECONDS
local tmpfile="${outfile}.partial"
ssh_cmd "echo '$PASS' | sudo -S dd if=$DEVICE bs=4M 2>/dev/null" 2>/dev/null \
| gzip -1 > "$tmpfile" &
local pid=$!
# Progress monitor
while kill -0 $pid 2>/dev/null; do
sleep 15
if [[ -f "$tmpfile" ]]; then
local size_h
size_h=$(ls -lh "$tmpfile" 2>/dev/null | awk '{print $5}')
local elapsed=$(( SECONDS - start_time ))
local min=$(( elapsed / 60 ))
local sec=$(( elapsed % 60 ))
printf "\r %dm%02ds elapsed — %s compressed" "$min" "$sec" "$size_h"
fi
done
wait $pid
local exit_code=$?
echo ""
if [[ $exit_code -ne 0 ]]; then
rm -f "$tmpfile"
die "Backup failed (exit code $exit_code)"
fi
mv "$tmpfile" "$outfile"
local elapsed=$(( SECONDS - start_time ))
local final_size
final_size=$(ls -lh "$outfile" | awk '{print $5}')
echo ""
echo "→ Verifying image integrity..."
if gzip -t "$outfile" 2>/dev/null; then
echo " ✓ gzip integrity OK"
else
die "Image file is corrupt!"
fi
# Verify full size by counting decompressed bytes
local actual_bytes
actual_bytes=$(gzip -d -c "$outfile" | wc -c | tr -d ' ')
if [[ "$actual_bytes" -eq "$card_bytes" ]]; then
echo " ✓ Size matches: $actual_bytes bytes (full ${card_gb}GB card)"
else
echo " ⚠ Size mismatch: expected $card_bytes, got $actual_bytes"
fi
echo ""
echo "╔══════════════════════════════════════════════════════╗"
echo " ✓ Backup complete"
echo " File: $outfile"
echo " Size: $final_size compressed (${card_gb}GB raw)"
echo " Time: $(( elapsed / 60 ))m $(( elapsed % 60 ))s"
echo "╚══════════════════════════════════════════════════════╝"
}
# ── Restore ─────────────────────────────────────────────────────────────────
do_restore() {
local imgfile="$1"
local target="${2:-}"
[[ -f "$imgfile" ]] || die "Image file not found: $imgfile"
echo "╔══════════════════════════════════════════════════════╗"
echo "║ Onefinity CNC Controller - SD Card Restore ║"
echo "╚══════════════════════════════════════════════════════╝"
echo ""
if [[ -n "$target" ]]; then
# ── Local restore: write to a local SD card device ──
[[ -b "$target" ]] || die "$target is not a block device"
local target_bytes
target_bytes=$(diskutil info -plist "$target" 2>/dev/null \
| plutil -extract TotalSize raw - 2>/dev/null \
|| blockdev --getsize64 "$target" 2>/dev/null \
|| echo 0)
echo " Image: $imgfile"
echo " Target: $target ($(echo "scale=1; $target_bytes/1073741824" | bc)GB)"
echo ""
echo " ⚠ THIS WILL ERASE ALL DATA ON $target"
echo ""
read -rp " Type YES to continue: " confirm
[[ "$confirm" == "YES" ]] || die "Aborted"
echo ""
echo "→ Unmounting target..."
diskutil unmountDisk "$target" 2>/dev/null || true
echo "→ Writing image to $target..."
local raw_target
raw_target=$(echo "$target" | sed 's|/dev/disk|/dev/rdisk|')
gzip -d -c "$imgfile" | sudo dd of="$raw_target" bs=4M status=progress
sync
echo ""
echo " ✓ Restore complete. Safe to eject $target."
else
# ── Remote restore: write back to Pi over SSH ──
echo " Image: $imgfile"
echo " Target: $USER@$HOST:$DEVICE"
echo ""
echo " ⚠ THIS WILL ERASE THE PI'S SD CARD"
echo " ⚠ The Pi must be booted from USB/network, not the SD card"
echo ""
read -rp " Type YES to continue: " confirm
[[ "$confirm" == "YES" ]] || die "Aborted"
echo ""
echo "→ Writing image to $HOST:$DEVICE..."
gzip -d -c "$imgfile" \
| ssh_cmd "echo '$PASS' | sudo -S dd of=$DEVICE bs=4M 2>/dev/null"
echo ""
echo " ✓ Remote restore complete."
fi
}
# ── Verify ──────────────────────────────────────────────────────────────────
do_verify() {
local imgfile="$1"
[[ -f "$imgfile" ]] || die "Image file not found: $imgfile"
echo "Verifying: $imgfile"
echo ""
local compressed_size
compressed_size=$(ls -lh "$imgfile" | awk '{print $5}')
echo " Compressed size: $compressed_size"
echo " Checking gzip integrity..."
if gzip -t "$imgfile" 2>/dev/null; then
echo " ✓ gzip OK"
else
die "gzip integrity check FAILED"
fi
echo " Counting uncompressed bytes..."
local raw_bytes
raw_bytes=$(gzip -d -c "$imgfile" | wc -c | tr -d ' ')
local raw_gb=$(echo "scale=1; $raw_bytes / 1073741824" | bc)
echo " ✓ Uncompressed size: ${raw_gb}GB ($raw_bytes bytes)"
echo " Checking partition table..."
gzip -d -c "$imgfile" 2>/dev/null | head -c 512 | xxd | head -4 || true
echo ""
echo " ✓ Image looks valid"
}
# ── Main ────────────────────────────────────────────────────────────────────
usage() {
cat <<EOF
Usage: $(basename "$0") <command> [options]
Commands:
backup [-o file.img.gz] Backup SD card from Pi over SSH
restore <image.gz> [/dev/diskN] Restore image to local SD card or remote Pi
verify <image.gz> Verify image integrity
Environment variables:
ONEFINITY_HOST Pi address (default: 10.1.10.55)
ONEFINITY_USER SSH user (default: bbmc)
ONEFINITY_PASS sudo password (default: onefinity)
Examples:
$(basename "$0") backup
$(basename "$0") backup -o /tmp/mybackup.img.gz
$(basename "$0") restore backup/onefinity-20260430.img.gz /dev/disk4
$(basename "$0") verify backup/onefinity-20260430.img.gz
EOF
exit 1
}
[[ $# -ge 1 ]] || usage
case "$1" in
backup) shift; do_backup "$@" ;;
restore) shift; [[ $# -ge 1 ]] || usage; do_restore "$@" ;;
verify) shift; [[ $# -ge 1 ]] || usage; do_verify "$@" ;;
*) usage ;;
esac

4
deploy-hardware.sh Executable file
View File

@@ -0,0 +1,4 @@
#!/bin/bash
# Shorthand for ./deploy.sh hardware
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec "$SCRIPT_DIR/deploy.sh" hardware "$@"

4
deploy-local.sh Executable file
View File

@@ -0,0 +1,4 @@
#!/bin/bash
# Shorthand for ./deploy.sh local
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec "$SCRIPT_DIR/deploy.sh" local "$@"

4
deploy-prod.sh Executable file
View File

@@ -0,0 +1,4 @@
#!/bin/bash
# Shorthand for ./deploy.sh prod
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec "$SCRIPT_DIR/deploy.sh" prod "$@"

52
deploy.sh Executable file
View File

@@ -0,0 +1,52 @@
#!/bin/bash
# Onefinity firmware deploy script.
#
# ./deploy.sh local — build & static-serve the UI on macOS
# (chrome only; no controller, shows
# DISCONNECTED overlay)
# ./deploy.sh hardware — fast iteration: rsync build/http/
# contents to the running Pi at
# onefinity.local, then restart bbctrl
# ./deploy.sh prod — full firmware update via the Pi's
# /api/firmware/update endpoint
# (equivalent to `make update`)
#
# Notes:
# * On macOS we cannot run the Python `bbctrl` controller directly
# because it imports the ARM-only camotics gplan.so. For full UI
# testing with live data, deploy to the Pi (hardware or prod).
# * `prod` requires a clean working tree.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
CMD="${1:-}"
case "$CMD" in
local) exec "$SCRIPT_DIR/scripts/deploy/local.sh" "$@" ;;
hardware) exec "$SCRIPT_DIR/scripts/deploy/hardware.sh" "$@" ;;
prod) exec "$SCRIPT_DIR/scripts/deploy/prod.sh" "$@" ;;
*)
cat <<USAGE
usage: $0 {local | hardware | prod}
local Build the UI and static-serve build/http/ in a tmux session
on macOS. Useful for iterating on the V09 chrome and routing.
URL: http://localhost:8770/
tmux: tmux attach -t onefin-local
hardware Fast iteration on the actual controller: rsync the freshly
built build/http/ tree onto onefinity.local, then restart
the bbctrl service. Requires SSH access as bbmc@onefinity.local.
Defaults: HOST=onefinity.local PASSWORD=onefinity
prod Build a full firmware package (.tar.bz2) and PUT it through
/api/firmware/update on the Pi. Equivalent to:
make update HOST=onefinity.local PASSWORD=onefinity
Requires a clean working tree.
USAGE
exit 1
;;
esac

View File

@@ -1,13 +1,24 @@
[Unit]
Description=Buildbotics Controller
After=network.target
# Note: bbctrl previously had `After=network.target`. That delays
# start by ~5s on this Pi while dhcpcd brings up wlan0/eth0, but
# bbctrl does not actually require network connectivity to come up
# (the AVR is on a local serial port, the LCD on I2C). Dropping it
# means the Pi shows the UI faster on cold boot. The wifi config UI
# still works because it queries iw/dhcpcd lazily on demand.
After=local-fs.target bbserial-rebind.service
Wants=bbserial-rebind.service
[Service]
User=root
ExecStart=/usr/local/bin/bbctrl -l /var/log/bbctrl.log
WorkingDirectory=/var/lib/bbctrl
Restart=always
StandardOutput=null
# StandardOutput was 'null'. Set to 'journal' so TRACE lines emitted by
# bbctrl.Trace are visible via `journalctl -u bbctrl`. Bbctrl still
# writes its own log via -l above; this only affects stdout/stderr.
StandardOutput=journal
StandardError=journal
Nice=-10
KillMode=process

View File

@@ -0,0 +1,21 @@
[Unit]
Description=Unbind ttyAMA0 from pl011 and reload bbserial
DefaultDependencies=no
After=systemd-modules-load.service local-fs.target
Before=bbctrl.service
ConditionPathExists=/sys/bus/amba/drivers/uart-pl011
[Service]
Type=oneshot
RemainAfterExit=yes
# Tolerate the device already being bound elsewhere or the module
# already being loaded — the goal is the end state (bbserial owns
# ttyAMA0), not running the steps.
ExecStart=/bin/sh -c '\
echo 3f201000.serial > /sys/bus/amba/drivers/uart-pl011/unbind 2>/dev/null || true; \
/sbin/modprobe -r bbserial 2>/dev/null || true; \
/sbin/modprobe bbserial \
'
[Install]
WantedBy=multi-user.target

84
scripts/deploy/hardware.sh Executable file
View File

@@ -0,0 +1,84 @@
#!/bin/bash
# --- Hardware iteration (live Pi at onefinity.local) ---
#
# Rsyncs the freshly built static UI tree (build/http/) onto the Pi's
# bbctrl egg directory and restarts bbctrl. This is much faster than
# a full firmware update and is the fastest way to iterate on the V09
# UI changes against real machine state (W axis, jog feedback, etc).
#
# Defaults:
# HOST=onefinity.local
# REMOTE_USER=bbmc
# PASSWORD=onefinity (used for sudo on the Pi)
#
# Override:
# HOST=10.1.10.55 ./deploy.sh hardware
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$SCRIPT_DIR"
HOST="${HOST:-onefinity.local}"
REMOTE_USER="${REMOTE_USER:-bbmc}"
PASSWORD="${PASSWORD:-onefinity}"
echo "Building UI bundle (HTML + resources)..."
make build/http/index.html >/dev/null
# Copy src/resources/* into build/http/. The Makefile's "all" target
# also does this, but pulls in cross-compiled subprojects (avr/boot/
# pwr/jig) we don't have toolchains for on macOS. This rsync mirrors
# only the resource tree.
rsync -a src/resources/ build/http/
echo "Locating bbctrl http/ directory on $HOST..."
REMOTE_HTTP_DIR="$(ssh -o ConnectTimeout=5 "${REMOTE_USER}@${HOST}" \
"ls -d /usr/local/lib/python*/dist-packages/bbctrl-*-py*.egg/bbctrl/http 2>/dev/null | head -1")"
if [[ -z "$REMOTE_HTTP_DIR" ]]; then
echo "ERROR: could not find bbctrl http/ directory on $HOST"
exit 1
fi
echo " $REMOTE_HTTP_DIR"
echo "Rsyncing build/http/ -> $HOST:$REMOTE_HTTP_DIR/"
# Stage to a tmp dir owned by $REMOTE_USER, then sudo-rsync into
# place. This avoids needing root over rsync. We do NOT use --delete
# anywhere -- the Pi's egg ships extra runtime files (config-template
# .json, default machine JSON, buildbotics.nc, etc.) that come with
# the bbctrl package and are not in this repo's src/resources. If
# they were deleted the controller's API would 500 because Python
# imports fail.
REMOTE_TMP="/tmp/onefin_ui_$$"
ssh -o ConnectTimeout=5 "${REMOTE_USER}@${HOST}" "mkdir -p '${REMOTE_TMP}'"
rsync -avz \
--exclude='hostinfo.txt' \
-e "ssh -o ConnectTimeout=5" \
build/http/ "${REMOTE_USER}@${HOST}:${REMOTE_TMP}/"
echo "Installing into ${REMOTE_HTTP_DIR}/ (sudo)..."
ssh -o ConnectTimeout=5 "${REMOTE_USER}@${HOST}" \
"echo '${PASSWORD}' | sudo -S bash -c '
rsync -a --exclude=hostinfo.txt \"${REMOTE_TMP}/\" \"${REMOTE_HTTP_DIR}/\" \
&& rm -rf \"${REMOTE_TMP}\"
'" 2>&1 | tail -3
# Patch bbctrl Web.py so font files get the correct MIME type. The
# Pi ships Python 3.5, whose `mimetypes` module doesn't know about
# woff/woff2/ttf, so Tornado serves them as application/octet-stream
# which Chromium 72 (the Pi's onboard browser) refuses to use as a
# web font, leading to all FontAwesome icons rendering as empty
# boxes in the kiosk UI. The patch is idempotent.
echo "Patching bbctrl font MIME types (idempotent)..."
scp -o ConnectTimeout=5 "$SCRIPT_DIR/scripts/deploy/patch_font_mime.py" \
"${REMOTE_USER}@${HOST}:/tmp/patch_font_mime.py" >/dev/null
ssh -o ConnectTimeout=5 "${REMOTE_USER}@${HOST}" \
"echo '${PASSWORD}' | sudo -S python3 /tmp/patch_font_mime.py" 2>&1 | tail -3
echo "Restarting bbctrl service..."
ssh -o ConnectTimeout=5 "${REMOTE_USER}@${HOST}" \
"echo '${PASSWORD}' | sudo -S systemctl restart bbctrl" 2>&1 | tail -3
echo ""
echo "Deployed to http://${HOST}/"
echo " Logs: ssh ${REMOTE_USER}@${HOST} 'journalctl -u bbctrl -f'"
echo " Open: open -a 'Google Chrome' http://${HOST}/"

72
scripts/deploy/local.sh Executable file
View File

@@ -0,0 +1,72 @@
#!/bin/bash
# --- Local development (macOS) ---
#
# Builds the UI bundle and static-serves it on http://localhost:8770/.
# Runs in a named tmux session so we can iterate (re-running this script
# rebuilds and restarts the server in-place, you keep your browser tab).
#
# What you'll see:
# * The full V09 chrome (header tabs, settings rail, jog grid, DRO
# skeleton, status strip).
# * A "DISCONNECTED" overlay because there's no controller backend.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$SCRIPT_DIR"
echo "🛠 Building UI bundle..."
make build/http/index.html >/dev/null
PORT="${PORT:-8770}"
SESSION="onefin-local"
ensure_tmux_window() {
local session="$1"
local window="${2:-}"
local target="${session}${window:+:$window}"
if tmux has-session -t "$session" 2>/dev/null; then
if tmux send-keys -t "$target" "" 2>/dev/null; then
echo "🔁 Reusing tmux session '$session'..."
tmux send-keys -t "$target" C-c
sleep 1
return
fi
echo "⚠️ Dead pane in '$session', recreating..."
tmux kill-session -t "$session" 2>/dev/null
fi
echo "🆕 Creating tmux session '$session'..."
tmux new-session -d -s "$session"
}
ensure_tmux_window "$SESSION"
# Free the port if a previous run is still listening.
if lsof -iTCP:"$PORT" -sTCP:LISTEN >/dev/null 2>&1; then
echo "⚠️ Port $PORT is busy; killing previous server..."
lsof -tiTCP:"$PORT" -sTCP:LISTEN | xargs -r kill 2>/dev/null || true
sleep 1
fi
tmux send-keys -t "$SESSION" \
"cd '$SCRIPT_DIR' && python3 -m http.server --directory build/http $PORT" \
C-m
echo ""
echo "✅ Static UI server started on http://localhost:$PORT/"
echo ""
echo " Routes to try:"
echo " http://localhost:$PORT/#control"
echo " http://localhost:$PORT/#program"
echo " http://localhost:$PORT/#console"
echo " http://localhost:$PORT/#settings (Display & Units)"
echo " http://localhost:$PORT/#admin-network (WiFi / IP)"
echo " http://localhost:$PORT/#motor:0 (Motor 0 settings)"
echo ""
echo " tmux: tmux attach -t $SESSION"
echo " stop: tmux kill-session -t $SESSION"
echo ""
echo " No controller is running, so the page shows DISCONNECTED and"
echo " axis values stay empty. For live data + W axis, run:"
echo " ./deploy.sh hardware (fast: rsync build/http -> Pi)"
echo " ./deploy.sh prod (full firmware update)"

View File

@@ -0,0 +1,102 @@
#!/usr/bin/env python3
"""
Patch bbctrl Web.py so font files get the correct MIME type.
Background
----------
The Onefinity controller (Pi 3B running Raspbian stretch) ships Python
3.5, whose ``mimetypes`` module does not recognize ``.woff``, ``.woff2``
or ``.ttf``. Tornado's ``StaticFileHandler`` therefore falls back to
``application/octet-stream`` for those, and Chromium 72 (the Pi's
onboard kiosk browser) refuses to use such payloads as web fonts. The
result is that every FontAwesome icon renders as an empty box on the
kiosk display.
This patch monkey-patches ``StaticFileHandler.get_content_type`` to
emit the right MIME types. It is idempotent: running it twice is a
no-op. Run with ``sudo`` so it can rewrite the egg's Web.py.
Used by:
scripts/deploy/hardware.sh
"""
from __future__ import print_function
import os
import sys
def find_web_py():
"""Return the absolute path to the bbctrl Web.py shipped in the egg."""
base = "/usr/local/lib"
for entry in os.listdir(base):
if not entry.startswith("python"):
continue
candidate_dir = os.path.join(base, entry, "dist-packages")
if not os.path.isdir(candidate_dir):
continue
for sub in os.listdir(candidate_dir):
if sub.startswith("bbctrl-") and sub.endswith(".egg"):
p = os.path.join(candidate_dir, sub, "bbctrl", "Web.py")
if os.path.isfile(p):
return p
return None
OLD_BLOCK = (
"class StaticFileHandler(tornado.web.StaticFileHandler):\n"
" def set_extra_headers(self, path):\n"
" self.set_header('Cache-Control',\n"
" 'no-store, no-cache, must-revalidate, max-age=0')"
)
NEW_BLOCK = (
"class StaticFileHandler(tornado.web.StaticFileHandler):\n"
" # FONT_MIME_FIX: Python 3.5's mimetypes module does not know\n"
" # woff/woff2/ttf, so Tornado serves them as application/octet-\n"
" # stream which Chromium 72 (the Pi's onboard kiosk browser)\n"
" # refuses to use as web fonts. Set explicit types so the FA6\n"
" # icon set actually renders on the kiosk display.\n"
" def get_content_type(self):\n"
" path = self.absolute_path or ''\n"
" if path.endswith('.woff2'): return 'font/woff2'\n"
" if path.endswith('.woff'): return 'font/woff'\n"
" if path.endswith('.ttf'): return 'font/ttf'\n"
" if path.endswith('.otf'): return 'font/otf'\n"
" if path.endswith('.eot'): return 'application/vnd.ms-fontobject'\n"
" return super().get_content_type()\n"
"\n"
" def set_extra_headers(self, path):\n"
" self.set_header('Cache-Control',\n"
" 'no-store, no-cache, must-revalidate, max-age=0')"
)
def main():
target = find_web_py()
if target is None:
print("ERROR: could not locate bbctrl Web.py under /usr/local/lib",
file=sys.stderr)
return 1
with open(target) as f:
src = f.read()
if "FONT_MIME_FIX" in src:
print("font mime: already patched ({})".format(target))
return 0
if OLD_BLOCK not in src:
print("font mime: expected block not found in {} -- skipping".format(target),
file=sys.stderr)
# Don't fail the deploy; just log and continue.
return 0
new_src = src.replace(OLD_BLOCK, NEW_BLOCK, 1)
with open(target, "w") as f:
f.write(new_src)
print("font mime: patched {}".format(target))
return 0
if __name__ == "__main__":
sys.exit(main())

42
scripts/deploy/prod.sh Executable file
View File

@@ -0,0 +1,42 @@
#!/bin/bash
# --- Production firmware update (Pi at onefinity.local) ---
#
# Builds a full firmware package (.tar.bz2) and PUTs it through the Pi's
# /api/firmware/update endpoint. This is the canonical OTA flow and goes
# through the bbctrl Tornado server's update handler.
#
# Defaults:
# HOST=onefinity.local
# PASSWORD=onefinity
#
# Override:
# HOST=10.1.10.55 PASSWORD=secret ./deploy.sh prod
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$SCRIPT_DIR"
HOST="${HOST:-onefinity.local}"
PASSWORD="${PASSWORD:-onefinity}"
# Require a clean working tree.
echo "🔍 Checking git state..."
if ! git diff --quiet || ! git diff --cached --quiet \
|| [[ -n "$(git ls-files --others --exclude-standard)" ]]; then
echo "❌ Refusing to deploy: working tree has uncommitted changes."
git status --short
exit 1
fi
echo "✅ Working tree is clean."
echo "🛠 Building firmware package..."
make pkg
echo "🚚 Uploading to http://${HOST}/api/firmware/update..."
make update HOST="$HOST" PASSWORD="$PASSWORD"
echo ""
echo "✅ Firmware update PUT to ${HOST}."
echo " The Pi will reboot itself after applying the update."
echo " Once it comes back, open: http://${HOST}/"

View File

@@ -19,8 +19,17 @@ if $UPDATE_PY; then
# Update service
rm -f /etc/init.d/bbctrl
cp scripts/bbctrl.service /etc/systemd/system/
# Cold-boot fast path:
# - bbserial-rebind.service replaces the bbserial unbind/reload
# that used to live in rc.local AFTER bbctrl was already
# listening on /dev/ttyAMA0. Doing it as a unit ordered
# Before=bbctrl.service eliminates a full bbctrl restart
# mid-boot (~5s saved).
cp scripts/bbserial-rebind.service /etc/systemd/system/
systemctl daemon-reload
systemctl enable bbctrl
systemctl enable bbserial-rebind.service
fi
if $UPDATE_AVR; then
@@ -118,8 +127,50 @@ if [ $? -ne 0 ]; then
REBOOT=true
fi
# Install rc.local
cp scripts/rc.local /etc/
# Install rc.local. Use the slimmed "fast" variant if it exists in this
# checkout (preferred); fall back to the legacy rc.local for older
# firmware tarballs that don't ship rc.local.fast yet.
if [ -f scripts/rc.local.fast ]; then
cp scripts/rc.local.fast /etc/rc.local
else
cp scripts/rc.local /etc/rc.local
fi
chmod +x /etc/rc.local
# Cold-boot: mask units that contribute to userspace startup time but
# do not benefit a deployed Onefinity Pi. Each is reversible with
# `systemctl unmask <unit>`.
# plymouth-read-write : 4s of work for a splash that rc.local kills
# immediately with `plymouth quit`.
# plymouth-quit-wait : holds graphical.target until the splash is
# fully gone; redundant once the splash is
# masked.
# raspi-config : one-shot first-boot config; on a deployed
# image it's a 2s no-op.
# sysstat : sadc CPU/IO stats logger; not used.
# Use --now so the change also applies to the running system; harmless
# on a fresh install where the units are inactive.
for unit in \
plymouth-read-write.service \
plymouth-quit-wait.service \
raspi-config.service \
sysstat.service; do
systemctl mask --now "$unit" 2>/dev/null || true
done
# Cold-boot: switch swap activation from dphys-swapfile (~4.3s LSB
# wrapper that re-checks the swap file size on every boot) to a plain
# fstab entry. The swap file itself is already created at
# /var/swap by the previous boot; we only need to make sure it gets
# `swapon`'d at local-fs.target instead.
SWAPFILE=/var/swap
if [ -f "$SWAPFILE" ]; then
if ! grep -qE "^[^#]*${SWAPFILE//\//\\/}[[:space:]]+swap" /etc/fstab; then
echo "$SWAPFILE none swap sw 0 0" >> /etc/fstab
fi
systemctl mask --now dphys-swapfile.service 2>/dev/null || true
swapon -a 2>/dev/null || true
fi
# Ensure that the watchdog python library is installed
pip3 list --format=columns | grep watchdog >/dev/null

View File

@@ -28,4 +28,4 @@ plymouth quit
# Start X in /home/pi
cd /home/pi
sudo -u pi startx
sudo -u pi startx -- -nocursor

42
scripts/rc.local.fast Normal file
View File

@@ -0,0 +1,42 @@
#!/bin/bash
# rc.local for the OneFinity Pi, "fast" variant.
#
# What changed vs. scripts/rc.local:
# - bbserial unbind/rebind moved to bbserial-rebind.service (runs
# once, before bbctrl, instead of after bbctrl is already
# listening on the serial port).
# - startx moved to kiosk.service so chromium starts in parallel
# with bbctrl rather than blocking on rc.local.
# - rc.local no longer keeps the Pi in 'starting' state forever,
# which fixes systemd-analyze.
set -e
# Mount /boot read only
mount -o remount,ro /boot 2>/dev/null || true
# Set SPI GPIO mode
gpio mode 27 alt3 || true
# Create browser memory limited cgroup
if [ -d /sys/fs/cgroup/memory ]; then
CGROUP=/sys/fs/cgroup/memory/chrome
[ -d "$CGROUP" ] || mkdir -p "$CGROUP"
chown -R pi:pi "$CGROUP"
echo 650000000 > "$CGROUP/memory.soft_limit_in_bytes"
echo 750000000 > "$CGROUP/memory.limit_in_bytes"
fi
# Stop boot splash; harmless if plymouth already gone.
plymouth quit 2>/dev/null || true
# Start X (chromium kiosk) in the background so rc.local can exit and
# late-boot units (bbctrl logrotate, etc.) don't block on it. Output
# is redirected so the journal doesn't fill up with X warnings.
cd /home/pi
# `-- -nocursor` hides the X pointer; this is a touchscreen kiosk and
# the mouse cursor only gets in the way.
nohup sudo -u pi startx -- -nocursor >/var/log/onefin-x.log 2>&1 &
disown
exit 0

View File

@@ -75,7 +75,7 @@ sed -i 's/^PARTUUID=.*\//\/dev\/mmcblk0p2 \//' /etc/fstab
# Enable browser in xorg
sed -i 's/allowed_users=console/allowed_users=anybody/' /etc/X11/Xwrapper.config
echo "sudo -u pi startx" >> /etc/rc.local
echo "sudo -u pi startx -- -nocursor" >> /etc/rc.local
cp /mnt/host/xinitrc /home/pi/.xinitrc
cp /mnt/host/ratpoisonrc /home/pi/.ratpoisonrc
cp /mnt/host/xorg.conf /etc/X11/

View File

@@ -4,6 +4,7 @@ const api = require("./api");
const cookie = require("./cookie")("bbctrl-");
const Sock = require("./sock");
const semverLt = require("semver/functions/lt");
const restartTiming = require("./restart-timing");
if (document.getElementById("svelte-dialog-host") != undefined) {
SvelteComponents.createComponent(
@@ -103,6 +104,17 @@ 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,
@@ -143,22 +155,15 @@ module.exports = new Vue({
estop: { template: "#estop-template" },
"loading-view": { template: "<h1>Loading...</h1>" },
"control-view": require("./control-view"),
"settings-view": require("./settings-view"),
"motor-view": require("./motor-view"),
"tool-view": require("./tool-view"),
"io-view": require("./io-view"),
"admin-general-view": require("./admin-general-view"),
"admin-network-view": require("./admin-network-view"),
"macros-view": require('./macros'),
"help-view": require("./help-view"),
"cheat-sheet-view": {
template: "#cheat-sheet-view-template",
data: function() {
return {
showUnimplemented: false
};
},
},
"program-view": require("./program-view"),
"console-view": require("./console-view"),
// The settings-shell renders the rail + an inner routed view.
// All settings-family hashes (settings, admin-general,
// admin-network, motor:N, tool, io, macros, help, cheat-sheet)
// resolve to this same shell; parse_hash() sets sub_tab so the
// shell knows which inner template to mount.
"settings-shell-view": require("./settings-shell-view"),
},
watch: {
@@ -166,6 +171,25 @@ module.exports = new Vue({
localStorage.setItem("display_units", value);
SvelteComponents.setDisplayUnits(value);
},
// Mirror controller messages into a console log used by the
// Console > Messages tab and the header badge counter.
"state.messages": {
handler: function(messages) {
if (!Array.isArray(messages)) return;
this.messages_log = messages.map(m => ({
text: m.text,
id: m.id,
level: /^#/.test(m.text || "") ? "info" : "warning",
ts: m.ts || Date.now(),
}));
if (this.top_tab === "console" && this.sub_tab === "messages") {
this.messages_seen = this.messages_log.length;
}
},
deep: true,
immediate: true,
},
},
events: {
@@ -227,6 +251,19 @@ module.exports = new Vue({
},
computed: {
// True when the UI is in kiosk mode — i.e. running on the
// controller's own onboard browser (Pi 3B at 1366x768) or
// explicitly forced via ?kiosk=1. Source-of-truth is the
// `kiosk-mode` class added to <html> by the inline script
// in index.pug, which already honors hostname + URL param +
// localStorage. The Pi's VideoCore IV is too slow for the
// three.js toolpath preview, so we suppress that panel in
// kiosk mode and let the gcode listing take the full width.
is_kiosk: function() {
return typeof document !== "undefined"
&& document.documentElement.classList.contains("kiosk-mode");
},
popupMessages: function() {
const msgs = [];
@@ -252,18 +289,130 @@ 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();
@@ -338,6 +487,12 @@ module.exports = new Vue({
toggle_rotary: async function(isActive) {
try {
await api.put("rotary", {status: isActive});
// The /api/rotary endpoint rewrites motors[1]/[2]
// in config.json on the server. Refetch so the UI
// reflects the new motor config (otherwise the
// motor settings page keeps showing pre-toggle
// values until the next page reload).
await this.update();
} catch (error) {
console.error(error);
alert("Error occured");
@@ -372,11 +527,19 @@ module.exports = new Vue({
connect: function() {
this.sock = new Sock(`//${location.host}/sockjs`);
let _gotFirstMsg = false;
let _gotFirstState = false;
this.sock.onmessage = (e) => {
if (typeof e.data != "object") {
return;
}
if (!_gotFirstMsg) {
_gotFirstMsg = true;
restartTiming.onWsFirstMessage();
}
if (e.data.log && e.data.log.msg !== "Switch not found") {
this.$broadcast("log", e.data.log);
@@ -386,6 +549,11 @@ module.exports = new Vue({
}
}
if (!_gotFirstState) {
_gotFirstState = true;
restartTiming.onFirstState();
}
// Check for session ID change on controller
if ("sid" in e.data) {
if (typeof this.sid == "undefined") {
@@ -410,6 +578,7 @@ module.exports = new Vue({
this.sock.onopen = () => {
this.status = "connected";
restartTiming.onWsOpen();
this.$emit(this.status);
this.$broadcast(this.status);
};
@@ -421,6 +590,21 @@ module.exports = new Vue({
};
},
// Maps a URL hash to (currentView, top_tab, sub_tab, index).
// Hash layouts supported (all kept for backward compat):
// #control -> control tab
// #program[:auto] -> program tab
// #console[:mdi|messages|indicators]
// -> console tab
// #settings -> settings tab home
// #admin-general -> settings tab, admin-general inside
// #admin-network -> settings tab, admin-network inside
// #motor:0..3 -> settings tab, motor 0..3
// #tool -> settings tab, tool view
// #io -> settings tab, io view
// #macros -> settings tab, macros view
// #help -> settings tab, help view
// #cheat-sheet -> settings tab, cheat sheet view
parse_hash: function() {
const hash = location.hash.substr(1);
@@ -430,12 +614,57 @@ module.exports = new Vue({
}
const parts = hash.split(":");
const head = parts[0];
if (parts.length == 2) {
this.index = parts[1];
this.index = parts.length > 1 ? parts[1] : -1;
// Legacy / settings-managed views resolve under the
// Settings tab while keeping their existing top-level
// hash. This preserves all existing deep links.
const settingsViews = [
"settings", "probing", "gcode",
"admin-general", "admin-network",
"motor", "tool", "io", "macros",
"help", "cheat-sheet",
];
if (head == "control") {
this.top_tab = "control";
this.sub_tab = "";
this.currentView = "control";
} else if (head == "program") {
this.top_tab = "program";
this.sub_tab = parts[1] || "auto";
this.currentView = "program";
} else if (head == "console") {
this.top_tab = "console";
this.sub_tab = parts[1] || "mdi";
this.currentView = "console";
} else if (settingsViews.indexOf(head) !== -1) {
this.top_tab = "settings";
this.sub_tab = head;
// All settings-family routes mount the same shell;
// shell picks inner view from sub_tab. Vary the
// currentView token so Vue 1 fully remounts the
// shell on every navigation — this avoids stale :class
// bindings against the local `sub` data prop.
this.currentView = "settings-shell";
} else {
// Unknown hash: route to settings shell anyway so we
// never end up rendering a bare loading screen.
this.top_tab = "settings";
this.sub_tab = head;
this.currentView = "settings-shell";
}
this.currentView = parts[0];
// Mark Console messages as seen when we enter that tab.
if (this.top_tab == "console" && this.sub_tab == "messages") {
this.messages_seen = this.messages_log.length;
}
},
toggle_sys_popover: function() {
this.sys_open = !this.sys_open;
},
save: async function() {

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

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

View File

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

View File

@@ -49,14 +49,17 @@ module.exports = {
methods: {
get_io_state_class: function(active, state) {
if (typeof active == "undefined" || typeof state == "undefined") {
return "fa-exclamation-triangle warn";
return "fa-triangle-exclamation warn";
}
// Tristated: render as the regular (outline) circle to
// distinguish from active/inactive solid circles. Adding
// `far` switches to the FA6 regular family.
if (state == 2) {
return "fa-circle-o";
return "far fa-circle";
}
const icon = state ? "fa-plus-circle" : "fa-minus-circle";
const icon = state ? "fa-circle-plus" : "fa-circle-minus";
return `${icon} ${active ? "active" : "inactive"}`;
},

View File

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

View File

@@ -87,100 +87,16 @@ module.exports = {
return this.stallRPM * this.stepsPerRev * ustep / 60;
},
current_axis: function() {
return this.state[this.index + 'an'];
},
current_max_velocity: function() {
return this.state[this.index + 'vm'];
},
current_max_soft_limit: function() {
return this.state[this.index + 'tm'];
},
current_min_soft_limit: function() {
return this.state[this.index + 'tn'];
},
current_max_accel: function() {
return this.state[this.index + 'am'];
},
current_max_jerk: function() {
return this.state[this.index + 'jm'];
},
current_step_angle: function() {
return this.state[this.index + 'sa'];
},
current_travel_per_rev: function() {
return this.state[this.index + 'tr'];
},
current_microsteps: function() {
return this.state[this.index + 'mi'];
}
},
attached: function() {
// Sync all state values with motor config when component is ready
// This ensures UI shows correct values when component is first loaded
console.log("Syncing state to motor config for motor index ",this.index);
this.syncStateToConfig();
},
watch: {
current_axis(new_value) {
const motor_axes = ["X", "Y", "Z", "A", "B", "C"]
if(motor_axes[new_value] != this.motor['axis']){
this.motor['axis'] = motor_axes[new_value];
}
},
current_max_velocity(new_value) {
if(new_value != this.motor['max-velocity']) {
this.motor['max-velocity'] = new_value;
}
},
current_max_soft_limit(new_value) {
if(new_value != this.motor['max-soft-limit']) {
this.motor['max-soft-limit'] = new_value;
}
},
current_min_soft_limit(new_value) {
if(new_value != this.motor['min-soft-limit']) {
this.motor['min-soft-limit'] = new_value;
}
},
current_max_accel(new_value) {
if(new_value != this.motor['max-accel']) {
this.motor['max-accel'] = new_value;
}
},
current_max_jerk(new_value) {
if(new_value != this.motor['max-jerk']) {
this.motor['max-jerk'] = new_value;
}
},
current_step_angle(new_value) {
if(new_value != this.motor['step-angle']) {
this.motor['step-angle'] = new_value;
}
},
current_travel_per_rev(new_value) {
if(new_value != this.motor['travel-per-rev']) {
this.motor['travel-per-rev'] = new_value;
}
},
current_microsteps(new_value) {
if(new_value != this.motor['microsteps']) {
this.motor['microsteps'] = new_value;
}
}
// NOTE: do not add `current_xxx` computed props that mirror
// controller state vars (`<idx>vm`, `<idx>am`, …) and pair
// them with watchers that copy state -> motor config. The
// controller streams those vars continuously over the WS;
// any watcher that writes them back into
// `config.motors[index]` will clobber whatever the user is
// typing into the form between websocket ticks. The form
// edits config directly; Save (app.js) PUTs it to the
// server. The server-side rotary toggle is handled by
// refetching config after the PUT, not by watching state.
},
events: {
@@ -210,45 +126,6 @@ module.exports = {
}
return templ.hmodes.indexOf(this.motor["homing-mode"]) != -1;
},
syncStateToConfig: function() {
// Force sync all state values to motor config
// This ensures the UI reflects the current state even if changes happened while component was unmounted
if(this.state == undefined) {
console.log("State is undefined");
return;
}
if (this.state[this.index + 'an'] != this.motor['axis']) {
const motor_axes = ["X", "Y", "Z", "A", "B", "C"];
this.$set('motor["axis"]', motor_axes[this.state[this.index + 'an']]);
}
if (this.state[this.index + 'vm'] != this.motor['max-velocity']) {
this.$set('motor["max-velocity"]', this.state[this.index + 'vm']);
}
if (this.state[this.index + 'tm'] != this.motor['max-soft-limit']) {
this.$set('motor["max-soft-limit"]', this.state[this.index + 'tm']);
}
if (this.state[this.index + 'tn'] != this.motor['min-soft-limit']) {
this.$set('motor["min-soft-limit"]', this.state[this.index + 'tn']);
}
if (this.state[this.index + 'am'] != this.motor['max-accel']) {
this.$set('motor["max-accel"]', this.state[this.index + 'am']);
}
if (this.state[this.index + 'jm'] != this.motor['max-jerk']) {
this.$set('motor["max-jerk"]', this.state[this.index + 'jm']);
}
if (this.state[this.index + 'sa'] != this.motor['step-angle']) {
this.$set('motor["step-angle"]', this.state[this.index + 'sa']);
}
if (this.state[this.index + 'tr'] != this.motor['travel-per-rev']) {
this.$set('motor["travel-per-rev"]', this.state[this.index + 'tr']);
}
if (this.state[this.index + 'mi'] != this.motor['microsteps']) {
this.$set('motor["microsteps"]', this.state[this.index + 'mi']);
}
}
}
};

View File

@@ -683,12 +683,16 @@ const OrbitControls = function(object, domElement) {
event.preventDefault();
}
// Chrome treats touch/wheel listeners as passive by default,
// which prevents OrbitControls.preventDefault() from suppressing
// page panning while interacting with the 3D viewer. Pass
// {passive: false} on the events that need to call preventDefault.
scope.domElement.addEventListener("contextmenu", onContextMenu, false);
scope.domElement.addEventListener("mousedown", onMouseDown, false);
scope.domElement.addEventListener("wheel", onMouseWheel, false);
scope.domElement.addEventListener("touchstart", onTouchStart, false);
scope.domElement.addEventListener("wheel", onMouseWheel, { passive: false });
scope.domElement.addEventListener("touchstart", onTouchStart, { passive: false });
scope.domElement.addEventListener("touchend", onTouchEnd, false);
scope.domElement.addEventListener("touchmove", onTouchMove, false);
scope.domElement.addEventListener("touchmove", onTouchMove, { passive: false });
window.addEventListener("keydown", onKeyDown, false);
this.update(); // force an update at start

View File

@@ -101,6 +101,13 @@ module.exports = {
Vue.nextTick(this.update);
},
beforeDestroy: function() {
if (this._sizeWatcher) {
this._sizeWatcher.disconnect();
this._sizeWatcher = null;
}
},
methods: {
update: async function() {
if (!this.webglAvailable) {
@@ -201,6 +208,12 @@ module.exports = {
}
const dims = this.get_dims();
// Skip layouts where the target has no measurable size.
// The render loop guard below will not draw frames until
// a real size has been observed at least once.
if (!(dims.width > 0 && dims.height > 0)) {
return;
}
this.camera.aspect = dims.width / dims.height;
this.camera.updateProjectionMatrix();
@@ -274,12 +287,23 @@ module.exports = {
}
try {
// Renderer
this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
// Renderer. Use an opaque canvas with a clear color
// that matches the page-side gradient so the moment
// the canvas is appended (and before the first 3D
// frame is drawn) the user does not see a flash from
// the page background through transparency.
this.renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: false,
});
this.renderer.setPixelRatio(window.devicePixelRatio);
this.renderer.setClearColor(0, 0);
this.renderer.setClearColor(0x222222, 1);
// Same color on the DOM element itself so the very
// first paint (before the WebGL context has cleared)
// is dark too.
this.renderer.domElement.style.background = "#222222";
this.renderer.domElement.style.display = "block";
this.target.appendChild(this.renderer.domElement);
} catch (e) {
console.log("WebGL not supported: ", e);
return;
@@ -333,8 +357,46 @@ module.exports = {
// Events
window.addEventListener("resize", this.update_view, false);
// Start it
// Start the render loop only after the target has a real,
// stable size. Without this, the first frame paints into
// a 0×0 / collapsed-flex canvas and a second frame paints
// again at the right size — visible as a flash on the
// very first mount of the Program tab.
const startRendering = () => {
if (this._rendering) return;
this._rendering = true;
this.update_view();
this.render();
};
const dims = this.get_dims();
if (dims.width > 0 && dims.height > 0) {
startRendering();
} else if (typeof ResizeObserver !== "undefined") {
this._sizeWatcher = new ResizeObserver(entries => {
for (const entry of entries) {
const r = entry.contentRect;
if (r.width > 0 && r.height > 0) {
this._sizeWatcher.disconnect();
this._sizeWatcher = null;
startRendering();
return;
}
}
});
this._sizeWatcher.observe(this.target);
} else {
// Old browser fallback: poll for a non-zero size.
const tick = () => {
const d = this.get_dims();
if (d.width > 0 && d.height > 0) {
startRendering();
} else {
requestAnimationFrame(tick);
}
};
requestAnimationFrame(tick);
}
},
create_surface_material: function() {
@@ -646,6 +708,14 @@ module.exports = {
return;
}
// Don't paint frames while the target has no size; this
// prevents an initial single-frame clear from painting
// before the layout has settled (visible as a dark flash).
const dims = this.get_dims();
if (!(dims.width > 0 && dims.height > 0)) {
return;
}
if (this.controls.update() || this.dirty) {
this.dirty = false;
this.renderer.render(this.scene, this.camera);

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

@@ -0,0 +1,607 @@
"use strict";
// Shared data, computed properties and methods that are used by both
// the Control view (for things like start/stop, run-macro, axis state)
// and the Program view (RUN/STOP/Upload/Download/Delete + file picker
// + gcode/path viewers). Splitting these out lets us mount the same
// behaviour under two top-level routes without duplicating code.
//
// The mixin intentionally does *not* require axis-vars; control-view
// keeps that one to itself.
const api = require("./api");
const utils = require("./utils");
module.exports = {
data: function () {
return {
mdi: "",
last_file: undefined,
last_file_time: undefined,
toolpath: {},
toolpath_progress: 0,
history: [],
speed_override: 1,
feed_override: 1,
deleteGCode: false,
folder_name: "",
edited: false,
uploading_files: false,
confirmDelete: false,
create_folder: false,
showGcodeMessage: false,
showNoGcodeMessage: false,
macrosLoading: false,
show_gcodes: false,
GCodeNotFound: false,
filesUploaded: 0,
totalFiles: 0,
files_sortby: "By Upload Date",
selected_items_to_delete: [],
search_query: "",
filtered_files: [],
selected_folder_index: null,
};
},
watch: {
"state.line": function () {
if (this.mach_state != "HOMING") {
this.$broadcast("gcode-line", this.state.line);
}
},
"state.selected_time": function () {
this.load();
},
},
computed: {
is_running: function () {
return this.mach_state == "RUNNING" || this.mach_state == "HOMING";
},
is_stopping: function () {
return this.mach_state == "STOPPING";
},
is_holding: function () {
return this.mach_state == "HOLDING";
},
is_ready: function () {
return this.mach_state == "READY";
},
is_idle: function () {
return this.state.cycle == "idle";
},
// True only while a loaded G-code program is actually being
// executed (running, paused/holding, or stopping). Excludes
// jogging, homing, probing, MDI commands and other one-off
// motion that also leave state.xx == "RUNNING" but must not
// swap the jog grid out for the "Now Running" panel.
//
// Distinguishing signal is state.cycle:
// - "idle" : nothing happening
// - "jogging" : user-initiated jog
// - "homing" : home cycle
// - "probing" : probe cycle
// - "mdi" : single MDI command
// - "running" : an actual loaded program is being run
// Only "running" (combined with a selected file) is what we want.
is_program_executing: function () {
if (!this.state) return false;
const xx = this.state.xx;
const cycle = this.state.cycle;
const isExecState = xx == "RUNNING" || xx == "HOLDING" || xx == "STOPPING";
if (!isExecState) return false;
// The cycle string narrows it to a real program run; anything
// else (jogging / homing / probing / mdi) is a one-off.
if (cycle && cycle != "running" && cycle != "idle") return false;
return !!this.state.selected;
},
is_paused: function () {
return this.is_holding && (this.pause_reason == "User pause" || this.pause_reason == "Program pause");
},
can_mdi: function () {
return this.is_idle || this.state.cycle == "mdi";
},
pause_reason: function () {
return this.state.pr;
},
plan_time: function () {
return this.state.plan_time;
},
plan_time_remaining: function () {
if (!(this.is_stopping || this.is_running || this.is_holding)) {
return 0;
}
return this.toolpath.time - this.plan_time;
},
eta: function () {
if (this.mach_state != "RUNNING") {
return "";
}
const remaining = this.plan_time_remaining;
const d = new Date();
d.setSeconds(d.getSeconds() + remaining);
return d.toLocaleString();
},
progress: function () {
if (!this.toolpath.time || this.is_ready) {
return 0;
}
const p = this.plan_time / this.toolpath.time;
return Math.min(1, p);
},
gcode_files: function () {
if (!this.state.folder) return [];
const list = Array.isArray(this.state.gcode_list) ? this.state.gcode_list : [];
const folder = list.find(item => item.name == this.state.folder);
if (!folder) return [];
const stateFiles = Array.isArray(this.state.files) ? this.state.files : [];
const files = (folder.files || [])
.filter(item => stateFiles.includes(item.file_name))
.map(item => item.file_name);
if (this.files_sortby == "A-Z") return files.sort();
if (this.files_sortby == "Z-A") return files.sort().reverse();
return files;
},
gcode_filtered_files: function () {
return this.filtered_files.filter(file =>
file.toLowerCase().includes(this.search_query.toLowerCase()));
},
gcode_folders: function () {
const list = Array.isArray(this.state.gcode_list) ? this.state.gcode_list : [];
return list
.map(item => item.name)
.filter(element => element !== "default")
.sort();
},
},
methods: {
save_config: async function (config) {
try {
await api.put("config/save", config);
this.$dispatch("update");
} catch (error) {
console.error("Restore Failed: ", error);
alert("Restore failed");
}
},
populateFiles(index) {
this.selected_folder_index = index;
this.filtered_files = this.state.gcode_list[index].files.map(item => item.file_name);
},
send: function (msg) {
this.$dispatch("send", msg);
},
toggle_sorting: function () {
if (this.files_sortby === "By Upload Date") this.files_sortby = "A-Z";
else if (this.files_sortby === "A-Z") this.files_sortby = "Z-A";
else if (this.files_sortby === "Z-A") this.files_sortby = "By Upload Date";
},
load: function () {
const file_time = this.state.selected_time;
const file = this.state.selected;
if (this.last_file == file && this.last_file_time == file_time) return;
// state.files can be undefined briefly after connect, before the
// controller has pushed its file list. Skip the existence check
// until we have a list to consult.
const files = Array.isArray(this.state.files) ? this.state.files : null;
if (this.state.selected && files && !files.includes(this.state.selected)) {
this.GCodeNotFound = true;
return;
}
this.last_file = file;
this.last_file_time = file_time;
this.$broadcast("gcode-load", file);
this.$broadcast("gcode-line", this.state.line);
this.toolpath_progress = 0;
this.load_toolpath(file, file_time);
},
load_toolpath: async function (file, file_time) {
this.toolpath = {};
if (!file || this.last_file_time != file_time) return;
this.showGcodeMessage = true;
while (this.showGcodeMessage) {
try {
const toolpath = await api.get(`path/${file}`);
this.toolpath_progress = toolpath.progress;
if (toolpath.progress === 1 || typeof toolpath.progress == "undefined") {
this.showGcodeMessage = false;
if (toolpath.bounds) {
toolpath.filename = file;
this.toolpath_progress = 1;
this.toolpath = toolpath;
const state = this.$root.state;
for (const axis of "xyzabc") {
Vue.set(state, `path_min_${axis}`, toolpath.bounds.min[axis]);
Vue.set(state, `path_max_${axis}`, toolpath.bounds.max[axis]);
}
}
}
} catch (error) {
console.error(error);
}
}
},
submit_mdi: function () {
this.send(this.mdi);
if (!this.history.length || this.history[0] != this.mdi) {
this.history.unshift(this.mdi);
}
this.mdi = "";
},
mdi_start_pause: function () {
if (this.state.xx == "RUNNING") this.pause();
else if (this.state.xx == "STOPPING" || this.state.xx == "HOLDING") this.unpause();
else this.submit_mdi();
},
load_history: function (index) {
this.mdi = this.history[index];
},
open_file: function () {
utils.clickFileInput("gcode-file-input");
},
open_folder: function () {
utils.clickFileInput("gcode-folder-input");
},
edited_folder_name: function (event) {
if (event.target.value.trim() != "") {
this.$dispatch("folder_name_edited");
}
},
update_config: function () {
this.config.gcode_list = [...this.state.gcode_list];
this.config.non_macros_list = [...this.state.non_macros_list];
this.config.macros_list = [...this.state.macros_list];
this.config.macros = [...this.state.macros];
},
reset_gcode: function () {
this.state.selected = "";
this.last_file = "";
this.$broadcast("gcode-load", "");
},
upload_gcode: async function (filename, file) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
this.filesUploaded++;
if (this.filesUploaded == this.totalFiles) {
this.uploading_files = false;
}
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) resolve("file uploaded");
else { console.error("File upload failed:", xhr.statusText); reject("upload failed"); }
};
xhr.onerror = () => { alert("Upload failed."); reject("upload failed"); };
xhr.open("PUT", `/api/file/${encodeURIComponent(filename)}`, true);
xhr.send(file);
});
},
readFile: function (file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = error => reject(error);
reader.readAsText(file, "utf-8");
});
},
validateFiles: async function (files) {
const validFiles = [];
for (const file of files) {
const extension = file.name.split(".").pop().toLowerCase();
const validExtensions = ["nc", "ngc", "gcode", "gc"];
if (validExtensions.includes(extension)) {
validFiles.push(file);
} else {
alert(`Unsupported file : ${file.name}`);
this.filesUploaded++;
if (this.filesUploaded == this.totalFiles) {
this.uploadFiles = false;
}
}
}
return validFiles;
},
uploadValidFiles: async function (files, folderName) {
const updatedConfig = { ...this.config };
for (const file of files) {
try {
const gcode = await this.readFile(file);
await this.upload_gcode(file.name, gcode);
const isAlreadyPresent = updatedConfig.non_macros_list.some(element => element.file_name === file.name);
if (!isAlreadyPresent) {
updatedConfig.non_macros_list.push({ file_name: file.name });
}
if (folderName) {
const folder = updatedConfig.gcode_list.find(item => item.type == "folder" && item.name == folderName);
if (folder) {
if (!folder.files.map(item => item.file_name).includes(file.name)) {
folder.files.push({ file_name: file.name });
}
} else {
updatedConfig.gcode_list.push({
name: folderName,
type: "folder",
files: [{ file_name: file.name }],
});
}
} else {
var folder_to_add = updatedConfig.gcode_list.find(
item => item.type == "folder" && item.name == this.state.folder,
);
if (!folder_to_add) {
folder_to_add = updatedConfig.gcode_list.unshift({
name: this.state.folder,
type: "folder",
files: [{ file_name: file.name }],
});
folder_to_add = updatedConfig.gcode_list[0];
}
if (!folder_to_add.files.find(item => item.file_name == file.name)) {
folder_to_add.files.push({ file_name: file.name });
}
}
} catch (error) {
console.warn(`error uploading file : `, error);
}
}
return updatedConfig;
},
upload_files: async function (files, folderName) {
this.update_config();
const validFiles = await this.validateFiles(files);
const updatedConfig = await this.uploadValidFiles(validFiles, folderName);
await this.save_config(updatedConfig);
},
upload_file: async function (e) {
this.uploading_files = true;
this.filesUploaded = 0;
const files = e.target.files || e.dataTransfer.files;
if (!files.length) return;
this.totalFiles = files.length;
await this.upload_files(files);
},
create_new_folder: async function () {
const folder_name = this.folder_name.trim();
if (folder_name != "") {
if (this.state.gcode_list.find(item => item.type == "folder" && item.name == folder_name)) {
alert("Folder with the same name already exists!");
return;
}
this.update_config();
this.config.gcode_list.push({
name: folder_name,
type: "folder",
files: [],
});
this.state.folder = folder_name;
this.edited = false;
this.create_folder = false;
this.folder_name = "";
this.save_config(this.config);
}
},
cancel_new_folder: function () {
this.create_folder = false;
this.folder_name = "";
},
upload_folder: async function (e) {
this.uploading_files = true;
this.filesUploaded = 0;
const files = e.target.files || e.dataTransfer.files;
if (!files.length) return;
this.totalFiles = files.length;
const folderName = files[0].webkitRelativePath.split("/")[0];
this.upload_files(files, folderName);
},
delete_current: async function () {
if (!this.state.selected) {
this.deleteGCode = false;
return;
}
this.update_config();
this.config.non_macros_list = this.config.non_macros_list.filter(
item => !this.selected_items_to_delete.includes(item.file_name),
);
const folder_to_update = this.config.gcode_list.find(
item => item.name == this.config.gcode_list[this.selected_folder_index].name && item.type == "folder",
);
folder_to_update.files = folder_to_update.files.filter(
item => !this.selected_items_to_delete.includes(item.file_name),
);
const exception_list = this.state.macros_list.map(item => item.file_name);
let files_to_delete = this.selected_items_to_delete.filter(item => !exception_list.includes(item));
await api.delete(`file/DINCAIQABiDARixAxiABDIHCAMQABiABDIHCAQQABiABDIH${files_to_delete.toString()}`);
this.save_config(this.config);
this.filtered_files = [];
this.search_query = "";
this.selected_folder_index = null;
this.selected_items_to_delete = [];
this.deleteGCode = false;
},
cancel_delete: function () {
this.filtered_files = [];
this.search_query = "";
this.selected_folder_index = null;
this.selected_items_to_delete = [];
this.deleteGCode = false;
},
delete_all: function () {
api.delete("file");
this.deleteGCode = false;
},
delete_all_except_macros: async function () {
this.update_config();
const macrosList = this.state.macros_list.map(item => item.file_name).toString();
api.delete(`file/EgZjaHJvbWUqCggBEAAYsQMYgAQyBggAEEUYOTIKCAE${macrosList}`);
this.config.non_macros_list = [];
this.config.gcode_list = [{ name: "default", type: "folder", files: [] }];
this.save_config(this.config);
this.state.folder = "default";
this.state.selected = "";
this.selected_items_to_delete = [];
this.deleteGCode = false;
},
delete_folder: async function () {
this.update_config();
if (this.state.folder && this.state.folder != "default") {
const files_to_move = this.config.gcode_list.find(
item => item.type == "folder" && item.name == this.state.folder,
);
if (files_to_move) {
const default_folder = this.config.gcode_list.find(item => item.name == "default");
default_folder.files = [...default_folder.files, ...files_to_move.files].sort();
this.config.gcode_list = this.config.gcode_list.filter(item => item.name != this.state.folder);
this.save_config(this.config);
}
}
this.state.folder = "default";
this.confirmDelete = false;
},
delete_folder_and_files: async function () {
if (!this.state.folder) {
this.confirmDelete = false;
return;
}
this.update_config();
const selected_folder = this.config.gcode_list.find(
item => item.type == "folder" && item.name == this.state.folder,
);
if (!selected_folder) return;
const macrosList = this.state.macros_list.map(item => item.file_name);
var files_to_delete = selected_folder.files
.map(item => item.file_name)
.filter(item => !macrosList.includes(item));
if (selected_folder.name != "default") {
this.config.gcode_list = this.config.gcode_list.filter(item => item.name != this.state.folder);
} else {
selected_folder.files = [];
}
await api.delete(`file/DINCAIQABiDARixAxiABDIHCAMQABiABDIHCAQQABiABDIH${files_to_delete.toString()}`);
this.config.non_macros_list = this.config.non_macros_list.filter(
item => !files_to_delete.includes(item.file_name),
);
this.save_config(this.config);
this.state.folder = "default";
this.confirmDelete = false;
},
start_pause: function () {
this.macrosLoading = false;
if (this.state.xx == "RUNNING") this.pause();
else if (this.state.xx == "STOPPING" || this.state.xx == "HOLDING") this.unpause();
else this.start();
},
start: function () { api.put("start"); },
pause: function () { api.put("pause"); },
unpause: function () { api.put("unpause"); },
optional_pause: function () { api.put("pause/optional"); },
stop: function () { api.put("stop"); },
step: function () { api.put("step"); },
override_feed: function () { api.put(`override/feed/${this.feed_override}`); },
override_speed: function () { api.put(`override/speed/${this.speed_override}`); },
run_macro: async function (id) {
if (this.state.macros[id].file_name == "default") {
this.showNoGcodeMessage = true;
return;
}
const file_name = this.state.macros[id].file_name;
try {
// Selecting a file on the server is a side effect of
// GET /api/file/<name>. The macro button used to mutate
// state.selected client-side and immediately call start, which
// raced the file fetch: if the server hadn't seen the new
// selection yet, mach.start() ran whichever file was selected
// last. Do it explicitly and await so start always sees the
// right file.
if (file_name != this.state.selected) {
this.state.selected = file_name;
// GET /api/file/<name> returns gcode text (not JSON), so use
// fetch directly. The server's FileHandler.get sets
// state.selected as a side effect; we await the response
// before starting so mach.start() reads the right file.
const resp = await fetch(
`/api/file/${encodeURIComponent(file_name)}`,
{ cache: "no-cache" }
);
if (!resp.ok) {
throw new Error(`file fetch failed: ${resp.status}`);
}
await resp.text();
}
this.load();
if (this.state.macros[id].alert == true) {
this.macrosLoading = true;
} else {
await this.start_pause();
}
} catch (error) {
console.warn("Error running macro: ", error);
}
},
},
};

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

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

80
src/js/restart-timing.js Normal file
View File

@@ -0,0 +1,80 @@
// Lightweight UI-side restart/cold-load timing.
//
// Records a few key marks using performance.now(), then POSTs them to
// /api/diag/timing/ui once 'ui.first_state' has fired. Disabled by
// setting window.BBCTRL_TRACE = false before this module is loaded.
//
// Marks collected:
// script.load -- this module evaluated
// ws.open -- websocket onopen
// ws.first_msg -- first message from controller
// ui.first_state -- first message that contained controller state
// window.load -- window 'load' event
//
// Aligning these with /api/diag/timing on the server gives the full
// picture from systemd start -> bbctrl up -> WS open -> UI rendered.
"use strict";
const _enabled = typeof window !== "undefined" && window.BBCTRL_TRACE !== false;
const _t0 = (typeof performance !== "undefined" && performance.now)
? performance.now()
: Date.now();
const _navStart = (typeof performance !== "undefined" && performance.timeOrigin)
? performance.timeOrigin
: Date.now();
const marks = [];
let posted = false;
function _now() {
return (typeof performance !== "undefined" && performance.now)
? performance.now() - _t0
: Date.now() - _t0;
}
function mark(name, fields) {
if (!_enabled) return;
marks.push(Object.assign({ n: name, t: Math.round(_now()) }, fields || {}));
}
function _post() {
if (!_enabled || posted) return;
posted = true;
const body = JSON.stringify({
navStart: _navStart,
t0_perf: _t0,
href: typeof location !== "undefined" ? location.href : "",
ua: typeof navigator !== "undefined" ? navigator.userAgent : "",
marks: marks,
});
try {
if (typeof fetch === "function") {
fetch("/api/diag/timing/ui", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: body,
keepalive: true,
}).catch(() => {});
}
} catch (e) { /* swallow */ }
}
// Record window load too; doesn't block posting.
if (_enabled && typeof window !== "undefined") {
window.addEventListener("load", () => mark("window.load"));
}
mark("script.load");
module.exports = {
enabled: _enabled,
mark: mark,
onWsOpen: () => mark("ws.open"),
onWsFirstMessage: () => mark("ws.first_msg"),
onFirstState: () => {
mark("ui.first_state");
// Defer slightly so any synchronous render finishes first.
setTimeout(_post, 100);
},
flush: _post,
};

View File

@@ -0,0 +1,169 @@
"use strict";
// Wrapper that adds a left-rail navigator around the settings family
// of views (Settings, Admin General, Admin Network, Tool, IO, Motor,
// Macros, Help, Cheat Sheet). The inner view is selected by the URL
// hash (parsed in app.js) and exposed as $root.sub_tab.
// Vue 1 has trouble making child components reactive to `$root.sub_tab`
// changes (whether via computed, watch, or prop binding through
// `<component :is>`). The shell instead listens to `hashchange`
// directly and parses the hash itself, mirroring app.js's logic, then
// keeps a local data prop `sub` that the template binds to. This is
// the only path that updates the rail's `:class` reactively.
module.exports = {
template: "#settings-shell-view-template",
props: ["config", "template", "state", "index"],
components: {
"settings-view-inner": require("./settings-view"),
"admin-general-view": require("./admin-general-view"),
"admin-network-view": require("./admin-network-view"),
"motor-view": require("./motor-view"),
"tool-view": require("./tool-view"),
"io-view": require("./io-view"),
"macros-view": require("./macros"),
"help-view": require("./help-view"),
"cheat-sheet-view": {
template: "#cheat-sheet-view-template",
data: function () {
return { showUnimplemented: false };
},
},
},
data: function () {
return {
sub: this.$root.sub_tab || "settings",
ridx: this.$root.index, // local copy of the motor index
// Whether the controller config has streamed in. The Svelte
// settings views crash on first paint with the placeholder
// config (settings.units / settings.easy-adapter / motion.*
// are all undefined). Gate the inner mount on this flag.
config_ready: false,
rail_items: [
{ sub: "settings", href: "#settings", icon: "fa-display", label: "Display & Units" },
{ sub: "probing", href: "#probing", icon: "fa-bullseye", label: "Probing" },
{ sub: "gcode", href: "#gcode", icon: "fa-code", label: "G-code & Motion" },
{ sub: "macros", href: "#macros", icon: "fa-keyboard", label: "Macros" },
{ sub: "cheat-sheet", href: "#cheat-sheet", icon: "fa-book", label: "G-code Cheat Sheet" },
{ sub: "admin-network", href: "#admin-network", icon: "fa-network-wired", label: "Network" },
{ sub: "admin-general", href: "#admin-general", icon: "fa-shield-halved", label: "General / Firmware" },
{ sub: "tool", href: "#tool", icon: "fa-bolt", label: "Spindle & Tool" },
{ sub: "io", href: "#io", icon: "fa-plug", label: "I/O" },
{ section: "Motors" },
{ sub: "motor", motor: 0, href: "#motor:0", icon: "fa-arrows-up-down-left-right", label: "Motor 0" },
{ sub: "motor", motor: 1, href: "#motor:1", icon: "fa-arrows-up-down-left-right", label: "Motor 1" },
{ sub: "motor", motor: 2, href: "#motor:2", icon: "fa-arrows-up-down-left-right", label: "Motor 2" },
{ sub: "motor", motor: 3, href: "#motor:3", icon: "fa-arrows-up-down-left-right", label: "Motor 3" },
{ section: " " },
{ sub: "help", href: "#help", icon: "fa-circle-question", label: "Help" },
],
};
},
ready: function () {
this._onHash = () => this.refresh_from_hash();
window.addEventListener("hashchange", this._onHash);
this.refresh_from_hash();
this._configPoll = setInterval(() => {
const c = this.$root && this.$root.config;
const ready = !!(c && c.full_version && c.full_version !== "<loading>"
&& c.settings && typeof c.settings === "object");
if (ready !== this.config_ready) this.config_ready = ready;
}, 200);
},
attached: function () {
// Vue 1 fires `attached` whenever the component is inserted into
// the DOM (which happens on every route change because the outer
// <component :is> recreates the instance). Re-bind the listener
// here so it works even after detach/attach cycles.
if (!this._onHash) {
this._onHash = () => this.refresh_from_hash();
}
window.addEventListener("hashchange", this._onHash);
this.refresh_from_hash();
},
detached: function () {
if (this._onHash) {
window.removeEventListener("hashchange", this._onHash);
}
},
beforeDestroy: function () {
if (this._onHash) {
window.removeEventListener("hashchange", this._onHash);
}
if (this._configPoll) clearInterval(this._configPoll);
},
methods: {
refresh_from_hash: function () {
const hash = location.hash.substr(1) || "settings";
const parts = hash.split(":");
this.sub = parts[0] || "settings";
this.ridx = parts[1] !== undefined ? parts[1] : -1;
},
is_active: function (item) {
if (!item || item.section) return false;
if (item.sub !== this.sub) return false;
if (item.sub === "motor") {
return "" + item.motor === "" + this.ridx;
}
return true;
},
on_rail_click: function (item, ev) {
if (!item) return;
// Always preventDefault on rail clicks. Letting the browser
// anchor-scroll to <div id="settings"> etc. inside .app-body
// can pull the .app-head out of view; we drive navigation
// ourselves through location.hash and our hashchange handler.
if (ev && ev.preventDefault) ev.preventDefault();
if (item.anchor) {
// Soft-link rail items use a #settings hash plus an in-page
// anchor scroll once the Svelte page has mounted. We scroll
// ONLY the .settings-content overflow container by setting
// its scrollTop directly — element.scrollIntoView() walks all
// ancestor scroll containers and can tug the .app-body / html
// layout, which under tablet mode pulls the fixed header out
// of view.
if (location.hash !== item.href) location.hash = item.href;
const reset = () => {
// Force any inadvertent ancestor scroll back to 0 before
// we move .settings-content explicitly.
window.scrollTo(0, 0);
const body = document.querySelector(".app-body");
if (body) body.scrollTop = 0;
document.documentElement.scrollTop = 0;
};
setTimeout(() => {
reset();
const el = document.getElementById(item.anchor);
const scroller = document.querySelector(".settings-content");
if (el && scroller) {
const elTop = el.getBoundingClientRect().top;
const scTop = scroller.getBoundingClientRect().top;
scroller.scrollTop = scroller.scrollTop + (elTop - scTop) - 12;
}
// Re-assert ancestor scroll = 0 in case the assignment above
// moved things.
requestAnimationFrame(reset);
}, 320);
} else {
if (location.hash !== item.href) location.hash = item.href;
// Reset .app-body scroll so each route starts at the top.
const body = document.querySelector(".app-body");
if (body) body.scrollTop = 0;
}
},
showShutdownDialog: function () {
SvelteComponents.showDialog("Shutdown");
},
},
};

View File

@@ -1,14 +1,60 @@
// V09 wraps the legacy Svelte SettingsView and filters its big page
// down to a single rail section so each rail item shows only the
// relevant controls. The Svelte component is left untouched (it is
// shared with the legacy UI) — we just hide the `<h2>` and `<fieldset>`
// elements whose `data-sec` does not match the active section.
module.exports = {
template: "#settings-view-template",
props: {
// "display" | "probing" | "gcode". Default is "display" which
// keeps the rail's "Display & Units" item working unchanged.
section: { default: "display" },
},
attached: function () {
this.svelteComponent = SvelteComponents.createComponent(
"SettingsView",
document.getElementById("settings")
);
// Defer one tick so Svelte has rendered the section markup.
setTimeout(() => this.apply_section_filter(), 0);
},
detached: function () {
this.svelteComponent.$destroy();
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";
}
}
},
},
};

View File

@@ -8,9 +8,8 @@ html(lang="en")
style: include ../static/css/pure-min.css
style: include ../static/css/side-menu.css
style: include ../static/css/font-awesome.min.css
style: include ../static/css/fa6.min.css
style: include ../static/css/Audiowide.css
style: include ../static/css/clusterize.css
style: include ../svelte-components/node_modules/svelte-material-ui/bare.css
@@ -19,103 +18,171 @@ html(lang="en")
style: include:stylus ../stylus/style.styl
body(v-cloak)
// Tablet (kiosk) mode — pins the .app-shell to 1920x1080 and
// scales it to fit the actual viewport so the UI always looks
// exactly like the 10.8" 1920x1080 portable monitor.
//
// Toggle: ?tablet=1 to enable
// ?tablet=0 to disable
// Sticky in localStorage; once set, no querystring is needed.
script.
(function () {
try {
var p = new URLSearchParams(location.search);
if (p.has("tablet")) {
var on = p.get("tablet") !== "0" && p.get("tablet") !== "false";
localStorage.setItem("ui-tablet-mode", on ? "1" : "0");
}
if (localStorage.getItem("ui-tablet-mode") === "1") {
document.documentElement.classList.add("tablet-mode");
}
function fit() {
if (!document.documentElement.classList.contains("tablet-mode")) return;
var s = Math.min(window.innerWidth / 1920, window.innerHeight / 1080);
document.documentElement.style.setProperty("--tablet-scale", s);
}
fit();
window.addEventListener("resize", fit);
// Kiosk mode: when the UI is loaded by the controller's
// own onboard browser (Chromium pointing at localhost on
// the Pi 3B at 1366x768), apply a tighter layout that
// packs the V09 UI into the smaller, slower display.
// Override with ?kiosk=0 to force the desktop layout.
if (p.has("kiosk")) {
var k = p.get("kiosk") !== "0" && p.get("kiosk") !== "false";
localStorage.setItem("ui-kiosk-mode", k ? "1" : "0");
}
var stored = localStorage.getItem("ui-kiosk-mode");
var auto = location.hostname === "localhost"
|| location.hostname === "127.0.0.1"
|| location.hostname === "::1";
if (stored === "1" || (stored !== "0" && auto)) {
document.documentElement.classList.add("kiosk-mode");
}
} catch (_e) {}
})();
#svelte-dialog-host
#overlay(v-if="status != 'connected'")
span {{status}}
#layout
a#menuLink.menu-link(href="#menu"): span
.app-shell
header.app-head
.brand-blk
.brand-logo
.brand-name ONEFINITY
#menu
button.save.pure-button.button-success(:disabled="!modified",
@click="save") Save
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
.pure-menu
ul.pure-menu-list
li.pure-menu-heading
a.pure-menu-link(href="#control") Control
.head-spacer
li.pure-menu-heading
a.pure-menu-link(href="#macros") Macros
.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="#settings") Settings
li.pure-menu-heading
a.pure-menu-link(href="#motor:0") Motors
li.pure-menu-item(v-for="motor in config.motors")
a.pure-menu-link(:href="'#motor:' + $index") Motor {{$index}}
li.pure-menu-heading
a.pure-menu-link(href="#tool") Tool
li.pure-menu-heading
a.pure-menu-link(href="#io") I/O
li.pure-menu-heading
a.pure-menu-link(href="#admin-general") Admin
li.pure-menu-item
a.pure-menu-link(href="#admin-general") General
li.pure-menu-item
a.pure-menu-link(href="#admin-network") Network
li.pure-menu-heading
a.pure-menu-link(href="#cheat-sheet") Cheat Sheet
li.pure-menu-heading
a.pure-menu-link(href="#help") Help
button.pure-button.pure-button-primary(@click="showShutdownDialog", style="width: 100%")
.fa.fa-power-off
#main
.nav-header
.brand
img(src="/images/onefinity_logo.png")
.version
div Version: v{{config.full_version}}
div IP Address: {{config.ip}}
div WiFi: {{config.wifiName}}
a.upgrade-link(v-if="show_upgrade()", href="#admin-general")
| Upgrade to v{{latestVersion}}
.fa.fa-exclamation-circle.upgrade-attention(v-if="show_upgrade()")
.pi-temp-warning
.fa.fa-thermometer-full(class="error",
v-if="80 <= state.rpi_temp",
.pi-temp-warning(v-if="80 <= state.rpi_temp",
title="Raspberry Pi temperature too high.")
.fa.fa-temperature-full
.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")
span.state-badge(:class="state_class", :title="mach_state_full")
span.dot
span {{state_label}}
.estop(:class="{active: state.es}")
estop(@click="estop")
.content(class="{{currentView}}-view")
// System popover (chip-soup destination)
.sys-popover(v-if="sys_open", @click.stop="")
.sp-row
.sp-icon: .fa.fa-microchip
.sp-text
.sp-label Firmware
.sp-val v{{config.full_version}}
a.sp-act(v-if="show_upgrade()", href="#admin-general")
| Upgrade to v{{latestVersion}}
.fa.fa-circle-exclamation.upgrade-attention
.sp-row
.sp-icon: .fa.fa-network-wired
.sp-text
.sp-label IP Address
.sp-val {{config.ip}}
.sp-row
.sp-icon: .fa.fa-wifi(:class="{'sp-warn': config.wifiName === 'not connected'}")
.sp-text
.sp-label WiFi
.sp-val {{config.wifiName}}
a.sp-act(href="#admin-network", @click="sys_open=false") Configure
.sp-row(v-if="enable_rotary")
.sp-icon: img(src="/images/rotary.svg", alt="rotary")
.sp-text
.sp-label Rotary
.sp-val {{is_rotary_active ? 'Active' : 'Inactive'}}
button.sp-act(@click="showSwitchRotaryModeDialog")
| {{is_rotary_active ? 'Disable' : 'Enable'}}
.sp-row(v-if="is_easy_adapter_active")
.sp-icon: .fa.fa-puzzle-piece
.sp-text
.sp-label Easy Adapter
.sp-val Active
.sp-row.video-row
.sp-icon: .fa.fa-video
.sp-text
.sp-label Camera
.sp-val {{has_camera ? 'Live' : 'Plug camera into USB'}}
.sp-act(v-if="has_camera", @click="toggle_video")
| {{video_size === 'small' ? 'Enlarge' : 'Shrink'}}
.video(v-if="sys_open && has_camera", title="Camera feed",
@click="toggle_video", @contextmenu="toggle_crosshair",
:class="video_size")
.crosshair(v-if="crosshair")
.vertical
.horizontal
.box
img(src="/api/video", @error="has_camera=false")
.sp-foot
button.sp-shutdown(@click="showShutdownDialog")
.fa.fa-power-off
| &nbsp;Shutdown
button.sp-save(:disabled="!modified", @click="save")
.fa.fa-save
| &nbsp;Save{{modified ? '*' : ''}}
// Routed view. We keep instances alive across tab swaps so:
// - The Program tab's WebGL <path-viewer> canvas does not
// get destroyed and recreated each time (which caused a
// dark flash as the GL context cleared the new canvas
// before its first frame).
// - The Program tab's clusterize.js gcode list does not
// re-virtualize from scratch on every visit.
// - The Settings shell's child Svelte components stay
// mounted, preserving any in-flight form state.
// The settings-shell handles its own inner v-if cascade so
// the Vue 1 reactivity quirk that motivated removing
// keep-alive earlier no longer applies here.
.app-body
component(:is="currentView + '-view'", :index="index",
:config="config", :template="template", :state="state", keep-alive)
:config="config", :template="template", :state="state",
:sub-tab="sub_tab", keep-alive)
message.error-message(:show.sync="errorShow")
div(slot="header")

View File

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

View File

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

View File

@@ -2,6 +2,8 @@ script#estop-template(type="text/x-template")
svg(version="1.1", xmlns:svg="http://www.w3.org/2000/svg",
xmlns="http://www.w3.org/2000/svg",
xmlns:xlink="http://www.w3.org/1999/xlink",
viewBox="0 0 130 130",
preserveAspectRatio="xMidYMid meet",
width="130", height="130")
defs
path#text-path-1(d="m 73.735,673.129 c 0,55.107 44.673,99.780 99.780,99.780 55.107,0 99.780,-44.673 99.780,-99.780 0,-55.107 -44.673,-99.780 -99.780,-99.780 -55.107,0 -99.780,44.673 -99.780,99.780 z")

View File

@@ -6,11 +6,11 @@ script#indicators-template(type="text/x-template")
tr
td
.fa.fa-plus-circle.io
.fa.fa-circle-plus.io
th Hi/+3.3v
th.separator
td
.fa.fa-minus-circle.io
.fa.fa-circle-minus.io
th Lo/Gnd
th.separator
td
@@ -22,7 +22,7 @@ script#indicators-template(type="text/x-template")
th Inactive
th.separator
td
.fa.fa-circle-o.io
.far.fa-circle.io
th Tristated/Disabled
table.inputs
@@ -169,14 +169,14 @@ script#indicators-template(type="text/x-template")
tr
th Motor
th(title="Overtemperature fault"): .fa.fa-thermometer-full
th(title="Overtemperature fault"): .fa.fa-temperature-full
th(title="Overcurrent motor channel A") A #[.fa.fa-bolt]
th(title="Predriver fault motor channel A")
| A #[.fa.fa-exclamation-triangle]
| A #[.fa.fa-triangle-exclamation]
th(title="Overcurrent motor channel B") B #[.fa.fa-bolt]
th(title="Predriver fault motor channel B")
| B #[.fa.fa-exclamation-triangle]
th(title="Driver communication failure"): .fa.fa-handshake-o
| B #[.fa.fa-triangle-exclamation]
th(title="Driver communication failure"): .fa.fa-handshake
th(title="Reset all motor flags")
.fa.fa-eraser(@click="motor_reset()")

View File

@@ -3,7 +3,7 @@ script#path-viewer-template(type="text/x-template")
.path-viewer-toolbar
.tool-button(title="Toggle path view size.",
@click="small = !small", :class="{active: !small}")
.fa.fa-arrows-alt
.fa.fa-up-down-left-right
.tool-button(@click="showTool = !showTool", :class="{active: showTool}",
title="Show/hide tool.")

View File

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

View File

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

View File

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

View File

@@ -223,6 +223,10 @@ class Comm(object):
self.ctrl.mach.process_log(msg)
elif 'firmware' in msg:
self.log.info('AVR firmware rebooted')
try:
import bbctrl.Trace as _T
_T.mark('avr.firmware_rebooted')
except Exception: pass
self.connect()
else:
self._update_state(msg)

View File

@@ -28,10 +28,12 @@
import os
import time
import bbctrl
import bbctrl.Trace as Trace
class Ctrl(object):
def __init__(self, args, ioloop, id):
Trace.mark('ctrl.init.start', id=id or '<default>')
self.args = args
self.ioloop = bbctrl.IOLoop(ioloop)
self.id = id
@@ -43,23 +45,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)
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: self.jog = bbctrl.Jog(self)
if not args.demo:
with Trace.span('ctrl.jog'):
self.jog = bbctrl.Jog(self)
with Trace.span('ctrl.pwr'):
self.pwr = bbctrl.Pwr(self)
with Trace.span('ctrl.mach.connect'):
self.mach.connect()
self.lcd.add_new_page(bbctrl.MainLCDPage(self))
@@ -67,7 +80,12 @@ class Ctrl(object):
os.environ['GCODE_SCRIPT_PATH'] = self.get_upload()
except Exception: self.log.get('Ctrl').exception('Internal error: Control initialization failed')
Trace.mark('ctrl.init.end')
Trace.sd_notify('STATUS=ctrl initialized\n')
except Exception:
Trace.mark('ctrl.init.error')
self.log.get('Ctrl').exception('Internal error: Control initialization failed')
def __del__(self): print('Ctrl deleted')

View File

@@ -182,4 +182,11 @@ 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

View File

@@ -30,7 +30,22 @@ import math
import re
import time
from collections import deque
import camotics.gplan as gplan # pylint: disable=no-name-in-module,import-error
# camotics.gplan is heavy (loads a C++ extension that pulls in libstdc++,
# boost::python, etc.). Defer it: bbctrl can listen on HTTP and serve
# the UI without ever touching the planner. Lazy-load the first time
# Planner.init() runs, which is when the user actually queues motion.
gplan = None
def _load_gplan():
global gplan
if gplan is None:
try:
import bbctrl.Trace as _T
with _T.span('imports.camotics_gplan'):
import camotics.gplan as _gplan # pylint: disable=no-name-in-module,import-error
except Exception:
import camotics.gplan as _gplan # pylint: disable=no-name-in-module,import-error
gplan = _gplan
return gplan
import bbctrl.Cmd as Cmd
from bbctrl.CommandQueue import CommandQueue
@@ -329,7 +344,7 @@ class Planner():
if stop:
self.ctrl.mach.stop()
self.planner = gplan.Planner()
self.planner = _load_gplan().Planner()
self.planner.set_resolver(self._get_var_cb)
# TODO logger is global and will not work correctly in demo mode
self.planner.set_logger(self._log_cb, 1, 'LinePlanner:3')

185
src/py/bbctrl/Trace.py Normal file
View File

@@ -0,0 +1,185 @@
################################################################################
# #
# Lightweight phase tracing for bbctrl restart / boot timing. #
# #
# Anchored at module import time. All timestamps are seconds since the #
# process anchor (monotonic). A wall-clock anchor is captured once so the #
# timeline can be aligned with journalctl / systemd-analyze. #
# #
# Set BBCTRL_TRACE=0 in the environment to disable all marks (no-op). #
# #
# Exposed by /api/diag/timing as JSON. #
# #
################################################################################
"""Bbctrl restart / startup tracing.
Usage:
import bbctrl.Trace as T
T.mark('proc.start')
with T.span('ctrl.avr.init'):
...
The timeline is also dumped on demand via /api/diag/timing.
"""
import os
import time
import json
import threading
_ENABLED = os.environ.get('BBCTRL_TRACE', '1') != '0'
_t0_monotonic = time.monotonic()
_t0_wall = time.time()
_lock = threading.Lock()
_events = [] # list of dicts: {t, name, fields}
_ui_timing = None # last timeline POSTed by the browser
def _read_kernel_anchors():
"""Return (btime_wall, uptime_at_anchor) so we can express bbctrl events
in seconds since kernel boot.
btime_wall: wall-clock epoch seconds when the kernel booted (from
/proc/stat 'btime').
uptime_at_anchor: monotonic offset (seconds since kernel boot) at the
moment Trace was imported. Equivalent to (Trace anchor) - btime
in wall time, but read directly from /proc/uptime so it isn't
sensitive to wall-clock skew.
"""
btime = None
uptime_at_anchor = None
try:
with open('/proc/stat') as f:
for line in f:
if line.startswith('btime '):
btime = int(line.split()[1])
break
except Exception:
pass
try:
with open('/proc/uptime') as f:
uptime_at_anchor = float(f.read().split()[0])
except Exception:
pass
return btime, uptime_at_anchor
_btime_wall, _uptime_at_anchor = _read_kernel_anchors()
def now():
return time.monotonic() - _t0_monotonic
def mark(name, **fields):
"""Record a single named event at the current monotonic time."""
if not _ENABLED:
return
t = now()
ev = {'t': round(t, 4), 'name': name}
if fields:
ev['fields'] = fields
with _lock:
_events.append(ev)
# Also surface in the regular log stream so journalctl shows it.
try:
extras = ''
if fields:
extras = ' ' + ' '.join('%s=%s' % (k, v) for k, v in fields.items())
print('TRACE +%.3fs %s%s' % (t, name, extras), flush=True)
except Exception:
pass
class span(object):
"""Context manager that emits <name>.start / <name>.end with duration."""
def __init__(self, name, **fields):
self.name = name
self.fields = fields
self._t = None
def __enter__(self):
if _ENABLED:
self._t = time.monotonic()
mark(self.name + '.start', **self.fields)
return self
def __exit__(self, exc_type, exc, tb):
if _ENABLED and self._t is not None:
dur_ms = int((time.monotonic() - self._t) * 1000)
extra = dict(self.fields)
extra['dur_ms'] = dur_ms
if exc_type is not None:
extra['error'] = exc_type.__name__
mark(self.name + '.end', **extra)
return False
def set_ui_timing(data):
global _ui_timing
_ui_timing = data
def _current_uptime():
try:
with open('/proc/uptime') as f:
return float(f.read().split()[0])
except Exception:
return None
def timeline():
with _lock:
events = list(_events)
return {
'enabled': _ENABLED,
't0_wall': _t0_wall,
't0_iso': time.strftime('%Y-%m-%dT%H:%M:%S', time.localtime(_t0_wall)),
'now': now(),
'pid': os.getpid(),
'events': events,
'ui': _ui_timing,
# Kernel-boot anchors so the timeline can be expressed in
# "seconds since power on".
'btime_wall': _btime_wall,
'uptime_at_anchor': _uptime_at_anchor,
'uptime_now': _current_uptime(),
}
def dump(path):
try:
with open(path, 'w') as f:
json.dump(timeline(), f, indent=2)
except Exception:
pass
# Sd_notify helper -------------------------------------------------------------
#
# Allows bbctrl to tell systemd "I am ready" / "current status is X" so
# `systemctl status bbctrl` and `systemd-analyze critical-chain` reflect the
# actual application state instead of just exec start.
def sd_notify(state):
"""Send a status line to systemd. Safe no-op when not under systemd."""
addr = os.environ.get('NOTIFY_SOCKET')
if not addr:
return
try:
import socket
sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
try:
# Abstract socket if it starts with '@'
target = '\0' + addr[1:] if addr.startswith('@') else addr
sock.sendto(state.encode('utf-8'), target)
finally:
sock.close()
except Exception:
pass
# Mark module-import time so even importing bbctrl shows up.
mark('trace.import')

View File

@@ -798,6 +798,32 @@ class RemoteDiagnosticsHandler(bbctrl.APIHandler):
'message': e.reason or "Unknown"
})
class TimingHandler(bbctrl.APIHandler):
"""Return the bbctrl process startup timeline as JSON.
Includes monotonic-anchored events from bbctrl.Trace, the wall
clock anchor (so the timeline can be aligned with journalctl /
systemd-analyze output), and the most recent UI-side timing
payload posted by the browser.
"""
def get(self):
import bbctrl.Trace as _T
self.write_json(_T.timeline())
class UITimingHandler(bbctrl.APIHandler):
"""Browser posts its performance.now() marks here once per load."""
def put_ok(self):
import bbctrl.Trace as _T
# self.json is parsed in APIHandler.prepare()
try:
_T.set_ui_timing(self.json)
_T.mark('ui.posted_timing',
marks=len(self.json.get('marks', []) or []))
except Exception: pass
# Base class for Web Socket connections
class ClientConnection(object):
def __init__(self, app):
@@ -873,6 +899,12 @@ class SockJSConnection(ClientConnection, sockjs.tornado.SockJSConnection):
ip = info.ip
if 'X-Real-IP' in info.headers: ip = info.headers['X-Real-IP']
self.app.get_ctrl(id).log.get('Web').info('Connection from %s' % ip)
try:
if not getattr(self.app, '_first_ws', False):
self.app._first_ws = True
import bbctrl.Trace as _T
_T.mark('ws.first_open', ip=ip)
except Exception: pass
super().on_open(id)
@@ -881,6 +913,23 @@ class StaticFileHandler(tornado.web.StaticFileHandler):
self.set_header('Cache-Control',
'no-store, no-cache, must-revalidate, max-age=0')
def prepare(self):
# Mark the first request for the index page so we can see when
# chromium actually started fetching the UI on cold boot.
try:
app = self.application
if not getattr(app, '_first_root_get', False):
# Treat any GET '/' or '/index.html' as the root fetch.
p = self.request.path
if p in ('/', '/index.html', ''):
app._first_root_get = True
import bbctrl.Trace as _T
_T.mark('web.first_root_get',
ip=self.request.remote_ip,
ua=(self.request.headers.get('User-Agent') or '')[:60])
except Exception: pass
return super().prepare()
class Web(tornado.web.Application):
def __init__(self, args, ioloop):
self.args = args
@@ -902,6 +951,8 @@ class Web(tornado.web.Application):
handlers = [
(r'/websocket', WSConnection),
(r'/api/diag/timing', TimingHandler),
(r'/api/diag/timing/ui', UITimingHandler),
(r'/api/log', LogHandler),
(r'/api/message/(\d+)/ack', MessageAckHandler),
(r'/api/bugreport', BugReportHandler),

View File

@@ -36,6 +36,13 @@ import datetime
from pkg_resources import Requirement, resource_filename
# Trace must be imported before the rest of bbctrl so its monotonic
# anchor is the earliest reasonable point and so import-time costs of
# heavy submodules (camotics gplan.so, sockjs, tornado, etc.) are
# attributable in /api/diag/timing.
import bbctrl.Trace as Trace
Trace.mark('imports.bbctrl.start')
from bbctrl.RequestHandler import RequestHandler
from bbctrl.APIHandler import APIHandler
from bbctrl.FileHandler import FileHandler
@@ -64,6 +71,8 @@ import bbctrl.v4l2 as v4l2
import bbctrl.Log as log
import bbctrl.ObjGraph as ObjGraph
Trace.mark('imports.bbctrl.end')
ctrl = None
@@ -167,19 +176,28 @@ def parse_args():
def run():
global ctrl
Trace.mark('run.enter')
args = parse_args()
Trace.mark('args.parsed')
# Set signal handler
signal.signal(signal.SIGTERM, on_exit)
# Create ioloop
ioloop = tornado.ioloop.IOLoop.current()
Trace.mark('ioloop.created')
# Set ObjGraph signal handler
if args.debug: Debugger(ioloop, args.debug)
# Start server
with Trace.span('web.init'):
web = Web(args, ioloop)
Trace.mark('listen', port=args.port, addr=args.addr)
# Notify systemd we are ready (no-op when not under systemd).
Trace.sd_notify('READY=1\nSTATUS=listening on %s:%d\n' %
(args.addr, args.port))
try:
ioloop.start()

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -2,5 +2,5 @@
font-family: 'Audiowide';
font-style: normal;
font-weight: 400;
src: local('Audiowide'), local('Audiowide-Regular'), url(http://fonts.gstatic.com/s/audiowide/v4/8XtYtNKEyyZh481XVWfVOqCWcynf_cDxXwCLxiixG1c.ttf) format('truetype');
src: local('Audiowide'), local('Audiowide-Regular'), url(https://fonts.gstatic.com/s/audiowide/v4/8XtYtNKEyyZh481XVWfVOqCWcynf_cDxXwCLxiixG1c.ttf) format('truetype');
}

9
src/static/css/fa6.min.css vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,35 +1,37 @@
// V09 redesign: the legacy side menu was removed. Keep this file
// shipped in case anything still references it, but no-op the click
// handler that used to wire up the burger menu so it does not throw
// "Cannot set properties of null" on the Settings tab.
(function (window, document) {
var menuLink = document.getElementById("menuLink");
if (!menuLink) {
return;
}
var layout = document.getElementById('layout'),
menu = document.getElementById('menu'),
menuLink = document.getElementById('menuLink');
var layout = document.getElementById("layout");
var menu = document.getElementById("menu");
function toggleClass(element, className) {
var classes = element.className.split(/\s+/),
length = classes.length,
i = 0;
for(; i < length; i++) {
if (!element) return;
var classes = element.className.split(/\s+/);
var i;
for (i = 0; i < classes.length; i++) {
if (classes[i] === className) {
classes.splice(i, 1);
break;
}
}
// The className is not found
if (length === classes.length) {
if (i === classes.length) {
classes.push(className);
}
element.className = classes.join(' ');
element.className = classes.join(" ");
}
menuLink.onclick = function (e) {
var active = 'active';
var active = "active";
e.preventDefault();
toggleClass(layout, active);
toggleClass(menu, active);
toggleClass(menuLink, active);
};
}(this, this.document));

File diff suppressed because it is too large Load Diff

View File

@@ -18,8 +18,8 @@
<h1>Settings</h1>
<div class="pure-form pure-form-aligned">
<h2>User Interface</h2>
<fieldset>
<h2 id="sec-display" data-sec="display">User Interface</h2>
<fieldset data-sec="display">
<div class="pure-control-group">
<label for="screen-rotation" />
<Button
@@ -45,8 +45,8 @@
</div> -->
</fieldset>
<h2>Units</h2>
<fieldset>
<h2 id="sec-units" data-sec="display">Units</h2>
<fieldset data-sec="display">
<ConfigTemplatedInput key={`settings.units`} />
<div class="tip">
Note, units sets both the machine default units and the units used in motor configuration. GCode program-start,
@@ -54,13 +54,13 @@
</div>
</fieldset>
<h2>Easy Adapter</h2>
<fieldset>
<h2 id="sec-easy-adapter" data-sec="display">Easy Adapter</h2>
<fieldset data-sec="display">
<ConfigTemplatedInput key={`settings.easy-adapter`} />
</fieldset>
<h2>Probing</h2>
<fieldset>
<h2 id="sec-probing" data-sec="probing">Probing</h2>
<fieldset data-sec="probing">
<ConfigTemplatedInput key={`settings.probing-prompts`} />
<div class="tip">
Onefinity highly recommends that you keep the safety prompts
@@ -87,15 +87,15 @@
{/each}
</fieldset>
<fieldset>
<h2>GCode</h2>
<fieldset data-sec="gcode">
<h2 id="sec-gcode" data-sec="gcode">GCode</h2>
{#each Object.keys(configTemplate.gcode) as key}
<ConfigTemplatedInput key={`gcode.${key}`} />
{/each}
</fieldset>
<h2>Path Accuracy</h2>
<fieldset>
<h2 id="sec-path-accuracy" data-sec="gcode">Path Accuracy</h2>
<fieldset data-sec="gcode">
<ConfigTemplatedInput key={`settings.max-deviation`} />
<div class="tip">
@@ -118,8 +118,8 @@
</div>
</fieldset>
<h2>Cornering Speed (Advanced)</h2>
<fieldset>
<h2 id="sec-cornering" data-sec="gcode">Cornering Speed (Advanced)</h2>
<fieldset data-sec="gcode">
<ConfigTemplatedInput key={`settings.junction-accel`} />
<div class="tip">
Junction acceleration limits the cornering speed the planner

View File

@@ -51,7 +51,7 @@
>
<div slot="trailingIcon">
{#if valid}
<Icon class="fa fa-check-circle-o" style="color: green;" />
<Icon class="fa fa-circle-check" style="color: green;" />
{/if}
</div>
<HelperText persistent slot="helper">{helperText}</HelperText>