Add W axis integration via auxcnc ESP32 over /dev/ttyUSB0
Rather than rebuild gplan + the AVR firmware to add a true 7th axis,
we treat W as a synchronous out-of-band axis that moves between G-code
blocks. The pipeline:
upload -> AuxPreprocessor rewrites W tokens into (MSG,HOOK:aux:N)
comments -> planner sees only XYZ + messages -> Hooks fires the
registered internal handler -> AuxAxis sends STEPS/HOME over serial
to the ESP and blocks the planner until done.
New files:
src/py/bbctrl/AuxAxis.py serial worker + RPC layer
src/py/bbctrl/AuxPreprocessor.py G-code rewriter
docs/AUX_W_AXIS.md design + ops notes
Changed:
Hooks.py register_internal(); fix the (MSG,HOOK:...) listener
to read the 'messages' state list (was broken before)
Ctrl.py instantiate AuxAxis, register aux/aux_rel/aux_home/
aux_setzero hooks
FileHandler.py rewrite uploads in place when they use W
Mach.py rewrite W tokens in MDI input the same way
Web.py REST endpoints under /api/aux/*
The ESP firmware in ../auxcnc was extended in lockstep: HOME, HOMECFG
(NVS-persisted), WPOS, HOMED?, LIMIT?, abortable STEPS with
limit-aware abort, trapezoidal ramps, deterministic [topic] reply
tokens, [boot] banner.
Real-time decisions (limit switch, step pulses) live on the ESP. The
host owns mm units, soft limits, and aux_homed bookkeeping. ESP
reboot mid-job clears aux_homed and surfaces a message; per design
manual jogs are still allowed without homing.
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -39,3 +39,5 @@ src/avr/emu/build/
|
|||||||
|
|
||||||
.pi/pi-python35.tar.gz
|
.pi/pi-python35.tar.gz
|
||||||
src/py/camotics/gplan.so.built
|
src/py/camotics/gplan.so.built
|
||||||
|
tmp/
|
||||||
|
backup/
|
||||||
|
|||||||
144
docs/AUX_W_AXIS.md
Normal file
144
docs/AUX_W_AXIS.md
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
# W axis (auxcnc) integration
|
||||||
|
|
||||||
|
This adds a virtual `W` axis to the bbctrl controller, driven by the
|
||||||
|
auxcnc ESP32 over USB serial (`/dev/ttyUSB0`). The ESP owns step-pulse
|
||||||
|
generation, real-time limit-switch monitoring, and the homing dance.
|
||||||
|
The Pi owns units (mm), soft limits, sequencing inside G-code jobs, and
|
||||||
|
a small REST API for jogging / homing from the UI.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
The bbctrl planner (gplan) only understands `xyzabc`, so adding a true
|
||||||
|
7th axis would require rebuilding gplan + the AVR firmware. We avoid
|
||||||
|
that by treating W as a synchronous out-of-band axis: W moves run
|
||||||
|
*between* G-code blocks, not blended with XYZ.
|
||||||
|
|
||||||
|
Pipeline:
|
||||||
|
|
||||||
|
1. User uploads a G-code file containing `W` words.
|
||||||
|
2. `FileHandler` runs `AuxPreprocessor` on the upload, rewriting W
|
||||||
|
tokens in place into `(MSG,HOOK:aux:<mm>)` etc. The original line
|
||||||
|
minus the W word continues to drive XYZ.
|
||||||
|
3. The planner sees only XYZ + message comments. When it reaches a
|
||||||
|
message line, the message goes through `state.add_message` which
|
||||||
|
`Hooks._on_state_change` watches for the `HOOK:` prefix.
|
||||||
|
4. `Hooks._fire('custom', ...)` finds the registered internal handler
|
||||||
|
for the event name (`aux`, `aux_rel`, `aux_home`, `aux_setzero`).
|
||||||
|
5. The handler runs in a hook thread, gating `Mach.unpause` until done.
|
||||||
|
While the handler is busy the machine is in HOLDING - no XYZ motion
|
||||||
|
can resume until W finishes.
|
||||||
|
6. The handler talks to the ESP over `/dev/ttyUSB0` via `AuxAxis`,
|
||||||
|
blocking on a deterministic reply token (`[step] done`, `[home]
|
||||||
|
done`, etc).
|
||||||
|
|
||||||
|
MDI commands containing `W` words are rewritten the same way at the
|
||||||
|
`Mach.mdi()` boundary so manual jog and macros work too.
|
||||||
|
|
||||||
|
## G-code surface
|
||||||
|
|
||||||
|
```gcode
|
||||||
|
G21 G90
|
||||||
|
G28 W0 ; home W axis
|
||||||
|
G1 W25 F300 ; move W to 25 mm absolute
|
||||||
|
G1 X100 W12.5 ; mixed: W moves first, then XYZ (configurable)
|
||||||
|
G91
|
||||||
|
G1 W-2.5 ; relative W move
|
||||||
|
G90
|
||||||
|
G92 W0 ; set current W as zero (G92-style)
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- `G28` / `G28.2` with W only -> homing hook; the bare `G28` is NOT
|
||||||
|
emitted to gplan (that would mean home-all).
|
||||||
|
- `G28.2 X0 Y0 W0` -> emit hook, then keep `G28.2 X0 Y0` for XY homing.
|
||||||
|
- A line with both W and XYZ axis words is split into two sequential
|
||||||
|
blocks. Default order: W first, then XYZ. Toggle via the
|
||||||
|
`w_first` constructor arg.
|
||||||
|
- Lines inside parens or after `;` are passed through verbatim.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Per-controller config lives at `<ctrl_path>/aux.json` (created on first
|
||||||
|
save via the API). Keys:
|
||||||
|
|
||||||
|
| Key | Default | Notes |
|
||||||
|
|------------------------|----------------|------------------------------------|
|
||||||
|
| `enabled` | `false` | Master switch |
|
||||||
|
| `port` | `/dev/ttyUSB0` | Serial device |
|
||||||
|
| `baud` | `115200` | |
|
||||||
|
| `steps_per_mm` | `80.0` | Logical steps per mm |
|
||||||
|
| `dir_sign` | `1` | +1 or -1: maps logical+ to motor+ |
|
||||||
|
| `min_w`, `max_w` | `0`, `100` | Soft limits in mm |
|
||||||
|
| `home_dir` | `'-'` | Direction toward limit switch |
|
||||||
|
| `home_position_mm` | `0.0` | mm value assigned at home |
|
||||||
|
| `home_fast_sps` | `4000` | Fast seek rate |
|
||||||
|
| `home_slow_sps` | `400` | Slow re-seek rate |
|
||||||
|
| `home_backoff_steps` | `200` | Backoff after touching limit |
|
||||||
|
| `home_maxtravel_steps` | `200000` | Hard cap on phase 1 seek |
|
||||||
|
| `step_max_sps` | `4000` | Cruise rate for STEPS |
|
||||||
|
| `step_accel_sps2` | `16000` | Trapezoidal ramp accel |
|
||||||
|
| `step_start_sps` | `200` | Ramp floor |
|
||||||
|
| `limit_low` | `true` | Switch active low (closed = LOW) |
|
||||||
|
|
||||||
|
Most of these are pushed to the ESP via `HOMECFG` on connect and
|
||||||
|
persisted there in NVS.
|
||||||
|
|
||||||
|
## REST API
|
||||||
|
|
||||||
|
| Verb | Path | Body | Effect |
|
||||||
|
|------|----------------------------|-----------------------|------------------------|
|
||||||
|
| GET | `/api/aux/config` | - | Current config |
|
||||||
|
| PUT | `/api/aux/config/save` | `{key: val, ...}` | Save and re-push |
|
||||||
|
| GET | `/api/aux/status` | - | `{enabled, present, homed, pos_mm}` |
|
||||||
|
| PUT | `/api/aux/home` | - | Run home cycle (blocks)|
|
||||||
|
| PUT | `/api/aux/abort` | - | Cancel running motion |
|
||||||
|
| PUT | `/api/aux/jog` | `{mm: 1.5}` or `{steps: 200}` | Relative move |
|
||||||
|
| PUT | `/api/aux/move` | `{mm: 12.5}` | Absolute move (mm) |
|
||||||
|
| PUT | `/api/aux/set-zero` | `{mm: 0}` | Set current pos to mm |
|
||||||
|
|
||||||
|
Steps-mode jog ignores soft limits (use it to inch the axis to the
|
||||||
|
limit switch when the axis isn't homed yet).
|
||||||
|
|
||||||
|
## State surface (UI)
|
||||||
|
|
||||||
|
These are pushed via `state.set` and visible in the websocket stream:
|
||||||
|
|
||||||
|
- `aux_enabled` - bool, axis is configured + enabled
|
||||||
|
- `aux_present` - bool, ESP responding on serial
|
||||||
|
- `aux_homed` - bool, has been homed since last ESP reset
|
||||||
|
- `aux_pos` - float, current W in mm (4 decimals)
|
||||||
|
|
||||||
|
## Edge cases
|
||||||
|
|
||||||
|
- **ESP reboots mid-job**: `[boot] auxcnc v=N` banner -> `aux_homed`
|
||||||
|
cleared, message added: "W axis controller restarted - re-home
|
||||||
|
before use". Subsequent W moves still run; if you want a hard fail
|
||||||
|
instead, that's a one-line change in `_require_present`.
|
||||||
|
- **Limit switch closed at boot of HOME**: `[home] failed
|
||||||
|
reason=already_at_limit` -> hook raises -> Mach surfaces error.
|
||||||
|
- **Pause mid-W-move**: the hook is blocking, so feed-hold takes
|
||||||
|
effect *after* the W move completes. For an immediate stop hit
|
||||||
|
estop; the Hooks listener will call `aux.abort()` which sends
|
||||||
|
`ABORT\n` to the ESP and the step-pulse loop exits.
|
||||||
|
- **Connection loss**: if `/dev/ttyUSB0` can't be opened at startup,
|
||||||
|
`aux_present=False` and any G-code with W will fail-fast at the
|
||||||
|
hook handler with "Aux axis not connected".
|
||||||
|
- **No home enforcement**: per design, manual jogs and W moves are
|
||||||
|
allowed even without a successful home. Soft limits still apply
|
||||||
|
unless you use the raw step jog endpoint.
|
||||||
|
|
||||||
|
## Files added/changed
|
||||||
|
|
||||||
|
- `src/py/bbctrl/AuxAxis.py` (new): serial worker + RPC layer
|
||||||
|
- `src/py/bbctrl/AuxPreprocessor.py` (new): G-code rewriter
|
||||||
|
- `src/py/bbctrl/Hooks.py`: register_internal(), fix the messages
|
||||||
|
listener so `(MSG,HOOK:...)` actually fires
|
||||||
|
- `src/py/bbctrl/Ctrl.py`: instantiate AuxAxis, register hooks
|
||||||
|
- `src/py/bbctrl/Mach.py`: rewrite MDI commands containing W
|
||||||
|
- `src/py/bbctrl/FileHandler.py`: rewrite uploads in place
|
||||||
|
- `src/py/bbctrl/Web.py`: REST endpoints
|
||||||
|
- `src/py/bbctrl/__init__.py`: export AuxAxis
|
||||||
|
- `auxcnc/src/main.cpp`: new commands HOME, HOMECFG, WPOS, HOMED?,
|
||||||
|
LIMIT?, ABORT-able STEPS with limit-aware abort, trapezoidal ramps,
|
||||||
|
NVS-persisted config, `[boot]` banner, deterministic reply tokens
|
||||||
477
src/py/bbctrl/AuxAxis.py
Normal file
477
src/py/bbctrl/AuxAxis.py
Normal file
@@ -0,0 +1,477 @@
|
|||||||
|
################################################################################
|
||||||
|
#
|
||||||
|
# AuxAxis - W-axis serial driver for the auxcnc ESP32 controller
|
||||||
|
#
|
||||||
|
# Owns /dev/ttyUSB0 (or whatever serial.port is configured to). Provides
|
||||||
|
# blocking RPCs for use from a hook thread. Maintains:
|
||||||
|
#
|
||||||
|
# - aux_present : True if serial is open and we've seen a boot banner
|
||||||
|
# - aux_homed : True if we've successfully run HOME since last reset
|
||||||
|
# - aux_pos : current logical position in mm (from ESP step counter
|
||||||
|
# * (1 / steps_per_mm * dir_sign))
|
||||||
|
#
|
||||||
|
# Real-time decisions (limit switch monitoring, step pulse generation) live
|
||||||
|
# on the ESP. The host is responsible for units, soft limits, and tracking
|
||||||
|
# whether we've ever boot-cycled the ESP since last home.
|
||||||
|
#
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import threading
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
try:
|
||||||
|
import serial
|
||||||
|
except ImportError:
|
||||||
|
serial = None
|
||||||
|
|
||||||
|
|
||||||
|
# Default config; overridden by ./aux.json or ctrl.config.
|
||||||
|
DEFAULTS = {
|
||||||
|
'enabled': False,
|
||||||
|
'port': '/dev/ttyUSB0',
|
||||||
|
'baud': 115200,
|
||||||
|
'steps_per_mm': 80.0, # logical steps per mm of W travel
|
||||||
|
'dir_sign': 1, # +1 or -1: maps logical+ to motor+ steps
|
||||||
|
'min_w': 0.0, # soft limit min (mm)
|
||||||
|
'max_w': 100.0, # soft limit max (mm)
|
||||||
|
'max_feed_mm_min': 600.0, # informational; rate caps are on the ESP
|
||||||
|
'home_dir': '-', # which direction is "toward limit" (host's view)
|
||||||
|
'home_position_mm': 0.0, # mm value to assign at home
|
||||||
|
# ESP-side homing rates (steps/sec). Pushed via HOMECFG on connect.
|
||||||
|
'home_fast_sps': 4000,
|
||||||
|
'home_slow_sps': 400,
|
||||||
|
'home_backoff_steps': 200,
|
||||||
|
'home_maxtravel_steps': 200000,
|
||||||
|
'step_max_sps': 4000,
|
||||||
|
'step_accel_sps2': 16000,
|
||||||
|
'step_start_sps': 200,
|
||||||
|
'limit_low': True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class AuxAxisError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class AuxAxis(object):
|
||||||
|
def __init__(self, ctrl):
|
||||||
|
self.ctrl = ctrl
|
||||||
|
self.log = ctrl.log.get('AuxAxis')
|
||||||
|
|
||||||
|
self._cfg = dict(DEFAULTS)
|
||||||
|
self._load_config()
|
||||||
|
|
||||||
|
self._sp = None
|
||||||
|
self._sp_lock = threading.Lock() # serial write/RPC serialization
|
||||||
|
self._rx_lock = threading.Lock() # read-line buffer access
|
||||||
|
self._reader_thread = None
|
||||||
|
self._stop = threading.Event()
|
||||||
|
|
||||||
|
# Pending replies waiting for a [topic] line. Single-slot since we
|
||||||
|
# serialize RPCs via _sp_lock.
|
||||||
|
self._pending_topics = []
|
||||||
|
self._pending_replies = []
|
||||||
|
self._pending_cv = threading.Condition()
|
||||||
|
|
||||||
|
# Async lines that aren't replies (e.g. logs) are simply logged.
|
||||||
|
self._present = False
|
||||||
|
self._homed = False
|
||||||
|
self._pos_steps = 0 # ESP step counter mirror
|
||||||
|
|
||||||
|
# Publish initial state
|
||||||
|
self._publish_state()
|
||||||
|
|
||||||
|
if not self._cfg['enabled']:
|
||||||
|
self.log.info('Aux axis disabled in config')
|
||||||
|
return
|
||||||
|
|
||||||
|
if serial is None:
|
||||||
|
self.log.error('pyserial not available; aux axis disabled')
|
||||||
|
return
|
||||||
|
|
||||||
|
self._open()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ config
|
||||||
|
|
||||||
|
def _config_path(self):
|
||||||
|
return self.ctrl.get_path(filename='aux.json')
|
||||||
|
|
||||||
|
def _load_config(self):
|
||||||
|
path = self._config_path()
|
||||||
|
if os.path.exists(path):
|
||||||
|
try:
|
||||||
|
with open(path) as f:
|
||||||
|
user = json.load(f)
|
||||||
|
# Be permissive; ignore unknown keys.
|
||||||
|
for k, v in user.items():
|
||||||
|
if k in self._cfg:
|
||||||
|
self._cfg[k] = v
|
||||||
|
self.log.info('Loaded aux config from %s' % path)
|
||||||
|
except Exception:
|
||||||
|
self.log.error('Failed to read aux.json: %s'
|
||||||
|
% traceback.format_exc())
|
||||||
|
|
||||||
|
def save_config(self, cfg):
|
||||||
|
merged = dict(DEFAULTS)
|
||||||
|
for k, v in cfg.items():
|
||||||
|
if k in DEFAULTS:
|
||||||
|
merged[k] = v
|
||||||
|
path = self._config_path()
|
||||||
|
with open(path, 'w') as f:
|
||||||
|
json.dump(merged, f, indent=2)
|
||||||
|
self._cfg = merged
|
||||||
|
self.log.info('Saved aux config')
|
||||||
|
# Push the relevant pieces to the ESP if connected.
|
||||||
|
if self._present:
|
||||||
|
try:
|
||||||
|
self._push_homecfg()
|
||||||
|
except Exception as e:
|
||||||
|
self.log.warning('Could not push HOMECFG after save: %s' % e)
|
||||||
|
|
||||||
|
def get_config(self):
|
||||||
|
return dict(self._cfg)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ public
|
||||||
|
|
||||||
|
@property
|
||||||
|
def enabled(self):
|
||||||
|
return bool(self._cfg.get('enabled', False))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def present(self):
|
||||||
|
return self._present
|
||||||
|
|
||||||
|
@property
|
||||||
|
def homed(self):
|
||||||
|
return self._homed
|
||||||
|
|
||||||
|
@property
|
||||||
|
def position_mm(self):
|
||||||
|
return self._steps_to_mm(self._pos_steps)
|
||||||
|
|
||||||
|
def home(self):
|
||||||
|
"""Run the homing cycle on the ESP. Blocks until done. Raises on
|
||||||
|
failure. Updates aux_homed and aux_pos."""
|
||||||
|
self._require_present()
|
||||||
|
line = self._rpc('HOME', topic='home', timeout=120.0)
|
||||||
|
# line is the body after '[home] '
|
||||||
|
if line.startswith('done'):
|
||||||
|
# ESP set its counter to home_zero; mirror that.
|
||||||
|
new_pos = self._parse_kv_int(line, 'pos', 0)
|
||||||
|
self._pos_steps = new_pos
|
||||||
|
self._homed = True
|
||||||
|
# Translate to home_position_mm. Conceptually the host says
|
||||||
|
# "after homing, W is here in mm". We achieve that by setting
|
||||||
|
# the ESP counter (WPOS) so the mm conversion works out.
|
||||||
|
target_pos = self._mm_to_steps(self._cfg['home_position_mm'])
|
||||||
|
if target_pos != new_pos:
|
||||||
|
self._rpc('WPOS %d' % target_pos, topic='ok', timeout=2.0)
|
||||||
|
self._pos_steps = target_pos
|
||||||
|
self._publish_state()
|
||||||
|
return
|
||||||
|
# failure
|
||||||
|
reason = line.split('reason=', 1)[1] if 'reason=' in line else line
|
||||||
|
raise AuxAxisError('Homing failed: %s' % reason)
|
||||||
|
|
||||||
|
def move_abs_mm(self, target_mm):
|
||||||
|
"""Move to absolute logical W position (mm). Blocks until done."""
|
||||||
|
self._require_present()
|
||||||
|
self._check_limits(target_mm)
|
||||||
|
target_steps = self._mm_to_steps(target_mm)
|
||||||
|
delta = target_steps - self._pos_steps
|
||||||
|
if delta == 0:
|
||||||
|
return
|
||||||
|
self._do_steps(delta)
|
||||||
|
|
||||||
|
def move_rel_mm(self, delta_mm):
|
||||||
|
"""Move by delta mm relative to current position. Blocks until done."""
|
||||||
|
self._require_present()
|
||||||
|
target_mm = self.position_mm + delta_mm
|
||||||
|
self._check_limits(target_mm)
|
||||||
|
target_steps = self._mm_to_steps(target_mm)
|
||||||
|
delta = target_steps - self._pos_steps
|
||||||
|
if delta == 0:
|
||||||
|
return
|
||||||
|
self._do_steps(delta)
|
||||||
|
|
||||||
|
def set_position_mm(self, mm):
|
||||||
|
"""Set current W to <mm> without moving (G92-style for W)."""
|
||||||
|
self._require_present()
|
||||||
|
steps = self._mm_to_steps(mm)
|
||||||
|
self._rpc('WPOS %d' % steps, topic='ok', timeout=2.0)
|
||||||
|
self._pos_steps = steps
|
||||||
|
# WPOS clears homed on the ESP; mirror it.
|
||||||
|
self._homed = False
|
||||||
|
self._publish_state()
|
||||||
|
|
||||||
|
def jog_steps(self, steps):
|
||||||
|
"""Raw step move bypassing mm conversion and soft limits.
|
||||||
|
Used by manual jog UI when axis isn't homed yet."""
|
||||||
|
self._require_present()
|
||||||
|
if steps == 0:
|
||||||
|
return
|
||||||
|
self._do_steps(int(steps), ignore_limits=True)
|
||||||
|
|
||||||
|
def abort(self):
|
||||||
|
"""Cancel any running ESP motion immediately."""
|
||||||
|
if not self._present:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
# Don't take the RPC lock; ABORT must be able to interrupt.
|
||||||
|
self._send_raw('ABORT')
|
||||||
|
except Exception as e:
|
||||||
|
self.log.warning('ABORT send failed: %s' % e)
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self._stop.set()
|
||||||
|
try:
|
||||||
|
if self._sp is not None:
|
||||||
|
self._sp.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ guts
|
||||||
|
|
||||||
|
def _require_present(self):
|
||||||
|
if not self.enabled:
|
||||||
|
raise AuxAxisError('Aux axis disabled')
|
||||||
|
if not self._present:
|
||||||
|
raise AuxAxisError('Aux axis not connected')
|
||||||
|
|
||||||
|
def _check_limits(self, target_mm):
|
||||||
|
lo = float(self._cfg['min_w'])
|
||||||
|
hi = float(self._cfg['max_w'])
|
||||||
|
if hi <= lo:
|
||||||
|
return # no limits
|
||||||
|
if target_mm < lo - 1e-6 or target_mm > hi + 1e-6:
|
||||||
|
raise AuxAxisError(
|
||||||
|
'W=%.3f out of soft limits [%.3f, %.3f]' % (target_mm, lo, hi))
|
||||||
|
|
||||||
|
def _mm_to_steps(self, mm):
|
||||||
|
spm = float(self._cfg['steps_per_mm'])
|
||||||
|
sign = 1 if int(self._cfg.get('dir_sign', 1)) >= 0 else -1
|
||||||
|
return int(round(mm * spm * sign))
|
||||||
|
|
||||||
|
def _steps_to_mm(self, steps):
|
||||||
|
spm = float(self._cfg['steps_per_mm']) or 1.0
|
||||||
|
sign = 1 if int(self._cfg.get('dir_sign', 1)) >= 0 else -1
|
||||||
|
return (steps / spm) * sign
|
||||||
|
|
||||||
|
def _do_steps(self, signed_count, ignore_limits=False):
|
||||||
|
max_rate = int(self._cfg['step_max_sps'])
|
||||||
|
accel = int(self._cfg['step_accel_sps2'])
|
||||||
|
safe_flag = 0 if ignore_limits else 1
|
||||||
|
cmd = 'STEPS %d maxrate=%d accel=%d safe=%d' % (
|
||||||
|
signed_count, max_rate, accel, safe_flag)
|
||||||
|
line = self._rpc(cmd, topic='step', timeout=300.0)
|
||||||
|
# line: "done count=N pos=P limit=L" or "aborted count=N pos=P [reason=...]"
|
||||||
|
if line.startswith('done'):
|
||||||
|
self._pos_steps = self._parse_kv_int(line, 'pos', self._pos_steps)
|
||||||
|
self._publish_state()
|
||||||
|
return
|
||||||
|
# aborted
|
||||||
|
self._pos_steps = self._parse_kv_int(line, 'pos', self._pos_steps)
|
||||||
|
self._publish_state()
|
||||||
|
reason = self._parse_kv_str(line, 'reason')
|
||||||
|
if reason == 'limit':
|
||||||
|
self._homed = False
|
||||||
|
raise AuxAxisError('W move aborted by limit switch')
|
||||||
|
raise AuxAxisError('W move aborted: %s' % line)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ serial I/O
|
||||||
|
|
||||||
|
def _open(self):
|
||||||
|
port = self._cfg['port']
|
||||||
|
baud = int(self._cfg['baud'])
|
||||||
|
try:
|
||||||
|
self._sp = serial.Serial(port, baud, timeout=0.2)
|
||||||
|
except Exception as e:
|
||||||
|
self.log.error('Could not open %s: %s' % (port, e))
|
||||||
|
self._sp = None
|
||||||
|
return
|
||||||
|
|
||||||
|
self.log.info('Opened %s @ %d' % (port, baud))
|
||||||
|
self._reader_thread = threading.Thread(
|
||||||
|
target=self._reader_loop, name='AuxAxis-rx', daemon=True)
|
||||||
|
self._reader_thread.start()
|
||||||
|
|
||||||
|
# Give the ESP a moment to settle, then push HOMECFG and query state.
|
||||||
|
# This runs in a background thread to avoid blocking startup.
|
||||||
|
threading.Thread(target=self._on_connect, daemon=True).start()
|
||||||
|
|
||||||
|
def _on_connect(self):
|
||||||
|
time.sleep(0.5)
|
||||||
|
try:
|
||||||
|
self._push_homecfg()
|
||||||
|
self._refresh_state()
|
||||||
|
except Exception as e:
|
||||||
|
self.log.warning('Aux post-connect setup failed: %s' % e)
|
||||||
|
|
||||||
|
def _push_homecfg(self):
|
||||||
|
c = self._cfg
|
||||||
|
cmd = ('HOMECFG dir=%s fast=%d slow=%d backoff=%d maxtravel=%d '
|
||||||
|
'zero=0 accel=%d step_max=%d step_start=%d limit_low=%d') % (
|
||||||
|
c['home_dir'],
|
||||||
|
int(c['home_fast_sps']),
|
||||||
|
int(c['home_slow_sps']),
|
||||||
|
int(c['home_backoff_steps']),
|
||||||
|
int(c['home_maxtravel_steps']),
|
||||||
|
int(c['step_accel_sps2']),
|
||||||
|
int(c['step_max_sps']),
|
||||||
|
int(c['step_start_sps']),
|
||||||
|
1 if c['limit_low'] else 0,
|
||||||
|
)
|
||||||
|
self._rpc(cmd, topic='homecfg', timeout=3.0)
|
||||||
|
|
||||||
|
def _refresh_state(self):
|
||||||
|
try:
|
||||||
|
r = self._rpc('WPOS?', topic='wpos', timeout=2.0)
|
||||||
|
self._pos_steps = int(r.strip())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
r = self._rpc('HOMED?', topic='homed', timeout=2.0)
|
||||||
|
self._homed = (r.strip() == '1')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._publish_state()
|
||||||
|
|
||||||
|
def _reader_loop(self):
|
||||||
|
buf = b''
|
||||||
|
while not self._stop.is_set():
|
||||||
|
sp = self._sp
|
||||||
|
if sp is None:
|
||||||
|
time.sleep(0.5)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
chunk = sp.read(256)
|
||||||
|
except Exception as e:
|
||||||
|
self.log.warning('Aux serial read error: %s' % e)
|
||||||
|
time.sleep(0.5)
|
||||||
|
continue
|
||||||
|
if not chunk:
|
||||||
|
continue
|
||||||
|
buf += chunk
|
||||||
|
while True:
|
||||||
|
nl = buf.find(b'\n')
|
||||||
|
if nl < 0:
|
||||||
|
break
|
||||||
|
line = buf[:nl].rstrip(b'\r').decode('utf-8', errors='replace')
|
||||||
|
buf = buf[nl+1:]
|
||||||
|
self._on_line(line)
|
||||||
|
|
||||||
|
def _on_line(self, line):
|
||||||
|
if not line:
|
||||||
|
return
|
||||||
|
# Boot banner -> reset homed flag.
|
||||||
|
if line.startswith('[boot]'):
|
||||||
|
self.log.warning('Aux ESP booted: %s' % line)
|
||||||
|
self._homed = False
|
||||||
|
self._present = True
|
||||||
|
self._publish_state()
|
||||||
|
self.ctrl.state.add_message(
|
||||||
|
'W axis controller restarted - re-home before use')
|
||||||
|
return
|
||||||
|
|
||||||
|
# Topic dispatch: "[topic] body..."
|
||||||
|
if line.startswith('[') and ']' in line:
|
||||||
|
rb = line.index(']')
|
||||||
|
topic = line[1:rb]
|
||||||
|
body = line[rb+1:].lstrip()
|
||||||
|
# Mark present on first known topic.
|
||||||
|
if not self._present:
|
||||||
|
self._present = True
|
||||||
|
self._publish_state()
|
||||||
|
# Match against the head of the pending queue.
|
||||||
|
with self._pending_cv:
|
||||||
|
if (self._pending_topics
|
||||||
|
and topic in self._pending_topics[0]):
|
||||||
|
# Pop and deliver
|
||||||
|
self._pending_topics.pop(0)
|
||||||
|
self._pending_replies.append(body)
|
||||||
|
self._pending_cv.notify_all()
|
||||||
|
return
|
||||||
|
# Async informational line; just log.
|
||||||
|
self.log.info('aux: %s' % line)
|
||||||
|
else:
|
||||||
|
self.log.info('aux: %s' % line)
|
||||||
|
|
||||||
|
def _send_raw(self, cmd):
|
||||||
|
sp = self._sp
|
||||||
|
if sp is None:
|
||||||
|
raise AuxAxisError('Serial not open')
|
||||||
|
if not cmd.endswith('\n'):
|
||||||
|
cmd = cmd + '\n'
|
||||||
|
sp.write(cmd.encode('utf-8'))
|
||||||
|
sp.flush()
|
||||||
|
|
||||||
|
def _rpc(self, cmd, topic, timeout=5.0):
|
||||||
|
"""Send `cmd`, wait for a reply line whose topic is in `topic`.
|
||||||
|
topic may be a single string or a tuple/list of acceptable topics
|
||||||
|
(e.g. ('home', 'err'))."""
|
||||||
|
if isinstance(topic, str):
|
||||||
|
topics = (topic, 'err')
|
||||||
|
else:
|
||||||
|
topics = tuple(topic) + ('err',)
|
||||||
|
|
||||||
|
with self._sp_lock:
|
||||||
|
with self._pending_cv:
|
||||||
|
self._pending_topics.append(topics)
|
||||||
|
self._pending_replies = [] # reset
|
||||||
|
self.log.info('aux >> %s' % cmd.strip())
|
||||||
|
self._send_raw(cmd)
|
||||||
|
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
with self._pending_cv:
|
||||||
|
while not self._pending_replies:
|
||||||
|
remaining = deadline - time.time()
|
||||||
|
if remaining <= 0:
|
||||||
|
# Drop the pending slot so we don't capture a
|
||||||
|
# late reply meant for the next caller.
|
||||||
|
try:
|
||||||
|
self._pending_topics.remove(topics)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
raise AuxAxisError(
|
||||||
|
'Timeout waiting for %s reply to "%s"'
|
||||||
|
% (topics, cmd.strip()))
|
||||||
|
self._pending_cv.wait(timeout=remaining)
|
||||||
|
reply = self._pending_replies.pop(0)
|
||||||
|
self.log.info('aux << %s' % reply)
|
||||||
|
if reply.startswith('err') or reply.startswith('error'):
|
||||||
|
raise AuxAxisError('ESP error: %s' % reply)
|
||||||
|
return reply
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_kv_int(line, key, default=0):
|
||||||
|
# Parse "key=N" (signed integer) out of a line.
|
||||||
|
for tok in line.split():
|
||||||
|
if tok.startswith(key + '='):
|
||||||
|
try:
|
||||||
|
return int(tok.split('=', 1)[1])
|
||||||
|
except ValueError:
|
||||||
|
return default
|
||||||
|
return default
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_kv_str(line, key, default=''):
|
||||||
|
for tok in line.split():
|
||||||
|
if tok.startswith(key + '='):
|
||||||
|
return tok.split('=', 1)[1]
|
||||||
|
return default
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ state push
|
||||||
|
|
||||||
|
def _publish_state(self):
|
||||||
|
st = self.ctrl.state
|
||||||
|
try:
|
||||||
|
st.set('aux_present', bool(self._present))
|
||||||
|
st.set('aux_homed', bool(self._homed))
|
||||||
|
st.set('aux_pos', round(self.position_mm, 4))
|
||||||
|
st.set('aux_enabled', bool(self.enabled))
|
||||||
|
except Exception:
|
||||||
|
# During very early startup, state may not be ready.
|
||||||
|
pass
|
||||||
237
src/py/bbctrl/AuxPreprocessor.py
Normal file
237
src/py/bbctrl/AuxPreprocessor.py
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
################################################################################
|
||||||
|
#
|
||||||
|
# AuxPreprocessor - rewrite W-axis G-code into hook calls
|
||||||
|
#
|
||||||
|
# The bbctrl planner only understands xyzabc. We expose a virtual W axis by
|
||||||
|
# rewriting the G-code file *before* it is fed to gplan, replacing each W
|
||||||
|
# move with a (MSG,HOOK:aux:...) line that the host's hook handler turns
|
||||||
|
# into a STEPS or HOME command on the ESP.
|
||||||
|
#
|
||||||
|
# Rules:
|
||||||
|
# - Mixed-axis blocks (W together with XYZABC) are split into two
|
||||||
|
# sequential blocks. By default the W move runs first; configurable.
|
||||||
|
# - G90/G91/G20/G21 modal state is tracked so we can convert relative-W
|
||||||
|
# and inch-W into the absolute mm value the hook handler expects.
|
||||||
|
# - G28 W0 / G28.2 W0 -> HOOK:aux_home
|
||||||
|
# - G92 Wx -> HOOK:aux_setzero:<mm>
|
||||||
|
# - G53 + W not specially handled (W only knows machine coords)
|
||||||
|
# - Lines inside parentheses or after `;` are passed through.
|
||||||
|
#
|
||||||
|
# The preprocessor is intentionally conservative: anything it doesn't
|
||||||
|
# understand involving W is left alone with a warning, so motion lands in
|
||||||
|
# gplan which will complain loudly rather than silently misbehaving.
|
||||||
|
#
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
|
||||||
|
# Match a word like "W12.5" or "W-3" or "w0". Also matches inside the same
|
||||||
|
# line as XYZ words. We pull W out specifically.
|
||||||
|
_W_TOKEN_RE = re.compile(r'(?<![A-Za-z_0-9])[Ww]\s*([-+]?\d*\.?\d+)')
|
||||||
|
|
||||||
|
# Detect any axis-bearing word (so we can tell mixed-axis lines apart).
|
||||||
|
_AXIS_WORD_RE = re.compile(r'(?<![A-Za-z_0-9])[XYZABCxyzabc]\s*[-+]?\d*\.?\d+')
|
||||||
|
|
||||||
|
# Strip line comments so we don't get fooled by "(W axis)".
|
||||||
|
_PAREN_COMMENT_RE = re.compile(r'\([^)]*\)')
|
||||||
|
|
||||||
|
# Modal G-code groups we care about.
|
||||||
|
_MODAL_RE = re.compile(r'(?<![A-Za-z_0-9])[Gg]\s*0*(\d+(?:\.\d+)?)')
|
||||||
|
|
||||||
|
|
||||||
|
class AuxPreprocessorError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class AuxPreprocessor(object):
|
||||||
|
def __init__(self, log=None, w_first=True):
|
||||||
|
self.log = log
|
||||||
|
# If True, on a mixed-axis line (e.g. G1 X10 W5), emit the W move
|
||||||
|
# first, then the XYZ move. Set False to invert.
|
||||||
|
self.w_first = w_first
|
||||||
|
|
||||||
|
def _info(self, msg):
|
||||||
|
if self.log:
|
||||||
|
self.log.info(msg)
|
||||||
|
|
||||||
|
def _warn(self, msg):
|
||||||
|
if self.log:
|
||||||
|
self.log.warning(msg)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ scan
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def file_uses_w(path):
|
||||||
|
"""Quick check: does this file contain any W-axis word? Used to skip
|
||||||
|
preprocessing entirely for files that don't care about W."""
|
||||||
|
try:
|
||||||
|
with open(path, 'r', encoding='utf-8', errors='replace') as f:
|
||||||
|
for line in f:
|
||||||
|
code = _PAREN_COMMENT_RE.sub('', line)
|
||||||
|
code = code.split(';', 1)[0]
|
||||||
|
if _W_TOKEN_RE.search(code):
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ core
|
||||||
|
|
||||||
|
def _strip_w(self, line):
|
||||||
|
"""Return (line_without_w, w_value_str_or_None). Only first W kept."""
|
||||||
|
m = _W_TOKEN_RE.search(line)
|
||||||
|
if m is None:
|
||||||
|
return line, None
|
||||||
|
# Remove just the matched W<num> token, preserving surrounding spaces.
|
||||||
|
rewritten = line[:m.start()] + line[m.end():]
|
||||||
|
return rewritten, m.group(1)
|
||||||
|
|
||||||
|
def _has_other_axis(self, code_no_w):
|
||||||
|
return _AXIS_WORD_RE.search(code_no_w) is not None
|
||||||
|
|
||||||
|
def _detect_modals(self, code, modal):
|
||||||
|
"""Update modal dict in-place from G-codes on this line."""
|
||||||
|
for mm in _MODAL_RE.finditer(code):
|
||||||
|
try:
|
||||||
|
g = float(mm.group(1))
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if g == 90: modal['abs'] = True
|
||||||
|
elif g == 91: modal['abs'] = False
|
||||||
|
elif g == 20: modal['inch'] = True
|
||||||
|
elif g == 21: modal['inch'] = False
|
||||||
|
# G28 / G28.2 / G92 are detected case-by-case below.
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_g28_like(code):
|
||||||
|
# Match G28 or G28.2 (homing).
|
||||||
|
return bool(re.search(r'(?<![A-Za-z_0-9])[Gg]\s*0*28(?:\.2)?(?![\w.])', code))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_g92(code):
|
||||||
|
return bool(re.search(r'(?<![A-Za-z_0-9])[Gg]\s*0*92(?![\w.])', code))
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ run
|
||||||
|
|
||||||
|
def process(self, src_path, dst_path):
|
||||||
|
"""Read src_path, write rewritten G-code to dst_path. Returns True
|
||||||
|
if any rewrite happened."""
|
||||||
|
modal = {'abs': True, 'inch': False} # G90 G21 are common defaults
|
||||||
|
rewrote_any = False
|
||||||
|
|
||||||
|
with open(src_path, 'r', encoding='utf-8', errors='replace') as fin, \
|
||||||
|
open(dst_path, 'w', encoding='utf-8') as fout:
|
||||||
|
for raw in fin:
|
||||||
|
line = raw.rstrip('\n')
|
||||||
|
|
||||||
|
# Comment-only or blank lines pass through verbatim.
|
||||||
|
code = _PAREN_COMMENT_RE.sub('', line)
|
||||||
|
code = code.split(';', 1)[0]
|
||||||
|
if not code.strip():
|
||||||
|
fout.write(raw)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Update modal from G-codes on this line first (so absolute
|
||||||
|
# vs incremental matches what the planner sees for XYZ).
|
||||||
|
self._detect_modals(code, modal)
|
||||||
|
|
||||||
|
if not _W_TOKEN_RE.search(code):
|
||||||
|
fout.write(raw)
|
||||||
|
continue
|
||||||
|
|
||||||
|
rewrote_any = True
|
||||||
|
|
||||||
|
# G28[.2] W... -> aux_home (W value is ignored except as
|
||||||
|
# a flag that W is being homed).
|
||||||
|
if self._is_g28_like(code):
|
||||||
|
code_no_w, _ = self._strip_w(line)
|
||||||
|
fout.write('(MSG,HOOK:aux_home:)\n')
|
||||||
|
# Only keep the residual line if other axes were also
|
||||||
|
# present (e.g. G28.2 X0 Y0 W0 still homes X+Y). A bare
|
||||||
|
# "G28" without axis args means "home all" in gcode
|
||||||
|
# which we explicitly DON'T want to trigger from a
|
||||||
|
# W-only home command.
|
||||||
|
rest_code = _PAREN_COMMENT_RE.sub('', code_no_w)
|
||||||
|
rest_code = rest_code.split(';', 1)[0]
|
||||||
|
if self._has_other_axis(rest_code):
|
||||||
|
fout.write(code_no_w + '\n')
|
||||||
|
continue
|
||||||
|
|
||||||
|
# G92 W... -> set W zero (or other value) without motion.
|
||||||
|
if self._is_g92(code):
|
||||||
|
line_no_w, w_val = self._strip_w(line)
|
||||||
|
target_mm = self._w_to_mm(w_val, modal, set_pos=True)
|
||||||
|
fout.write('(MSG,HOOK:aux_setzero:%g)\n' % target_mm)
|
||||||
|
rest_code = _PAREN_COMMENT_RE.sub('', line_no_w)
|
||||||
|
rest_code = rest_code.split(';', 1)[0]
|
||||||
|
if self._has_other_axis(rest_code):
|
||||||
|
fout.write(line_no_w + '\n')
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Plain motion: G0/G1 etc with W word.
|
||||||
|
line_no_w, w_val = self._strip_w(line)
|
||||||
|
target_mm = self._w_to_mm(w_val, modal, set_pos=False)
|
||||||
|
# Distinguish absolute vs relative: encode both, the hook
|
||||||
|
# handler will pick the right operation.
|
||||||
|
if modal['abs']:
|
||||||
|
hook_line = '(MSG,HOOK:aux:%g)' % target_mm
|
||||||
|
else:
|
||||||
|
hook_line = '(MSG,HOOK:aux_rel:%g)' % target_mm
|
||||||
|
|
||||||
|
rest_code = _PAREN_COMMENT_RE.sub('', line_no_w)
|
||||||
|
rest_code = rest_code.split(';', 1)[0]
|
||||||
|
has_xyz = self._has_other_axis(rest_code)
|
||||||
|
|
||||||
|
if not has_xyz:
|
||||||
|
# Pure W move; drop the (now-empty) original line.
|
||||||
|
fout.write(hook_line + '\n')
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Mixed-axis: split. Default order is W first.
|
||||||
|
if self.w_first:
|
||||||
|
fout.write(hook_line + '\n')
|
||||||
|
fout.write(line_no_w + '\n')
|
||||||
|
else:
|
||||||
|
fout.write(line_no_w + '\n')
|
||||||
|
fout.write(hook_line + '\n')
|
||||||
|
|
||||||
|
return rewrote_any
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ unit conv
|
||||||
|
|
||||||
|
def _w_to_mm(self, w_str, modal, set_pos):
|
||||||
|
try:
|
||||||
|
v = float(w_str)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise AuxPreprocessorError('Invalid W value: %r' % w_str)
|
||||||
|
if modal['inch']:
|
||||||
|
v *= 25.4
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
def preprocess_file(src_path, log=None, w_first=True):
|
||||||
|
"""Convenience: rewrite src_path in place if it uses W.
|
||||||
|
Returns True if the file was rewritten."""
|
||||||
|
if not AuxPreprocessor.file_uses_w(src_path):
|
||||||
|
return False
|
||||||
|
pre = AuxPreprocessor(log=log, w_first=w_first)
|
||||||
|
fd, tmp = tempfile.mkstemp(prefix='auxpre_', suffix='.nc',
|
||||||
|
dir=os.path.dirname(src_path) or None)
|
||||||
|
os.close(fd)
|
||||||
|
try:
|
||||||
|
rewrote = pre.process(src_path, tmp)
|
||||||
|
if rewrote:
|
||||||
|
shutil.move(tmp, src_path)
|
||||||
|
return True
|
||||||
|
os.unlink(tmp)
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
os.unlink(tmp)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
@@ -60,6 +60,8 @@ class Ctrl(object):
|
|||||||
if not args.demo: self.jog = bbctrl.Jog(self)
|
if not args.demo: self.jog = bbctrl.Jog(self)
|
||||||
self.pwr = bbctrl.Pwr(self)
|
self.pwr = bbctrl.Pwr(self)
|
||||||
self.hooks = bbctrl.Hooks(self)
|
self.hooks = bbctrl.Hooks(self)
|
||||||
|
self.aux = bbctrl.AuxAxis(self)
|
||||||
|
self._register_aux_hooks()
|
||||||
|
|
||||||
self.mach.connect()
|
self.mach.connect()
|
||||||
|
|
||||||
@@ -110,8 +112,46 @@ class Ctrl(object):
|
|||||||
self.preplanner.start()
|
self.preplanner.start()
|
||||||
|
|
||||||
|
|
||||||
|
def _register_aux_hooks(self):
|
||||||
|
"""Wire up the auxcnc HOOK: events to AuxAxis methods."""
|
||||||
|
log = self.log.get('AuxAxis')
|
||||||
|
|
||||||
|
def _hook_move(ctx):
|
||||||
|
data = (ctx.get('data') or '').strip()
|
||||||
|
if not data:
|
||||||
|
raise Exception('aux hook missing target')
|
||||||
|
self.aux.move_abs_mm(float(data))
|
||||||
|
|
||||||
|
def _hook_move_rel(ctx):
|
||||||
|
data = (ctx.get('data') or '').strip()
|
||||||
|
if not data:
|
||||||
|
raise Exception('aux_rel hook missing delta')
|
||||||
|
self.aux.move_rel_mm(float(data))
|
||||||
|
|
||||||
|
def _hook_home(ctx):
|
||||||
|
self.aux.home()
|
||||||
|
|
||||||
|
def _hook_setzero(ctx):
|
||||||
|
data = (ctx.get('data') or '').strip()
|
||||||
|
mm = float(data) if data else 0.0
|
||||||
|
self.aux.set_position_mm(mm)
|
||||||
|
|
||||||
|
self.hooks.register_internal('aux', _hook_move,
|
||||||
|
block_unpause=True, auto_resume=True)
|
||||||
|
self.hooks.register_internal('aux_rel', _hook_move_rel,
|
||||||
|
block_unpause=True, auto_resume=True)
|
||||||
|
self.hooks.register_internal('aux_home', _hook_home,
|
||||||
|
block_unpause=True, auto_resume=True,
|
||||||
|
timeout=180)
|
||||||
|
self.hooks.register_internal('aux_setzero', _hook_setzero,
|
||||||
|
block_unpause=True, auto_resume=True)
|
||||||
|
log.info('Aux hooks registered')
|
||||||
|
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
self.log.get('Ctrl').info('Closing %s' % self.id)
|
self.log.get('Ctrl').info('Closing %s' % self.id)
|
||||||
self.ioloop.close()
|
self.ioloop.close()
|
||||||
self.avr.close()
|
self.avr.close()
|
||||||
self.mach.planner.close()
|
self.mach.planner.close()
|
||||||
|
try: self.aux.close()
|
||||||
|
except Exception: pass
|
||||||
|
|||||||
@@ -99,6 +99,19 @@ class FileHandler(bbctrl.APIHandler):
|
|||||||
|
|
||||||
del (self.uploadFile)
|
del (self.uploadFile)
|
||||||
|
|
||||||
|
# If the uploaded G-code uses the virtual W axis, rewrite the
|
||||||
|
# file in place so the planner sees (MSG,HOOK:aux:*) lines
|
||||||
|
# instead of W tokens it can't parse.
|
||||||
|
try:
|
||||||
|
from bbctrl.AuxPreprocessor import preprocess_file
|
||||||
|
log = self.get_log('AuxPreprocessor')
|
||||||
|
if preprocess_file(filename.decode('utf8'), log=log):
|
||||||
|
log.info('Rewrote W-axis tokens in %s' %
|
||||||
|
self.uploadFilename)
|
||||||
|
except Exception:
|
||||||
|
self.get_log('AuxPreprocessor').exception(
|
||||||
|
'W-axis preprocess failed; uploading unchanged')
|
||||||
|
|
||||||
self.get_ctrl().preplanner.invalidate(self.uploadFilename)
|
self.get_ctrl().preplanner.invalidate(self.uploadFilename)
|
||||||
self.get_ctrl().state.add_file(self.uploadFilename)
|
self.get_ctrl().state.add_file(self.uploadFilename)
|
||||||
|
|
||||||
|
|||||||
@@ -91,12 +91,19 @@ class Hooks:
|
|||||||
self._hook_error = None # Error from last hook, if any
|
self._hook_error = None # Error from last hook, if any
|
||||||
self._hook_thread = None
|
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
|
# Track state for edge detection — must be set before add_listener
|
||||||
# because add_listener fires immediately with current state
|
# because add_listener fires immediately with current state
|
||||||
self._last_cycle = ctrl.state.get('cycle', 'idle')
|
self._last_cycle = ctrl.state.get('cycle', 'idle')
|
||||||
self._last_state = ctrl.state.get('xx', '')
|
self._last_state = ctrl.state.get('xx', '')
|
||||||
self._last_tool = ctrl.state.get('tool', 0)
|
self._last_tool = ctrl.state.get('tool', 0)
|
||||||
self._last_pause_reason = ctrl.state.get('pr', '')
|
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._initialized = False
|
||||||
|
|
||||||
self._load_config()
|
self._load_config()
|
||||||
@@ -191,10 +198,19 @@ class Hooks:
|
|||||||
new_state = state.get('xx', '')
|
new_state = state.get('xx', '')
|
||||||
if new_state != self._last_state:
|
if new_state != self._last_state:
|
||||||
if new_state == 'ESTOPPED':
|
if new_state == 'ESTOPPED':
|
||||||
# Cancel any running hook on estop
|
# Cancel any running hook on estop. The hook thread
|
||||||
|
# cannot be killed from Python, but we can ask the
|
||||||
|
# AuxAxis to send ABORT to the ESP so its in-flight
|
||||||
|
# motion stops.
|
||||||
if self._hook_busy:
|
if self._hook_busy:
|
||||||
self.log.warning('E-stop: cancelling hook "%s"' %
|
self.log.warning('E-stop: cancelling hook "%s"' %
|
||||||
self._hook_busy_event)
|
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 = False
|
||||||
self._hook_busy_event = None
|
self._hook_busy_event = None
|
||||||
self._fire('estop', {})
|
self._fire('estop', {})
|
||||||
@@ -207,25 +223,60 @@ class Hooks:
|
|||||||
self._fire('pause', {'reason': pr})
|
self._fire('pause', {'reason': pr})
|
||||||
self._last_pause_reason = pr
|
self._last_pause_reason = pr
|
||||||
|
|
||||||
# Detect custom hook messages: (MSG,HOOK:event_name:data)
|
# Detect custom hook messages emitted via (MSG,HOOK:event_name:data)
|
||||||
if 'message' in update:
|
# gcode comments. State stores them as a list under 'messages'
|
||||||
msg = update['message']
|
# ([{'id': N, 'text': '...'}, ...]); fire only on new ids.
|
||||||
if isinstance(msg, str) and msg.startswith('HOOK:'):
|
if 'messages' in update:
|
||||||
parts = msg[5:].split(':', 1)
|
msgs = update['messages']
|
||||||
event = parts[0]
|
if isinstance(msgs, list):
|
||||||
data = parts[1] if len(parts) > 1 else ''
|
for m in msgs:
|
||||||
self._fire('custom', {
|
try:
|
||||||
'event': event,
|
mid = m.get('id', -1)
|
||||||
'data': data,
|
text = m.get('text', '')
|
||||||
}, custom_name=event)
|
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 --
|
# -- Hook execution --
|
||||||
|
|
||||||
|
def register_internal(self, name, fn, block_unpause=True,
|
||||||
|
auto_resume=True, timeout=120):
|
||||||
|
"""Register an in-process handler for HOOK:<name> events.
|
||||||
|
|
||||||
|
fn(context) -> None. May raise. Runs synchronously in the hook
|
||||||
|
thread; while it runs and block_unpause=True, Mach.unpause is
|
||||||
|
gated."""
|
||||||
|
self._internal[name] = {
|
||||||
|
'type': 'internal',
|
||||||
|
'fn': fn,
|
||||||
|
'block_unpause': block_unpause,
|
||||||
|
'auto_resume': auto_resume,
|
||||||
|
'timeout': timeout,
|
||||||
|
}
|
||||||
|
self.log.info('Registered internal hook: %s' % name)
|
||||||
|
|
||||||
def _fire(self, event, context, custom_name=None):
|
def _fire(self, event, context, custom_name=None):
|
||||||
"""Fire a hook event."""
|
"""Fire a hook event."""
|
||||||
hook = self.hooks.get(event)
|
# Internal handlers win over hooks.json entries.
|
||||||
if custom_name and not hook:
|
hook = None
|
||||||
hook = self.hooks.get(custom_name)
|
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:
|
if not hook:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -298,13 +349,18 @@ class Hooks:
|
|||||||
self.log.error('Auto-resume failed: %s' % e)
|
self.log.error('Auto-resume failed: %s' % e)
|
||||||
|
|
||||||
def _execute_hook(self, hook, context):
|
def _execute_hook(self, hook, context):
|
||||||
"""Execute a single hook (webhook or script). May block."""
|
"""Execute a single hook (webhook, script, or internal). May block."""
|
||||||
hook_type = hook.get('type', 'webhook')
|
hook_type = hook.get('type', 'webhook')
|
||||||
|
|
||||||
if hook_type == 'webhook':
|
if hook_type == 'webhook':
|
||||||
self._fire_webhook(hook, context)
|
self._fire_webhook(hook, context)
|
||||||
elif hook_type == 'script':
|
elif hook_type == 'script':
|
||||||
self._fire_script(hook, context)
|
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:
|
else:
|
||||||
raise Exception('Unknown hook type: %s' % hook_type)
|
raise Exception('Unknown hook type: %s' % hook_type)
|
||||||
|
|
||||||
|
|||||||
@@ -256,6 +256,9 @@ class Mach(Comm):
|
|||||||
if cmd[0] == '$': self._query_var(cmd)
|
if cmd[0] == '$': self._query_var(cmd)
|
||||||
elif cmd[0] == '\\': super().queue_command(cmd[1:])
|
elif cmd[0] == '\\': super().queue_command(cmd[1:])
|
||||||
else:
|
else:
|
||||||
|
# Rewrite W-axis tokens in MDI input the same way the
|
||||||
|
# FileHandler rewrites uploaded files.
|
||||||
|
cmd = self._rewrite_w_mdi(cmd)
|
||||||
self._begin_cycle('mdi')
|
self._begin_cycle('mdi')
|
||||||
self.planner.mdi(cmd, with_limits)
|
self.planner.mdi(cmd, with_limits)
|
||||||
super().resume()
|
super().resume()
|
||||||
@@ -263,6 +266,35 @@ class Mach(Comm):
|
|||||||
self.mlog.info("Exception during MDI: %s" % err)
|
self.mlog.info("Exception during MDI: %s" % err)
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def _rewrite_w_mdi(self, cmd):
|
||||||
|
"""Apply the W-axis preprocessor to a single MDI line. Returns
|
||||||
|
possibly-multi-line G-code with HOOK: comments inserted."""
|
||||||
|
try:
|
||||||
|
from bbctrl.AuxPreprocessor import AuxPreprocessor, _W_TOKEN_RE
|
||||||
|
if not _W_TOKEN_RE.search(cmd):
|
||||||
|
return cmd
|
||||||
|
import io, tempfile, os
|
||||||
|
# AuxPreprocessor.process is file-based; route through
|
||||||
|
# tempfiles so we don't fork the regex/state logic.
|
||||||
|
pre = AuxPreprocessor(log=self.mlog)
|
||||||
|
with tempfile.NamedTemporaryFile('w', suffix='.nc',
|
||||||
|
delete=False) as fi:
|
||||||
|
fi.write(cmd if cmd.endswith('\n') else cmd + '\n')
|
||||||
|
ipath = fi.name
|
||||||
|
opath = ipath + '.out'
|
||||||
|
try:
|
||||||
|
pre.process(ipath, opath)
|
||||||
|
rewritten = open(opath).read()
|
||||||
|
finally:
|
||||||
|
try: os.unlink(ipath)
|
||||||
|
except OSError: pass
|
||||||
|
try: os.unlink(opath)
|
||||||
|
except OSError: pass
|
||||||
|
return rewritten
|
||||||
|
except Exception as e:
|
||||||
|
self.mlog.warning('W-axis MDI rewrite failed: %s' % e)
|
||||||
|
return cmd
|
||||||
|
|
||||||
def set(self, code, value):
|
def set(self, code, value):
|
||||||
super().queue_command('${}={}'.format(code, value))
|
super().queue_command('${}={}'.format(code, value))
|
||||||
|
|
||||||
|
|||||||
@@ -787,6 +787,72 @@ class HooksFireHandler(bbctrl.APIHandler):
|
|||||||
self.get_ctrl().hooks._fire(event, data)
|
self.get_ctrl().hooks._fire(event, data)
|
||||||
|
|
||||||
|
|
||||||
|
# ----- W axis (auxcnc) endpoints --------------------------------------------
|
||||||
|
|
||||||
|
class AuxConfigGetHandler(bbctrl.APIHandler):
|
||||||
|
def get(self):
|
||||||
|
self.write_json(self.get_ctrl().aux.get_config())
|
||||||
|
|
||||||
|
|
||||||
|
class AuxConfigSaveHandler(bbctrl.APIHandler):
|
||||||
|
def put_ok(self):
|
||||||
|
self.get_ctrl().aux.save_config(self.json or {})
|
||||||
|
|
||||||
|
|
||||||
|
class AuxStatusHandler(bbctrl.APIHandler):
|
||||||
|
def get(self):
|
||||||
|
aux = self.get_ctrl().aux
|
||||||
|
self.write_json({
|
||||||
|
'enabled': aux.enabled,
|
||||||
|
'present': aux.present,
|
||||||
|
'homed': aux.homed,
|
||||||
|
'pos_mm': aux.position_mm,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
class AuxHomeHandler(bbctrl.APIHandler):
|
||||||
|
def put_ok(self):
|
||||||
|
# Run synchronously via the AuxAxis' own RPC; this blocks the
|
||||||
|
# request. Fine because the UI shows a spinner.
|
||||||
|
self.get_ctrl().aux.home()
|
||||||
|
|
||||||
|
|
||||||
|
class AuxAbortHandler(bbctrl.APIHandler):
|
||||||
|
def put_ok(self):
|
||||||
|
self.get_ctrl().aux.abort()
|
||||||
|
|
||||||
|
|
||||||
|
class AuxJogHandler(bbctrl.APIHandler):
|
||||||
|
"""Body: {"mm": 1.5} for relative-mm move,
|
||||||
|
{"steps": 200} for raw step move (bypasses soft limits)."""
|
||||||
|
def put_ok(self):
|
||||||
|
body = self.json or {}
|
||||||
|
aux = self.get_ctrl().aux
|
||||||
|
if 'mm' in body:
|
||||||
|
aux.move_rel_mm(float(body['mm']))
|
||||||
|
elif 'steps' in body:
|
||||||
|
aux.jog_steps(int(body['steps']))
|
||||||
|
else:
|
||||||
|
raise HTTPError(400, 'mm or steps required')
|
||||||
|
|
||||||
|
|
||||||
|
class AuxMoveHandler(bbctrl.APIHandler):
|
||||||
|
"""Body: {"mm": 12.5} absolute move in mm."""
|
||||||
|
def put_ok(self):
|
||||||
|
body = self.json or {}
|
||||||
|
if 'mm' not in body:
|
||||||
|
raise HTTPError(400, 'mm required')
|
||||||
|
self.get_ctrl().aux.move_abs_mm(float(body['mm']))
|
||||||
|
|
||||||
|
|
||||||
|
class AuxSetZeroHandler(bbctrl.APIHandler):
|
||||||
|
"""Body: {"mm": 0} set current position to <mm>."""
|
||||||
|
def put_ok(self):
|
||||||
|
body = self.json or {}
|
||||||
|
mm = float(body.get('mm', 0.0))
|
||||||
|
self.get_ctrl().aux.set_position_mm(mm)
|
||||||
|
|
||||||
|
|
||||||
class RemoteDiagnosticsHandler(bbctrl.APIHandler):
|
class RemoteDiagnosticsHandler(bbctrl.APIHandler):
|
||||||
|
|
||||||
def get(self):
|
def get(self):
|
||||||
@@ -966,6 +1032,14 @@ class Web(tornado.web.Application):
|
|||||||
(r'/api/hooks/save', HooksSaveHandler),
|
(r'/api/hooks/save', HooksSaveHandler),
|
||||||
(r'/api/hooks/status', HooksStatusHandler),
|
(r'/api/hooks/status', HooksStatusHandler),
|
||||||
(r'/api/hooks/fire/([\w-]+)', HooksFireHandler),
|
(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'}),
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ 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.Hooks import Hooks
|
||||||
|
from bbctrl.AuxAxis import AuxAxis
|
||||||
import bbctrl.Cmd as Cmd
|
import bbctrl.Cmd as Cmd
|
||||||
import bbctrl.v4l2 as v4l2
|
import bbctrl.v4l2 as v4l2
|
||||||
import bbctrl.Log as log
|
import bbctrl.Log as log
|
||||||
|
|||||||
Reference in New Issue
Block a user