Compare commits

11 Commits

Author SHA1 Message Date
Claude
c7cf9483b3 Add W axis integration via auxcnc ESP32 over /dev/ttyUSB0
Rather than rebuild gplan + the AVR firmware to add a true 7th axis,
we treat W as a synchronous out-of-band axis that moves between G-code
blocks. The pipeline:

  upload -> AuxPreprocessor rewrites W tokens into (MSG,HOOK:aux:N)
  comments -> planner sees only XYZ + messages -> Hooks fires the
  registered internal handler -> AuxAxis sends STEPS/HOME over serial
  to the ESP and blocks the planner until done.

New files:
  src/py/bbctrl/AuxAxis.py       serial worker + RPC layer
  src/py/bbctrl/AuxPreprocessor.py  G-code rewriter
  docs/AUX_W_AXIS.md             design + ops notes

Changed:
  Hooks.py        register_internal(); fix the (MSG,HOOK:...) listener
                  to read the 'messages' state list (was broken before)
  Ctrl.py         instantiate AuxAxis, register aux/aux_rel/aux_home/
                  aux_setzero hooks
  FileHandler.py  rewrite uploads in place when they use W
  Mach.py         rewrite W tokens in MDI input the same way
  Web.py          REST endpoints under /api/aux/*

The ESP firmware in ../auxcnc was extended in lockstep: HOME, HOMECFG
(NVS-persisted), WPOS, HOMED?, LIMIT?, abortable STEPS with
limit-aware abort, trapezoidal ramps, deterministic [topic] reply
tokens, [boot] banner.

Real-time decisions (limit switch, step pulses) live on the ESP. The
host owns mm units, soft limits, and aux_homed bookkeeping. ESP
reboot mid-job clears aux_homed and surfaces a message; per design
manual jogs are still allowed without homing.
2026-04-30 16:51:24 +02:00
54a15f9d12 Rewrite BUILD.md: clean up, add quick start, remove dead weight
- Quick start section at the top (3 commands)
- Removed inline Bullseye build recipe (moved to 'why not' appendix)
- Added build time estimates
- Cleaner table formatting
- gplan.so contents documented (cbang + camotics)
2026-04-30 16:39:57 +02:00
704bc8d35c gplan.so: build from source using Raspbian Stretch Docker
Use balenalib/raspberry-pi-debian:stretch with legacy.raspbian.org repos.
Exact match: GCC 6.3, Python 3.5, GLIBC 2.24 — identical to the Pi.
First build ~25min (QEMU), subsequent builds ~1sec (cached image).

Replaces the broken Bullseye approach that had GLIBC/GLIBCXX mismatches.
2026-04-30 16:33:20 +02:00
4d2d5fd88c Update BUILD.md: gplan.so can't be built from source on Bullseye
Document GLIBC/GLIBCXX version constraints and Python 3.5 compat notes.
Recommend using official release gplan.so instead.
2026-04-30 15:57:31 +02:00
eab204b7be Fix Python 3.5 compat: capture_output and text= not available
Use stdout=PIPE/stderr=PIPE and manual .decode() instead.
Use official 1.6.6 gplan.so (built with Stretch-era GCC, no GLIBC_2.29 dep).
2026-04-30 15:56:42 +02:00
e3c059eb9b Add cached gplan.so build: 30min first time, 3sec after
- Dockerfile.gplan: pre-built armv7 image with cbang + camotics objects
- build-gplan.sh: relinks against Python 3.5m in ~3sec
- Pi Python 3.5 headers cached in .pi/pi-python35.tar.gz (gitignored)
2026-04-30 14:43:05 +02:00
7306464440 Document gplan.so build-from-source procedure
Build in armv7 QEMU Docker, compile with Python 3.9 SCons,
relink final .so against Python 3.5m from the Pi.
2026-04-30 13:52:58 +02:00
1625b768d8 Add build/flash/backup documentation for Pi firmware 2026-04-30 12:09:12 +02:00
5be7515a92 Fix gplan.so: use armv7 binary from official 1.6.6 release
The gplan.so (CAMotics G-code planner) must be a 32-bit ARM binary
matching the Pi's Python 3.5. Source it from the official release
package rather than cross-compiling (SCons ignores CC/CXX overrides).

Also revert install.sh gplan.so preservation logic — simpler to just
ship the correct binary in the package.
2026-04-30 11:36:09 +02:00
7d0755c55b Hooks v2: block unpause until hook completes
- Blocking hooks (block_unpause: true, default for tool-change) run
  in a background thread and gate Mach.unpause() via can_unpause()
- Machine stays in HOLDING state while hook runs — AVR steppers idle,
  spindle state preserved, position locked
- auto_resume option to unpause automatically after hook completes
- E-stop cancels any running hook immediately
- Hook status pushed to frontend via state (hook_busy, hook_event)
- GET /api/hooks/status endpoint for polling
- Non-blocking hooks (program-start, program-end, etc.) fire-and-forget
2026-04-21 08:10:07 +02:00
7f8fd23615 Add hooks system for external triggers during G-code execution
- New Hooks module (src/py/bbctrl/Hooks.py) that watches controller state
  and fires webhooks or scripts on events:
  - tool-change (M6), program-start, program-end, pause, estop,
    homing-start, homing-end, custom (via MSG comments)
- API endpoints:
  - GET /api/hooks - get current hook config
  - PUT /api/hooks/save - save hook config
  - PUT /api/hooks/fire/<event> - manually fire a hook (for testing)
- Hook config stored in hooks.json with two types:
  - webhook: HTTP POST/PUT to external URL with JSON context
  - script: run local command with env vars (HOOK_OLD_TOOL, etc.)
- Fix tornado.web.asynchronous deprecation in Camera.py
- Wired into Ctrl initialization and state listener system
2026-04-20 17:43:02 +02:00
16 changed files with 1821 additions and 3 deletions

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

14
.gitignore vendored
View File

@@ -27,3 +27,17 @@ __pycache__
*.elf
*.hex
.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
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"

144
docs/AUX_W_AXIS.md Normal file
View 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

View File

@@ -17,7 +17,7 @@ setup(
license = pkg['license'],
url = pkg['homepage'],
package_dir = {'': 'src/py'},
packages = ['bbctrl', 'inevent', 'lcd', 'camotics','iw_parse'],
packages = ['bbctrl', 'inevent', 'lcd', 'camotics', 'iw_parse'],
include_package_data = True,
entry_points = {
'console_scripts': [

477
src/py/bbctrl/AuxAxis.py Normal file
View 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

View 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

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

@@ -59,6 +59,9 @@ class Ctrl(object):
self.preplanner = bbctrl.Preplanner(self)
if not args.demo: self.jog = bbctrl.Jog(self)
self.pwr = bbctrl.Pwr(self)
self.hooks = bbctrl.Hooks(self)
self.aux = bbctrl.AuxAxis(self)
self._register_aux_hooks()
self.mach.connect()
@@ -109,8 +112,46 @@ class Ctrl(object):
self.preplanner.start()
def _register_aux_hooks(self):
"""Wire up the auxcnc HOOK: events to AuxAxis methods."""
log = self.log.get('AuxAxis')
def _hook_move(ctx):
data = (ctx.get('data') or '').strip()
if not data:
raise Exception('aux hook missing target')
self.aux.move_abs_mm(float(data))
def _hook_move_rel(ctx):
data = (ctx.get('data') or '').strip()
if not data:
raise Exception('aux_rel hook missing delta')
self.aux.move_rel_mm(float(data))
def _hook_home(ctx):
self.aux.home()
def _hook_setzero(ctx):
data = (ctx.get('data') or '').strip()
mm = float(data) if data else 0.0
self.aux.set_position_mm(mm)
self.hooks.register_internal('aux', _hook_move,
block_unpause=True, auto_resume=True)
self.hooks.register_internal('aux_rel', _hook_move_rel,
block_unpause=True, auto_resume=True)
self.hooks.register_internal('aux_home', _hook_home,
block_unpause=True, auto_resume=True,
timeout=180)
self.hooks.register_internal('aux_setzero', _hook_setzero,
block_unpause=True, auto_resume=True)
log.info('Aux hooks registered')
def close(self):
self.log.get('Ctrl').info('Closing %s' % self.id)
self.ioloop.close()
self.avr.close()
self.mach.planner.close()
try: self.aux.close()
except Exception: pass

View File

@@ -99,6 +99,19 @@ class FileHandler(bbctrl.APIHandler):
del (self.uploadFile)
# If the uploaded G-code uses the virtual W axis, rewrite the
# file in place so the planner sees (MSG,HOOK:aux:*) lines
# instead of W tokens it can't parse.
try:
from bbctrl.AuxPreprocessor import preprocess_file
log = self.get_log('AuxPreprocessor')
if preprocess_file(filename.decode('utf8'), log=log):
log.info('Rewrote W-axis tokens in %s' %
self.uploadFilename)
except Exception:
self.get_log('AuxPreprocessor').exception(
'W-axis preprocess failed; uploading unchanged')
self.get_ctrl().preplanner.invalidate(self.uploadFilename)
self.get_ctrl().state.add_file(self.uploadFilename)

429
src/py/bbctrl/Hooks.py Normal file
View 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'))

View File

@@ -256,6 +256,9 @@ class Mach(Comm):
if cmd[0] == '$': self._query_var(cmd)
elif cmd[0] == '\\': super().queue_command(cmd[1:])
else:
# Rewrite W-axis tokens in MDI input the same way the
# FileHandler rewrites uploaded files.
cmd = self._rewrite_w_mdi(cmd)
self._begin_cycle('mdi')
self.planner.mdi(cmd, with_limits)
super().resume()
@@ -263,6 +266,35 @@ class Mach(Comm):
self.mlog.info("Exception during MDI: %s" % err)
pass
def _rewrite_w_mdi(self, cmd):
"""Apply the W-axis preprocessor to a single MDI line. Returns
possibly-multi-line G-code with HOOK: comments inserted."""
try:
from bbctrl.AuxPreprocessor import AuxPreprocessor, _W_TOKEN_RE
if not _W_TOKEN_RE.search(cmd):
return cmd
import io, tempfile, os
# AuxPreprocessor.process is file-based; route through
# tempfiles so we don't fork the regex/state logic.
pre = AuxPreprocessor(log=self.mlog)
with tempfile.NamedTemporaryFile('w', suffix='.nc',
delete=False) as fi:
fi.write(cmd if cmd.endswith('\n') else cmd + '\n')
ipath = fi.name
opath = ipath + '.out'
try:
pre.process(ipath, opath)
rewritten = open(opath).read()
finally:
try: os.unlink(ipath)
except OSError: pass
try: os.unlink(opath)
except OSError: pass
return rewritten
except Exception as e:
self.mlog.warning('W-axis MDI rewrite failed: %s' % e)
return cmd
def set(self, code, value):
super().queue_command('${}={}'.format(code, value))
@@ -349,6 +381,10 @@ class Mach(Comm):
def unpause(self):
if self._is_paused():
# Gate unpause on hook completion
if hasattr(self.ctrl, 'hooks') and \
not self.ctrl.hooks.can_unpause():
return
self.ctrl.state.set('optional_pause', False)
self._unpause()

View File

@@ -766,6 +766,93 @@ class RotaryHandler(bbctrl.APIHandler):
log.error('Unexpected error: {}'.format(e))
class HooksGetHandler(bbctrl.APIHandler):
def get(self):
self.write_json(self.get_ctrl().hooks.get_config())
class HooksSaveHandler(bbctrl.APIHandler):
def put_ok(self):
self.get_ctrl().hooks.save_config(self.json)
class HooksStatusHandler(bbctrl.APIHandler):
def get(self):
self.write_json(self.get_ctrl().hooks.get_status())
class HooksFireHandler(bbctrl.APIHandler):
def put_ok(self, event):
data = self.json if hasattr(self, 'json') and self.json else {}
self.get_ctrl().hooks._fire(event, data)
# ----- W axis (auxcnc) endpoints --------------------------------------------
class AuxConfigGetHandler(bbctrl.APIHandler):
def get(self):
self.write_json(self.get_ctrl().aux.get_config())
class AuxConfigSaveHandler(bbctrl.APIHandler):
def put_ok(self):
self.get_ctrl().aux.save_config(self.json or {})
class AuxStatusHandler(bbctrl.APIHandler):
def get(self):
aux = self.get_ctrl().aux
self.write_json({
'enabled': aux.enabled,
'present': aux.present,
'homed': aux.homed,
'pos_mm': aux.position_mm,
})
class AuxHomeHandler(bbctrl.APIHandler):
def put_ok(self):
# Run synchronously via the AuxAxis' own RPC; this blocks the
# request. Fine because the UI shows a spinner.
self.get_ctrl().aux.home()
class AuxAbortHandler(bbctrl.APIHandler):
def put_ok(self):
self.get_ctrl().aux.abort()
class AuxJogHandler(bbctrl.APIHandler):
"""Body: {"mm": 1.5} for relative-mm move,
{"steps": 200} for raw step move (bypasses soft limits)."""
def put_ok(self):
body = self.json or {}
aux = self.get_ctrl().aux
if 'mm' in body:
aux.move_rel_mm(float(body['mm']))
elif 'steps' in body:
aux.jog_steps(int(body['steps']))
else:
raise HTTPError(400, 'mm or steps required')
class AuxMoveHandler(bbctrl.APIHandler):
"""Body: {"mm": 12.5} absolute move in mm."""
def put_ok(self):
body = self.json or {}
if 'mm' not in body:
raise HTTPError(400, 'mm required')
self.get_ctrl().aux.move_abs_mm(float(body['mm']))
class AuxSetZeroHandler(bbctrl.APIHandler):
"""Body: {"mm": 0} set current position to <mm>."""
def put_ok(self):
body = self.json or {}
mm = float(body.get('mm', 0.0))
self.get_ctrl().aux.set_position_mm(mm)
class RemoteDiagnosticsHandler(bbctrl.APIHandler):
def get(self):
@@ -941,6 +1028,18 @@ class Web(tornado.web.Application):
(r'/api/time', TimeHandler),
(r'/api/rotary', RotaryHandler),
(r'/api/remote-diagnostics', RemoteDiagnosticsHandler),
(r'/api/hooks', HooksGetHandler),
(r'/api/hooks/save', HooksSaveHandler),
(r'/api/hooks/status', HooksStatusHandler),
(r'/api/hooks/fire/([\w-]+)', HooksFireHandler),
(r'/api/aux/config', AuxConfigGetHandler),
(r'/api/aux/config/save', AuxConfigSaveHandler),
(r'/api/aux/status', AuxStatusHandler),
(r'/api/aux/home', AuxHomeHandler),
(r'/api/aux/abort', AuxAbortHandler),
(r'/api/aux/jog', AuxJogHandler),
(r'/api/aux/move', AuxMoveHandler),
(r'/api/aux/set-zero', AuxSetZeroHandler),
(r'/(.*)', StaticFileHandler,
{'path': bbctrl.get_resource('http/'),
'default_filename': 'index.html'}),

View File

@@ -59,6 +59,8 @@ from bbctrl.AVR import AVR
from bbctrl.AVREmu import AVREmu
from bbctrl.IOLoop import IOLoop
from bbctrl.MonitorTemp import MonitorTemp
from bbctrl.Hooks import Hooks
from bbctrl.AuxAxis import AuxAxis
import bbctrl.Cmd as Cmd
import bbctrl.v4l2 as v4l2
import bbctrl.Log as log