From c7cf9483b3601413a5ce0f1141b32a80a0315dea Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Apr 2026 16:51:24 +0200 Subject: [PATCH] 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. --- .gitignore | 2 + docs/AUX_W_AXIS.md | 144 ++++++++++ src/py/bbctrl/AuxAxis.py | 477 +++++++++++++++++++++++++++++++ src/py/bbctrl/AuxPreprocessor.py | 237 +++++++++++++++ src/py/bbctrl/Ctrl.py | 40 +++ src/py/bbctrl/FileHandler.py | 13 + src/py/bbctrl/Hooks.py | 88 ++++-- src/py/bbctrl/Mach.py | 32 +++ src/py/bbctrl/Web.py | 74 +++++ src/py/bbctrl/__init__.py | 1 + 10 files changed, 1092 insertions(+), 16 deletions(-) create mode 100644 docs/AUX_W_AXIS.md create mode 100644 src/py/bbctrl/AuxAxis.py create mode 100644 src/py/bbctrl/AuxPreprocessor.py diff --git a/.gitignore b/.gitignore index 20e8c00..d4a6821 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,5 @@ src/avr/emu/build/ .pi/pi-python35.tar.gz src/py/camotics/gplan.so.built +tmp/ +backup/ diff --git a/docs/AUX_W_AXIS.md b/docs/AUX_W_AXIS.md new file mode 100644 index 0000000..5ccd9a0 --- /dev/null +++ b/docs/AUX_W_AXIS.md @@ -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:)` 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 `/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 diff --git a/src/py/bbctrl/AuxAxis.py b/src/py/bbctrl/AuxAxis.py new file mode 100644 index 0000000..00914c8 --- /dev/null +++ b/src/py/bbctrl/AuxAxis.py @@ -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 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 diff --git a/src/py/bbctrl/AuxPreprocessor.py b/src/py/bbctrl/AuxPreprocessor.py new file mode 100644 index 0000000..5a1ae48 --- /dev/null +++ b/src/py/bbctrl/AuxPreprocessor.py @@ -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: +# - 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'(? 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'(? 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 diff --git a/src/py/bbctrl/Ctrl.py b/src/py/bbctrl/Ctrl.py index 16bd39d..f1e0e18 100644 --- a/src/py/bbctrl/Ctrl.py +++ b/src/py/bbctrl/Ctrl.py @@ -60,6 +60,8 @@ class Ctrl(object): if not args.demo: self.jog = bbctrl.Jog(self) self.pwr = bbctrl.Pwr(self) self.hooks = bbctrl.Hooks(self) + self.aux = bbctrl.AuxAxis(self) + self._register_aux_hooks() self.mach.connect() @@ -110,8 +112,46 @@ class Ctrl(object): self.preplanner.start() + def _register_aux_hooks(self): + """Wire up the auxcnc HOOK: events to AuxAxis methods.""" + log = self.log.get('AuxAxis') + + def _hook_move(ctx): + data = (ctx.get('data') or '').strip() + if not data: + raise Exception('aux hook missing target') + self.aux.move_abs_mm(float(data)) + + def _hook_move_rel(ctx): + data = (ctx.get('data') or '').strip() + if not data: + raise Exception('aux_rel hook missing delta') + self.aux.move_rel_mm(float(data)) + + def _hook_home(ctx): + self.aux.home() + + def _hook_setzero(ctx): + data = (ctx.get('data') or '').strip() + mm = float(data) if data else 0.0 + self.aux.set_position_mm(mm) + + self.hooks.register_internal('aux', _hook_move, + block_unpause=True, auto_resume=True) + self.hooks.register_internal('aux_rel', _hook_move_rel, + block_unpause=True, auto_resume=True) + self.hooks.register_internal('aux_home', _hook_home, + block_unpause=True, auto_resume=True, + timeout=180) + self.hooks.register_internal('aux_setzero', _hook_setzero, + block_unpause=True, auto_resume=True) + log.info('Aux hooks registered') + + def close(self): self.log.get('Ctrl').info('Closing %s' % self.id) self.ioloop.close() self.avr.close() self.mach.planner.close() + try: self.aux.close() + except Exception: pass diff --git a/src/py/bbctrl/FileHandler.py b/src/py/bbctrl/FileHandler.py index 3e4b144..ca44cda 100644 --- a/src/py/bbctrl/FileHandler.py +++ b/src/py/bbctrl/FileHandler.py @@ -99,6 +99,19 @@ class FileHandler(bbctrl.APIHandler): del (self.uploadFile) + # If the uploaded G-code uses the virtual W axis, rewrite the + # file in place so the planner sees (MSG,HOOK:aux:*) lines + # instead of W tokens it can't parse. + try: + from bbctrl.AuxPreprocessor import preprocess_file + log = self.get_log('AuxPreprocessor') + if preprocess_file(filename.decode('utf8'), log=log): + log.info('Rewrote W-axis tokens in %s' % + self.uploadFilename) + except Exception: + self.get_log('AuxPreprocessor').exception( + 'W-axis preprocess failed; uploading unchanged') + self.get_ctrl().preplanner.invalidate(self.uploadFilename) self.get_ctrl().state.add_file(self.uploadFilename) diff --git a/src/py/bbctrl/Hooks.py b/src/py/bbctrl/Hooks.py index d0853c5..4708a47 100644 --- a/src/py/bbctrl/Hooks.py +++ b/src/py/bbctrl/Hooks.py @@ -91,12 +91,19 @@ class Hooks: 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:). + # 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() @@ -191,10 +198,19 @@ class Hooks: new_state = state.get('xx', '') if new_state != self._last_state: 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: 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', {}) @@ -207,25 +223,60 @@ class Hooks: self._fire('pause', {'reason': pr}) self._last_pause_reason = pr - # Detect custom hook messages: (MSG,HOOK:event_name:data) - if 'message' in update: - msg = update['message'] - if isinstance(msg, str) and msg.startswith('HOOK:'): - parts = msg[5:].split(':', 1) - event = parts[0] - data = parts[1] if len(parts) > 1 else '' - self._fire('custom', { - 'event': event, - 'data': data, - }, custom_name=event) + # Detect custom hook messages emitted via (MSG,HOOK:event_name:data) + # gcode comments. State stores them as a list under 'messages' + # ([{'id': N, 'text': '...'}, ...]); fire only on new ids. + if 'messages' in update: + msgs = update['messages'] + if isinstance(msgs, list): + for m in msgs: + try: + mid = m.get('id', -1) + text = m.get('text', '') + except AttributeError: + continue + if mid <= self._last_msg_id: + continue + self._last_msg_id = mid + if isinstance(text, str) and text.startswith('HOOK:'): + parts = text[5:].split(':', 1) + event = parts[0] + data = parts[1] if len(parts) > 1 else '' + self._fire('custom', { + 'event': event, + 'data': data, + }, custom_name=event) # -- Hook execution -- + def register_internal(self, name, fn, block_unpause=True, + auto_resume=True, timeout=120): + """Register an in-process handler for HOOK: 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.""" - hook = self.hooks.get(event) - if custom_name and not hook: - hook = self.hooks.get(custom_name) + # 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 @@ -298,13 +349,18 @@ class Hooks: self.log.error('Auto-resume failed: %s' % e) 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') 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) diff --git a/src/py/bbctrl/Mach.py b/src/py/bbctrl/Mach.py index f8a01a3..b5a79e6 100644 --- a/src/py/bbctrl/Mach.py +++ b/src/py/bbctrl/Mach.py @@ -256,6 +256,9 @@ class Mach(Comm): if cmd[0] == '$': self._query_var(cmd) elif cmd[0] == '\\': super().queue_command(cmd[1:]) else: + # Rewrite W-axis tokens in MDI input the same way the + # FileHandler rewrites uploaded files. + cmd = self._rewrite_w_mdi(cmd) self._begin_cycle('mdi') self.planner.mdi(cmd, with_limits) super().resume() @@ -263,6 +266,35 @@ class Mach(Comm): self.mlog.info("Exception during MDI: %s" % err) pass + def _rewrite_w_mdi(self, cmd): + """Apply the W-axis preprocessor to a single MDI line. Returns + possibly-multi-line G-code with HOOK: comments inserted.""" + try: + from bbctrl.AuxPreprocessor import AuxPreprocessor, _W_TOKEN_RE + if not _W_TOKEN_RE.search(cmd): + return cmd + import io, tempfile, os + # AuxPreprocessor.process is file-based; route through + # tempfiles so we don't fork the regex/state logic. + pre = AuxPreprocessor(log=self.mlog) + with tempfile.NamedTemporaryFile('w', suffix='.nc', + delete=False) as fi: + fi.write(cmd if cmd.endswith('\n') else cmd + '\n') + ipath = fi.name + opath = ipath + '.out' + try: + pre.process(ipath, opath) + rewritten = open(opath).read() + finally: + try: os.unlink(ipath) + except OSError: pass + try: os.unlink(opath) + except OSError: pass + return rewritten + except Exception as e: + self.mlog.warning('W-axis MDI rewrite failed: %s' % e) + return cmd + def set(self, code, value): super().queue_command('${}={}'.format(code, value)) diff --git a/src/py/bbctrl/Web.py b/src/py/bbctrl/Web.py index d7a7e7b..e4c0557 100644 --- a/src/py/bbctrl/Web.py +++ b/src/py/bbctrl/Web.py @@ -787,6 +787,72 @@ class HooksFireHandler(bbctrl.APIHandler): 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 .""" + def put_ok(self): + body = self.json or {} + mm = float(body.get('mm', 0.0)) + self.get_ctrl().aux.set_position_mm(mm) + + class RemoteDiagnosticsHandler(bbctrl.APIHandler): def get(self): @@ -966,6 +1032,14 @@ class Web(tornado.web.Application): (r'/api/hooks/save', HooksSaveHandler), (r'/api/hooks/status', HooksStatusHandler), (r'/api/hooks/fire/([\w-]+)', HooksFireHandler), + (r'/api/aux/config', AuxConfigGetHandler), + (r'/api/aux/config/save', AuxConfigSaveHandler), + (r'/api/aux/status', AuxStatusHandler), + (r'/api/aux/home', AuxHomeHandler), + (r'/api/aux/abort', AuxAbortHandler), + (r'/api/aux/jog', AuxJogHandler), + (r'/api/aux/move', AuxMoveHandler), + (r'/api/aux/set-zero', AuxSetZeroHandler), (r'/(.*)', StaticFileHandler, {'path': bbctrl.get_resource('http/'), 'default_filename': 'index.html'}), diff --git a/src/py/bbctrl/__init__.py b/src/py/bbctrl/__init__.py index db3cd07..1c1c4d6 100644 --- a/src/py/bbctrl/__init__.py +++ b/src/py/bbctrl/__init__.py @@ -60,6 +60,7 @@ from bbctrl.AVREmu import AVREmu from bbctrl.IOLoop import IOLoop from bbctrl.MonitorTemp import MonitorTemp from bbctrl.Hooks import Hooks +from bbctrl.AuxAxis import AuxAxis import bbctrl.Cmd as Cmd import bbctrl.v4l2 as v4l2 import bbctrl.Log as log