################################################################################ # # 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:). # 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: 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'))