Compare commits
11 Commits
master
...
c7cf9483b3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7cf9483b3 | ||
| 54a15f9d12 | |||
| 704bc8d35c | |||
| 4d2d5fd88c | |||
| eab204b7be | |||
| e3c059eb9b | |||
| 7306464440 | |||
| 1625b768d8 | |||
| 5be7515a92 | |||
| 7d0755c55b | |||
| 7f8fd23615 |
0
.devcontainer/install_tools.sh
Normal file → Executable file
0
.devcontainer/install_tools.sh
Normal file → Executable file
14
.gitignore
vendored
14
.gitignore
vendored
@@ -27,3 +27,17 @@ __pycache__
|
|||||||
*.elf
|
*.elf
|
||||||
*.hex
|
*.hex
|
||||||
.idea/deployment.xml
|
.idea/deployment.xml
|
||||||
|
|
||||||
|
# Demo mode artifacts
|
||||||
|
bbctrl.log*
|
||||||
|
hooks.json
|
||||||
|
/*/bbctrl.log*
|
||||||
|
src/py/camotics/__init__.py
|
||||||
|
src/py/camotics/gplan.so
|
||||||
|
src/avr/emu/bbemu
|
||||||
|
src/avr/emu/build/
|
||||||
|
|
||||||
|
.pi/pi-python35.tar.gz
|
||||||
|
src/py/camotics/gplan.so.built
|
||||||
|
tmp/
|
||||||
|
backup/
|
||||||
|
|||||||
249
.pi/BUILD.md
Normal file
249
.pi/BUILD.md
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
# Onefinity CNC Firmware — Build, Flash & Backup
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Build gplan.so (first time ~25min, then ~1sec)
|
||||||
|
.pi/build-gplan.sh
|
||||||
|
|
||||||
|
# 2. Build firmware package (frontend + AVR + Python, ~1min)
|
||||||
|
docker run --rm -v "$(pwd):/workspace" -w /workspace onefin-dev \
|
||||||
|
bash -c 'make all && python3 ./setup.py sdist'
|
||||||
|
|
||||||
|
# 3. Flash to controller
|
||||||
|
curl -X PUT -F "firmware=@dist/bbctrl-1.6.7.tar.bz2" \
|
||||||
|
-F "password=onefinity" http://10.1.10.55/api/firmware/update
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
The controller is a **Raspberry Pi 2/3** (armv7l, Raspbian Stretch, Python 3.5)
|
||||||
|
connected to an **ATxmega192a3u** AVR over serial. The Pi runs a Tornado web
|
||||||
|
server that serves the UI, parses G-code, and plans motion. The AVR executes
|
||||||
|
realtime step/direction pulses.
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser ←WebSocket→ Pi (Tornado/Python) → GCode Planner → Serial → AVR → Stepper drivers
|
||||||
|
```
|
||||||
|
|
||||||
|
The firmware package (`bbctrl-X.Y.Z.tar.bz2`) contains:
|
||||||
|
|
||||||
|
| Component | Source | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| Python backend | `src/py/bbctrl/` | Tornado web server, state machine, planner bridge |
|
||||||
|
| Web frontend | `build/http/` | Pug + Stylus + Svelte → static HTML/JS/CSS |
|
||||||
|
| AVR firmware | `src/avr/bbctrl-avr-firmware.hex` | Realtime motion controller |
|
||||||
|
| gplan.so | `src/py/camotics/gplan.so` | CAMotics G-code planner (native ARM C++ extension) |
|
||||||
|
| Install scripts | `scripts/install.sh` | AVR flash, Python install, service restart |
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- **Docker** with QEMU binfmt support (default on Docker Desktop for Mac)
|
||||||
|
- **devcontainer image**: `docker build -t onefin-dev -f .devcontainer/Dockerfile .devcontainer/`
|
||||||
|
- **SSH access**: `ssh bbmc@10.1.10.55` (password: `onefinity`)
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
### Step 1: gplan.so
|
||||||
|
|
||||||
|
`gplan.so` is the CAMotics G-code planner — a C++ Python extension that must
|
||||||
|
be a **32-bit ARM binary linked against Python 3.5**. It cannot be built in the
|
||||||
|
devcontainer (wrong arch + wrong Python + wrong glibc).
|
||||||
|
|
||||||
|
**Build from source** (recommended):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.pi/build-gplan.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This uses a Raspbian Stretch Docker image (`balenalib/raspberry-pi-debian:stretch`)
|
||||||
|
with the Pi's exact toolchain: GCC 6.3, Python 3.5, GLIBC 2.24. The image is
|
||||||
|
built once (~25min under QEMU), then cached — subsequent runs take ~1sec.
|
||||||
|
|
||||||
|
The image pre-compiles two C++ dependencies:
|
||||||
|
- [cbang](https://github.com/CauldronDevelopmentLLC/cbang) @ `18f1e96` — C++ utility library
|
||||||
|
- [camotics](https://github.com/CauldronDevelopmentLLC/camotics) @ `ec876c8` — G-code planner with S-curve motion planning
|
||||||
|
|
||||||
|
To force a full rebuild: `docker rmi onefin-gplan && .pi/build-gplan.sh`
|
||||||
|
|
||||||
|
**Alternatives** (if Docker build fails):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From official release
|
||||||
|
curl -sL https://github.com/OneFinityCNC/onefinity-firmware/releases/download/v1.6.6/onefinity-1.6.6.tar.bz2 \
|
||||||
|
| tar xjf - --include='*/gplan.so' -O > src/py/camotics/gplan.so
|
||||||
|
|
||||||
|
# From the running Pi
|
||||||
|
scp bbmc@10.1.10.55:/usr/local/lib/python3.5/dist-packages/bbctrl-*.egg/camotics/gplan.so src/py/camotics/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Verify** — must show `ELF 32-bit ... ARM ... libpython3.5m`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
file src/py/camotics/gplan.so
|
||||||
|
readelf -d src/py/camotics/gplan.so | grep python
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Firmware package
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm -v "$(pwd):/workspace" -w /workspace onefin-dev \
|
||||||
|
bash -c 'make all && python3 ./setup.py sdist'
|
||||||
|
```
|
||||||
|
|
||||||
|
This builds inside the devcontainer (arm64 Bullseye — fine for frontend/AVR/Python):
|
||||||
|
|
||||||
|
| Component | Tool | Time |
|
||||||
|
|---|---|---|
|
||||||
|
| Node modules | `npm install` | ~30sec |
|
||||||
|
| Svelte components | `vite build` | ~5sec |
|
||||||
|
| Pug/Stylus → HTML | `pug-cli`, `stylus` | ~2sec |
|
||||||
|
| AVR firmware | `avr-g++` (ATxmega192a3u) | ~10sec |
|
||||||
|
| Boot/Power/Jig MCUs | `avr-gcc` | ~5sec |
|
||||||
|
| Python sdist | `setup.py sdist` | ~2sec |
|
||||||
|
|
||||||
|
Produces: `dist/bbctrl-X.Y.Z.tar.bz2` (~3-4MB)
|
||||||
|
|
||||||
|
### bbserial.ko (kernel module — usually skip)
|
||||||
|
|
||||||
|
Cross-compiles against the Pi's kernel headers (4.9.59-v7+). The Pi already has
|
||||||
|
a working `bbserial.ko` installed. `install.sh` skips it gracefully if missing.
|
||||||
|
|
||||||
|
## Flashing
|
||||||
|
|
||||||
|
### Via web API (machine running)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X PUT -F "firmware=@dist/bbctrl-1.6.7.tar.bz2" \
|
||||||
|
-F "password=onefinity" http://10.1.10.55/api/firmware/update
|
||||||
|
```
|
||||||
|
|
||||||
|
Or: `make update HOST=10.1.10.55`
|
||||||
|
|
||||||
|
### Via SSH (web UI down or crash-looping)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scp dist/bbctrl-1.6.7.tar.bz2 bbmc@10.1.10.55:/tmp/
|
||||||
|
ssh bbmc@10.1.10.55 'echo onefinity | sudo -S bash -c "
|
||||||
|
systemctl stop bbctrl
|
||||||
|
mkdir -p /var/lib/bbctrl/firmware
|
||||||
|
cp /tmp/bbctrl-1.6.7.tar.bz2 /var/lib/bbctrl/firmware/update.tar.bz2
|
||||||
|
/usr/local/bin/update-bbctrl
|
||||||
|
"'
|
||||||
|
```
|
||||||
|
|
||||||
|
### What happens during flash
|
||||||
|
|
||||||
|
1. `update-bbctrl` stops bbctrl, extracts tarball to `/tmp/update/`
|
||||||
|
2. `install.sh` runs:
|
||||||
|
- Flashes AVR via `scripts/avr109-flash.py` (serial bootloader protocol)
|
||||||
|
- `setup.py install --force` — installs Python package + frontend + gplan.so
|
||||||
|
- Restarts `bbctrl` systemd service
|
||||||
|
- May reboot if boot config or kernel module changed
|
||||||
|
|
||||||
|
### Recovery from bad flash
|
||||||
|
|
||||||
|
SSH still works even when bbctrl is crash-looping:
|
||||||
|
1. Check the error: `sudo python3 /usr/local/bin/bbctrl 2>&1 | head -20`
|
||||||
|
2. Common cause: wrong gplan.so architecture → replace with correct one (see above)
|
||||||
|
3. Nuclear option: restore SD card from backup
|
||||||
|
|
||||||
|
## Running Locally (demo mode)
|
||||||
|
|
||||||
|
Full stack in Docker with AVR emulator — no Pi needed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build bbemu (AVR emulator, native in devcontainer)
|
||||||
|
docker run --rm -v "$(pwd):/workspace" -w /workspace/src/avr/emu onefin-dev make
|
||||||
|
|
||||||
|
# Run demo (needs arm64 gplan.so for the container, not armhf)
|
||||||
|
docker run --rm -d --name onefin-demo \
|
||||||
|
-v "$(pwd):/workspace" -w /workspace -p 8765:80 \
|
||||||
|
onefin-dev bash -c '
|
||||||
|
pip3 install -q tornado sockjs-tornado pyserial watchdog
|
||||||
|
cp src/avr/emu/bbemu /usr/local/bin/
|
||||||
|
pip3 install -q -e .
|
||||||
|
exec bbctrl --demo --port 80 --addr 0.0.0.0 --disable-camera
|
||||||
|
'
|
||||||
|
```
|
||||||
|
|
||||||
|
Open http://localhost:8765 — full UI with emulated controller.
|
||||||
|
|
||||||
|
Note: demo mode needs a **container-arch** gplan.so (arm64 + Python 3.9), not the
|
||||||
|
Pi one. The devcontainer build from the Makefile's `gplan` target produces this,
|
||||||
|
or it can be built following the procedure in `scripts/gplan-build.sh`.
|
||||||
|
|
||||||
|
## SD Card Backup & Restore
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backup (~50 min, streams raw dd from Pi, compresses locally)
|
||||||
|
./backup/onefinity-backup.sh backup
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
./backup/onefinity-backup.sh verify backup/onefinity-20260430.img.gz
|
||||||
|
|
||||||
|
# Restore to local SD card
|
||||||
|
./backup/onefinity-backup.sh restore backup/onefinity-20260430.img.gz /dev/diskN
|
||||||
|
|
||||||
|
# Restore back to Pi over SSH
|
||||||
|
./backup/onefinity-backup.sh restore backup/onefinity-20260430.img.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
Environment: `ONEFINITY_HOST` (default 10.1.10.55), `ONEFINITY_USER` (bbmc),
|
||||||
|
`ONEFINITY_PASS` (onefinity).
|
||||||
|
|
||||||
|
## Python 3.5 Compatibility
|
||||||
|
|
||||||
|
The Pi runs Python 3.5.3. Avoid features added in later versions:
|
||||||
|
|
||||||
|
| Avoid | Use instead |
|
||||||
|
|---|---|
|
||||||
|
| `f"hello {name}"` | `"hello %s" % name` or `"hello {}".format(name)` |
|
||||||
|
| `subprocess.run(capture_output=True)` | `stdout=subprocess.PIPE, stderr=subprocess.PIPE` |
|
||||||
|
| `subprocess.run(text=True)` | `.stdout.decode('utf-8')` |
|
||||||
|
| `dataclasses` | plain classes with `__init__` |
|
||||||
|
| `:=` walrus operator | separate assignment |
|
||||||
|
| `asyncio.run()` | `loop.run_until_complete()` |
|
||||||
|
| `dict[str, int]` | `Dict[str, int]` from `typing` |
|
||||||
|
|
||||||
|
## Pi Details
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| Host | `10.1.10.55` |
|
||||||
|
| SSH | `bbmc` / `onefinity` |
|
||||||
|
| OS | Raspbian Stretch (Debian 9) |
|
||||||
|
| Kernel | 4.9.59-v7+ |
|
||||||
|
| Python | 3.5.3 |
|
||||||
|
| GCC | 6.3.0 |
|
||||||
|
| GLIBC | 2.24 (max symbol: GLIBC_2.24) |
|
||||||
|
| GLIBCXX | 3.4.22 |
|
||||||
|
| Arch | armv7l (32-bit ARM, EABI5) |
|
||||||
|
| SD card | 30GB (~2.8GB used) |
|
||||||
|
| Service | `systemctl {start,stop,restart,status} bbctrl` |
|
||||||
|
| Log | `/var/log/bbctrl.log` or `journalctl -u bbctrl` |
|
||||||
|
| Config | `/var/lib/bbctrl/config.json` |
|
||||||
|
| Uploads | `/var/lib/bbctrl/upload/` |
|
||||||
|
| Web root | `/usr/local/lib/python3.5/dist-packages/bbctrl-*.egg/bbctrl/http/` |
|
||||||
|
| AVR serial | `/dev/ttyAMA0` at 230400 baud |
|
||||||
|
|
||||||
|
## Why Not Build gplan.so on Bullseye?
|
||||||
|
|
||||||
|
Documented for reference — we tried two approaches that don't work:
|
||||||
|
|
||||||
|
**1. devcontainer (arm64 Bullseye):** Wrong ELF class (64-bit vs 32-bit) and wrong
|
||||||
|
Python (3.9 vs 3.5). Cross-compiling with `CXX=arm-linux-gnueabihf-g++` fails
|
||||||
|
because SCons ignores CC/CXX environment variables.
|
||||||
|
|
||||||
|
**2. Bullseye armhf container:** Correct architecture, but GCC 10 / glibc 2.31
|
||||||
|
produce objects requiring GLIBC_2.29+ and GLIBCXX_3.4.26+ symbols. The Pi's
|
||||||
|
Stretch only has GLIBC_2.24 / GLIBCXX_3.4.22. Even `-static-libstdc++
|
||||||
|
-static-libgcc` doesn't help — glibc symbols leak through the object files.
|
||||||
|
Relinking against Python 3.5m works but the GLIBC mismatch remains.
|
||||||
|
|
||||||
|
**3. Plain `debian:stretch` armhf:** The archived repos have broken package
|
||||||
|
metadata — `apt-get install build-essential` fails with unresolvable version
|
||||||
|
conflicts.
|
||||||
|
|
||||||
|
**Solution:** `balenalib/raspberry-pi-debian:stretch` with `legacy.raspbian.org`
|
||||||
|
repos. See `.pi/Dockerfile.gplan`.
|
||||||
48
.pi/Dockerfile.gplan
Normal file
48
.pi/Dockerfile.gplan
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# Raspbian Stretch armhf build environment for gplan.so
|
||||||
|
# Matches the Pi exactly: GCC 6.3, Python 3.5, GLIBC 2.24
|
||||||
|
#
|
||||||
|
# Build image: docker build -t onefin-gplan -f .pi/Dockerfile.gplan .pi/
|
||||||
|
# Build gplan: .pi/build-gplan.sh
|
||||||
|
FROM balenalib/raspberry-pi-debian:stretch
|
||||||
|
|
||||||
|
# Fix repos to use archived Raspbian mirrors
|
||||||
|
RUN echo "deb http://legacy.raspbian.org/raspbian/ stretch main contrib non-free rpi" \
|
||||||
|
> /etc/apt/sources.list && \
|
||||||
|
rm -f /etc/apt/sources.list.d/*.list
|
||||||
|
|
||||||
|
RUN apt-get -o Acquire::Check-Valid-Until=false \
|
||||||
|
-o Acquire::AllowInsecureRepositories=true update && \
|
||||||
|
apt-get -o Acquire::Check-Valid-Until=false --allow-unauthenticated \
|
||||||
|
install -y --no-install-recommends \
|
||||||
|
build-essential python3-dev scons git ca-certificates \
|
||||||
|
libssl-dev libexpat1-dev libbz2-dev liblz4-dev zlib1g-dev perl file && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Clone and build cbang
|
||||||
|
RUN mkdir -p /opt/cbang && cd /opt/cbang && git init -q && \
|
||||||
|
git remote add origin https://github.com/CauldronDevelopmentLLC/cbang && \
|
||||||
|
git fetch --depth 1 -q origin 18f1e963107ef26abe750c023355a5c40dd07853 && \
|
||||||
|
git reset --hard FETCH_HEAD -q && \
|
||||||
|
scons -j2 disable_local="re2 libevent" && \
|
||||||
|
rm -rf .git build/dep
|
||||||
|
|
||||||
|
# Clone, patch, and build camotics/gplan
|
||||||
|
RUN mkdir -p /opt/camotics && cd /opt/camotics && git init -q && \
|
||||||
|
git remote add origin https://github.com/CauldronDevelopmentLLC/camotics && \
|
||||||
|
git fetch --depth 1 -q origin ec876c80d20fc19837133087cef0c447df5a939d && \
|
||||||
|
git reset --hard FETCH_HEAD -q && \
|
||||||
|
mkdir -p build && touch build/version.txt && \
|
||||||
|
P="src/gcode/plan" && \
|
||||||
|
for F in LineCommand.cpp LinePlanner.cpp; do \
|
||||||
|
for V in maxVel maxJerk maxAccel; do \
|
||||||
|
perl -i -0pe "s/(fabs\((config\.$V\[axis\]) \/ unit\[axis\]\));/std::min(\2, \1);/gm" $P/$F; \
|
||||||
|
done; \
|
||||||
|
done && \
|
||||||
|
rm -rf .git
|
||||||
|
|
||||||
|
ENV CBANG_HOME=/opt/cbang
|
||||||
|
|
||||||
|
# Pre-compile everything including gplan.so
|
||||||
|
RUN cd /opt/camotics && scons -j2 gplan.so with_gui=0 with_tpl=0
|
||||||
|
|
||||||
|
WORKDIR /opt/camotics
|
||||||
30
.pi/build-gplan.sh
Executable file
30
.pi/build-gplan.sh
Executable file
@@ -0,0 +1,30 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Build gplan.so for the Onefinity Pi (armv7l, Python 3.5, GCC 6.3)
|
||||||
|
#
|
||||||
|
# Uses a Raspbian Stretch Docker image that exactly matches the Pi's
|
||||||
|
# toolchain. No cross-compile, no relink hacks, no GLIBC mismatches.
|
||||||
|
#
|
||||||
|
# First run: ~30min (builds Docker image with cbang + camotics)
|
||||||
|
# After that: ~1sec (copies pre-built gplan.so from image)
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||||
|
IMAGE="onefin-gplan"
|
||||||
|
OUTPUT="$PROJECT_DIR/src/py/camotics/gplan.so"
|
||||||
|
|
||||||
|
# Build image if needed (one-time)
|
||||||
|
if ! docker image inspect "$IMAGE" >/dev/null 2>&1; then
|
||||||
|
echo "Building $IMAGE Docker image (one-time, ~30min under QEMU)..."
|
||||||
|
docker build -t "$IMAGE" -f "$SCRIPT_DIR/Dockerfile.gplan" "$SCRIPT_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Copy gplan.so out of the image
|
||||||
|
echo "Extracting gplan.so..."
|
||||||
|
docker run --rm -v "$PROJECT_DIR:/workspace" "$IMAGE" \
|
||||||
|
bash -c 'cp /opt/camotics/gplan.so /workspace/src/py/camotics/gplan.so && \
|
||||||
|
file /workspace/src/py/camotics/gplan.so && \
|
||||||
|
readelf -d /workspace/src/py/camotics/gplan.so | grep -E "NEEDED|python"'
|
||||||
|
|
||||||
|
echo "✓ Built: $OUTPUT"
|
||||||
144
docs/AUX_W_AXIS.md
Normal file
144
docs/AUX_W_AXIS.md
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
# W axis (auxcnc) integration
|
||||||
|
|
||||||
|
This adds a virtual `W` axis to the bbctrl controller, driven by the
|
||||||
|
auxcnc ESP32 over USB serial (`/dev/ttyUSB0`). The ESP owns step-pulse
|
||||||
|
generation, real-time limit-switch monitoring, and the homing dance.
|
||||||
|
The Pi owns units (mm), soft limits, sequencing inside G-code jobs, and
|
||||||
|
a small REST API for jogging / homing from the UI.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
The bbctrl planner (gplan) only understands `xyzabc`, so adding a true
|
||||||
|
7th axis would require rebuilding gplan + the AVR firmware. We avoid
|
||||||
|
that by treating W as a synchronous out-of-band axis: W moves run
|
||||||
|
*between* G-code blocks, not blended with XYZ.
|
||||||
|
|
||||||
|
Pipeline:
|
||||||
|
|
||||||
|
1. User uploads a G-code file containing `W` words.
|
||||||
|
2. `FileHandler` runs `AuxPreprocessor` on the upload, rewriting W
|
||||||
|
tokens in place into `(MSG,HOOK:aux:<mm>)` etc. The original line
|
||||||
|
minus the W word continues to drive XYZ.
|
||||||
|
3. The planner sees only XYZ + message comments. When it reaches a
|
||||||
|
message line, the message goes through `state.add_message` which
|
||||||
|
`Hooks._on_state_change` watches for the `HOOK:` prefix.
|
||||||
|
4. `Hooks._fire('custom', ...)` finds the registered internal handler
|
||||||
|
for the event name (`aux`, `aux_rel`, `aux_home`, `aux_setzero`).
|
||||||
|
5. The handler runs in a hook thread, gating `Mach.unpause` until done.
|
||||||
|
While the handler is busy the machine is in HOLDING - no XYZ motion
|
||||||
|
can resume until W finishes.
|
||||||
|
6. The handler talks to the ESP over `/dev/ttyUSB0` via `AuxAxis`,
|
||||||
|
blocking on a deterministic reply token (`[step] done`, `[home]
|
||||||
|
done`, etc).
|
||||||
|
|
||||||
|
MDI commands containing `W` words are rewritten the same way at the
|
||||||
|
`Mach.mdi()` boundary so manual jog and macros work too.
|
||||||
|
|
||||||
|
## G-code surface
|
||||||
|
|
||||||
|
```gcode
|
||||||
|
G21 G90
|
||||||
|
G28 W0 ; home W axis
|
||||||
|
G1 W25 F300 ; move W to 25 mm absolute
|
||||||
|
G1 X100 W12.5 ; mixed: W moves first, then XYZ (configurable)
|
||||||
|
G91
|
||||||
|
G1 W-2.5 ; relative W move
|
||||||
|
G90
|
||||||
|
G92 W0 ; set current W as zero (G92-style)
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- `G28` / `G28.2` with W only -> homing hook; the bare `G28` is NOT
|
||||||
|
emitted to gplan (that would mean home-all).
|
||||||
|
- `G28.2 X0 Y0 W0` -> emit hook, then keep `G28.2 X0 Y0` for XY homing.
|
||||||
|
- A line with both W and XYZ axis words is split into two sequential
|
||||||
|
blocks. Default order: W first, then XYZ. Toggle via the
|
||||||
|
`w_first` constructor arg.
|
||||||
|
- Lines inside parens or after `;` are passed through verbatim.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Per-controller config lives at `<ctrl_path>/aux.json` (created on first
|
||||||
|
save via the API). Keys:
|
||||||
|
|
||||||
|
| Key | Default | Notes |
|
||||||
|
|------------------------|----------------|------------------------------------|
|
||||||
|
| `enabled` | `false` | Master switch |
|
||||||
|
| `port` | `/dev/ttyUSB0` | Serial device |
|
||||||
|
| `baud` | `115200` | |
|
||||||
|
| `steps_per_mm` | `80.0` | Logical steps per mm |
|
||||||
|
| `dir_sign` | `1` | +1 or -1: maps logical+ to motor+ |
|
||||||
|
| `min_w`, `max_w` | `0`, `100` | Soft limits in mm |
|
||||||
|
| `home_dir` | `'-'` | Direction toward limit switch |
|
||||||
|
| `home_position_mm` | `0.0` | mm value assigned at home |
|
||||||
|
| `home_fast_sps` | `4000` | Fast seek rate |
|
||||||
|
| `home_slow_sps` | `400` | Slow re-seek rate |
|
||||||
|
| `home_backoff_steps` | `200` | Backoff after touching limit |
|
||||||
|
| `home_maxtravel_steps` | `200000` | Hard cap on phase 1 seek |
|
||||||
|
| `step_max_sps` | `4000` | Cruise rate for STEPS |
|
||||||
|
| `step_accel_sps2` | `16000` | Trapezoidal ramp accel |
|
||||||
|
| `step_start_sps` | `200` | Ramp floor |
|
||||||
|
| `limit_low` | `true` | Switch active low (closed = LOW) |
|
||||||
|
|
||||||
|
Most of these are pushed to the ESP via `HOMECFG` on connect and
|
||||||
|
persisted there in NVS.
|
||||||
|
|
||||||
|
## REST API
|
||||||
|
|
||||||
|
| Verb | Path | Body | Effect |
|
||||||
|
|------|----------------------------|-----------------------|------------------------|
|
||||||
|
| GET | `/api/aux/config` | - | Current config |
|
||||||
|
| PUT | `/api/aux/config/save` | `{key: val, ...}` | Save and re-push |
|
||||||
|
| GET | `/api/aux/status` | - | `{enabled, present, homed, pos_mm}` |
|
||||||
|
| PUT | `/api/aux/home` | - | Run home cycle (blocks)|
|
||||||
|
| PUT | `/api/aux/abort` | - | Cancel running motion |
|
||||||
|
| PUT | `/api/aux/jog` | `{mm: 1.5}` or `{steps: 200}` | Relative move |
|
||||||
|
| PUT | `/api/aux/move` | `{mm: 12.5}` | Absolute move (mm) |
|
||||||
|
| PUT | `/api/aux/set-zero` | `{mm: 0}` | Set current pos to mm |
|
||||||
|
|
||||||
|
Steps-mode jog ignores soft limits (use it to inch the axis to the
|
||||||
|
limit switch when the axis isn't homed yet).
|
||||||
|
|
||||||
|
## State surface (UI)
|
||||||
|
|
||||||
|
These are pushed via `state.set` and visible in the websocket stream:
|
||||||
|
|
||||||
|
- `aux_enabled` - bool, axis is configured + enabled
|
||||||
|
- `aux_present` - bool, ESP responding on serial
|
||||||
|
- `aux_homed` - bool, has been homed since last ESP reset
|
||||||
|
- `aux_pos` - float, current W in mm (4 decimals)
|
||||||
|
|
||||||
|
## Edge cases
|
||||||
|
|
||||||
|
- **ESP reboots mid-job**: `[boot] auxcnc v=N` banner -> `aux_homed`
|
||||||
|
cleared, message added: "W axis controller restarted - re-home
|
||||||
|
before use". Subsequent W moves still run; if you want a hard fail
|
||||||
|
instead, that's a one-line change in `_require_present`.
|
||||||
|
- **Limit switch closed at boot of HOME**: `[home] failed
|
||||||
|
reason=already_at_limit` -> hook raises -> Mach surfaces error.
|
||||||
|
- **Pause mid-W-move**: the hook is blocking, so feed-hold takes
|
||||||
|
effect *after* the W move completes. For an immediate stop hit
|
||||||
|
estop; the Hooks listener will call `aux.abort()` which sends
|
||||||
|
`ABORT\n` to the ESP and the step-pulse loop exits.
|
||||||
|
- **Connection loss**: if `/dev/ttyUSB0` can't be opened at startup,
|
||||||
|
`aux_present=False` and any G-code with W will fail-fast at the
|
||||||
|
hook handler with "Aux axis not connected".
|
||||||
|
- **No home enforcement**: per design, manual jogs and W moves are
|
||||||
|
allowed even without a successful home. Soft limits still apply
|
||||||
|
unless you use the raw step jog endpoint.
|
||||||
|
|
||||||
|
## Files added/changed
|
||||||
|
|
||||||
|
- `src/py/bbctrl/AuxAxis.py` (new): serial worker + RPC layer
|
||||||
|
- `src/py/bbctrl/AuxPreprocessor.py` (new): G-code rewriter
|
||||||
|
- `src/py/bbctrl/Hooks.py`: register_internal(), fix the messages
|
||||||
|
listener so `(MSG,HOOK:...)` actually fires
|
||||||
|
- `src/py/bbctrl/Ctrl.py`: instantiate AuxAxis, register hooks
|
||||||
|
- `src/py/bbctrl/Mach.py`: rewrite MDI commands containing W
|
||||||
|
- `src/py/bbctrl/FileHandler.py`: rewrite uploads in place
|
||||||
|
- `src/py/bbctrl/Web.py`: REST endpoints
|
||||||
|
- `src/py/bbctrl/__init__.py`: export AuxAxis
|
||||||
|
- `auxcnc/src/main.cpp`: new commands HOME, HOMECFG, WPOS, HOMED?,
|
||||||
|
LIMIT?, ABORT-able STEPS with limit-aware abort, trapezoidal ramps,
|
||||||
|
NVS-persisted config, `[boot]` banner, deterministic reply tokens
|
||||||
477
src/py/bbctrl/AuxAxis.py
Normal file
477
src/py/bbctrl/AuxAxis.py
Normal file
@@ -0,0 +1,477 @@
|
|||||||
|
################################################################################
|
||||||
|
#
|
||||||
|
# AuxAxis - W-axis serial driver for the auxcnc ESP32 controller
|
||||||
|
#
|
||||||
|
# Owns /dev/ttyUSB0 (or whatever serial.port is configured to). Provides
|
||||||
|
# blocking RPCs for use from a hook thread. Maintains:
|
||||||
|
#
|
||||||
|
# - aux_present : True if serial is open and we've seen a boot banner
|
||||||
|
# - aux_homed : True if we've successfully run HOME since last reset
|
||||||
|
# - aux_pos : current logical position in mm (from ESP step counter
|
||||||
|
# * (1 / steps_per_mm * dir_sign))
|
||||||
|
#
|
||||||
|
# Real-time decisions (limit switch monitoring, step pulse generation) live
|
||||||
|
# on the ESP. The host is responsible for units, soft limits, and tracking
|
||||||
|
# whether we've ever boot-cycled the ESP since last home.
|
||||||
|
#
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import threading
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
try:
|
||||||
|
import serial
|
||||||
|
except ImportError:
|
||||||
|
serial = None
|
||||||
|
|
||||||
|
|
||||||
|
# Default config; overridden by ./aux.json or ctrl.config.
|
||||||
|
DEFAULTS = {
|
||||||
|
'enabled': False,
|
||||||
|
'port': '/dev/ttyUSB0',
|
||||||
|
'baud': 115200,
|
||||||
|
'steps_per_mm': 80.0, # logical steps per mm of W travel
|
||||||
|
'dir_sign': 1, # +1 or -1: maps logical+ to motor+ steps
|
||||||
|
'min_w': 0.0, # soft limit min (mm)
|
||||||
|
'max_w': 100.0, # soft limit max (mm)
|
||||||
|
'max_feed_mm_min': 600.0, # informational; rate caps are on the ESP
|
||||||
|
'home_dir': '-', # which direction is "toward limit" (host's view)
|
||||||
|
'home_position_mm': 0.0, # mm value to assign at home
|
||||||
|
# ESP-side homing rates (steps/sec). Pushed via HOMECFG on connect.
|
||||||
|
'home_fast_sps': 4000,
|
||||||
|
'home_slow_sps': 400,
|
||||||
|
'home_backoff_steps': 200,
|
||||||
|
'home_maxtravel_steps': 200000,
|
||||||
|
'step_max_sps': 4000,
|
||||||
|
'step_accel_sps2': 16000,
|
||||||
|
'step_start_sps': 200,
|
||||||
|
'limit_low': True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class AuxAxisError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class AuxAxis(object):
|
||||||
|
def __init__(self, ctrl):
|
||||||
|
self.ctrl = ctrl
|
||||||
|
self.log = ctrl.log.get('AuxAxis')
|
||||||
|
|
||||||
|
self._cfg = dict(DEFAULTS)
|
||||||
|
self._load_config()
|
||||||
|
|
||||||
|
self._sp = None
|
||||||
|
self._sp_lock = threading.Lock() # serial write/RPC serialization
|
||||||
|
self._rx_lock = threading.Lock() # read-line buffer access
|
||||||
|
self._reader_thread = None
|
||||||
|
self._stop = threading.Event()
|
||||||
|
|
||||||
|
# Pending replies waiting for a [topic] line. Single-slot since we
|
||||||
|
# serialize RPCs via _sp_lock.
|
||||||
|
self._pending_topics = []
|
||||||
|
self._pending_replies = []
|
||||||
|
self._pending_cv = threading.Condition()
|
||||||
|
|
||||||
|
# Async lines that aren't replies (e.g. logs) are simply logged.
|
||||||
|
self._present = False
|
||||||
|
self._homed = False
|
||||||
|
self._pos_steps = 0 # ESP step counter mirror
|
||||||
|
|
||||||
|
# Publish initial state
|
||||||
|
self._publish_state()
|
||||||
|
|
||||||
|
if not self._cfg['enabled']:
|
||||||
|
self.log.info('Aux axis disabled in config')
|
||||||
|
return
|
||||||
|
|
||||||
|
if serial is None:
|
||||||
|
self.log.error('pyserial not available; aux axis disabled')
|
||||||
|
return
|
||||||
|
|
||||||
|
self._open()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ config
|
||||||
|
|
||||||
|
def _config_path(self):
|
||||||
|
return self.ctrl.get_path(filename='aux.json')
|
||||||
|
|
||||||
|
def _load_config(self):
|
||||||
|
path = self._config_path()
|
||||||
|
if os.path.exists(path):
|
||||||
|
try:
|
||||||
|
with open(path) as f:
|
||||||
|
user = json.load(f)
|
||||||
|
# Be permissive; ignore unknown keys.
|
||||||
|
for k, v in user.items():
|
||||||
|
if k in self._cfg:
|
||||||
|
self._cfg[k] = v
|
||||||
|
self.log.info('Loaded aux config from %s' % path)
|
||||||
|
except Exception:
|
||||||
|
self.log.error('Failed to read aux.json: %s'
|
||||||
|
% traceback.format_exc())
|
||||||
|
|
||||||
|
def save_config(self, cfg):
|
||||||
|
merged = dict(DEFAULTS)
|
||||||
|
for k, v in cfg.items():
|
||||||
|
if k in DEFAULTS:
|
||||||
|
merged[k] = v
|
||||||
|
path = self._config_path()
|
||||||
|
with open(path, 'w') as f:
|
||||||
|
json.dump(merged, f, indent=2)
|
||||||
|
self._cfg = merged
|
||||||
|
self.log.info('Saved aux config')
|
||||||
|
# Push the relevant pieces to the ESP if connected.
|
||||||
|
if self._present:
|
||||||
|
try:
|
||||||
|
self._push_homecfg()
|
||||||
|
except Exception as e:
|
||||||
|
self.log.warning('Could not push HOMECFG after save: %s' % e)
|
||||||
|
|
||||||
|
def get_config(self):
|
||||||
|
return dict(self._cfg)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ public
|
||||||
|
|
||||||
|
@property
|
||||||
|
def enabled(self):
|
||||||
|
return bool(self._cfg.get('enabled', False))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def present(self):
|
||||||
|
return self._present
|
||||||
|
|
||||||
|
@property
|
||||||
|
def homed(self):
|
||||||
|
return self._homed
|
||||||
|
|
||||||
|
@property
|
||||||
|
def position_mm(self):
|
||||||
|
return self._steps_to_mm(self._pos_steps)
|
||||||
|
|
||||||
|
def home(self):
|
||||||
|
"""Run the homing cycle on the ESP. Blocks until done. Raises on
|
||||||
|
failure. Updates aux_homed and aux_pos."""
|
||||||
|
self._require_present()
|
||||||
|
line = self._rpc('HOME', topic='home', timeout=120.0)
|
||||||
|
# line is the body after '[home] '
|
||||||
|
if line.startswith('done'):
|
||||||
|
# ESP set its counter to home_zero; mirror that.
|
||||||
|
new_pos = self._parse_kv_int(line, 'pos', 0)
|
||||||
|
self._pos_steps = new_pos
|
||||||
|
self._homed = True
|
||||||
|
# Translate to home_position_mm. Conceptually the host says
|
||||||
|
# "after homing, W is here in mm". We achieve that by setting
|
||||||
|
# the ESP counter (WPOS) so the mm conversion works out.
|
||||||
|
target_pos = self._mm_to_steps(self._cfg['home_position_mm'])
|
||||||
|
if target_pos != new_pos:
|
||||||
|
self._rpc('WPOS %d' % target_pos, topic='ok', timeout=2.0)
|
||||||
|
self._pos_steps = target_pos
|
||||||
|
self._publish_state()
|
||||||
|
return
|
||||||
|
# failure
|
||||||
|
reason = line.split('reason=', 1)[1] if 'reason=' in line else line
|
||||||
|
raise AuxAxisError('Homing failed: %s' % reason)
|
||||||
|
|
||||||
|
def move_abs_mm(self, target_mm):
|
||||||
|
"""Move to absolute logical W position (mm). Blocks until done."""
|
||||||
|
self._require_present()
|
||||||
|
self._check_limits(target_mm)
|
||||||
|
target_steps = self._mm_to_steps(target_mm)
|
||||||
|
delta = target_steps - self._pos_steps
|
||||||
|
if delta == 0:
|
||||||
|
return
|
||||||
|
self._do_steps(delta)
|
||||||
|
|
||||||
|
def move_rel_mm(self, delta_mm):
|
||||||
|
"""Move by delta mm relative to current position. Blocks until done."""
|
||||||
|
self._require_present()
|
||||||
|
target_mm = self.position_mm + delta_mm
|
||||||
|
self._check_limits(target_mm)
|
||||||
|
target_steps = self._mm_to_steps(target_mm)
|
||||||
|
delta = target_steps - self._pos_steps
|
||||||
|
if delta == 0:
|
||||||
|
return
|
||||||
|
self._do_steps(delta)
|
||||||
|
|
||||||
|
def set_position_mm(self, mm):
|
||||||
|
"""Set current W to <mm> without moving (G92-style for W)."""
|
||||||
|
self._require_present()
|
||||||
|
steps = self._mm_to_steps(mm)
|
||||||
|
self._rpc('WPOS %d' % steps, topic='ok', timeout=2.0)
|
||||||
|
self._pos_steps = steps
|
||||||
|
# WPOS clears homed on the ESP; mirror it.
|
||||||
|
self._homed = False
|
||||||
|
self._publish_state()
|
||||||
|
|
||||||
|
def jog_steps(self, steps):
|
||||||
|
"""Raw step move bypassing mm conversion and soft limits.
|
||||||
|
Used by manual jog UI when axis isn't homed yet."""
|
||||||
|
self._require_present()
|
||||||
|
if steps == 0:
|
||||||
|
return
|
||||||
|
self._do_steps(int(steps), ignore_limits=True)
|
||||||
|
|
||||||
|
def abort(self):
|
||||||
|
"""Cancel any running ESP motion immediately."""
|
||||||
|
if not self._present:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
# Don't take the RPC lock; ABORT must be able to interrupt.
|
||||||
|
self._send_raw('ABORT')
|
||||||
|
except Exception as e:
|
||||||
|
self.log.warning('ABORT send failed: %s' % e)
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self._stop.set()
|
||||||
|
try:
|
||||||
|
if self._sp is not None:
|
||||||
|
self._sp.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ guts
|
||||||
|
|
||||||
|
def _require_present(self):
|
||||||
|
if not self.enabled:
|
||||||
|
raise AuxAxisError('Aux axis disabled')
|
||||||
|
if not self._present:
|
||||||
|
raise AuxAxisError('Aux axis not connected')
|
||||||
|
|
||||||
|
def _check_limits(self, target_mm):
|
||||||
|
lo = float(self._cfg['min_w'])
|
||||||
|
hi = float(self._cfg['max_w'])
|
||||||
|
if hi <= lo:
|
||||||
|
return # no limits
|
||||||
|
if target_mm < lo - 1e-6 or target_mm > hi + 1e-6:
|
||||||
|
raise AuxAxisError(
|
||||||
|
'W=%.3f out of soft limits [%.3f, %.3f]' % (target_mm, lo, hi))
|
||||||
|
|
||||||
|
def _mm_to_steps(self, mm):
|
||||||
|
spm = float(self._cfg['steps_per_mm'])
|
||||||
|
sign = 1 if int(self._cfg.get('dir_sign', 1)) >= 0 else -1
|
||||||
|
return int(round(mm * spm * sign))
|
||||||
|
|
||||||
|
def _steps_to_mm(self, steps):
|
||||||
|
spm = float(self._cfg['steps_per_mm']) or 1.0
|
||||||
|
sign = 1 if int(self._cfg.get('dir_sign', 1)) >= 0 else -1
|
||||||
|
return (steps / spm) * sign
|
||||||
|
|
||||||
|
def _do_steps(self, signed_count, ignore_limits=False):
|
||||||
|
max_rate = int(self._cfg['step_max_sps'])
|
||||||
|
accel = int(self._cfg['step_accel_sps2'])
|
||||||
|
safe_flag = 0 if ignore_limits else 1
|
||||||
|
cmd = 'STEPS %d maxrate=%d accel=%d safe=%d' % (
|
||||||
|
signed_count, max_rate, accel, safe_flag)
|
||||||
|
line = self._rpc(cmd, topic='step', timeout=300.0)
|
||||||
|
# line: "done count=N pos=P limit=L" or "aborted count=N pos=P [reason=...]"
|
||||||
|
if line.startswith('done'):
|
||||||
|
self._pos_steps = self._parse_kv_int(line, 'pos', self._pos_steps)
|
||||||
|
self._publish_state()
|
||||||
|
return
|
||||||
|
# aborted
|
||||||
|
self._pos_steps = self._parse_kv_int(line, 'pos', self._pos_steps)
|
||||||
|
self._publish_state()
|
||||||
|
reason = self._parse_kv_str(line, 'reason')
|
||||||
|
if reason == 'limit':
|
||||||
|
self._homed = False
|
||||||
|
raise AuxAxisError('W move aborted by limit switch')
|
||||||
|
raise AuxAxisError('W move aborted: %s' % line)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ serial I/O
|
||||||
|
|
||||||
|
def _open(self):
|
||||||
|
port = self._cfg['port']
|
||||||
|
baud = int(self._cfg['baud'])
|
||||||
|
try:
|
||||||
|
self._sp = serial.Serial(port, baud, timeout=0.2)
|
||||||
|
except Exception as e:
|
||||||
|
self.log.error('Could not open %s: %s' % (port, e))
|
||||||
|
self._sp = None
|
||||||
|
return
|
||||||
|
|
||||||
|
self.log.info('Opened %s @ %d' % (port, baud))
|
||||||
|
self._reader_thread = threading.Thread(
|
||||||
|
target=self._reader_loop, name='AuxAxis-rx', daemon=True)
|
||||||
|
self._reader_thread.start()
|
||||||
|
|
||||||
|
# Give the ESP a moment to settle, then push HOMECFG and query state.
|
||||||
|
# This runs in a background thread to avoid blocking startup.
|
||||||
|
threading.Thread(target=self._on_connect, daemon=True).start()
|
||||||
|
|
||||||
|
def _on_connect(self):
|
||||||
|
time.sleep(0.5)
|
||||||
|
try:
|
||||||
|
self._push_homecfg()
|
||||||
|
self._refresh_state()
|
||||||
|
except Exception as e:
|
||||||
|
self.log.warning('Aux post-connect setup failed: %s' % e)
|
||||||
|
|
||||||
|
def _push_homecfg(self):
|
||||||
|
c = self._cfg
|
||||||
|
cmd = ('HOMECFG dir=%s fast=%d slow=%d backoff=%d maxtravel=%d '
|
||||||
|
'zero=0 accel=%d step_max=%d step_start=%d limit_low=%d') % (
|
||||||
|
c['home_dir'],
|
||||||
|
int(c['home_fast_sps']),
|
||||||
|
int(c['home_slow_sps']),
|
||||||
|
int(c['home_backoff_steps']),
|
||||||
|
int(c['home_maxtravel_steps']),
|
||||||
|
int(c['step_accel_sps2']),
|
||||||
|
int(c['step_max_sps']),
|
||||||
|
int(c['step_start_sps']),
|
||||||
|
1 if c['limit_low'] else 0,
|
||||||
|
)
|
||||||
|
self._rpc(cmd, topic='homecfg', timeout=3.0)
|
||||||
|
|
||||||
|
def _refresh_state(self):
|
||||||
|
try:
|
||||||
|
r = self._rpc('WPOS?', topic='wpos', timeout=2.0)
|
||||||
|
self._pos_steps = int(r.strip())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
r = self._rpc('HOMED?', topic='homed', timeout=2.0)
|
||||||
|
self._homed = (r.strip() == '1')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._publish_state()
|
||||||
|
|
||||||
|
def _reader_loop(self):
|
||||||
|
buf = b''
|
||||||
|
while not self._stop.is_set():
|
||||||
|
sp = self._sp
|
||||||
|
if sp is None:
|
||||||
|
time.sleep(0.5)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
chunk = sp.read(256)
|
||||||
|
except Exception as e:
|
||||||
|
self.log.warning('Aux serial read error: %s' % e)
|
||||||
|
time.sleep(0.5)
|
||||||
|
continue
|
||||||
|
if not chunk:
|
||||||
|
continue
|
||||||
|
buf += chunk
|
||||||
|
while True:
|
||||||
|
nl = buf.find(b'\n')
|
||||||
|
if nl < 0:
|
||||||
|
break
|
||||||
|
line = buf[:nl].rstrip(b'\r').decode('utf-8', errors='replace')
|
||||||
|
buf = buf[nl+1:]
|
||||||
|
self._on_line(line)
|
||||||
|
|
||||||
|
def _on_line(self, line):
|
||||||
|
if not line:
|
||||||
|
return
|
||||||
|
# Boot banner -> reset homed flag.
|
||||||
|
if line.startswith('[boot]'):
|
||||||
|
self.log.warning('Aux ESP booted: %s' % line)
|
||||||
|
self._homed = False
|
||||||
|
self._present = True
|
||||||
|
self._publish_state()
|
||||||
|
self.ctrl.state.add_message(
|
||||||
|
'W axis controller restarted - re-home before use')
|
||||||
|
return
|
||||||
|
|
||||||
|
# Topic dispatch: "[topic] body..."
|
||||||
|
if line.startswith('[') and ']' in line:
|
||||||
|
rb = line.index(']')
|
||||||
|
topic = line[1:rb]
|
||||||
|
body = line[rb+1:].lstrip()
|
||||||
|
# Mark present on first known topic.
|
||||||
|
if not self._present:
|
||||||
|
self._present = True
|
||||||
|
self._publish_state()
|
||||||
|
# Match against the head of the pending queue.
|
||||||
|
with self._pending_cv:
|
||||||
|
if (self._pending_topics
|
||||||
|
and topic in self._pending_topics[0]):
|
||||||
|
# Pop and deliver
|
||||||
|
self._pending_topics.pop(0)
|
||||||
|
self._pending_replies.append(body)
|
||||||
|
self._pending_cv.notify_all()
|
||||||
|
return
|
||||||
|
# Async informational line; just log.
|
||||||
|
self.log.info('aux: %s' % line)
|
||||||
|
else:
|
||||||
|
self.log.info('aux: %s' % line)
|
||||||
|
|
||||||
|
def _send_raw(self, cmd):
|
||||||
|
sp = self._sp
|
||||||
|
if sp is None:
|
||||||
|
raise AuxAxisError('Serial not open')
|
||||||
|
if not cmd.endswith('\n'):
|
||||||
|
cmd = cmd + '\n'
|
||||||
|
sp.write(cmd.encode('utf-8'))
|
||||||
|
sp.flush()
|
||||||
|
|
||||||
|
def _rpc(self, cmd, topic, timeout=5.0):
|
||||||
|
"""Send `cmd`, wait for a reply line whose topic is in `topic`.
|
||||||
|
topic may be a single string or a tuple/list of acceptable topics
|
||||||
|
(e.g. ('home', 'err'))."""
|
||||||
|
if isinstance(topic, str):
|
||||||
|
topics = (topic, 'err')
|
||||||
|
else:
|
||||||
|
topics = tuple(topic) + ('err',)
|
||||||
|
|
||||||
|
with self._sp_lock:
|
||||||
|
with self._pending_cv:
|
||||||
|
self._pending_topics.append(topics)
|
||||||
|
self._pending_replies = [] # reset
|
||||||
|
self.log.info('aux >> %s' % cmd.strip())
|
||||||
|
self._send_raw(cmd)
|
||||||
|
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
with self._pending_cv:
|
||||||
|
while not self._pending_replies:
|
||||||
|
remaining = deadline - time.time()
|
||||||
|
if remaining <= 0:
|
||||||
|
# Drop the pending slot so we don't capture a
|
||||||
|
# late reply meant for the next caller.
|
||||||
|
try:
|
||||||
|
self._pending_topics.remove(topics)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
raise AuxAxisError(
|
||||||
|
'Timeout waiting for %s reply to "%s"'
|
||||||
|
% (topics, cmd.strip()))
|
||||||
|
self._pending_cv.wait(timeout=remaining)
|
||||||
|
reply = self._pending_replies.pop(0)
|
||||||
|
self.log.info('aux << %s' % reply)
|
||||||
|
if reply.startswith('err') or reply.startswith('error'):
|
||||||
|
raise AuxAxisError('ESP error: %s' % reply)
|
||||||
|
return reply
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_kv_int(line, key, default=0):
|
||||||
|
# Parse "key=N" (signed integer) out of a line.
|
||||||
|
for tok in line.split():
|
||||||
|
if tok.startswith(key + '='):
|
||||||
|
try:
|
||||||
|
return int(tok.split('=', 1)[1])
|
||||||
|
except ValueError:
|
||||||
|
return default
|
||||||
|
return default
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_kv_str(line, key, default=''):
|
||||||
|
for tok in line.split():
|
||||||
|
if tok.startswith(key + '='):
|
||||||
|
return tok.split('=', 1)[1]
|
||||||
|
return default
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ state push
|
||||||
|
|
||||||
|
def _publish_state(self):
|
||||||
|
st = self.ctrl.state
|
||||||
|
try:
|
||||||
|
st.set('aux_present', bool(self._present))
|
||||||
|
st.set('aux_homed', bool(self._homed))
|
||||||
|
st.set('aux_pos', round(self.position_mm, 4))
|
||||||
|
st.set('aux_enabled', bool(self.enabled))
|
||||||
|
except Exception:
|
||||||
|
# During very early startup, state may not be ready.
|
||||||
|
pass
|
||||||
237
src/py/bbctrl/AuxPreprocessor.py
Normal file
237
src/py/bbctrl/AuxPreprocessor.py
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
################################################################################
|
||||||
|
#
|
||||||
|
# AuxPreprocessor - rewrite W-axis G-code into hook calls
|
||||||
|
#
|
||||||
|
# The bbctrl planner only understands xyzabc. We expose a virtual W axis by
|
||||||
|
# rewriting the G-code file *before* it is fed to gplan, replacing each W
|
||||||
|
# move with a (MSG,HOOK:aux:...) line that the host's hook handler turns
|
||||||
|
# into a STEPS or HOME command on the ESP.
|
||||||
|
#
|
||||||
|
# Rules:
|
||||||
|
# - Mixed-axis blocks (W together with XYZABC) are split into two
|
||||||
|
# sequential blocks. By default the W move runs first; configurable.
|
||||||
|
# - G90/G91/G20/G21 modal state is tracked so we can convert relative-W
|
||||||
|
# and inch-W into the absolute mm value the hook handler expects.
|
||||||
|
# - G28 W0 / G28.2 W0 -> HOOK:aux_home
|
||||||
|
# - G92 Wx -> HOOK:aux_setzero:<mm>
|
||||||
|
# - G53 + W not specially handled (W only knows machine coords)
|
||||||
|
# - Lines inside parentheses or after `;` are passed through.
|
||||||
|
#
|
||||||
|
# The preprocessor is intentionally conservative: anything it doesn't
|
||||||
|
# understand involving W is left alone with a warning, so motion lands in
|
||||||
|
# gplan which will complain loudly rather than silently misbehaving.
|
||||||
|
#
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
|
||||||
|
# Match a word like "W12.5" or "W-3" or "w0". Also matches inside the same
|
||||||
|
# line as XYZ words. We pull W out specifically.
|
||||||
|
_W_TOKEN_RE = re.compile(r'(?<![A-Za-z_0-9])[Ww]\s*([-+]?\d*\.?\d+)')
|
||||||
|
|
||||||
|
# Detect any axis-bearing word (so we can tell mixed-axis lines apart).
|
||||||
|
_AXIS_WORD_RE = re.compile(r'(?<![A-Za-z_0-9])[XYZABCxyzabc]\s*[-+]?\d*\.?\d+')
|
||||||
|
|
||||||
|
# Strip line comments so we don't get fooled by "(W axis)".
|
||||||
|
_PAREN_COMMENT_RE = re.compile(r'\([^)]*\)')
|
||||||
|
|
||||||
|
# Modal G-code groups we care about.
|
||||||
|
_MODAL_RE = re.compile(r'(?<![A-Za-z_0-9])[Gg]\s*0*(\d+(?:\.\d+)?)')
|
||||||
|
|
||||||
|
|
||||||
|
class AuxPreprocessorError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class AuxPreprocessor(object):
|
||||||
|
def __init__(self, log=None, w_first=True):
|
||||||
|
self.log = log
|
||||||
|
# If True, on a mixed-axis line (e.g. G1 X10 W5), emit the W move
|
||||||
|
# first, then the XYZ move. Set False to invert.
|
||||||
|
self.w_first = w_first
|
||||||
|
|
||||||
|
def _info(self, msg):
|
||||||
|
if self.log:
|
||||||
|
self.log.info(msg)
|
||||||
|
|
||||||
|
def _warn(self, msg):
|
||||||
|
if self.log:
|
||||||
|
self.log.warning(msg)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ scan
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def file_uses_w(path):
|
||||||
|
"""Quick check: does this file contain any W-axis word? Used to skip
|
||||||
|
preprocessing entirely for files that don't care about W."""
|
||||||
|
try:
|
||||||
|
with open(path, 'r', encoding='utf-8', errors='replace') as f:
|
||||||
|
for line in f:
|
||||||
|
code = _PAREN_COMMENT_RE.sub('', line)
|
||||||
|
code = code.split(';', 1)[0]
|
||||||
|
if _W_TOKEN_RE.search(code):
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ core
|
||||||
|
|
||||||
|
def _strip_w(self, line):
|
||||||
|
"""Return (line_without_w, w_value_str_or_None). Only first W kept."""
|
||||||
|
m = _W_TOKEN_RE.search(line)
|
||||||
|
if m is None:
|
||||||
|
return line, None
|
||||||
|
# Remove just the matched W<num> token, preserving surrounding spaces.
|
||||||
|
rewritten = line[:m.start()] + line[m.end():]
|
||||||
|
return rewritten, m.group(1)
|
||||||
|
|
||||||
|
def _has_other_axis(self, code_no_w):
|
||||||
|
return _AXIS_WORD_RE.search(code_no_w) is not None
|
||||||
|
|
||||||
|
def _detect_modals(self, code, modal):
|
||||||
|
"""Update modal dict in-place from G-codes on this line."""
|
||||||
|
for mm in _MODAL_RE.finditer(code):
|
||||||
|
try:
|
||||||
|
g = float(mm.group(1))
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if g == 90: modal['abs'] = True
|
||||||
|
elif g == 91: modal['abs'] = False
|
||||||
|
elif g == 20: modal['inch'] = True
|
||||||
|
elif g == 21: modal['inch'] = False
|
||||||
|
# G28 / G28.2 / G92 are detected case-by-case below.
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_g28_like(code):
|
||||||
|
# Match G28 or G28.2 (homing).
|
||||||
|
return bool(re.search(r'(?<![A-Za-z_0-9])[Gg]\s*0*28(?:\.2)?(?![\w.])', code))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_g92(code):
|
||||||
|
return bool(re.search(r'(?<![A-Za-z_0-9])[Gg]\s*0*92(?![\w.])', code))
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ run
|
||||||
|
|
||||||
|
def process(self, src_path, dst_path):
|
||||||
|
"""Read src_path, write rewritten G-code to dst_path. Returns True
|
||||||
|
if any rewrite happened."""
|
||||||
|
modal = {'abs': True, 'inch': False} # G90 G21 are common defaults
|
||||||
|
rewrote_any = False
|
||||||
|
|
||||||
|
with open(src_path, 'r', encoding='utf-8', errors='replace') as fin, \
|
||||||
|
open(dst_path, 'w', encoding='utf-8') as fout:
|
||||||
|
for raw in fin:
|
||||||
|
line = raw.rstrip('\n')
|
||||||
|
|
||||||
|
# Comment-only or blank lines pass through verbatim.
|
||||||
|
code = _PAREN_COMMENT_RE.sub('', line)
|
||||||
|
code = code.split(';', 1)[0]
|
||||||
|
if not code.strip():
|
||||||
|
fout.write(raw)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Update modal from G-codes on this line first (so absolute
|
||||||
|
# vs incremental matches what the planner sees for XYZ).
|
||||||
|
self._detect_modals(code, modal)
|
||||||
|
|
||||||
|
if not _W_TOKEN_RE.search(code):
|
||||||
|
fout.write(raw)
|
||||||
|
continue
|
||||||
|
|
||||||
|
rewrote_any = True
|
||||||
|
|
||||||
|
# G28[.2] W... -> aux_home (W value is ignored except as
|
||||||
|
# a flag that W is being homed).
|
||||||
|
if self._is_g28_like(code):
|
||||||
|
code_no_w, _ = self._strip_w(line)
|
||||||
|
fout.write('(MSG,HOOK:aux_home:)\n')
|
||||||
|
# Only keep the residual line if other axes were also
|
||||||
|
# present (e.g. G28.2 X0 Y0 W0 still homes X+Y). A bare
|
||||||
|
# "G28" without axis args means "home all" in gcode
|
||||||
|
# which we explicitly DON'T want to trigger from a
|
||||||
|
# W-only home command.
|
||||||
|
rest_code = _PAREN_COMMENT_RE.sub('', code_no_w)
|
||||||
|
rest_code = rest_code.split(';', 1)[0]
|
||||||
|
if self._has_other_axis(rest_code):
|
||||||
|
fout.write(code_no_w + '\n')
|
||||||
|
continue
|
||||||
|
|
||||||
|
# G92 W... -> set W zero (or other value) without motion.
|
||||||
|
if self._is_g92(code):
|
||||||
|
line_no_w, w_val = self._strip_w(line)
|
||||||
|
target_mm = self._w_to_mm(w_val, modal, set_pos=True)
|
||||||
|
fout.write('(MSG,HOOK:aux_setzero:%g)\n' % target_mm)
|
||||||
|
rest_code = _PAREN_COMMENT_RE.sub('', line_no_w)
|
||||||
|
rest_code = rest_code.split(';', 1)[0]
|
||||||
|
if self._has_other_axis(rest_code):
|
||||||
|
fout.write(line_no_w + '\n')
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Plain motion: G0/G1 etc with W word.
|
||||||
|
line_no_w, w_val = self._strip_w(line)
|
||||||
|
target_mm = self._w_to_mm(w_val, modal, set_pos=False)
|
||||||
|
# Distinguish absolute vs relative: encode both, the hook
|
||||||
|
# handler will pick the right operation.
|
||||||
|
if modal['abs']:
|
||||||
|
hook_line = '(MSG,HOOK:aux:%g)' % target_mm
|
||||||
|
else:
|
||||||
|
hook_line = '(MSG,HOOK:aux_rel:%g)' % target_mm
|
||||||
|
|
||||||
|
rest_code = _PAREN_COMMENT_RE.sub('', line_no_w)
|
||||||
|
rest_code = rest_code.split(';', 1)[0]
|
||||||
|
has_xyz = self._has_other_axis(rest_code)
|
||||||
|
|
||||||
|
if not has_xyz:
|
||||||
|
# Pure W move; drop the (now-empty) original line.
|
||||||
|
fout.write(hook_line + '\n')
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Mixed-axis: split. Default order is W first.
|
||||||
|
if self.w_first:
|
||||||
|
fout.write(hook_line + '\n')
|
||||||
|
fout.write(line_no_w + '\n')
|
||||||
|
else:
|
||||||
|
fout.write(line_no_w + '\n')
|
||||||
|
fout.write(hook_line + '\n')
|
||||||
|
|
||||||
|
return rewrote_any
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ unit conv
|
||||||
|
|
||||||
|
def _w_to_mm(self, w_str, modal, set_pos):
|
||||||
|
try:
|
||||||
|
v = float(w_str)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise AuxPreprocessorError('Invalid W value: %r' % w_str)
|
||||||
|
if modal['inch']:
|
||||||
|
v *= 25.4
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
def preprocess_file(src_path, log=None, w_first=True):
|
||||||
|
"""Convenience: rewrite src_path in place if it uses W.
|
||||||
|
Returns True if the file was rewritten."""
|
||||||
|
if not AuxPreprocessor.file_uses_w(src_path):
|
||||||
|
return False
|
||||||
|
pre = AuxPreprocessor(log=log, w_first=w_first)
|
||||||
|
fd, tmp = tempfile.mkstemp(prefix='auxpre_', suffix='.nc',
|
||||||
|
dir=os.path.dirname(src_path) or None)
|
||||||
|
os.close(fd)
|
||||||
|
try:
|
||||||
|
rewrote = pre.process(src_path, tmp)
|
||||||
|
if rewrote:
|
||||||
|
shutil.move(tmp, src_path)
|
||||||
|
return True
|
||||||
|
os.unlink(tmp)
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
os.unlink(tmp)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
@@ -468,8 +468,7 @@ class VideoHandler(web.RequestHandler):
|
|||||||
self.camera = app.camera
|
self.camera = app.camera
|
||||||
|
|
||||||
|
|
||||||
@web.asynchronous
|
async def get(self):
|
||||||
def get(self):
|
|
||||||
self.request.connection.stream.max_write_buffer_size = 10000
|
self.request.connection.stream.max_write_buffer_size = 10000
|
||||||
|
|
||||||
self.set_header('Cache-Control', 'no-store, no-cache, must-revalidate, '
|
self.set_header('Cache-Control', 'no-store, no-cache, must-revalidate, '
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ class Ctrl(object):
|
|||||||
self.preplanner = bbctrl.Preplanner(self)
|
self.preplanner = bbctrl.Preplanner(self)
|
||||||
if not args.demo: self.jog = bbctrl.Jog(self)
|
if not args.demo: self.jog = bbctrl.Jog(self)
|
||||||
self.pwr = bbctrl.Pwr(self)
|
self.pwr = bbctrl.Pwr(self)
|
||||||
|
self.hooks = bbctrl.Hooks(self)
|
||||||
|
self.aux = bbctrl.AuxAxis(self)
|
||||||
|
self._register_aux_hooks()
|
||||||
|
|
||||||
self.mach.connect()
|
self.mach.connect()
|
||||||
|
|
||||||
@@ -109,8 +112,46 @@ class Ctrl(object):
|
|||||||
self.preplanner.start()
|
self.preplanner.start()
|
||||||
|
|
||||||
|
|
||||||
|
def _register_aux_hooks(self):
|
||||||
|
"""Wire up the auxcnc HOOK: events to AuxAxis methods."""
|
||||||
|
log = self.log.get('AuxAxis')
|
||||||
|
|
||||||
|
def _hook_move(ctx):
|
||||||
|
data = (ctx.get('data') or '').strip()
|
||||||
|
if not data:
|
||||||
|
raise Exception('aux hook missing target')
|
||||||
|
self.aux.move_abs_mm(float(data))
|
||||||
|
|
||||||
|
def _hook_move_rel(ctx):
|
||||||
|
data = (ctx.get('data') or '').strip()
|
||||||
|
if not data:
|
||||||
|
raise Exception('aux_rel hook missing delta')
|
||||||
|
self.aux.move_rel_mm(float(data))
|
||||||
|
|
||||||
|
def _hook_home(ctx):
|
||||||
|
self.aux.home()
|
||||||
|
|
||||||
|
def _hook_setzero(ctx):
|
||||||
|
data = (ctx.get('data') or '').strip()
|
||||||
|
mm = float(data) if data else 0.0
|
||||||
|
self.aux.set_position_mm(mm)
|
||||||
|
|
||||||
|
self.hooks.register_internal('aux', _hook_move,
|
||||||
|
block_unpause=True, auto_resume=True)
|
||||||
|
self.hooks.register_internal('aux_rel', _hook_move_rel,
|
||||||
|
block_unpause=True, auto_resume=True)
|
||||||
|
self.hooks.register_internal('aux_home', _hook_home,
|
||||||
|
block_unpause=True, auto_resume=True,
|
||||||
|
timeout=180)
|
||||||
|
self.hooks.register_internal('aux_setzero', _hook_setzero,
|
||||||
|
block_unpause=True, auto_resume=True)
|
||||||
|
log.info('Aux hooks registered')
|
||||||
|
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
self.log.get('Ctrl').info('Closing %s' % self.id)
|
self.log.get('Ctrl').info('Closing %s' % self.id)
|
||||||
self.ioloop.close()
|
self.ioloop.close()
|
||||||
self.avr.close()
|
self.avr.close()
|
||||||
self.mach.planner.close()
|
self.mach.planner.close()
|
||||||
|
try: self.aux.close()
|
||||||
|
except Exception: pass
|
||||||
|
|||||||
@@ -99,6 +99,19 @@ class FileHandler(bbctrl.APIHandler):
|
|||||||
|
|
||||||
del (self.uploadFile)
|
del (self.uploadFile)
|
||||||
|
|
||||||
|
# If the uploaded G-code uses the virtual W axis, rewrite the
|
||||||
|
# file in place so the planner sees (MSG,HOOK:aux:*) lines
|
||||||
|
# instead of W tokens it can't parse.
|
||||||
|
try:
|
||||||
|
from bbctrl.AuxPreprocessor import preprocess_file
|
||||||
|
log = self.get_log('AuxPreprocessor')
|
||||||
|
if preprocess_file(filename.decode('utf8'), log=log):
|
||||||
|
log.info('Rewrote W-axis tokens in %s' %
|
||||||
|
self.uploadFilename)
|
||||||
|
except Exception:
|
||||||
|
self.get_log('AuxPreprocessor').exception(
|
||||||
|
'W-axis preprocess failed; uploading unchanged')
|
||||||
|
|
||||||
self.get_ctrl().preplanner.invalidate(self.uploadFilename)
|
self.get_ctrl().preplanner.invalidate(self.uploadFilename)
|
||||||
self.get_ctrl().state.add_file(self.uploadFilename)
|
self.get_ctrl().state.add_file(self.uploadFilename)
|
||||||
|
|
||||||
|
|||||||
429
src/py/bbctrl/Hooks.py
Normal file
429
src/py/bbctrl/Hooks.py
Normal file
@@ -0,0 +1,429 @@
|
|||||||
|
################################################################################
|
||||||
|
#
|
||||||
|
# Hooks - External event triggers during G-code execution
|
||||||
|
#
|
||||||
|
# Integrates with the controller's pause/unpause cycle to run external
|
||||||
|
# actions (webhooks, scripts) at specific points during G-code execution.
|
||||||
|
#
|
||||||
|
# ## How tool-change hooks work (the important one):
|
||||||
|
#
|
||||||
|
# G-code: T5 M6
|
||||||
|
#
|
||||||
|
# 1. Planner replaces M6 with tool-change override G-code (configurable).
|
||||||
|
# Default: "M0 M6 (MSG, Change tool)"
|
||||||
|
#
|
||||||
|
# 2. Planner emits: set(tool,5), pause(program), message("Change tool")
|
||||||
|
# These are sent to the AVR as serial commands.
|
||||||
|
#
|
||||||
|
# 3. AVR finishes current move, enters HOLDING state.
|
||||||
|
# Reports back: xx=HOLDING, pr="Program pause"
|
||||||
|
#
|
||||||
|
# 4. Pi: Mach._update() sees HOLDING, flushes CommandQueue.
|
||||||
|
# CommandQueue executes callbacks: state.set('tool', 5) fires.
|
||||||
|
#
|
||||||
|
# 5. Hooks._on_state_change() sees tool changed.
|
||||||
|
# Sets self._hook_busy = True, runs the hook in a thread.
|
||||||
|
# While _hook_busy, Mach.unpause() is blocked via can_unpause().
|
||||||
|
#
|
||||||
|
# 6. Machine sits in HOLDING. UI shows "Change tool" message.
|
||||||
|
# User cannot resume yet (unpause is gated).
|
||||||
|
#
|
||||||
|
# 7. Hook thread finishes (toolchanger done). Sets _hook_busy = False.
|
||||||
|
# If auto_resume is set, calls unpause automatically.
|
||||||
|
# Otherwise user clicks Continue in UI.
|
||||||
|
#
|
||||||
|
# 8. Mach.unpause() → planner.restart() → AVR UNPAUSE → motion resumes.
|
||||||
|
#
|
||||||
|
# ## Configuration (hooks.json):
|
||||||
|
#
|
||||||
|
# {
|
||||||
|
# "tool-change": {
|
||||||
|
# "type": "webhook",
|
||||||
|
# "url": "http://toolchanger.local/api/change",
|
||||||
|
# "method": "POST",
|
||||||
|
# "timeout": 120,
|
||||||
|
# "block_unpause": true,
|
||||||
|
# "auto_resume": true
|
||||||
|
# },
|
||||||
|
# "program-start": {
|
||||||
|
# "type": "script",
|
||||||
|
# "command": "/usr/local/bin/dust-collector on",
|
||||||
|
# "block_unpause": false
|
||||||
|
# }
|
||||||
|
# }
|
||||||
|
#
|
||||||
|
# block_unpause: if true, unpause is blocked until hook completes
|
||||||
|
# auto_resume: if true AND block_unpause, auto-unpause after hook done
|
||||||
|
#
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import traceback
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
from urllib.error import URLError
|
||||||
|
|
||||||
|
|
||||||
|
# Events that can be hooked
|
||||||
|
HOOK_EVENTS = [
|
||||||
|
'tool-change', # M6 - tool change requested
|
||||||
|
'program-start', # Program begins running
|
||||||
|
'program-end', # M2/M30 - program ends
|
||||||
|
'pause', # M0/M1 - program pause
|
||||||
|
'estop', # Emergency stop triggered
|
||||||
|
'homing-start', # Homing cycle begins
|
||||||
|
'homing-end', # Homing cycle completes
|
||||||
|
'custom', # Triggered by (MSG,HOOK:name:data) comments
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class Hooks:
|
||||||
|
def __init__(self, ctrl):
|
||||||
|
self.ctrl = ctrl
|
||||||
|
self.log = ctrl.log.get('Hooks')
|
||||||
|
self.hooks = {}
|
||||||
|
|
||||||
|
# Hook execution state
|
||||||
|
self._hook_busy = False # True while a blocking hook runs
|
||||||
|
self._hook_busy_event = None # Which event is blocking
|
||||||
|
self._hook_error = None # Error from last hook, if any
|
||||||
|
self._hook_thread = None
|
||||||
|
|
||||||
|
# In-process hook handlers registered by Python modules. Keyed by
|
||||||
|
# event name (matches what the G-code emits as HOOK:<event>).
|
||||||
|
# Take precedence over hooks.json entries with the same name.
|
||||||
|
self._internal = {}
|
||||||
|
|
||||||
|
# Track state for edge detection — must be set before add_listener
|
||||||
|
# because add_listener fires immediately with current state
|
||||||
|
self._last_cycle = ctrl.state.get('cycle', 'idle')
|
||||||
|
self._last_state = ctrl.state.get('xx', '')
|
||||||
|
self._last_tool = ctrl.state.get('tool', 0)
|
||||||
|
self._last_pause_reason = ctrl.state.get('pr', '')
|
||||||
|
# Highest message id we've already inspected for HOOK: lines.
|
||||||
|
self._last_msg_id = -1
|
||||||
|
self._initialized = False
|
||||||
|
|
||||||
|
self._load_config()
|
||||||
|
|
||||||
|
# Listen for state changes
|
||||||
|
ctrl.state.add_listener(self._on_state_change)
|
||||||
|
self._initialized = True
|
||||||
|
|
||||||
|
# -- Config management --
|
||||||
|
|
||||||
|
def _get_config_path(self):
|
||||||
|
return self.ctrl.get_path(filename='hooks.json')
|
||||||
|
|
||||||
|
def _load_config(self):
|
||||||
|
path = self._get_config_path()
|
||||||
|
if os.path.exists(path):
|
||||||
|
try:
|
||||||
|
with open(path) as f:
|
||||||
|
self.hooks = json.load(f)
|
||||||
|
self.log.info('Loaded %d hook(s) from %s' %
|
||||||
|
(len(self.hooks), path))
|
||||||
|
except Exception:
|
||||||
|
self.log.error('Failed to load hooks.json: %s' %
|
||||||
|
traceback.format_exc())
|
||||||
|
else:
|
||||||
|
self.log.info('No hooks.json found, hooks disabled')
|
||||||
|
|
||||||
|
def save_config(self, config):
|
||||||
|
"""Save hook configuration (called from API)."""
|
||||||
|
path = self._get_config_path()
|
||||||
|
with open(path, 'w') as f:
|
||||||
|
json.dump(config, f, indent=2)
|
||||||
|
self.hooks = config
|
||||||
|
self.log.info('Saved %d hook(s)' % len(config))
|
||||||
|
|
||||||
|
def get_config(self):
|
||||||
|
return self.hooks
|
||||||
|
|
||||||
|
# -- Unpause gating (called from Mach) --
|
||||||
|
|
||||||
|
def can_unpause(self):
|
||||||
|
"""Returns True if no blocking hook is running.
|
||||||
|
Called by Mach.unpause() to gate resume."""
|
||||||
|
if self._hook_busy:
|
||||||
|
self.log.info('Unpause blocked: hook "%s" still running' %
|
||||||
|
self._hook_busy_event)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_status(self):
|
||||||
|
"""Return current hook execution status for the UI."""
|
||||||
|
return {
|
||||||
|
'busy': self._hook_busy,
|
||||||
|
'event': self._hook_busy_event,
|
||||||
|
'error': self._hook_error,
|
||||||
|
}
|
||||||
|
|
||||||
|
# -- State change listener --
|
||||||
|
|
||||||
|
def _on_state_change(self, update):
|
||||||
|
"""Called on every state update from the controller."""
|
||||||
|
if not self._initialized:
|
||||||
|
return
|
||||||
|
state = self.ctrl.state
|
||||||
|
|
||||||
|
# Detect tool change (tool number changed while HOLDING)
|
||||||
|
if 'tool' in update:
|
||||||
|
new_tool = update['tool']
|
||||||
|
if new_tool != self._last_tool:
|
||||||
|
self._fire('tool-change', {
|
||||||
|
'old_tool': self._last_tool,
|
||||||
|
'new_tool': new_tool,
|
||||||
|
})
|
||||||
|
self._last_tool = new_tool
|
||||||
|
|
||||||
|
# Detect cycle changes
|
||||||
|
if 'cycle' in update:
|
||||||
|
new_cycle = update['cycle']
|
||||||
|
if new_cycle != self._last_cycle:
|
||||||
|
if new_cycle == 'running' and self._last_cycle == 'idle':
|
||||||
|
self._fire('program-start', {})
|
||||||
|
elif new_cycle == 'idle' and self._last_cycle == 'running':
|
||||||
|
self._fire('program-end', {})
|
||||||
|
elif new_cycle == 'homing':
|
||||||
|
self._fire('homing-start', {})
|
||||||
|
elif self._last_cycle == 'homing' and new_cycle == 'idle':
|
||||||
|
self._fire('homing-end', {})
|
||||||
|
self._last_cycle = new_cycle
|
||||||
|
|
||||||
|
# Detect AVR state changes
|
||||||
|
if 'xc' in update or 'xx' in update:
|
||||||
|
new_state = state.get('xx', '')
|
||||||
|
if new_state != self._last_state:
|
||||||
|
if new_state == 'ESTOPPED':
|
||||||
|
# Cancel any running hook on estop. The hook thread
|
||||||
|
# cannot be killed from Python, but we can ask the
|
||||||
|
# AuxAxis to send ABORT to the ESP so its in-flight
|
||||||
|
# motion stops.
|
||||||
|
if self._hook_busy:
|
||||||
|
self.log.warning('E-stop: cancelling hook "%s"' %
|
||||||
|
self._hook_busy_event)
|
||||||
|
try:
|
||||||
|
aux = getattr(self.ctrl, 'aux', None)
|
||||||
|
if aux is not None:
|
||||||
|
aux.abort()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._hook_busy = False
|
||||||
|
self._hook_busy_event = None
|
||||||
|
self._fire('estop', {})
|
||||||
|
self._last_state = new_state
|
||||||
|
|
||||||
|
# Detect pause
|
||||||
|
if 'pr' in update:
|
||||||
|
pr = update['pr']
|
||||||
|
if pr and pr != self._last_pause_reason:
|
||||||
|
self._fire('pause', {'reason': pr})
|
||||||
|
self._last_pause_reason = pr
|
||||||
|
|
||||||
|
# Detect custom hook messages emitted via (MSG,HOOK:event_name:data)
|
||||||
|
# gcode comments. State stores them as a list under 'messages'
|
||||||
|
# ([{'id': N, 'text': '...'}, ...]); fire only on new ids.
|
||||||
|
if 'messages' in update:
|
||||||
|
msgs = update['messages']
|
||||||
|
if isinstance(msgs, list):
|
||||||
|
for m in msgs:
|
||||||
|
try:
|
||||||
|
mid = m.get('id', -1)
|
||||||
|
text = m.get('text', '')
|
||||||
|
except AttributeError:
|
||||||
|
continue
|
||||||
|
if mid <= self._last_msg_id:
|
||||||
|
continue
|
||||||
|
self._last_msg_id = mid
|
||||||
|
if isinstance(text, str) and text.startswith('HOOK:'):
|
||||||
|
parts = text[5:].split(':', 1)
|
||||||
|
event = parts[0]
|
||||||
|
data = parts[1] if len(parts) > 1 else ''
|
||||||
|
self._fire('custom', {
|
||||||
|
'event': event,
|
||||||
|
'data': data,
|
||||||
|
}, custom_name=event)
|
||||||
|
|
||||||
|
# -- Hook execution --
|
||||||
|
|
||||||
|
def register_internal(self, name, fn, block_unpause=True,
|
||||||
|
auto_resume=True, timeout=120):
|
||||||
|
"""Register an in-process handler for HOOK:<name> events.
|
||||||
|
|
||||||
|
fn(context) -> None. May raise. Runs synchronously in the hook
|
||||||
|
thread; while it runs and block_unpause=True, Mach.unpause is
|
||||||
|
gated."""
|
||||||
|
self._internal[name] = {
|
||||||
|
'type': 'internal',
|
||||||
|
'fn': fn,
|
||||||
|
'block_unpause': block_unpause,
|
||||||
|
'auto_resume': auto_resume,
|
||||||
|
'timeout': timeout,
|
||||||
|
}
|
||||||
|
self.log.info('Registered internal hook: %s' % name)
|
||||||
|
|
||||||
|
def _fire(self, event, context, custom_name=None):
|
||||||
|
"""Fire a hook event."""
|
||||||
|
# Internal handlers win over hooks.json entries.
|
||||||
|
hook = None
|
||||||
|
if custom_name:
|
||||||
|
hook = self._internal.get(custom_name)
|
||||||
|
if not hook:
|
||||||
|
hook = self._internal.get(event)
|
||||||
|
if not hook:
|
||||||
|
hook = self.hooks.get(event)
|
||||||
|
if custom_name and not hook:
|
||||||
|
hook = self.hooks.get(custom_name)
|
||||||
|
if not hook:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.log.info('Hook firing: %s %s' % (event, json.dumps(context)))
|
||||||
|
|
||||||
|
# Add standard context
|
||||||
|
state = self.ctrl.state
|
||||||
|
context.update({
|
||||||
|
'event': event,
|
||||||
|
'position': (state.get_position()
|
||||||
|
if hasattr(state, 'get_position') else {}),
|
||||||
|
'state': state.get('xx', ''),
|
||||||
|
'cycle': state.get('cycle', 'idle'),
|
||||||
|
})
|
||||||
|
|
||||||
|
block_unpause = hook.get('block_unpause', event == 'tool-change')
|
||||||
|
auto_resume = hook.get('auto_resume', False)
|
||||||
|
|
||||||
|
if block_unpause:
|
||||||
|
# Run in thread, block unpause until done
|
||||||
|
self._hook_busy = True
|
||||||
|
self._hook_busy_event = event
|
||||||
|
self._hook_error = None
|
||||||
|
|
||||||
|
# Update UI state so frontend knows we're busy
|
||||||
|
self.ctrl.state.set('hook_busy', True)
|
||||||
|
self.ctrl.state.set('hook_event', event)
|
||||||
|
|
||||||
|
self._hook_thread = threading.Thread(
|
||||||
|
target=self._run_hook_blocking,
|
||||||
|
args=(hook, event, context, auto_resume),
|
||||||
|
daemon=True
|
||||||
|
)
|
||||||
|
self._hook_thread.start()
|
||||||
|
else:
|
||||||
|
# Fire and forget (non-blocking)
|
||||||
|
self._execute_hook(hook, context)
|
||||||
|
|
||||||
|
def _run_hook_blocking(self, hook, event, context, auto_resume):
|
||||||
|
"""Runs in a background thread. Blocks unpause until complete."""
|
||||||
|
try:
|
||||||
|
self._execute_hook(hook, context)
|
||||||
|
self.log.info('Hook "%s" completed successfully' % event)
|
||||||
|
except Exception as e:
|
||||||
|
self._hook_error = str(e)
|
||||||
|
self.log.error('Hook "%s" failed: %s' % (event, e))
|
||||||
|
finally:
|
||||||
|
self._hook_busy = False
|
||||||
|
self._hook_busy_event = None
|
||||||
|
|
||||||
|
# Schedule UI update on the ioloop thread
|
||||||
|
self.ctrl.ioloop.call_later(0, self._hook_finished, auto_resume)
|
||||||
|
|
||||||
|
def _hook_finished(self, auto_resume):
|
||||||
|
"""Called on the ioloop after a blocking hook completes."""
|
||||||
|
self.ctrl.state.set('hook_busy', False)
|
||||||
|
self.ctrl.state.set('hook_event', '')
|
||||||
|
|
||||||
|
if self._hook_error:
|
||||||
|
self.ctrl.state.set('hook_error', self._hook_error)
|
||||||
|
self.log.error('Hook error: %s' % self._hook_error)
|
||||||
|
else:
|
||||||
|
self.ctrl.state.set('hook_error', '')
|
||||||
|
|
||||||
|
if auto_resume and not self._hook_error:
|
||||||
|
self.log.info('Hook done, auto-resuming')
|
||||||
|
try:
|
||||||
|
self.ctrl.mach.unpause()
|
||||||
|
except Exception as e:
|
||||||
|
self.log.error('Auto-resume failed: %s' % e)
|
||||||
|
|
||||||
|
def _execute_hook(self, hook, context):
|
||||||
|
"""Execute a single hook (webhook, script, or internal). May block."""
|
||||||
|
hook_type = hook.get('type', 'webhook')
|
||||||
|
|
||||||
|
if hook_type == 'webhook':
|
||||||
|
self._fire_webhook(hook, context)
|
||||||
|
elif hook_type == 'script':
|
||||||
|
self._fire_script(hook, context)
|
||||||
|
elif hook_type == 'internal':
|
||||||
|
fn = hook.get('fn')
|
||||||
|
if fn is None:
|
||||||
|
raise Exception('Internal hook missing fn')
|
||||||
|
fn(context)
|
||||||
|
else:
|
||||||
|
raise Exception('Unknown hook type: %s' % hook_type)
|
||||||
|
|
||||||
|
def _fire_webhook(self, hook, context):
|
||||||
|
"""Fire a webhook HTTP request."""
|
||||||
|
url = hook.get('url')
|
||||||
|
if not url:
|
||||||
|
raise Exception('Webhook missing url')
|
||||||
|
|
||||||
|
method = hook.get('method', 'POST').upper()
|
||||||
|
timeout = hook.get('timeout', 30)
|
||||||
|
headers = dict(hook.get('headers', {}))
|
||||||
|
body = dict(hook.get('body', {}))
|
||||||
|
|
||||||
|
# Merge context into body
|
||||||
|
body['_context'] = context
|
||||||
|
|
||||||
|
data = json.dumps(body).encode('utf-8')
|
||||||
|
headers['Content-Type'] = 'application/json'
|
||||||
|
|
||||||
|
req = Request(url, data=data, headers=headers, method=method)
|
||||||
|
self.log.info('Webhook %s %s' % (method, url))
|
||||||
|
|
||||||
|
resp = urlopen(req, timeout=timeout)
|
||||||
|
self.log.info('Webhook response: %d' % resp.status)
|
||||||
|
|
||||||
|
if resp.status >= 400:
|
||||||
|
raise Exception('Webhook returned %d' % resp.status)
|
||||||
|
|
||||||
|
def _fire_script(self, hook, context):
|
||||||
|
"""Fire a local script/command. Blocks until complete."""
|
||||||
|
command = hook.get('command')
|
||||||
|
if not command:
|
||||||
|
raise Exception('Script hook missing command')
|
||||||
|
|
||||||
|
timeout = hook.get('timeout', 120)
|
||||||
|
|
||||||
|
# Pass context as environment variables
|
||||||
|
env = os.environ.copy()
|
||||||
|
env['HOOK_EVENT'] = context.get('event', '')
|
||||||
|
env['HOOK_STATE'] = context.get('state', '')
|
||||||
|
env['HOOK_CYCLE'] = context.get('cycle', '')
|
||||||
|
env['HOOK_DATA'] = json.dumps(context)
|
||||||
|
|
||||||
|
if 'old_tool' in context:
|
||||||
|
env['HOOK_OLD_TOOL'] = str(context['old_tool'])
|
||||||
|
if 'new_tool' in context:
|
||||||
|
env['HOOK_NEW_TOOL'] = str(context['new_tool'])
|
||||||
|
|
||||||
|
self.log.info('Script: %s' % command)
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
command, shell=True, env=env,
|
||||||
|
timeout=timeout,
|
||||||
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE
|
||||||
|
)
|
||||||
|
|
||||||
|
stdout = result.stdout.decode('utf-8', errors='replace').strip()
|
||||||
|
stderr = result.stderr.decode('utf-8', errors='replace').strip()
|
||||||
|
|
||||||
|
if stdout:
|
||||||
|
self.log.info('Script stdout: %s' % stdout)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise Exception('Script failed (%d): %s' %
|
||||||
|
(result.returncode, stderr or 'non-zero exit'))
|
||||||
@@ -256,6 +256,9 @@ class Mach(Comm):
|
|||||||
if cmd[0] == '$': self._query_var(cmd)
|
if cmd[0] == '$': self._query_var(cmd)
|
||||||
elif cmd[0] == '\\': super().queue_command(cmd[1:])
|
elif cmd[0] == '\\': super().queue_command(cmd[1:])
|
||||||
else:
|
else:
|
||||||
|
# Rewrite W-axis tokens in MDI input the same way the
|
||||||
|
# FileHandler rewrites uploaded files.
|
||||||
|
cmd = self._rewrite_w_mdi(cmd)
|
||||||
self._begin_cycle('mdi')
|
self._begin_cycle('mdi')
|
||||||
self.planner.mdi(cmd, with_limits)
|
self.planner.mdi(cmd, with_limits)
|
||||||
super().resume()
|
super().resume()
|
||||||
@@ -263,6 +266,35 @@ class Mach(Comm):
|
|||||||
self.mlog.info("Exception during MDI: %s" % err)
|
self.mlog.info("Exception during MDI: %s" % err)
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def _rewrite_w_mdi(self, cmd):
|
||||||
|
"""Apply the W-axis preprocessor to a single MDI line. Returns
|
||||||
|
possibly-multi-line G-code with HOOK: comments inserted."""
|
||||||
|
try:
|
||||||
|
from bbctrl.AuxPreprocessor import AuxPreprocessor, _W_TOKEN_RE
|
||||||
|
if not _W_TOKEN_RE.search(cmd):
|
||||||
|
return cmd
|
||||||
|
import io, tempfile, os
|
||||||
|
# AuxPreprocessor.process is file-based; route through
|
||||||
|
# tempfiles so we don't fork the regex/state logic.
|
||||||
|
pre = AuxPreprocessor(log=self.mlog)
|
||||||
|
with tempfile.NamedTemporaryFile('w', suffix='.nc',
|
||||||
|
delete=False) as fi:
|
||||||
|
fi.write(cmd if cmd.endswith('\n') else cmd + '\n')
|
||||||
|
ipath = fi.name
|
||||||
|
opath = ipath + '.out'
|
||||||
|
try:
|
||||||
|
pre.process(ipath, opath)
|
||||||
|
rewritten = open(opath).read()
|
||||||
|
finally:
|
||||||
|
try: os.unlink(ipath)
|
||||||
|
except OSError: pass
|
||||||
|
try: os.unlink(opath)
|
||||||
|
except OSError: pass
|
||||||
|
return rewritten
|
||||||
|
except Exception as e:
|
||||||
|
self.mlog.warning('W-axis MDI rewrite failed: %s' % e)
|
||||||
|
return cmd
|
||||||
|
|
||||||
def set(self, code, value):
|
def set(self, code, value):
|
||||||
super().queue_command('${}={}'.format(code, value))
|
super().queue_command('${}={}'.format(code, value))
|
||||||
|
|
||||||
@@ -349,6 +381,10 @@ class Mach(Comm):
|
|||||||
|
|
||||||
def unpause(self):
|
def unpause(self):
|
||||||
if self._is_paused():
|
if self._is_paused():
|
||||||
|
# Gate unpause on hook completion
|
||||||
|
if hasattr(self.ctrl, 'hooks') and \
|
||||||
|
not self.ctrl.hooks.can_unpause():
|
||||||
|
return
|
||||||
self.ctrl.state.set('optional_pause', False)
|
self.ctrl.state.set('optional_pause', False)
|
||||||
self._unpause()
|
self._unpause()
|
||||||
|
|
||||||
|
|||||||
@@ -766,6 +766,93 @@ class RotaryHandler(bbctrl.APIHandler):
|
|||||||
log.error('Unexpected error: {}'.format(e))
|
log.error('Unexpected error: {}'.format(e))
|
||||||
|
|
||||||
|
|
||||||
|
class HooksGetHandler(bbctrl.APIHandler):
|
||||||
|
def get(self):
|
||||||
|
self.write_json(self.get_ctrl().hooks.get_config())
|
||||||
|
|
||||||
|
|
||||||
|
class HooksSaveHandler(bbctrl.APIHandler):
|
||||||
|
def put_ok(self):
|
||||||
|
self.get_ctrl().hooks.save_config(self.json)
|
||||||
|
|
||||||
|
|
||||||
|
class HooksStatusHandler(bbctrl.APIHandler):
|
||||||
|
def get(self):
|
||||||
|
self.write_json(self.get_ctrl().hooks.get_status())
|
||||||
|
|
||||||
|
|
||||||
|
class HooksFireHandler(bbctrl.APIHandler):
|
||||||
|
def put_ok(self, event):
|
||||||
|
data = self.json if hasattr(self, 'json') and self.json else {}
|
||||||
|
self.get_ctrl().hooks._fire(event, data)
|
||||||
|
|
||||||
|
|
||||||
|
# ----- W axis (auxcnc) endpoints --------------------------------------------
|
||||||
|
|
||||||
|
class AuxConfigGetHandler(bbctrl.APIHandler):
|
||||||
|
def get(self):
|
||||||
|
self.write_json(self.get_ctrl().aux.get_config())
|
||||||
|
|
||||||
|
|
||||||
|
class AuxConfigSaveHandler(bbctrl.APIHandler):
|
||||||
|
def put_ok(self):
|
||||||
|
self.get_ctrl().aux.save_config(self.json or {})
|
||||||
|
|
||||||
|
|
||||||
|
class AuxStatusHandler(bbctrl.APIHandler):
|
||||||
|
def get(self):
|
||||||
|
aux = self.get_ctrl().aux
|
||||||
|
self.write_json({
|
||||||
|
'enabled': aux.enabled,
|
||||||
|
'present': aux.present,
|
||||||
|
'homed': aux.homed,
|
||||||
|
'pos_mm': aux.position_mm,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
class AuxHomeHandler(bbctrl.APIHandler):
|
||||||
|
def put_ok(self):
|
||||||
|
# Run synchronously via the AuxAxis' own RPC; this blocks the
|
||||||
|
# request. Fine because the UI shows a spinner.
|
||||||
|
self.get_ctrl().aux.home()
|
||||||
|
|
||||||
|
|
||||||
|
class AuxAbortHandler(bbctrl.APIHandler):
|
||||||
|
def put_ok(self):
|
||||||
|
self.get_ctrl().aux.abort()
|
||||||
|
|
||||||
|
|
||||||
|
class AuxJogHandler(bbctrl.APIHandler):
|
||||||
|
"""Body: {"mm": 1.5} for relative-mm move,
|
||||||
|
{"steps": 200} for raw step move (bypasses soft limits)."""
|
||||||
|
def put_ok(self):
|
||||||
|
body = self.json or {}
|
||||||
|
aux = self.get_ctrl().aux
|
||||||
|
if 'mm' in body:
|
||||||
|
aux.move_rel_mm(float(body['mm']))
|
||||||
|
elif 'steps' in body:
|
||||||
|
aux.jog_steps(int(body['steps']))
|
||||||
|
else:
|
||||||
|
raise HTTPError(400, 'mm or steps required')
|
||||||
|
|
||||||
|
|
||||||
|
class AuxMoveHandler(bbctrl.APIHandler):
|
||||||
|
"""Body: {"mm": 12.5} absolute move in mm."""
|
||||||
|
def put_ok(self):
|
||||||
|
body = self.json or {}
|
||||||
|
if 'mm' not in body:
|
||||||
|
raise HTTPError(400, 'mm required')
|
||||||
|
self.get_ctrl().aux.move_abs_mm(float(body['mm']))
|
||||||
|
|
||||||
|
|
||||||
|
class AuxSetZeroHandler(bbctrl.APIHandler):
|
||||||
|
"""Body: {"mm": 0} set current position to <mm>."""
|
||||||
|
def put_ok(self):
|
||||||
|
body = self.json or {}
|
||||||
|
mm = float(body.get('mm', 0.0))
|
||||||
|
self.get_ctrl().aux.set_position_mm(mm)
|
||||||
|
|
||||||
|
|
||||||
class RemoteDiagnosticsHandler(bbctrl.APIHandler):
|
class RemoteDiagnosticsHandler(bbctrl.APIHandler):
|
||||||
|
|
||||||
def get(self):
|
def get(self):
|
||||||
@@ -941,6 +1028,18 @@ class Web(tornado.web.Application):
|
|||||||
(r'/api/time', TimeHandler),
|
(r'/api/time', TimeHandler),
|
||||||
(r'/api/rotary', RotaryHandler),
|
(r'/api/rotary', RotaryHandler),
|
||||||
(r'/api/remote-diagnostics', RemoteDiagnosticsHandler),
|
(r'/api/remote-diagnostics', RemoteDiagnosticsHandler),
|
||||||
|
(r'/api/hooks', HooksGetHandler),
|
||||||
|
(r'/api/hooks/save', HooksSaveHandler),
|
||||||
|
(r'/api/hooks/status', HooksStatusHandler),
|
||||||
|
(r'/api/hooks/fire/([\w-]+)', HooksFireHandler),
|
||||||
|
(r'/api/aux/config', AuxConfigGetHandler),
|
||||||
|
(r'/api/aux/config/save', AuxConfigSaveHandler),
|
||||||
|
(r'/api/aux/status', AuxStatusHandler),
|
||||||
|
(r'/api/aux/home', AuxHomeHandler),
|
||||||
|
(r'/api/aux/abort', AuxAbortHandler),
|
||||||
|
(r'/api/aux/jog', AuxJogHandler),
|
||||||
|
(r'/api/aux/move', AuxMoveHandler),
|
||||||
|
(r'/api/aux/set-zero', AuxSetZeroHandler),
|
||||||
(r'/(.*)', StaticFileHandler,
|
(r'/(.*)', StaticFileHandler,
|
||||||
{'path': bbctrl.get_resource('http/'),
|
{'path': bbctrl.get_resource('http/'),
|
||||||
'default_filename': 'index.html'}),
|
'default_filename': 'index.html'}),
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ from bbctrl.AVR import AVR
|
|||||||
from bbctrl.AVREmu import AVREmu
|
from bbctrl.AVREmu import AVREmu
|
||||||
from bbctrl.IOLoop import IOLoop
|
from bbctrl.IOLoop import IOLoop
|
||||||
from bbctrl.MonitorTemp import MonitorTemp
|
from bbctrl.MonitorTemp import MonitorTemp
|
||||||
|
from bbctrl.Hooks import Hooks
|
||||||
|
from bbctrl.AuxAxis import AuxAxis
|
||||||
import bbctrl.Cmd as Cmd
|
import bbctrl.Cmd as Cmd
|
||||||
import bbctrl.v4l2 as v4l2
|
import bbctrl.v4l2 as v4l2
|
||||||
import bbctrl.Log as log
|
import bbctrl.Log as log
|
||||||
|
|||||||
Reference in New Issue
Block a user