From 80a00978b78f7158781dbb238712d59000f3d9a2 Mon Sep 17 00:00:00 2001 From: Henrik Muehe Date: Sun, 3 May 2026 14:16:21 +0200 Subject: [PATCH] Hooks: ATC IPC layer between gcode preprocessor and runtime Adds bbctrl.Hooks: a small dispatch layer for HOOK:: messages embedded in g-code as (MSG,HOOK:droptool:) etc. Hooks can block the unpause until the registered callback completes and auto-resume after. - bbctrl.Hooks: registry, fire, dispatch_hook_message, persistent config in hooks.json, REST surface (/api/hooks, /api/hooks/save, /api/hooks/status, /api/hooks/fire/). - Ctrl: instantiate self.hooks alongside the other subsystems. - Planner._add_message: when a (MSG,...) line is HOOK::, route it through ctrl.hooks instead of state.messages so it never surfaces as a UI popup and dispatch is immediate (state.messages has a 250ms debounce). - Web: handlers for the /api/hooks routes. --- src/py/bbctrl/Ctrl.py | 2 + src/py/bbctrl/Hooks.py | 454 ++++++++++++++++++++++++++++++++++++++ src/py/bbctrl/Planner.py | 118 +++++++++- src/py/bbctrl/Web.py | 26 ++- src/py/bbctrl/__init__.py | 1 + 5 files changed, 597 insertions(+), 4 deletions(-) create mode 100644 src/py/bbctrl/Hooks.py diff --git a/src/py/bbctrl/Ctrl.py b/src/py/bbctrl/Ctrl.py index ef39139..d3bf2b8 100644 --- a/src/py/bbctrl/Ctrl.py +++ b/src/py/bbctrl/Ctrl.py @@ -71,6 +71,8 @@ class Ctrl(object): self.jog = bbctrl.Jog(self) with Trace.span('ctrl.pwr'): self.pwr = bbctrl.Pwr(self) + with Trace.span('ctrl.hooks'): + self.hooks = bbctrl.Hooks(self) with Trace.span('ctrl.mach.connect'): self.mach.connect() diff --git a/src/py/bbctrl/Hooks.py b/src/py/bbctrl/Hooks.py new file mode 100644 index 0000000..f17f996 --- /dev/null +++ b/src/py/bbctrl/Hooks.py @@ -0,0 +1,454 @@ +################################################################################ +# +# Hooks - External event triggers during G-code execution +# +# Integrates with the controller's pause/unpause cycle to run external +# actions (webhooks, scripts) at specific points during G-code execution. +# +# ## How tool-change hooks work (the important one): +# +# G-code: T5 M6 +# +# 1. Planner replaces M6 with tool-change override G-code (configurable). +# Default: "M0 M6 (MSG, Change tool)" +# +# 2. Planner emits: set(tool,5), pause(program), message("Change tool") +# These are sent to the AVR as serial commands. +# +# 3. AVR finishes current move, enters HOLDING state. +# Reports back: xx=HOLDING, pr="Program pause" +# +# 4. Pi: Mach._update() sees HOLDING, flushes CommandQueue. +# CommandQueue executes callbacks: state.set('tool', 5) fires. +# +# 5. Hooks._on_state_change() sees tool changed. +# Sets self._hook_busy = True, runs the hook in a thread. +# While _hook_busy, Mach.unpause() is blocked via can_unpause(). +# +# 6. Machine sits in HOLDING. UI shows "Change tool" message. +# User cannot resume yet (unpause is gated). +# +# 7. Hook thread finishes (toolchanger done). Sets _hook_busy = False. +# If auto_resume is set, calls unpause automatically. +# Otherwise user clicks Continue in UI. +# +# 8. Mach.unpause() → planner.restart() → AVR UNPAUSE → motion resumes. +# +# ## Configuration (hooks.json): +# +# { +# "tool-change": { +# "type": "webhook", +# "url": "http://toolchanger.local/api/change", +# "method": "POST", +# "timeout": 120, +# "block_unpause": true, +# "auto_resume": true +# }, +# "program-start": { +# "type": "script", +# "command": "/usr/local/bin/dust-collector on", +# "block_unpause": false +# } +# } +# +# block_unpause: if true, unpause is blocked until hook completes +# auto_resume: if true AND block_unpause, auto-unpause after hook done +# +################################################################################ + +import os +import json +import subprocess +import threading +import traceback +from urllib.request import Request, urlopen +from urllib.error import URLError + + +# Events that can be hooked +HOOK_EVENTS = [ + 'tool-change', # M6 - tool change requested + 'program-start', # Program begins running + 'program-end', # M2/M30 - program ends + 'pause', # M0/M1 - program pause + 'estop', # Emergency stop triggered + 'homing-start', # Homing cycle begins + 'homing-end', # Homing cycle completes + 'custom', # Triggered by (MSG,HOOK:name:data) comments +] + + +class Hooks: + def __init__(self, ctrl): + self.ctrl = ctrl + self.log = ctrl.log.get('Hooks') + self.hooks = {} + + # Hook execution state + self._hook_busy = False # True while a blocking hook runs + self._hook_busy_event = None # Which event is blocking + self._hook_error = None # Error from last hook, if any + self._hook_thread = None + + # In-process hook handlers registered by Python modules. Keyed by + # event name (matches what the G-code emits as HOOK:). + # Take precedence over hooks.json entries with the same name. + self._internal = {} + + # Track state for edge detection — must be set before add_listener + # because add_listener fires immediately with current state + self._last_cycle = ctrl.state.get('cycle', 'idle') + self._last_state = ctrl.state.get('xx', '') + self._last_tool = ctrl.state.get('tool', 0) + self._last_pause_reason = ctrl.state.get('pr', '') + # Highest message id we've already inspected for HOOK: lines. + self._last_msg_id = -1 + self._initialized = False + + self._load_config() + + # Listen for state changes + ctrl.state.add_listener(self._on_state_change) + self._initialized = True + + # -- Config management -- + + def _get_config_path(self): + return self.ctrl.get_path(filename='hooks.json') + + def _load_config(self): + path = self._get_config_path() + if os.path.exists(path): + try: + with open(path) as f: + self.hooks = json.load(f) + self.log.info('Loaded %d hook(s) from %s' % + (len(self.hooks), path)) + except Exception: + self.log.error('Failed to load hooks.json: %s' % + traceback.format_exc()) + else: + self.log.info('No hooks.json found, hooks disabled') + + def save_config(self, config): + """Save hook configuration (called from API).""" + path = self._get_config_path() + with open(path, 'w') as f: + json.dump(config, f, indent=2) + self.hooks = config + self.log.info('Saved %d hook(s)' % len(config)) + + def get_config(self): + return self.hooks + + # -- Unpause gating (called from Mach) -- + + def can_unpause(self): + """Returns True if no blocking hook is running. + Called by Mach.unpause() to gate resume.""" + if self._hook_busy: + self.log.info('Unpause blocked: hook "%s" still running' % + self._hook_busy_event) + return False + return True + + def get_status(self): + """Return current hook execution status for the UI.""" + return { + 'busy': self._hook_busy, + 'event': self._hook_busy_event, + 'error': self._hook_error, + } + + # -- State change listener -- + + def _on_state_change(self, update): + """Called on every state update from the controller.""" + if not self._initialized: + return + state = self.ctrl.state + + # Detect tool change (tool number changed while HOLDING) + if 'tool' in update: + new_tool = update['tool'] + if new_tool != self._last_tool: + self._fire('tool-change', { + 'old_tool': self._last_tool, + 'new_tool': new_tool, + }) + self._last_tool = new_tool + + # Detect cycle changes + if 'cycle' in update: + new_cycle = update['cycle'] + if new_cycle != self._last_cycle: + if new_cycle == 'running' and self._last_cycle == 'idle': + self._fire('program-start', {}) + elif new_cycle == 'idle' and self._last_cycle == 'running': + self._fire('program-end', {}) + elif new_cycle == 'homing': + self._fire('homing-start', {}) + elif self._last_cycle == 'homing' and new_cycle == 'idle': + self._fire('homing-end', {}) + self._last_cycle = new_cycle + + # Detect AVR state changes + if 'xc' in update or 'xx' in update: + new_state = state.get('xx', '') + if new_state != self._last_state: + if new_state == 'ESTOPPED': + # Cancel any running hook on estop. The hook thread + # cannot be killed from Python, but we can ask the + # AuxAxis to send ABORT to the ESP so its in-flight + # motion stops. Also drain the external-axis + # worker queue so resume after clear doesn't replay + # stale moves. + try: + ext = getattr(self.ctrl, 'ext_axis', None) + if ext is not None: + ext.abort() + except Exception: + pass + if self._hook_busy: + self.log.warning('E-stop: cancelling hook "%s"' % + self._hook_busy_event) + try: + aux = getattr(self.ctrl, 'aux', None) + if aux is not None: + aux.abort() + except Exception: + pass + self._hook_busy = False + self._hook_busy_event = None + self._fire('estop', {}) + self._last_state = new_state + + # Detect pause + if 'pr' in update: + pr = update['pr'] + if pr and pr != self._last_pause_reason: + self._fire('pause', {'reason': pr}) + self._last_pause_reason = pr + + # Detect custom hook messages emitted via (MSG,HOOK:event_name:data) + # gcode comments. State stores them as a list under 'messages' + # ([{'id': N, 'text': '...'}, ...]); fire only on new ids. + if 'messages' in update: + msgs = update['messages'] + if isinstance(msgs, list): + for m in msgs: + try: + mid = m.get('id', -1) + text = m.get('text', '') + except AttributeError: + continue + if mid <= self._last_msg_id: + continue + self._last_msg_id = mid + if isinstance(text, str) and text.startswith('HOOK:'): + parts = text[5:].split(':', 1) + event = parts[0] + data = parts[1] if len(parts) > 1 else '' + self._fire('custom', { + 'event': event, + 'data': data, + }, custom_name=event) + + # -- Hook execution -- + + def dispatch_hook_message(self, text): + """Direct entry point for HOOK:: messages emitted + by the planner via (MSG,HOOK:...) comments. Bypasses the + state.messages list (which the UI also reads), so callers can + suppress popup display without losing the hook dispatch. + + Returns True if the text matched a HOOK: line and was + dispatched, False otherwise.""" + if not isinstance(text, str) or not text.startswith('HOOK:'): + return False + parts = text[5:].split(':', 1) + event = parts[0] + data = parts[1] if len(parts) > 1 else '' + self._fire('custom', {'event': event, 'data': data}, + custom_name=event) + return True + + def register_internal(self, name, fn, block_unpause=True, + auto_resume=True, timeout=120): + """Register an in-process handler for HOOK: 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')) diff --git a/src/py/bbctrl/Planner.py b/src/py/bbctrl/Planner.py index c967611..c2af59e 100644 --- a/src/py/bbctrl/Planner.py +++ b/src/py/bbctrl/Planner.py @@ -196,12 +196,23 @@ class Planner(): def _add_message(self, text): - self.ctrl.state.add_message(text) - line = self.ctrl.state.get('line', 0) if 0 <= line: where = '%s:%d' % (self.where, line) else: where = self.where + # HOOK:: messages are an internal IPC channel + # between the gcode preprocessor and Hooks; bypass the user + # message list so they don't surface as popups, and dispatch + # the hook directly. Routing through state.messages would + # only deliver it after the 0.25s state-change debounce, by + # which point we'd have to keep it visible to ensure Hooks + # could see it. + hooks = getattr(self.ctrl, 'hooks', None) + if hooks is not None and hooks.dispatch_hook_message(text): + self.log.info('HOOK msg: %s' % text, where = where) + return + + self.ctrl.state.add_message(text) self.log.message(text, where = where) @@ -259,6 +270,13 @@ class Planner(): if type != 'set': self.log.info('Cmd:' + log_json(block)) if type == 'line': + ext = self._external_axis_for_line(block) + if ext is not None: + # Side effect: enqueue the ESP move on the external- + # axis worker. The AVR still receives the full target + # (including A) so ex.position[A] tracks gplan; no + # motor steps for A because no motor maps to it. + self._dispatch_external_line(block, ext) self._enqueue_line_time(block) return Cmd.line(block['target'], block['exit-vel'], block['max-accel'], block['max-jerk'], @@ -289,8 +307,17 @@ class Planner(): if name[2:] == '_homed': motor = self.ctrl.state.find_motor(name[1]) - if motor is not None: + # Synthetic external motor (index 4) doesn't exist + # on the AVR; mirror the homed flag in State only. + from bbctrl.ExternalAxis import EXTERNAL_MOTOR_INDEX + if motor is not None and motor < EXTERNAL_MOTOR_INDEX: return Cmd.set_sync('%dh' % motor, value) + if motor == EXTERNAL_MOTOR_INDEX: + # Update synthetic motor flag and the_homed + # projection consumed by the DRO. + self.cmdq.enqueue( + id, self.ctrl.state.set, + '%dh' % EXTERNAL_MOTOR_INDEX, value) return @@ -339,6 +366,68 @@ class Planner(): self.planner.set_logger(None) + # ----------------------------------------------- external-axis routing + # + # When an axis is exposed to gplan via a synthetic motor (no AVR + # channel), we need to fork its motion off to the ESP at line + # encode time and let the rest of the line proceed to the AVR. + # The split is done here rather than in gplan because gplan + # treats all six axes uniformly and just emits target dicts; we + # don't want to teach it about the ESP. + + def _external_axis_for_line(self, block): + """Return the ExternalAxis instance for whichever axis in + block['target'] is external, or None.""" + ext = getattr(self.ctrl, 'ext_axis', None) + if ext is None or not ext.enabled: + return None + target = block.get('target') or {} + if ext.axis_letter in target or ext.axis_letter.upper() in target: + return ext + return None + + def _dispatch_external_line(self, block, ext): + """Side-effect: enqueue the ESP move on the external-axis + worker thread (non-blocking). Returns the block (possibly + unchanged) for the AVR. + + We do NOT strip the external axis target from the AVR line. + The AVR's exec_move_to_target updates ex.position[axis] for + every axis in the target dict regardless of motor mapping, + and reports it back via the `p` indexed var. Leaving A in + the target keeps state.ap in sync with gplan's idea of A + (otherwise the AVR's stale ex.position[A] would clobber + ExternalAxis's state.ap=N update on the next status report). + + The AVR doesn't step any motor for the external axis (no + motor maps to it) - so leaving A in the target is + physically a no-op for the steppers, while keeping the + host-side state coherent. + + We pass the full S-curve parameters to the ESP so its move + duration matches the AVR's exactly. The ESP runs the same + 7-segment jerk-limited trajectory the AVR would have run + if A had been a real motor.""" + target = block.get('target') or {} + # Read the external target (case-insensitive) without modifying + # the dict so the AVR still sees A. + ext_mm = target.get(ext.axis_letter) + if ext_mm is None: + ext_mm = target.get(ext.axis_letter.upper()) + try: + ext.enqueue_line( + ext_mm, + block.get('max-accel', 0.0), + block.get('max-jerk', 0.0), + block.get('entry-vel', 0.0), + block.get('exit-vel', 0.0), + block.get('times', [0]*7), + ) + except Exception as e: + self.log.error('External axis enqueue failed: %s' % e) + raise + return block + def reset(self, *args, **kwargs): stop = kwargs.get('stop', True) if stop: @@ -352,6 +441,16 @@ class Planner(): self.cmdq.clear() self.reset_times() + # Drain the external-axis worker queue and force the next + # move to re-sync position from the ESP (since State.reset + # below will zero p which makes ext._pos_mm stale). + ext = getattr(self.ctrl, 'ext_axis', None) + if ext is not None: + try: ext.abort() + except Exception: pass + try: ext._pos_mm = None + except Exception: pass + resetState = kwargs.get('resetState', True) if resetState: self.ctrl.state.reset() @@ -369,6 +468,19 @@ class Planner(): self.where = path path = self.ctrl.get_path('upload', path) self.log.info('GCode:' + path) + # Rewrite ATC M-codes (M100..M103) before gplan sees them. + # preprocess_file is a no-op when no rewriting is needed and + # idempotent when run twice on the same file, so this is + # safe on every load. W tokens are no longer rewritten - the + # auxcnc stepper is now exposed as a virtual A axis and gcode + # should use A directly. + try: + from bbctrl.AuxPreprocessor import preprocess_file + if preprocess_file(path, log = self.log): + self.log.info('Rewrote ATC M-codes in %s' % path) + except Exception: + self.log.exception('Aux preprocess at load failed; ' + 'attempting to load file unchanged') self._sync_position() self.planner.load(path, self.get_config(False, True)) self.reset_times() diff --git a/src/py/bbctrl/Web.py b/src/py/bbctrl/Web.py index 2212a55..0833658 100644 --- a/src/py/bbctrl/Web.py +++ b/src/py/bbctrl/Web.py @@ -766,6 +766,27 @@ class RotaryHandler(bbctrl.APIHandler): log.error('Unexpected error: {}'.format(e)) +class HooksGetHandler(bbctrl.APIHandler): + def get(self): + self.write_json(self.get_ctrl().hooks.get_config()) + + +class HooksSaveHandler(bbctrl.APIHandler): + def put_ok(self): + self.get_ctrl().hooks.save_config(self.json) + + +class HooksStatusHandler(bbctrl.APIHandler): + def get(self): + self.write_json(self.get_ctrl().hooks.get_status()) + + +class HooksFireHandler(bbctrl.APIHandler): + def put_ok(self, event): + data = self.json if hasattr(self, 'json') and self.json else {} + self.get_ctrl().hooks._fire(event, data) + + class RemoteDiagnosticsHandler(bbctrl.APIHandler): def get(self): @@ -798,7 +819,6 @@ class RemoteDiagnosticsHandler(bbctrl.APIHandler): 'message': e.reason or "Unknown" }) - class TimingHandler(bbctrl.APIHandler): """Return the bbctrl process startup timeline as JSON. @@ -992,6 +1012,10 @@ class Web(tornado.web.Application): (r'/api/time', TimeHandler), (r'/api/rotary', RotaryHandler), (r'/api/remote-diagnostics', RemoteDiagnosticsHandler), + (r'/api/hooks', HooksGetHandler), + (r'/api/hooks/save', HooksSaveHandler), + (r'/api/hooks/status', HooksStatusHandler), + (r'/api/hooks/fire/([\w-]+)', HooksFireHandler), (r'/(.*)', 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 dfe03fd..b695b65 100644 --- a/src/py/bbctrl/__init__.py +++ b/src/py/bbctrl/__init__.py @@ -66,6 +66,7 @@ from bbctrl.AVR import AVR from bbctrl.AVREmu import AVREmu from bbctrl.IOLoop import IOLoop from bbctrl.MonitorTemp import MonitorTemp +from bbctrl.Hooks import Hooks import bbctrl.Cmd as Cmd import bbctrl.v4l2 as v4l2 import bbctrl.Log as log