diff --git a/.devcontainer/install_tools.sh b/.devcontainer/install_tools.sh old mode 100644 new mode 100755 diff --git a/.gitignore b/.gitignore index 95987ce..92d0408 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,13 @@ __pycache__ *.elf *.hex .idea/deployment.xml + +# Demo mode artifacts +bbctrl.log* +hooks.json +/*/bbctrl.log* +src/py/camotics/__init__.py +src/py/camotics/gplan.so +src/avr/emu/bbemu +src/avr/emu/build/ + diff --git a/src/py/bbctrl/Camera.py b/src/py/bbctrl/Camera.py index da42be1..87059eb 100644 --- a/src/py/bbctrl/Camera.py +++ b/src/py/bbctrl/Camera.py @@ -468,8 +468,7 @@ class VideoHandler(web.RequestHandler): self.camera = app.camera - @web.asynchronous - def get(self): + async def get(self): self.request.connection.stream.max_write_buffer_size = 10000 self.set_header('Cache-Control', 'no-store, no-cache, must-revalidate, ' diff --git a/src/py/bbctrl/Ctrl.py b/src/py/bbctrl/Ctrl.py index 9a46323..16bd39d 100644 --- a/src/py/bbctrl/Ctrl.py +++ b/src/py/bbctrl/Ctrl.py @@ -59,6 +59,7 @@ class Ctrl(object): self.preplanner = bbctrl.Preplanner(self) if not args.demo: self.jog = bbctrl.Jog(self) self.pwr = bbctrl.Pwr(self) + self.hooks = bbctrl.Hooks(self) self.mach.connect() diff --git a/src/py/bbctrl/Hooks.py b/src/py/bbctrl/Hooks.py new file mode 100644 index 0000000..db2573b --- /dev/null +++ b/src/py/bbctrl/Hooks.py @@ -0,0 +1,268 @@ +################################################################################ +# +# Hooks - External event triggers during G-code execution +# +# Watches planner state changes and fires webhooks / runs scripts when +# specific events occur (tool change, program start/end, pause, etc.) +# +# Configuration is loaded from hooks.json in the controller directory: +# +# { +# "tool-change": { +# "type": "webhook", +# "url": "http://toolchanger.local/api/change", +# "method": "POST", +# "timeout": 30, +# "wait": true +# }, +# "program-start": { +# "type": "script", +# "command": "/usr/local/bin/dust-collector on" +# }, +# "program-end": { +# "type": "webhook", +# "url": "http://homeassistant.local:8123/api/services/switch/turn_off", +# "method": "POST", +# "headers": {"Authorization": "Bearer TOKEN"}, +# "body": {"entity_id": "switch.dust_collector"} +# }, +# "pause": { +# "type": "script", +# "command": "/usr/local/bin/notify 'CNC paused'" +# } +# } +# +################################################################################ + +import os +import json +import subprocess +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 + 'probe-start', # Probe cycle begins + '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 = {} + + # 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', '') + self._initialized = False + + self._load_config() + + # Listen for state changes + ctrl.state.add_listener(self._on_state_change) + self._initialized = True + + 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 + + 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) + 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 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': + 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: (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) + + def _fire(self, event, context, custom_name=None): + """Fire a hook event.""" + # Look up by event name, or by custom name for custom events + 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'), + }) + + hook_type = hook.get('type', 'webhook') + + try: + if hook_type == 'webhook': + self._fire_webhook(hook, context) + elif hook_type == 'script': + self._fire_script(hook, context) + else: + self.log.error('Unknown hook type: %s' % hook_type) + except Exception: + self.log.error('Hook %s failed: %s' % (event, traceback.format_exc())) + + def _fire_webhook(self, hook, context): + """Fire a webhook HTTP request.""" + url = hook.get('url') + if not url: + self.log.error('Webhook missing url') + return + + method = hook.get('method', 'POST').upper() + timeout = hook.get('timeout', 10) + headers = hook.get('headers', {}) + body = 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)) + + try: + resp = urlopen(req, timeout=timeout) + self.log.info('Webhook response: %d' % resp.status) + except URLError as e: + self.log.error('Webhook failed: %s' % e) + except Exception as e: + self.log.error('Webhook error: %s' % e) + + def _fire_script(self, hook, context): + """Fire a local script/command.""" + command = hook.get('command') + if not command: + self.log.error('Script hook missing command') + return + + timeout = hook.get('timeout', 30) + wait = hook.get('wait', True) + + # 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 (wait=%s)' % (command, wait)) + + try: + if wait: + result = subprocess.run( + command, shell=True, env=env, + timeout=timeout, + capture_output=True, text=True + ) + if result.returncode != 0: + self.log.error('Script failed (%d): %s' % + (result.returncode, result.stderr)) + else: + if result.stdout.strip(): + self.log.info('Script output: %s' % result.stdout.strip()) + else: + subprocess.Popen(command, shell=True, env=env) + except subprocess.TimeoutExpired: + self.log.error('Script timed out after %ds' % timeout) + except Exception as e: + self.log.error('Script error: %s' % e) diff --git a/src/py/bbctrl/Web.py b/src/py/bbctrl/Web.py index 1af8d54..42a9439 100644 --- a/src/py/bbctrl/Web.py +++ b/src/py/bbctrl/Web.py @@ -766,6 +766,22 @@ 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 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): @@ -941,6 +957,9 @@ 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/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 b1a6452..db3cd07 100644 --- a/src/py/bbctrl/__init__.py +++ b/src/py/bbctrl/__init__.py @@ -59,6 +59,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