Hooks: ATC IPC layer between gcode preprocessor and runtime
Adds bbctrl.Hooks: a small dispatch layer for HOOK:<event>:<data> 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/<event>). - Ctrl: instantiate self.hooks alongside the other subsystems. - Planner._add_message: when a (MSG,...) line is HOOK:<event>:<data>, 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.
This commit is contained in:
@@ -71,6 +71,8 @@ class Ctrl(object):
|
|||||||
self.jog = bbctrl.Jog(self)
|
self.jog = bbctrl.Jog(self)
|
||||||
with Trace.span('ctrl.pwr'):
|
with Trace.span('ctrl.pwr'):
|
||||||
self.pwr = bbctrl.Pwr(self)
|
self.pwr = bbctrl.Pwr(self)
|
||||||
|
with Trace.span('ctrl.hooks'):
|
||||||
|
self.hooks = bbctrl.Hooks(self)
|
||||||
|
|
||||||
with Trace.span('ctrl.mach.connect'):
|
with Trace.span('ctrl.mach.connect'):
|
||||||
self.mach.connect()
|
self.mach.connect()
|
||||||
|
|||||||
454
src/py/bbctrl/Hooks.py
Normal file
454
src/py/bbctrl/Hooks.py
Normal file
@@ -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:<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. 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:<event>:<data> 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:<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'))
|
||||||
@@ -196,12 +196,23 @@ class Planner():
|
|||||||
|
|
||||||
|
|
||||||
def _add_message(self, text):
|
def _add_message(self, text):
|
||||||
self.ctrl.state.add_message(text)
|
|
||||||
|
|
||||||
line = self.ctrl.state.get('line', 0)
|
line = self.ctrl.state.get('line', 0)
|
||||||
if 0 <= line: where = '%s:%d' % (self.where, line)
|
if 0 <= line: where = '%s:%d' % (self.where, line)
|
||||||
else: where = self.where
|
else: where = self.where
|
||||||
|
|
||||||
|
# HOOK:<event>:<data> 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)
|
self.log.message(text, where = where)
|
||||||
|
|
||||||
|
|
||||||
@@ -259,6 +270,13 @@ class Planner():
|
|||||||
if type != 'set': self.log.info('Cmd:' + log_json(block))
|
if type != 'set': self.log.info('Cmd:' + log_json(block))
|
||||||
|
|
||||||
if type == 'line':
|
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)
|
self._enqueue_line_time(block)
|
||||||
return Cmd.line(block['target'], block['exit-vel'],
|
return Cmd.line(block['target'], block['exit-vel'],
|
||||||
block['max-accel'], block['max-jerk'],
|
block['max-accel'], block['max-jerk'],
|
||||||
@@ -289,8 +307,17 @@ class Planner():
|
|||||||
|
|
||||||
if name[2:] == '_homed':
|
if name[2:] == '_homed':
|
||||||
motor = self.ctrl.state.find_motor(name[1])
|
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)
|
return Cmd.set_sync('%dh' % motor, value)
|
||||||
|
if motor == EXTERNAL_MOTOR_INDEX:
|
||||||
|
# Update synthetic motor flag and the<axis>_homed
|
||||||
|
# projection consumed by the DRO.
|
||||||
|
self.cmdq.enqueue(
|
||||||
|
id, self.ctrl.state.set,
|
||||||
|
'%dh' % EXTERNAL_MOTOR_INDEX, value)
|
||||||
|
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -339,6 +366,68 @@ class Planner():
|
|||||||
self.planner.set_logger(None)
|
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):
|
def reset(self, *args, **kwargs):
|
||||||
stop = kwargs.get('stop', True)
|
stop = kwargs.get('stop', True)
|
||||||
if stop:
|
if stop:
|
||||||
@@ -352,6 +441,16 @@ class Planner():
|
|||||||
self.cmdq.clear()
|
self.cmdq.clear()
|
||||||
self.reset_times()
|
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 <axis>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)
|
resetState = kwargs.get('resetState', True)
|
||||||
if resetState:
|
if resetState:
|
||||||
self.ctrl.state.reset()
|
self.ctrl.state.reset()
|
||||||
@@ -369,6 +468,19 @@ class Planner():
|
|||||||
self.where = path
|
self.where = path
|
||||||
path = self.ctrl.get_path('upload', path)
|
path = self.ctrl.get_path('upload', path)
|
||||||
self.log.info('GCode:' + 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._sync_position()
|
||||||
self.planner.load(path, self.get_config(False, True))
|
self.planner.load(path, self.get_config(False, True))
|
||||||
self.reset_times()
|
self.reset_times()
|
||||||
|
|||||||
@@ -766,6 +766,27 @@ class RotaryHandler(bbctrl.APIHandler):
|
|||||||
log.error('Unexpected error: {}'.format(e))
|
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):
|
class RemoteDiagnosticsHandler(bbctrl.APIHandler):
|
||||||
|
|
||||||
def get(self):
|
def get(self):
|
||||||
@@ -798,7 +819,6 @@ class RemoteDiagnosticsHandler(bbctrl.APIHandler):
|
|||||||
'message': e.reason or "Unknown"
|
'message': e.reason or "Unknown"
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
class TimingHandler(bbctrl.APIHandler):
|
class TimingHandler(bbctrl.APIHandler):
|
||||||
"""Return the bbctrl process startup timeline as JSON.
|
"""Return the bbctrl process startup timeline as JSON.
|
||||||
|
|
||||||
@@ -992,6 +1012,10 @@ class Web(tornado.web.Application):
|
|||||||
(r'/api/time', TimeHandler),
|
(r'/api/time', TimeHandler),
|
||||||
(r'/api/rotary', RotaryHandler),
|
(r'/api/rotary', RotaryHandler),
|
||||||
(r'/api/remote-diagnostics', RemoteDiagnosticsHandler),
|
(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,
|
(r'/(.*)', StaticFileHandler,
|
||||||
{'path': bbctrl.get_resource('http/'),
|
{'path': bbctrl.get_resource('http/'),
|
||||||
'default_filename': 'index.html'}),
|
'default_filename': 'index.html'}),
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ from bbctrl.AVR import AVR
|
|||||||
from bbctrl.AVREmu import AVREmu
|
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
|
||||||
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