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.
430 lines
16 KiB
Python
430 lines
16 KiB
Python
################################################################################
|
|
#
|
|
# Hooks - External event triggers during G-code execution
|
|
#
|
|
# Integrates with the controller's pause/unpause cycle to run external
|
|
# actions (webhooks, scripts) at specific points during G-code execution.
|
|
#
|
|
# ## How tool-change hooks work (the important one):
|
|
#
|
|
# G-code: T5 M6
|
|
#
|
|
# 1. Planner replaces M6 with tool-change override G-code (configurable).
|
|
# Default: "M0 M6 (MSG, Change tool)"
|
|
#
|
|
# 2. Planner emits: set(tool,5), pause(program), message("Change tool")
|
|
# These are sent to the AVR as serial commands.
|
|
#
|
|
# 3. AVR finishes current move, enters HOLDING state.
|
|
# Reports back: xx=HOLDING, pr="Program pause"
|
|
#
|
|
# 4. Pi: Mach._update() sees HOLDING, flushes CommandQueue.
|
|
# CommandQueue executes callbacks: state.set('tool', 5) fires.
|
|
#
|
|
# 5. Hooks._on_state_change() sees tool changed.
|
|
# Sets self._hook_busy = True, runs the hook in a thread.
|
|
# While _hook_busy, Mach.unpause() is blocked via can_unpause().
|
|
#
|
|
# 6. Machine sits in HOLDING. UI shows "Change tool" message.
|
|
# User cannot resume yet (unpause is gated).
|
|
#
|
|
# 7. Hook thread finishes (toolchanger done). Sets _hook_busy = False.
|
|
# If auto_resume is set, calls unpause automatically.
|
|
# Otherwise user clicks Continue in UI.
|
|
#
|
|
# 8. Mach.unpause() → planner.restart() → AVR UNPAUSE → motion resumes.
|
|
#
|
|
# ## Configuration (hooks.json):
|
|
#
|
|
# {
|
|
# "tool-change": {
|
|
# "type": "webhook",
|
|
# "url": "http://toolchanger.local/api/change",
|
|
# "method": "POST",
|
|
# "timeout": 120,
|
|
# "block_unpause": true,
|
|
# "auto_resume": true
|
|
# },
|
|
# "program-start": {
|
|
# "type": "script",
|
|
# "command": "/usr/local/bin/dust-collector on",
|
|
# "block_unpause": false
|
|
# }
|
|
# }
|
|
#
|
|
# block_unpause: if true, unpause is blocked until hook completes
|
|
# auto_resume: if true AND block_unpause, auto-unpause after hook done
|
|
#
|
|
################################################################################
|
|
|
|
import os
|
|
import json
|
|
import subprocess
|
|
import threading
|
|
import traceback
|
|
from urllib.request import Request, urlopen
|
|
from urllib.error import URLError
|
|
|
|
|
|
# Events that can be hooked
|
|
HOOK_EVENTS = [
|
|
'tool-change', # M6 - tool change requested
|
|
'program-start', # Program begins running
|
|
'program-end', # M2/M30 - program ends
|
|
'pause', # M0/M1 - program pause
|
|
'estop', # Emergency stop triggered
|
|
'homing-start', # Homing cycle begins
|
|
'homing-end', # Homing cycle completes
|
|
'custom', # Triggered by (MSG,HOOK:name:data) comments
|
|
]
|
|
|
|
|
|
class Hooks:
|
|
def __init__(self, ctrl):
|
|
self.ctrl = ctrl
|
|
self.log = ctrl.log.get('Hooks')
|
|
self.hooks = {}
|
|
|
|
# Hook execution state
|
|
self._hook_busy = False # True while a blocking hook runs
|
|
self._hook_busy_event = None # Which event is blocking
|
|
self._hook_error = None # Error from last hook, if any
|
|
self._hook_thread = None
|
|
|
|
# In-process hook handlers registered by Python modules. Keyed by
|
|
# event name (matches what the G-code emits as HOOK:<event>).
|
|
# Take precedence over hooks.json entries with the same name.
|
|
self._internal = {}
|
|
|
|
# Track state for edge detection — must be set before add_listener
|
|
# because add_listener fires immediately with current state
|
|
self._last_cycle = ctrl.state.get('cycle', 'idle')
|
|
self._last_state = ctrl.state.get('xx', '')
|
|
self._last_tool = ctrl.state.get('tool', 0)
|
|
self._last_pause_reason = ctrl.state.get('pr', '')
|
|
# Highest message id we've already inspected for HOOK: lines.
|
|
self._last_msg_id = -1
|
|
self._initialized = False
|
|
|
|
self._load_config()
|
|
|
|
# Listen for state changes
|
|
ctrl.state.add_listener(self._on_state_change)
|
|
self._initialized = True
|
|
|
|
# -- Config management --
|
|
|
|
def _get_config_path(self):
|
|
return self.ctrl.get_path(filename='hooks.json')
|
|
|
|
def _load_config(self):
|
|
path = self._get_config_path()
|
|
if os.path.exists(path):
|
|
try:
|
|
with open(path) as f:
|
|
self.hooks = json.load(f)
|
|
self.log.info('Loaded %d hook(s) from %s' %
|
|
(len(self.hooks), path))
|
|
except Exception:
|
|
self.log.error('Failed to load hooks.json: %s' %
|
|
traceback.format_exc())
|
|
else:
|
|
self.log.info('No hooks.json found, hooks disabled')
|
|
|
|
def save_config(self, config):
|
|
"""Save hook configuration (called from API)."""
|
|
path = self._get_config_path()
|
|
with open(path, 'w') as f:
|
|
json.dump(config, f, indent=2)
|
|
self.hooks = config
|
|
self.log.info('Saved %d hook(s)' % len(config))
|
|
|
|
def get_config(self):
|
|
return self.hooks
|
|
|
|
# -- Unpause gating (called from Mach) --
|
|
|
|
def can_unpause(self):
|
|
"""Returns True if no blocking hook is running.
|
|
Called by Mach.unpause() to gate resume."""
|
|
if self._hook_busy:
|
|
self.log.info('Unpause blocked: hook "%s" still running' %
|
|
self._hook_busy_event)
|
|
return False
|
|
return True
|
|
|
|
def get_status(self):
|
|
"""Return current hook execution status for the UI."""
|
|
return {
|
|
'busy': self._hook_busy,
|
|
'event': self._hook_busy_event,
|
|
'error': self._hook_error,
|
|
}
|
|
|
|
# -- State change listener --
|
|
|
|
def _on_state_change(self, update):
|
|
"""Called on every state update from the controller."""
|
|
if not self._initialized:
|
|
return
|
|
state = self.ctrl.state
|
|
|
|
# Detect tool change (tool number changed while HOLDING)
|
|
if 'tool' in update:
|
|
new_tool = update['tool']
|
|
if new_tool != self._last_tool:
|
|
self._fire('tool-change', {
|
|
'old_tool': self._last_tool,
|
|
'new_tool': new_tool,
|
|
})
|
|
self._last_tool = new_tool
|
|
|
|
# Detect cycle changes
|
|
if 'cycle' in update:
|
|
new_cycle = update['cycle']
|
|
if new_cycle != self._last_cycle:
|
|
if new_cycle == 'running' and self._last_cycle == 'idle':
|
|
self._fire('program-start', {})
|
|
elif new_cycle == 'idle' and self._last_cycle == 'running':
|
|
self._fire('program-end', {})
|
|
elif new_cycle == 'homing':
|
|
self._fire('homing-start', {})
|
|
elif self._last_cycle == 'homing' and new_cycle == 'idle':
|
|
self._fire('homing-end', {})
|
|
self._last_cycle = new_cycle
|
|
|
|
# Detect AVR state changes
|
|
if 'xc' in update or 'xx' in update:
|
|
new_state = state.get('xx', '')
|
|
if new_state != self._last_state:
|
|
if new_state == 'ESTOPPED':
|
|
# Cancel any running hook on estop. The hook thread
|
|
# cannot be killed from Python, but we can ask the
|
|
# AuxAxis to send ABORT to the ESP so its in-flight
|
|
# motion stops.
|
|
if self._hook_busy:
|
|
self.log.warning('E-stop: cancelling hook "%s"' %
|
|
self._hook_busy_event)
|
|
try:
|
|
aux = getattr(self.ctrl, 'aux', None)
|
|
if aux is not None:
|
|
aux.abort()
|
|
except Exception:
|
|
pass
|
|
self._hook_busy = False
|
|
self._hook_busy_event = None
|
|
self._fire('estop', {})
|
|
self._last_state = new_state
|
|
|
|
# Detect pause
|
|
if 'pr' in update:
|
|
pr = update['pr']
|
|
if pr and pr != self._last_pause_reason:
|
|
self._fire('pause', {'reason': pr})
|
|
self._last_pause_reason = pr
|
|
|
|
# Detect custom hook messages emitted via (MSG,HOOK:event_name:data)
|
|
# gcode comments. State stores them as a list under 'messages'
|
|
# ([{'id': N, 'text': '...'}, ...]); fire only on new ids.
|
|
if 'messages' in update:
|
|
msgs = update['messages']
|
|
if isinstance(msgs, list):
|
|
for m in msgs:
|
|
try:
|
|
mid = m.get('id', -1)
|
|
text = m.get('text', '')
|
|
except AttributeError:
|
|
continue
|
|
if mid <= self._last_msg_id:
|
|
continue
|
|
self._last_msg_id = mid
|
|
if isinstance(text, str) and text.startswith('HOOK:'):
|
|
parts = text[5:].split(':', 1)
|
|
event = parts[0]
|
|
data = parts[1] if len(parts) > 1 else ''
|
|
self._fire('custom', {
|
|
'event': event,
|
|
'data': data,
|
|
}, custom_name=event)
|
|
|
|
# -- Hook execution --
|
|
|
|
def register_internal(self, name, fn, block_unpause=True,
|
|
auto_resume=True, timeout=120):
|
|
"""Register an in-process handler for HOOK:<name> events.
|
|
|
|
fn(context) -> None. May raise. Runs synchronously in the hook
|
|
thread; while it runs and block_unpause=True, Mach.unpause is
|
|
gated."""
|
|
self._internal[name] = {
|
|
'type': 'internal',
|
|
'fn': fn,
|
|
'block_unpause': block_unpause,
|
|
'auto_resume': auto_resume,
|
|
'timeout': timeout,
|
|
}
|
|
self.log.info('Registered internal hook: %s' % name)
|
|
|
|
def _fire(self, event, context, custom_name=None):
|
|
"""Fire a hook event."""
|
|
# Internal handlers win over hooks.json entries.
|
|
hook = None
|
|
if custom_name:
|
|
hook = self._internal.get(custom_name)
|
|
if not hook:
|
|
hook = self._internal.get(event)
|
|
if not hook:
|
|
hook = self.hooks.get(event)
|
|
if custom_name and not hook:
|
|
hook = self.hooks.get(custom_name)
|
|
if not hook:
|
|
return
|
|
|
|
self.log.info('Hook firing: %s %s' % (event, json.dumps(context)))
|
|
|
|
# Add standard context
|
|
state = self.ctrl.state
|
|
context.update({
|
|
'event': event,
|
|
'position': (state.get_position()
|
|
if hasattr(state, 'get_position') else {}),
|
|
'state': state.get('xx', ''),
|
|
'cycle': state.get('cycle', 'idle'),
|
|
})
|
|
|
|
block_unpause = hook.get('block_unpause', event == 'tool-change')
|
|
auto_resume = hook.get('auto_resume', False)
|
|
|
|
if block_unpause:
|
|
# Run in thread, block unpause until done
|
|
self._hook_busy = True
|
|
self._hook_busy_event = event
|
|
self._hook_error = None
|
|
|
|
# Update UI state so frontend knows we're busy
|
|
self.ctrl.state.set('hook_busy', True)
|
|
self.ctrl.state.set('hook_event', event)
|
|
|
|
self._hook_thread = threading.Thread(
|
|
target=self._run_hook_blocking,
|
|
args=(hook, event, context, auto_resume),
|
|
daemon=True
|
|
)
|
|
self._hook_thread.start()
|
|
else:
|
|
# Fire and forget (non-blocking)
|
|
self._execute_hook(hook, context)
|
|
|
|
def _run_hook_blocking(self, hook, event, context, auto_resume):
|
|
"""Runs in a background thread. Blocks unpause until complete."""
|
|
try:
|
|
self._execute_hook(hook, context)
|
|
self.log.info('Hook "%s" completed successfully' % event)
|
|
except Exception as e:
|
|
self._hook_error = str(e)
|
|
self.log.error('Hook "%s" failed: %s' % (event, e))
|
|
finally:
|
|
self._hook_busy = False
|
|
self._hook_busy_event = None
|
|
|
|
# Schedule UI update on the ioloop thread
|
|
self.ctrl.ioloop.call_later(0, self._hook_finished, auto_resume)
|
|
|
|
def _hook_finished(self, auto_resume):
|
|
"""Called on the ioloop after a blocking hook completes."""
|
|
self.ctrl.state.set('hook_busy', False)
|
|
self.ctrl.state.set('hook_event', '')
|
|
|
|
if self._hook_error:
|
|
self.ctrl.state.set('hook_error', self._hook_error)
|
|
self.log.error('Hook error: %s' % self._hook_error)
|
|
else:
|
|
self.ctrl.state.set('hook_error', '')
|
|
|
|
if auto_resume and not self._hook_error:
|
|
self.log.info('Hook done, auto-resuming')
|
|
try:
|
|
self.ctrl.mach.unpause()
|
|
except Exception as e:
|
|
self.log.error('Auto-resume failed: %s' % e)
|
|
|
|
def _execute_hook(self, hook, context):
|
|
"""Execute a single hook (webhook, script, or internal). May block."""
|
|
hook_type = hook.get('type', 'webhook')
|
|
|
|
if hook_type == 'webhook':
|
|
self._fire_webhook(hook, context)
|
|
elif hook_type == 'script':
|
|
self._fire_script(hook, context)
|
|
elif hook_type == 'internal':
|
|
fn = hook.get('fn')
|
|
if fn is None:
|
|
raise Exception('Internal hook missing fn')
|
|
fn(context)
|
|
else:
|
|
raise Exception('Unknown hook type: %s' % hook_type)
|
|
|
|
def _fire_webhook(self, hook, context):
|
|
"""Fire a webhook HTTP request."""
|
|
url = hook.get('url')
|
|
if not url:
|
|
raise Exception('Webhook missing url')
|
|
|
|
method = hook.get('method', 'POST').upper()
|
|
timeout = hook.get('timeout', 30)
|
|
headers = dict(hook.get('headers', {}))
|
|
body = dict(hook.get('body', {}))
|
|
|
|
# Merge context into body
|
|
body['_context'] = context
|
|
|
|
data = json.dumps(body).encode('utf-8')
|
|
headers['Content-Type'] = 'application/json'
|
|
|
|
req = Request(url, data=data, headers=headers, method=method)
|
|
self.log.info('Webhook %s %s' % (method, url))
|
|
|
|
resp = urlopen(req, timeout=timeout)
|
|
self.log.info('Webhook response: %d' % resp.status)
|
|
|
|
if resp.status >= 400:
|
|
raise Exception('Webhook returned %d' % resp.status)
|
|
|
|
def _fire_script(self, hook, context):
|
|
"""Fire a local script/command. Blocks until complete."""
|
|
command = hook.get('command')
|
|
if not command:
|
|
raise Exception('Script hook missing command')
|
|
|
|
timeout = hook.get('timeout', 120)
|
|
|
|
# Pass context as environment variables
|
|
env = os.environ.copy()
|
|
env['HOOK_EVENT'] = context.get('event', '')
|
|
env['HOOK_STATE'] = context.get('state', '')
|
|
env['HOOK_CYCLE'] = context.get('cycle', '')
|
|
env['HOOK_DATA'] = json.dumps(context)
|
|
|
|
if 'old_tool' in context:
|
|
env['HOOK_OLD_TOOL'] = str(context['old_tool'])
|
|
if 'new_tool' in context:
|
|
env['HOOK_NEW_TOOL'] = str(context['new_tool'])
|
|
|
|
self.log.info('Script: %s' % command)
|
|
|
|
result = subprocess.run(
|
|
command, shell=True, env=env,
|
|
timeout=timeout,
|
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE
|
|
)
|
|
|
|
stdout = result.stdout.decode('utf-8', errors='replace').strip()
|
|
stderr = result.stderr.decode('utf-8', errors='replace').strip()
|
|
|
|
if stdout:
|
|
self.log.info('Script stdout: %s' % stdout)
|
|
|
|
if result.returncode != 0:
|
|
raise Exception('Script failed (%d): %s' %
|
|
(result.returncode, stderr or 'non-zero exit'))
|