Compare commits
11 Commits
214de86bdf
...
77b5b42fec
| Author | SHA1 | Date | |
|---|---|---|---|
| 77b5b42fec | |||
| 9526ad797d | |||
| 683fa673ae | |||
| 4d71585a00 | |||
| 77bda775dd | |||
| 99f48309fa | |||
| 1afb51098e | |||
| 576957da4a | |||
| 6dbc7e6d04 | |||
| 46fa0765f5 | |||
| fe362e10ab |
24
README.md
24
README.md
@@ -1,8 +1,9 @@
|
|||||||
# OneFinity CNC Controller Firmware (community fork)
|
# OneFinity CNC Controller Firmware (A-axis fork)
|
||||||
|
|
||||||
This is the OneFinity / Buildbotics bbctrl firmware with a redesigned
|
This is the community-fork firmware (V09 UI, FA6, cold-boot work,
|
||||||
UI (V09), Font Awesome 6, faster cold boot, and a streamlined macOS
|
macOS dev tooling) with a virtual A axis driven by an auxcnc ESP32
|
||||||
dev / deploy workflow.
|
over USB serial. See [docs/AUX_A_AXIS.md](docs/AUX_A_AXIS.md) for the
|
||||||
|
design and config.
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
@@ -16,7 +17,7 @@ src/svelte-components/ Newer Svelte UI for dialogs and settings
|
|||||||
src/pug/ Pug templates compiled into build/http/index.html
|
src/pug/ Pug templates compiled into build/http/index.html
|
||||||
src/resources/ Static assets and config templates
|
src/resources/ Static assets and config templates
|
||||||
scripts/ Install / update / RPi build helpers
|
scripts/ Install / update / RPi build helpers
|
||||||
docs/ Architecture, dev setup
|
docs/ Architecture, dev setup, A-axis docs
|
||||||
```
|
```
|
||||||
|
|
||||||
## Build & flash (quick path, macOS or Linux)
|
## Build & flash (quick path, macOS or Linux)
|
||||||
@@ -101,6 +102,7 @@ bbctrl restarts, then the new UI).
|
|||||||
```bash
|
```bash
|
||||||
curl -s http://onefinity.local/ | grep -c "OneFinity"
|
curl -s http://onefinity.local/ | grep -c "OneFinity"
|
||||||
curl -s http://onefinity.local/api/diag/timing | head
|
curl -s http://onefinity.local/api/diag/timing | head
|
||||||
|
curl -s http://onefinity.local/api/aux/status # if A axis is enabled
|
||||||
```
|
```
|
||||||
|
|
||||||
## Build & flash (full path, Debian/Linux)
|
## Build & flash (full path, Debian/Linux)
|
||||||
@@ -108,3 +110,15 @@ curl -s http://onefinity.local/api/diag/timing | head
|
|||||||
For AVR + GPlan rebuilds, see [docs/development.md](docs/development.md).
|
For AVR + GPlan rebuilds, see [docs/development.md](docs/development.md).
|
||||||
That path uses qemu + chroot to cross-compile gplan for ARM and needs
|
That path uses qemu + chroot to cross-compile gplan for ARM and needs
|
||||||
the `gcc-avr` / `avr-libc` toolchain.
|
the `gcc-avr` / `avr-libc` toolchain.
|
||||||
|
|
||||||
|
## A axis (auxcnc)
|
||||||
|
|
||||||
|
This fork adds a virtual A axis. See
|
||||||
|
[docs/AUX_A_AXIS.md](docs/AUX_A_AXIS.md) for:
|
||||||
|
|
||||||
|
- G-code surface (`G28 A0`, `G1 A25`, etc.)
|
||||||
|
- The G-code preprocessor and hook architecture
|
||||||
|
- aux.json keys
|
||||||
|
- REST API (`/api/aux/*`)
|
||||||
|
- UI surface (jog row in Control, settings panel in Settings)
|
||||||
|
- Edge cases (ESP reboot mid-job, limit closed at home start, …)
|
||||||
|
|||||||
183
docs/AUX_A_AXIS.md
Normal file
183
docs/AUX_A_AXIS.md
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
# A axis (auxcnc) integration
|
||||||
|
|
||||||
|
> **Note:** This document describes the original out-of-band W-axis
|
||||||
|
> architecture (gcode preprocessor rewriting W tokens into HOOK
|
||||||
|
> messages dispatched between blocks). The current implementation
|
||||||
|
> integrates the auxcnc-driven stepper as a *virtual A axis* through
|
||||||
|
> gplan via a synthetic motor (`bbctrl/ExternalAxis.py`), so A is
|
||||||
|
> blended with XYZ in the same S-curve plan and the gcode surface
|
||||||
|
> below applies as plain `A` words.
|
||||||
|
>
|
||||||
|
> The HOOK pipeline still exists for ATC pneumatics (M100..M103),
|
||||||
|
> see `bbctrl/AuxPreprocessor.py`.
|
||||||
|
|
||||||
|
This adds a virtual `A` 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: A moves run
|
||||||
|
*between* G-code blocks, not blended with XYZ.
|
||||||
|
|
||||||
|
Pipeline:
|
||||||
|
|
||||||
|
1. User uploads a G-code file containing `A` words.
|
||||||
|
2. `FileHandler` runs `AuxPreprocessor` on the upload, rewriting W
|
||||||
|
tokens in place into `(MSG,HOOK:aux:<mm>)` etc. The original line
|
||||||
|
minus the A 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 A 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 `A` 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 A0 ; home A axis
|
||||||
|
G1 A25 F300 ; move A to 25 mm absolute
|
||||||
|
G1 X100 W12.5 ; mixed: A moves first, then XYZ (configurable)
|
||||||
|
G91
|
||||||
|
G1 A-2.5 ; relative A move
|
||||||
|
G90
|
||||||
|
G92 A0 ; set current A 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).
|
||||||
|
|
||||||
|
## UI
|
||||||
|
|
||||||
|
**Control view**
|
||||||
|
|
||||||
|
- A jog row appears under the XYZ jog grid when `aux_enabled` is true,
|
||||||
|
with three buttons: `A-`, `A+`, and a wide `Home W`. There is
|
||||||
|
intentionally no separate "set zero" or "W origin" button - homing
|
||||||
|
lands the axis at `home_position_mm` (0 by default), so home and
|
||||||
|
zero are the same point.
|
||||||
|
- The DRO table shows a A axis row with position, status (OFFLINE /
|
||||||
|
UNHOMED / HOMED), and a single Home button in the actions column
|
||||||
|
(the cog and map-marker columns are placeholders for layout).
|
||||||
|
|
||||||
|
**Settings view**
|
||||||
|
|
||||||
|
A "W Axis (auxcnc)" section exposes every aux.json field except
|
||||||
|
`enabled` (which stays read-only - flipping the A axis on/off requires
|
||||||
|
editingaux.json on the controller, so a fresh install can't surprise
|
||||||
|
the user with hardware that isn't there). Saving PUTs the merged
|
||||||
|
config to `/api/aux/config/save`, which writes aux.json and pushes
|
||||||
|
`HOMECFG` to the ESP. A status line shows whether the axis is
|
||||||
|
disabled / offline / connected-unhomed / homed at `<pos> mm`.
|
||||||
|
|
||||||
|
## State surface
|
||||||
|
|
||||||
|
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: "A axis controller restarted - re-home
|
||||||
|
before use". Subsequent A 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 A 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 A 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
|
||||||
|
- `src/pug/templates/control-view.pug`: W jog row + DRO row
|
||||||
|
- `src/js/control-view.js`: aux_home / aux_jog / aux_jog_incr handlers
|
||||||
|
- `src/js/axis-vars.js`: `_compute_aux_axis` for W state
|
||||||
|
- `src/svelte-components/src/components/WAxisSettings.svelte`: settings panel
|
||||||
|
- `src/svelte-components/src/components/SettingsView.svelte`: hosts WAxisSettings
|
||||||
|
- `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
|
||||||
@@ -9,6 +9,9 @@
|
|||||||
# * The full V09 chrome (header tabs, settings rail, jog grid, DRO
|
# * The full V09 chrome (header tabs, settings rail, jog grid, DRO
|
||||||
# skeleton, status strip).
|
# skeleton, status strip).
|
||||||
# * A "DISCONNECTED" overlay because there's no controller backend.
|
# * A "DISCONNECTED" overlay because there's no controller backend.
|
||||||
|
# * The A axis row in jog/DRO is hidden (correct: it appears only when
|
||||||
|
# the controller reports `aux_enabled = true`). To exercise the A
|
||||||
|
# axis end-to-end, deploy to the Pi (`./deploy.sh hardware`).
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
|||||||
20
src/js/a-axis-view.js
Normal file
20
src/js/a-axis-view.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
// V09 A-axis page — mounts the AAxisSettings Svelte component
|
||||||
|
// inside the settings shell so it gets a real top-level rail entry
|
||||||
|
// instead of being a soft-link anchor inside Display & Units.
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
template: "#a-axis-view-template",
|
||||||
|
|
||||||
|
attached: function () {
|
||||||
|
this.svelteComponent = SvelteComponents.createComponent(
|
||||||
|
"AAxisSettings",
|
||||||
|
document.getElementById("a-axis-mount")
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
detached: function () {
|
||||||
|
if (this.svelteComponent) this.svelteComponent.$destroy();
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -391,6 +391,7 @@ module.exports = new Vue({
|
|||||||
"admin-general", "admin-network",
|
"admin-general", "admin-network",
|
||||||
"motor", "tool", "io", "macros",
|
"motor", "tool", "io", "macros",
|
||||||
"help", "cheat-sheet",
|
"help", "cheat-sheet",
|
||||||
|
"a-axis",
|
||||||
];
|
];
|
||||||
const initialHead = (location.hash || "").replace(/^#/, "").split(":")[0];
|
const initialHead = (location.hash || "").replace(/^#/, "").split(":")[0];
|
||||||
if (settingsFamily.indexOf(initialHead) === -1) {
|
if (settingsFamily.indexOf(initialHead) === -1) {
|
||||||
@@ -626,6 +627,7 @@ module.exports = new Vue({
|
|||||||
"admin-general", "admin-network",
|
"admin-general", "admin-network",
|
||||||
"motor", "tool", "io", "macros",
|
"motor", "tool", "io", "macros",
|
||||||
"help", "cheat-sheet",
|
"help", "cheat-sheet",
|
||||||
|
"a-axis",
|
||||||
];
|
];
|
||||||
|
|
||||||
if (head == "control") {
|
if (head == "control") {
|
||||||
@@ -687,6 +689,13 @@ module.exports = new Vue({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await api.put("config/save", this.config);
|
await api.put("config/save", this.config);
|
||||||
|
// Notify any embedded Svelte subviews that own their
|
||||||
|
// own persistence (A axis -> aux.json, etc.) that
|
||||||
|
// the user just hit the master Save button. They
|
||||||
|
// listen for `onefin:save-all` and PUT their state.
|
||||||
|
try {
|
||||||
|
window.dispatchEvent(new CustomEvent("onefin:save-all"));
|
||||||
|
} catch (_e) {}
|
||||||
this.modified = false;
|
this.modified = false;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Save failed:", error);
|
console.error("Save failed:", error);
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ module.exports = {
|
|||||||
return this._compute_axis("c");
|
return this._compute_axis("c");
|
||||||
},
|
},
|
||||||
|
|
||||||
|
w: function() {
|
||||||
|
return this._compute_aux_axis();
|
||||||
|
},
|
||||||
|
|
||||||
axes: function() {
|
axes: function() {
|
||||||
return this._compute_axes();
|
return this._compute_axes();
|
||||||
}
|
}
|
||||||
@@ -52,7 +56,12 @@ module.exports = {
|
|||||||
const abs = this.state[`${axis}p`] || 0;
|
const abs = this.state[`${axis}p`] || 0;
|
||||||
const off = this.state[`offset_${axis}`];
|
const off = this.state[`offset_${axis}`];
|
||||||
const motor_id = this._get_motor_id(axis);
|
const motor_id = this._get_motor_id(axis);
|
||||||
const motor = motor_id == -1 ? {} : this.config.motors[motor_id];
|
// motor_id may be 4 for the synthetic external-axis motor;
|
||||||
|
// there is no entry for it in config.motors so guard with
|
||||||
|
// an empty object to avoid undefined property access.
|
||||||
|
const motor = (motor_id == -1
|
||||||
|
? {}
|
||||||
|
: (this.config.motors[motor_id] || {}));
|
||||||
const enabled = this._check_is_enabled(axis);
|
const enabled = this._check_is_enabled(axis);
|
||||||
const homingMode = motor["homing-mode"];
|
const homingMode = motor["homing-mode"];
|
||||||
const homed = this.state[`${motor_id}homed`];
|
const homed = this.state[`${motor_id}homed`];
|
||||||
@@ -185,24 +194,114 @@ module.exports = {
|
|||||||
_get_motor_id: function(axis) {
|
_get_motor_id: function(axis) {
|
||||||
for (let i = 0; i < this.config.motors.length; i++) {
|
for (let i = 0; i < this.config.motors.length; i++) {
|
||||||
const motor = this.config.motors[i];
|
const motor = this.config.motors[i];
|
||||||
if (motor.axis.toLowerCase() == axis) {
|
// motor.axis can be undefined on initial load before
|
||||||
|
// config has streamed in. Guard so the computed does
|
||||||
|
// not throw and bubble a Vue warning into the console.
|
||||||
|
if (motor && typeof motor.axis === "string" &&
|
||||||
|
motor.axis.toLowerCase() == axis) {
|
||||||
return i;
|
return i;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Synthetic external motor (index 4) used by ExternalAxis
|
||||||
|
// to expose the auxcnc ESP stepper as a virtual axis.
|
||||||
|
// Its `Nan` lives in state, not config.
|
||||||
|
const axes = { x: 0, y: 1, z: 2, a: 3, b: 4, c: 5 };
|
||||||
|
const wanted = axes[axis];
|
||||||
|
const extAn = this.state && this.state["4an"];
|
||||||
|
if (typeof wanted === "number" && typeof extAn === "number"
|
||||||
|
&& extAn === wanted) {
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
|
||||||
return -1;
|
return -1;
|
||||||
},
|
},
|
||||||
|
|
||||||
_check_is_enabled: function(axis){
|
_check_is_enabled: function(axis){
|
||||||
|
// Prefer config.motors[i].axis (always present once the
|
||||||
|
// config has loaded). Fall back to the per-motor state
|
||||||
|
// `Nan` field, which is what the legacy UI used. This
|
||||||
|
// avoids hiding axis rows during the brief window after
|
||||||
|
// config has loaded but before the controller has pushed
|
||||||
|
// its first state delta.
|
||||||
const axes = { x: 0, y: 1, z: 2, a: 3 };
|
const axes = { x: 0, y: 1, z: 2, a: 3 };
|
||||||
for(let i = 0; i < this.config.motors.length; i++){
|
const wanted = axes[axis];
|
||||||
if(this.state[`${i}an`] == axes[axis]){
|
for (let i = 0; i < this.config.motors.length; i++) {
|
||||||
|
const motor = this.config.motors[i] || {};
|
||||||
|
if (typeof motor.axis === "string" &&
|
||||||
|
motor.axis.toLowerCase() == axis) {
|
||||||
|
return motor.enabled !== false;
|
||||||
|
}
|
||||||
|
// Only use the state Nan fallback for axes we know
|
||||||
|
// about (x/y/z/a). Otherwise undefined == undefined
|
||||||
|
// would mistakenly match every axis (b, c, ...).
|
||||||
|
if (typeof wanted === "number") {
|
||||||
|
const an = this.state[`${i}an`];
|
||||||
|
if (typeof an === "number" && an === wanted) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Synthetic external motor (index 4) - the auxcnc ESP
|
||||||
|
// stepper exposed as A via ExternalAxis.
|
||||||
|
if (typeof wanted === "number") {
|
||||||
|
const extAn = this.state["4an"];
|
||||||
|
const extMe = this.state["4me"];
|
||||||
|
if (typeof extAn === "number" && extAn === wanted
|
||||||
|
&& extMe) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
_compute_aux_axis: function() {
|
||||||
|
// Auxiliary axis driven by the auxcnc ESP32 (typically
|
||||||
|
// exposed to gplan as A). Position, homed flag and
|
||||||
|
// presence come from the bbctrl AuxAxis driver via
|
||||||
|
// state.aux_*. No motor mapping, no soft-limit warnings
|
||||||
|
// on toolpath bounds (auxcnc enforces its own).
|
||||||
|
const enabled = !!this.state.aux_enabled;
|
||||||
|
const present = !!this.state.aux_present;
|
||||||
|
const homed = !!this.state.aux_homed;
|
||||||
|
const pos = this.state.aux_pos || 0;
|
||||||
|
|
||||||
|
let klass = `${homed ? "homed" : "unhomed"} axis-w`;
|
||||||
|
let state = present ? "UNHOMED" : "OFFLINE";
|
||||||
|
let icon = present ? "question-circle" : "plug";
|
||||||
|
let title = present
|
||||||
|
? "Click the home button to home the auxiliary axis."
|
||||||
|
: "Aux controller not connected on /dev/ttyUSB0.";
|
||||||
|
if (homed) {
|
||||||
|
state = "HOMED";
|
||||||
|
icon = "check-circle";
|
||||||
|
title = "Auxiliary axis successfully homed.";
|
||||||
|
} else if (!present) {
|
||||||
|
klass += " error";
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
pos: pos,
|
||||||
|
abs: pos,
|
||||||
|
off: 0,
|
||||||
|
min: 0, max: 0, dim: 0,
|
||||||
|
pathMin: 0, pathMax: 0, pathDim: 0,
|
||||||
|
motor: -1,
|
||||||
|
enabled: enabled,
|
||||||
|
homingMode: "limit-switch",
|
||||||
|
homed: homed,
|
||||||
|
klass: klass,
|
||||||
|
state: state,
|
||||||
|
icon: icon,
|
||||||
|
title: title,
|
||||||
|
ticon: "check-circle",
|
||||||
|
tstate: "OK",
|
||||||
|
toolmsg: "Auxiliary axis is not constrained by tool path bounds.",
|
||||||
|
tklass: `${homed ? "homed" : "unhomed"} axis-w`,
|
||||||
|
isAux: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
_compute_axes: function() {
|
_compute_axes: function() {
|
||||||
let homed = false;
|
let homed = false;
|
||||||
|
|
||||||
|
|||||||
@@ -249,13 +249,83 @@ module.exports = {
|
|||||||
api.put(`home/${axis}/clear`);
|
api.put(`home/${axis}/clear`);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
aux_home: function () {
|
||||||
|
api.put("aux/home").catch(function (err) {
|
||||||
|
console.error("Aux home failed:", err);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Home every enabled axis (legacy Onefinity "Home All"). Sequence:
|
||||||
|
// 1. Z, X, Y (and A/B/C if enabled) via /api/home on the AVR
|
||||||
|
// 2. Auxiliary axis via /api/aux/home on the ESP
|
||||||
|
// ONLY when the auxcnc axis is not integrated as a virtual
|
||||||
|
// machine axis. With the gplan A-axis integration (synthetic
|
||||||
|
// motor 4 enabled), Mach.home() already homes the external
|
||||||
|
// axis as part of the xyzabc pass - calling aux/home
|
||||||
|
// afterwards would home it a second time.
|
||||||
|
// /api/home returns as soon as the request is queued, not when
|
||||||
|
// homing completes, so we have to watch state.cycle:
|
||||||
|
// - first wait for it to *leave* 'idle' (cycle began),
|
||||||
|
// - then wait for it to come *back* to 'idle' (cycle ended).
|
||||||
|
// Only then do we fire the auxiliary home, so the gantry and the
|
||||||
|
// auxcnc ESP never move at the same time.
|
||||||
home_all: async function () {
|
home_all: async function () {
|
||||||
this.ask_home = false;
|
this.ask_home = false;
|
||||||
try {
|
try {
|
||||||
await api.put("home");
|
await api.put("home");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Home all failed:", e);
|
console.error("Home all (XYZ) failed:", e);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
if (!this.w || !this.w.enabled) return;
|
||||||
|
|
||||||
|
// When the synthetic external motor (index 4) is enabled,
|
||||||
|
// the auxcnc axis is mapped onto a real machine axis letter
|
||||||
|
// (e.g. A) and was already homed by /api/home above.
|
||||||
|
if (this.state && this.state["4me"]) return;
|
||||||
|
|
||||||
|
const wait = (ms) => new Promise(r => setTimeout(r, ms));
|
||||||
|
const cycleNow = () => (this.state && this.state.cycle) || "idle";
|
||||||
|
|
||||||
|
// Phase 1: wait up to 5s for the homing cycle to actually start.
|
||||||
|
// If the request was rejected upstream (e.g. estopped) cycle
|
||||||
|
// never leaves idle and we bail rather than home A in isolation.
|
||||||
|
const startedAt = Date.now();
|
||||||
|
while (Date.now() - startedAt < 5000) {
|
||||||
|
if (cycleNow() != "idle") break;
|
||||||
|
await wait(100);
|
||||||
|
}
|
||||||
|
if (cycleNow() == "idle") {
|
||||||
|
console.warn("home_all: main homing cycle never started; skipping aux");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: wait up to 2 minutes for the gantry to finish.
|
||||||
|
const settledAt = Date.now();
|
||||||
|
while (Date.now() - settledAt < 120000) {
|
||||||
|
if (cycleNow() == "idle") break;
|
||||||
|
await wait(200);
|
||||||
|
}
|
||||||
|
if (cycleNow() != "idle") {
|
||||||
|
console.warn("home_all: gantry homing did not complete in time");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
api.put("aux/home").catch(function (err) {
|
||||||
|
console.error("Aux home failed:", err);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
aux_jog: function (delta_mm) {
|
||||||
|
api.put("aux/jog", { mm: delta_mm }).catch(function (err) {
|
||||||
|
console.error("Aux jog failed:", err);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
aux_jog_incr: function (sign) {
|
||||||
|
const amount = this.jog_incr_amounts[this.display_units][this.jog_incr];
|
||||||
|
const delta_mm = sign * (this.metric ? amount : amount * 25.4);
|
||||||
|
this.aux_jog(delta_mm);
|
||||||
},
|
},
|
||||||
|
|
||||||
show_set_position: function (axis) {
|
show_set_position: function (axis) {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ module.exports = {
|
|||||||
"io-view": require("./io-view"),
|
"io-view": require("./io-view"),
|
||||||
"macros-view": require("./macros"),
|
"macros-view": require("./macros"),
|
||||||
"help-view": require("./help-view"),
|
"help-view": require("./help-view"),
|
||||||
|
"a-axis-view": require("./a-axis-view"),
|
||||||
"cheat-sheet-view": {
|
"cheat-sheet-view": {
|
||||||
template: "#cheat-sheet-view-template",
|
template: "#cheat-sheet-view-template",
|
||||||
data: function () {
|
data: function () {
|
||||||
@@ -56,6 +57,9 @@ module.exports = {
|
|||||||
{ sub: "motor", motor: 1, href: "#motor:1", icon: "fa-arrows-up-down-left-right", label: "Motor 1" },
|
{ sub: "motor", motor: 1, href: "#motor:1", icon: "fa-arrows-up-down-left-right", label: "Motor 1" },
|
||||||
{ sub: "motor", motor: 2, href: "#motor:2", icon: "fa-arrows-up-down-left-right", label: "Motor 2" },
|
{ sub: "motor", motor: 2, href: "#motor:2", icon: "fa-arrows-up-down-left-right", label: "Motor 2" },
|
||||||
{ sub: "motor", motor: 3, href: "#motor:3", icon: "fa-arrows-up-down-left-right", label: "Motor 3" },
|
{ sub: "motor", motor: 3, href: "#motor:3", icon: "fa-arrows-up-down-left-right", label: "Motor 3" },
|
||||||
|
// Auxiliary axis (auxcnc ESP32 - exposed to gplan as A).
|
||||||
|
// Mounts the AAxisSettings Svelte component on its own page.
|
||||||
|
{ sub: "a-axis", href: "#a-axis", icon: "fa-arrows-up-down", label: "A Axis" },
|
||||||
{ section: " " },
|
{ section: " " },
|
||||||
{ sub: "help", href: "#help", icon: "fa-circle-question", label: "Help" },
|
{ sub: "help", href: "#help", icon: "fa-circle-question", label: "Help" },
|
||||||
],
|
],
|
||||||
@@ -133,6 +137,7 @@ module.exports = {
|
|||||||
// layout, which under tablet mode pulls the fixed header out
|
// layout, which under tablet mode pulls the fixed header out
|
||||||
// of view.
|
// of view.
|
||||||
if (location.hash !== item.href) location.hash = item.href;
|
if (location.hash !== item.href) location.hash = item.href;
|
||||||
|
this._a_axis_focus = (item.sub === "a-axis");
|
||||||
const reset = () => {
|
const reset = () => {
|
||||||
// Force any inadvertent ancestor scroll back to 0 before
|
// Force any inadvertent ancestor scroll back to 0 before
|
||||||
// we move .settings-content explicitly.
|
// we move .settings-content explicitly.
|
||||||
@@ -155,6 +160,7 @@ module.exports = {
|
|||||||
requestAnimationFrame(reset);
|
requestAnimationFrame(reset);
|
||||||
}, 320);
|
}, 320);
|
||||||
} else {
|
} else {
|
||||||
|
this._a_axis_focus = false;
|
||||||
if (location.hash !== item.href) location.hash = item.href;
|
if (location.hash !== item.href) location.hash = item.href;
|
||||||
// Reset .app-body scroll so each route starts at the top.
|
// Reset .app-body scroll so each route starts at the top.
|
||||||
const body = document.querySelector(".app-body");
|
const body = document.querySelector(".app-body");
|
||||||
|
|||||||
4
src/pug/templates/a-axis-view.pug
Normal file
4
src/pug/templates/a-axis-view.pug
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
script#a-axis-view-template(type="text/x-template")
|
||||||
|
#a-axis-page
|
||||||
|
h1 A Axis (auxcnc)
|
||||||
|
#a-axis-mount
|
||||||
@@ -92,8 +92,33 @@ script#control-view-template(type="text/x-template")
|
|||||||
.fa.fa-arrow-down.ico(style="transform: rotate(-45deg)")
|
.fa.fa-arrow-down.ico(style="transform: rotate(-45deg)")
|
||||||
button.jbtn(@click="jog_fn(0, 0, -1, 0)") Z−
|
button.jbtn(@click="jog_fn(0, 0, -1, 0)") Z−
|
||||||
|
|
||||||
// Row 4 — A axis (rotary) when rotary is enabled.
|
// Row 4 — A axis (the auxcnc-driven external axis) when enabled.
|
||||||
template(v-if="state['2an'] == 3")
|
// A- | A+ | Probe XYZ | Probe Z
|
||||||
|
// "Home A" lives in the DRO table's actions column on the
|
||||||
|
// right, so it doesn't need a tile here. The legacy w.enabled
|
||||||
|
// gate is kept so older installs (where the auxcnc axis still
|
||||||
|
// appears as W via the side-channel) keep working.
|
||||||
|
template(v-if="w.enabled || a.enabled")
|
||||||
|
button.jbtn(@click="aux_jog_incr(-1)",
|
||||||
|
:disabled="!(w.enabled || a.enabled)")
|
||||||
|
.fa.fa-arrow-down.ico
|
||||||
|
span.lbl A−
|
||||||
|
button.jbtn(@click="aux_jog_incr(+1)",
|
||||||
|
:disabled="!(w.enabled || a.enabled)")
|
||||||
|
.fa.fa-arrow-up.ico
|
||||||
|
span.lbl A+
|
||||||
|
button.jbtn(@click="showProbeDialog('xyz')",
|
||||||
|
:class="{'load-on': !state['pw']}")
|
||||||
|
.fa.fa-bullseye.ico
|
||||||
|
span.lbl Probe XYZ
|
||||||
|
button.jbtn(@click="showProbeDialog('z')",
|
||||||
|
:class="{'load-on': !state['pw']}")
|
||||||
|
.fa.fa-bullseye.ico
|
||||||
|
span.lbl Probe Z
|
||||||
|
|
||||||
|
// Row 4 — A axis (rotary) when no W and rotary is enabled
|
||||||
|
// (Vue 1 has no v-else-if; we negate w.enabled explicitly.)
|
||||||
|
template(v-if="!w.enabled && state['2an'] == 3")
|
||||||
button.jbtn.dir(@click="jog_fn(0, 0, 0, -1)")
|
button.jbtn.dir(@click="jog_fn(0, 0, 0, -1)")
|
||||||
.fa.fa-rotate-left.ico
|
.fa.fa-rotate-left.ico
|
||||||
span.lbl A−
|
span.lbl A−
|
||||||
@@ -109,7 +134,7 @@ script#control-view-template(type="text/x-template")
|
|||||||
span.lbl Probe
|
span.lbl Probe
|
||||||
|
|
||||||
// Row 4 — fallback probe / zero / home shortcuts
|
// Row 4 — fallback probe / zero / home shortcuts
|
||||||
template(v-if="state['2an'] != 3")
|
template(v-if="!w.enabled && state['2an'] != 3")
|
||||||
button.jbtn(@click="showProbeDialog('xyz')",
|
button.jbtn(@click="showProbeDialog('xyz')",
|
||||||
:class="{'load-on': !state['pw']}")
|
:class="{'load-on': !state['pw']}")
|
||||||
.fa.fa-bullseye.ico
|
.fa.fa-bullseye.ico
|
||||||
@@ -193,7 +218,8 @@ script#control-view-template(type="text/x-template")
|
|||||||
.actions-cell
|
.actions-cell
|
||||||
// Master Home All. Each row's Actions cell has a per-axis
|
// Master Home All. Each row's Actions cell has a per-axis
|
||||||
// home button; this header-level button homes every
|
// home button; this header-level button homes every
|
||||||
// enabled axis (legacy Onefinity behavior).
|
// enabled axis (legacy Onefinity behavior). Auto-includes
|
||||||
|
// the auxiliary A axis when it is enabled.
|
||||||
button.icon-btn(:disabled="!is_idle",
|
button.icon-btn(:disabled="!is_idle",
|
||||||
title="Home all axes.", @click="home_all()")
|
title="Home all axes.", @click="home_all()")
|
||||||
.fa.fa-house-chimney
|
.fa.fa-house-chimney
|
||||||
@@ -223,6 +249,28 @@ script#control-view-template(type="text/x-template")
|
|||||||
@click=`home('${axis}')`)
|
@click=`home('${axis}')`)
|
||||||
.fa.fa-home
|
.fa.fa-home
|
||||||
|
|
||||||
|
// Legacy auxiliary-axis row - shown only when the auxcnc stepper is
|
||||||
|
// *not* exposed as a virtual A axis. After v2 the standard
|
||||||
|
// A row above renders this axis natively (with full offset
|
||||||
|
// + set-position support); this row only appears on legacy
|
||||||
|
// installs that haven't migrated yet.
|
||||||
|
.dro-row(:class="w.klass + ' ' + w.tklass",
|
||||||
|
v-if="w.enabled && !a.enabled",
|
||||||
|
:title="w.title")
|
||||||
|
.dro-axis.axis-w W
|
||||||
|
.dro-pos: unit-value(:value="w.pos", precision=4)
|
||||||
|
.dro-sec: unit-value(:value="w.abs", precision=3)
|
||||||
|
.dro-sec —
|
||||||
|
.actions-cell
|
||||||
|
button.icon-btn(disabled, style="visibility:hidden")
|
||||||
|
.fa.fa-gear
|
||||||
|
button.icon-btn(disabled, style="visibility:hidden")
|
||||||
|
.fa.fa-location-dot
|
||||||
|
button.icon-btn(:class="w.homed ? 'state-green' : 'state-amber'",
|
||||||
|
:disabled="!w.enabled",
|
||||||
|
title="Home auxiliary axis.", @click="aux_home()")
|
||||||
|
.fa.fa-home
|
||||||
|
|
||||||
// ----- Status strip -----
|
// ----- Status strip -----
|
||||||
.status-strip
|
.status-strip
|
||||||
.stat-card
|
.stat-card
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ script#settings-shell-view-template(type="text/x-template")
|
|||||||
:index="index", :config="config", :template="template", :state="state")
|
:index="index", :config="config", :template="template", :state="state")
|
||||||
io-view(v-if="sub === 'io' && config_ready",
|
io-view(v-if="sub === 'io' && config_ready",
|
||||||
:index="index", :config="config", :template="template", :state="state")
|
:index="index", :config="config", :template="template", :state="state")
|
||||||
|
a-axis-view(v-if="sub === 'a-axis' && config_ready",
|
||||||
|
:index="index", :config="config", :template="template", :state="state")
|
||||||
macros-view(v-if="sub === 'macros' && config_ready",
|
macros-view(v-if="sub === 'macros' && config_ready",
|
||||||
:index="index", :config="config", :template="template", :state="state")
|
:index="index", :config="config", :template="template", :state="state")
|
||||||
help-view(v-if="sub === 'help' && config_ready",
|
help-view(v-if="sub === 'help' && config_ready",
|
||||||
|
|||||||
706
src/py/bbctrl/AuxAxis.py
Normal file
706
src/py/bbctrl/AuxAxis.py
Normal file
@@ -0,0 +1,706 @@
|
|||||||
|
################################################################################
|
||||||
|
#
|
||||||
|
# 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 axis travel
|
||||||
|
'dir_sign': 1, # +1 or -1: maps logical+ to motor+ steps
|
||||||
|
# Logical axis letter exposed to gplan. The auxcnc ESP stepper
|
||||||
|
# is presented to the planner as this axis (default 'a' = standard
|
||||||
|
# 4th axis). gcode uses A for moves; the host ExternalAxis layer
|
||||||
|
# forks A motion to the ESP transparently.
|
||||||
|
'axis_letter': 'a',
|
||||||
|
'min_mm': 0.0, # soft limit min (mm), exposed as 4tn
|
||||||
|
'max_mm': 100.0, # soft limit max (mm), exposed as 4tm
|
||||||
|
# Per-axis kinematic limits used to populate the planner's config.
|
||||||
|
# Units match the bbctrl/onefinity per-motor convention so the
|
||||||
|
# values are directly comparable to motors 0-3:
|
||||||
|
# max_velocity_m_per_min m/min (planner sees * 1000 = mm/min)
|
||||||
|
# max_accel_km_per_min2 km/min2 (planner sees * 1e6 = mm/min2)
|
||||||
|
# max_jerk_km_per_min3 km/min3 (planner sees * 1e6 = mm/min3)
|
||||||
|
'max_velocity_m_per_min': 6.0,
|
||||||
|
'max_accel_km_per_min2': 100.0,
|
||||||
|
'max_jerk_km_per_min3': 500.0,
|
||||||
|
# Informational only - rate caps that actually clamp the move
|
||||||
|
# are on the ESP via step_max_sps below.
|
||||||
|
'max_feed_mm_min': 600.0,
|
||||||
|
'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.
|
||||||
|
# Speeds tuned for a typical 25 steps/mm aux drive (so 1 step =
|
||||||
|
# 0.04 mm). With the limit-aware ESP firmware these values give
|
||||||
|
# a brisk seek (100 mm/s), enough backoff to clear the switch
|
||||||
|
# hysteresis (16 mm), and a slow re-engage (10 mm/s) that's
|
||||||
|
# accurate without being painfully slow on a longer axis.
|
||||||
|
'home_fast_sps': 2500, # ≈ 100 mm/s @ 25 steps/mm
|
||||||
|
'home_slow_sps': 250, # ≈ 10 mm/s
|
||||||
|
'home_backoff_steps': 400, # ≈ 16 mm
|
||||||
|
'home_maxtravel_steps': 200000,
|
||||||
|
# If HOME starts with the limit switch already tripped the ESP
|
||||||
|
# first moves this many steps away from the limit and then
|
||||||
|
# rechecks. If the switch is still active afterward, HOME hard-
|
||||||
|
# fails (refuses to set zero blindly when we may already be past
|
||||||
|
# the home position). Default ≈ 10 mm @ 25 steps/mm. Set to 0 to
|
||||||
|
# disable the preclear move (HOME then fails immediately if the
|
||||||
|
# switch reads active at start, matching the original behaviour).
|
||||||
|
'home_preclear_mm': 10.0,
|
||||||
|
'step_max_sps': 4000, # ≈ 160 mm/s normal-move cap
|
||||||
|
'step_accel_sps2': 12000,
|
||||||
|
'step_start_sps': 200,
|
||||||
|
'limit_low': True,
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Z-A coupling interlock
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# The auxiliary A axis carries a tool that physically hangs below
|
||||||
|
# the Z-axis spindle nose. Beyond a certain Z descent the two
|
||||||
|
# collide unless A drops with Z. The constraint, in machine coords,
|
||||||
|
# is:
|
||||||
|
# A_machine - Z_machine <= K
|
||||||
|
# where K = (A_home_mm - z_home_mm) + couple_z_clearance_mm.
|
||||||
|
# When enabled this is enforced everywhere motion can be
|
||||||
|
# initiated (planner, MDI, jog, file load) and the AuxPreprocessor
|
||||||
|
# injects pre-position A moves before Z descends past the safe
|
||||||
|
# band.
|
||||||
|
'couple_z_enabled': True,
|
||||||
|
'couple_z_clearance_mm': 22.0, # Z drop allowed before A must follow
|
||||||
|
'z_home_mm': 0.0, # Z's machine position when homed
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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')
|
||||||
|
|
||||||
|
# Legacy aux.json fields that have been renamed for clarity.
|
||||||
|
# Loaded values are migrated up on every load/save so existing
|
||||||
|
# installs keep working without operator intervention.
|
||||||
|
_LEGACY_FIELD_MAP = {
|
||||||
|
'min_w': 'min_mm',
|
||||||
|
'max_w': 'max_mm',
|
||||||
|
}
|
||||||
|
|
||||||
|
def _migrate_legacy_fields(self, cfg):
|
||||||
|
"""In-place rename of legacy keys in `cfg` (dict). Returns
|
||||||
|
True if anything was migrated, so callers can decide whether
|
||||||
|
to persist the upgraded form.
|
||||||
|
"""
|
||||||
|
migrated = False
|
||||||
|
for old, new in self._LEGACY_FIELD_MAP.items():
|
||||||
|
if old in cfg:
|
||||||
|
if new not in cfg:
|
||||||
|
cfg[new] = cfg[old]
|
||||||
|
del cfg[old]
|
||||||
|
migrated = True
|
||||||
|
return migrated
|
||||||
|
|
||||||
|
def _load_config(self):
|
||||||
|
path = self._config_path()
|
||||||
|
if os.path.exists(path):
|
||||||
|
try:
|
||||||
|
with open(path) as f:
|
||||||
|
user = json.load(f)
|
||||||
|
migrated = self._migrate_legacy_fields(user)
|
||||||
|
# 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)
|
||||||
|
if migrated:
|
||||||
|
# Persist the upgraded form so future restarts
|
||||||
|
# see the new field names directly.
|
||||||
|
try:
|
||||||
|
self.save_config(self._cfg)
|
||||||
|
self.log.info(
|
||||||
|
'Migrated aux.json legacy fields '
|
||||||
|
'(min_w/max_w -> min_mm/max_mm)')
|
||||||
|
except Exception:
|
||||||
|
self.log.warning(
|
||||||
|
'Could not persist aux.json migration')
|
||||||
|
except Exception:
|
||||||
|
self.log.error('Failed to read aux.json: %s'
|
||||||
|
% traceback.format_exc())
|
||||||
|
|
||||||
|
def save_config(self, cfg):
|
||||||
|
merged = dict(DEFAULTS)
|
||||||
|
# Accept legacy keys from callers that may still send the
|
||||||
|
# old names (older UI bundles, hand-edited POSTs).
|
||||||
|
cfg = dict(cfg)
|
||||||
|
self._migrate_legacy_fields(cfg)
|
||||||
|
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 set_state_observer(self, fn):
|
||||||
|
"""Register a callback invoked after every _publish_state.
|
||||||
|
Used by ExternalAxis to mirror the homed flag into State."""
|
||||||
|
self._state_observer = fn
|
||||||
|
|
||||||
|
def home(self):
|
||||||
|
"""Run the homing cycle on the ESP. Blocks until done. Raises on
|
||||||
|
failure. Updates aux_homed and aux_pos.
|
||||||
|
|
||||||
|
The ESP's home_zero is pre-loaded via HOMECFG so when the cycle
|
||||||
|
completes the step counter already corresponds to home_position_mm.
|
||||||
|
That way the homed-state survives a bbctrl restart correctly
|
||||||
|
(we don't need a post-home WPOS write, which would clear HOMED)."""
|
||||||
|
self._require_present()
|
||||||
|
# Make sure home_zero on the ESP matches our current
|
||||||
|
# home_position_mm in case the user just edited config.
|
||||||
|
self._push_homecfg()
|
||||||
|
line = self._rpc('HOME', topic='home', timeout=120.0)
|
||||||
|
# line is the body after '[home] '. Only terminal lines use
|
||||||
|
# the [home] topic now (done / failed); progress is [home_log].
|
||||||
|
if line.startswith('done'):
|
||||||
|
self._pos_steps = self._parse_kv_int(line, 'pos', 0)
|
||||||
|
self._homed = True
|
||||||
|
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)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------- ATC commands
|
||||||
|
#
|
||||||
|
# The auxcnc firmware drives an AMB 1050 FME-W DI tool changer via
|
||||||
|
# three pneumatic valves on relays 1-3. The ESP runs the timed
|
||||||
|
# sequences itself; the host just kicks them off and waits for the
|
||||||
|
# terminal reply.
|
||||||
|
|
||||||
|
def atc_droptool(self, timeout=30.0):
|
||||||
|
"""Eject the current tool. Opens the collet (V1), oscillates the
|
||||||
|
ejector (V2), then re-clamps with a bleed cycle. Blocks until
|
||||||
|
the ESP reports done. Raises on failure."""
|
||||||
|
self._require_present()
|
||||||
|
line = self._rpc('DROPTOOL', topic='droptool', timeout=timeout)
|
||||||
|
if line.startswith('done'):
|
||||||
|
return
|
||||||
|
reason = line.split('reason=', 1)[1] if 'reason=' in line else line
|
||||||
|
raise AuxAxisError('DROPTOOL failed: %s' % reason)
|
||||||
|
|
||||||
|
def atc_grabtool(self, timeout=30.0):
|
||||||
|
"""Pick up a tool that's already been seated by the operator.
|
||||||
|
Opens V1 (releases the collet), waits for the operator to insert
|
||||||
|
the holder, then re-clamps with a bleed cycle. Blocks."""
|
||||||
|
self._require_present()
|
||||||
|
line = self._rpc('GRABTOOL', topic='grabtool', timeout=timeout)
|
||||||
|
if line.startswith('done'):
|
||||||
|
return
|
||||||
|
reason = line.split('reason=', 1)[1] if 'reason=' in line else line
|
||||||
|
raise AuxAxisError('GRABTOOL failed: %s' % reason)
|
||||||
|
|
||||||
|
def atc_release(self, timeout=5.0):
|
||||||
|
"""Manually open the collet (release-only, no clamp). Use
|
||||||
|
atc_clamp() afterwards once the new holder is in place."""
|
||||||
|
self._require_present()
|
||||||
|
line = self._rpc('RELEASE', topic='release', timeout=timeout)
|
||||||
|
if line.startswith('done'):
|
||||||
|
return
|
||||||
|
reason = line.split('reason=', 1)[1] if 'reason=' in line else line
|
||||||
|
raise AuxAxisError('RELEASE failed: %s' % reason)
|
||||||
|
|
||||||
|
def atc_clamp(self, timeout=10.0):
|
||||||
|
"""Manually clamp the collet (run a full bleed cycle). Pairs
|
||||||
|
with atc_release() for two-step manual tool changes."""
|
||||||
|
self._require_present()
|
||||||
|
line = self._rpc('CLAMP', topic='clamp', timeout=timeout)
|
||||||
|
if line.startswith('done'):
|
||||||
|
return
|
||||||
|
reason = line.split('reason=', 1)[1] if 'reason=' in line else line
|
||||||
|
raise AuxAxisError('CLAMP failed: %s' % reason)
|
||||||
|
|
||||||
|
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_mm'])
|
||||||
|
hi = float(self._cfg['max_mm'])
|
||||||
|
if hi <= lo:
|
||||||
|
return # no limits
|
||||||
|
if target_mm < lo - 1e-6 or target_mm > hi + 1e-6:
|
||||||
|
raise AuxAxisError(
|
||||||
|
'A=%.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)
|
||||||
|
|
||||||
|
def _do_line(self, signed_steps, length_mm,
|
||||||
|
max_accel_mm_min2, max_jerk_mm_min3,
|
||||||
|
entry_vel_mm_min, exit_vel_mm_min,
|
||||||
|
times_min, ignore_limits=False, timeout=300.0):
|
||||||
|
"""Run a 7-segment jerk-limited S-curve on the ESP that mirrors
|
||||||
|
gplan/buildbotics' planner output exactly.
|
||||||
|
|
||||||
|
Parameters are in the same units the AVR/gplan use:
|
||||||
|
- length_mm: absolute travel in mm (>= 0)
|
||||||
|
- max_accel: mm/min^2
|
||||||
|
- max_jerk: mm/min^3
|
||||||
|
- entry/exit_vel: mm/min
|
||||||
|
- times_min: 7-tuple of section durations in minutes
|
||||||
|
|
||||||
|
ignore_limits sets safe=0 on the ESP - used for jog/move
|
||||||
|
endpoints that may run before homing.
|
||||||
|
|
||||||
|
Blocks until the ESP reports done or aborted. Updates the
|
||||||
|
position mirror and re-publishes state on every reply.
|
||||||
|
"""
|
||||||
|
if signed_steps == 0 or length_mm <= 0:
|
||||||
|
return
|
||||||
|
if not any(times_min):
|
||||||
|
raise AuxAxisError('LINE rejected: all section times are zero')
|
||||||
|
# Build the LINE command. Float formatting matches the AVR's
|
||||||
|
# printf precision (6 sig figs) - that's well above what the
|
||||||
|
# ESP needs given it integrates into a few thousand 4 ms
|
||||||
|
# segments per move.
|
||||||
|
parts = [
|
||||||
|
'LINE',
|
||||||
|
'steps=%d' % int(signed_steps),
|
||||||
|
'length=%.6f' % float(length_mm),
|
||||||
|
'max_accel=%.6f' % float(max_accel_mm_min2),
|
||||||
|
'max_jerk=%.6f' % float(max_jerk_mm_min3),
|
||||||
|
'entry_vel=%.6f' % float(entry_vel_mm_min),
|
||||||
|
'exit_vel=%.6f' % float(exit_vel_mm_min),
|
||||||
|
]
|
||||||
|
for i, t in enumerate(times_min):
|
||||||
|
if t and t > 0:
|
||||||
|
parts.append('t%d=%.9f' % (i, float(t)))
|
||||||
|
if ignore_limits:
|
||||||
|
parts.append('safe=0')
|
||||||
|
cmd = ' '.join(parts)
|
||||||
|
line = self._rpc(cmd, topic='line', timeout=timeout)
|
||||||
|
# line: "done pos=P emitted=N" or "aborted pos=P emitted=N 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
|
||||||
|
zero_steps = self._mm_to_steps(c['home_position_mm'])
|
||||||
|
# preclear: how far (in steps) the ESP backs off if HOME is
|
||||||
|
# invoked while the limit switch is already tripped. Computed
|
||||||
|
# from home_preclear_mm so the operator configures it in mm.
|
||||||
|
spm = float(c.get('steps_per_mm', 1.0)) or 1.0
|
||||||
|
preclear_steps = int(round(abs(float(c['home_preclear_mm'])) * spm))
|
||||||
|
cmd = ('HOMECFG dir=%s fast=%d slow=%d backoff=%d maxtravel=%d '
|
||||||
|
'zero=%d accel=%d step_max=%d step_start=%d limit_low=%d '
|
||||||
|
'preclear=%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(zero_steps),
|
||||||
|
int(c['step_accel_sps2']),
|
||||||
|
int(c['step_max_sps']),
|
||||||
|
int(c['step_start_sps']),
|
||||||
|
1 if c['limit_low'] else 0,
|
||||||
|
preclear_steps,
|
||||||
|
)
|
||||||
|
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
|
||||||
|
# Force the host to start unhomed regardless of what the ESP
|
||||||
|
# remembers from a prior session. The ESP's homed flag survives
|
||||||
|
# bbctrl restarts (since the ESP itself wasn't power-cycled),
|
||||||
|
# but the host's planner offsets and DRO position get reset to
|
||||||
|
# zero on bbctrl boot. Trusting the ESP's homed flag would mean
|
||||||
|
# the user thinks A is homed at the wrong work-coord origin
|
||||||
|
# (offset_a=0 but ESP physically at home_position_mm). Sending
|
||||||
|
# UNHOME forces the user to re-home explicitly, which sets up
|
||||||
|
# the offset and gplan state correctly via the homing path in
|
||||||
|
# Mach.home.
|
||||||
|
try:
|
||||||
|
self._rpc('UNHOME', topic='ok', timeout=2.0)
|
||||||
|
self._homed = False
|
||||||
|
except Exception:
|
||||||
|
# Fall back to whatever HOMED? says - but treat any
|
||||||
|
# missing UNHOME support as "trust ESP's flag" so we
|
||||||
|
# don't break older firmware.
|
||||||
|
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(
|
||||||
|
'Auxiliary 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
|
||||||
|
# Notify the external-axis layer so it can mirror state
|
||||||
|
# (e.g. homed flag) into the synthetic motor vars.
|
||||||
|
observer = getattr(self, '_state_observer', None)
|
||||||
|
if observer is not None:
|
||||||
|
try:
|
||||||
|
observer()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
400
src/py/bbctrl/AuxPreprocessor.py
Normal file
400
src/py/bbctrl/AuxPreprocessor.py
Normal file
@@ -0,0 +1,400 @@
|
|||||||
|
################################################################################
|
||||||
|
#
|
||||||
|
# AuxPreprocessor - rewrite ATC M-codes into hook calls
|
||||||
|
#
|
||||||
|
# History
|
||||||
|
# -------
|
||||||
|
# v1: rewrote W tokens into (MSG,HOOK:aux:N) lines because the bbctrl
|
||||||
|
# planner only understood XYZABC and the W axis was driven via a
|
||||||
|
# side-channel.
|
||||||
|
# v2: W is now exposed to gplan as a virtual A axis (see ExternalAxis),
|
||||||
|
# so gplan handles W motion natively. The preprocessor no longer
|
||||||
|
# touches W tokens. ATC pneumatics still go through the hook
|
||||||
|
# channel because they're events, not motion.
|
||||||
|
#
|
||||||
|
# What this still does
|
||||||
|
# --------------------
|
||||||
|
# Maps four user-defined M-codes onto pneumatic-tool-changer events:
|
||||||
|
#
|
||||||
|
# M100 DROPTOOL -> (MSG,HOOK:droptool:)
|
||||||
|
# M101 GRABTOOL -> (MSG,HOOK:grabtool:)
|
||||||
|
# M102 RELEASE -> (MSG,HOOK:release:)
|
||||||
|
# M103 CLAMP -> (MSG,HOOK:clamp:)
|
||||||
|
#
|
||||||
|
# M100-M103 are in LinuxCNC/Buildbotics' user-defined range, so the
|
||||||
|
# planner won't error if the codes leak through unrewritten - it just
|
||||||
|
# won't *do* anything. We strip them out and emit the matching hook
|
||||||
|
# line in their place.
|
||||||
|
#
|
||||||
|
# The preprocessor is intentionally conservative: anything it doesn't
|
||||||
|
# understand is left alone.
|
||||||
|
#
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
|
||||||
|
# Strip line comments so we don't get fooled by "(M100 not really)".
|
||||||
|
_PAREN_COMMENT_RE = re.compile(r'\([^)]*\)')
|
||||||
|
|
||||||
|
# ATC pneumatics M-codes mapped onto hook events.
|
||||||
|
_ATC_M_CODES = {
|
||||||
|
100: 'droptool',
|
||||||
|
101: 'grabtool',
|
||||||
|
102: 'release',
|
||||||
|
103: 'clamp',
|
||||||
|
}
|
||||||
|
_ATC_M_RE = re.compile(
|
||||||
|
r'(?<![A-Za-z_0-9])[Mm]\s*0*(' +
|
||||||
|
'|'.join(str(n) for n in _ATC_M_CODES) +
|
||||||
|
r')(?![\w.])'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Detect a W axis token. We no longer rewrite W to A automatically;
|
||||||
|
# instead we warn so the user knows their old gcode needs migration.
|
||||||
|
# (The W support was removed when the axis was integrated as a real
|
||||||
|
# A axis through gplan.)
|
||||||
|
_W_TOKEN_RE = re.compile(r'(?<![A-Za-z_0-9])[Ww]\s*[-+]?\d*\.?\d+')
|
||||||
|
|
||||||
|
# Match a single axis word (letter + optional whitespace + signed decimal)
|
||||||
|
# for Z, A, X, Y. Used to extract modal targets while preserving the
|
||||||
|
# original line for emission. We deliberately ignore I/J/K/R (arc params)
|
||||||
|
# because they're not endpoints.
|
||||||
|
_AXIS_TOKEN_RES = {
|
||||||
|
'z': re.compile(r'(?<![A-Za-z_0-9])[Zz]\s*([-+]?\d*\.?\d+)'),
|
||||||
|
'a': re.compile(r'(?<![A-Za-z_0-9])[Aa]\s*([-+]?\d*\.?\d+)'),
|
||||||
|
'x': re.compile(r'(?<![A-Za-z_0-9])[Xx]\s*([-+]?\d*\.?\d+)'),
|
||||||
|
'y': re.compile(r'(?<![A-Za-z_0-9])[Yy]\s*([-+]?\d*\.?\d+)'),
|
||||||
|
}
|
||||||
|
_G_CODE_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, coupling=None):
|
||||||
|
"""`coupling`, when supplied, enables Z-A coupling injection.
|
||||||
|
Expected shape:
|
||||||
|
{
|
||||||
|
'enabled': bool,
|
||||||
|
'clearance_mm': float, # max (A_wc - Z_wc)
|
||||||
|
'a_initial_wc': float, # A's work-coord position at
|
||||||
|
# file start (typically 0 if
|
||||||
|
# operator zeroed at home)
|
||||||
|
'z_initial_wc': float, # Z's work-coord position at
|
||||||
|
# file start (typically 0)
|
||||||
|
}
|
||||||
|
Pass None to disable injection (preprocessor still rewrites
|
||||||
|
ATC M-codes)."""
|
||||||
|
self.log = log
|
||||||
|
self._w_warned = False
|
||||||
|
self._coupling = coupling if (coupling and
|
||||||
|
coupling.get('enabled')) else None
|
||||||
|
# Modal state used while scanning the file.
|
||||||
|
if self._coupling is not None:
|
||||||
|
self._a_wc = float(coupling.get('a_initial_wc', 0.0))
|
||||||
|
self._z_wc = float(coupling.get('z_initial_wc', 0.0))
|
||||||
|
self._K = float(coupling.get('clearance_mm', 0.0))
|
||||||
|
else:
|
||||||
|
self._a_wc = 0.0
|
||||||
|
self._z_wc = 0.0
|
||||||
|
self._K = 0.0
|
||||||
|
self._g91_warned = False
|
||||||
|
# Distance mode: True for absolute (G90), False for incremental
|
||||||
|
# (G91). Per RS274 the modal default at start is G90.
|
||||||
|
self._g90 = True
|
||||||
|
|
||||||
|
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_aux(path, coupling=None):
|
||||||
|
"""Quick check: does this file contain anything the preprocessor
|
||||||
|
would rewrite? Returns True for ATC M-codes always, and for
|
||||||
|
any Z/A move if coupling is enabled (we have to scan to know
|
||||||
|
whether injection is needed, so any motion file qualifies)."""
|
||||||
|
couple_active = bool(coupling and coupling.get('enabled'))
|
||||||
|
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 _ATC_M_RE.search(code):
|
||||||
|
return True
|
||||||
|
if couple_active:
|
||||||
|
if _AXIS_TOKEN_RES['z'].search(code) or \
|
||||||
|
_AXIS_TOKEN_RES['a'].search(code):
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Backwards-compat alias.
|
||||||
|
file_uses_w = file_uses_aux
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ Z-A coupling
|
||||||
|
#
|
||||||
|
# Track modal Z and A targets across the file. Whenever a line
|
||||||
|
# would put A above Z by more than `clearance_mm` (i.e. A_wc -
|
||||||
|
# Z_wc > K), we inject `G0 A<safe>` immediately before it so A is
|
||||||
|
# already at the safe position when Z descends. The injected move
|
||||||
|
# uses G0 (rapid) so it's quick.
|
||||||
|
#
|
||||||
|
# Endpoint-only check: gplan plans line endpoints. As long as
|
||||||
|
# (target_A_wc - target_Z_wc) <= K, the trajectory stays safe
|
||||||
|
# because Z's *minimum* during a single line is its endpoint (Z
|
||||||
|
# moves monotonically along a single line block in absolute
|
||||||
|
# mode) and A is held at the pre-positioned value during the move.
|
||||||
|
|
||||||
|
def _extract_g_codes(self, code):
|
||||||
|
"""Return the set of G-codes referenced on `code`. Numeric
|
||||||
|
only, e.g. {0, 1, 90, 17}. Used to track modal state."""
|
||||||
|
out = set()
|
||||||
|
for m in _G_CODE_RE.finditer(code):
|
||||||
|
try:
|
||||||
|
out.add(int(float(m.group(1))))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return out
|
||||||
|
|
||||||
|
def _extract_axis(self, axis, code):
|
||||||
|
"""Return the last value of `axis` token on `code`, or None."""
|
||||||
|
rx = _AXIS_TOKEN_RES.get(axis)
|
||||||
|
if rx is None:
|
||||||
|
return None
|
||||||
|
last = None
|
||||||
|
for m in rx.finditer(code):
|
||||||
|
try:
|
||||||
|
last = float(m.group(1))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return last
|
||||||
|
|
||||||
|
def _maybe_inject_a_down(self, code, fout):
|
||||||
|
"""Inspect `code` (with comments stripped) for an upcoming Z
|
||||||
|
descent; emit a `G0 A<safe>` line on `fout` if needed and
|
||||||
|
update self._a_wc accordingly. Returns True if anything was
|
||||||
|
injected.
|
||||||
|
|
||||||
|
On a violation that cannot be fixed by lowering A (e.g. the
|
||||||
|
operator wrote `G0 A0` while Z is too deep), raise
|
||||||
|
AuxPreprocessorError so the file load surfaces the problem -
|
||||||
|
per the rule we agreed: error, don't silently insert a Z-up.
|
||||||
|
"""
|
||||||
|
if self._coupling is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Distance mode tracking.
|
||||||
|
gs = self._extract_g_codes(code)
|
||||||
|
if 90 in gs: self._g90 = True
|
||||||
|
if 91 in gs:
|
||||||
|
if self._g90 and not self._g91_warned:
|
||||||
|
self._warn(
|
||||||
|
'AuxPreprocessor: G91 (incremental mode) detected; '
|
||||||
|
'Z-A coupling injection is disabled for the rest of '
|
||||||
|
'the file. The runtime check still applies.')
|
||||||
|
self._g91_warned = True
|
||||||
|
self._g90 = False
|
||||||
|
|
||||||
|
# G92 sets coordinate offsets. The new modal value of an
|
||||||
|
# axis is whatever value follows on the same word (e.g.
|
||||||
|
# G92 A0 sets A_wc = 0). Apply that and skip injection.
|
||||||
|
if 92 in gs:
|
||||||
|
new_a = self._extract_axis('a', code)
|
||||||
|
new_z = self._extract_axis('z', code)
|
||||||
|
if new_a is not None: self._a_wc = new_a
|
||||||
|
if new_z is not None: self._z_wc = new_z
|
||||||
|
return False
|
||||||
|
|
||||||
|
# In incremental mode we can still track approximately, but
|
||||||
|
# the user has been warned; skip injection.
|
||||||
|
if not self._g90:
|
||||||
|
return False
|
||||||
|
|
||||||
|
new_z_target = self._extract_axis('z', code)
|
||||||
|
new_a_target = self._extract_axis('a', code)
|
||||||
|
if new_z_target is None and new_a_target is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Modal values after the line executes.
|
||||||
|
a_after = new_a_target if new_a_target is not None else self._a_wc
|
||||||
|
z_after = new_z_target if new_z_target is not None else self._z_wc
|
||||||
|
|
||||||
|
eps = 1e-4
|
||||||
|
if a_after - z_after <= self._K + eps:
|
||||||
|
# Move is safe as authored. Update modal state.
|
||||||
|
self._a_wc = a_after
|
||||||
|
self._z_wc = z_after
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Violation. Two cases:
|
||||||
|
#
|
||||||
|
# (a) The line lowers Z (z_after < self._z_wc) and A is
|
||||||
|
# held or moved upward, so A needs to drop to keep up.
|
||||||
|
# We can fix this by pre-positioning A at z_after + K
|
||||||
|
# BEFORE the line - at which point gplan's plan for the
|
||||||
|
# line is safe at every point along it.
|
||||||
|
#
|
||||||
|
# (b) The line raises A above the safe band while Z is
|
||||||
|
# held (z_after >= self._z_wc) - i.e. the operator
|
||||||
|
# wrote `G0 A0` while Z is parked deep. Auto-injecting
|
||||||
|
# a Z-up here is unsafe (Z could swing into a fixture
|
||||||
|
# or the part) so we error out and let the operator
|
||||||
|
# author the lift.
|
||||||
|
|
||||||
|
safe_a = z_after + self._K
|
||||||
|
|
||||||
|
# If the line itself targets an A above the safe band, the
|
||||||
|
# endpoint violates the rule no matter what we pre-position.
|
||||||
|
# Refuse rather than emit something that runs the gantry into
|
||||||
|
# the tool.
|
||||||
|
if new_a_target is not None and new_a_target > safe_a + eps:
|
||||||
|
raise AuxPreprocessorError(
|
||||||
|
'Z-A coupling violation: line targets A=%.3f at '
|
||||||
|
'Z=%.3f, but max A allowed is %.3f (clearance %.3f). '
|
||||||
|
'Lower the A target or add a Z-up move first.' % (
|
||||||
|
new_a_target, z_after, safe_a, self._K))
|
||||||
|
|
||||||
|
# If the line raises A above the current safe band but Z
|
||||||
|
# isn't dropping with it (no Z target on the line, or Z stays
|
||||||
|
# put), the violation is the operator's A-up, not a Z-down.
|
||||||
|
# Refuse rather than insert a Z-up (which could swing through
|
||||||
|
# a fixture or part).
|
||||||
|
if (new_a_target is not None and
|
||||||
|
new_a_target > self._a_wc + eps and
|
||||||
|
new_z_target is None):
|
||||||
|
raise AuxPreprocessorError(
|
||||||
|
'Z-A coupling violation at line raising A to %.3f '
|
||||||
|
'while Z is at %.3f (max A allowed is %.3f given '
|
||||||
|
'clearance %.3f). Add a Z-up move first.' % (
|
||||||
|
new_a_target, z_after, safe_a, self._K))
|
||||||
|
|
||||||
|
# Case (a): pre-position A.
|
||||||
|
# Don't move A *up* as part of pre-position - if the safe
|
||||||
|
# value is above where A already is, we'd lift A into a
|
||||||
|
# potential collision elsewhere. In practice safe_a < a_wc
|
||||||
|
# whenever we get here (otherwise no violation), but assert
|
||||||
|
# to be sure.
|
||||||
|
if safe_a > self._a_wc + eps:
|
||||||
|
raise AuxPreprocessorError(
|
||||||
|
'Z-A coupling: cannot fix line by lowering A '
|
||||||
|
'(safe A = %.3f > current A = %.3f).' % (
|
||||||
|
safe_a, self._a_wc))
|
||||||
|
fout.write('(injected by AuxPreprocessor: Z-A coupling)\n')
|
||||||
|
fout.write('G0 A%.4f\n' % safe_a)
|
||||||
|
self._a_wc = safe_a
|
||||||
|
# Don't update z_wc yet - the original line will do that
|
||||||
|
# when it runs. But our modal copy must reflect the post-line
|
||||||
|
# value so subsequent injections compute correctly.
|
||||||
|
self._z_wc = z_after
|
||||||
|
# If the original line also moved A, our pre-positioning
|
||||||
|
# supersedes it (we overwrite a_wc above with safe_a then
|
||||||
|
# the original line's A target may push it back up). Update
|
||||||
|
# a_wc to the line's authored A value so further checks see
|
||||||
|
# the post-line state.
|
||||||
|
if new_a_target is not None:
|
||||||
|
self._a_wc = new_a_target
|
||||||
|
return True
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ run
|
||||||
|
|
||||||
|
def process(self, src_path, dst_path):
|
||||||
|
"""Read src_path, write rewritten G-code to dst_path. Returns
|
||||||
|
True if any rewrite happened."""
|
||||||
|
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
|
||||||
|
|
||||||
|
# Warn (once) if the file still uses W tokens. The
|
||||||
|
# standard way is now G1 A<value>; old files must be
|
||||||
|
# migrated by hand.
|
||||||
|
if (not self._w_warned) and _W_TOKEN_RE.search(code):
|
||||||
|
self._warn('Found W axis token in gcode; W is no '
|
||||||
|
'longer recognized by bbctrl. Use A '
|
||||||
|
'instead. (warning suppressed for '
|
||||||
|
'subsequent W tokens in this file)')
|
||||||
|
self._w_warned = True
|
||||||
|
|
||||||
|
# Z-A coupling injection BEFORE the line is emitted.
|
||||||
|
if self._maybe_inject_a_down(code, fout):
|
||||||
|
rewrote_any = True
|
||||||
|
|
||||||
|
# ATC M-codes (M100-M103). Each ATC M-code on the line
|
||||||
|
# is replaced with its (MSG,HOOK:<event>:) line and
|
||||||
|
# stripped from the residual.
|
||||||
|
atc_matches = list(_ATC_M_RE.finditer(line))
|
||||||
|
if atc_matches:
|
||||||
|
rewrote_any = True
|
||||||
|
for m in atc_matches:
|
||||||
|
try: num = int(m.group(1))
|
||||||
|
except ValueError: continue
|
||||||
|
event = _ATC_M_CODES.get(num)
|
||||||
|
if event:
|
||||||
|
fout.write('(MSG,HOOK:%s:)\n' % event)
|
||||||
|
line = _ATC_M_RE.sub('', line)
|
||||||
|
code = _PAREN_COMMENT_RE.sub('', line)
|
||||||
|
code = code.split(';', 1)[0]
|
||||||
|
if not code.strip():
|
||||||
|
# Nothing meaningful left; preserve any trailing
|
||||||
|
# comment text but skip empty lines.
|
||||||
|
rest = line.rstrip()
|
||||||
|
if rest:
|
||||||
|
fout.write(rest + '\n')
|
||||||
|
continue
|
||||||
|
# Other gcode remains on the line - emit it.
|
||||||
|
fout.write(line + '\n')
|
||||||
|
continue
|
||||||
|
|
||||||
|
# No rewrite needed.
|
||||||
|
fout.write(raw)
|
||||||
|
|
||||||
|
return rewrote_any
|
||||||
|
|
||||||
|
|
||||||
|
def preprocess_file(src_path, log=None, coupling=None, **_unused):
|
||||||
|
"""Convenience: rewrite src_path in place if it contains ATC
|
||||||
|
M-codes or needs Z-A coupling injection. Returns True if the
|
||||||
|
file was rewritten.
|
||||||
|
|
||||||
|
`coupling` is an optional dict (see AuxPreprocessor.__init__).
|
||||||
|
Extra keyword args are accepted for backwards compat (the old
|
||||||
|
w_first arg is no longer used)."""
|
||||||
|
if not AuxPreprocessor.file_uses_aux(src_path, coupling=coupling):
|
||||||
|
return False
|
||||||
|
pre = AuxPreprocessor(log=log, coupling=coupling)
|
||||||
|
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
|
||||||
@@ -216,6 +216,32 @@ class Config(object):
|
|||||||
defaults = json.load(f)
|
defaults = json.load(f)
|
||||||
config['selected-tool-settings'] = defaults['selected-tool-settings'];
|
config['selected-tool-settings'] = defaults['selected-tool-settings'];
|
||||||
|
|
||||||
|
# Auxiliary axis nomenclature: rename W -> A in macro names and
|
||||||
|
# filenames. The auxcnc-driven stepper has been integrated into
|
||||||
|
# gplan as A since the option-b migration; old configs may
|
||||||
|
# still carry W Down/W Up macro entries pointing at
|
||||||
|
# w_down.nc/w_up.nc which were renamed on disk to a_down.nc /
|
||||||
|
# a_up.nc. Migrate idempotently on every load so a stale
|
||||||
|
# in-memory copy can never reintroduce the old names.
|
||||||
|
macros = config.get('macros') if isinstance(config, dict) else None
|
||||||
|
if isinstance(macros, list):
|
||||||
|
renames = {
|
||||||
|
'w_down.nc': 'a_down.nc',
|
||||||
|
'w_up.nc': 'a_up.nc',
|
||||||
|
}
|
||||||
|
display_renames = {
|
||||||
|
'W Down': 'A Down',
|
||||||
|
'W Up': 'A Up',
|
||||||
|
}
|
||||||
|
for m in macros:
|
||||||
|
if not isinstance(m, dict): continue
|
||||||
|
fn = m.get('file_name')
|
||||||
|
if isinstance(fn, str) and fn in renames:
|
||||||
|
m['file_name'] = renames[fn]
|
||||||
|
nm = m.get('name')
|
||||||
|
if isinstance(nm, str) and nm in display_renames:
|
||||||
|
m['name'] = display_renames[nm]
|
||||||
|
|
||||||
config['version'] = self.version.split('b')[0]
|
config['version'] = self.version.split('b')[0]
|
||||||
config['full_version'] = self.version
|
config['full_version'] = self.version
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,24 @@ class Ctrl(object):
|
|||||||
self.jog = bbctrl.Jog(self)
|
self.jog = bbctrl.Jog(self)
|
||||||
with Trace.span('ctrl.pwr'):
|
with Trace.span('ctrl.pwr'):
|
||||||
self.pwr = bbctrl.Pwr(self)
|
self.pwr = bbctrl.Pwr(self)
|
||||||
|
with Trace.span('ctrl.hooks'):
|
||||||
|
self.hooks = bbctrl.Hooks(self)
|
||||||
|
with Trace.span('ctrl.aux'):
|
||||||
|
self.aux = bbctrl.AuxAxis(self)
|
||||||
|
with Trace.span('ctrl.ext_axis'):
|
||||||
|
# ExternalAxis exposes the auxcnc ESP stepper as a
|
||||||
|
# virtual A axis that gplan handles natively. Created
|
||||||
|
# unconditionally so State sees the synthetic motor
|
||||||
|
# vars even when aux is disabled (kept inert in that
|
||||||
|
# case via ext_axis.enabled).
|
||||||
|
axis_letter = self.aux._cfg.get('axis_letter', 'a')
|
||||||
|
self.ext_axis = bbctrl.ExternalAxis(
|
||||||
|
self, self.aux, axis_letter=axis_letter)
|
||||||
|
# Hook AuxAxis post-publish callback so homed flag
|
||||||
|
# mirrors into State after homing.
|
||||||
|
self.aux.set_state_observer(
|
||||||
|
self.ext_axis.refresh_homed)
|
||||||
|
self._register_aux_hooks()
|
||||||
|
|
||||||
with Trace.span('ctrl.mach.connect'):
|
with Trace.span('ctrl.mach.connect'):
|
||||||
self.mach.connect()
|
self.mach.connect()
|
||||||
@@ -127,8 +145,61 @@ class Ctrl(object):
|
|||||||
self.preplanner.start()
|
self.preplanner.start()
|
||||||
|
|
||||||
|
|
||||||
|
def _register_aux_hooks(self):
|
||||||
|
"""Wire up auxcnc HOOK: events to AuxAxis methods.
|
||||||
|
|
||||||
|
v2: motion hooks (aux/aux_rel/aux_home/aux_setzero) are
|
||||||
|
retired now that the W axis is integrated through gplan as
|
||||||
|
a virtual A axis (see ExternalAxis). Only the ATC pneumatic
|
||||||
|
hooks remain - those are events, not motion.
|
||||||
|
|
||||||
|
For backwards compatibility with files that still contain
|
||||||
|
(MSG,HOOK:aux_home:) (e.g. older preprocessed gcode), keep
|
||||||
|
an aux_home alias that routes to the standard ext_axis homing
|
||||||
|
path."""
|
||||||
|
log = self.log.get('AuxAxis')
|
||||||
|
|
||||||
|
def _hook_aux_home(ctx):
|
||||||
|
# Legacy: route to the standard external-axis homing.
|
||||||
|
if self.ext_axis is not None and self.ext_axis.enabled:
|
||||||
|
self.ext_axis.home()
|
||||||
|
else:
|
||||||
|
self.aux.home()
|
||||||
|
|
||||||
|
def _hook_droptool(ctx): self.aux.atc_droptool()
|
||||||
|
def _hook_grabtool(ctx): self.aux.atc_grabtool()
|
||||||
|
def _hook_release(ctx): self.aux.atc_release()
|
||||||
|
def _hook_clamp(ctx): self.aux.atc_clamp()
|
||||||
|
|
||||||
|
# Legacy alias for older gcode that used aux_home.
|
||||||
|
self.hooks.register_internal('aux_home', _hook_aux_home,
|
||||||
|
block_unpause=True, auto_resume=True,
|
||||||
|
timeout=180)
|
||||||
|
|
||||||
|
# ATC pneumatics. block_unpause + auto_resume so a program
|
||||||
|
# using M100/M101/M102/M103 pauses at the right point and
|
||||||
|
# resumes once the sequence is done.
|
||||||
|
self.hooks.register_internal('droptool', _hook_droptool,
|
||||||
|
block_unpause=True, auto_resume=True,
|
||||||
|
timeout=60)
|
||||||
|
self.hooks.register_internal('grabtool', _hook_grabtool,
|
||||||
|
block_unpause=True, auto_resume=True,
|
||||||
|
timeout=60)
|
||||||
|
self.hooks.register_internal('release', _hook_release,
|
||||||
|
block_unpause=True, auto_resume=True,
|
||||||
|
timeout=10)
|
||||||
|
self.hooks.register_internal('clamp', _hook_clamp,
|
||||||
|
block_unpause=True, auto_resume=True,
|
||||||
|
timeout=15)
|
||||||
|
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.ext_axis.close()
|
||||||
|
except Exception: pass
|
||||||
|
try: self.aux.close()
|
||||||
|
except Exception: pass
|
||||||
|
|||||||
677
src/py/bbctrl/ExternalAxis.py
Normal file
677
src/py/bbctrl/ExternalAxis.py
Normal file
@@ -0,0 +1,677 @@
|
|||||||
|
################################################################################
|
||||||
|
#
|
||||||
|
# ExternalAxis - bridges a logical motorless axis to step generation on
|
||||||
|
# the auxcnc ESP, so the Buildbotics planner can drive a stepper that
|
||||||
|
# isn't on the AVR.
|
||||||
|
#
|
||||||
|
# Architecture
|
||||||
|
# ------------
|
||||||
|
# The bbctrl planner (camotics gplan) handles parsing, units, modal
|
||||||
|
# state, soft limits, accel ramping and S-curve timing for axes
|
||||||
|
# X, Y, Z, A, B, C. The AVR has 4 motor channels (0-3) and only
|
||||||
|
# generates step pulses for axes that have a motor mapped to them.
|
||||||
|
# An axis with no mapped motor is fully accepted by the AVR - it
|
||||||
|
# updates its internal `ex.position[axis]` and reports `<axis>p` to
|
||||||
|
# the host, but no stepper turns.
|
||||||
|
#
|
||||||
|
# We exploit that: the W stepper is exposed to gplan as A, but no
|
||||||
|
# AVR motor maps to A. The planner does all the gcode-level work
|
||||||
|
# correctly (G90/G91, soft limits, accel, units, modal feed rate);
|
||||||
|
# we intercept the resulting `Cmd.line` blocks in `Planner.__encode`,
|
||||||
|
# strip A out, and forward the A delta to the auxcnc ESP as STEPS.
|
||||||
|
#
|
||||||
|
# To make gplan and State *believe* A is enabled we register a
|
||||||
|
# synthetic motor (index 4) into State.vars, populated from
|
||||||
|
# aux.json, with `4an=3` (axis A), `4me=1` (enabled), and the
|
||||||
|
# usual velocity/accel/jerk/soft-limit vars. State.find_motor and
|
||||||
|
# the snapshot projection are extended to walk index 4. Motor-4
|
||||||
|
# vars never leave the host (they're not in the AVR's schema) so
|
||||||
|
# the AVR is undisturbed.
|
||||||
|
#
|
||||||
|
# v1 coupling: serialize. If a line has any A delta we wait for
|
||||||
|
# the ESP to finish before letting subsequent commands flow. This
|
||||||
|
# matches the behaviour of the previous hook-based approach (no
|
||||||
|
# XYZ+A blending) but with all the planner's correctness guarantees.
|
||||||
|
#
|
||||||
|
# v2 could match ESP move duration to the gplan trapezoid time and
|
||||||
|
# allow concurrent motion; out of scope for v1.
|
||||||
|
#
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
import threading
|
||||||
|
|
||||||
|
try:
|
||||||
|
from queue import Queue
|
||||||
|
except ImportError:
|
||||||
|
from Queue import Queue # py2 just in case
|
||||||
|
|
||||||
|
|
||||||
|
# Synthetic motor index used to expose the external axis to State.
|
||||||
|
# The AVR has motors 0..3; we use 4 as a host-only sentinel.
|
||||||
|
EXTERNAL_MOTOR_INDEX = 4
|
||||||
|
|
||||||
|
# Axis letters in their canonical order; 'a' is index 3.
|
||||||
|
_AXIS_LETTERS = 'xyzabc'
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalAxisError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalAxis(object):
|
||||||
|
"""Bridge between Planner line blocks and AuxAxis serial RPCs.
|
||||||
|
|
||||||
|
Owns no thread; runs RPC calls inline on whatever thread invokes
|
||||||
|
execute_to_mm / home / abort. The Planner runs `__encode` on its
|
||||||
|
own thread which is allowed to block on planner I/O, so blocking
|
||||||
|
inside the interceptor is fine.
|
||||||
|
|
||||||
|
Position tracking: gplan emits absolute targets in mm; the ESP
|
||||||
|
counts steps relative to home_zero. We mirror the last commanded
|
||||||
|
mm position so subsequent line blocks compute the correct delta.
|
||||||
|
`_pos_mm` is also published as `<axis>p` so DRO updates."""
|
||||||
|
|
||||||
|
def __init__(self, ctrl, aux, axis_letter='a'):
|
||||||
|
self.ctrl = ctrl
|
||||||
|
self.aux = aux
|
||||||
|
self.log = ctrl.log.get('ExternalAxis')
|
||||||
|
|
||||||
|
self.axis_letter = (axis_letter or 'a').lower()[:1]
|
||||||
|
if self.axis_letter not in _AXIS_LETTERS:
|
||||||
|
raise ExternalAxisError(
|
||||||
|
'Invalid external axis letter: %r' % axis_letter)
|
||||||
|
# Index in 'xyzabc' (0..5)
|
||||||
|
self.axis_index = _AXIS_LETTERS.index(self.axis_letter)
|
||||||
|
|
||||||
|
self._busy = threading.Event()
|
||||||
|
# Last absolute mm we committed; None until first move /
|
||||||
|
# homing event syncs us up.
|
||||||
|
self._pos_mm = None
|
||||||
|
|
||||||
|
# Single-slot worker queue: __encode posts (target_mm,) tuples
|
||||||
|
# here; the worker thread runs the ESP RPC. Capacity is
|
||||||
|
# intentionally bounded - if it fills it means motion is
|
||||||
|
# outpacing the ESP and we should backpressure the planner.
|
||||||
|
self._work_q = Queue(maxsize=64)
|
||||||
|
self._stop = threading.Event()
|
||||||
|
self._worker = threading.Thread(
|
||||||
|
target=self._worker_loop,
|
||||||
|
name='ExternalAxis-worker', daemon=True)
|
||||||
|
self._worker.start()
|
||||||
|
|
||||||
|
# Push synthetic motor vars into State so the planner sees
|
||||||
|
# this axis as enabled with proper limits/velocity/accel.
|
||||||
|
self._publish_synthetic_motor()
|
||||||
|
# Also seed <axis>p so the DRO has something to render.
|
||||||
|
self.ctrl.state.set(self.axis_letter + 'p', 0.0)
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- enabled
|
||||||
|
|
||||||
|
@property
|
||||||
|
def enabled(self):
|
||||||
|
try:
|
||||||
|
return bool(self.aux is not None
|
||||||
|
and self.aux.enabled
|
||||||
|
and self.aux.present)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# -------------------------------------------------------- configuration
|
||||||
|
|
||||||
|
@property
|
||||||
|
def steps_per_mm(self):
|
||||||
|
try:
|
||||||
|
return float(self.aux._cfg.get('steps_per_mm', 25.0))
|
||||||
|
except Exception:
|
||||||
|
return 25.0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dir_sign(self):
|
||||||
|
try:
|
||||||
|
v = int(self.aux._cfg.get('dir_sign', 1))
|
||||||
|
return -1 if v < 0 else 1
|
||||||
|
except Exception:
|
||||||
|
return 1
|
||||||
|
|
||||||
|
@property
|
||||||
|
def home_position_mm(self):
|
||||||
|
try:
|
||||||
|
return float(self.aux._cfg.get('home_position_mm', 0.0))
|
||||||
|
except Exception:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
# ------------------------------------------------------- soft limits
|
||||||
|
|
||||||
|
def _soft_limits(self):
|
||||||
|
"""Return (min_mm, max_mm) in machine coords, or (None, None)
|
||||||
|
if soft limits are disabled (max <= min)."""
|
||||||
|
try:
|
||||||
|
lo = float(self.aux._cfg.get('min_mm', 0.0))
|
||||||
|
hi = float(self.aux._cfg.get('max_mm', 0.0))
|
||||||
|
except Exception:
|
||||||
|
return (None, None)
|
||||||
|
if hi <= lo:
|
||||||
|
return (None, None)
|
||||||
|
return (lo, hi)
|
||||||
|
|
||||||
|
def _check_soft_limit(self, target_abs_mm):
|
||||||
|
"""Raise ExternalAxisError if target_abs_mm is outside the
|
||||||
|
configured soft limits. Skips the check when the axis isn't
|
||||||
|
homed (matching the standard bbctrl convention that soft
|
||||||
|
limits are gated by homing state) - that lets the user jog
|
||||||
|
away from a stuck position before homing without false
|
||||||
|
rejections.
|
||||||
|
|
||||||
|
Called by both planner-driven motion (enqueue_target_mm) and
|
||||||
|
UI motion (execute_to_mm), so this is the single source of
|
||||||
|
truth regardless of which path triggered the move."""
|
||||||
|
# Honour the homing gate.
|
||||||
|
try:
|
||||||
|
homed = bool(self.aux._homed)
|
||||||
|
except Exception:
|
||||||
|
homed = False
|
||||||
|
if not homed:
|
||||||
|
return
|
||||||
|
lo, hi = self._soft_limits()
|
||||||
|
if lo is None:
|
||||||
|
return
|
||||||
|
# Use a tiny epsilon so floating-point round-trip targets
|
||||||
|
# right at the boundary aren't rejected.
|
||||||
|
eps = 1e-4
|
||||||
|
target = float(target_abs_mm)
|
||||||
|
if target < lo - eps or target > hi + eps:
|
||||||
|
raise ExternalAxisError(
|
||||||
|
'%s axis target %.4f mm is outside soft limits '
|
||||||
|
'[%.3f, %.3f] mm' % (
|
||||||
|
self.axis_letter.upper(), target, lo, hi))
|
||||||
|
|
||||||
|
# ----------------------------------------------- Z-A coupling
|
||||||
|
#
|
||||||
|
# The auxiliary tool hangs below the Z spindle. Beyond a small
|
||||||
|
# Z descent the two collide unless A drops with Z. The
|
||||||
|
# constraint, in machine coords, is
|
||||||
|
#
|
||||||
|
# A_machine - Z_machine <= K
|
||||||
|
# K = (A_home_mm - z_home_mm) + couple_z_clearance_mm
|
||||||
|
#
|
||||||
|
# Enforced before any motion (planner blocks, MDI, jogs). The
|
||||||
|
# AuxPreprocessor injects pre-position A moves into uploaded
|
||||||
|
# files so well-formed gcode runs without having to think about
|
||||||
|
# this. Disabled when couple_z_enabled is false.
|
||||||
|
|
||||||
|
@property
|
||||||
|
def couple_z_enabled(self):
|
||||||
|
try:
|
||||||
|
return bool(self.aux._cfg.get('couple_z_enabled', False))
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def couple_K(self):
|
||||||
|
"""Limit constant K (machine-coord units): the maximum value
|
||||||
|
of (A_machine - Z_machine) before the tool collides. Returns
|
||||||
|
None if the rule isn't applicable (coupling disabled or
|
||||||
|
config missing)."""
|
||||||
|
try:
|
||||||
|
cfg = self.aux._cfg
|
||||||
|
clearance = float(cfg.get('couple_z_clearance_mm', 0.0))
|
||||||
|
a_home = float(cfg.get('home_position_mm', 0.0))
|
||||||
|
z_home = float(cfg.get('z_home_mm', 0.0))
|
||||||
|
return (a_home - z_home) + clearance
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def couple_clearance_mm(self):
|
||||||
|
"""Raw clearance from config: how far Z may travel below its
|
||||||
|
home before A has to start dropping with it. Used by the
|
||||||
|
AuxPreprocessor to inject pre-position A moves into uploaded
|
||||||
|
gcode."""
|
||||||
|
try:
|
||||||
|
return float(self.aux._cfg.get('couple_z_clearance_mm', 0.0))
|
||||||
|
except Exception:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
def _z_machine_now(self):
|
||||||
|
"""Read Z's current machine position from State, or None if
|
||||||
|
Z isn't homed/reported yet. The AVR reports absolute machine
|
||||||
|
positions in <axis>p; the work-coord display is computed by
|
||||||
|
the UI as zp - offset_z, but here we want machine directly."""
|
||||||
|
try:
|
||||||
|
st = self.ctrl.state
|
||||||
|
zp = st.get('zp', None)
|
||||||
|
if zp is None:
|
||||||
|
return None
|
||||||
|
return float(zp)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _a_machine_now(self):
|
||||||
|
"""A's current machine position. ExternalAxis tracks this
|
||||||
|
directly in self._pos_mm (mm in machine coords - we don't
|
||||||
|
apply G92 to A internally; offset_a is informational)."""
|
||||||
|
try:
|
||||||
|
if self._pos_mm is not None:
|
||||||
|
return float(self._pos_mm)
|
||||||
|
# Fall back to whatever the ESP last reported.
|
||||||
|
return float(self.aux.position_mm)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def coupling_for_preprocessor(self):
|
||||||
|
"""Return the dict the AuxPreprocessor wants for in-file
|
||||||
|
injection, or None when coupling is off. We assume the
|
||||||
|
operator authors gcode in a frame where the at-home position
|
||||||
|
is A_wc=0, Z_wc=0 - which matches our home-zeroed setup.
|
||||||
|
Files that use a different convention will fall through to
|
||||||
|
the runtime check."""
|
||||||
|
if not self.couple_z_enabled:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
'enabled': True,
|
||||||
|
'clearance_mm': self.couple_clearance_mm,
|
||||||
|
'a_initial_wc': 0.0,
|
||||||
|
'z_initial_wc': 0.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
def check_coupling(self, target_a_machine=None, target_z_machine=None):
|
||||||
|
"""Validate that a proposed motion respects the Z-A coupling.
|
||||||
|
|
||||||
|
Each argument is a target *machine* mm position; pass None to
|
||||||
|
keep the current value of that axis.
|
||||||
|
|
||||||
|
Improvement-aware: a move is rejected only when it *worsens*
|
||||||
|
an already-violating state (or moves a healthy state into
|
||||||
|
violation). Pure XY jogs that touch neither Z nor A are not
|
||||||
|
passed through here; jogs that hold Z or A at their current
|
||||||
|
value (gplan emits the unchanged value in `target`) pass
|
||||||
|
because (a-z) doesn't change. Z-up moves while in violation
|
||||||
|
also pass because they reduce (a-z) toward the bound.
|
||||||
|
|
||||||
|
Raises ExternalAxisError on violation. Skipped when coupling
|
||||||
|
is disabled, the aux axis isn't homed, or current positions
|
||||||
|
aren't yet known.
|
||||||
|
"""
|
||||||
|
if not self.couple_z_enabled:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
homed = bool(self.aux._homed)
|
||||||
|
except Exception:
|
||||||
|
homed = False
|
||||||
|
if not homed:
|
||||||
|
return
|
||||||
|
K = self.couple_K
|
||||||
|
if K is None:
|
||||||
|
return
|
||||||
|
a_now = self._a_machine_now()
|
||||||
|
z_now = self._z_machine_now()
|
||||||
|
if a_now is None or z_now is None:
|
||||||
|
return
|
||||||
|
a_after = (float(target_a_machine)
|
||||||
|
if target_a_machine is not None else a_now)
|
||||||
|
z_after = (float(target_z_machine)
|
||||||
|
if target_z_machine is not None else z_now)
|
||||||
|
eps = 1e-4
|
||||||
|
gap_after = a_after - z_after
|
||||||
|
gap_before = a_now - z_now
|
||||||
|
# Only refuse when (a) the resulting state would violate the
|
||||||
|
# constraint AND (b) the move makes things at least as bad
|
||||||
|
# as the current state. This lets the operator escape an
|
||||||
|
# already-violating state by moving in the right direction
|
||||||
|
# (Z up, A down).
|
||||||
|
if gap_after > K + eps and gap_after > gap_before - eps:
|
||||||
|
raise ExternalAxisError(
|
||||||
|
'Z-A coupling violation: A=%.3f mm and Z=%.3f mm '
|
||||||
|
'(machine) would put A above Z by %.3f mm; max '
|
||||||
|
'allowed is %.3f mm. Drop A or raise Z first.' % (
|
||||||
|
a_after, z_after, gap_after, K))
|
||||||
|
|
||||||
|
# ----------------------------------------------------------- conversion
|
||||||
|
|
||||||
|
def mm_to_steps_delta(self, delta_mm):
|
||||||
|
return int(round(float(delta_mm) * self.steps_per_mm * self.dir_sign))
|
||||||
|
|
||||||
|
def steps_to_mm(self, steps):
|
||||||
|
return (float(steps) / self.steps_per_mm) * self.dir_sign
|
||||||
|
|
||||||
|
# ---------------------------------------------------- synthetic motor
|
||||||
|
|
||||||
|
def _publish_synthetic_motor(self):
|
||||||
|
"""Write motor-4 vars into State so find_motor('a') and
|
||||||
|
get_axis_vector('vm') see A as a real axis. The AVR never
|
||||||
|
sees these (motor index 4 is not in its var schema)."""
|
||||||
|
cfg = self.aux._cfg if self.aux is not None else {}
|
||||||
|
st = self.ctrl.state
|
||||||
|
i = str(EXTERNAL_MOTOR_INDEX)
|
||||||
|
|
||||||
|
# Axis assignment: 'an' is the 0-based axis index in xyzabc.
|
||||||
|
st.set(i + 'an', self.axis_index)
|
||||||
|
# Motor enabled.
|
||||||
|
st.set(i + 'me', 1 if (self.aux and self.aux.enabled) else 0)
|
||||||
|
# Homed flag - cleared until aux reports homed.
|
||||||
|
try:
|
||||||
|
homed = bool(self.aux._homed)
|
||||||
|
except Exception:
|
||||||
|
homed = False
|
||||||
|
st.set(i + 'h', 1 if homed else 0)
|
||||||
|
|
||||||
|
# Velocity / accel / jerk: the planner reads these via
|
||||||
|
# state.get_axis_vector('<code>', SCALE) which multiplies the
|
||||||
|
# stored raw value by SCALE. The bbctrl convention (matching
|
||||||
|
# what motors 0-3 store) is:
|
||||||
|
# vm: stored in m/min, planner expects mm/min (scale 1000)
|
||||||
|
# am: stored in km/min^2, planner expects mm/min^2 (scale 1e6)
|
||||||
|
# jm: stored in km/min^3, planner expects mm/min^3 (scale 1e6)
|
||||||
|
# Onefinity defaults for XY are vm=10, am=750, jm=1000. We
|
||||||
|
# follow the same convention; aux.json exposes the values in
|
||||||
|
# those user-facing units so they're directly comparable.
|
||||||
|
st.set(i + 'vm', float(cfg.get('max_velocity_m_per_min', 6.0)))
|
||||||
|
st.set(i + 'am', float(cfg.get('max_accel_km_per_min2', 100.0)))
|
||||||
|
st.set(i + 'jm', float(cfg.get('max_jerk_km_per_min3', 500.0)))
|
||||||
|
|
||||||
|
# Soft limits in machine units (mm). State.get_soft_limit_vector
|
||||||
|
# returns these directly, no scaling.
|
||||||
|
st.set(i + 'tn', float(cfg.get('min_mm', 0.0)))
|
||||||
|
st.set(i + 'tm', float(cfg.get('max_mm', 0.0)))
|
||||||
|
|
||||||
|
# home_position / home_travel are exposed as callbacks for
|
||||||
|
# motors 0..3 (see State.__init__). Register the same lazy
|
||||||
|
# callbacks for motor 4 so gplan's resolver lookup
|
||||||
|
# (_<axis>_home_position / _<axis>_home_travel) returns the
|
||||||
|
# right values for the external axis.
|
||||||
|
st.set_callback(
|
||||||
|
i + 'home_position', lambda name: self.home_position_mm)
|
||||||
|
st.set_callback(
|
||||||
|
i + 'home_travel',
|
||||||
|
lambda name: float(self.aux._cfg.get('max_mm', 0.0))
|
||||||
|
- self.home_position_mm)
|
||||||
|
|
||||||
|
# Misc fields that other code paths might query. Defaults
|
||||||
|
# mirror what the AVR pushes for motors 0-3.
|
||||||
|
st.set(i + 'sa', 1.8)
|
||||||
|
st.set(i + 'mi', 16)
|
||||||
|
st.set(i + 'tr', 4.0)
|
||||||
|
st.set(i + 'sp', 200)
|
||||||
|
st.set(i + 'ic', 0.0)
|
||||||
|
st.set(i + 'dc', 0.0)
|
||||||
|
st.set(i + 'rv', False)
|
||||||
|
st.set(i + 'tc', 1)
|
||||||
|
st.set(i + 'lb', 5)
|
||||||
|
st.set(i + 'ho', 0)
|
||||||
|
st.set(i + 'os', 0)
|
||||||
|
st.set(i + 'oa', False)
|
||||||
|
st.set(i + 'lm', 8)
|
||||||
|
st.set(i + 'lv', 0.1)
|
||||||
|
st.set(i + 'sv', 1.688)
|
||||||
|
st.set(i + 'tv', 1.997)
|
||||||
|
st.set(i + 'lw', 2) # min-switch
|
||||||
|
st.set(i + 'xw', 2) # max-switch
|
||||||
|
st.set(i + 'ls', 0)
|
||||||
|
st.set(i + 'xs', 0)
|
||||||
|
st.set(i + 'df', 0)
|
||||||
|
|
||||||
|
def refresh_homed(self):
|
||||||
|
"""Called when AuxAxis updates its homed flag. Mirrors into
|
||||||
|
State so is_axis_homed('a') returns the right answer.
|
||||||
|
|
||||||
|
Updates several places at once because different layers read
|
||||||
|
the homed state via different keys:
|
||||||
|
- synthetic motor flag: 4h (used by snapshot -> a_h)
|
||||||
|
- axis-level flag: a_homed (used by State.is_axis_homed
|
||||||
|
and gplan _a_homed resolver)"""
|
||||||
|
try:
|
||||||
|
homed = bool(self.aux._homed)
|
||||||
|
except Exception:
|
||||||
|
homed = False
|
||||||
|
st = self.ctrl.state
|
||||||
|
st.set(str(EXTERNAL_MOTOR_INDEX) + 'h', 1 if homed else 0)
|
||||||
|
st.set(self.axis_letter + '_homed', bool(homed))
|
||||||
|
|
||||||
|
# ----------------------------------------------------------- line split
|
||||||
|
|
||||||
|
def split_target(self, target):
|
||||||
|
"""Pop the external axis out of a target dict and return
|
||||||
|
(target_without_ext, ext_mm_or_None). Both case variants
|
||||||
|
accepted defensively."""
|
||||||
|
if not target:
|
||||||
|
return target, None
|
||||||
|
ax = self.axis_letter
|
||||||
|
new_target = dict(target)
|
||||||
|
ext_mm = new_target.pop(ax, None)
|
||||||
|
if ext_mm is None:
|
||||||
|
ext_mm = new_target.pop(ax.upper(), None)
|
||||||
|
return new_target, ext_mm
|
||||||
|
|
||||||
|
# -------------------------------------------------------- execution API
|
||||||
|
|
||||||
|
def is_busy(self):
|
||||||
|
return self._busy.is_set()
|
||||||
|
|
||||||
|
def execute_to_mm(self, ext_mm):
|
||||||
|
"""Synchronously run an external move. Blocks until the ESP
|
||||||
|
reports done. Used by the legacy /api/aux/move and /api/aux/jog
|
||||||
|
endpoints which may want to wait. Most planner-driven motion
|
||||||
|
goes through enqueue_target_mm instead, which is non-blocking.
|
||||||
|
|
||||||
|
Soft limits are enforced here (not just in gplan) because the
|
||||||
|
UI jog/move endpoints don't go through the planner.
|
||||||
|
|
||||||
|
Updates state.<axis>p immediately on completion. For the
|
||||||
|
planner-driven path that goes through enqueue_target_mm, the
|
||||||
|
AVR's own ap reports drive state.<axis>p instead."""
|
||||||
|
if not self.enabled:
|
||||||
|
raise ExternalAxisError(
|
||||||
|
'External axis %r not available (aux disabled or '
|
||||||
|
'not connected)' % self.axis_letter)
|
||||||
|
|
||||||
|
self._check_soft_limit(ext_mm)
|
||||||
|
# Coupling: A is in machine coords directly (we don't apply
|
||||||
|
# a G92 offset to A), so target_a_machine == ext_mm.
|
||||||
|
self.check_coupling(target_a_machine=ext_mm)
|
||||||
|
steps, abs_mm = self._compute_move(ext_mm)
|
||||||
|
if steps == 0:
|
||||||
|
self._pos_mm = abs_mm
|
||||||
|
self.ctrl.state.set(self.axis_letter + 'p', self._pos_mm)
|
||||||
|
return
|
||||||
|
|
||||||
|
self._busy.set()
|
||||||
|
try:
|
||||||
|
self.aux._do_steps(steps, ignore_limits=True)
|
||||||
|
self._pos_mm = abs_mm
|
||||||
|
self.ctrl.state.set(self.axis_letter + 'p', self._pos_mm)
|
||||||
|
finally:
|
||||||
|
self._busy.clear()
|
||||||
|
|
||||||
|
def enqueue_target_mm(self, ext_mm):
|
||||||
|
"""Legacy non-blocking variant: post a fixed-rate STEPS move
|
||||||
|
to the worker queue. No longer used by Planner.__encode (which
|
||||||
|
uses enqueue_line for full S-curve mirroring), but kept for
|
||||||
|
UI jog endpoints that don't have planner timing data.
|
||||||
|
|
||||||
|
Soft limits are enforced here (defense in depth on top of
|
||||||
|
gplan)."""
|
||||||
|
if not self.enabled:
|
||||||
|
raise ExternalAxisError(
|
||||||
|
'External axis %r not available' % self.axis_letter)
|
||||||
|
self._check_soft_limit(ext_mm)
|
||||||
|
self.check_coupling(target_a_machine=ext_mm)
|
||||||
|
steps, abs_mm = self._compute_move(ext_mm)
|
||||||
|
# Internal mirror only - drives subsequent delta computation.
|
||||||
|
# state.<axis>p is left to the AVR's status reports.
|
||||||
|
self._pos_mm = abs_mm
|
||||||
|
if steps == 0:
|
||||||
|
return
|
||||||
|
self._work_q.put(('move', steps))
|
||||||
|
|
||||||
|
def enqueue_line(self, ext_mm, max_accel_mm_min2, max_jerk_mm_min3,
|
||||||
|
entry_vel_mm_min, exit_vel_mm_min, times_ms):
|
||||||
|
"""Post a full S-curve LINE block to the ESP worker. Mirrors
|
||||||
|
gplan's planned trajectory exactly (same 7-segment math, same
|
||||||
|
unit system) so the ESP's move duration matches what the AVR
|
||||||
|
would have produced for an A motor.
|
||||||
|
|
||||||
|
Called by Planner.__encode for every line block that touches
|
||||||
|
the external axis.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
ext_mm: absolute target in mm (gplan target['a'])
|
||||||
|
max_accel_mm_min2:from block['max-accel']
|
||||||
|
max_jerk_mm_min3: from block['max-jerk']
|
||||||
|
entry_vel_mm_min: from block['entry-vel'] (typically 0 for
|
||||||
|
the first block, exit_vel of the prior
|
||||||
|
block otherwise)
|
||||||
|
exit_vel_mm_min: from block['exit-vel']
|
||||||
|
times_ms: 7-tuple of section durations in ms
|
||||||
|
(block['times'] - the same units gplan uses)
|
||||||
|
"""
|
||||||
|
if not self.enabled:
|
||||||
|
raise ExternalAxisError(
|
||||||
|
'External axis %r not available' % self.axis_letter)
|
||||||
|
self._check_soft_limit(ext_mm)
|
||||||
|
self.check_coupling(target_a_machine=ext_mm)
|
||||||
|
steps, abs_mm = self._compute_move(ext_mm)
|
||||||
|
delta_mm = abs(abs_mm - (self._pos_mm if self._pos_mm is not None
|
||||||
|
else 0.0))
|
||||||
|
# Update internal mirror; AVR drives state.<axis>p.
|
||||||
|
self._pos_mm = abs_mm
|
||||||
|
if steps == 0 or delta_mm <= 0:
|
||||||
|
return
|
||||||
|
# ms -> minutes (the unit gplan/AVR/ESP use internally for
|
||||||
|
# SCurve math).
|
||||||
|
times_min = tuple((t / 60000.0) if t else 0.0 for t in times_ms)
|
||||||
|
self._work_q.put(('line', steps, delta_mm,
|
||||||
|
float(max_accel_mm_min2),
|
||||||
|
float(max_jerk_mm_min3),
|
||||||
|
float(entry_vel_mm_min),
|
||||||
|
float(exit_vel_mm_min),
|
||||||
|
times_min))
|
||||||
|
|
||||||
|
def _compute_move(self, ext_mm):
|
||||||
|
"""Return (signed_steps, absolute_mm) for a target in mm.
|
||||||
|
Caches first-time position from the ESP."""
|
||||||
|
if self._pos_mm is None:
|
||||||
|
self._pos_mm = self._read_esp_position_mm()
|
||||||
|
delta_mm = float(ext_mm) - self._pos_mm
|
||||||
|
return self.mm_to_steps_delta(delta_mm), float(ext_mm)
|
||||||
|
|
||||||
|
def _worker_loop(self):
|
||||||
|
"""Background thread that drains the work queue. RPCs to the
|
||||||
|
ESP are slow (multi-second moves) and must not run on the
|
||||||
|
ioloop thread. We serialize ESP commands here so multiple
|
||||||
|
line-block enqueues for the external axis are processed in
|
||||||
|
the order the planner emitted them."""
|
||||||
|
while not self._stop.is_set():
|
||||||
|
try:
|
||||||
|
op = self._work_q.get(timeout=0.5)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if op is None:
|
||||||
|
continue
|
||||||
|
kind = op[0]
|
||||||
|
try:
|
||||||
|
self._busy.set()
|
||||||
|
if kind == 'move':
|
||||||
|
steps = op[1]
|
||||||
|
self.aux._do_steps(steps, ignore_limits=True)
|
||||||
|
elif kind == 'line':
|
||||||
|
(_, steps, length_mm,
|
||||||
|
max_accel, max_jerk,
|
||||||
|
entry_vel, exit_vel,
|
||||||
|
times_min) = op
|
||||||
|
self.aux._do_line(
|
||||||
|
steps, length_mm, max_accel, max_jerk,
|
||||||
|
entry_vel, exit_vel, times_min,
|
||||||
|
ignore_limits=True)
|
||||||
|
elif kind == 'home':
|
||||||
|
self.aux.home()
|
||||||
|
# _pos_mm and DRO updated by the caller's enqueue.
|
||||||
|
except Exception as e:
|
||||||
|
self.log.error('External axis worker failed on %s: %s'
|
||||||
|
% (kind, e))
|
||||||
|
finally:
|
||||||
|
self._busy.clear()
|
||||||
|
self._work_q.task_done()
|
||||||
|
|
||||||
|
def wait_idle(self, timeout=None):
|
||||||
|
"""Block until the worker queue is empty. Used by callers
|
||||||
|
that need post-motion state to be settled (e.g. homing,
|
||||||
|
stop/abort handlers)."""
|
||||||
|
try:
|
||||||
|
# Queue.join blocks until task_done has been called for
|
||||||
|
# every item put. It does not honour a timeout, so we
|
||||||
|
# poll instead when one is requested.
|
||||||
|
if timeout is None:
|
||||||
|
self._work_q.join()
|
||||||
|
return True
|
||||||
|
import time
|
||||||
|
deadline = time.time() + float(timeout)
|
||||||
|
while time.time() < deadline:
|
||||||
|
if self._work_q.unfinished_tasks == 0:
|
||||||
|
return True
|
||||||
|
time.sleep(0.05)
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self._stop.set()
|
||||||
|
try:
|
||||||
|
self._work_q.put(None, block=False)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def home(self):
|
||||||
|
"""Run the ESP homing cycle and sync our recorded position
|
||||||
|
to the configured home_position_mm. Blocks; called from
|
||||||
|
Mach.home (which already runs synchronously per axis)."""
|
||||||
|
if not self.enabled:
|
||||||
|
raise ExternalAxisError(
|
||||||
|
'External axis %r not available' % self.axis_letter)
|
||||||
|
# Drain pending moves so we don't home into stale work.
|
||||||
|
self.wait_idle(timeout=30.0)
|
||||||
|
self._busy.set()
|
||||||
|
try:
|
||||||
|
self.aux.home()
|
||||||
|
self._pos_mm = self.home_position_mm
|
||||||
|
self.ctrl.state.set(self.axis_letter + 'p', self._pos_mm)
|
||||||
|
self.refresh_homed()
|
||||||
|
finally:
|
||||||
|
self._busy.clear()
|
||||||
|
|
||||||
|
def abort(self):
|
||||||
|
"""Cancel the ESP move and drop pending queued work.
|
||||||
|
Caller (estop / stop handler) is responsible for the
|
||||||
|
planner-side cleanup."""
|
||||||
|
try:
|
||||||
|
if self.aux is not None:
|
||||||
|
self.aux.abort()
|
||||||
|
finally:
|
||||||
|
self._busy.clear()
|
||||||
|
# Drain any pending ops so resume after an abort doesn't
|
||||||
|
# replay stale targets.
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
self._work_q.get_nowait()
|
||||||
|
self._work_q.task_done()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ------------------------------------------------------- ESP introspection
|
||||||
|
|
||||||
|
def _read_esp_position_mm(self):
|
||||||
|
"""Convert AuxAxis._pos_steps mirror to mm. Falls back to 0."""
|
||||||
|
try:
|
||||||
|
steps = int(self.aux._pos_steps)
|
||||||
|
except Exception:
|
||||||
|
steps = 0
|
||||||
|
return self.steps_to_mm(steps)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------- DRO update
|
||||||
|
|
||||||
|
def sync_dro(self):
|
||||||
|
"""Push the current position to State as <axis>p so the DRO
|
||||||
|
reflects what we believe gplan/ESP agreed on. Called after
|
||||||
|
moves; also safe to call from external code."""
|
||||||
|
if self._pos_mm is None:
|
||||||
|
return
|
||||||
|
self.ctrl.state.set(self.axis_letter + 'p', self._pos_mm)
|
||||||
@@ -99,6 +99,27 @@ class FileHandler(bbctrl.APIHandler):
|
|||||||
|
|
||||||
del (self.uploadFile)
|
del (self.uploadFile)
|
||||||
|
|
||||||
|
# If the uploaded G-code uses ATC M-codes (M100..M103),
|
||||||
|
# rewrite them into (MSG,HOOK:droptool:) etc so the hook
|
||||||
|
# layer can dispatch them at runtime. The planner accepts
|
||||||
|
# M100-M103 in user-defined range but doesn't *do* anything
|
||||||
|
# with them. Motion in A goes through gplan unchanged - the
|
||||||
|
# auxcnc stepper is exposed as a virtual A axis (see
|
||||||
|
# ExternalAxis).
|
||||||
|
try:
|
||||||
|
from bbctrl.AuxPreprocessor import preprocess_file
|
||||||
|
log = self.get_log('AuxPreprocessor')
|
||||||
|
ext = getattr(self.get_ctrl(), 'ext_axis', None)
|
||||||
|
coupling = (ext.coupling_for_preprocessor()
|
||||||
|
if ext is not None else None)
|
||||||
|
if preprocess_file(filename.decode('utf8'),
|
||||||
|
log=log, coupling=coupling):
|
||||||
|
log.info('Rewrote upload (ATC / Z-A coupling) in %s'
|
||||||
|
% self.uploadFilename)
|
||||||
|
except Exception:
|
||||||
|
self.get_log('AuxPreprocessor').exception(
|
||||||
|
'Aux 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)
|
||||||
|
|
||||||
|
|||||||
454
src/py/bbctrl/Hooks.py
Normal file
454
src/py/bbctrl/Hooks.py
Normal file
@@ -0,0 +1,454 @@
|
|||||||
|
################################################################################
|
||||||
|
#
|
||||||
|
# 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. Also drain the external-axis
|
||||||
|
# worker queue so resume after clear doesn't replay
|
||||||
|
# stale moves.
|
||||||
|
try:
|
||||||
|
ext = getattr(self.ctrl, 'ext_axis', None)
|
||||||
|
if ext is not None:
|
||||||
|
ext.abort()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
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 dispatch_hook_message(self, text):
|
||||||
|
"""Direct entry point for HOOK:<event>:<data> messages emitted
|
||||||
|
by the planner via (MSG,HOOK:...) comments. Bypasses the
|
||||||
|
state.messages list (which the UI also reads), so callers can
|
||||||
|
suppress popup display without losing the hook dispatch.
|
||||||
|
|
||||||
|
Returns True if the text matched a HOOK: line and was
|
||||||
|
dispatched, False otherwise."""
|
||||||
|
if not isinstance(text, str) or not text.startswith('HOOK:'):
|
||||||
|
return False
|
||||||
|
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)
|
||||||
|
return True
|
||||||
|
|
||||||
|
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'))
|
||||||
@@ -95,6 +95,10 @@ class Mach(Comm):
|
|||||||
self.planner = bbctrl.Planner(ctrl)
|
self.planner = bbctrl.Planner(ctrl)
|
||||||
self.unpausing = False
|
self.unpausing = False
|
||||||
self.stopping = False
|
self.stopping = False
|
||||||
|
# Guard against overlapping deferred-external-homing threads
|
||||||
|
# if the user clicks Home (All) again while the previous run
|
||||||
|
# is still waiting for the AVR cycle to finish.
|
||||||
|
self._ext_home_thread = None
|
||||||
|
|
||||||
ctrl.state.set('cycle', 'idle')
|
ctrl.state.set('cycle', 'idle')
|
||||||
|
|
||||||
@@ -256,6 +260,12 @@ 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 ATC M-codes in MDI input the same way the
|
||||||
|
# FileHandler rewrites uploaded files. Motion (X/Y/Z/A)
|
||||||
|
# is left unchanged: the planner handles it natively
|
||||||
|
# now that the auxcnc stepper is exposed as a virtual
|
||||||
|
# A axis (see ExternalAxis).
|
||||||
|
cmd = self._rewrite_aux_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,11 +273,51 @@ class Mach(Comm):
|
|||||||
self.mlog.info("Exception during MDI: %s" % err)
|
self.mlog.info("Exception during MDI: %s" % err)
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def _rewrite_aux_mdi(self, cmd):
|
||||||
|
"""Apply the ATC M-code preprocessor to a single MDI line.
|
||||||
|
Returns possibly-multi-line G-code with HOOK: comments inserted."""
|
||||||
|
try:
|
||||||
|
from bbctrl.AuxPreprocessor import AuxPreprocessor, _ATC_M_RE
|
||||||
|
if not _ATC_M_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('Aux 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))
|
||||||
|
|
||||||
|
|
||||||
def jog(self, axes):
|
def jog(self, axes):
|
||||||
|
# Strip the external axis from the jog request before sending
|
||||||
|
# to the AVR. v1 doesn't support continuous-rate jogging on
|
||||||
|
# the ESP-driven axis - users jog A via /api/aux/jog (relative
|
||||||
|
# mm steps) instead. Sending A to the AVR is harmless (no
|
||||||
|
# motor maps to it) but cleaner to strip.
|
||||||
|
ext = getattr(self.ctrl, 'ext_axis', None)
|
||||||
|
if ext is not None and isinstance(axes, dict):
|
||||||
|
axes = {k: v for k, v in axes.items()
|
||||||
|
if k.lower() != ext.axis_letter}
|
||||||
|
if not axes:
|
||||||
|
return
|
||||||
self._begin_cycle('jogging')
|
self._begin_cycle('jogging')
|
||||||
self.planner.position_change()
|
self.planner.position_change()
|
||||||
super().queue_command(Cmd.jog(axes))
|
super().queue_command(Cmd.jog(axes))
|
||||||
@@ -281,10 +331,52 @@ class Mach(Comm):
|
|||||||
axes = 'zxybc' if is_rotary_active else 'zxyabc' # TODO This should be configurable
|
axes = 'zxybc' if is_rotary_active else 'zxyabc' # TODO This should be configurable
|
||||||
else: axes = '%c' % axis
|
else: axes = '%c' % axis
|
||||||
|
|
||||||
|
# Collect external axes here and process them *after* every
|
||||||
|
# AVR axis above has finished its homing cycle. Without this,
|
||||||
|
# the AVR is still running Z/X/Y homing G-code in the
|
||||||
|
# planner queue while ext.home() synchronously drives the ESP
|
||||||
|
# to home A in parallel - which is unsafe (the gantry and W
|
||||||
|
# axis can move at the same time) and visually confusing.
|
||||||
|
# We defer external homing to a background thread that
|
||||||
|
# polls cycle until the AVR cycle completes.
|
||||||
|
external_pending = []
|
||||||
|
|
||||||
for axis in axes:
|
for axis in axes:
|
||||||
enabled = state.is_axis_enabled(axis)
|
enabled = state.is_axis_enabled(axis)
|
||||||
mode = state.axis_homing_mode(axis)
|
mode = state.axis_homing_mode(axis)
|
||||||
|
|
||||||
|
# External axes (e.g. the auxcnc-driven A axis) home via
|
||||||
|
# their own ESP-side homing routine; the standard
|
||||||
|
# G28.2 / G38.6 / latch sequence doesn't apply.
|
||||||
|
#
|
||||||
|
# After homing we want a deterministic outcome regardless
|
||||||
|
# of where the user was before:
|
||||||
|
# physical position = home_position_mm (e.g. 134 mm)
|
||||||
|
# work-coord origin = home position (user A = 0)
|
||||||
|
# work offset = home_position_mm (so abs - off = 0)
|
||||||
|
#
|
||||||
|
# ext.home() blocks on the ESP and updates state.ap to
|
||||||
|
# home_position_mm. We then need to tell the AVR (so its
|
||||||
|
# ex.position[A] matches physical reality) and gplan
|
||||||
|
# (so trajectory planning sees abs at home).
|
||||||
|
#
|
||||||
|
# We deliberately avoid G28.3 here: gplan's G28.3 keeps the
|
||||||
|
# current user-coord position fixed and adjusts the offset
|
||||||
|
# to match the new abs, which means re-homing after a move
|
||||||
|
# accumulates offset (134 -> 268 -> ...). Using G92 a0
|
||||||
|
# *after* syncing abs gives the desired "user A = 0 here"
|
||||||
|
# outcome with offset = home_position every time.
|
||||||
|
ext = getattr(self.ctrl, 'ext_axis', None)
|
||||||
|
if ext is not None and ext.enabled \
|
||||||
|
and ext.axis_letter == axis.lower():
|
||||||
|
if 1 < len(axes) and not enabled:
|
||||||
|
continue
|
||||||
|
# Defer until AVR axes are done. We capture the axis
|
||||||
|
# letter and ext reference; the actual homing runs
|
||||||
|
# in _run_external_homing below.
|
||||||
|
external_pending.append((axis, ext))
|
||||||
|
continue
|
||||||
|
|
||||||
# If this is not a request to home a specific axis and the
|
# If this is not a request to home a specific axis and the
|
||||||
# axis is disabled or in manual homing mode, don't show any
|
# axis is disabled or in manual homing mode, don't show any
|
||||||
# warnings
|
# warnings
|
||||||
@@ -315,8 +407,138 @@ class Mach(Comm):
|
|||||||
self.planner.mdi(gcode, False)
|
self.planner.mdi(gcode, False)
|
||||||
super().resume()
|
super().resume()
|
||||||
|
|
||||||
|
# Kick off the deferred external-axis homing on a background
|
||||||
|
# thread so we don't block the HTTP handler (which is on the
|
||||||
|
# IOLoop) waiting for the AVR cycle to finish.
|
||||||
|
if external_pending:
|
||||||
|
prev = self._ext_home_thread
|
||||||
|
if prev is not None and prev.is_alive():
|
||||||
|
self.mlog.info(
|
||||||
|
'External homing already in progress; ignoring '
|
||||||
|
'duplicate request')
|
||||||
|
else:
|
||||||
|
import threading
|
||||||
|
t = threading.Thread(
|
||||||
|
target=self._run_external_homing,
|
||||||
|
args=(list(external_pending),),
|
||||||
|
name='ext-home-deferred',
|
||||||
|
daemon=True)
|
||||||
|
self._ext_home_thread = t
|
||||||
|
t.start()
|
||||||
|
|
||||||
def unhome(self, axis): self.mdi('G28.2 %c0' % axis)
|
def _run_external_homing(self, pending):
|
||||||
|
"""Background worker: wait for the AVR cycle to drop to idle
|
||||||
|
(meaning all queued AVR-side homing is done), then run each
|
||||||
|
deferred external-axis home in order.
|
||||||
|
|
||||||
|
We split the work between two threads:
|
||||||
|
- this background thread blocks on the ESP serial RPC
|
||||||
|
(ext.home(), which can take 5-10 seconds while the
|
||||||
|
carriage seeks the limit and backs off twice);
|
||||||
|
- small bookkeeping operations that touch gplan, the AVR
|
||||||
|
command queue, or shared State are scheduled back onto
|
||||||
|
the IOLoop via ctrl.ioloop.add_callback() so we don't
|
||||||
|
race with the rest of the controller.
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
# Wait up to 5 minutes for the AVR cycle to leave 'homing'.
|
||||||
|
# Long enough for any reasonable Onefinity full-travel home
|
||||||
|
# (Y axis at slow rate covers ~800 mm).
|
||||||
|
deadline = time.time() + 300.0
|
||||||
|
while time.time() < deadline:
|
||||||
|
cycle = self._get_cycle()
|
||||||
|
# 'homing' is the AVR's homing cycle; we wait for it to
|
||||||
|
# return to idle. If the user estopped or the cycle was
|
||||||
|
# aborted, cycle goes to idle too - we still proceed and
|
||||||
|
# the external home will fail-soft if conditions are wrong.
|
||||||
|
if cycle == 'idle':
|
||||||
|
break
|
||||||
|
time.sleep(0.1)
|
||||||
|
else:
|
||||||
|
self.mlog.error(
|
||||||
|
'External axis homing aborted: AVR cycle did not '
|
||||||
|
'return to idle within timeout')
|
||||||
|
return
|
||||||
|
|
||||||
|
for axis, ext in pending:
|
||||||
|
self.mlog.info('Homing external %s axis via auxcnc' %
|
||||||
|
axis.upper())
|
||||||
|
# Begin the cycle on the IOLoop so cycle-state writes go
|
||||||
|
# through the same thread that all other state writes do.
|
||||||
|
self.ctrl.ioloop.add_callback(self._begin_cycle, 'homing')
|
||||||
|
try:
|
||||||
|
# ext.home() runs on this background thread - it
|
||||||
|
# blocks on serial I/O and is fully thread-safe (the
|
||||||
|
# AuxAxis driver has its own RPC lock).
|
||||||
|
ext.home()
|
||||||
|
home_mm = ext.home_position_mm
|
||||||
|
# All of the post-home bookkeeping touches gplan and
|
||||||
|
# the AVR command queue, both of which run on the
|
||||||
|
# IOLoop. Schedule it there in a single callback so
|
||||||
|
# the steps run in order without intervening events.
|
||||||
|
self.ctrl.ioloop.add_callback(
|
||||||
|
self._finish_external_home, axis, home_mm)
|
||||||
|
except Exception as e:
|
||||||
|
self.mlog.error(
|
||||||
|
'External axis homing failed: %s' % e)
|
||||||
|
# Cycle reset must also happen on the IOLoop. Without
|
||||||
|
# this the UI stays locked at 'homing' since the AVR
|
||||||
|
# never moved (no state change to drive _update's
|
||||||
|
# cycle-end path).
|
||||||
|
self.ctrl.ioloop.add_callback(
|
||||||
|
self._abort_external_home_cycle)
|
||||||
|
|
||||||
|
def _finish_external_home(self, axis, home_mm):
|
||||||
|
"""IOLoop-side completion of an external axis home.
|
||||||
|
Synchronizes AVR position, refreshes the planner, and emits
|
||||||
|
a G92 to set the user-coord origin at the home position.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 1) Update AVR: no motor steps, just position sync.
|
||||||
|
super().queue_command(Cmd.set_axis(axis, home_mm))
|
||||||
|
# 2) Force planner to resync abs from State on the next
|
||||||
|
# planner call (which is the MDI below).
|
||||||
|
self.planner.position_change()
|
||||||
|
# 3) G92 <axis>0: with abs already at home_mm, sets
|
||||||
|
# user-coord A = 0 and offset = home_mm. Use
|
||||||
|
# planner.mdi (not Mach.mdi) so we don't flip cycle
|
||||||
|
# to 'mdi' inside the 'homing' cycle.
|
||||||
|
self.planner.mdi('G92 %c0' % axis, False)
|
||||||
|
super().resume()
|
||||||
|
except Exception:
|
||||||
|
self.mlog.exception(
|
||||||
|
'Post-home bookkeeping failed for external axis')
|
||||||
|
self._abort_external_home_cycle()
|
||||||
|
|
||||||
|
def _abort_external_home_cycle(self):
|
||||||
|
"""Reset cycle to idle from the IOLoop after a failed
|
||||||
|
external axis home. The AVR never moved so _update's normal
|
||||||
|
cycle-end path won't fire; do it explicitly here.
|
||||||
|
"""
|
||||||
|
if self._get_cycle() == 'homing':
|
||||||
|
try:
|
||||||
|
self._set_cycle('idle')
|
||||||
|
except Exception:
|
||||||
|
self.mlog.exception(
|
||||||
|
'Failed to reset cycle to idle after external '
|
||||||
|
'homing error')
|
||||||
|
|
||||||
|
|
||||||
|
def unhome(self, axis):
|
||||||
|
# External axes don't have AVR-side homed state to clear; the
|
||||||
|
# ESP holds its own homed flag. We don't have an explicit
|
||||||
|
# "unhome" verb on the ESP, but a stale homed flag is harmless
|
||||||
|
# because the next absolute move will fail-soft via
|
||||||
|
# ExternalAxis._pos_mm sync. Still mirror the cleared flag
|
||||||
|
# into State for the UI.
|
||||||
|
ext = getattr(self.ctrl, 'ext_axis', None)
|
||||||
|
if ext is not None and ext.enabled \
|
||||||
|
and chr(axis).lower() == ext.axis_letter:
|
||||||
|
from bbctrl.ExternalAxis import EXTERNAL_MOTOR_INDEX
|
||||||
|
self.ctrl.state.set('%dh' % EXTERNAL_MOTOR_INDEX, 0)
|
||||||
|
self.ctrl.state.set(ext.axis_letter + '_homed', False)
|
||||||
|
return
|
||||||
|
self.mdi('G28.2 %c0' % axis)
|
||||||
def estop(self): super().estop()
|
def estop(self): super().estop()
|
||||||
|
|
||||||
|
|
||||||
@@ -343,12 +565,22 @@ class Mach(Comm):
|
|||||||
def stop(self):
|
def stop(self):
|
||||||
if self._get_state() != 'jogging': self.stopping = True
|
if self._get_state() != 'jogging': self.stopping = True
|
||||||
super().i2c_command(Cmd.STOP)
|
super().i2c_command(Cmd.STOP)
|
||||||
|
# Drain the external-axis worker queue so post-stop resumption
|
||||||
|
# doesn't replay queued moves that the user wanted cancelled.
|
||||||
|
ext = getattr(self.ctrl, 'ext_axis', None)
|
||||||
|
if ext is not None:
|
||||||
|
try: ext.abort()
|
||||||
|
except Exception: pass
|
||||||
|
|
||||||
def pause(self): super().pause()
|
def pause(self): super().pause()
|
||||||
|
|
||||||
|
|
||||||
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()
|
||||||
|
|
||||||
|
|||||||
@@ -196,12 +196,23 @@ class Planner():
|
|||||||
|
|
||||||
|
|
||||||
def _add_message(self, text):
|
def _add_message(self, text):
|
||||||
self.ctrl.state.add_message(text)
|
|
||||||
|
|
||||||
line = self.ctrl.state.get('line', 0)
|
line = self.ctrl.state.get('line', 0)
|
||||||
if 0 <= line: where = '%s:%d' % (self.where, line)
|
if 0 <= line: where = '%s:%d' % (self.where, line)
|
||||||
else: where = self.where
|
else: where = self.where
|
||||||
|
|
||||||
|
# HOOK:<event>:<data> messages are an internal IPC channel
|
||||||
|
# between the gcode preprocessor and Hooks; bypass the user
|
||||||
|
# message list so they don't surface as popups, and dispatch
|
||||||
|
# the hook directly. Routing through state.messages would
|
||||||
|
# only deliver it after the 0.25s state-change debounce, by
|
||||||
|
# which point we'd have to keep it visible to ensure Hooks
|
||||||
|
# could see it.
|
||||||
|
hooks = getattr(self.ctrl, 'hooks', None)
|
||||||
|
if hooks is not None and hooks.dispatch_hook_message(text):
|
||||||
|
self.log.info('HOOK msg: %s' % text, where = where)
|
||||||
|
return
|
||||||
|
|
||||||
|
self.ctrl.state.add_message(text)
|
||||||
self.log.message(text, where = where)
|
self.log.message(text, where = where)
|
||||||
|
|
||||||
|
|
||||||
@@ -259,6 +270,54 @@ class Planner():
|
|||||||
if type != 'set': self.log.info('Cmd:' + log_json(block))
|
if type != 'set': self.log.info('Cmd:' + log_json(block))
|
||||||
|
|
||||||
if type == 'line':
|
if type == 'line':
|
||||||
|
# Z-A coupling check: every line block that touches Z (or
|
||||||
|
# A) is validated against the projected (A,Z) machine
|
||||||
|
# pair. The ExternalAxis check is improvement-aware: it
|
||||||
|
# only refuses moves that worsen an existing violation
|
||||||
|
# or push a healthy state into one. So pure-XY jogs and
|
||||||
|
# recovery moves (Z up, A down) are not rejected even
|
||||||
|
# when (A-Z) is currently above the bound.
|
||||||
|
ext_check = getattr(self.ctrl, 'ext_axis', None)
|
||||||
|
if ext_check is not None:
|
||||||
|
from bbctrl.ExternalAxis import ExternalAxisError
|
||||||
|
target = block.get('target') or {}
|
||||||
|
z_target = target.get('z')
|
||||||
|
if z_target is None: z_target = target.get('Z')
|
||||||
|
a_letter = ext_check.axis_letter
|
||||||
|
a_target = target.get(a_letter)
|
||||||
|
if a_target is None:
|
||||||
|
a_target = target.get(a_letter.upper())
|
||||||
|
if z_target is not None or a_target is not None:
|
||||||
|
try:
|
||||||
|
ext_check.check_coupling(
|
||||||
|
target_a_machine=a_target,
|
||||||
|
target_z_machine=z_target)
|
||||||
|
except ExternalAxisError as e:
|
||||||
|
# Convert the raw error into a clean abort:
|
||||||
|
# surface the message to the operator, stop
|
||||||
|
# the cycle, and skip this block. Returning
|
||||||
|
# None drops the block from the AVR queue;
|
||||||
|
# mach.stop() halts further planner output
|
||||||
|
# so the rest of an offending program can't
|
||||||
|
# leak through. The planner stays usable
|
||||||
|
# for new MDI / jog commands.
|
||||||
|
self.log.warning('Z-A coupling refused: %s' % e)
|
||||||
|
try:
|
||||||
|
self.ctrl.state.add_message(
|
||||||
|
'Z-A coupling refused move: ' + str(e))
|
||||||
|
except Exception: pass
|
||||||
|
try:
|
||||||
|
self.ctrl.mach.stop()
|
||||||
|
except Exception: pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
ext = self._external_axis_for_line(block)
|
||||||
|
if ext is not None:
|
||||||
|
# Side effect: enqueue the ESP move on the external-
|
||||||
|
# axis worker. The AVR still receives the full target
|
||||||
|
# (including A) so ex.position[A] tracks gplan; no
|
||||||
|
# motor steps for A because no motor maps to it.
|
||||||
|
self._dispatch_external_line(block, ext)
|
||||||
self._enqueue_line_time(block)
|
self._enqueue_line_time(block)
|
||||||
return Cmd.line(block['target'], block['exit-vel'],
|
return Cmd.line(block['target'], block['exit-vel'],
|
||||||
block['max-accel'], block['max-jerk'],
|
block['max-accel'], block['max-jerk'],
|
||||||
@@ -289,8 +348,17 @@ class Planner():
|
|||||||
|
|
||||||
if name[2:] == '_homed':
|
if name[2:] == '_homed':
|
||||||
motor = self.ctrl.state.find_motor(name[1])
|
motor = self.ctrl.state.find_motor(name[1])
|
||||||
if motor is not None:
|
# Synthetic external motor (index 4) doesn't exist
|
||||||
|
# on the AVR; mirror the homed flag in State only.
|
||||||
|
from bbctrl.ExternalAxis import EXTERNAL_MOTOR_INDEX
|
||||||
|
if motor is not None and motor < EXTERNAL_MOTOR_INDEX:
|
||||||
return Cmd.set_sync('%dh' % motor, value)
|
return Cmd.set_sync('%dh' % motor, value)
|
||||||
|
if motor == EXTERNAL_MOTOR_INDEX:
|
||||||
|
# Update synthetic motor flag and the<axis>_homed
|
||||||
|
# projection consumed by the DRO.
|
||||||
|
self.cmdq.enqueue(
|
||||||
|
id, self.ctrl.state.set,
|
||||||
|
'%dh' % EXTERNAL_MOTOR_INDEX, value)
|
||||||
|
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -339,6 +407,68 @@ class Planner():
|
|||||||
self.planner.set_logger(None)
|
self.planner.set_logger(None)
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------- external-axis routing
|
||||||
|
#
|
||||||
|
# When an axis is exposed to gplan via a synthetic motor (no AVR
|
||||||
|
# channel), we need to fork its motion off to the ESP at line
|
||||||
|
# encode time and let the rest of the line proceed to the AVR.
|
||||||
|
# The split is done here rather than in gplan because gplan
|
||||||
|
# treats all six axes uniformly and just emits target dicts; we
|
||||||
|
# don't want to teach it about the ESP.
|
||||||
|
|
||||||
|
def _external_axis_for_line(self, block):
|
||||||
|
"""Return the ExternalAxis instance for whichever axis in
|
||||||
|
block['target'] is external, or None."""
|
||||||
|
ext = getattr(self.ctrl, 'ext_axis', None)
|
||||||
|
if ext is None or not ext.enabled:
|
||||||
|
return None
|
||||||
|
target = block.get('target') or {}
|
||||||
|
if ext.axis_letter in target or ext.axis_letter.upper() in target:
|
||||||
|
return ext
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _dispatch_external_line(self, block, ext):
|
||||||
|
"""Side-effect: enqueue the ESP move on the external-axis
|
||||||
|
worker thread (non-blocking). Returns the block (possibly
|
||||||
|
unchanged) for the AVR.
|
||||||
|
|
||||||
|
We do NOT strip the external axis target from the AVR line.
|
||||||
|
The AVR's exec_move_to_target updates ex.position[axis] for
|
||||||
|
every axis in the target dict regardless of motor mapping,
|
||||||
|
and reports it back via the `p` indexed var. Leaving A in
|
||||||
|
the target keeps state.ap in sync with gplan's idea of A
|
||||||
|
(otherwise the AVR's stale ex.position[A] would clobber
|
||||||
|
ExternalAxis's state.ap=N update on the next status report).
|
||||||
|
|
||||||
|
The AVR doesn't step any motor for the external axis (no
|
||||||
|
motor maps to it) - so leaving A in the target is
|
||||||
|
physically a no-op for the steppers, while keeping the
|
||||||
|
host-side state coherent.
|
||||||
|
|
||||||
|
We pass the full S-curve parameters to the ESP so its move
|
||||||
|
duration matches the AVR's exactly. The ESP runs the same
|
||||||
|
7-segment jerk-limited trajectory the AVR would have run
|
||||||
|
if A had been a real motor."""
|
||||||
|
target = block.get('target') or {}
|
||||||
|
# Read the external target (case-insensitive) without modifying
|
||||||
|
# the dict so the AVR still sees A.
|
||||||
|
ext_mm = target.get(ext.axis_letter)
|
||||||
|
if ext_mm is None:
|
||||||
|
ext_mm = target.get(ext.axis_letter.upper())
|
||||||
|
try:
|
||||||
|
ext.enqueue_line(
|
||||||
|
ext_mm,
|
||||||
|
block.get('max-accel', 0.0),
|
||||||
|
block.get('max-jerk', 0.0),
|
||||||
|
block.get('entry-vel', 0.0),
|
||||||
|
block.get('exit-vel', 0.0),
|
||||||
|
block.get('times', [0]*7),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
self.log.error('External axis enqueue failed: %s' % e)
|
||||||
|
raise
|
||||||
|
return block
|
||||||
|
|
||||||
def reset(self, *args, **kwargs):
|
def reset(self, *args, **kwargs):
|
||||||
stop = kwargs.get('stop', True)
|
stop = kwargs.get('stop', True)
|
||||||
if stop:
|
if stop:
|
||||||
@@ -352,6 +482,16 @@ class Planner():
|
|||||||
self.cmdq.clear()
|
self.cmdq.clear()
|
||||||
self.reset_times()
|
self.reset_times()
|
||||||
|
|
||||||
|
# Drain the external-axis worker queue and force the next
|
||||||
|
# move to re-sync position from the ESP (since State.reset
|
||||||
|
# below will zero <axis>p which makes ext._pos_mm stale).
|
||||||
|
ext = getattr(self.ctrl, 'ext_axis', None)
|
||||||
|
if ext is not None:
|
||||||
|
try: ext.abort()
|
||||||
|
except Exception: pass
|
||||||
|
try: ext._pos_mm = None
|
||||||
|
except Exception: pass
|
||||||
|
|
||||||
resetState = kwargs.get('resetState', True)
|
resetState = kwargs.get('resetState', True)
|
||||||
if resetState:
|
if resetState:
|
||||||
self.ctrl.state.reset()
|
self.ctrl.state.reset()
|
||||||
@@ -369,6 +509,22 @@ class Planner():
|
|||||||
self.where = path
|
self.where = path
|
||||||
path = self.ctrl.get_path('upload', path)
|
path = self.ctrl.get_path('upload', path)
|
||||||
self.log.info('GCode:' + path)
|
self.log.info('GCode:' + path)
|
||||||
|
# Rewrite ATC M-codes (M100..M103) before gplan sees them.
|
||||||
|
# preprocess_file is a no-op when no rewriting is needed and
|
||||||
|
# idempotent when run twice on the same file, so this is
|
||||||
|
# safe on every load. W tokens are no longer rewritten - the
|
||||||
|
# auxcnc stepper is now exposed as a virtual A axis and gcode
|
||||||
|
# should use A directly.
|
||||||
|
try:
|
||||||
|
from bbctrl.AuxPreprocessor import preprocess_file
|
||||||
|
ext = getattr(self.ctrl, 'ext_axis', None)
|
||||||
|
coupling = (ext.coupling_for_preprocessor()
|
||||||
|
if ext is not None else None)
|
||||||
|
if preprocess_file(path, log=self.log, coupling=coupling):
|
||||||
|
self.log.info('Rewrote (ATC / Z-A coupling) in %s' % path)
|
||||||
|
except Exception:
|
||||||
|
self.log.exception('Aux preprocess at load failed; '
|
||||||
|
'attempting to load file unchanged')
|
||||||
self._sync_position()
|
self._sync_position()
|
||||||
self.planner.load(path, self.get_config(False, True))
|
self.planner.load(path, self.get_config(False, True))
|
||||||
self.reset_times()
|
self.reset_times()
|
||||||
|
|||||||
@@ -107,8 +107,14 @@ class State(object):
|
|||||||
|
|
||||||
|
|
||||||
def reset(self):
|
def reset(self):
|
||||||
# Unhome all motors
|
# Unhome all motors (real AVR motors 0..3 and the synthetic
|
||||||
for i in range(4): self.set('%dhomed' % i, False)
|
# external-axis motor at index 4 used by ExternalAxis).
|
||||||
|
# Both <motor>homed and <motor>h are cleared - they're set
|
||||||
|
# by different code paths (gplan emits homed via _<axis>_homed
|
||||||
|
# set blocks, AVR reports h directly).
|
||||||
|
for i in range(5):
|
||||||
|
self.set('%dhomed' % i, False)
|
||||||
|
self.set('%dh' % i, 0)
|
||||||
|
|
||||||
# Zero offsets and positions
|
# Zero offsets and positions
|
||||||
for axis in 'xyzabc':
|
for axis in 'xyzabc':
|
||||||
@@ -280,8 +286,11 @@ class State(object):
|
|||||||
axis_motors = {axis: self.find_motor(axis) for axis in 'xyzabc'}
|
axis_motors = {axis: self.find_motor(axis) for axis in 'xyzabc'}
|
||||||
axis_vars = {}
|
axis_vars = {}
|
||||||
|
|
||||||
|
# NOTE: motor index '4' is a host-only synthetic motor used
|
||||||
|
# by ExternalAxis to expose the auxcnc ESP-driven stepper as
|
||||||
|
# an additional axis. Real AVR motors are 0..3.
|
||||||
for name, value in vars.items():
|
for name, value in vars.items():
|
||||||
if name[0] in '0123':
|
if name[0] in '01234':
|
||||||
motor = int(name[0])
|
motor = int(name[0])
|
||||||
|
|
||||||
for axis in 'xyzabc':
|
for axis in 'xyzabc':
|
||||||
@@ -330,6 +339,9 @@ class State(object):
|
|||||||
def get_axis_vector(self, name, scale = 1):
|
def get_axis_vector(self, name, scale = 1):
|
||||||
v = {}
|
v = {}
|
||||||
|
|
||||||
|
# 0..3 are AVR motor channels. 4 is the host-side synthetic
|
||||||
|
# motor used by ExternalAxis. find_motor returns the right
|
||||||
|
# index regardless of whether the axis is physical or external.
|
||||||
for axis in 'xyzabc':
|
for axis in 'xyzabc':
|
||||||
motor = self.find_motor(axis)
|
motor = self.find_motor(axis)
|
||||||
|
|
||||||
@@ -351,7 +363,10 @@ class State(object):
|
|||||||
|
|
||||||
|
|
||||||
def find_motor(self, axis):
|
def find_motor(self, axis):
|
||||||
for motor in range(4):
|
# Walk 0..4: 0..3 are real AVR motors, 4 is the synthetic
|
||||||
|
# host-side motor used to expose the auxcnc ESP stepper as
|
||||||
|
# an external axis.
|
||||||
|
for motor in range(5):
|
||||||
if not ('%dan' % motor) in self.vars: continue
|
if not ('%dan' % motor) in self.vars: continue
|
||||||
motor_axis = 'xyzabc'[self.vars['%dan' % motor]]
|
motor_axis = 'xyzabc'[self.vars['%dan' % motor]]
|
||||||
if motor_axis == axis.lower() and self.vars.get('%dme' % motor, 0):
|
if motor_axis == axis.lower() and self.vars.get('%dme' % motor, 0):
|
||||||
|
|||||||
@@ -766,6 +766,111 @@ 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. Route through ExternalAxis so the
|
||||||
|
# synthetic motor's homed flag and DRO update.
|
||||||
|
ext = getattr(self.get_ctrl(), 'ext_axis', None)
|
||||||
|
if ext is not None and ext.enabled:
|
||||||
|
ext.home()
|
||||||
|
else:
|
||||||
|
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).
|
||||||
|
|
||||||
|
Note: with the gplan-integrated W axis, jog-by-mm goes through
|
||||||
|
ExternalAxis so the DRO updates and gplan's idea of A's position
|
||||||
|
stays in sync. jog-by-steps still bypasses everything for the
|
||||||
|
homing/setup workflow where the axis isn't homed yet."""
|
||||||
|
def put_ok(self):
|
||||||
|
body = self.json or {}
|
||||||
|
aux = self.get_ctrl().aux
|
||||||
|
ext = getattr(self.get_ctrl(), 'ext_axis', None)
|
||||||
|
if 'mm' in body:
|
||||||
|
delta_mm = float(body['mm'])
|
||||||
|
if ext is not None and ext.enabled and ext._pos_mm is not None:
|
||||||
|
ext.execute_to_mm(ext._pos_mm + delta_mm)
|
||||||
|
else:
|
||||||
|
aux.move_rel_mm(delta_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')
|
||||||
|
ext = getattr(self.get_ctrl(), 'ext_axis', None)
|
||||||
|
if ext is not None and ext.enabled:
|
||||||
|
ext.execute_to_mm(float(body['mm']))
|
||||||
|
else:
|
||||||
|
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):
|
||||||
@@ -798,7 +903,6 @@ class RemoteDiagnosticsHandler(bbctrl.APIHandler):
|
|||||||
'message': e.reason or "Unknown"
|
'message': e.reason or "Unknown"
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
class TimingHandler(bbctrl.APIHandler):
|
class TimingHandler(bbctrl.APIHandler):
|
||||||
"""Return the bbctrl process startup timeline as JSON.
|
"""Return the bbctrl process startup timeline as JSON.
|
||||||
|
|
||||||
@@ -992,6 +1096,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'}),
|
||||||
|
|||||||
@@ -66,6 +66,9 @@ 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
|
||||||
|
from bbctrl.ExternalAxis import ExternalAxis
|
||||||
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
|
||||||
|
|||||||
@@ -1457,6 +1457,9 @@ tt.save
|
|||||||
.dro-axis.axis-c
|
.dro-axis.axis-c
|
||||||
color #d946ef
|
color #d946ef
|
||||||
|
|
||||||
|
.dro-axis.axis-w
|
||||||
|
color #7c3aed
|
||||||
|
|
||||||
.dro-pos
|
.dro-pos
|
||||||
font-family 'JetBrains Mono', monospace
|
font-family 'JetBrains Mono', monospace
|
||||||
font-size 36px
|
font-size 36px
|
||||||
|
|||||||
336
src/svelte-components/src/components/AAxisSettings.svelte
Normal file
336
src/svelte-components/src/components/AAxisSettings.svelte
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import Button, { Label } from "@smui/button";
|
||||||
|
import * as api from "$lib/api";
|
||||||
|
|
||||||
|
// Mirrors the DEFAULTS in src/py/bbctrl/AuxAxis.py. The "enabled"
|
||||||
|
// flag is read-only here; toggling the auxiliary A axis on/off
|
||||||
|
// is done via aux.json on disk, so adding/removing the hardware
|
||||||
|
// doesn't have a surprise UI that bricks bring-up. Legacy aux.json
|
||||||
|
// files using min_w/max_w are migrated up to min_mm/max_mm by
|
||||||
|
// AuxAxis._migrate_legacy_fields on load.
|
||||||
|
type AuxConfig = {
|
||||||
|
enabled: boolean;
|
||||||
|
port: string;
|
||||||
|
baud: number;
|
||||||
|
steps_per_mm: number;
|
||||||
|
dir_sign: number;
|
||||||
|
axis_letter: string;
|
||||||
|
min_mm: number;
|
||||||
|
max_mm: number;
|
||||||
|
max_feed_mm_min: number;
|
||||||
|
max_velocity_m_per_min: number;
|
||||||
|
max_accel_km_per_min2: number;
|
||||||
|
max_jerk_km_per_min3: number;
|
||||||
|
home_dir: string;
|
||||||
|
home_position_mm: number;
|
||||||
|
home_fast_sps: number;
|
||||||
|
home_slow_sps: number;
|
||||||
|
home_backoff_steps: number;
|
||||||
|
home_maxtravel_steps: number;
|
||||||
|
step_max_sps: number;
|
||||||
|
step_accel_sps2: number;
|
||||||
|
step_start_sps: number;
|
||||||
|
limit_low: boolean;
|
||||||
|
couple_z_enabled: boolean;
|
||||||
|
couple_z_clearance_mm: number;
|
||||||
|
z_home_mm: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
let cfg: AuxConfig | null = null;
|
||||||
|
let status: { enabled: boolean; present: boolean; homed: boolean; pos_mm: number } | null = null;
|
||||||
|
let busy = false;
|
||||||
|
|
||||||
|
// Listen for the global "save-all" event the Vue root dispatches
|
||||||
|
// when the user clicks the master Save button. We persist our
|
||||||
|
// current cfg the same way the in-form button used to. This way
|
||||||
|
// the user only ever needs one Save button.
|
||||||
|
function onGlobalSave() {
|
||||||
|
save().catch(e => console.error("aux save failed:", e));
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
await refresh();
|
||||||
|
window.addEventListener("onefin:save-all", onGlobalSave);
|
||||||
|
return () => window.removeEventListener("onefin:save-all", onGlobalSave);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
try {
|
||||||
|
cfg = await api.GET("aux/config");
|
||||||
|
status = await api.GET("aux/status");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to load aux config/status:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!cfg) return;
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
await api.PUT("aux/config/save", cfg);
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to save aux config:", e);
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark the root config as modified whenever an auxiliary axis
|
||||||
|
// field is edited, so the master Save button highlights and
|
||||||
|
// the user knows there are unsaved changes.
|
||||||
|
function markDirty() {
|
||||||
|
try {
|
||||||
|
const root = (window as any).$root || (window as any).Vue?.root;
|
||||||
|
if (root && "modified" in root) root.modified = true;
|
||||||
|
} catch (_e) {}
|
||||||
|
// Also dispatch a generic event the Vue root listens for.
|
||||||
|
window.dispatchEvent(new CustomEvent("onefin:dirty"));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="a-axis-settings">
|
||||||
|
{#if !cfg}
|
||||||
|
<p class="tip">Loading A axis configuration...</p>
|
||||||
|
{:else}
|
||||||
|
<div class="status">
|
||||||
|
{#if status}
|
||||||
|
<span>
|
||||||
|
Status:
|
||||||
|
{#if !status.enabled}
|
||||||
|
disabled
|
||||||
|
{:else if !status.present}
|
||||||
|
offline
|
||||||
|
{:else if status.homed}
|
||||||
|
homed at {status.pos_mm.toFixed(3)} mm
|
||||||
|
{:else}
|
||||||
|
connected, unhomed
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pure-form pure-form-aligned" on:input={markDirty} on:change={markDirty}>
|
||||||
|
<fieldset>
|
||||||
|
<div class="pure-control-group" title="Enable the auxiliary axis (auxcnc-driven A). Edit aux.json to toggle.">
|
||||||
|
<label for="enabled">enabled</label>
|
||||||
|
<input id="enabled" type="checkbox" checked={cfg.enabled} disabled />
|
||||||
|
<label for="" class="units">(edit aux.json)</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pure-control-group" title="Serial port for the auxcnc ESP32.">
|
||||||
|
<label for="port">serial port</label>
|
||||||
|
<input id="port" type="text" bind:value={cfg.port} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pure-control-group" title="Serial baud rate.">
|
||||||
|
<label for="baud">baud</label>
|
||||||
|
<input id="baud" type="number" bind:value={cfg.baud} min={1200} step={1} />
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<h3>Mechanics</h3>
|
||||||
|
<fieldset>
|
||||||
|
<div class="pure-control-group" title="Logical steps per mm of axis travel.">
|
||||||
|
<label for="steps_per_mm">steps per mm</label>
|
||||||
|
<input id="steps_per_mm" type="number" bind:value={cfg.steps_per_mm} step="any" />
|
||||||
|
<label for="" class="units">steps/mm</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pure-control-group" title="Direction sign: +1 or -1. Flip if A+ moves the wrong way.">
|
||||||
|
<label for="dir_sign">direction sign</label>
|
||||||
|
<select id="dir_sign" bind:value={cfg.dir_sign}>
|
||||||
|
<option value={1}>+1</option>
|
||||||
|
<option value={-1}>-1</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pure-control-group" title="gcode axis letter exposed to the planner. Default 'a' (the standard 4th axis).">
|
||||||
|
<label for="axis_letter">axis letter</label>
|
||||||
|
<select id="axis_letter" bind:value={cfg.axis_letter}>
|
||||||
|
<option value="a">A</option>
|
||||||
|
<option value="b">B</option>
|
||||||
|
<option value="c">C</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pure-control-group" title="Soft-limit minimum in mm.">
|
||||||
|
<label for="min_mm">soft min</label>
|
||||||
|
<input id="min_mm" type="number" bind:value={cfg.min_mm} step="any" />
|
||||||
|
<label for="" class="units">mm</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pure-control-group" title="Soft-limit maximum in mm.">
|
||||||
|
<label for="max_mm">soft max</label>
|
||||||
|
<input id="max_mm" type="number" bind:value={cfg.max_mm} step="any" />
|
||||||
|
<label for="" class="units">mm</label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<h3>Z-A Coupling</h3>
|
||||||
|
<p class="tip">
|
||||||
|
The auxiliary tool hangs below the Z spindle. Beyond a small
|
||||||
|
Z descent the two collide unless A drops with Z. The rule
|
||||||
|
in machine coordinates is
|
||||||
|
<code>A − Z ≤ (A_home − Z_home) + clearance</code>.
|
||||||
|
When enabled, the planner refuses moves that would violate
|
||||||
|
it and the gcode preprocessor injects pre-position A moves
|
||||||
|
into uploaded files.
|
||||||
|
</p>
|
||||||
|
<fieldset>
|
||||||
|
<div class="pure-control-group" title="Master switch for the Z-A interlock. When off, no checks are performed.">
|
||||||
|
<label for="couple_z_enabled">enable coupling</label>
|
||||||
|
<input id="couple_z_enabled" type="checkbox" bind:checked={cfg.couple_z_enabled} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pure-control-group" title="How far Z may descend below its home position before A must move with it.">
|
||||||
|
<label for="couple_z_clearance_mm">Z clearance</label>
|
||||||
|
<input id="couple_z_clearance_mm" type="number" bind:value={cfg.couple_z_clearance_mm} step="any" />
|
||||||
|
<label for="" class="units">mm</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pure-control-group" title="Z's machine position when homed. Almost always 0.">
|
||||||
|
<label for="z_home_mm">Z home position</label>
|
||||||
|
<input id="z_home_mm" type="number" bind:value={cfg.z_home_mm} step="any" />
|
||||||
|
<label for="" class="units">mm</label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<h3>Planner Limits</h3>
|
||||||
|
<fieldset>
|
||||||
|
<div class="pure-control-group" title="Maximum velocity used by gplan trajectory planning.">
|
||||||
|
<label for="max_velocity_m_per_min">max velocity</label>
|
||||||
|
<input id="max_velocity_m_per_min" type="number" bind:value={cfg.max_velocity_m_per_min} step="any" />
|
||||||
|
<label for="" class="units">m/min</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pure-control-group" title="Maximum acceleration used by gplan trajectory planning.">
|
||||||
|
<label for="max_accel_km_per_min2">max acceleration</label>
|
||||||
|
<input id="max_accel_km_per_min2" type="number" bind:value={cfg.max_accel_km_per_min2} step="any" />
|
||||||
|
<label for="" class="units">km/min²</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pure-control-group" title="Maximum jerk used by gplan trajectory planning.">
|
||||||
|
<label for="max_jerk_km_per_min3">max jerk</label>
|
||||||
|
<input id="max_jerk_km_per_min3" type="number" bind:value={cfg.max_jerk_km_per_min3} step="any" />
|
||||||
|
<label for="" class="units">km/min³</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pure-control-group" title="Informational max feed; rate caps live on the ESP via step_max_sps.">
|
||||||
|
<label for="max_feed_mm_min">max feed</label>
|
||||||
|
<input id="max_feed_mm_min" type="number" bind:value={cfg.max_feed_mm_min} step="any" />
|
||||||
|
<label for="" class="units">mm/min</label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<h3>Homing</h3>
|
||||||
|
<fieldset>
|
||||||
|
<div class="pure-control-group" title="Direction the axis moves when looking for the home limit switch.">
|
||||||
|
<label for="home_dir">home direction</label>
|
||||||
|
<select id="home_dir" bind:value={cfg.home_dir}>
|
||||||
|
<option value="-">- (toward A-)</option>
|
||||||
|
<option value="+">+ (toward A+)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pure-control-group" title="Axis position assigned when homing completes.">
|
||||||
|
<label for="home_position_mm">home position</label>
|
||||||
|
<input id="home_position_mm" type="number" bind:value={cfg.home_position_mm} step="any" />
|
||||||
|
<label for="" class="units">mm</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pure-control-group" title="Fast seek rate during homing search.">
|
||||||
|
<label for="home_fast_sps">fast seek</label>
|
||||||
|
<input id="home_fast_sps" type="number" bind:value={cfg.home_fast_sps} step={1} min={1} />
|
||||||
|
<label for="" class="units">steps/s</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pure-control-group" title="Slow seek rate during homing latch.">
|
||||||
|
<label for="home_slow_sps">slow seek</label>
|
||||||
|
<input id="home_slow_sps" type="number" bind:value={cfg.home_slow_sps} step={1} min={1} />
|
||||||
|
<label for="" class="units">steps/s</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pure-control-group" title="Backoff after the limit triggers, before the slow seek.">
|
||||||
|
<label for="home_backoff_steps">backoff</label>
|
||||||
|
<input id="home_backoff_steps" type="number" bind:value={cfg.home_backoff_steps} step={1} min={0} />
|
||||||
|
<label for="" class="units">steps</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pure-control-group" title="Maximum travel before homing aborts as a runaway.">
|
||||||
|
<label for="home_maxtravel_steps">max travel</label>
|
||||||
|
<input id="home_maxtravel_steps" type="number" bind:value={cfg.home_maxtravel_steps} step={1} min={1} />
|
||||||
|
<label for="" class="units">steps</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pure-control-group" title="Limit switch active-low? Off = active-high.">
|
||||||
|
<label for="limit_low">limit active low</label>
|
||||||
|
<input id="limit_low" type="checkbox" bind:checked={cfg.limit_low} />
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<h3>Step Profile</h3>
|
||||||
|
<fieldset>
|
||||||
|
<div class="pure-control-group" title="Maximum step rate during normal moves.">
|
||||||
|
<label for="step_max_sps">max rate</label>
|
||||||
|
<input id="step_max_sps" type="number" bind:value={cfg.step_max_sps} step={1} min={1} />
|
||||||
|
<label for="" class="units">steps/s</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pure-control-group" title="Acceleration in steps per second squared.">
|
||||||
|
<label for="step_accel_sps2">acceleration</label>
|
||||||
|
<input id="step_accel_sps2" type="number" bind:value={cfg.step_accel_sps2} step={1} min={1} />
|
||||||
|
<label for="" class="units">steps/s²</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pure-control-group" title="Initial step rate at the start of a move.">
|
||||||
|
<label for="step_start_sps">start rate</label>
|
||||||
|
<input id="step_start_sps" type="number" bind:value={cfg.step_start_sps} step={1} min={1} />
|
||||||
|
<label for="" class="units">steps/s</label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div class="tip">
|
||||||
|
Changes are written to aux.json when you click the
|
||||||
|
master <strong>Save</strong> button at the bottom of the
|
||||||
|
settings rail. Homing rates and the limit polarity are
|
||||||
|
pushed to the ESP immediately; any running motion is
|
||||||
|
unaffected. Re-home the auxiliary axis after changing direction,
|
||||||
|
sign, or step settings.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.a-axis-settings {
|
||||||
|
.status {
|
||||||
|
margin-bottom: 1em;
|
||||||
|
font-size: 90%;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
margin-left: 210px;
|
||||||
|
margin-top: 1em;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-msg {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tip {
|
||||||
|
margin-left: 210px;
|
||||||
|
margin-top: 1em;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 90%;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -2,6 +2,10 @@
|
|||||||
import configTemplate from "../../../resources/config-template.json";
|
import configTemplate from "../../../resources/config-template.json";
|
||||||
import ScreenRotationDialog from "$dialogs/ScreenRotationDialog.svelte";
|
import ScreenRotationDialog from "$dialogs/ScreenRotationDialog.svelte";
|
||||||
import ConfigTemplatedInput from "./ConfigTemplatedInput.svelte";
|
import ConfigTemplatedInput from "./ConfigTemplatedInput.svelte";
|
||||||
|
// AAxisSettings is mounted directly by the V09 settings shell at
|
||||||
|
// #a-axis instead of being embedded here — see
|
||||||
|
// src/pug/templates/a-axis-view.pug.
|
||||||
|
// import AAxisSettings from "./AAxisSettings.svelte";
|
||||||
import SetTimeDialog from "$dialogs/SetTimeDialog.svelte";
|
import SetTimeDialog from "$dialogs/SetTimeDialog.svelte";
|
||||||
import Button, { Label } from "@smui/button";
|
import Button, { Label } from "@smui/button";
|
||||||
|
|
||||||
@@ -94,6 +98,10 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
|
<!-- W Axis (auxcnc) is now its own routed page in the V09
|
||||||
|
settings shell (#a-axis). Keep the SettingsView free of
|
||||||
|
that section so we don't render it twice. -->
|
||||||
|
|
||||||
<h2 id="sec-path-accuracy" data-sec="gcode">Path Accuracy</h2>
|
<h2 id="sec-path-accuracy" data-sec="gcode">Path Accuracy</h2>
|
||||||
<fieldset data-sec="gcode">
|
<fieldset data-sec="gcode">
|
||||||
<ConfigTemplatedInput key={`settings.max-deviation`} />
|
<ConfigTemplatedInput key={`settings.max-deviation`} />
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ matchAll.shim();
|
|||||||
import AdminNetworkView from "$components/AdminNetworkView.svelte";
|
import AdminNetworkView from "$components/AdminNetworkView.svelte";
|
||||||
import SettingsView from "$components/SettingsView.svelte";
|
import SettingsView from "$components/SettingsView.svelte";
|
||||||
import HelpView from "$components/HelpView.svelte";
|
import HelpView from "$components/HelpView.svelte";
|
||||||
|
import AAxisSettings from "$components/AAxisSettings.svelte";
|
||||||
import DialogHost, { showDialog } from "$dialogs/DialogHost.svelte";
|
import DialogHost, { showDialog } from "$dialogs/DialogHost.svelte";
|
||||||
import { handleConfigUpdate, setDisplayUnits } from "$lib/ConfigStore";
|
import { handleConfigUpdate, setDisplayUnits } from "$lib/ConfigStore";
|
||||||
import { handleControllerStateUpdate } from "$lib/ControllerState";
|
import { handleControllerStateUpdate } from "$lib/ControllerState";
|
||||||
@@ -22,6 +23,9 @@ export function createComponent(component: string, target: HTMLElement, props: R
|
|||||||
case "HelpView":
|
case "HelpView":
|
||||||
return new HelpView({ target, props });
|
return new HelpView({ target, props });
|
||||||
|
|
||||||
|
case "AAxisSettings":
|
||||||
|
return new AAxisSettings({ target, props });
|
||||||
|
|
||||||
case "DialogHost":
|
case "DialogHost":
|
||||||
return new DialogHost({ target, props });
|
return new DialogHost({ target, props });
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user