From 7f8fd236151e79e0eb193e28376a647b42dc99d8 Mon Sep 17 00:00:00 2001 From: Henrik Muehe Date: Mon, 20 Apr 2026 17:43:02 +0200 Subject: [PATCH 01/20] Add hooks system for external triggers during G-code execution - New Hooks module (src/py/bbctrl/Hooks.py) that watches controller state and fires webhooks or scripts on events: - tool-change (M6), program-start, program-end, pause, estop, homing-start, homing-end, custom (via MSG comments) - API endpoints: - GET /api/hooks - get current hook config - PUT /api/hooks/save - save hook config - PUT /api/hooks/fire/ - manually fire a hook (for testing) - Hook config stored in hooks.json with two types: - webhook: HTTP POST/PUT to external URL with JSON context - script: run local command with env vars (HOOK_OLD_TOOL, etc.) - Fix tornado.web.asynchronous deprecation in Camera.py - Wired into Ctrl initialization and state listener system --- .devcontainer/install_tools.sh | 0 .gitignore | 10 ++ src/py/bbctrl/Camera.py | 3 +- src/py/bbctrl/Ctrl.py | 1 + src/py/bbctrl/Hooks.py | 268 +++++++++++++++++++++++++++++++++ src/py/bbctrl/Web.py | 19 +++ src/py/bbctrl/__init__.py | 1 + 7 files changed, 300 insertions(+), 2 deletions(-) mode change 100644 => 100755 .devcontainer/install_tools.sh create mode 100644 src/py/bbctrl/Hooks.py 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 From 7d0755c55b7eeef2217fae77a217770595dae422 Mon Sep 17 00:00:00 2001 From: Henrik Muehe Date: Tue, 21 Apr 2026 08:10:07 +0200 Subject: [PATCH 02/20] Hooks v2: block unpause until hook completes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Blocking hooks (block_unpause: true, default for tool-change) run in a background thread and gate Mach.unpause() via can_unpause() - Machine stays in HOLDING state while hook runs — AVR steppers idle, spindle state preserved, position locked - auto_resume option to unpause automatically after hook completes - E-stop cancels any running hook immediately - Hook status pushed to frontend via state (hook_busy, hook_event) - GET /api/hooks/status endpoint for polling - Non-blocking hooks (program-start, program-end, etc.) fire-and-forget --- src/py/bbctrl/Hooks.py | 241 +++++++++++++++++++++++++++++------------ src/py/bbctrl/Mach.py | 4 + src/py/bbctrl/Web.py | 6 + 3 files changed, 182 insertions(+), 69 deletions(-) diff --git a/src/py/bbctrl/Hooks.py b/src/py/bbctrl/Hooks.py index db2573b..4911b68 100644 --- a/src/py/bbctrl/Hooks.py +++ b/src/py/bbctrl/Hooks.py @@ -2,41 +2,65 @@ # # 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.) +# Integrates with the controller's pause/unpause cycle to run external +# actions (webhooks, scripts) at specific points during G-code execution. # -# Configuration is loaded from hooks.json in the controller directory: +# ## 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": 30, -# "wait": true +# "timeout": 120, +# "block_unpause": true, +# "auto_resume": 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'" +# "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 @@ -48,7 +72,6 @@ HOOK_EVENTS = [ '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 @@ -62,6 +85,12 @@ class Hooks: 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 + # 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') @@ -76,6 +105,8 @@ class Hooks: 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') @@ -104,13 +135,34 @@ class Hooks: 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) + # Detect tool change (tool number changed while HOLDING) if 'tool' in update: new_tool = update['tool'] if new_tool != self._last_tool: @@ -134,11 +186,17 @@ class Hooks: self._fire('homing-end', {}) self._last_cycle = new_cycle - # Detect state changes + # 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 + if self._hook_busy: + self.log.warning('E-stop: cancelling hook "%s"' % + self._hook_busy_event) + self._hook_busy = False + self._hook_busy_event = None self._fire('estop', {}) self._last_state = new_state @@ -161,9 +219,10 @@ class Hooks: 'data': data, }, custom_name=event) + # -- Hook execution -- + 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) @@ -176,34 +235,89 @@ class Hooks: state = self.ctrl.state context.update({ 'event': event, - 'position': state.get_position() if hasattr(state, 'get_position') else {}, + '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 or script). May block.""" 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())) + if hook_type == 'webhook': + self._fire_webhook(hook, context) + elif hook_type == 'script': + self._fire_script(hook, 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: - self.log.error('Webhook missing url') - return + raise Exception('Webhook missing url') method = hook.get('method', 'POST').upper() - timeout = hook.get('timeout', 10) - headers = hook.get('headers', {}) - body = hook.get('body', {}) + timeout = hook.get('timeout', 30) + headers = dict(hook.get('headers', {})) + body = dict(hook.get('body', {})) # Merge context into body body['_context'] = context @@ -212,26 +326,21 @@ class Hooks: 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) + 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.""" + """Fire a local script/command. Blocks until complete.""" command = hook.get('command') if not command: - self.log.error('Script hook missing command') - return + raise Exception('Script hook missing command') - timeout = hook.get('timeout', 30) - wait = hook.get('wait', True) + timeout = hook.get('timeout', 120) # Pass context as environment variables env = os.environ.copy() @@ -245,24 +354,18 @@ class Hooks: if 'new_tool' in context: env['HOOK_NEW_TOOL'] = str(context['new_tool']) - self.log.info('Script: %s (wait=%s)' % (command, wait)) + self.log.info('Script: %s' % command) - 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) + result = subprocess.run( + command, shell=True, env=env, + timeout=timeout, + capture_output=True, text=True + ) + + if result.stdout.strip(): + self.log.info('Script stdout: %s' % result.stdout.strip()) + + if result.returncode != 0: + raise Exception('Script failed (%d): %s' % + (result.returncode, + result.stderr.strip() or 'non-zero exit')) diff --git a/src/py/bbctrl/Mach.py b/src/py/bbctrl/Mach.py index 24e122c..f8a01a3 100644 --- a/src/py/bbctrl/Mach.py +++ b/src/py/bbctrl/Mach.py @@ -349,6 +349,10 @@ class Mach(Comm): def unpause(self): if self._is_paused(): + # Gate unpause on hook completion + if hasattr(self.ctrl, 'hooks') and \ + not self.ctrl.hooks.can_unpause(): + return self.ctrl.state.set('optional_pause', False) self._unpause() diff --git a/src/py/bbctrl/Web.py b/src/py/bbctrl/Web.py index 42a9439..d7a7e7b 100644 --- a/src/py/bbctrl/Web.py +++ b/src/py/bbctrl/Web.py @@ -776,6 +776,11 @@ class HooksSaveHandler(bbctrl.APIHandler): 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 {} @@ -959,6 +964,7 @@ class Web(tornado.web.Application): (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/'), From 5be7515a92f19513213f93ad4417d3027ac4358b Mon Sep 17 00:00:00 2001 From: Henrik Muehe Date: Thu, 30 Apr 2026 11:36:05 +0200 Subject: [PATCH 03/20] Fix gplan.so: use armv7 binary from official 1.6.6 release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gplan.so (CAMotics G-code planner) must be a 32-bit ARM binary matching the Pi's Python 3.5. Source it from the official release package rather than cross-compiling (SCons ignores CC/CXX overrides). Also revert install.sh gplan.so preservation logic — simpler to just ship the correct binary in the package. --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 4e061fb..0a91749 100755 --- a/setup.py +++ b/setup.py @@ -17,7 +17,7 @@ setup( license = pkg['license'], url = pkg['homepage'], package_dir = {'': 'src/py'}, - packages = ['bbctrl', 'inevent', 'lcd', 'camotics','iw_parse'], + packages = ['bbctrl', 'inevent', 'lcd', 'camotics', 'iw_parse'], include_package_data = True, entry_points = { 'console_scripts': [ From 1625b768d805f07604dcd800b000f4d95e0c3276 Mon Sep 17 00:00:00 2001 From: Henrik Muehe Date: Thu, 30 Apr 2026 12:09:12 +0200 Subject: [PATCH 04/20] Add build/flash/backup documentation for Pi firmware --- .pi/BUILD.md | 172 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 .pi/BUILD.md diff --git a/.pi/BUILD.md b/.pi/BUILD.md new file mode 100644 index 0000000..1b16032 --- /dev/null +++ b/.pi/BUILD.md @@ -0,0 +1,172 @@ +# Onefinity CNC Firmware — Build, Flash & Backup + +## Architecture Overview + +The Onefinity controller is a **Raspberry Pi 2/3** (armv7l, Raspbian Stretch, Python 3.5) connected to an **ATxmega192a3u** AVR microcontroller over serial. The Pi runs a Tornado web server (`bbctrl`) that serves the UI and plans G-code motion. The AVR executes realtime step/direction pulses. + +``` +Browser ←WebSocket→ Pi (Tornado/Python) → GCode Planner → Serial → AVR → Stepper drivers +``` + +The firmware package (`bbctrl-X.Y.Z.tar.bz2`) contains: +- **Python backend** (`src/py/bbctrl/`) — Tornado web server, planner, state machine +- **Web frontend** (`build/http/`) — Pug/Stylus/Svelte compiled to static HTML/JS/CSS +- **AVR firmware** (`src/avr/bbctrl-avr-firmware.hex`) — realtime motion controller +- **gplan.so** (`src/py/camotics/gplan.so`) — CAMotics G-code planner (native ARM .so) +- **Install scripts** (`scripts/install.sh`, etc.) + +## Prerequisites + +- Docker (for the devcontainer build environment) +- The devcontainer image: `docker build -t onefin-dev -f .devcontainer/Dockerfile .devcontainer/` +- SSH access to the Pi: `ssh bbmc@10.1.10.55` (password: `onefinity`) + +## Building + +### Full build (frontend + AVR + package) + +```bash +docker run --rm -v "$(pwd):/workspace" -w /workspace onefin-dev \ + bash -c 'make all && python3 ./setup.py sdist' +``` + +Produces: `dist/bbctrl-1.6.7.tar.bz2` (~3MB) + +### What `make all` builds + +| Component | Command | Output | +|---|---|---| +| Web frontend | `npm install`, pug/stylus/svelte compile | `build/http/` | +| AVR firmware | `make -C src/avr` (avr-g++) | `src/avr/bbctrl-avr-firmware.hex` | +| Bootloader | `make -C src/boot` | `src/boot/bbctrl-avr-boot.hex` | +| Power MCU | `make -C src/pwr` | `src/pwr/bbctrl-pwr-firmware.hex` | +| Jig firmware | `make -C src/jig` | `src/jig/bbctrl-jig-firmware.hex` | + +### gplan.so — the critical gotcha + +`gplan.so` is the CAMotics G-code planner compiled as a Python C extension. It **must be a 32-bit ARM binary** linked against **Python 3.5** to run on the Pi. + +**Do NOT build gplan.so in the devcontainer.** The devcontainer runs arm64/Debian Bullseye with Python 3.9. The resulting `.so` will be the wrong architecture and wrong Python ABI. Cross-compiling also fails because SCons ignores CC/CXX overrides. + +**Where to get it:** +1. From the official release: `https://github.com/OneFinityCNC/onefinity-firmware/releases/download/v1.6.6/onefinity-1.6.6.tar.bz2` — extract `src/py/camotics/gplan.so` +2. From a working Pi: `scp bbmc@10.1.10.55:/usr/local/lib/python3.5/dist-packages/bbctrl-*.egg/camotics/gplan.so src/py/camotics/` +3. From a backup image + +The correct file is: +``` +ELF 32-bit LSB shared object, ARM, EABI5 version 1 (GNU/Linux), dynamically linked +``` + +If you see `ELF 64-bit LSB shared object, ARM aarch64` — that's the wrong one. + +### bbserial.ko — kernel module + +The `bbserial.ko` kernel module requires cross-compiling against the Pi's exact kernel headers (4.9.59-v7+). The `make pkg` target tries to build it but it's rarely needed — the Pi already has a matching `.ko` installed. The `install.sh` script skips it gracefully if the file is missing (`cp: cannot stat 'src/bbserial/bbserial.ko': No such file or directory`). + +### AVR emulator (for local demo mode) + +```bash +docker run --rm -v "$(pwd):/workspace" -w /workspace/src/avr/emu onefin-dev make +``` + +Produces `src/avr/emu/bbemu` — a native binary that emulates the AVR for demo mode. + +## Flashing + +### Via web API (recommended, machine must be running) + +```bash +curl -X PUT \ + -H "Content-Type: multipart/form-data" \ + -F "firmware=@dist/bbctrl-1.6.7.tar.bz2" \ + -F "password=onefinity" \ + http://10.1.10.55/api/firmware/update +``` + +Or: `make update HOST=10.1.10.55 PASSWORD=onefinity` + +### Via SSH (if web UI is down / crash-looping) + +```bash +scp dist/bbctrl-1.6.7.tar.bz2 bbmc@10.1.10.55:/tmp/ + +ssh bbmc@10.1.10.55 'echo onefinity | sudo -S bash -c " + systemctl stop bbctrl + mkdir -p /var/lib/bbctrl/firmware + cp /tmp/bbctrl-1.6.7.tar.bz2 /var/lib/bbctrl/firmware/update.tar.bz2 + /usr/local/bin/update-bbctrl +"' +``` + +### What happens during flash + +1. `update-bbctrl` stops bbctrl, extracts tarball to `/tmp/update/` +2. `install.sh` runs: + - Flashes AVR via `scripts/avr109-flash.py` (serial bootloader protocol) + - `setup.py install --force` — installs Python package + frontend + gplan.so + - Restarts `bbctrl` systemd service + - May reboot if boot config or kernel module changed + +### Recovery if flash breaks the Pi + +If bbctrl is crash-looping after a bad flash: +1. SSH still works: `ssh bbmc@10.1.10.55` +2. Check the error: `sudo python3 /usr/local/bin/bbctrl 2>&1 | head -20` +3. Common fix: replace gplan.so with correct ARM binary (see above) +4. Nuclear option: restore from SD card backup (see below) + +## Running locally (demo mode) + +Run the full stack in Docker with the AVR emulator: + +```bash +# Build everything first (make all + bbemu + gplan.so for arm64 devcontainer) +# Then: +docker run --rm -d --name onefin-demo \ + -v "$(pwd):/workspace" -w /workspace -p 8765:80 \ + onefin-dev bash -c ' + pip3 install -q tornado sockjs-tornado pyserial watchdog + cp src/avr/emu/bbemu /usr/local/bin/ + pip3 install -q -e . + exec bbctrl --demo --port 80 --addr 0.0.0.0 --disable-camera + ' +``` + +Note: demo mode needs its own gplan.so matching the container's arch (arm64 + Python 3.9). Build it with the gplan build procedure in the Makefile, or use the one already in `src/py/camotics/` if it matches. + +Open http://localhost:8765 — full UI with emulated AVR. + +## SD Card Backup & Restore + +```bash +# Backup (streams raw dd from Pi, compresses locally with gzip, ~50 min) +./backup/onefinity-backup.sh backup + +# Verify +./backup/onefinity-backup.sh verify backup/onefinity-20260430.img.gz + +# Restore to local SD card +./backup/onefinity-backup.sh restore backup/onefinity-20260430.img.gz /dev/diskN +``` + +See `backup/onefinity-backup.sh` for details. Environment variables: `ONEFINITY_HOST` (default 10.1.10.55), `ONEFINITY_USER` (bbmc), `ONEFINITY_PASS` (onefinity). + +## Pi Details + +| | | +|---|---| +| Host | `10.1.10.55` | +| SSH user | `bbmc` | +| sudo password | `onefinity` | +| OS | Raspbian Stretch (Debian 9) | +| Kernel | 4.9.59-v7+ | +| Python | 3.5.3 | +| Arch | armv7l (32-bit ARM) | +| SD card | 30GB | +| Service | `systemctl {start,stop,restart,status} bbctrl` | +| Log | `/var/log/bbctrl.log` or `journalctl -u bbctrl` | +| Config | `/var/lib/bbctrl/config.json` | +| Uploads | `/var/lib/bbctrl/upload/` | +| Web root | `/usr/local/lib/python3.5/dist-packages/bbctrl-*.egg/bbctrl/http/` | +| AVR serial | `/dev/ttyAMA0` at 230400 baud | From 73064644403583e9bed7b28dfbeb2c2d1e5ae33f Mon Sep 17 00:00:00 2001 From: Henrik Muehe Date: Thu, 30 Apr 2026 13:52:58 +0200 Subject: [PATCH 05/20] Document gplan.so build-from-source procedure Build in armv7 QEMU Docker, compile with Python 3.9 SCons, relink final .so against Python 3.5m from the Pi. --- .pi/BUILD.md | 96 +++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 87 insertions(+), 9 deletions(-) diff --git a/.pi/BUILD.md b/.pi/BUILD.md index 1b16032..80b0b80 100644 --- a/.pi/BUILD.md +++ b/.pi/BUILD.md @@ -46,19 +46,97 @@ Produces: `dist/bbctrl-1.6.7.tar.bz2` (~3MB) `gplan.so` is the CAMotics G-code planner compiled as a Python C extension. It **must be a 32-bit ARM binary** linked against **Python 3.5** to run on the Pi. -**Do NOT build gplan.so in the devcontainer.** The devcontainer runs arm64/Debian Bullseye with Python 3.9. The resulting `.so` will be the wrong architecture and wrong Python ABI. Cross-compiling also fails because SCons ignores CC/CXX overrides. +**Do NOT build gplan.so in the devcontainer.** The devcontainer runs arm64/Debian Bullseye with Python 3.9. The resulting `.so` will be the wrong architecture and wrong Python ABI. Cross-compiling with `CXX=arm-linux-gnueabihf-g++` also fails because SCons ignores CC/CXX env vars. -**Where to get it:** -1. From the official release: `https://github.com/OneFinityCNC/onefinity-firmware/releases/download/v1.6.6/onefinity-1.6.6.tar.bz2` — extract `src/py/camotics/gplan.so` -2. From a working Pi: `scp bbmc@10.1.10.55:/usr/local/lib/python3.5/dist-packages/bbctrl-*.egg/camotics/gplan.so src/py/camotics/` -3. From a backup image - -The correct file is: +The correct file must be: ``` -ELF 32-bit LSB shared object, ARM, EABI5 version 1 (GNU/Linux), dynamically linked +ELF 32-bit LSB shared object, ARM, EABI5 (linked against libpython3.5m.so.1.0) +``` +If you see `ELF 64-bit LSB shared object, ARM aarch64` or `libpython3.9` — wrong. + +**Option A: Build from source (recommended)** + +Uses Docker's armv7 QEMU emulation on Apple Silicon. Requires a one-time +copy of Python 3.5 headers from the Pi (~1.7MB): + +```bash +# One-time: grab Python 3.5 headers + lib from Pi +ssh bbmc@10.1.10.55 'tar czf - /usr/include/python3.5m \ + /usr/lib/arm-linux-gnueabihf/libpython3.5m.so*' > /tmp/pi-python35.tar.gz ``` -If you see `ELF 64-bit LSB shared object, ARM aarch64` — that's the wrong one. +Then build (takes ~30min under QEMU): + +```bash +docker run --rm --platform linux/arm/v7 \ + -v "$(pwd):/workspace" -w /workspace \ + -v /tmp/pi-python35.tar.gz:/tmp/pi-python35.tar.gz \ + debian:bullseye bash -c ' + set -e + tar xzf /tmp/pi-python35.tar.gz -C / + apt-get update -qq && apt-get install -y -qq build-essential scons git \ + ca-certificates python3-dev libssl-dev libexpat1-dev libbz2-dev \ + liblz4-dev zlib1g-dev perl file + ln -sf /usr/lib/arm-linux-gnueabihf/libpython3.5m.so.1.0 \ + /usr/lib/arm-linux-gnueabihf/libpython3.5m.so + + # Clone cbang + camotics at pinned commits + mkdir -p /tmp/cbang && cd /tmp/cbang && git init -q + git remote add origin https://github.com/CauldronDevelopmentLLC/cbang + git fetch --depth 1 -q origin 18f1e963107ef26abe750c023355a5c40dd07853 + git reset --hard FETCH_HEAD -q + + mkdir -p /tmp/camotics && cd /tmp/camotics && git init -q + git remote add origin https://github.com/CauldronDevelopmentLLC/camotics + git fetch --depth 1 -q origin ec876c80d20fc19837133087cef0c447df5a939d + git reset --hard FETCH_HEAD -q + + # Build cbang + cd /tmp/cbang && scons -j2 disable_local="re2 libevent" + export CBANG_HOME="/tmp/cbang" + + # Patch camotics (clamp div-by-zero in planner) + P="/tmp/camotics/src/gcode/plan" + mkdir -p /tmp/camotics/build && touch /tmp/camotics/build/version.txt + for F in LineCommand.cpp LinePlanner.cpp; do + for V in maxVel maxJerk maxAccel; do + perl -i -0pe "s/(fabs\\((config\\.$V\\[axis\\]) \\/ unit\\[axis\\]\\));/std::min(\\2, \\1);/gm" $P/$F + done + done + + # Compile with Python 3.9 (SCons needs python3-dev to find headers) + cd /tmp/camotics && scons -j2 gplan.so with_gui=0 with_tpl=0 + + # Relink against Python 3.5m (the Pi target) + g++ -o /workspace/src/py/camotics/gplan.so \ + -Wl,--as-needed -Wl,-s -Wl,-x -Wl,--gc-sections -pthread -shared \ + build/gplan.os -L/tmp/cbang/lib \ + build/libCAMoticsPy.a build/libCAMotics.a build/libDXF.a \ + build/libSTL.a build/libGCode.a \ + -lstdc++ -lutil -lm -ldl -lz -lcbang -lcbang-boost \ + -lssl -lcrypto -llz4 -lexpat -lbz2 -lcrypt -lpthread \ + -lpython3.5m build/dxflib/libdxflib.a + ' +``` + +Why the relink step: SCons compiles `.o` files that are Python-version-agnostic +(they only use `#include ` which is ABI-compatible between 3.5-3.9 +for the subset camotics uses). The only version-specific part is the final +`-lpython3.X` link. So we let SCons build with 3.9 (since it ignores overrides) +then relink the same objects against 3.5m. + +**Option B: From official release** + +```bash +curl -L https://github.com/OneFinityCNC/onefinity-firmware/releases/download/v1.6.6/onefinity-1.6.6.tar.bz2 \ + | tar xjf - --include='*/gplan.so' --strip-components=3 -C src/py/camotics/ +``` + +**Option C: From a working Pi** + +```bash +scp bbmc@10.1.10.55:/usr/local/lib/python3.5/dist-packages/bbctrl-*.egg/camotics/gplan.so src/py/camotics/ +``` ### bbserial.ko — kernel module From e3c059eb9bec1ed8c7c0fd001092ace3d807f64f Mon Sep 17 00:00:00 2001 From: Henrik Muehe Date: Thu, 30 Apr 2026 14:43:05 +0200 Subject: [PATCH 06/20] Add cached gplan.so build: 30min first time, 3sec after - Dockerfile.gplan: pre-built armv7 image with cbang + camotics objects - build-gplan.sh: relinks against Python 3.5m in ~3sec - Pi Python 3.5 headers cached in .pi/pi-python35.tar.gz (gitignored) --- .gitignore | 1 + .pi/Dockerfile.gplan | 40 ++++++++++++++++++++++++++++++ .pi/build-gplan.sh | 58 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 99 insertions(+) create mode 100644 .pi/Dockerfile.gplan create mode 100755 .pi/build-gplan.sh diff --git a/.gitignore b/.gitignore index 92d0408..6c57627 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,4 @@ src/py/camotics/gplan.so src/avr/emu/bbemu src/avr/emu/build/ +.pi/pi-python35.tar.gz diff --git a/.pi/Dockerfile.gplan b/.pi/Dockerfile.gplan new file mode 100644 index 0000000..88c0e9c --- /dev/null +++ b/.pi/Dockerfile.gplan @@ -0,0 +1,40 @@ +# Pre-built armv7 environment for gplan.so +# Build once: docker build --platform linux/arm/v7 -t onefin-gplan -f .pi/Dockerfile.gplan .pi/ +# Then use: .pi/build-gplan.sh +FROM debian:bullseye + +RUN apt-get update -qq && \ + DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \ + build-essential scons git ca-certificates python3-dev \ + libssl-dev libexpat1-dev libbz2-dev liblz4-dev zlib1g-dev perl file && \ + rm -rf /var/lib/apt/lists/* + +# Clone and build cbang (static lib, ~5min under QEMU) +RUN mkdir -p /opt/cbang && cd /opt/cbang && git init -q && \ + git remote add origin https://github.com/CauldronDevelopmentLLC/cbang && \ + git fetch --depth 1 -q origin 18f1e963107ef26abe750c023355a5c40dd07853 && \ + git reset --hard FETCH_HEAD -q && \ + scons -j2 disable_local="re2 libevent" && \ + rm -rf .git build/dep + +# Clone and patch camotics (source only, don't compile yet — that depends on workspace) +RUN mkdir -p /opt/camotics && cd /opt/camotics && git init -q && \ + git remote add origin https://github.com/CauldronDevelopmentLLC/camotics && \ + git fetch --depth 1 -q origin ec876c80d20fc19837133087cef0c447df5a939d && \ + git reset --hard FETCH_HEAD -q && \ + mkdir -p build && touch build/version.txt && \ + P="src/gcode/plan" && \ + for F in LineCommand.cpp LinePlanner.cpp; do \ + for V in maxVel maxJerk maxAccel; do \ + perl -i -0pe "s/(fabs\((config\.$V\[axis\]) \/ unit\[axis\]\));/std::min(\2, \1);/gm" $P/$F; \ + done; \ + done && \ + rm -rf .git + +# Pre-compile camotics objects (the slow part, ~20min under QEMU) +ENV CBANG_HOME=/opt/cbang +RUN cd /opt/camotics && scons -j2 gplan.so with_gui=0 with_tpl=0 && \ + rm -f gplan.so # remove the python3.9-linked one, we relink at build time + +ENV CBANG_HOME=/opt/cbang +WORKDIR /opt/camotics diff --git a/.pi/build-gplan.sh b/.pi/build-gplan.sh new file mode 100755 index 0000000..4789eb3 --- /dev/null +++ b/.pi/build-gplan.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Build gplan.so for the Onefinity Pi (armv7l, Python 3.5) +# +# First run: ~30min (builds the Docker image with pre-compiled cbang/camotics) +# After that: ~2sec (just relinks against Python 3.5m) +# +# Prerequisites: +# - Docker with QEMU binfmt support (default on Docker Desktop) +# - Python 3.5 headers from the Pi in .pi/pi-python35.tar.gz +# Grab once: ssh bbmc@10.1.10.55 'tar czf - /usr/include/python3.5m \ +# /usr/lib/arm-linux-gnueabihf/libpython3.5m.so*' > .pi/pi-python35.tar.gz + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" +IMAGE="onefin-gplan" +HEADERS="$SCRIPT_DIR/pi-python35.tar.gz" +OUTPUT="$PROJECT_DIR/src/py/camotics/gplan.so" + +# Check for Python 3.5 headers +if [[ ! -f "$HEADERS" ]]; then + echo "Python 3.5 headers not found at $HEADERS" + echo "Fetching from Pi..." + ssh bbmc@10.1.10.55 'tar czf - /usr/include/python3.5m \ + /usr/lib/arm-linux-gnueabihf/libpython3.5m.so*' > "$HEADERS" +fi + +# Build image if needed (one-time, ~30min) +if ! docker image inspect "$IMAGE" >/dev/null 2>&1; then + echo "Building $IMAGE Docker image (one-time, ~30min under QEMU)..." + docker build --platform linux/arm/v7 -t "$IMAGE" -f "$SCRIPT_DIR/Dockerfile.gplan" "$SCRIPT_DIR" +fi + +# Relink gplan.so against Python 3.5m (~2sec) +echo "Linking gplan.so against Python 3.5m..." +docker run --rm --platform linux/arm/v7 \ + -v "$HEADERS:/tmp/pi-python35.tar.gz:ro" \ + -v "$PROJECT_DIR:/workspace" \ + "$IMAGE" bash -c ' + tar xzf /tmp/pi-python35.tar.gz -C / + ln -sf /usr/lib/arm-linux-gnueabihf/libpython3.5m.so.1.0 \ + /usr/lib/arm-linux-gnueabihf/libpython3.5m.so + + g++ -o /workspace/src/py/camotics/gplan.so \ + -Wl,--as-needed -Wl,-s -Wl,-x -Wl,--gc-sections -pthread -shared \ + build/gplan.os -L/opt/cbang/lib \ + build/libCAMoticsPy.a build/libCAMotics.a build/libDXF.a \ + build/libSTL.a build/libGCode.a \ + -lstdc++ -lutil -lm -ldl -lz -lcbang -lcbang-boost \ + -lssl -lcrypto -llz4 -lexpat -lbz2 -lcrypt -lpthread \ + -lpython3.5m build/dxflib/libdxflib.a + + file /workspace/src/py/camotics/gplan.so + readelf -d /workspace/src/py/camotics/gplan.so | grep python +' + +echo "✓ Built: $OUTPUT" From eab204b7be6a59c75c024a7d04d5ba75f82dbc9d Mon Sep 17 00:00:00 2001 From: Henrik Muehe Date: Thu, 30 Apr 2026 15:56:37 +0200 Subject: [PATCH 07/20] Fix Python 3.5 compat: capture_output and text= not available Use stdout=PIPE/stderr=PIPE and manual .decode() instead. Use official 1.6.6 gplan.so (built with Stretch-era GCC, no GLIBC_2.29 dep). --- src/py/bbctrl/Hooks.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/py/bbctrl/Hooks.py b/src/py/bbctrl/Hooks.py index 4911b68..d0853c5 100644 --- a/src/py/bbctrl/Hooks.py +++ b/src/py/bbctrl/Hooks.py @@ -359,13 +359,15 @@ class Hooks: result = subprocess.run( command, shell=True, env=env, timeout=timeout, - capture_output=True, text=True + stdout=subprocess.PIPE, stderr=subprocess.PIPE ) - if result.stdout.strip(): - self.log.info('Script stdout: %s' % result.stdout.strip()) + 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, - result.stderr.strip() or 'non-zero exit')) + (result.returncode, stderr or 'non-zero exit')) From 4d2d5fd88ca315c13b6a39c8c84632d9b84410d3 Mon Sep 17 00:00:00 2001 From: Henrik Muehe Date: Thu, 30 Apr 2026 15:57:31 +0200 Subject: [PATCH 08/20] Update BUILD.md: gplan.so can't be built from source on Bullseye Document GLIBC/GLIBCXX version constraints and Python 3.5 compat notes. Recommend using official release gplan.so instead. --- .pi/BUILD.md | 59 +++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 42 insertions(+), 17 deletions(-) diff --git a/.pi/BUILD.md b/.pi/BUILD.md index 80b0b80..c485ed8 100644 --- a/.pi/BUILD.md +++ b/.pi/BUILD.md @@ -54,7 +54,23 @@ ELF 32-bit LSB shared object, ARM, EABI5 (linked against libpython3.5m.so.1.0) ``` If you see `ELF 64-bit LSB shared object, ARM aarch64` or `libpython3.9` — wrong. -**Option A: Build from source (recommended)** +**Option A: From official release (recommended)** + +```bash +curl -sL https://github.com/OneFinityCNC/onefinity-firmware/releases/download/v1.6.6/onefinity-1.6.6.tar.bz2 \ + | tar xjf - --include='*/gplan.so' -O > src/py/camotics/gplan.so +``` + +The gplan.so is pure G-code planning with no version-specific code — safe to +reuse across firmware versions. + +**Option B: From a working Pi** + +```bash +scp bbmc@10.1.10.55:/usr/local/lib/python3.5/dist-packages/bbctrl-*.egg/camotics/gplan.so src/py/camotics/ +``` + +**Option C: Build from source (broken — documented for reference)** Uses Docker's armv7 QEMU emulation on Apple Silicon. Requires a one-time copy of Python 3.5 headers from the Pi (~1.7MB): @@ -119,24 +135,23 @@ docker run --rm --platform linux/arm/v7 \ ' ``` -Why the relink step: SCons compiles `.o` files that are Python-version-agnostic -(they only use `#include ` which is ABI-compatible between 3.5-3.9 -for the subset camotics uses). The only version-specific part is the final -`-lpython3.X` link. So we let SCons build with 3.9 (since it ignores overrides) -then relink the same objects against 3.5m. +**Why this doesn't actually work:** The `.o` files compile fine and the +Python relink works, but the compiled objects use GLIBC_2.29+ symbols +(from Bullseye's glibc 2.31) and GLIBCXX_3.4.26+ (from GCC 10). The Pi's +Stretch has GLIBC_2.24 / GLIBCXX_3.4.22 max. Even with `-static-libstdc++ +-static-libgcc`, glibc symbols like `GLIBC_2.29` leak through the object +files compiled against Bullseye headers. -**Option B: From official release** +To truly build from source you'd need a Stretch armhf container — but +Stretch's archived repos have broken package metadata that prevents +installing build-essential + scons. The official gplan.so was built +in a Raspbian Stretch chroot (see `scripts/gplan-init-build.sh`). -```bash -curl -L https://github.com/OneFinityCNC/onefinity-firmware/releases/download/v1.6.6/onefinity-1.6.6.tar.bz2 \ - | tar xjf - --include='*/gplan.so' --strip-components=3 -C src/py/camotics/ -``` - -**Option C: From a working Pi** - -```bash -scp bbmc@10.1.10.55:/usr/local/lib/python3.5/dist-packages/bbctrl-*.egg/camotics/gplan.so src/py/camotics/ -``` +**Key Pi constraints for native code:** +- GLIBC ≤ 2.24 (Stretch) +- GLIBCXX ≤ 3.4.22 (GCC 6) +- Python 3.5m (`libpython3.5m.so.1.0`) +- ELF 32-bit ARM EABI5 ### bbserial.ko — kernel module @@ -230,6 +245,16 @@ Open http://localhost:8765 — full UI with emulated AVR. See `backup/onefinity-backup.sh` for details. Environment variables: `ONEFINITY_HOST` (default 10.1.10.55), `ONEFINITY_USER` (bbmc), `ONEFINITY_PASS` (onefinity). +## Python 3.5 Compatibility + +The Pi runs Python 3.5.3. Watch out for features added in later versions: +- No `f"strings"` (use `"%s" % var` or `"{}".format(var)`) +- No `subprocess.run(capture_output=True)` (use `stdout=PIPE, stderr=PIPE`) +- No `subprocess.run(text=True)` (use `.decode('utf-8')` on stdout/stderr) +- No `dataclasses`, no `:=` walrus operator +- No `asyncio.run()` (use `loop.run_until_complete()`) +- No `typing` generics like `dict[str, int]` (use `Dict[str, int]` from typing) + ## Pi Details | | | From 704bc8d35c3af432163f1c14c27eb8b95f838409 Mon Sep 17 00:00:00 2001 From: Henrik Muehe Date: Thu, 30 Apr 2026 16:33:11 +0200 Subject: [PATCH 09/20] gplan.so: build from source using Raspbian Stretch Docker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use balenalib/raspberry-pi-debian:stretch with legacy.raspbian.org repos. Exact match: GCC 6.3, Python 3.5, GLIBC 2.24 — identical to the Pi. First build ~25min (QEMU), subsequent builds ~1sec (cached image). Replaces the broken Bullseye approach that had GLIBC/GLIBCXX mismatches. --- .gitignore | 1 + .pi/BUILD.md | 31 +++++++++++++++++-------- .pi/Dockerfile.gplan | 34 +++++++++++++++++----------- .pi/build-gplan.sh | 54 +++++++++++--------------------------------- 4 files changed, 56 insertions(+), 64 deletions(-) diff --git a/.gitignore b/.gitignore index 6c57627..20e8c00 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,4 @@ src/avr/emu/bbemu src/avr/emu/build/ .pi/pi-python35.tar.gz +src/py/camotics/gplan.so.built diff --git a/.pi/BUILD.md b/.pi/BUILD.md index c485ed8..cfeb1ed 100644 --- a/.pi/BUILD.md +++ b/.pi/BUILD.md @@ -54,23 +54,35 @@ ELF 32-bit LSB shared object, ARM, EABI5 (linked against libpython3.5m.so.1.0) ``` If you see `ELF 64-bit LSB shared object, ARM aarch64` or `libpython3.9` — wrong. -**Option A: From official release (recommended)** +**Option A: Build from source (recommended)** + +Uses a Raspbian Stretch Docker image that exactly matches the Pi's toolchain: +GCC 6.3, Python 3.5, GLIBC 2.24. No cross-compile hacks, no ABI mismatches. + +```bash +.pi/build-gplan.sh +``` + +- First run: ~25min (builds `onefin-gplan` Docker image with pre-compiled cbang + camotics) +- After that: ~1sec (copies cached `gplan.so` from image) +- Force rebuild: `docker rmi onefin-gplan && .pi/build-gplan.sh` + +See `.pi/Dockerfile.gplan` for the full build recipe. + +**Option B: From official release** ```bash curl -sL https://github.com/OneFinityCNC/onefinity-firmware/releases/download/v1.6.6/onefinity-1.6.6.tar.bz2 \ | tar xjf - --include='*/gplan.so' -O > src/py/camotics/gplan.so ``` -The gplan.so is pure G-code planning with no version-specific code — safe to -reuse across firmware versions. - -**Option B: From a working Pi** +**Option C: From a working Pi** ```bash scp bbmc@10.1.10.55:/usr/local/lib/python3.5/dist-packages/bbctrl-*.egg/camotics/gplan.so src/py/camotics/ ``` -**Option C: Build from source (broken — documented for reference)** +**Failed approach: Debian Bullseye armhf (documented for reference)** Uses Docker's armv7 QEMU emulation on Apple Silicon. Requires a one-time copy of Python 3.5 headers from the Pi (~1.7MB): @@ -142,10 +154,9 @@ Stretch has GLIBC_2.24 / GLIBCXX_3.4.22 max. Even with `-static-libstdc++ -static-libgcc`, glibc symbols like `GLIBC_2.29` leak through the object files compiled against Bullseye headers. -To truly build from source you'd need a Stretch armhf container — but -Stretch's archived repos have broken package metadata that prevents -installing build-essential + scons. The official gplan.so was built -in a Raspbian Stretch chroot (see `scripts/gplan-init-build.sh`). +The solution was to use `balenalib/raspberry-pi-debian:stretch` with +`legacy.raspbian.org` repos — these still work unlike bare `debian:stretch`. +See `.pi/Dockerfile.gplan`. **Key Pi constraints for native code:** - GLIBC ≤ 2.24 (Stretch) diff --git a/.pi/Dockerfile.gplan b/.pi/Dockerfile.gplan index 88c0e9c..97ee8f5 100644 --- a/.pi/Dockerfile.gplan +++ b/.pi/Dockerfile.gplan @@ -1,15 +1,24 @@ -# Pre-built armv7 environment for gplan.so -# Build once: docker build --platform linux/arm/v7 -t onefin-gplan -f .pi/Dockerfile.gplan .pi/ -# Then use: .pi/build-gplan.sh -FROM debian:bullseye +# Raspbian Stretch armhf build environment for gplan.so +# Matches the Pi exactly: GCC 6.3, Python 3.5, GLIBC 2.24 +# +# Build image: docker build -t onefin-gplan -f .pi/Dockerfile.gplan .pi/ +# Build gplan: .pi/build-gplan.sh +FROM balenalib/raspberry-pi-debian:stretch -RUN apt-get update -qq && \ - DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \ - build-essential scons git ca-certificates python3-dev \ +# Fix repos to use archived Raspbian mirrors +RUN echo "deb http://legacy.raspbian.org/raspbian/ stretch main contrib non-free rpi" \ + > /etc/apt/sources.list && \ + rm -f /etc/apt/sources.list.d/*.list + +RUN apt-get -o Acquire::Check-Valid-Until=false \ + -o Acquire::AllowInsecureRepositories=true update && \ + apt-get -o Acquire::Check-Valid-Until=false --allow-unauthenticated \ + install -y --no-install-recommends \ + build-essential python3-dev scons git ca-certificates \ libssl-dev libexpat1-dev libbz2-dev liblz4-dev zlib1g-dev perl file && \ rm -rf /var/lib/apt/lists/* -# Clone and build cbang (static lib, ~5min under QEMU) +# Clone and build cbang RUN mkdir -p /opt/cbang && cd /opt/cbang && git init -q && \ git remote add origin https://github.com/CauldronDevelopmentLLC/cbang && \ git fetch --depth 1 -q origin 18f1e963107ef26abe750c023355a5c40dd07853 && \ @@ -17,7 +26,7 @@ RUN mkdir -p /opt/cbang && cd /opt/cbang && git init -q && \ scons -j2 disable_local="re2 libevent" && \ rm -rf .git build/dep -# Clone and patch camotics (source only, don't compile yet — that depends on workspace) +# Clone, patch, and build camotics/gplan RUN mkdir -p /opt/camotics && cd /opt/camotics && git init -q && \ git remote add origin https://github.com/CauldronDevelopmentLLC/camotics && \ git fetch --depth 1 -q origin ec876c80d20fc19837133087cef0c447df5a939d && \ @@ -31,10 +40,9 @@ RUN mkdir -p /opt/camotics && cd /opt/camotics && git init -q && \ done && \ rm -rf .git -# Pre-compile camotics objects (the slow part, ~20min under QEMU) ENV CBANG_HOME=/opt/cbang -RUN cd /opt/camotics && scons -j2 gplan.so with_gui=0 with_tpl=0 && \ - rm -f gplan.so # remove the python3.9-linked one, we relink at build time -ENV CBANG_HOME=/opt/cbang +# Pre-compile everything including gplan.so +RUN cd /opt/camotics && scons -j2 gplan.so with_gui=0 with_tpl=0 + WORKDIR /opt/camotics diff --git a/.pi/build-gplan.sh b/.pi/build-gplan.sh index 4789eb3..fa15a99 100755 --- a/.pi/build-gplan.sh +++ b/.pi/build-gplan.sh @@ -1,58 +1,30 @@ #!/usr/bin/env bash set -euo pipefail -# Build gplan.so for the Onefinity Pi (armv7l, Python 3.5) +# Build gplan.so for the Onefinity Pi (armv7l, Python 3.5, GCC 6.3) # -# First run: ~30min (builds the Docker image with pre-compiled cbang/camotics) -# After that: ~2sec (just relinks against Python 3.5m) +# Uses a Raspbian Stretch Docker image that exactly matches the Pi's +# toolchain. No cross-compile, no relink hacks, no GLIBC mismatches. # -# Prerequisites: -# - Docker with QEMU binfmt support (default on Docker Desktop) -# - Python 3.5 headers from the Pi in .pi/pi-python35.tar.gz -# Grab once: ssh bbmc@10.1.10.55 'tar czf - /usr/include/python3.5m \ -# /usr/lib/arm-linux-gnueabihf/libpython3.5m.so*' > .pi/pi-python35.tar.gz +# First run: ~30min (builds Docker image with cbang + camotics) +# After that: ~1sec (copies pre-built gplan.so from image) SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" PROJECT_DIR="$(dirname "$SCRIPT_DIR")" IMAGE="onefin-gplan" -HEADERS="$SCRIPT_DIR/pi-python35.tar.gz" OUTPUT="$PROJECT_DIR/src/py/camotics/gplan.so" -# Check for Python 3.5 headers -if [[ ! -f "$HEADERS" ]]; then - echo "Python 3.5 headers not found at $HEADERS" - echo "Fetching from Pi..." - ssh bbmc@10.1.10.55 'tar czf - /usr/include/python3.5m \ - /usr/lib/arm-linux-gnueabihf/libpython3.5m.so*' > "$HEADERS" -fi - -# Build image if needed (one-time, ~30min) +# Build image if needed (one-time) if ! docker image inspect "$IMAGE" >/dev/null 2>&1; then echo "Building $IMAGE Docker image (one-time, ~30min under QEMU)..." - docker build --platform linux/arm/v7 -t "$IMAGE" -f "$SCRIPT_DIR/Dockerfile.gplan" "$SCRIPT_DIR" + docker build -t "$IMAGE" -f "$SCRIPT_DIR/Dockerfile.gplan" "$SCRIPT_DIR" fi -# Relink gplan.so against Python 3.5m (~2sec) -echo "Linking gplan.so against Python 3.5m..." -docker run --rm --platform linux/arm/v7 \ - -v "$HEADERS:/tmp/pi-python35.tar.gz:ro" \ - -v "$PROJECT_DIR:/workspace" \ - "$IMAGE" bash -c ' - tar xzf /tmp/pi-python35.tar.gz -C / - ln -sf /usr/lib/arm-linux-gnueabihf/libpython3.5m.so.1.0 \ - /usr/lib/arm-linux-gnueabihf/libpython3.5m.so - - g++ -o /workspace/src/py/camotics/gplan.so \ - -Wl,--as-needed -Wl,-s -Wl,-x -Wl,--gc-sections -pthread -shared \ - build/gplan.os -L/opt/cbang/lib \ - build/libCAMoticsPy.a build/libCAMotics.a build/libDXF.a \ - build/libSTL.a build/libGCode.a \ - -lstdc++ -lutil -lm -ldl -lz -lcbang -lcbang-boost \ - -lssl -lcrypto -llz4 -lexpat -lbz2 -lcrypt -lpthread \ - -lpython3.5m build/dxflib/libdxflib.a - - file /workspace/src/py/camotics/gplan.so - readelf -d /workspace/src/py/camotics/gplan.so | grep python -' +# Copy gplan.so out of the image +echo "Extracting gplan.so..." +docker run --rm -v "$PROJECT_DIR:/workspace" "$IMAGE" \ + bash -c 'cp /opt/camotics/gplan.so /workspace/src/py/camotics/gplan.so && \ + file /workspace/src/py/camotics/gplan.so && \ + readelf -d /workspace/src/py/camotics/gplan.so | grep -E "NEEDED|python"' echo "✓ Built: $OUTPUT" From 54a15f9d12b8c6cb45b2b5f8a877fe488b3b01ce Mon Sep 17 00:00:00 2001 From: Henrik Muehe Date: Thu, 30 Apr 2026 16:39:57 +0200 Subject: [PATCH 10/20] Rewrite BUILD.md: clean up, add quick start, remove dead weight - Quick start section at the top (3 commands) - Removed inline Bullseye build recipe (moved to 'why not' appendix) - Added build time estimates - Cleaner table formatting - gplan.so contents documented (cbang + camotics) --- .pi/BUILD.md | 331 +++++++++++++++++++++++---------------------------- 1 file changed, 147 insertions(+), 184 deletions(-) diff --git a/.pi/BUILD.md b/.pi/BUILD.md index cfeb1ed..63e2624 100644 --- a/.pi/BUILD.md +++ b/.pi/BUILD.md @@ -1,200 +1,129 @@ # Onefinity CNC Firmware — Build, Flash & Backup +## Quick Start + +```bash +# 1. Build gplan.so (first time ~25min, then ~1sec) +.pi/build-gplan.sh + +# 2. Build firmware package (frontend + AVR + Python, ~1min) +docker run --rm -v "$(pwd):/workspace" -w /workspace onefin-dev \ + bash -c 'make all && python3 ./setup.py sdist' + +# 3. Flash to controller +curl -X PUT -F "firmware=@dist/bbctrl-1.6.7.tar.bz2" \ + -F "password=onefinity" http://10.1.10.55/api/firmware/update +``` + ## Architecture Overview -The Onefinity controller is a **Raspberry Pi 2/3** (armv7l, Raspbian Stretch, Python 3.5) connected to an **ATxmega192a3u** AVR microcontroller over serial. The Pi runs a Tornado web server (`bbctrl`) that serves the UI and plans G-code motion. The AVR executes realtime step/direction pulses. +The controller is a **Raspberry Pi 2/3** (armv7l, Raspbian Stretch, Python 3.5) +connected to an **ATxmega192a3u** AVR over serial. The Pi runs a Tornado web +server that serves the UI, parses G-code, and plans motion. The AVR executes +realtime step/direction pulses. ``` Browser ←WebSocket→ Pi (Tornado/Python) → GCode Planner → Serial → AVR → Stepper drivers ``` The firmware package (`bbctrl-X.Y.Z.tar.bz2`) contains: -- **Python backend** (`src/py/bbctrl/`) — Tornado web server, planner, state machine -- **Web frontend** (`build/http/`) — Pug/Stylus/Svelte compiled to static HTML/JS/CSS -- **AVR firmware** (`src/avr/bbctrl-avr-firmware.hex`) — realtime motion controller -- **gplan.so** (`src/py/camotics/gplan.so`) — CAMotics G-code planner (native ARM .so) -- **Install scripts** (`scripts/install.sh`, etc.) + +| Component | Source | Description | +|---|---|---| +| Python backend | `src/py/bbctrl/` | Tornado web server, state machine, planner bridge | +| Web frontend | `build/http/` | Pug + Stylus + Svelte → static HTML/JS/CSS | +| AVR firmware | `src/avr/bbctrl-avr-firmware.hex` | Realtime motion controller | +| gplan.so | `src/py/camotics/gplan.so` | CAMotics G-code planner (native ARM C++ extension) | +| Install scripts | `scripts/install.sh` | AVR flash, Python install, service restart | ## Prerequisites -- Docker (for the devcontainer build environment) -- The devcontainer image: `docker build -t onefin-dev -f .devcontainer/Dockerfile .devcontainer/` -- SSH access to the Pi: `ssh bbmc@10.1.10.55` (password: `onefinity`) +- **Docker** with QEMU binfmt support (default on Docker Desktop for Mac) +- **devcontainer image**: `docker build -t onefin-dev -f .devcontainer/Dockerfile .devcontainer/` +- **SSH access**: `ssh bbmc@10.1.10.55` (password: `onefinity`) ## Building -### Full build (frontend + AVR + package) +### Step 1: gplan.so + +`gplan.so` is the CAMotics G-code planner — a C++ Python extension that must +be a **32-bit ARM binary linked against Python 3.5**. It cannot be built in the +devcontainer (wrong arch + wrong Python + wrong glibc). + +**Build from source** (recommended): + +```bash +.pi/build-gplan.sh +``` + +This uses a Raspbian Stretch Docker image (`balenalib/raspberry-pi-debian:stretch`) +with the Pi's exact toolchain: GCC 6.3, Python 3.5, GLIBC 2.24. The image is +built once (~25min under QEMU), then cached — subsequent runs take ~1sec. + +The image pre-compiles two C++ dependencies: +- [cbang](https://github.com/CauldronDevelopmentLLC/cbang) @ `18f1e96` — C++ utility library +- [camotics](https://github.com/CauldronDevelopmentLLC/camotics) @ `ec876c8` — G-code planner with S-curve motion planning + +To force a full rebuild: `docker rmi onefin-gplan && .pi/build-gplan.sh` + +**Alternatives** (if Docker build fails): + +```bash +# From official release +curl -sL https://github.com/OneFinityCNC/onefinity-firmware/releases/download/v1.6.6/onefinity-1.6.6.tar.bz2 \ + | tar xjf - --include='*/gplan.so' -O > src/py/camotics/gplan.so + +# From the running Pi +scp bbmc@10.1.10.55:/usr/local/lib/python3.5/dist-packages/bbctrl-*.egg/camotics/gplan.so src/py/camotics/ +``` + +**Verify** — must show `ELF 32-bit ... ARM ... libpython3.5m`: + +```bash +file src/py/camotics/gplan.so +readelf -d src/py/camotics/gplan.so | grep python +``` + +### Step 2: Firmware package ```bash docker run --rm -v "$(pwd):/workspace" -w /workspace onefin-dev \ bash -c 'make all && python3 ./setup.py sdist' ``` -Produces: `dist/bbctrl-1.6.7.tar.bz2` (~3MB) +This builds inside the devcontainer (arm64 Bullseye — fine for frontend/AVR/Python): -### What `make all` builds - -| Component | Command | Output | +| Component | Tool | Time | |---|---|---| -| Web frontend | `npm install`, pug/stylus/svelte compile | `build/http/` | -| AVR firmware | `make -C src/avr` (avr-g++) | `src/avr/bbctrl-avr-firmware.hex` | -| Bootloader | `make -C src/boot` | `src/boot/bbctrl-avr-boot.hex` | -| Power MCU | `make -C src/pwr` | `src/pwr/bbctrl-pwr-firmware.hex` | -| Jig firmware | `make -C src/jig` | `src/jig/bbctrl-jig-firmware.hex` | +| Node modules | `npm install` | ~30sec | +| Svelte components | `vite build` | ~5sec | +| Pug/Stylus → HTML | `pug-cli`, `stylus` | ~2sec | +| AVR firmware | `avr-g++` (ATxmega192a3u) | ~10sec | +| Boot/Power/Jig MCUs | `avr-gcc` | ~5sec | +| Python sdist | `setup.py sdist` | ~2sec | -### gplan.so — the critical gotcha +Produces: `dist/bbctrl-X.Y.Z.tar.bz2` (~3-4MB) -`gplan.so` is the CAMotics G-code planner compiled as a Python C extension. It **must be a 32-bit ARM binary** linked against **Python 3.5** to run on the Pi. +### bbserial.ko (kernel module — usually skip) -**Do NOT build gplan.so in the devcontainer.** The devcontainer runs arm64/Debian Bullseye with Python 3.9. The resulting `.so` will be the wrong architecture and wrong Python ABI. Cross-compiling with `CXX=arm-linux-gnueabihf-g++` also fails because SCons ignores CC/CXX env vars. - -The correct file must be: -``` -ELF 32-bit LSB shared object, ARM, EABI5 (linked against libpython3.5m.so.1.0) -``` -If you see `ELF 64-bit LSB shared object, ARM aarch64` or `libpython3.9` — wrong. - -**Option A: Build from source (recommended)** - -Uses a Raspbian Stretch Docker image that exactly matches the Pi's toolchain: -GCC 6.3, Python 3.5, GLIBC 2.24. No cross-compile hacks, no ABI mismatches. - -```bash -.pi/build-gplan.sh -``` - -- First run: ~25min (builds `onefin-gplan` Docker image with pre-compiled cbang + camotics) -- After that: ~1sec (copies cached `gplan.so` from image) -- Force rebuild: `docker rmi onefin-gplan && .pi/build-gplan.sh` - -See `.pi/Dockerfile.gplan` for the full build recipe. - -**Option B: From official release** - -```bash -curl -sL https://github.com/OneFinityCNC/onefinity-firmware/releases/download/v1.6.6/onefinity-1.6.6.tar.bz2 \ - | tar xjf - --include='*/gplan.so' -O > src/py/camotics/gplan.so -``` - -**Option C: From a working Pi** - -```bash -scp bbmc@10.1.10.55:/usr/local/lib/python3.5/dist-packages/bbctrl-*.egg/camotics/gplan.so src/py/camotics/ -``` - -**Failed approach: Debian Bullseye armhf (documented for reference)** - -Uses Docker's armv7 QEMU emulation on Apple Silicon. Requires a one-time -copy of Python 3.5 headers from the Pi (~1.7MB): - -```bash -# One-time: grab Python 3.5 headers + lib from Pi -ssh bbmc@10.1.10.55 'tar czf - /usr/include/python3.5m \ - /usr/lib/arm-linux-gnueabihf/libpython3.5m.so*' > /tmp/pi-python35.tar.gz -``` - -Then build (takes ~30min under QEMU): - -```bash -docker run --rm --platform linux/arm/v7 \ - -v "$(pwd):/workspace" -w /workspace \ - -v /tmp/pi-python35.tar.gz:/tmp/pi-python35.tar.gz \ - debian:bullseye bash -c ' - set -e - tar xzf /tmp/pi-python35.tar.gz -C / - apt-get update -qq && apt-get install -y -qq build-essential scons git \ - ca-certificates python3-dev libssl-dev libexpat1-dev libbz2-dev \ - liblz4-dev zlib1g-dev perl file - ln -sf /usr/lib/arm-linux-gnueabihf/libpython3.5m.so.1.0 \ - /usr/lib/arm-linux-gnueabihf/libpython3.5m.so - - # Clone cbang + camotics at pinned commits - mkdir -p /tmp/cbang && cd /tmp/cbang && git init -q - git remote add origin https://github.com/CauldronDevelopmentLLC/cbang - git fetch --depth 1 -q origin 18f1e963107ef26abe750c023355a5c40dd07853 - git reset --hard FETCH_HEAD -q - - mkdir -p /tmp/camotics && cd /tmp/camotics && git init -q - git remote add origin https://github.com/CauldronDevelopmentLLC/camotics - git fetch --depth 1 -q origin ec876c80d20fc19837133087cef0c447df5a939d - git reset --hard FETCH_HEAD -q - - # Build cbang - cd /tmp/cbang && scons -j2 disable_local="re2 libevent" - export CBANG_HOME="/tmp/cbang" - - # Patch camotics (clamp div-by-zero in planner) - P="/tmp/camotics/src/gcode/plan" - mkdir -p /tmp/camotics/build && touch /tmp/camotics/build/version.txt - for F in LineCommand.cpp LinePlanner.cpp; do - for V in maxVel maxJerk maxAccel; do - perl -i -0pe "s/(fabs\\((config\\.$V\\[axis\\]) \\/ unit\\[axis\\]\\));/std::min(\\2, \\1);/gm" $P/$F - done - done - - # Compile with Python 3.9 (SCons needs python3-dev to find headers) - cd /tmp/camotics && scons -j2 gplan.so with_gui=0 with_tpl=0 - - # Relink against Python 3.5m (the Pi target) - g++ -o /workspace/src/py/camotics/gplan.so \ - -Wl,--as-needed -Wl,-s -Wl,-x -Wl,--gc-sections -pthread -shared \ - build/gplan.os -L/tmp/cbang/lib \ - build/libCAMoticsPy.a build/libCAMotics.a build/libDXF.a \ - build/libSTL.a build/libGCode.a \ - -lstdc++ -lutil -lm -ldl -lz -lcbang -lcbang-boost \ - -lssl -lcrypto -llz4 -lexpat -lbz2 -lcrypt -lpthread \ - -lpython3.5m build/dxflib/libdxflib.a - ' -``` - -**Why this doesn't actually work:** The `.o` files compile fine and the -Python relink works, but the compiled objects use GLIBC_2.29+ symbols -(from Bullseye's glibc 2.31) and GLIBCXX_3.4.26+ (from GCC 10). The Pi's -Stretch has GLIBC_2.24 / GLIBCXX_3.4.22 max. Even with `-static-libstdc++ --static-libgcc`, glibc symbols like `GLIBC_2.29` leak through the object -files compiled against Bullseye headers. - -The solution was to use `balenalib/raspberry-pi-debian:stretch` with -`legacy.raspbian.org` repos — these still work unlike bare `debian:stretch`. -See `.pi/Dockerfile.gplan`. - -**Key Pi constraints for native code:** -- GLIBC ≤ 2.24 (Stretch) -- GLIBCXX ≤ 3.4.22 (GCC 6) -- Python 3.5m (`libpython3.5m.so.1.0`) -- ELF 32-bit ARM EABI5 - -### bbserial.ko — kernel module - -The `bbserial.ko` kernel module requires cross-compiling against the Pi's exact kernel headers (4.9.59-v7+). The `make pkg` target tries to build it but it's rarely needed — the Pi already has a matching `.ko` installed. The `install.sh` script skips it gracefully if the file is missing (`cp: cannot stat 'src/bbserial/bbserial.ko': No such file or directory`). - -### AVR emulator (for local demo mode) - -```bash -docker run --rm -v "$(pwd):/workspace" -w /workspace/src/avr/emu onefin-dev make -``` - -Produces `src/avr/emu/bbemu` — a native binary that emulates the AVR for demo mode. +Cross-compiles against the Pi's kernel headers (4.9.59-v7+). The Pi already has +a working `bbserial.ko` installed. `install.sh` skips it gracefully if missing. ## Flashing -### Via web API (recommended, machine must be running) +### Via web API (machine running) ```bash -curl -X PUT \ - -H "Content-Type: multipart/form-data" \ - -F "firmware=@dist/bbctrl-1.6.7.tar.bz2" \ - -F "password=onefinity" \ - http://10.1.10.55/api/firmware/update +curl -X PUT -F "firmware=@dist/bbctrl-1.6.7.tar.bz2" \ + -F "password=onefinity" http://10.1.10.55/api/firmware/update ``` -Or: `make update HOST=10.1.10.55 PASSWORD=onefinity` +Or: `make update HOST=10.1.10.55` -### Via SSH (if web UI is down / crash-looping) +### Via SSH (web UI down or crash-looping) ```bash scp dist/bbctrl-1.6.7.tar.bz2 bbmc@10.1.10.55:/tmp/ - ssh bbmc@10.1.10.55 'echo onefinity | sudo -S bash -c " systemctl stop bbctrl mkdir -p /var/lib/bbctrl/firmware @@ -212,21 +141,22 @@ ssh bbmc@10.1.10.55 'echo onefinity | sudo -S bash -c " - Restarts `bbctrl` systemd service - May reboot if boot config or kernel module changed -### Recovery if flash breaks the Pi +### Recovery from bad flash -If bbctrl is crash-looping after a bad flash: -1. SSH still works: `ssh bbmc@10.1.10.55` -2. Check the error: `sudo python3 /usr/local/bin/bbctrl 2>&1 | head -20` -3. Common fix: replace gplan.so with correct ARM binary (see above) -4. Nuclear option: restore from SD card backup (see below) +SSH still works even when bbctrl is crash-looping: +1. Check the error: `sudo python3 /usr/local/bin/bbctrl 2>&1 | head -20` +2. Common cause: wrong gplan.so architecture → replace with correct one (see above) +3. Nuclear option: restore SD card from backup -## Running locally (demo mode) +## Running Locally (demo mode) -Run the full stack in Docker with the AVR emulator: +Full stack in Docker with AVR emulator — no Pi needed: ```bash -# Build everything first (make all + bbemu + gplan.so for arm64 devcontainer) -# Then: +# Build bbemu (AVR emulator, native in devcontainer) +docker run --rm -v "$(pwd):/workspace" -w /workspace/src/avr/emu onefin-dev make + +# Run demo (needs arm64 gplan.so for the container, not armhf) docker run --rm -d --name onefin-demo \ -v "$(pwd):/workspace" -w /workspace -p 8765:80 \ onefin-dev bash -c ' @@ -237,14 +167,16 @@ docker run --rm -d --name onefin-demo \ ' ``` -Note: demo mode needs its own gplan.so matching the container's arch (arm64 + Python 3.9). Build it with the gplan build procedure in the Makefile, or use the one already in `src/py/camotics/` if it matches. +Open http://localhost:8765 — full UI with emulated controller. -Open http://localhost:8765 — full UI with emulated AVR. +Note: demo mode needs a **container-arch** gplan.so (arm64 + Python 3.9), not the +Pi one. The devcontainer build from the Makefile's `gplan` target produces this, +or it can be built following the procedure in `scripts/gplan-build.sh`. ## SD Card Backup & Restore ```bash -# Backup (streams raw dd from Pi, compresses locally with gzip, ~50 min) +# Backup (~50 min, streams raw dd from Pi, compresses locally) ./backup/onefinity-backup.sh backup # Verify @@ -252,35 +184,66 @@ Open http://localhost:8765 — full UI with emulated AVR. # Restore to local SD card ./backup/onefinity-backup.sh restore backup/onefinity-20260430.img.gz /dev/diskN + +# Restore back to Pi over SSH +./backup/onefinity-backup.sh restore backup/onefinity-20260430.img.gz ``` -See `backup/onefinity-backup.sh` for details. Environment variables: `ONEFINITY_HOST` (default 10.1.10.55), `ONEFINITY_USER` (bbmc), `ONEFINITY_PASS` (onefinity). +Environment: `ONEFINITY_HOST` (default 10.1.10.55), `ONEFINITY_USER` (bbmc), +`ONEFINITY_PASS` (onefinity). ## Python 3.5 Compatibility -The Pi runs Python 3.5.3. Watch out for features added in later versions: -- No `f"strings"` (use `"%s" % var` or `"{}".format(var)`) -- No `subprocess.run(capture_output=True)` (use `stdout=PIPE, stderr=PIPE`) -- No `subprocess.run(text=True)` (use `.decode('utf-8')` on stdout/stderr) -- No `dataclasses`, no `:=` walrus operator -- No `asyncio.run()` (use `loop.run_until_complete()`) -- No `typing` generics like `dict[str, int]` (use `Dict[str, int]` from typing) +The Pi runs Python 3.5.3. Avoid features added in later versions: + +| Avoid | Use instead | +|---|---| +| `f"hello {name}"` | `"hello %s" % name` or `"hello {}".format(name)` | +| `subprocess.run(capture_output=True)` | `stdout=subprocess.PIPE, stderr=subprocess.PIPE` | +| `subprocess.run(text=True)` | `.stdout.decode('utf-8')` | +| `dataclasses` | plain classes with `__init__` | +| `:=` walrus operator | separate assignment | +| `asyncio.run()` | `loop.run_until_complete()` | +| `dict[str, int]` | `Dict[str, int]` from `typing` | ## Pi Details | | | |---|---| | Host | `10.1.10.55` | -| SSH user | `bbmc` | -| sudo password | `onefinity` | +| SSH | `bbmc` / `onefinity` | | OS | Raspbian Stretch (Debian 9) | | Kernel | 4.9.59-v7+ | | Python | 3.5.3 | -| Arch | armv7l (32-bit ARM) | -| SD card | 30GB | +| GCC | 6.3.0 | +| GLIBC | 2.24 (max symbol: GLIBC_2.24) | +| GLIBCXX | 3.4.22 | +| Arch | armv7l (32-bit ARM, EABI5) | +| SD card | 30GB (~2.8GB used) | | Service | `systemctl {start,stop,restart,status} bbctrl` | | Log | `/var/log/bbctrl.log` or `journalctl -u bbctrl` | | Config | `/var/lib/bbctrl/config.json` | | Uploads | `/var/lib/bbctrl/upload/` | | Web root | `/usr/local/lib/python3.5/dist-packages/bbctrl-*.egg/bbctrl/http/` | | AVR serial | `/dev/ttyAMA0` at 230400 baud | + +## Why Not Build gplan.so on Bullseye? + +Documented for reference — we tried two approaches that don't work: + +**1. devcontainer (arm64 Bullseye):** Wrong ELF class (64-bit vs 32-bit) and wrong +Python (3.9 vs 3.5). Cross-compiling with `CXX=arm-linux-gnueabihf-g++` fails +because SCons ignores CC/CXX environment variables. + +**2. Bullseye armhf container:** Correct architecture, but GCC 10 / glibc 2.31 +produce objects requiring GLIBC_2.29+ and GLIBCXX_3.4.26+ symbols. The Pi's +Stretch only has GLIBC_2.24 / GLIBCXX_3.4.22. Even `-static-libstdc++ +-static-libgcc` doesn't help — glibc symbols leak through the object files. +Relinking against Python 3.5m works but the GLIBC mismatch remains. + +**3. Plain `debian:stretch` armhf:** The archived repos have broken package +metadata — `apt-get install build-essential` fails with unresolvable version +conflicts. + +**Solution:** `balenalib/raspberry-pi-debian:stretch` with `legacy.raspbian.org` +repos. See `.pi/Dockerfile.gplan`. From c7cf9483b3601413a5ce0f1141b32a80a0315dea Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Apr 2026 16:51:24 +0200 Subject: [PATCH 11/20] Add W axis integration via auxcnc ESP32 over /dev/ttyUSB0 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. --- .gitignore | 2 + docs/AUX_W_AXIS.md | 144 ++++++++++ src/py/bbctrl/AuxAxis.py | 477 +++++++++++++++++++++++++++++++ src/py/bbctrl/AuxPreprocessor.py | 237 +++++++++++++++ src/py/bbctrl/Ctrl.py | 40 +++ src/py/bbctrl/FileHandler.py | 13 + src/py/bbctrl/Hooks.py | 88 ++++-- src/py/bbctrl/Mach.py | 32 +++ src/py/bbctrl/Web.py | 74 +++++ src/py/bbctrl/__init__.py | 1 + 10 files changed, 1092 insertions(+), 16 deletions(-) create mode 100644 docs/AUX_W_AXIS.md create mode 100644 src/py/bbctrl/AuxAxis.py create mode 100644 src/py/bbctrl/AuxPreprocessor.py diff --git a/.gitignore b/.gitignore index 20e8c00..d4a6821 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,5 @@ src/avr/emu/build/ .pi/pi-python35.tar.gz src/py/camotics/gplan.so.built +tmp/ +backup/ diff --git a/docs/AUX_W_AXIS.md b/docs/AUX_W_AXIS.md new file mode 100644 index 0000000..5ccd9a0 --- /dev/null +++ b/docs/AUX_W_AXIS.md @@ -0,0 +1,144 @@ +# W axis (auxcnc) integration + +This adds a virtual `W` axis to the bbctrl controller, driven by the +auxcnc ESP32 over USB serial (`/dev/ttyUSB0`). The ESP owns step-pulse +generation, real-time limit-switch monitoring, and the homing dance. +The Pi owns units (mm), soft limits, sequencing inside G-code jobs, and +a small REST API for jogging / homing from the UI. + +## How it works + +The bbctrl planner (gplan) only understands `xyzabc`, so adding a true +7th axis would require rebuilding gplan + the AVR firmware. We avoid +that by treating W as a synchronous out-of-band axis: W moves run +*between* G-code blocks, not blended with XYZ. + +Pipeline: + +1. User uploads a G-code file containing `W` words. +2. `FileHandler` runs `AuxPreprocessor` on the upload, rewriting W + tokens in place into `(MSG,HOOK:aux:)` etc. The original line + minus the W word continues to drive XYZ. +3. The planner sees only XYZ + message comments. When it reaches a + message line, the message goes through `state.add_message` which + `Hooks._on_state_change` watches for the `HOOK:` prefix. +4. `Hooks._fire('custom', ...)` finds the registered internal handler + for the event name (`aux`, `aux_rel`, `aux_home`, `aux_setzero`). +5. The handler runs in a hook thread, gating `Mach.unpause` until done. + While the handler is busy the machine is in HOLDING - no XYZ motion + can resume until W finishes. +6. The handler talks to the ESP over `/dev/ttyUSB0` via `AuxAxis`, + blocking on a deterministic reply token (`[step] done`, `[home] + done`, etc). + +MDI commands containing `W` words are rewritten the same way at the +`Mach.mdi()` boundary so manual jog and macros work too. + +## G-code surface + +```gcode +G21 G90 +G28 W0 ; home W axis +G1 W25 F300 ; move W to 25 mm absolute +G1 X100 W12.5 ; mixed: W moves first, then XYZ (configurable) +G91 +G1 W-2.5 ; relative W move +G90 +G92 W0 ; set current W as zero (G92-style) +``` + +Rules: + +- `G28` / `G28.2` with W only -> homing hook; the bare `G28` is NOT + emitted to gplan (that would mean home-all). +- `G28.2 X0 Y0 W0` -> emit hook, then keep `G28.2 X0 Y0` for XY homing. +- A line with both W and XYZ axis words is split into two sequential + blocks. Default order: W first, then XYZ. Toggle via the + `w_first` constructor arg. +- Lines inside parens or after `;` are passed through verbatim. + +## Configuration + +Per-controller config lives at `/aux.json` (created on first +save via the API). Keys: + +| Key | Default | Notes | +|------------------------|----------------|------------------------------------| +| `enabled` | `false` | Master switch | +| `port` | `/dev/ttyUSB0` | Serial device | +| `baud` | `115200` | | +| `steps_per_mm` | `80.0` | Logical steps per mm | +| `dir_sign` | `1` | +1 or -1: maps logical+ to motor+ | +| `min_w`, `max_w` | `0`, `100` | Soft limits in mm | +| `home_dir` | `'-'` | Direction toward limit switch | +| `home_position_mm` | `0.0` | mm value assigned at home | +| `home_fast_sps` | `4000` | Fast seek rate | +| `home_slow_sps` | `400` | Slow re-seek rate | +| `home_backoff_steps` | `200` | Backoff after touching limit | +| `home_maxtravel_steps` | `200000` | Hard cap on phase 1 seek | +| `step_max_sps` | `4000` | Cruise rate for STEPS | +| `step_accel_sps2` | `16000` | Trapezoidal ramp accel | +| `step_start_sps` | `200` | Ramp floor | +| `limit_low` | `true` | Switch active low (closed = LOW) | + +Most of these are pushed to the ESP via `HOMECFG` on connect and +persisted there in NVS. + +## REST API + +| Verb | Path | Body | Effect | +|------|----------------------------|-----------------------|------------------------| +| GET | `/api/aux/config` | - | Current config | +| PUT | `/api/aux/config/save` | `{key: val, ...}` | Save and re-push | +| GET | `/api/aux/status` | - | `{enabled, present, homed, pos_mm}` | +| PUT | `/api/aux/home` | - | Run home cycle (blocks)| +| PUT | `/api/aux/abort` | - | Cancel running motion | +| PUT | `/api/aux/jog` | `{mm: 1.5}` or `{steps: 200}` | Relative move | +| PUT | `/api/aux/move` | `{mm: 12.5}` | Absolute move (mm) | +| PUT | `/api/aux/set-zero` | `{mm: 0}` | Set current pos to mm | + +Steps-mode jog ignores soft limits (use it to inch the axis to the +limit switch when the axis isn't homed yet). + +## State surface (UI) + +These are pushed via `state.set` and visible in the websocket stream: + +- `aux_enabled` - bool, axis is configured + enabled +- `aux_present` - bool, ESP responding on serial +- `aux_homed` - bool, has been homed since last ESP reset +- `aux_pos` - float, current W in mm (4 decimals) + +## Edge cases + +- **ESP reboots mid-job**: `[boot] auxcnc v=N` banner -> `aux_homed` + cleared, message added: "W axis controller restarted - re-home + before use". Subsequent W moves still run; if you want a hard fail + instead, that's a one-line change in `_require_present`. +- **Limit switch closed at boot of HOME**: `[home] failed + reason=already_at_limit` -> hook raises -> Mach surfaces error. +- **Pause mid-W-move**: the hook is blocking, so feed-hold takes + effect *after* the W move completes. For an immediate stop hit + estop; the Hooks listener will call `aux.abort()` which sends + `ABORT\n` to the ESP and the step-pulse loop exits. +- **Connection loss**: if `/dev/ttyUSB0` can't be opened at startup, + `aux_present=False` and any G-code with W will fail-fast at the + hook handler with "Aux axis not connected". +- **No home enforcement**: per design, manual jogs and W moves are + allowed even without a successful home. Soft limits still apply + unless you use the raw step jog endpoint. + +## Files added/changed + +- `src/py/bbctrl/AuxAxis.py` (new): serial worker + RPC layer +- `src/py/bbctrl/AuxPreprocessor.py` (new): G-code rewriter +- `src/py/bbctrl/Hooks.py`: register_internal(), fix the messages + listener so `(MSG,HOOK:...)` actually fires +- `src/py/bbctrl/Ctrl.py`: instantiate AuxAxis, register hooks +- `src/py/bbctrl/Mach.py`: rewrite MDI commands containing W +- `src/py/bbctrl/FileHandler.py`: rewrite uploads in place +- `src/py/bbctrl/Web.py`: REST endpoints +- `src/py/bbctrl/__init__.py`: export AuxAxis +- `auxcnc/src/main.cpp`: new commands HOME, HOMECFG, WPOS, HOMED?, + LIMIT?, ABORT-able STEPS with limit-aware abort, trapezoidal ramps, + NVS-persisted config, `[boot]` banner, deterministic reply tokens diff --git a/src/py/bbctrl/AuxAxis.py b/src/py/bbctrl/AuxAxis.py new file mode 100644 index 0000000..00914c8 --- /dev/null +++ b/src/py/bbctrl/AuxAxis.py @@ -0,0 +1,477 @@ +################################################################################ +# +# AuxAxis - W-axis serial driver for the auxcnc ESP32 controller +# +# Owns /dev/ttyUSB0 (or whatever serial.port is configured to). Provides +# blocking RPCs for use from a hook thread. Maintains: +# +# - aux_present : True if serial is open and we've seen a boot banner +# - aux_homed : True if we've successfully run HOME since last reset +# - aux_pos : current logical position in mm (from ESP step counter +# * (1 / steps_per_mm * dir_sign)) +# +# Real-time decisions (limit switch monitoring, step pulse generation) live +# on the ESP. The host is responsible for units, soft limits, and tracking +# whether we've ever boot-cycled the ESP since last home. +# +################################################################################ + +import os +import json +import time +import threading +import traceback + +try: + import serial +except ImportError: + serial = None + + +# Default config; overridden by ./aux.json or ctrl.config. +DEFAULTS = { + 'enabled': False, + 'port': '/dev/ttyUSB0', + 'baud': 115200, + 'steps_per_mm': 80.0, # logical steps per mm of W travel + 'dir_sign': 1, # +1 or -1: maps logical+ to motor+ steps + 'min_w': 0.0, # soft limit min (mm) + 'max_w': 100.0, # soft limit max (mm) + 'max_feed_mm_min': 600.0, # informational; rate caps are on the ESP + 'home_dir': '-', # which direction is "toward limit" (host's view) + 'home_position_mm': 0.0, # mm value to assign at home + # ESP-side homing rates (steps/sec). Pushed via HOMECFG on connect. + 'home_fast_sps': 4000, + 'home_slow_sps': 400, + 'home_backoff_steps': 200, + 'home_maxtravel_steps': 200000, + 'step_max_sps': 4000, + 'step_accel_sps2': 16000, + 'step_start_sps': 200, + 'limit_low': True, +} + + +class AuxAxisError(Exception): + pass + + +class AuxAxis(object): + def __init__(self, ctrl): + self.ctrl = ctrl + self.log = ctrl.log.get('AuxAxis') + + self._cfg = dict(DEFAULTS) + self._load_config() + + self._sp = None + self._sp_lock = threading.Lock() # serial write/RPC serialization + self._rx_lock = threading.Lock() # read-line buffer access + self._reader_thread = None + self._stop = threading.Event() + + # Pending replies waiting for a [topic] line. Single-slot since we + # serialize RPCs via _sp_lock. + self._pending_topics = [] + self._pending_replies = [] + self._pending_cv = threading.Condition() + + # Async lines that aren't replies (e.g. logs) are simply logged. + self._present = False + self._homed = False + self._pos_steps = 0 # ESP step counter mirror + + # Publish initial state + self._publish_state() + + if not self._cfg['enabled']: + self.log.info('Aux axis disabled in config') + return + + if serial is None: + self.log.error('pyserial not available; aux axis disabled') + return + + self._open() + + # ------------------------------------------------------------------ config + + def _config_path(self): + return self.ctrl.get_path(filename='aux.json') + + def _load_config(self): + path = self._config_path() + if os.path.exists(path): + try: + with open(path) as f: + user = json.load(f) + # Be permissive; ignore unknown keys. + for k, v in user.items(): + if k in self._cfg: + self._cfg[k] = v + self.log.info('Loaded aux config from %s' % path) + except Exception: + self.log.error('Failed to read aux.json: %s' + % traceback.format_exc()) + + def save_config(self, cfg): + merged = dict(DEFAULTS) + for k, v in cfg.items(): + if k in DEFAULTS: + merged[k] = v + path = self._config_path() + with open(path, 'w') as f: + json.dump(merged, f, indent=2) + self._cfg = merged + self.log.info('Saved aux config') + # Push the relevant pieces to the ESP if connected. + if self._present: + try: + self._push_homecfg() + except Exception as e: + self.log.warning('Could not push HOMECFG after save: %s' % e) + + def get_config(self): + return dict(self._cfg) + + # ------------------------------------------------------------------ public + + @property + def enabled(self): + return bool(self._cfg.get('enabled', False)) + + @property + def present(self): + return self._present + + @property + def homed(self): + return self._homed + + @property + def position_mm(self): + return self._steps_to_mm(self._pos_steps) + + def home(self): + """Run the homing cycle on the ESP. Blocks until done. Raises on + failure. Updates aux_homed and aux_pos.""" + self._require_present() + line = self._rpc('HOME', topic='home', timeout=120.0) + # line is the body after '[home] ' + if line.startswith('done'): + # ESP set its counter to home_zero; mirror that. + new_pos = self._parse_kv_int(line, 'pos', 0) + self._pos_steps = new_pos + self._homed = True + # Translate to home_position_mm. Conceptually the host says + # "after homing, W is here in mm". We achieve that by setting + # the ESP counter (WPOS) so the mm conversion works out. + target_pos = self._mm_to_steps(self._cfg['home_position_mm']) + if target_pos != new_pos: + self._rpc('WPOS %d' % target_pos, topic='ok', timeout=2.0) + self._pos_steps = target_pos + self._publish_state() + return + # failure + reason = line.split('reason=', 1)[1] if 'reason=' in line else line + raise AuxAxisError('Homing failed: %s' % reason) + + def move_abs_mm(self, target_mm): + """Move to absolute logical W position (mm). Blocks until done.""" + self._require_present() + self._check_limits(target_mm) + target_steps = self._mm_to_steps(target_mm) + delta = target_steps - self._pos_steps + if delta == 0: + return + self._do_steps(delta) + + def move_rel_mm(self, delta_mm): + """Move by delta mm relative to current position. Blocks until done.""" + self._require_present() + target_mm = self.position_mm + delta_mm + self._check_limits(target_mm) + target_steps = self._mm_to_steps(target_mm) + delta = target_steps - self._pos_steps + if delta == 0: + return + self._do_steps(delta) + + def set_position_mm(self, mm): + """Set current W to without moving (G92-style for W).""" + self._require_present() + steps = self._mm_to_steps(mm) + self._rpc('WPOS %d' % steps, topic='ok', timeout=2.0) + self._pos_steps = steps + # WPOS clears homed on the ESP; mirror it. + self._homed = False + self._publish_state() + + def jog_steps(self, steps): + """Raw step move bypassing mm conversion and soft limits. + Used by manual jog UI when axis isn't homed yet.""" + self._require_present() + if steps == 0: + return + self._do_steps(int(steps), ignore_limits=True) + + def abort(self): + """Cancel any running ESP motion immediately.""" + if not self._present: + return + try: + # Don't take the RPC lock; ABORT must be able to interrupt. + self._send_raw('ABORT') + except Exception as e: + self.log.warning('ABORT send failed: %s' % e) + + def close(self): + self._stop.set() + try: + if self._sp is not None: + self._sp.close() + except Exception: + pass + + # ------------------------------------------------------------------ guts + + def _require_present(self): + if not self.enabled: + raise AuxAxisError('Aux axis disabled') + if not self._present: + raise AuxAxisError('Aux axis not connected') + + def _check_limits(self, target_mm): + lo = float(self._cfg['min_w']) + hi = float(self._cfg['max_w']) + if hi <= lo: + return # no limits + if target_mm < lo - 1e-6 or target_mm > hi + 1e-6: + raise AuxAxisError( + 'W=%.3f out of soft limits [%.3f, %.3f]' % (target_mm, lo, hi)) + + def _mm_to_steps(self, mm): + spm = float(self._cfg['steps_per_mm']) + sign = 1 if int(self._cfg.get('dir_sign', 1)) >= 0 else -1 + return int(round(mm * spm * sign)) + + def _steps_to_mm(self, steps): + spm = float(self._cfg['steps_per_mm']) or 1.0 + sign = 1 if int(self._cfg.get('dir_sign', 1)) >= 0 else -1 + return (steps / spm) * sign + + def _do_steps(self, signed_count, ignore_limits=False): + max_rate = int(self._cfg['step_max_sps']) + accel = int(self._cfg['step_accel_sps2']) + safe_flag = 0 if ignore_limits else 1 + cmd = 'STEPS %d maxrate=%d accel=%d safe=%d' % ( + signed_count, max_rate, accel, safe_flag) + line = self._rpc(cmd, topic='step', timeout=300.0) + # line: "done count=N pos=P limit=L" or "aborted count=N pos=P [reason=...]" + if line.startswith('done'): + self._pos_steps = self._parse_kv_int(line, 'pos', self._pos_steps) + self._publish_state() + return + # aborted + self._pos_steps = self._parse_kv_int(line, 'pos', self._pos_steps) + self._publish_state() + reason = self._parse_kv_str(line, 'reason') + if reason == 'limit': + self._homed = False + raise AuxAxisError('W move aborted by limit switch') + raise AuxAxisError('W move aborted: %s' % line) + + # ------------------------------------------------------------ serial I/O + + def _open(self): + port = self._cfg['port'] + baud = int(self._cfg['baud']) + try: + self._sp = serial.Serial(port, baud, timeout=0.2) + except Exception as e: + self.log.error('Could not open %s: %s' % (port, e)) + self._sp = None + return + + self.log.info('Opened %s @ %d' % (port, baud)) + self._reader_thread = threading.Thread( + target=self._reader_loop, name='AuxAxis-rx', daemon=True) + self._reader_thread.start() + + # Give the ESP a moment to settle, then push HOMECFG and query state. + # This runs in a background thread to avoid blocking startup. + threading.Thread(target=self._on_connect, daemon=True).start() + + def _on_connect(self): + time.sleep(0.5) + try: + self._push_homecfg() + self._refresh_state() + except Exception as e: + self.log.warning('Aux post-connect setup failed: %s' % e) + + def _push_homecfg(self): + c = self._cfg + cmd = ('HOMECFG dir=%s fast=%d slow=%d backoff=%d maxtravel=%d ' + 'zero=0 accel=%d step_max=%d step_start=%d limit_low=%d') % ( + c['home_dir'], + int(c['home_fast_sps']), + int(c['home_slow_sps']), + int(c['home_backoff_steps']), + int(c['home_maxtravel_steps']), + int(c['step_accel_sps2']), + int(c['step_max_sps']), + int(c['step_start_sps']), + 1 if c['limit_low'] else 0, + ) + self._rpc(cmd, topic='homecfg', timeout=3.0) + + def _refresh_state(self): + try: + r = self._rpc('WPOS?', topic='wpos', timeout=2.0) + self._pos_steps = int(r.strip()) + except Exception: + pass + try: + r = self._rpc('HOMED?', topic='homed', timeout=2.0) + self._homed = (r.strip() == '1') + except Exception: + pass + self._publish_state() + + def _reader_loop(self): + buf = b'' + while not self._stop.is_set(): + sp = self._sp + if sp is None: + time.sleep(0.5) + continue + try: + chunk = sp.read(256) + except Exception as e: + self.log.warning('Aux serial read error: %s' % e) + time.sleep(0.5) + continue + if not chunk: + continue + buf += chunk + while True: + nl = buf.find(b'\n') + if nl < 0: + break + line = buf[:nl].rstrip(b'\r').decode('utf-8', errors='replace') + buf = buf[nl+1:] + self._on_line(line) + + def _on_line(self, line): + if not line: + return + # Boot banner -> reset homed flag. + if line.startswith('[boot]'): + self.log.warning('Aux ESP booted: %s' % line) + self._homed = False + self._present = True + self._publish_state() + self.ctrl.state.add_message( + 'W axis controller restarted - re-home before use') + return + + # Topic dispatch: "[topic] body..." + if line.startswith('[') and ']' in line: + rb = line.index(']') + topic = line[1:rb] + body = line[rb+1:].lstrip() + # Mark present on first known topic. + if not self._present: + self._present = True + self._publish_state() + # Match against the head of the pending queue. + with self._pending_cv: + if (self._pending_topics + and topic in self._pending_topics[0]): + # Pop and deliver + self._pending_topics.pop(0) + self._pending_replies.append(body) + self._pending_cv.notify_all() + return + # Async informational line; just log. + self.log.info('aux: %s' % line) + else: + self.log.info('aux: %s' % line) + + def _send_raw(self, cmd): + sp = self._sp + if sp is None: + raise AuxAxisError('Serial not open') + if not cmd.endswith('\n'): + cmd = cmd + '\n' + sp.write(cmd.encode('utf-8')) + sp.flush() + + def _rpc(self, cmd, topic, timeout=5.0): + """Send `cmd`, wait for a reply line whose topic is in `topic`. + topic may be a single string or a tuple/list of acceptable topics + (e.g. ('home', 'err')).""" + if isinstance(topic, str): + topics = (topic, 'err') + else: + topics = tuple(topic) + ('err',) + + with self._sp_lock: + with self._pending_cv: + self._pending_topics.append(topics) + self._pending_replies = [] # reset + self.log.info('aux >> %s' % cmd.strip()) + self._send_raw(cmd) + + deadline = time.time() + timeout + with self._pending_cv: + while not self._pending_replies: + remaining = deadline - time.time() + if remaining <= 0: + # Drop the pending slot so we don't capture a + # late reply meant for the next caller. + try: + self._pending_topics.remove(topics) + except ValueError: + pass + raise AuxAxisError( + 'Timeout waiting for %s reply to "%s"' + % (topics, cmd.strip())) + self._pending_cv.wait(timeout=remaining) + reply = self._pending_replies.pop(0) + self.log.info('aux << %s' % reply) + if reply.startswith('err') or reply.startswith('error'): + raise AuxAxisError('ESP error: %s' % reply) + return reply + + @staticmethod + def _parse_kv_int(line, key, default=0): + # Parse "key=N" (signed integer) out of a line. + for tok in line.split(): + if tok.startswith(key + '='): + try: + return int(tok.split('=', 1)[1]) + except ValueError: + return default + return default + + @staticmethod + def _parse_kv_str(line, key, default=''): + for tok in line.split(): + if tok.startswith(key + '='): + return tok.split('=', 1)[1] + return default + + # ------------------------------------------------------------ state push + + def _publish_state(self): + st = self.ctrl.state + try: + st.set('aux_present', bool(self._present)) + st.set('aux_homed', bool(self._homed)) + st.set('aux_pos', round(self.position_mm, 4)) + st.set('aux_enabled', bool(self.enabled)) + except Exception: + # During very early startup, state may not be ready. + pass diff --git a/src/py/bbctrl/AuxPreprocessor.py b/src/py/bbctrl/AuxPreprocessor.py new file mode 100644 index 0000000..5a1ae48 --- /dev/null +++ b/src/py/bbctrl/AuxPreprocessor.py @@ -0,0 +1,237 @@ +################################################################################ +# +# AuxPreprocessor - rewrite W-axis G-code into hook calls +# +# The bbctrl planner only understands xyzabc. We expose a virtual W axis by +# rewriting the G-code file *before* it is fed to gplan, replacing each W +# move with a (MSG,HOOK:aux:...) line that the host's hook handler turns +# into a STEPS or HOME command on the ESP. +# +# Rules: +# - Mixed-axis blocks (W together with XYZABC) are split into two +# sequential blocks. By default the W move runs first; configurable. +# - G90/G91/G20/G21 modal state is tracked so we can convert relative-W +# and inch-W into the absolute mm value the hook handler expects. +# - G28 W0 / G28.2 W0 -> HOOK:aux_home +# - G92 Wx -> HOOK:aux_setzero: +# - G53 + W not specially handled (W only knows machine coords) +# - Lines inside parentheses or after `;` are passed through. +# +# The preprocessor is intentionally conservative: anything it doesn't +# understand involving W is left alone with a warning, so motion lands in +# gplan which will complain loudly rather than silently misbehaving. +# +################################################################################ + +import os +import re +import shutil +import tempfile + + +# Match a word like "W12.5" or "W-3" or "w0". Also matches inside the same +# line as XYZ words. We pull W out specifically. +_W_TOKEN_RE = re.compile(r'(? token, preserving surrounding spaces. + rewritten = line[:m.start()] + line[m.end():] + return rewritten, m.group(1) + + def _has_other_axis(self, code_no_w): + return _AXIS_WORD_RE.search(code_no_w) is not None + + def _detect_modals(self, code, modal): + """Update modal dict in-place from G-codes on this line.""" + for mm in _MODAL_RE.finditer(code): + try: + g = float(mm.group(1)) + except ValueError: + continue + if g == 90: modal['abs'] = True + elif g == 91: modal['abs'] = False + elif g == 20: modal['inch'] = True + elif g == 21: modal['inch'] = False + # G28 / G28.2 / G92 are detected case-by-case below. + + @staticmethod + def _is_g28_like(code): + # Match G28 or G28.2 (homing). + return bool(re.search(r'(? aux_home (W value is ignored except as + # a flag that W is being homed). + if self._is_g28_like(code): + code_no_w, _ = self._strip_w(line) + fout.write('(MSG,HOOK:aux_home:)\n') + # Only keep the residual line if other axes were also + # present (e.g. G28.2 X0 Y0 W0 still homes X+Y). A bare + # "G28" without axis args means "home all" in gcode + # which we explicitly DON'T want to trigger from a + # W-only home command. + rest_code = _PAREN_COMMENT_RE.sub('', code_no_w) + rest_code = rest_code.split(';', 1)[0] + if self._has_other_axis(rest_code): + fout.write(code_no_w + '\n') + continue + + # G92 W... -> set W zero (or other value) without motion. + if self._is_g92(code): + line_no_w, w_val = self._strip_w(line) + target_mm = self._w_to_mm(w_val, modal, set_pos=True) + fout.write('(MSG,HOOK:aux_setzero:%g)\n' % target_mm) + rest_code = _PAREN_COMMENT_RE.sub('', line_no_w) + rest_code = rest_code.split(';', 1)[0] + if self._has_other_axis(rest_code): + fout.write(line_no_w + '\n') + continue + + # Plain motion: G0/G1 etc with W word. + line_no_w, w_val = self._strip_w(line) + target_mm = self._w_to_mm(w_val, modal, set_pos=False) + # Distinguish absolute vs relative: encode both, the hook + # handler will pick the right operation. + if modal['abs']: + hook_line = '(MSG,HOOK:aux:%g)' % target_mm + else: + hook_line = '(MSG,HOOK:aux_rel:%g)' % target_mm + + rest_code = _PAREN_COMMENT_RE.sub('', line_no_w) + rest_code = rest_code.split(';', 1)[0] + has_xyz = self._has_other_axis(rest_code) + + if not has_xyz: + # Pure W move; drop the (now-empty) original line. + fout.write(hook_line + '\n') + continue + + # Mixed-axis: split. Default order is W first. + if self.w_first: + fout.write(hook_line + '\n') + fout.write(line_no_w + '\n') + else: + fout.write(line_no_w + '\n') + fout.write(hook_line + '\n') + + return rewrote_any + + # ------------------------------------------------------------ unit conv + + def _w_to_mm(self, w_str, modal, set_pos): + try: + v = float(w_str) + except (TypeError, ValueError): + raise AuxPreprocessorError('Invalid W value: %r' % w_str) + if modal['inch']: + v *= 25.4 + return v + + +def preprocess_file(src_path, log=None, w_first=True): + """Convenience: rewrite src_path in place if it uses W. + Returns True if the file was rewritten.""" + if not AuxPreprocessor.file_uses_w(src_path): + return False + pre = AuxPreprocessor(log=log, w_first=w_first) + fd, tmp = tempfile.mkstemp(prefix='auxpre_', suffix='.nc', + dir=os.path.dirname(src_path) or None) + os.close(fd) + try: + rewrote = pre.process(src_path, tmp) + if rewrote: + shutil.move(tmp, src_path) + return True + os.unlink(tmp) + return False + except Exception: + try: + os.unlink(tmp) + except OSError: + pass + raise diff --git a/src/py/bbctrl/Ctrl.py b/src/py/bbctrl/Ctrl.py index 16bd39d..f1e0e18 100644 --- a/src/py/bbctrl/Ctrl.py +++ b/src/py/bbctrl/Ctrl.py @@ -60,6 +60,8 @@ class Ctrl(object): if not args.demo: self.jog = bbctrl.Jog(self) self.pwr = bbctrl.Pwr(self) self.hooks = bbctrl.Hooks(self) + self.aux = bbctrl.AuxAxis(self) + self._register_aux_hooks() self.mach.connect() @@ -110,8 +112,46 @@ class Ctrl(object): self.preplanner.start() + def _register_aux_hooks(self): + """Wire up the auxcnc HOOK: events to AuxAxis methods.""" + log = self.log.get('AuxAxis') + + def _hook_move(ctx): + data = (ctx.get('data') or '').strip() + if not data: + raise Exception('aux hook missing target') + self.aux.move_abs_mm(float(data)) + + def _hook_move_rel(ctx): + data = (ctx.get('data') or '').strip() + if not data: + raise Exception('aux_rel hook missing delta') + self.aux.move_rel_mm(float(data)) + + def _hook_home(ctx): + self.aux.home() + + def _hook_setzero(ctx): + data = (ctx.get('data') or '').strip() + mm = float(data) if data else 0.0 + self.aux.set_position_mm(mm) + + self.hooks.register_internal('aux', _hook_move, + block_unpause=True, auto_resume=True) + self.hooks.register_internal('aux_rel', _hook_move_rel, + block_unpause=True, auto_resume=True) + self.hooks.register_internal('aux_home', _hook_home, + block_unpause=True, auto_resume=True, + timeout=180) + self.hooks.register_internal('aux_setzero', _hook_setzero, + block_unpause=True, auto_resume=True) + log.info('Aux hooks registered') + + def close(self): self.log.get('Ctrl').info('Closing %s' % self.id) self.ioloop.close() self.avr.close() self.mach.planner.close() + try: self.aux.close() + except Exception: pass diff --git a/src/py/bbctrl/FileHandler.py b/src/py/bbctrl/FileHandler.py index 3e4b144..ca44cda 100644 --- a/src/py/bbctrl/FileHandler.py +++ b/src/py/bbctrl/FileHandler.py @@ -99,6 +99,19 @@ class FileHandler(bbctrl.APIHandler): del (self.uploadFile) + # If the uploaded G-code uses the virtual W axis, rewrite the + # file in place so the planner sees (MSG,HOOK:aux:*) lines + # instead of W tokens it can't parse. + try: + from bbctrl.AuxPreprocessor import preprocess_file + log = self.get_log('AuxPreprocessor') + if preprocess_file(filename.decode('utf8'), log=log): + log.info('Rewrote W-axis tokens in %s' % + self.uploadFilename) + except Exception: + self.get_log('AuxPreprocessor').exception( + 'W-axis preprocess failed; uploading unchanged') + self.get_ctrl().preplanner.invalidate(self.uploadFilename) self.get_ctrl().state.add_file(self.uploadFilename) diff --git a/src/py/bbctrl/Hooks.py b/src/py/bbctrl/Hooks.py index d0853c5..4708a47 100644 --- a/src/py/bbctrl/Hooks.py +++ b/src/py/bbctrl/Hooks.py @@ -91,12 +91,19 @@ class Hooks: 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() @@ -191,10 +198,19 @@ class Hooks: new_state = state.get('xx', '') if new_state != self._last_state: if new_state == 'ESTOPPED': - # Cancel any running hook on estop + # 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', {}) @@ -207,25 +223,60 @@ class Hooks: 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) + # 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.""" - hook = self.hooks.get(event) - if custom_name and not hook: - hook = self.hooks.get(custom_name) + # 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 @@ -298,13 +349,18 @@ class Hooks: self.log.error('Auto-resume failed: %s' % e) def _execute_hook(self, hook, context): - """Execute a single hook (webhook or script). May block.""" + """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) diff --git a/src/py/bbctrl/Mach.py b/src/py/bbctrl/Mach.py index f8a01a3..b5a79e6 100644 --- a/src/py/bbctrl/Mach.py +++ b/src/py/bbctrl/Mach.py @@ -256,6 +256,9 @@ class Mach(Comm): if cmd[0] == '$': self._query_var(cmd) elif cmd[0] == '\\': super().queue_command(cmd[1:]) else: + # Rewrite W-axis tokens in MDI input the same way the + # FileHandler rewrites uploaded files. + cmd = self._rewrite_w_mdi(cmd) self._begin_cycle('mdi') self.planner.mdi(cmd, with_limits) super().resume() @@ -263,6 +266,35 @@ class Mach(Comm): self.mlog.info("Exception during MDI: %s" % err) pass + def _rewrite_w_mdi(self, cmd): + """Apply the W-axis preprocessor to a single MDI line. Returns + possibly-multi-line G-code with HOOK: comments inserted.""" + try: + from bbctrl.AuxPreprocessor import AuxPreprocessor, _W_TOKEN_RE + if not _W_TOKEN_RE.search(cmd): + return cmd + import io, tempfile, os + # AuxPreprocessor.process is file-based; route through + # tempfiles so we don't fork the regex/state logic. + pre = AuxPreprocessor(log=self.mlog) + with tempfile.NamedTemporaryFile('w', suffix='.nc', + delete=False) as fi: + fi.write(cmd if cmd.endswith('\n') else cmd + '\n') + ipath = fi.name + opath = ipath + '.out' + try: + pre.process(ipath, opath) + rewritten = open(opath).read() + finally: + try: os.unlink(ipath) + except OSError: pass + try: os.unlink(opath) + except OSError: pass + return rewritten + except Exception as e: + self.mlog.warning('W-axis MDI rewrite failed: %s' % e) + return cmd + def set(self, code, value): super().queue_command('${}={}'.format(code, value)) diff --git a/src/py/bbctrl/Web.py b/src/py/bbctrl/Web.py index d7a7e7b..e4c0557 100644 --- a/src/py/bbctrl/Web.py +++ b/src/py/bbctrl/Web.py @@ -787,6 +787,72 @@ class HooksFireHandler(bbctrl.APIHandler): self.get_ctrl().hooks._fire(event, data) +# ----- W axis (auxcnc) endpoints -------------------------------------------- + +class AuxConfigGetHandler(bbctrl.APIHandler): + def get(self): + self.write_json(self.get_ctrl().aux.get_config()) + + +class AuxConfigSaveHandler(bbctrl.APIHandler): + def put_ok(self): + self.get_ctrl().aux.save_config(self.json or {}) + + +class AuxStatusHandler(bbctrl.APIHandler): + def get(self): + aux = self.get_ctrl().aux + self.write_json({ + 'enabled': aux.enabled, + 'present': aux.present, + 'homed': aux.homed, + 'pos_mm': aux.position_mm, + }) + + +class AuxHomeHandler(bbctrl.APIHandler): + def put_ok(self): + # Run synchronously via the AuxAxis' own RPC; this blocks the + # request. Fine because the UI shows a spinner. + self.get_ctrl().aux.home() + + +class AuxAbortHandler(bbctrl.APIHandler): + def put_ok(self): + self.get_ctrl().aux.abort() + + +class AuxJogHandler(bbctrl.APIHandler): + """Body: {"mm": 1.5} for relative-mm move, + {"steps": 200} for raw step move (bypasses soft limits).""" + def put_ok(self): + body = self.json or {} + aux = self.get_ctrl().aux + if 'mm' in body: + aux.move_rel_mm(float(body['mm'])) + elif 'steps' in body: + aux.jog_steps(int(body['steps'])) + else: + raise HTTPError(400, 'mm or steps required') + + +class AuxMoveHandler(bbctrl.APIHandler): + """Body: {"mm": 12.5} absolute move in mm.""" + def put_ok(self): + body = self.json or {} + if 'mm' not in body: + raise HTTPError(400, 'mm required') + self.get_ctrl().aux.move_abs_mm(float(body['mm'])) + + +class AuxSetZeroHandler(bbctrl.APIHandler): + """Body: {"mm": 0} set current position to .""" + def put_ok(self): + body = self.json or {} + mm = float(body.get('mm', 0.0)) + self.get_ctrl().aux.set_position_mm(mm) + + class RemoteDiagnosticsHandler(bbctrl.APIHandler): def get(self): @@ -966,6 +1032,14 @@ class Web(tornado.web.Application): (r'/api/hooks/save', HooksSaveHandler), (r'/api/hooks/status', HooksStatusHandler), (r'/api/hooks/fire/([\w-]+)', HooksFireHandler), + (r'/api/aux/config', AuxConfigGetHandler), + (r'/api/aux/config/save', AuxConfigSaveHandler), + (r'/api/aux/status', AuxStatusHandler), + (r'/api/aux/home', AuxHomeHandler), + (r'/api/aux/abort', AuxAbortHandler), + (r'/api/aux/jog', AuxJogHandler), + (r'/api/aux/move', AuxMoveHandler), + (r'/api/aux/set-zero', AuxSetZeroHandler), (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 db3cd07..1c1c4d6 100644 --- a/src/py/bbctrl/__init__.py +++ b/src/py/bbctrl/__init__.py @@ -60,6 +60,7 @@ from bbctrl.AVREmu import AVREmu from bbctrl.IOLoop import IOLoop from bbctrl.MonitorTemp import MonitorTemp from bbctrl.Hooks import Hooks +from bbctrl.AuxAxis import AuxAxis import bbctrl.Cmd as Cmd import bbctrl.v4l2 as v4l2 import bbctrl.Log as log From 4f74e75d44a7d44c2ed78bc8d6f85ab3690a4d93 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Apr 2026 17:10:00 +0200 Subject: [PATCH 12/20] UI: render W (aux) axis row in the main DRO Adds the auxcnc W axis to the front-page Position table: - axis-vars.js exposes a 'w' computed property fed by state.aux_pos / aux_enabled / aux_homed / aux_present (set by AuxAxis on the host). No motor mapping, no soft-limit warnings - the aux controller does its own bounds. - control-view.pug adds a W row after the xyzabc loop. The Set/Zero button calls /api/aux/set-zero {mm:0} and the Home button calls /api/aux/home, which hit the new endpoints exposed by Web.py. - control-view.js: aux_home(), aux_set_zero(), and aux_jog() helpers. When aux_enabled is false (no aux.json or aux.json has enabled=false) the row stays hidden, matching the existing axis-row behavior. --- src/js/axis-vars.js | 50 ++++++++++++++++++++++++++++++ src/js/control-view.js | 18 +++++++++++ src/pug/templates/control-view.pug | 22 +++++++++++++ 3 files changed, 90 insertions(+) diff --git a/src/js/axis-vars.js b/src/js/axis-vars.js index 344a497..abe96c0 100644 --- a/src/js/axis-vars.js +++ b/src/js/axis-vars.js @@ -32,6 +32,10 @@ module.exports = { return this._compute_axis("c"); }, + w: function() { + return this._compute_aux_axis(); + }, + axes: function() { return this._compute_axes(); } @@ -203,6 +207,52 @@ module.exports = { return false; }, + _compute_aux_axis: function() { + // Virtual W axis driven by the auxcnc ESP32. Position, homed + // flag and presence come from the bbctrl AuxAxis driver via + // state.aux_*. No motor mapping, no soft-limit warnings on + // toolpath bounds (auxcnc enforces its own). + const enabled = !!this.state.aux_enabled; + const present = !!this.state.aux_present; + const homed = !!this.state.aux_homed; + const pos = this.state.aux_pos || 0; + + let klass = `${homed ? "homed" : "unhomed"} axis-w`; + let state = present ? "UNHOMED" : "OFFLINE"; + let icon = present ? "question-circle" : "plug"; + let title = present + ? "Click the home button to home W axis." + : "Aux controller not connected on /dev/ttyUSB0."; + if (homed) { + state = "HOMED"; + icon = "check-circle"; + title = "W axis successfully homed."; + } else if (!present) { + klass += " error"; + } + + return { + pos: pos, + abs: pos, + off: 0, + min: 0, max: 0, dim: 0, + pathMin: 0, pathMax: 0, pathDim: 0, + motor: -1, + enabled: enabled, + homingMode: "limit-switch", + homed: homed, + klass: klass, + state: state, + icon: icon, + title: title, + ticon: "check-circle", + tstate: "OK", + toolmsg: "W axis is not constrained by tool path bounds.", + tklass: `${homed ? "homed" : "unhomed"} axis-w`, + isAux: true, + }; + }, + _compute_axes: function() { let homed = false; diff --git a/src/js/control-view.js b/src/js/control-view.js index a22de0b..0fc9e3b 100644 --- a/src/js/control-view.js +++ b/src/js/control-view.js @@ -765,6 +765,24 @@ module.exports = { api.put(`home/${axis}/clear`); }, + aux_home: function () { + api.put("aux/home").catch(function (err) { + console.error("W home failed:", err); + }); + }, + + aux_set_zero: function () { + api.put("aux/set-zero", { mm: 0 }).catch(function (err) { + console.error("W set-zero failed:", err); + }); + }, + + aux_jog: function (delta_mm) { + api.put("aux/jog", { mm: delta_mm }).catch(function (err) { + console.error("W jog failed:", err); + }); + }, + show_set_position: function (axis) { SvelteComponents.showDialog("SetAxisPosition", { axis }); }, diff --git a/src/pug/templates/control-view.pug b/src/pug/templates/control-view.pug index 06077d3..84470c6 100644 --- a/src/pug/templates/control-view.pug +++ b/src/pug/templates/control-view.pug @@ -191,6 +191,28 @@ script#control-view-template(type="text/x-template") button.pure-button(:disabled="!is_idle", @click=`home('${axis}')`, title=`Home {{'${axis}' | upper}} axis.`, style="height:60px;width:60px") .fa.fa-home + + // Auxiliary W axis (auxcnc ESP32 over /dev/ttyUSB0) + tr.axis(:class="w.klass", v-if="w.enabled", :title="w.title") + th.name w + td.position: unit-value(:value="w.pos", precision=4) + td.absolute: unit-value(:value="w.abs", precision=3) + td.offset — + td.state + .fa(:class="'fa-' + w.icon") + | {{w.state}} + td.tstate(:class="w.tklass", :title="w.toolmsg") + .fa(:class="'fa-' + w.ticon") + | {{w.tstate}} + + th.actions + button.pure-button(title="Set W axis position to 0.", + @click="aux_set_zero()", style="height:60px;width:60px") + .fa.fa-map-marker + + button.pure-button(:disabled="!w.enabled", @click="aux_home()", + title="Home W axis.", style="height:60px;width:60px") + .fa.fa-home tr(style="vertical-align: top;") td From 23f22105a83057c969ebb327320007471e25dd2c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Apr 2026 17:23:55 +0200 Subject: [PATCH 13/20] UI: align W axis marker/home buttons with the X/Y/Z columns The xyzabc rows have three actions (set-position cog, zero marker, home), W only has two. Without a placeholder the W buttons render in the left two slots of the actions cell, leaving the home button unaligned with the home column above. Added a hidden disabled cog button so the marker and home buttons sit under the same columns as the rest. --- src/pug/templates/control-view.pug | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/pug/templates/control-view.pug b/src/pug/templates/control-view.pug index 84470c6..cdc42dc 100644 --- a/src/pug/templates/control-view.pug +++ b/src/pug/templates/control-view.pug @@ -206,6 +206,13 @@ script#control-view-template(type="text/x-template") | {{w.tstate}} th.actions + // Invisible placeholder so the W marker/home buttons line + // up under the same columns as the X/Y/Z buttons above + // (which have a third "set position" cog). + button.pure-button(disabled, + style="height:60px;width:60px;visibility:hidden") + .fa.fa-cog + button.pure-button(title="Set W axis position to 0.", @click="aux_set_zero()", style="height:60px;width:60px") .fa.fa-map-marker From 7d5949f5fc30200ef26f1a4eded5fc317c8d3efe Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Apr 2026 17:37:49 +0200 Subject: [PATCH 14/20] UI: add W jog row (W- / W Origin / W+ / Home W) under the XYZ jog grid Mirrors the 4-column rotary A row that appears when 2an==3, so the same fine/small/medium/large increment selector that drives XYZ jogging now also drives W jogging. New control-view methods: - aux_jog_incr(sign) - PUTs aux/jog with the current jog_incr amount converted to mm (handles imperial display units) - aux_move_zero() - PUTs aux/move {mm:0}, the absolute counterpart to aux_set_zero (which redefines the current pos as zero without moving) Row is hidden when w.enabled is false, so users without the auxcnc controller see no change. --- src/js/control-view.js | 17 +++++++++++++++++ src/pug/templates/control-view.pug | 29 +++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/src/js/control-view.js b/src/js/control-view.js index 0fc9e3b..b134bcc 100644 --- a/src/js/control-view.js +++ b/src/js/control-view.js @@ -783,6 +783,23 @@ module.exports = { }); }, + // Use the same fine/small/medium/large increment buttons as the XYZ + // jog grid. sign=+1 for W+, -1 for W-. + aux_jog_incr: function (sign) { + const amount = + this.jog_incr_amounts[this.display_units][this.jog_incr]; + const delta_mm = sign * (this.metric ? amount : amount * 25.4); + this.aux_jog(delta_mm); + }, + + // "W Origin" - move W to 0 (absolute). Distinct from aux_set_zero, + // which sets the *current* position as the new zero without moving. + aux_move_zero: function () { + api.put("aux/move", { mm: 0 }).catch(function (err) { + console.error("W move-to-zero failed:", err); + }); + }, + show_set_position: function (axis) { SvelteComponents.showDialog("SetAxisPosition", { axis }); }, diff --git a/src/pug/templates/control-view.pug b/src/pug/templates/control-view.pug index cdc42dc..ee0df14 100644 --- a/src/pug/templates/control-view.pug +++ b/src/pug/templates/control-view.pug @@ -107,6 +107,35 @@ script#control-view-template(type="text/x-template") button(:style="getJogIncrStyle('large')", @click="jog_incr = 'large'") span {{jog_incr_amounts[display_units].large}}#[span.jog-units {{metric ? 'mm' : 'in'}}] + // W axis jog row (auxcnc). Only shown when the aux controller + // is enabled in aux.json. + tr(v-if="w.enabled") + td(style="height:100px", align="center", colspan="1") + button(@click="aux_jog_incr(-1)", + :disabled="!w.enabled", + style="display:grid;justify-content:center;align-items:center;padding:14px;") + | W- + .fa.fa-arrow-down + + td(style="height:100px", align="center", colspan="1") + button(@click="aux_move_zero()", :disabled="!w.enabled") + | W + br + | Origin + + td(style="height:100px", align="center", colspan="1") + button(@click="aux_jog_incr(+1)", + :disabled="!w.enabled", + style="display:grid;justify-content:center;align-items:center;padding:14px;") + | W+ + .fa.fa-arrow-up + + td(style="height:100px", align="center", colspan="1") + button(@click="aux_home()", :disabled="!w.enabled") + | Home + br + | W + tr(v-if="state['2an'] == 3") td(style="height:100px", align="center", colspan="1") button(@click="show_probe_dialog=true") From 2413fc49ab68e17c5f319936906b72b94a746451 Mon Sep 17 00:00:00 2001 From: Henrik Muehe Date: Thu, 30 Apr 2026 19:07:17 +0200 Subject: [PATCH 15/20] UI: collapse W axis to home-only (drop set-zero / W-origin) The W axis homing already drives toward the configured limit (home_dir in aux.json, default '-') and lands at home_position_mm = 0, so 'home' and 'zero' are the same point. Remove the now-redundant 'W Origin' (move-to-zero) and 'Set W to 0' map-marker buttons; keep just W-, W+, and a single Home W button. Also drop the unused aux_move_zero / aux_set_zero JS handlers. --- src/js/control-view.js | 14 -------------- src/pug/templates/control-view.pug | 25 +++++++++++-------------- 2 files changed, 11 insertions(+), 28 deletions(-) diff --git a/src/js/control-view.js b/src/js/control-view.js index b134bcc..a9c23f6 100644 --- a/src/js/control-view.js +++ b/src/js/control-view.js @@ -771,12 +771,6 @@ module.exports = { }); }, - aux_set_zero: function () { - api.put("aux/set-zero", { mm: 0 }).catch(function (err) { - console.error("W set-zero failed:", err); - }); - }, - aux_jog: function (delta_mm) { api.put("aux/jog", { mm: delta_mm }).catch(function (err) { console.error("W jog failed:", err); @@ -792,14 +786,6 @@ module.exports = { this.aux_jog(delta_mm); }, - // "W Origin" - move W to 0 (absolute). Distinct from aux_set_zero, - // which sets the *current* position as the new zero without moving. - aux_move_zero: function () { - api.put("aux/move", { mm: 0 }).catch(function (err) { - console.error("W move-to-zero failed:", err); - }); - }, - show_set_position: function (axis) { SvelteComponents.showDialog("SetAxisPosition", { axis }); }, diff --git a/src/pug/templates/control-view.pug b/src/pug/templates/control-view.pug index ee0df14..4393835 100644 --- a/src/pug/templates/control-view.pug +++ b/src/pug/templates/control-view.pug @@ -108,7 +108,9 @@ script#control-view-template(type="text/x-template") span {{jog_incr_amounts[display_units].large}}#[span.jog-units {{metric ? 'mm' : 'in'}}] // W axis jog row (auxcnc). Only shown when the aux controller - // is enabled in aux.json. + // is enabled in aux.json. We treat home == 0 for the W axis, + // so there is no separate "set zero" / "W origin" button - + // just W-, W+, and Home. tr(v-if="w.enabled") td(style="height:100px", align="center", colspan="1") button(@click="aux_jog_incr(-1)", @@ -117,12 +119,6 @@ script#control-view-template(type="text/x-template") | W- .fa.fa-arrow-down - td(style="height:100px", align="center", colspan="1") - button(@click="aux_move_zero()", :disabled="!w.enabled") - | W - br - | Origin - td(style="height:100px", align="center", colspan="1") button(@click="aux_jog_incr(+1)", :disabled="!w.enabled", @@ -130,8 +126,9 @@ script#control-view-template(type="text/x-template") | W+ .fa.fa-arrow-up - td(style="height:100px", align="center", colspan="1") - button(@click="aux_home()", :disabled="!w.enabled") + td(style="height:100px", align="center", colspan="2") + button(@click="aux_home()", :disabled="!w.enabled", + style="height:100px;width:200px") | Home br | W @@ -235,15 +232,15 @@ script#control-view-template(type="text/x-template") | {{w.tstate}} th.actions - // Invisible placeholder so the W marker/home buttons line - // up under the same columns as the X/Y/Z buttons above - // (which have a third "set position" cog). + // Invisible placeholders so the W home button lines up + // under the X/Y/Z home column. The W axis has no "set + // position" cog and no "zero offset" marker - home == 0. button.pure-button(disabled, style="height:60px;width:60px;visibility:hidden") .fa.fa-cog - button.pure-button(title="Set W axis position to 0.", - @click="aux_set_zero()", style="height:60px;width:60px") + button.pure-button(disabled, + style="height:60px;width:60px;visibility:hidden") .fa.fa-map-marker button.pure-button(:disabled="!w.enabled", @click="aux_home()", From 36829020a560a8705025d6364830cb5d0921a02e Mon Sep 17 00:00:00 2001 From: Henrik Muehe Date: Thu, 30 Apr 2026 19:10:24 +0200 Subject: [PATCH 16/20] Settings: add W axis (auxcnc) panel Expose the aux.json fields under a new 'W Axis (auxcnc)' section in Settings: serial port/baud, mechanics (steps/mm, dir sign, soft limits, max feed), homing (direction, position, fast/slow seek, backoff, max travel, limit polarity) and the step profile (max/start rate, accel). The 'enabled' flag stays read-only in the UI; flipping the W axis on/off is still done via aux.json so a fresh install can't surprise the user with hardware that isn't there. Live status (offline / unhomed / homed at mm) is shown above the form. Saving PUTs the merged config to /api/aux/config/save, which writes aux.json and pushes the homing/step config to the ESP. --- .../src/components/SettingsView.svelte | 6 + .../src/components/WAxisSettings.svelte | 262 ++++++++++++++++++ 2 files changed, 268 insertions(+) create mode 100644 src/svelte-components/src/components/WAxisSettings.svelte diff --git a/src/svelte-components/src/components/SettingsView.svelte b/src/svelte-components/src/components/SettingsView.svelte index f7c295e..c2a32f6 100644 --- a/src/svelte-components/src/components/SettingsView.svelte +++ b/src/svelte-components/src/components/SettingsView.svelte @@ -2,6 +2,7 @@ import configTemplate from "../../../resources/config-template.json"; import ScreenRotationDialog from "$dialogs/ScreenRotationDialog.svelte"; import ConfigTemplatedInput from "./ConfigTemplatedInput.svelte"; + import WAxisSettings from "./WAxisSettings.svelte"; import SetTimeDialog from "$dialogs/SetTimeDialog.svelte"; import Button, { Label } from "@smui/button"; @@ -94,6 +95,11 @@ {/each} +

W Axis (auxcnc)

+
+ +
+

Path Accuracy

diff --git a/src/svelte-components/src/components/WAxisSettings.svelte b/src/svelte-components/src/components/WAxisSettings.svelte new file mode 100644 index 0000000..e5c4959 --- /dev/null +++ b/src/svelte-components/src/components/WAxisSettings.svelte @@ -0,0 +1,262 @@ + + +
+ {#if !cfg} +

Loading W axis configuration...

+ {:else} +
+ {#if status} + + Status: + {#if !status.enabled} + disabled + {:else if !status.present} + offline + {:else if status.homed} + homed at {status.pos_mm.toFixed(3)} mm + {:else} + connected, unhomed + {/if} + + {/if} +
+ +
+
+
+ + + +
+ +
+ + +
+ +
+ + +
+
+ +

Mechanics

+
+
+ + + +
+ +
+ + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+
+ +

Homing

+
+
+ + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + +
+
+ +

Step Profile

+
+
+ + + +
+ +
+ + + +
+ +
+ + + +
+
+ +
+ + {#if saveMessage} + {saveMessage} + {/if} +
+ +
+ Changes are written to aux.json. Homing rates and the + limit polarity are pushed to the ESP immediately; any + running motion is unaffected. Re-home the W axis after + changing direction, sign, or step settings. +
+
+ {/if} +
+ + From ef78f20eaa9501dc496acfb18b416500bfa9dcc6 Mon Sep 17 00:00:00 2001 From: Henrik Muehe Date: Thu, 30 Apr 2026 19:54:30 +0200 Subject: [PATCH 17/20] Docs: README + W axis docs cover macOS build/flash and new UI - README.md (was a one-liner): describe the layout, the macOS quick path including the esbuild platform-pin gotcha, and how to flash with curl or 'make update'. - docs/AUX_W_AXIS.md: document the new Control jog row layout, the Settings 'W Axis (auxcnc)' section, and list the additional UI files touched by this fork. --- README.md | 123 ++++++++++++++++++++++++++++++++++++++++++++- docs/AUX_W_AXIS.md | 30 ++++++++++- 2 files changed, 151 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8988acf..90e6554 100644 --- a/README.md +++ b/README.md @@ -1 +1,122 @@ -#OneFinity CNC Controller Firmware +# OneFinity CNC Controller Firmware (W-axis fork) + +This is the OneFinity / Buildbotics bbctrl firmware with a virtual W +axis driven by an auxcnc ESP32 over USB serial. See +[docs/AUX_W_AXIS.md](docs/AUX_W_AXIS.md) for the design and config. + +## Layout + +``` +src/avr/ AVR firmware (motion controller, AtxMega) +src/boot/ AVR bootloader +src/bbserial/ Linux kernel module for the bbserial driver +src/py/bbctrl/ Python control daemon (Tornado + websockets) +src/js/ Vue.js UI (legacy) +src/svelte-components/ Newer Svelte UI for dialogs and settings +src/pug/ Pug templates compiled into build/http/index.html +src/resources/ Static assets and config templates +scripts/ Install / update / RPi build helpers +docs/ Architecture, dev setup, W-axis docs +``` + +## Build & flash (quick path, macOS or Linux) + +The full build (`make`) requires `avr-gcc`, but the controller and UI +only depend on the Python + web parts. If you're shipping a UI/Python +change you don't need the AVR toolchain. + +### Prerequisites + +- Node.js (any recent LTS) with npm +- Python 3 with setuptools +- `npm install` once at the project root (this is wired into the + `node_modules` Make target, but on a fresh checkout it's clearer to + do it explicitly) + +```bash +npm install +(cd src/svelte-components && npm install) +``` + +#### macOS gotcha: esbuild platform pin + +The Pi build leaves `node_modules/esbuild` pinned to +`linux-arm64`, which won't run on Darwin. If `npm run build` inside +`src/svelte-components` complains about esbuild, reinstall it for the +host: + +```bash +cd src/svelte-components +rm -rf node_modules/esbuild +npm install esbuild@0.14.49 --no-save +``` + +(Use the version that matches `package-lock.json`.) + +### Build the web UI + Python sdist + +```bash +# Build the Svelte components +(cd src/svelte-components && npm run build) + +# Render pug templates and copy assets into build/http +make all # AVR step will fail without avr-gcc; safe to ignore + # if you didn't change anything under src/avr or src/boot + +# Package +./setup.py sdist +ls dist/bbctrl-*.tar.bz2 +``` + +`make pkg` is the canonical target but it tries to build AVR first. On +hosts without avr-gcc, run the steps above directly. + +If `bbctrl-*.tar.bz2` is missing `src/bbserial/bbserial.ko`, copy the +prebuilt `.ko` from a previous official release into `src/bbserial/` +before running `setup.py sdist` (the install script on the controller +just installs the existing module if a newer one isn't shipped). + +### Flash to a controller + +```bash +curl -X PUT -H "Content-Type: multipart/form-data" \ + -F "firmware=@dist/bbctrl-1.6.7.tar.bz2" \ + -F "password=onefinity" \ + http://onefinity.local/api/firmware/update +``` + +…or use the Make target: + +```bash +make update HOST=onefinity.local PASSWORD=onefinity +``` + +The controller stops bbctrl, untars the package, runs +`scripts/install.sh`, and brings the service back up. Total downtime +is ~30-45s. Watch progress at `http:///` (you'll get 404s while +bbctrl restarts, then the new UI). + +### Verify the flash + +```bash +curl -s http://onefinity.local/ | grep -c "OneFinity" +curl -s http://onefinity.local/api/aux/status # if W axis is enabled +``` + +## Build & flash (full path, Debian/Linux) + +For AVR + GPlan rebuilds, see [docs/development.md](docs/development.md). +That path uses qemu + chroot to cross-compile gplan for ARM and needs +the `gcc-avr` / `avr-libc` toolchain. + +## W axis (auxcnc) + +This fork adds a virtual W axis. See +[docs/AUX_W_AXIS.md](docs/AUX_W_AXIS.md) for: + +- G-code surface (`G28 W0`, `G1 W25`, etc.) +- The G-code preprocessor and hook architecture +- aux.json keys +- REST API (`/api/aux/*`) +- UI surface (jog row in Control, settings panel in Settings) +- Edge cases (ESP reboot mid-job, limit closed at home start, …) diff --git a/docs/AUX_W_AXIS.md b/docs/AUX_W_AXIS.md index 5ccd9a0..2948cf2 100644 --- a/docs/AUX_W_AXIS.md +++ b/docs/AUX_W_AXIS.md @@ -100,7 +100,30 @@ persisted there in NVS. Steps-mode jog ignores soft limits (use it to inch the axis to the limit switch when the axis isn't homed yet). -## State surface (UI) +## UI + +**Control view** + +- A jog row appears under the XYZ jog grid when `aux_enabled` is true, + with three buttons: `W-`, `W+`, and a wide `Home W`. There is + intentionally no separate "set zero" or "W origin" button - homing + lands the axis at `home_position_mm` (0 by default), so home and + zero are the same point. +- The DRO table shows a W axis row with position, status (OFFLINE / + UNHOMED / HOMED), and a single Home button in the actions column + (the cog and map-marker columns are placeholders for layout). + +**Settings view** + +A "W Axis (auxcnc)" section exposes every aux.json field except +`enabled` (which stays read-only - flipping the W axis on/off requires +editingaux.json on the controller, so a fresh install can't surprise +the user with hardware that isn't there). Saving PUTs the merged +config to `/api/aux/config/save`, which writes aux.json and pushes +`HOMECFG` to the ESP. A status line shows whether the axis is +disabled / offline / connected-unhomed / homed at ` mm`. + +## State surface These are pushed via `state.set` and visible in the websocket stream: @@ -139,6 +162,11 @@ These are pushed via `state.set` and visible in the websocket stream: - `src/py/bbctrl/FileHandler.py`: rewrite uploads in place - `src/py/bbctrl/Web.py`: REST endpoints - `src/py/bbctrl/__init__.py`: export AuxAxis +- `src/pug/templates/control-view.pug`: W jog row + DRO row +- `src/js/control-view.js`: aux_home / aux_jog / aux_jog_incr handlers +- `src/js/axis-vars.js`: `_compute_aux_axis` for W state +- `src/svelte-components/src/components/WAxisSettings.svelte`: settings panel +- `src/svelte-components/src/components/SettingsView.svelte`: hosts WAxisSettings - `auxcnc/src/main.cpp`: new commands HOME, HOMECFG, WPOS, HOMED?, LIMIT?, ABORT-able STEPS with limit-aware abort, trapezoidal ramps, NVS-persisted config, `[boot]` banner, deterministic reply tokens From ef4658aaf62926624940339ca2eb48dce2b6feb4 Mon Sep 17 00:00:00 2001 From: Henrik Muehe Date: Thu, 30 Apr 2026 20:00:03 +0200 Subject: [PATCH 18/20] Plan: V09 full UX redesign mock + implementation plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/mocks/v09_full_ux.html — high-fidelity 1920x1080 mock showing the proposed Control / Program / Console / Settings tab layout with the V09 flat slate jog/macro palette and an underline ribbon header tab style. - plans/2026-04-30_ux_redesign.md — phased implementation plan to port index.pug + control-view.pug to the new shell while keeping hash routing and existing settings/admin views intact. --- docs/mocks/v09_full_ux.html | 900 ++++++++++++++++++++++++++++++++ plans/2026-04-30_ux_redesign.md | 163 ++++++ 2 files changed, 1063 insertions(+) create mode 100644 docs/mocks/v09_full_ux.html create mode 100644 plans/2026-04-30_ux_redesign.md diff --git a/docs/mocks/v09_full_ux.html b/docs/mocks/v09_full_ux.html new file mode 100644 index 0000000..2b5841d --- /dev/null +++ b/docs/mocks/v09_full_ux.html @@ -0,0 +1,900 @@ + + + + + +Onefinity · V09 · Full UX + + + + + + +
+ +
+
+
+ ONEFINITY · V09 · Full UX preview +
+ Click the inner tabs to navigate +
+ + + 100% +
+ +
+
+
+ + +
+
+
+ +
ONEFINITY
+
+
+ + + + +
+ + READY + +
+ +
+ + +
+
+ +
+
+
Jog · step 10mm
+
+ +
+
+
+ + + + + + + + + + + + + + + + +
+
+ + +
+
+
+
Axis
Position
Absolute
Offset
State
Toolpath
Actions
+
+
+
X
+
0.000mm
+
0.000
+
0.000
+
Unhomed
+
OK
+
+ + + +
+
+
+
Y
+
0.000mm
+
0.000
+
0.000
+
Unhomed
+
OK
+
+ + + +
+
+
+
Z
+
0.000mm
+
0.000
+
0.000
+
Unhomed
+
Over
+
+ + + +
+
+
+
W
+
0.000mm
+
0.000
+
+
Unhomed
+
OK
+
+ + +
+
+
+ +
+
State
READY
No alerts
+
Velocity / Feed
0 · 0
m/min · mm/min
+
Spindle
0 (0)
RPM (commanded / actual)
+
Job
0 / 1,785
Line · 19:07 remaining
+
+
+
+ + +
+ + + + + + + + +
+
+ + +
+
+ +
+
+ + + + + + +
+ +
+ + + Default folder + thin-rough.nc + By Upload Date +
+ +
+
+
+
+ + + + + + + + + Stock: 250 × 25 × 16 mm + + + + + + START + END + + + + + + X + Z + Y + + +
+
+ + + + + + + +
+
+ thin-rough.nc · 1,785 lines · 12.4 KB + est. 19:07 +
+
+
+
+ +
+
+ + +
+
+ +
+ + + +
+ + +
+
+ G> + G0 X100 Y50 F2000 + +
+
+ + + + + + + + + + + + + + + + +
+
+
19:42:11G21✓ ok
+
19:42:14G90✓ ok
+
19:43:02G0 Y12.800✓ ok
+
19:43:08G0 Z19.040✓ ok
+
19:43:30G1 Z-20 F800✗ blocked: Z over travel
+
19:44:01G0 Z5✓ ok
+
+
+ + +
+
+
+
+
+
Z toolpath exceeds soft-limit
+
2 min ago · sticky
+
+
Loaded program reaches Z = -16.500. Configured soft-limit is Z = -15.000. Adjust the Z origin or set a deeper soft-limit before running.
+
+
+ + +
+
+ +
+
+
+
+
Camera offline
+
12 min ago
+
+
Camera at 10.1.10.55:8554 did not respond on last poll. Live preview disabled.
+
+
+ + +
+
+ +
+
+
+
+
File uploaded · thin-rough.nc
+
21 min ago
+
+
1,785 lines · 12.4 KB · checksum verified.
+
+
+ +
+
+ +
+
+
+
+
WiFi: not connected
+
1 h ago
+
+
Falling back to wired ethernet. SSID workshop-2g last seen 53 min ago.
+
+
+ + +
+
+
+ + +
+
+
Spindle Load
+
0 %
+
idle
+
+
+
+
Spindle Temp
+
24 °C
+
nominal
+
+
+
+
Driver Voltage
+
48.1 V
+
ok
+
+
+
Coolant
+
OFF
+
standby
+
+ +
+
Limit X
+
CLEAR
+
ok
+
+
+
Limit Y
+
CLEAR
+
ok
+
+
+
Limit Z
+
BLOCKED
+
over-travel
+
+
+
Probe
+
OPEN
+
not contacted
+
+ +
+
E-Stop
+
RELEASED
+
safe
+
+
+
Door
+
CLOSED
+
ok
+
+
+
Air Pressure
+
6.2 bar
+
ok
+
+
+
+
Vacuum
+
−0.81 bar
+
hold
+
+
+
+ +
+
+ + +
+
+
+
Display & Units
+
Motion
+
Spindle
+
Safety / Soft-limits
+
Network
+
Camera
+
Macros
+
About
+
+
+
+
Display & Units
+
+
+
Display Units
+
Position, feed and dimensions throughout the UI.
+
+
+
+
+
+
+
Decimal places
+
Position readout precision.
+
+
+
0–4
+
+
+
+
Pulse-dot animation
+
Animate status badges (ready, idle, alarm).
+
+
+
+
+
+
+
Theme
+
Pick a tile finish.
+
+
V09 · Flat soft slate
+
+
+
+ +
+
Network
+
+
+
IP Address
+
Wired ethernet, DHCP.
+
+
10.1.10.55
+
+
+
+
+
WiFi
+
Wireless network connection.
+
+
Not connected
+
+
+
+
+
Hostname
+
Used in mDNS / Bonjour discovery.
+
+
+
+
+
+
+
+
+ +
+
+ +
+
+
+ +
+ + + + diff --git a/plans/2026-04-30_ux_redesign.md b/plans/2026-04-30_ux_redesign.md new file mode 100644 index 0000000..1b66b66 --- /dev/null +++ b/plans/2026-04-30_ux_redesign.md @@ -0,0 +1,163 @@ +# UX Redesign — Implementation Plan + +Reference mock: `docs/mocks/v09_full_ux.html` +Target hardware: 10.8" portable monitor, 1920×1080, capacitive touch, Chrome fullscreen. + +## 1. Goals + +The redesign keeps every existing feature but reorganizes the page into a single-screen control surface for finger-touch use: + +- A slim 96 px header replaces the 140 px nav-header. Only logo + ONEFINITY wordmark + tab bar + system pill + READY badge + octagonal STOP. +- 4 top-level sections accessed via underline-ribbon tabs in the header: + 1. **Control** — jog pad, DRO table, status strip, macro row. + 2. **Program** — Auto run controls, file actions, G-code listing, 3D viewer. + 3. **Console** — MDI, Messages, Indicators (sub-tabs). + 4. **Settings** — paged settings (replaces the Pure left rail). +- Touch targets ≥ 64 px (jog tiles 72 px, axis action icons 72 px, macro buttons 84 px). +- All action chip-soup (WiFi/Camera/Rotary/IP/Version) collapses into one "All systems · view" pill that opens a popover. Burger menu removed (Settings tab supersedes it). +- V09 jog/macro palette: flat soft slate (#3f4b63), no drop shadow; yellow (#fde047) accent for active states (step seg, tab underline, macro number badge). + +## 2. Scope of code change + +The build is Pug + Stylus + Browserify Vue (Vue 1.x). `index.pug` defines the chrome; `src/pug/templates/*.pug` defines each view; `src/js/*.js` mirrors them as Vue components routed by `currentView` from the URL hash. + +Files we will touch: + +- `src/pug/index.pug` — replace `#layout / #menu / #main / .nav-header` with the new header + tab bar + body. Drop the burger and the side-menu include. +- `src/pug/templates/control-view.pug` — restructure into the new Control panel (jog grid + DRO table + status strip + macro row). MDI/Messages/Indicators move out. +- New `src/pug/templates/program-view.pug` — Auto sub-panel content (action bar, file bar, gcode-viewer, path-viewer). +- New `src/pug/templates/console-view.pug` — MDI / Messages / Indicators sub-tabs hosting existing `console.pug` and `indicators.pug` partials. +- `src/js/app.js` — extend `parse_hash` so `#program`, `#console`, `#settings` resolve; expose tab state for the header to highlight. +- `src/js/control-view.js` — keep jog/DRO logic, drop the Auto/MDI/Messages/Indicators internal `tab` state and template hooks. +- New `src/js/program-view.js`, `src/js/console-view.js` — extracted Vue components. +- `src/stylus/style.styl` — add `.app-shell`, `.head`, `.tabs-host`, `.ktab`, panel styles, V09 jog tokens. Keep legacy classes alive until templates fully migrated. +- `src/static/css/side-menu.css` — stop including in `index.pug`. +- Settings: keep `settings-view.pug`, `admin-general-view.pug`, `admin-network-view.pug`, `motor-view.pug`, `tool-view.pug`, `io-view.pug`, etc., and surface them through a left-rail navigator inside the Settings panel rather than the sidebar. + +## 3. Routing model + +We keep the existing URL hash routing because everything in `src/js/app.js#parse_hash` and the deep-linked menu items (`#motor:0`, `#admin-network`, etc.) depend on it. + +| URL hash | Top tab | Notes | +|-------------------------|------------|-------------------------------------------------------| +| `#control` | Control | Default | +| `#program` / `#program:auto` | Program | Auto sub-view (only sub-view for now) | +| `#console` / `#console:mdi` | Console | MDI default, also `:messages` and `:indicators` | +| `#settings` | Settings | Settings home (Display & Units) | +| `#admin-general`, `#admin-network`, `#motor:N`, `#tool`, `#io`, `#help`, `#cheat-sheet` | Settings | Existing routes remain, surfaced in the Settings left rail | + +The header tab bar maps URL prefix → active tab. A tiny helper `topTabFromHash(hash)` lives in `app.js` and is reused by the header template. + +## 4. Step-by-step + +### Phase 1 — Mock parity (1–2 days) +1. Add `docs/mocks/v09_full_ux.html` (done) so anyone can preview the target. +2. Move the V09 palette into Stylus tokens at the top of `style.styl`: + ```styl + $jog-bg = #3f4b63 + $jog-hover = #4a5777 + $jog-dir = #5b6885 + $jog-ghost = #8c97ad + $accent = #fde047 + $accent-ink = #0f172a + ``` +3. Build the header in `index.pug`: + ```pug + .app-shell + header.head + .brand-blk + .brand-logo + .brand-name ONEFINITY + nav.tabs-host(role="tablist") + a.ktab(:class="{active: topTab === 'control'}", href="#control") + .fa.fa-gamepad + | Control + a.ktab(:class="{active: topTab === 'program'}", href="#program") … + a.ktab(:class="{active: topTab === 'console'}", href="#console") … + a.ktab(:class="{active: topTab === 'settings'}", href="#settings") … + button.sys-btn(@click="toggle_sys_popover") … + span.state-badge(:class="state_class") + estop(@click="estop") + ``` +4. Style the header tabs as **underline ribbon** (V02): transparent fills, slate-gray text, dark text + 5 px yellow underline on active. CSS already proven in the mock. +5. Move the rotary toggle and pi-temp warning into the system pill popover. + +### Phase 2 — Control panel (2 days) +1. Rewrite the outer markup of `control-view.pug` to a CSS grid: + ``` + .control-grid → 720px jog-card | 1fr right-col(dro-card + status-strip) + ``` + Drop the ``-based outer layout (axes table stays — it's a real data table). +2. Replace the legacy `
control-buttons, .info, .override and .tabs blocks entirely. Program panel (program-view.pug + .js) - Extracts the Auto bar, file selectors, gcode-viewer and path-viewer out of control-view. - Action buttons (RUN/STOP/UPLOAD-FOLDER/UPLOAD-FILE/DOWNLOAD-FILE/ DELETE) at 84px with explicit color affordances. - Reuses control-view's existing methods via the new program-mixin. Console panel (console-view.pug + .js) - Three sub-tabs: MDI / Messages / Indicators. Sub-tab persists in the URL fragment (#console:messages etc.). - MDI: terminal-style prompt + SEND, plus an 8-wide on-screen keypad (G0/G1/G2/G3/G28/G92/M3/M5 + axis letters + CLEAR/SEND). - Messages: pulls from .messages_log (mirrored from state.messages); badge in the header tab counts unread. - Indicators: mounts the existing component. Settings shell (settings-shell.pug + .js) - New left rail navigator listing Display, Network, General/Firmware, Spindle&Tool, IO, Motors 0..3, Macros, Cheat Sheet, Help. - Inner area mounts the existing settings family templates via an explicit v-if cascade (avoiding a Vue 1 :is reactivity quirk). - Shutdown / Save buttons relocated from the dropped side menu. JS plumbing - main.js: Vue.config.async = false to keep dependent watchers in sync when reactive data is mutated outside Vue's normal event loop (e.g. from a hashchange listener). - program-mixin.js extracted so control-view.js no longer carries the file/macro/gcode methods that are now Program-only. - control-view.js trimmed to jog/DRO/probe/home logic. - console-view.js / settings-shell-view.js use a hashchange listener + local data props because Vue 1 cannot reliably observe .sub_tab from a child component. Stylus rewrite - Removes the old .header (140px), .nav-header, .brand subtree, #menu, #main, .control-view block, .info, .override, .toolbar, .macros-div, .macros-button, the .tabs > input radio-tab system and the .control- view #control media-query overrides. None of these are referenced any more. - Adds V09 tokens (jog/macro palette + accent + line/card colors) at the top, the new shell rules, .ktab / .sys-btn / .state-badge / .estop chrome, the .control-page grid, status strip + override drawer, .program-page action / file bars and program body, .console-page MDI keypad / messages / indicators panes, and the .settings-shell rail. - Adds a 1820px breakpoint that stacks the right column under the jog on smaller portable monitors. Hard cut: no config.ui.layout flag, the old shell is removed in this single commit. side-menu.css is no longer included from index.pug. Tested locally with agent-browser (1920x1080) on every top tab and every settings sub-route; routing, active tab highlighting and inner view selection all work without a controller connection. --- src/js/app.js | 212 ++- src/js/console-view.js | 125 ++ src/js/control-view.js | 673 +-------- src/js/main.js | 10 + src/js/program-mixin.js | 554 +++++++ src/js/program-view.js | 60 + src/js/settings-shell-view.js | 109 ++ src/pug/index.pug | 175 ++- src/pug/templates/console-view.pug | 67 + src/pug/templates/control-view.pug | 717 +++------ src/pug/templates/program-view.pug | 137 ++ src/pug/templates/settings-shell.pug | 44 + src/stylus/style.styl | 2068 +++++++++++++++++--------- 13 files changed, 3044 insertions(+), 1907 deletions(-) create mode 100644 src/js/console-view.js create mode 100644 src/js/program-mixin.js create mode 100644 src/js/program-view.js create mode 100644 src/js/settings-shell-view.js create mode 100644 src/pug/templates/console-view.pug create mode 100644 src/pug/templates/program-view.pug create mode 100644 src/pug/templates/settings-shell.pug diff --git a/src/js/app.js b/src/js/app.js index ceeb9ef..c8cd5ce 100644 --- a/src/js/app.js +++ b/src/js/app.js @@ -103,12 +103,23 @@ module.exports = new Vue({ return { status: "connecting", currentView: "loading", + // Top-level shell tab. Mapped from the URL hash by parse_hash(). + // One of: control | program | console | settings + top_tab: "control", + // Sub-route when a tab has internal pages (e.g. console:mdi, + // settings:admin-network, settings:motor:0). The settings sub + // also drives which inner view is mounted. + sub_tab: "", + sys_open: false, + has_camera: true, + messages_log: [], + messages_seen: 0, display_units: localStorage.getItem("display_units") || "METRIC", index: -1, modified: false, template: require("../resources/config-template.json"), config: { - settings: { + settings: { units: "METRIC", "easy-adapter": false }, @@ -143,22 +154,15 @@ module.exports = new Vue({ estop: { template: "#estop-template" }, "loading-view": { template: "

Loading...

" }, "control-view": require("./control-view"), - "settings-view": require("./settings-view"), - "motor-view": require("./motor-view"), - "tool-view": require("./tool-view"), - "io-view": require("./io-view"), - "admin-general-view": require("./admin-general-view"), - "admin-network-view": require("./admin-network-view"), - "macros-view": require('./macros'), - "help-view": require("./help-view"), - "cheat-sheet-view": { - template: "#cheat-sheet-view-template", - data: function() { - return { - showUnimplemented: false - }; - }, - }, + "program-view": require("./program-view"), + "console-view": require("./console-view"), + + // The settings-shell renders the rail + an inner routed view. + // All settings-family hashes (settings, admin-general, + // admin-network, motor:N, tool, io, macros, help, cheat-sheet) + // resolve to this same shell; parse_hash() sets sub_tab so the + // shell knows which inner template to mount. + "settings-shell-view": require("./settings-shell-view"), }, watch: { @@ -166,6 +170,25 @@ module.exports = new Vue({ localStorage.setItem("display_units", value); SvelteComponents.setDisplayUnits(value); }, + + // Mirror controller messages into a console log used by the + // Console > Messages tab and the header badge counter. + "state.messages": { + handler: function(messages) { + if (!Array.isArray(messages)) return; + this.messages_log = messages.map(m => ({ + text: m.text, + id: m.id, + level: /^#/.test(m.text || "") ? "info" : "warning", + ts: m.ts || Date.now(), + })); + if (this.top_tab === "console" && this.sub_tab === "messages") { + this.messages_seen = this.messages_log.length; + } + }, + deep: true, + immediate: true, + }, }, events: { @@ -252,18 +275,106 @@ module.exports = new Vue({ enable_rotary: function() { if(this.state["2an"] == 1 || this.state["2an"] == 3) return true; return false; - } + }, + + // ---------------- header chrome helpers ---------------- + + // Underlying machine state from the controller. Mirrors + // control-view's `mach_state` so the header has access without + // depending on the routed component. + mach_state: function() { + const cycle = this.state.cycle; + const xx = this.state.xx; + + if (xx != "ESTOPPED" && (cycle == "jogging" || cycle == "homing")) { + return cycle.toUpperCase(); + } + + return xx || ""; + }, + + // Short text for the READY pill in the header. + state_label: function() { + const s = this.mach_state; + if (!s) return "--"; + return s; + }, + + // Class added to the READY pill (.state-badge) so styling can + // reflect ready / running / holding / fault / estop. + state_class: function() { + const s = this.mach_state; + if (s == "ESTOPPED" || s == "FAULT" || s == "SHUTDOWN") return "bad"; + if (s == "HOLDING" || s == "STOPPING") return "warn"; + if (s == "RUNNING" || s == "HOMING" || s == "JOGGING") return "busy"; + if (s == "READY") return "ok"; + return "unknown"; + }, + + mach_state_full: function() { + const s = this.mach_state; + if (s == "ESTOPPED") return "E-Stopped \u2014 release to clear"; + if (s == "HOLDING") return "Feed hold (" + (this.state.pr || "paused") + ")"; + if (s == "RUNNING") return "Running program"; + if (s == "HOMING") return "Homing axes"; + if (s == "JOGGING") return "Jogging"; + if (s == "READY") return "Ready"; + return s; + }, + + // Pip color for the unified system pill. + sys_class: function() { + const wifi_off = !this.config.wifiName || this.config.wifiName == "not connected"; + const cam_off = !this.has_camera; + const hot = this.state && 80 <= this.state.rpi_temp; + if (hot) return "red"; + if (wifi_off || cam_off) return "amber"; + return "green"; + }, + + // Compact summary for the system pill. + sys_summary: function() { + const issues = []; + if (!this.config.wifiName || this.config.wifiName == "not connected") { + issues.push("WiFi off"); + } + if (!this.has_camera) issues.push("Camera offline"); + if (this.state && 80 <= this.state.rpi_temp) issues.push("Pi hot"); + if (this.is_rotary_active) issues.push("Rotary"); + if (issues.length === 0) return "All systems"; + if (issues.length === 1) return issues[0]; + return issues.length + " notes"; + }, + + // Number of unread Console > Messages entries. + messages_count: function() { + return Math.max(0, this.messages_log.length - this.messages_seen); + }, }, ready: function() { window.onhashchange = () => this.parse_hash(); + + // Resolve the initial route before the websocket connects so + // the shell shows the right view even on a slow / offline + // controller. update() will call parse_hash() again once the + // first config is in. + this.parse_hash(); + this.connect(); + // Close the system popover when clicking anywhere else. + document.addEventListener("click", () => { + if (this.sys_open) this.sys_open = false; + }); + SvelteComponents.registerControllerMethods({ dispatch: (...args) => this.$dispatch(...args) }); }, + + methods: { block_error_dialog: function() { this.errorTimeoutStart = Date.now(); @@ -421,6 +532,21 @@ module.exports = new Vue({ }; }, + // Maps a URL hash to (currentView, top_tab, sub_tab, index). + // Hash layouts supported (all kept for backward compat): + // #control -> control tab + // #program[:auto] -> program tab + // #console[:mdi|messages|indicators] + // -> console tab + // #settings -> settings tab home + // #admin-general -> settings tab, admin-general inside + // #admin-network -> settings tab, admin-network inside + // #motor:0..3 -> settings tab, motor 0..3 + // #tool -> settings tab, tool view + // #io -> settings tab, io view + // #macros -> settings tab, macros view + // #help -> settings tab, help view + // #cheat-sheet -> settings tab, cheat sheet view parse_hash: function() { const hash = location.hash.substr(1); @@ -430,12 +556,56 @@ module.exports = new Vue({ } const parts = hash.split(":"); + const head = parts[0]; - if (parts.length == 2) { - this.index = parts[1]; + this.index = parts.length > 1 ? parts[1] : -1; + + // Legacy / settings-managed views resolve under the + // Settings tab while keeping their existing top-level + // hash. This preserves all existing deep links. + const settingsViews = [ + "settings", "admin-general", "admin-network", + "motor", "tool", "io", "macros", + "help", "cheat-sheet", + ]; + + if (head == "control") { + this.top_tab = "control"; + this.sub_tab = ""; + this.currentView = "control"; + } else if (head == "program") { + this.top_tab = "program"; + this.sub_tab = parts[1] || "auto"; + this.currentView = "program"; + } else if (head == "console") { + this.top_tab = "console"; + this.sub_tab = parts[1] || "mdi"; + this.currentView = "console"; + } else if (settingsViews.indexOf(head) !== -1) { + this.top_tab = "settings"; + this.sub_tab = head; + // All settings-family routes mount the same shell; + // shell picks inner view from sub_tab. Vary the + // currentView token so Vue 1 fully remounts the + // shell on every navigation — this avoids stale :class + // bindings against the local `sub` data prop. + this.currentView = "settings-shell"; + } else { + // Unknown hash: route to settings shell anyway so we + // never end up rendering a bare loading screen. + this.top_tab = "settings"; + this.sub_tab = head; + this.currentView = "settings-shell"; } - this.currentView = parts[0]; + // Mark Console messages as seen when we enter that tab. + if (this.top_tab == "console" && this.sub_tab == "messages") { + this.messages_seen = this.messages_log.length; + } + }, + + toggle_sys_popover: function() { + this.sys_open = !this.sys_open; }, save: async function() { diff --git a/src/js/console-view.js b/src/js/console-view.js new file mode 100644 index 0000000..d2cd677 --- /dev/null +++ b/src/js/console-view.js @@ -0,0 +1,125 @@ +"use strict"; + +const api = require("./api"); + +// Console tab — MDI command input, message log and live indicators. +// Sub-tab state syncs with the URL hash (#console:mdi | +// #console:messages | #console:indicators) so deep links work. + +module.exports = { + template: "#console-view-template", + props: ["config", "template", "state"], + + data: function () { + return { + mdi: "", + history: [], + sub: "mdi", + // Local mirror of $root.messages_count so Vue 1 reactivity works. + unread_messages_local: 0, + }; + }, + + watch: { + sub: function () { + // Switching to messages marks them as seen so the header badge + // clears. + if (this.sub === "messages") { + this.$root.messages_seen = this.$root.messages_log.length; + this.unread_messages_local = 0; + } + }, + }, + + computed: { + unread_messages: function () { + return this.unread_messages_local; + }, + + mach_state: function () { + const cycle = this.state.cycle; + const xx = this.state.xx; + if (xx != "ESTOPPED" && (cycle == "jogging" || cycle == "homing")) { + return cycle.toUpperCase(); + } + return xx || ""; + }, + + is_idle: function () { return this.state.cycle == "idle"; }, + + can_mdi: function () { + return this.is_idle || this.state.cycle == "mdi"; + }, + + mach_units: function () { + return this.$root.display_units; + }, + }, + + ready: function () { + this._onHash = () => this.refresh_from_hash(); + window.addEventListener("hashchange", this._onHash); + this.refresh_from_hash(); + this._poll = setInterval(() => { + // Cheap re-poll for unread message count; Vue 1 cannot observe + // `$root.messages_count` directly so we mirror it here. + const c = this.$root && this.$root.messages_count; + if (typeof c === "number" && c !== this.unread_messages_local) { + this.unread_messages_local = c; + } + }, 500); + }, + + beforeDestroy: function () { + if (this._onHash) window.removeEventListener("hashchange", this._onHash); + if (this._poll) clearInterval(this._poll); + }, + + methods: { + refresh_from_hash: function () { + const hash = location.hash.substr(1); + const parts = hash.split(":"); + const sub = parts[0] === "console" ? (parts[1] || "mdi") : "mdi"; + this.sub = sub; + if (sub === "messages" && this.$root) { + this.$root.messages_seen = this.$root.messages_log.length; + this.unread_messages_local = 0; + } + }, + + select_sub: function (name) { + this.sub = name; + // Update URL hash for deep links / back-button. + const h = "#console" + (name && name !== "mdi" ? ":" + name : ""); + if (location.hash !== h) { + history.replaceState(null, "", h); + } + if (name === "messages") { + this.$root.messages_seen = this.$root.messages_log.length; + this.unread_messages_local = 0; + } + }, + + prepend: function (token) { + this.mdi = token + this.mdi.trimStart(); + }, + + append: function (token) { + const tail = this.mdi.endsWith(" ") || !this.mdi ? "" : " "; + this.mdi = this.mdi + tail + token; + }, + + submit_mdi: function () { + if (!this.mdi) return; + this.$dispatch("send", this.mdi); + if (!this.history.length || this.history[0] != this.mdi) { + this.history.unshift(this.mdi); + } + this.mdi = ""; + }, + + load_history: function (index) { + this.mdi = this.history[index]; + }, + }, +}; diff --git a/src/js/control-view.js b/src/js/control-view.js index a9c23f6..7fa1134 100644 --- a/src/js/control-view.js +++ b/src/js/control-view.js @@ -1,7 +1,6 @@ "use strict"; const api = require("./api"); -const utils = require("./utils"); const cookie = require("./cookie")("bbctrl-"); module.exports = { @@ -12,15 +11,7 @@ module.exports = { return { current_time: "", mach_units: this.$root.state.metric ? "METRIC" : "IMPERIAL", - mdi: "", - last_file: undefined, - last_file_time: undefined, - toolpath: {}, - toolpath_progress: 0, axes: "xyzabc", - history: [], - speed_override: 1, - feed_override: 1, jog_incr_amounts: { METRIC: { fine: 0.1, @@ -38,34 +29,14 @@ module.exports = { jog_incr: localStorage.getItem("jog_incr") || "small", jog_step: cookie.get_bool("jog-step"), jog_adjust: parseInt(cookie.get("jog-adjust", 2)), - deleteGCode: false, - tab: "auto", ask_home: true, - folder_name: "", - edited: false, - uploading_files: false, - confirmDelete: false, - create_folder: false, - showGcodeMessage: false, - showNoGcodeMessage: false, - macrosLoading: false, - show_gcodes: false, - GCodeNotFound: false, show_probe_dialog: false, - filesUploaded: 0, - totalFiles: 0, - files_sortby: "By Upload Date", - selected_items_to_delete: [], - search_query: "", - filtered_files: [], - selected_folder_index: null, + overrides_open: false, }; }, components: { "axis-control": require("./axis-control"), - "path-viewer": require("./path-viewer"), - "gcode-viewer": require("./gcode-viewer"), }, watch: { @@ -80,16 +51,6 @@ module.exports = { immediate: true, }, - "state.line": function () { - if (this.mach_state != "HOMING") { - this.$broadcast("gcode-line", this.state.line); - } - }, - - "state.selected_time": function () { - this.load(); - }, - jog_step: function () { cookie.set_bool("jog-step", this.jog_step); }, @@ -127,43 +88,16 @@ module.exports = { return state || ""; }, - pause_reason: function () { - return this.state.pr; - }, - - is_running: function () { - return this.mach_state == "RUNNING" || this.mach_state == "HOMING"; - }, - - is_stopping: function () { - return this.mach_state == "STOPPING"; - }, - - is_holding: function () { - return this.mach_state == "HOLDING"; - }, - - is_ready: function () { - return this.mach_state == "READY"; + can_set_axis: function () { + return this.state.cycle == "idle"; }, is_idle: function () { return this.state.cycle == "idle"; }, - is_paused: function () { - return this.is_holding && (this.pause_reason == "User pause" || this.pause_reason == "Program pause"); - }, - - can_mdi: function () { - return this.is_idle || this.state.cycle == "mdi"; - }, - - can_set_axis: function () { - return this.is_idle; - - // TODO allow setting axis position during pause - // return this.is_idle || this.is_paused; + is_ready: function () { + return this.mach_state == "READY"; }, message: function () { @@ -191,57 +125,21 @@ module.exports = { }, plan_time_remaining: function () { - if (!(this.is_stopping || this.is_running || this.is_holding)) { - return 0; - } - - return this.toolpath.time - this.plan_time; + const stopping = this.mach_state == "STOPPING"; + const running = this.mach_state == "RUNNING" || this.mach_state == "HOMING"; + const holding = this.mach_state == "HOLDING"; + if (!(stopping || running || holding)) return 0; + const tp = this.$root && this.$root.toolpath ? this.$root.toolpath.time : 0; + return (tp || 0) - this.plan_time; }, - eta: function () { - if (this.mach_state != "RUNNING") { - return ""; - } - - const remaining = this.plan_time_remaining; - const d = new Date(); - d.setSeconds(d.getSeconds() + remaining); - return d.toLocaleString(); - }, - - progress: function () { - if (!this.toolpath.time || this.is_ready) { - return 0; - } - - const p = this.plan_time / this.toolpath.time; - return Math.min(1, p); - }, - gcode_files: function () { - if (!this.state.folder) { - return []; - } - const folder = this.state.gcode_list.find(item => item.name == this.state.folder); - if (!folder) { - return []; - } - const files = folder.files.filter(item => this.state.files.includes(item.file_name)).map(item => item.file_name); - if (this.files_sortby == "A-Z") { - return files.sort(); - } else if (this.files_sortby == "Z-A") { - return files.sort().reverse(); - } else { - return files; - } - }, - gcode_filtered_files: function () { - return this.filtered_files.filter(file => file.toLowerCase().includes(this.search_query.toLowerCase())); - }, - gcode_folders: function () { - return this.state.gcode_list - .map(item => item.name) - .filter(element => element !== "default") - .sort(); + state_kpi_class: function () { + const s = this.mach_state; + if (s == "ESTOPPED" || s == "FAULT" || s == "SHUTDOWN") return "bad"; + if (s == "HOLDING" || s == "STOPPING") return "warn"; + if (s == "RUNNING" || s == "HOMING" || s == "JOGGING") return "busy"; + if (s == "READY") return "ok"; + return ""; }, }, @@ -264,14 +162,9 @@ module.exports = { M72 `); }, - folder_name_edited: function () { - this.edited = true; - }, }, ready: function () { - this.load(); - setInterval(() => { this.current_time = new Date().toLocaleTimeString(); }, 1000); @@ -287,25 +180,9 @@ module.exports = { }, methods: { - save_config: async function (config) { - try { - await api.put("config/save", config); - this.$dispatch("update"); - } catch (error) { - console.error("Restore Failed: ", error); - alert("Restore failed"); - } - }, - - populateFiles(index) { - this.selected_folder_index = index; - this.filtered_files = this.state.gcode_list[index].files.map(item => item.file_name); - }, - getJogIncrStyle(value) { const weight = `font-weight:${this.jog_incr === value ? "bold" : "normal"}`; const color = this.jog_incr === value ? "color:#0078e7" : ""; - return [weight, color].join(";"); }, @@ -324,426 +201,6 @@ module.exports = { `); }, - send: function (msg) { - this.$dispatch("send", msg); - }, - - toggle_sorting: function () { - if (this.files_sortby === "By Upload Date") { - this.files_sortby = "A-Z"; - } else if (this.files_sortby === "A-Z") { - this.files_sortby = "Z-A"; - } else if (this.files_sortby === "Z-A") { - this.files_sortby = "By Upload Date"; - } - }, - - load: function () { - const file_time = this.state.selected_time; - const file = this.state.selected; - if (this.last_file == file && this.last_file_time == file_time) { - return; - } - - if (this.state.selected && !this.state.files.includes(this.state.selected)) { - this.GCodeNotFound = true; - return; - } - - this.last_file = file; - this.last_file_time = file_time; - - this.$broadcast("gcode-load", file); - this.$broadcast("gcode-line", this.state.line); - this.toolpath_progress = 0; - this.load_toolpath(file, file_time); - }, - - load_toolpath: async function (file, file_time) { - this.toolpath = {}; - - if (!file || this.last_file_time != file_time) { - return; - } - - this.showGcodeMessage = true; - - while (this.showGcodeMessage) { - try { - const toolpath = await api.get(`path/${file}`); - this.toolpath_progress = toolpath.progress; - - if (toolpath.progress === 1 || typeof toolpath.progress == "undefined") { - this.showGcodeMessage = false; - - if (toolpath.bounds) { - toolpath.filename = file; - this.toolpath_progress = 1; - this.toolpath = toolpath; - - const state = this.$root.state; - for (const axis of "xyzabc") { - Vue.set(state, `path_min_${axis}`, toolpath.bounds.min[axis]); - Vue.set(state, `path_max_${axis}`, toolpath.bounds.max[axis]); - } - } - } - } catch (error) { - console.error(error); - } - } - }, - - submit_mdi: function () { - this.send(this.mdi); - - if (!this.history.length || this.history[0] != this.mdi) { - this.history.unshift(this.mdi); - } - - this.mdi = ""; - }, - - mdi_start_pause: function () { - if (this.state.xx == "RUNNING") { - this.pause(); - } else if (this.state.xx == "STOPPING" || this.state.xx == "HOLDING") { - this.unpause(); - } else { - this.submit_mdi(); - } - }, - - load_history: function (index) { - this.mdi = this.history[index]; - }, - - open_file: function () { - utils.clickFileInput("gcode-file-input"); - }, - - open_folder: function () { - utils.clickFileInput("gcode-folder-input"); - }, - - edited_folder_name: function (event) { - if (event.target.value.trim() != "") { - this.$dispatch("folder_name_edited"); - } - }, - - update_config: function () { - this.config.gcode_list = [...this.state.gcode_list]; - this.config.non_macros_list = [...this.state.non_macros_list]; - this.config.macros_list = [...this.state.macros_list]; - this.config.macros = [...this.state.macros]; - }, - - reset_gcode: function () { - this.state.selected = ""; - this.last_file = ""; - this.$broadcast("gcode-load", ""); - }, - - upload_gcode: async function (filename, file) { - return new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest(); - - this.filesUploaded++; - if (this.filesUploaded == this.totalFiles) { - this.uploading_files = false; - } - - xhr.onload = () => { - if (xhr.status >= 200 && xhr.status < 300) { - resolve("file uploaded"); - } else { - console.error("File upload failed:", xhr.statusText); - reject("upload failed"); - } - }; - - xhr.onerror = () => { - alert("Upload failed."); - reject("upload failed"); - }; - - xhr.open("PUT", `/api/file/${encodeURIComponent(filename)}`, true); - xhr.send(file); - }); - }, - - readFile: function (file) { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - - reader.onload = () => { - resolve(reader.result); - }; - - reader.onerror = error => { - reject(error); - }; - - reader.readAsText(file, "utf-8"); - }); - }, - - validateFiles: async function (files) { - const validFiles = []; - for (const file of files) { - const extension = file.name.split(".").pop().toLowerCase(); - const validExtensions = ["nc", "ngc", "gcode", "gc"]; - - if (validExtensions.includes(extension)) { - validFiles.push(file); - } else { - alert(`Unsupported file : ${file.name}`); - this.filesUploaded++; - if (this.filesUploaded == this.totalFiles) { - this.uploadFiles = false; - } - } - } - - return validFiles; - }, - - uploadValidFiles: async function (files, folderName) { - const updatedConfig = { ...this.config }; - - for (const file of files) { - try { - const gcode = await this.readFile(file); - await this.upload_gcode(file.name, gcode); - - const isAlreadyPresent = updatedConfig.non_macros_list.some(element => element.file_name === file.name); - - if (!isAlreadyPresent) { - updatedConfig.non_macros_list.push({ file_name: file.name }); - } - - if (folderName) { - const folder = updatedConfig.gcode_list.find(item => item.type == "folder" && item.name == folderName); - if (folder) { - if (!folder.files.map(item => item.file_name).includes(file.name)) { - folder.files.push({ file_name: file.name }); - } - } else { - updatedConfig.gcode_list.push({ - name: folderName, - type: "folder", - files: [ - { - file_name: file.name, - }, - ], - }); - } - } else { - var folder_to_add = updatedConfig.gcode_list.find( - item => item.type == "folder" && item.name == this.state.folder, - ); - if (!folder_to_add) { - folder_to_add = updatedConfig.gcode_list.unshift({ - name: this.state.folder, - type: "folder", - files: [ - { - file_name: file.name, - }, - ], - }); - folder_to_add = updatedConfig.gcode_list[0]; - } - if (!folder_to_add.files.find(item => item.file_name == file.name)) { - folder_to_add.files.push({ file_name: file.name }); - } - } - } catch (error) { - console.warn(`error uploading file : `, error); - } - } - return updatedConfig; - }, - - upload_files: async function (files, folderName) { - this.update_config(); - - const validFiles = await this.validateFiles(files); - const updatedConfig = await this.uploadValidFiles(validFiles, folderName); - - await this.save_config(updatedConfig); - }, - - upload_file: async function (e) { - this.uploading_files = true; - this.filesUploaded = 0; - - const files = e.target.files || e.dataTransfer.files; - if (!files.length) { - return; - } - - this.totalFiles = files.length; - - await this.upload_files(files); - }, - - create_new_folder: async function () { - const folder_name = this.folder_name.trim(); - if (folder_name != "") { - if (this.state.gcode_list.find(item => item.type == "folder" && item.name == folder_name)) { - alert("Folder with the same name already exists!"); - return; - } else { - this.update_config(); - this.config.gcode_list.push({ - name: folder_name, - type: "folder", - files: [], - }); - } - this.state.folder = folder_name; - this.edited = false; - this.create_folder = false; - this.folder_name = ""; - this.save_config(this.config); - } - }, - - cancel_new_folder: function () { - this.create_folder = false; - this.folder_name = ""; - }, - - upload_folder: async function (e) { - this.uploading_files = true; - this.filesUploaded = 0; - - const files = e.target.files || e.dataTransfer.files; - if (!files.length) { - return; - } - this.totalFiles = files.length; - const folderName = files[0].webkitRelativePath.split("/")[0]; - - this.upload_files(files, folderName); - }, - - delete_current: async function () { - if (!this.state.selected) { - this.deleteGCode = false; - return; - } - - this.update_config(); - - this.config.non_macros_list = this.config.non_macros_list.filter( - item => !this.selected_items_to_delete.includes(item.file_name), - ); - const folder_to_update = this.config.gcode_list.find( - item => item.name == this.config.gcode_list[this.selected_folder_index].name && item.type == "folder", - ); - folder_to_update.files = folder_to_update.files.filter( - item => !this.selected_items_to_delete.includes(item.file_name), - ); - - const exception_list = this.state.macros_list.map(item => item.file_name); - let files_to_delete = this.selected_items_to_delete.filter(item => !exception_list.includes(item)); - - await api.delete(`file/DINCAIQABiDARixAxiABDIHCAMQABiABDIHCAQQABiABDIH${files_to_delete.toString()}`); - - this.save_config(this.config); - this.filtered_files = []; - this.search_query = ""; - this.selected_folder_index = null; - this.selected_items_to_delete = []; - this.deleteGCode = false; - }, - - cancel_delete: function () { - this.filtered_files = []; - this.search_query = ""; - this.selected_folder_index = null; - this.selected_items_to_delete = []; - this.deleteGCode = false; - }, - - delete_all: function () { - api.delete("file"); - this.deleteGCode = false; - }, - - delete_all_except_macros: async function () { - this.update_config(); - const macrosList = this.state.macros_list.map(item => item.file_name).toString(); - api.delete(`file/EgZjaHJvbWUqCggBEAAYsQMYgAQyBggAEEUYOTIKCAE${macrosList}`); - this.config.non_macros_list = []; - this.config.gcode_list = [ - { - name: "default", - type: "folder", - files: [], - }, - ]; - - this.save_config(this.config); - this.state.folder = "default"; - this.state.selected = ""; - this.selected_items_to_delete = []; - this.deleteGCode = false; - }, - - delete_folder: async function () { - this.update_config(); - if (this.state.folder && this.state.folder != "default") { - const files_to_move = this.config.gcode_list.find( - item => item.type == "folder" && item.name == this.state.folder, - ); - if (files_to_move) { - const default_folder = this.config.gcode_list.find(item => item.name == "default"); - default_folder.files = [...default_folder.files, ...files_to_move.files].sort(); - this.config.gcode_list = this.config.gcode_list.filter(item => item.name != this.state.folder); - this.save_config(this.config); - } - } - this.state.folder = "default"; - this.confirmDelete = false; - }, - delete_folder_and_files: async function () { - if (!this.state.folder) { - this.confirmDelete = false; - return; - } - - this.update_config(); - - const selected_folder = this.config.gcode_list.find( - item => item.type == "folder" && item.name == this.state.folder, - ); - if (!selected_folder) { - return; - } - const macrosList = this.state.macros_list.map(item => item.file_name); - var files_to_delete = selected_folder.files - .map(item => item.file_name) - .filter(item => !macrosList.includes(item)); - if (selected_folder.name != "default") { - this.config.gcode_list = this.config.gcode_list.filter(item => item.name != this.state.folder); - } else { - selected_folder.files = []; - } - - await api.delete(`file/DINCAIQABiDARixAxiABDIHCAMQABiABDIHCAQQABiABDIH${files_to_delete.toString()}`); - this.config.non_macros_list = this.config.non_macros_list.filter( - item => !files_to_delete.includes(item.file_name), - ); - this.save_config(this.config); - this.state.folder = "default"; - this.confirmDelete = false; - }, - home: function (axis) { this.ask_home = false; @@ -777,11 +234,8 @@ module.exports = { }); }, - // Use the same fine/small/medium/large increment buttons as the XYZ - // jog grid. sign=+1 for W+, -1 for W-. aux_jog_incr: function (sign) { - const amount = - this.jog_incr_amounts[this.display_units][this.jog_incr]; + const amount = this.jog_incr_amounts[this.display_units][this.jog_incr]; const delta_mm = sign * (this.metric ? amount : amount * 25.4); this.aux_jog(delta_mm); }, @@ -811,93 +265,20 @@ module.exports = { }, zero: function (axis) { - if (typeof axis == "undefined") { - this.zero_all(); - } else { - this.set_position(axis, 0); - } - }, - - start_pause: function () { - this.macrosLoading = false; - if (this.state.xx == "RUNNING") { - this.pause(); - } else if (this.state.xx == "STOPPING" || this.state.xx == "HOLDING") { - this.unpause(); - } else { - this.start(); - } - }, - - start: function () { - api.put("start"); - }, - - pause: function () { - api.put("pause"); - }, - - unpause: function () { - api.put("unpause"); - }, - - optional_pause: function () { - api.put("pause/optional"); - }, - - stop: function () { - api.put("stop"); - }, - - step: function () { - api.put("step"); - }, - - override_feed: function () { - api.put(`override/feed/${this.feed_override}`); - }, - - override_speed: function () { - api.put(`override/speed/${this.speed_override}`); - }, - - current: function (axis, value) { - const x = value / 32.0; - if (this.state[`${axis}pl`] == x) { - return; - } - - const data = {}; - data[`${axis}pl`] = x; - this.send(JSON.stringify(data)); + if (typeof axis == "undefined") this.zero_all(); + else this.set_position(axis, 0); }, showProbeDialog: function (probeType) { - if(this.show_probe_dialog){ + if (this.show_probe_dialog) { this.show_probe_dialog = false; } - SvelteComponents.showDialog("Probe", { probeType, isRotaryActive: this.state["2an"] == 3 }); - }, - run_macro: function (id) { - if (this.state.macros[id].file_name == "default") { - this.showNoGcodeMessage = true; - } else { - if (this.state.macros[id].file_name != this.state.selected) { - this.state.selected = this.state.macros[id].file_name; - } - try { - this.load(); - if (this.state.macros[id].alert == true) { - this.macrosLoading = true; - } else { - setImmediate(() => this.start_pause()); - } - } catch (error) { - console.warn("Error running program: ", error); - } - } + SvelteComponents.showDialog("Probe", { + probeType, + isRotaryActive: this.state["2an"] == 3, + }); }, }, - mixins: [require("./axis-vars")], + mixins: [require("./program-mixin"), require("./axis-vars")], }; diff --git a/src/js/main.js b/src/js/main.js index f58ad13..06ebc9f 100644 --- a/src/js/main.js +++ b/src/js/main.js @@ -44,6 +44,16 @@ window.onload = function() { cookie_set("client-id", uuid(), 10000); } + // Vue 1's async queue can drop dependent watcher updates when + // data props are mutated outside the normal event flow (e.g. from + // a `hashchange` listener that fires before Vue's tick scheduler + // has caught up). Disable async batching so every reactive write + // synchronously re-evaluates dependents — this matches Vue 1's + // older default and is what the legacy UI implicitly relied on. + if (Vue.config) { + Vue.config.async = false; + } + // Register global components Vue.component("templated-input", require("./templated-input")); Vue.component("message", require("./message")); diff --git a/src/js/program-mixin.js b/src/js/program-mixin.js new file mode 100644 index 0000000..b3b5ae4 --- /dev/null +++ b/src/js/program-mixin.js @@ -0,0 +1,554 @@ +"use strict"; + +// Shared data, computed properties and methods that are used by both +// the Control view (for things like start/stop, run-macro, axis state) +// and the Program view (RUN/STOP/Upload/Download/Delete + file picker +// + gcode/path viewers). Splitting these out lets us mount the same +// behaviour under two top-level routes without duplicating code. +// +// The mixin intentionally does *not* require axis-vars; control-view +// keeps that one to itself. + +const api = require("./api"); +const utils = require("./utils"); + +module.exports = { + data: function () { + return { + mdi: "", + last_file: undefined, + last_file_time: undefined, + toolpath: {}, + toolpath_progress: 0, + history: [], + speed_override: 1, + feed_override: 1, + deleteGCode: false, + folder_name: "", + edited: false, + uploading_files: false, + confirmDelete: false, + create_folder: false, + showGcodeMessage: false, + showNoGcodeMessage: false, + macrosLoading: false, + show_gcodes: false, + GCodeNotFound: false, + filesUploaded: 0, + totalFiles: 0, + files_sortby: "By Upload Date", + selected_items_to_delete: [], + search_query: "", + filtered_files: [], + selected_folder_index: null, + }; + }, + + watch: { + "state.line": function () { + if (this.mach_state != "HOMING") { + this.$broadcast("gcode-line", this.state.line); + } + }, + + "state.selected_time": function () { + this.load(); + }, + }, + + computed: { + is_running: function () { + return this.mach_state == "RUNNING" || this.mach_state == "HOMING"; + }, + + is_stopping: function () { + return this.mach_state == "STOPPING"; + }, + + is_holding: function () { + return this.mach_state == "HOLDING"; + }, + + is_ready: function () { + return this.mach_state == "READY"; + }, + + is_idle: function () { + return this.state.cycle == "idle"; + }, + + is_paused: function () { + return this.is_holding && (this.pause_reason == "User pause" || this.pause_reason == "Program pause"); + }, + + can_mdi: function () { + return this.is_idle || this.state.cycle == "mdi"; + }, + + pause_reason: function () { + return this.state.pr; + }, + + plan_time: function () { + return this.state.plan_time; + }, + + plan_time_remaining: function () { + if (!(this.is_stopping || this.is_running || this.is_holding)) { + return 0; + } + return this.toolpath.time - this.plan_time; + }, + + eta: function () { + if (this.mach_state != "RUNNING") { + return ""; + } + const remaining = this.plan_time_remaining; + const d = new Date(); + d.setSeconds(d.getSeconds() + remaining); + return d.toLocaleString(); + }, + + progress: function () { + if (!this.toolpath.time || this.is_ready) { + return 0; + } + const p = this.plan_time / this.toolpath.time; + return Math.min(1, p); + }, + + gcode_files: function () { + if (!this.state.folder) return []; + const folder = this.state.gcode_list.find(item => item.name == this.state.folder); + if (!folder) return []; + const files = folder.files + .filter(item => this.state.files.includes(item.file_name)) + .map(item => item.file_name); + if (this.files_sortby == "A-Z") return files.sort(); + if (this.files_sortby == "Z-A") return files.sort().reverse(); + return files; + }, + + gcode_filtered_files: function () { + return this.filtered_files.filter(file => + file.toLowerCase().includes(this.search_query.toLowerCase())); + }, + + gcode_folders: function () { + return this.state.gcode_list + .map(item => item.name) + .filter(element => element !== "default") + .sort(); + }, + }, + + methods: { + save_config: async function (config) { + try { + await api.put("config/save", config); + this.$dispatch("update"); + } catch (error) { + console.error("Restore Failed: ", error); + alert("Restore failed"); + } + }, + + populateFiles(index) { + this.selected_folder_index = index; + this.filtered_files = this.state.gcode_list[index].files.map(item => item.file_name); + }, + + send: function (msg) { + this.$dispatch("send", msg); + }, + + toggle_sorting: function () { + if (this.files_sortby === "By Upload Date") this.files_sortby = "A-Z"; + else if (this.files_sortby === "A-Z") this.files_sortby = "Z-A"; + else if (this.files_sortby === "Z-A") this.files_sortby = "By Upload Date"; + }, + + load: function () { + const file_time = this.state.selected_time; + const file = this.state.selected; + if (this.last_file == file && this.last_file_time == file_time) return; + + if (this.state.selected && !this.state.files.includes(this.state.selected)) { + this.GCodeNotFound = true; + return; + } + + this.last_file = file; + this.last_file_time = file_time; + + this.$broadcast("gcode-load", file); + this.$broadcast("gcode-line", this.state.line); + this.toolpath_progress = 0; + this.load_toolpath(file, file_time); + }, + + load_toolpath: async function (file, file_time) { + this.toolpath = {}; + if (!file || this.last_file_time != file_time) return; + + this.showGcodeMessage = true; + + while (this.showGcodeMessage) { + try { + const toolpath = await api.get(`path/${file}`); + this.toolpath_progress = toolpath.progress; + + if (toolpath.progress === 1 || typeof toolpath.progress == "undefined") { + this.showGcodeMessage = false; + + if (toolpath.bounds) { + toolpath.filename = file; + this.toolpath_progress = 1; + this.toolpath = toolpath; + + const state = this.$root.state; + for (const axis of "xyzabc") { + Vue.set(state, `path_min_${axis}`, toolpath.bounds.min[axis]); + Vue.set(state, `path_max_${axis}`, toolpath.bounds.max[axis]); + } + } + } + } catch (error) { + console.error(error); + } + } + }, + + submit_mdi: function () { + this.send(this.mdi); + if (!this.history.length || this.history[0] != this.mdi) { + this.history.unshift(this.mdi); + } + this.mdi = ""; + }, + + mdi_start_pause: function () { + if (this.state.xx == "RUNNING") this.pause(); + else if (this.state.xx == "STOPPING" || this.state.xx == "HOLDING") this.unpause(); + else this.submit_mdi(); + }, + + load_history: function (index) { + this.mdi = this.history[index]; + }, + + open_file: function () { + utils.clickFileInput("gcode-file-input"); + }, + + open_folder: function () { + utils.clickFileInput("gcode-folder-input"); + }, + + edited_folder_name: function (event) { + if (event.target.value.trim() != "") { + this.$dispatch("folder_name_edited"); + } + }, + + update_config: function () { + this.config.gcode_list = [...this.state.gcode_list]; + this.config.non_macros_list = [...this.state.non_macros_list]; + this.config.macros_list = [...this.state.macros_list]; + this.config.macros = [...this.state.macros]; + }, + + reset_gcode: function () { + this.state.selected = ""; + this.last_file = ""; + this.$broadcast("gcode-load", ""); + }, + + upload_gcode: async function (filename, file) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + this.filesUploaded++; + if (this.filesUploaded == this.totalFiles) { + this.uploading_files = false; + } + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) resolve("file uploaded"); + else { console.error("File upload failed:", xhr.statusText); reject("upload failed"); } + }; + xhr.onerror = () => { alert("Upload failed."); reject("upload failed"); }; + xhr.open("PUT", `/api/file/${encodeURIComponent(filename)}`, true); + xhr.send(file); + }); + }, + + readFile: function (file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.onerror = error => reject(error); + reader.readAsText(file, "utf-8"); + }); + }, + + validateFiles: async function (files) { + const validFiles = []; + for (const file of files) { + const extension = file.name.split(".").pop().toLowerCase(); + const validExtensions = ["nc", "ngc", "gcode", "gc"]; + if (validExtensions.includes(extension)) { + validFiles.push(file); + } else { + alert(`Unsupported file : ${file.name}`); + this.filesUploaded++; + if (this.filesUploaded == this.totalFiles) { + this.uploadFiles = false; + } + } + } + return validFiles; + }, + + uploadValidFiles: async function (files, folderName) { + const updatedConfig = { ...this.config }; + + for (const file of files) { + try { + const gcode = await this.readFile(file); + await this.upload_gcode(file.name, gcode); + + const isAlreadyPresent = updatedConfig.non_macros_list.some(element => element.file_name === file.name); + if (!isAlreadyPresent) { + updatedConfig.non_macros_list.push({ file_name: file.name }); + } + + if (folderName) { + const folder = updatedConfig.gcode_list.find(item => item.type == "folder" && item.name == folderName); + if (folder) { + if (!folder.files.map(item => item.file_name).includes(file.name)) { + folder.files.push({ file_name: file.name }); + } + } else { + updatedConfig.gcode_list.push({ + name: folderName, + type: "folder", + files: [{ file_name: file.name }], + }); + } + } else { + var folder_to_add = updatedConfig.gcode_list.find( + item => item.type == "folder" && item.name == this.state.folder, + ); + if (!folder_to_add) { + folder_to_add = updatedConfig.gcode_list.unshift({ + name: this.state.folder, + type: "folder", + files: [{ file_name: file.name }], + }); + folder_to_add = updatedConfig.gcode_list[0]; + } + if (!folder_to_add.files.find(item => item.file_name == file.name)) { + folder_to_add.files.push({ file_name: file.name }); + } + } + } catch (error) { + console.warn(`error uploading file : `, error); + } + } + return updatedConfig; + }, + + upload_files: async function (files, folderName) { + this.update_config(); + const validFiles = await this.validateFiles(files); + const updatedConfig = await this.uploadValidFiles(validFiles, folderName); + await this.save_config(updatedConfig); + }, + + upload_file: async function (e) { + this.uploading_files = true; + this.filesUploaded = 0; + const files = e.target.files || e.dataTransfer.files; + if (!files.length) return; + this.totalFiles = files.length; + await this.upload_files(files); + }, + + create_new_folder: async function () { + const folder_name = this.folder_name.trim(); + if (folder_name != "") { + if (this.state.gcode_list.find(item => item.type == "folder" && item.name == folder_name)) { + alert("Folder with the same name already exists!"); + return; + } + this.update_config(); + this.config.gcode_list.push({ + name: folder_name, + type: "folder", + files: [], + }); + this.state.folder = folder_name; + this.edited = false; + this.create_folder = false; + this.folder_name = ""; + this.save_config(this.config); + } + }, + + cancel_new_folder: function () { + this.create_folder = false; + this.folder_name = ""; + }, + + upload_folder: async function (e) { + this.uploading_files = true; + this.filesUploaded = 0; + const files = e.target.files || e.dataTransfer.files; + if (!files.length) return; + this.totalFiles = files.length; + const folderName = files[0].webkitRelativePath.split("/")[0]; + this.upload_files(files, folderName); + }, + + delete_current: async function () { + if (!this.state.selected) { + this.deleteGCode = false; + return; + } + this.update_config(); + + this.config.non_macros_list = this.config.non_macros_list.filter( + item => !this.selected_items_to_delete.includes(item.file_name), + ); + const folder_to_update = this.config.gcode_list.find( + item => item.name == this.config.gcode_list[this.selected_folder_index].name && item.type == "folder", + ); + folder_to_update.files = folder_to_update.files.filter( + item => !this.selected_items_to_delete.includes(item.file_name), + ); + + const exception_list = this.state.macros_list.map(item => item.file_name); + let files_to_delete = this.selected_items_to_delete.filter(item => !exception_list.includes(item)); + + await api.delete(`file/DINCAIQABiDARixAxiABDIHCAMQABiABDIHCAQQABiABDIH${files_to_delete.toString()}`); + + this.save_config(this.config); + this.filtered_files = []; + this.search_query = ""; + this.selected_folder_index = null; + this.selected_items_to_delete = []; + this.deleteGCode = false; + }, + + cancel_delete: function () { + this.filtered_files = []; + this.search_query = ""; + this.selected_folder_index = null; + this.selected_items_to_delete = []; + this.deleteGCode = false; + }, + + delete_all: function () { + api.delete("file"); + this.deleteGCode = false; + }, + + delete_all_except_macros: async function () { + this.update_config(); + const macrosList = this.state.macros_list.map(item => item.file_name).toString(); + api.delete(`file/EgZjaHJvbWUqCggBEAAYsQMYgAQyBggAEEUYOTIKCAE${macrosList}`); + this.config.non_macros_list = []; + this.config.gcode_list = [{ name: "default", type: "folder", files: [] }]; + this.save_config(this.config); + this.state.folder = "default"; + this.state.selected = ""; + this.selected_items_to_delete = []; + this.deleteGCode = false; + }, + + delete_folder: async function () { + this.update_config(); + if (this.state.folder && this.state.folder != "default") { + const files_to_move = this.config.gcode_list.find( + item => item.type == "folder" && item.name == this.state.folder, + ); + if (files_to_move) { + const default_folder = this.config.gcode_list.find(item => item.name == "default"); + default_folder.files = [...default_folder.files, ...files_to_move.files].sort(); + this.config.gcode_list = this.config.gcode_list.filter(item => item.name != this.state.folder); + this.save_config(this.config); + } + } + this.state.folder = "default"; + this.confirmDelete = false; + }, + + delete_folder_and_files: async function () { + if (!this.state.folder) { + this.confirmDelete = false; + return; + } + this.update_config(); + const selected_folder = this.config.gcode_list.find( + item => item.type == "folder" && item.name == this.state.folder, + ); + if (!selected_folder) return; + + const macrosList = this.state.macros_list.map(item => item.file_name); + var files_to_delete = selected_folder.files + .map(item => item.file_name) + .filter(item => !macrosList.includes(item)); + + if (selected_folder.name != "default") { + this.config.gcode_list = this.config.gcode_list.filter(item => item.name != this.state.folder); + } else { + selected_folder.files = []; + } + + await api.delete(`file/DINCAIQABiDARixAxiABDIHCAMQABiABDIHCAQQABiABDIH${files_to_delete.toString()}`); + this.config.non_macros_list = this.config.non_macros_list.filter( + item => !files_to_delete.includes(item.file_name), + ); + this.save_config(this.config); + this.state.folder = "default"; + this.confirmDelete = false; + }, + + start_pause: function () { + this.macrosLoading = false; + if (this.state.xx == "RUNNING") this.pause(); + else if (this.state.xx == "STOPPING" || this.state.xx == "HOLDING") this.unpause(); + else this.start(); + }, + + start: function () { api.put("start"); }, + pause: function () { api.put("pause"); }, + unpause: function () { api.put("unpause"); }, + optional_pause: function () { api.put("pause/optional"); }, + stop: function () { api.put("stop"); }, + step: function () { api.put("step"); }, + + override_feed: function () { api.put(`override/feed/${this.feed_override}`); }, + override_speed: function () { api.put(`override/speed/${this.speed_override}`); }, + + run_macro: function (id) { + if (this.state.macros[id].file_name == "default") { + this.showNoGcodeMessage = true; + } else { + if (this.state.macros[id].file_name != this.state.selected) { + this.state.selected = this.state.macros[id].file_name; + } + try { + this.load(); + if (this.state.macros[id].alert == true) { + this.macrosLoading = true; + } else { + setImmediate(() => this.start_pause()); + } + } catch (error) { + console.warn("Error running program: ", error); + } + } + }, + }, +}; diff --git a/src/js/program-view.js b/src/js/program-view.js new file mode 100644 index 0000000..5f25627 --- /dev/null +++ b/src/js/program-view.js @@ -0,0 +1,60 @@ +"use strict"; + +// Program tab — file management, run/stop, gcode listing and 3D +// toolpath preview. Reuses the shared mixin (program-mixin) that also +// powers the legacy bits of control-view; this view does not host the +// jog grid or the DRO. + +module.exports = { + template: "#program-view-template", + props: ["config", "template", "state"], + + components: { + "path-viewer": require("./path-viewer"), + "gcode-viewer": require("./gcode-viewer"), + }, + + data: function () { + return {}; + }, + + watch: { + "state.metric": { + handler: function () {}, + immediate: true, + }, + }, + + computed: { + display_units: { + cache: false, + get: function () { return this.$root.display_units; }, + set: function (value) { + this.config.settings.units = value; + this.$root.display_units = value; + this.$dispatch("config-changed"); + }, + }, + + metric: function () { + return this.display_units === "METRIC"; + }, + + mach_state: function () { + const cycle = this.state.cycle; + const xx = this.state.xx; + if (xx != "ESTOPPED" && (cycle == "jogging" || cycle == "homing")) { + return cycle.toUpperCase(); + } + return xx || ""; + }, + + can_set_axis: function () { return this.state.cycle == "idle"; }, + }, + + ready: function () { + this.load(); + }, + + mixins: [require("./program-mixin")], +}; diff --git a/src/js/settings-shell-view.js b/src/js/settings-shell-view.js new file mode 100644 index 0000000..cf88802 --- /dev/null +++ b/src/js/settings-shell-view.js @@ -0,0 +1,109 @@ +"use strict"; + +// Wrapper that adds a left-rail navigator around the settings family +// of views (Settings, Admin General, Admin Network, Tool, IO, Motor, +// Macros, Help, Cheat Sheet). The inner view is selected by the URL +// hash (parsed in app.js) and exposed as $root.sub_tab. + +// Vue 1 has trouble making child components reactive to `$root.sub_tab` +// changes (whether via computed, watch, or prop binding through +// ``). The shell instead listens to `hashchange` +// directly and parses the hash itself, mirroring app.js's logic, then +// keeps a local data prop `sub` that the template binds to. This is +// the only path that updates the rail's `:class` reactively. +module.exports = { + template: "#settings-shell-view-template", + props: ["config", "template", "state", "index"], + + components: { + "settings-view-inner": require("./settings-view"), + "admin-general-view": require("./admin-general-view"), + "admin-network-view": require("./admin-network-view"), + "motor-view": require("./motor-view"), + "tool-view": require("./tool-view"), + "io-view": require("./io-view"), + "macros-view": require("./macros"), + "help-view": require("./help-view"), + "cheat-sheet-view": { + template: "#cheat-sheet-view-template", + data: function () { + return { showUnimplemented: false }; + }, + }, + }, + + data: function () { + return { + sub: this.$root.sub_tab || "settings", + ridx: this.$root.index, // local copy of the motor index + rail_items: [ + { sub: "settings", href: "#settings", icon: "fa-display", label: "Display & Units" }, + { sub: "admin-network", href: "#admin-network", icon: "fa-network-wired", label: "Network" }, + { sub: "admin-general", href: "#admin-general", icon: "fa-shield-halved", label: "General / Firmware" }, + { sub: "tool", href: "#tool", icon: "fa-bolt", label: "Spindle & Tool" }, + { sub: "io", href: "#io", icon: "fa-plug", label: "I/O" }, + { section: "Motors" }, + { sub: "motor", motor: 0, href: "#motor:0", icon: "fa-arrows-up-down-left-right", label: "Motor 0" }, + { sub: "motor", motor: 1, href: "#motor:1", icon: "fa-arrows-up-down-left-right", label: "Motor 1" }, + { sub: "motor", motor: 2, href: "#motor:2", icon: "fa-arrows-up-down-left-right", label: "Motor 2" }, + { sub: "motor", motor: 3, href: "#motor:3", icon: "fa-arrows-up-down-left-right", label: "Motor 3" }, + { section: " " }, + { sub: "macros", href: "#macros", icon: "fa-keyboard", label: "Macros" }, + { sub: "cheat-sheet", href: "#cheat-sheet", icon: "fa-book", label: "G-code Cheat Sheet" }, + { sub: "help", href: "#help", icon: "fa-circle-question", label: "Help" }, + ], + }; + }, + + ready: function () { + this._onHash = () => this.refresh_from_hash(); + window.addEventListener("hashchange", this._onHash); + this.refresh_from_hash(); + }, + + attached: function () { + // Vue 1 fires `attached` whenever the component is inserted into + // the DOM (which happens on every route change because the outer + // recreates the instance). Re-bind the listener + // here so it works even after detach/attach cycles. + if (!this._onHash) { + this._onHash = () => this.refresh_from_hash(); + } + window.addEventListener("hashchange", this._onHash); + this.refresh_from_hash(); + }, + + detached: function () { + if (this._onHash) { + window.removeEventListener("hashchange", this._onHash); + } + }, + + beforeDestroy: function () { + if (this._onHash) { + window.removeEventListener("hashchange", this._onHash); + } + }, + + methods: { + refresh_from_hash: function () { + const hash = location.hash.substr(1) || "settings"; + const parts = hash.split(":"); + this.sub = parts[0] || "settings"; + this.ridx = parts[1] !== undefined ? parts[1] : -1; + }, + + is_active: function (item) { + if (!item || item.section) return false; + if (item.sub !== this.sub) return false; + if (item.sub === "motor") { + return "" + item.motor === "" + this.ridx; + } + return true; + }, + + showShutdownDialog: function () { + SvelteComponents.showDialog("Shutdown"); + }, + }, +}; diff --git a/src/pug/index.pug b/src/pug/index.pug index 79f1416..b9a65b5 100644 --- a/src/pug/index.pug +++ b/src/pug/index.pug @@ -8,7 +8,6 @@ html(lang="en") style: include ../static/css/pure-min.css - style: include ../static/css/side-menu.css style: include ../static/css/font-awesome.min.css style: include ../static/css/Audiowide.css @@ -23,99 +22,113 @@ html(lang="en") #overlay(v-if="status != 'connected'") span {{status}} - - #layout - a#menuLink.menu-link(href="#menu"): span - #menu - button.save.pure-button.button-success(:disabled="!modified", - @click="save") Save + .app-shell + header.app-head + .brand-blk + .brand-logo + .brand-name ONEFINITY - .pure-menu - ul.pure-menu-list - li.pure-menu-heading - a.pure-menu-link(href="#control") Control + nav.tabs-host(role="tablist") + a.ktab(:class="{active: top_tab === 'control'}", href="#control", + title="Jog, DRO, macros") + .fa.fa-gamepad + span Control + a.ktab(:class="{active: top_tab === 'program'}", href="#program", + title="Run programs, files, toolpath preview") + .fa.fa-list-ol + span Program + a.ktab(:class="{active: top_tab === 'console'}", href="#console", + title="MDI, messages, indicators") + .fa.fa-terminal + span Console + span.ktab-badge(v-if="messages_count") {{messages_count}} + a.ktab(:class="{active: top_tab === 'settings'}", href="#settings", + title="Configuration, network, macros") + .fa.fa-sliders + span Settings - li.pure-menu-heading - a.pure-menu-link(href="#macros") Macros + .head-spacer - li.pure-menu-heading - a.pure-menu-link(href="#settings") Settings + .sys-btn(@click.stop="toggle_sys_popover", :class="{open: sys_open}") + span.pip(:class="sys_class") + span.sys-text {{sys_summary}} + .fa.fa-chevron-down - li.pure-menu-heading - a.pure-menu-link(href="#motor:0") Motors + .pi-temp-warning(v-if="80 <= state.rpi_temp", + title="Raspberry Pi temperature too high.") + .fa.fa-thermometer-full - li.pure-menu-item(v-for="motor in config.motors") - a.pure-menu-link(:href="'#motor:' + $index") Motor {{$index}} + span.state-badge(:class="state_class", :title="mach_state_full") + span.dot + span {{state_label}} - li.pure-menu-heading - a.pure-menu-link(href="#tool") Tool + .estop(:class="{active: state.es}") + estop(@click="estop") - li.pure-menu-heading - a.pure-menu-link(href="#io") I/O - - li.pure-menu-heading - a.pure-menu-link(href="#admin-general") Admin - - li.pure-menu-item - a.pure-menu-link(href="#admin-general") General - - li.pure-menu-item - a.pure-menu-link(href="#admin-network") Network - - li.pure-menu-heading - a.pure-menu-link(href="#cheat-sheet") Cheat Sheet - - li.pure-menu-heading - a.pure-menu-link(href="#help") Help - - button.pure-button.pure-button-primary(@click="showShutdownDialog", style="width: 100%") - .fa.fa-power-off - - #main - .nav-header - .brand - img(src="/images/onefinity_logo.png") - .version - div Version: v{{config.full_version}} - div IP Address: {{config.ip}} - div WiFi: {{config.wifiName}} - a.upgrade-link(v-if="show_upgrade()", href="#admin-general") - | Upgrade to v{{latestVersion}} - .fa.fa-exclamation-circle.upgrade-attention(v-if="show_upgrade()") - - .pi-temp-warning - .fa.fa-thermometer-full(class="error", - v-if="80 <= state.rpi_temp", - title="Raspberry Pi temperature too high.") - - .easy-adapter(v-if="is_easy_adapter_active") - .round-dot - div.easy-adapter-text Easy Adapter - - .whitespace - - div - button.rotary-button(:disabled="!enable_rotary", :class="is_rotary_active && 'active'", @click="showSwitchRotaryModeDialog") - img(src="/images/rotary.svg", alt="rotary", :style="is_rotary_active ? 'width:90%;' : 'width:85%;'") - div.rotary-text Rotary - - .video(title="Plug camera into USB.\n" + - "Left click to toggle video size.\n" + - "Right click to toggle crosshair.", @click="toggle_video", - @contextmenu="toggle_crosshair", :class="video_size") + // System popover (chip-soup destination) + .sys-popover(v-if="sys_open", @click.stop="") + .sp-row + .sp-icon: .fa.fa-microchip + .sp-text + .sp-label Firmware + .sp-val v{{config.full_version}} + a.sp-act(v-if="show_upgrade()", href="#admin-general") + | Upgrade to v{{latestVersion}} + .fa.fa-exclamation-circle.upgrade-attention + .sp-row + .sp-icon: .fa.fa-network-wired + .sp-text + .sp-label IP Address + .sp-val {{config.ip}} + .sp-row + .sp-icon: .fa.fa-wifi(:class="{'sp-warn': config.wifiName === 'not connected'}") + .sp-text + .sp-label WiFi + .sp-val {{config.wifiName}} + a.sp-act(href="#admin-network", @click="sys_open=false") Configure + .sp-row(v-if="enable_rotary") + .sp-icon: img(src="/images/rotary.svg", alt="rotary") + .sp-text + .sp-label Rotary + .sp-val {{is_rotary_active ? 'Active' : 'Inactive'}} + button.sp-act(@click="showSwitchRotaryModeDialog") + | {{is_rotary_active ? 'Disable' : 'Enable'}} + .sp-row(v-if="is_easy_adapter_active") + .sp-icon: .fa.fa-puzzle-piece + .sp-text + .sp-label Easy Adapter + .sp-val Active + .sp-row.video-row + .sp-icon: .fa.fa-video + .sp-text + .sp-label Camera + .sp-val {{has_camera ? 'Live' : 'Plug camera into USB'}} + .sp-act(v-if="has_camera", @click="toggle_video") + | {{video_size === 'small' ? 'Enlarge' : 'Shrink'}} + .video(v-if="sys_open && has_camera", title="Camera feed", + @click="toggle_video", @contextmenu="toggle_crosshair", + :class="video_size") .crosshair(v-if="crosshair") .vertical .horizontal .box - img(src="/api/video") + img(src="/api/video", @error="has_camera=false") + .sp-foot + button.sp-shutdown(@click="showShutdownDialog") + .fa.fa-power-off + |  Shutdown + button.sp-save(:disabled="!modified", @click="save") + .fa.fa-save + |  Save{{modified ? '*' : ''}} - .estop(:class="{active: state.es}") - estop(@click="estop") - - .content(class="{{currentView}}-view") - component(:is="currentView + '-view'", :index="index", - :config="config", :template="template", :state="state", keep-alive) + // Routed view (no keep-alive: Vue 1 has issues re-evaluating + // dynamic :class / v-if bindings on cached components when the + // route changes within the same kept-alive tree) + .app-body + component(:is="currentView + '-view'", :index="index", + :config="config", :template="template", :state="state", + :sub-tab="sub_tab") message.error-message(:show.sync="errorShow") div(slot="header") diff --git a/src/pug/templates/console-view.pug b/src/pug/templates/console-view.pug new file mode 100644 index 0000000..1bd6aa4 --- /dev/null +++ b/src/pug/templates/console-view.pug @@ -0,0 +1,67 @@ +script#console-view-template(type="text/x-template") + .console-page + .console-card + .ptab-bar + button.ptab(:class="{active: sub === 'mdi'}", @click="select_sub('mdi')") + .fa.fa-keyboard + |  MDI + button.ptab(:class="{active: sub === 'messages'}", @click="select_sub('messages')") + .fa.fa-comment-dots + |  Messages + span.ptab-badge(v-if="unread_messages") {{unread_messages}} + button.ptab(:class="{active: sub === 'indicators'}", @click="select_sub('indicators')") + .fa.fa-bell + |  Indicators + + // ----- MDI ----- + .mdi-pane(v-show="sub === 'mdi'") + .mdi-input + span.prompt G> + input(type="text", v-model="mdi", :disabled="!can_mdi", + @keyup.enter="submit_mdi", placeholder="enter a G-code command…") + button.mdi-send(:disabled="!can_mdi || !mdi", @click="submit_mdi") + .fa.fa-paper-plane + |  SEND + .mdi-keys + button.mkey(@click="prepend('G0 ')") G0 + button.mkey(@click="prepend('G1 ')") G1 + button.mkey(@click="prepend('G2 ')") G2 + button.mkey(@click="prepend('G3 ')") G3 + button.mkey(@click="prepend('G28 ')") G28 + button.mkey(@click="prepend('G92 ')") G92 + button.mkey(@click="prepend('M3 ')") M3 + button.mkey(@click="prepend('M5 ')") M5 + button.mkey(@click="append('X')") X + button.mkey(@click="append('Y')") Y + button.mkey(@click="append('Z')") Z + button.mkey(@click="append('W')") W + button.mkey(@click="append('F')") F + button.mkey(@click="append('S')") S + button.mkey.clear(@click="mdi = ''") CLEAR + button.mkey.send(:disabled="!can_mdi || !mdi", @click="submit_mdi") SEND ↵ + + em Machine units: #[strong {{mach_units}}]. G20/G21 to switch. + + .mdi-history(:class="{placeholder: !history.length}") + span.mdi-empty(v-if="!history.length") MDI history will display here. + .h-row(v-for="item in history", @click="load_history($index)", + track-by="$index") + span.h-cmd {{item}} + span.h-status ↻ + + // ----- Messages ----- + .messages-pane(v-show="sub === 'messages'") + .msg-empty(v-if="!$root.messages_log.length") + .fa.fa-check-circle + |  No messages. + .msg(v-for="m in $root.messages_log", + :class="m.level === 'warning' ? 'warn' : 'info'", track-by="$index") + .mi + .fa(:class="m.level === 'warning' ? 'fa-triangle-exclamation' : 'fa-circle-info'") + div + .mtitle {{m.text}} + .mtime ID {{m.id}} + + // ----- Indicators ----- + .indicators-pane(v-show="sub === 'indicators'") + indicators(:state="state", :template="template") diff --git a/src/pug/templates/control-view.pug b/src/pug/templates/control-view.pug index 4393835..f88d0d5 100644 --- a/src/pug/templates/control-view.pug +++ b/src/pug/templates/control-view.pug @@ -1,43 +1,39 @@ script#control-view-template(type="text/x-template") - #control + .control-page + // ----- Modal dialogs (kept verbatim from legacy) ----- message(:show.sync="showGcodeMessage") h3(slot="header") Processing New File - div(slot="body") h3 Please wait.. p Simulating GCode to check for errors, calculate ETA and generate 3D view. - div(slot="footer") label Simulating {{(toolpath_progress || 0) | percent}} - + message(:show.sync="showNoGcodeMessage") - h3(slot="header") GCode Not Set - div(slot="body") - p Configure the GCode for the selected macro to use it - - div(slot="footer") - button.pure-button(@click="showNoGcodeMessage=false") OK + h3(slot="header") GCode Not Set + div(slot="body") + p Configure the GCode for the selected macro to use it + div(slot="footer") + button.pure-button(@click="showNoGcodeMessage=false") OK message(:show.sync="macrosLoading") - h3(slot="header") Run Macro? - div(slot="body") - p - | The macro file - strong {{state.selected}} - | is being loaded. - - div(slot="footer") - button.pure-button(@click="macrosLoading=false") Cancel - button.pure-button.pure-button-primary(@click="start_pause") Run - + h3(slot="header") Run Macro? + div(slot="body") + p + | The macro file + strong {{state.selected}} + | is being loaded. + div(slot="footer") + button.pure-button(@click="macrosLoading=false") Cancel + button.pure-button.pure-button-primary(@click="start_pause") Run + message(:show.sync="GCodeNotFound") h3(slot="header") File not found div(slot="body") - p It seems like the file you selected cannot be found. Try uploading again. + p It seems like the file you selected cannot be found. Try uploading again. div(slot="footer") - button.pure-button.button-error(@click="GCodeNotFound=false") - | OK - + button.pure-button.button-error(@click="GCodeNotFound=false") OK + message(:show.sync="show_probe_dialog") h3(slot="header") Probe Rotary div(slot="body") @@ -46,485 +42,232 @@ script#control-view-template(type="text/x-template") div(slot="footer") button.pure-button(@click="show_probe_dialog=false") Cancel + // ----- Main grid: jog | (DRO + status strip) ----- + .control-grid - table(style="table-layout: fixed; width: 100%;") - tr(style="height: fit-content;") - td(style="white-space: nowrap; width: 410px;", rowspan="2") - table.control-buttons(table-layout="fixed") - colgroup - col(style="width:100px") - col(style="width:100px") - col(style="width:100px") - col(style="width:100px") - tr - td(style="height:100px",align="center") - button(@click="jog_fn(-1,1,0,0)") - .fa.fa-arrow-right(style="transform: rotate(-135deg);") - td(style="height:100px",align="center") - button(@click="jog_fn(0,1,0,0)") Y+ - td(style="height:100px",align="center") - button(@click="jog_fn(1,1,0,0)") - .fa.fa-arrow-right(style="transform: rotate(-45deg);") - td(style="height:100px",align="center") - button(,@click="jog_fn(0,0,1,0)") Z+ - tr - td(style="height:100px",align="center") - button(@click="jog_fn(-1,0,0,0)") X- - td(style="height:100px",align="center") - button(@click="showMoveToZeroDialog('xy')") - | XY - br - | Origin - td(style="height:100px",align="center") - button(@click="jog_fn(1,0,0,0)") X+ - td(style="height:100px",align="center") - button(@click="showMoveToZeroDialog('z')") - | Z - br - | Origin - tr - td(style="height:100px",align="center") - button(@click="jog_fn(-1,-1,0,0)") - .fa.fa-arrow-right(style="transform: rotate(135deg);") - td(style="height:100px",align="center") - button(@click="jog_fn(0,-1,0,0)") Y- - td(style="height:100px",align="center") - button(@click="jog_fn(1,-1,0,0)") - .fa.fa-arrow-right(style="transform: rotate(45deg);") - td(style="height:100px",align="center") - button(@click="jog_fn(0,0,-1,0)") Z- - tr - td(style="height:100px",align="center") - button(:style="getJogIncrStyle('fine')", @click="jog_incr = 'fine'") - span {{jog_incr_amounts[display_units].fine}}#[span.jog-units {{metric ? 'mm' : 'in'}}] - td(style="height:100px",align="center") - button(:style="getJogIncrStyle('small')", @click="jog_incr = 'small'") - span {{jog_incr_amounts[display_units].small}}#[span.jog-units {{metric ? 'mm' : 'in'}}] - td(style="height:100px",align="center") - button(:style="getJogIncrStyle('medium')", @click="jog_incr = 'medium'") - span {{jog_incr_amounts[display_units].medium}}#[span.jog-units {{metric ? 'mm' : 'in'}}] - td(style="height:100px",align="center") - button(:style="getJogIncrStyle('large')", @click="jog_incr = 'large'") - span {{jog_incr_amounts[display_units].large}}#[span.jog-units {{metric ? 'mm' : 'in'}}] + // ===== JOG ===== + .jog-card + .jog-head + .jog-title + | Jog + span.step-pre · step + span.step {{jog_incr_amounts[display_units][jog_incr]}}#[span.unit {{metric ? 'mm' : 'in'}}] + .step-seg + button(:class="{active: jog_incr === 'fine'}", @click="jog_incr = 'fine'") + | {{jog_incr_amounts[display_units].fine}} + button(:class="{active: jog_incr === 'small'}", @click="jog_incr = 'small'") + | {{jog_incr_amounts[display_units].small}} + button(:class="{active: jog_incr === 'medium'}", @click="jog_incr = 'medium'") + | {{jog_incr_amounts[display_units].medium}} + button(:class="{active: jog_incr === 'large'}", @click="jog_incr = 'large'") + | {{jog_incr_amounts[display_units].large}} - // W axis jog row (auxcnc). Only shown when the aux controller - // is enabled in aux.json. We treat home == 0 for the W axis, - // so there is no separate "set zero" / "W origin" button - - // just W-, W+, and Home. - tr(v-if="w.enabled") - td(style="height:100px", align="center", colspan="1") - button(@click="aux_jog_incr(-1)", - :disabled="!w.enabled", - style="display:grid;justify-content:center;align-items:center;padding:14px;") - | W- - .fa.fa-arrow-down + .jog-grid + // Row 1 + button.jbtn.dir(@click="jog_fn(-1, 1, 0, 0)", title="X- Y+") + .fa.fa-arrow-up.ico(style="transform: rotate(-45deg)") + button.jbtn(@click="jog_fn(0, 1, 0, 0)") Y+ + button.jbtn.dir(@click="jog_fn(1, 1, 0, 0)", title="X+ Y+") + .fa.fa-arrow-up.ico(style="transform: rotate(45deg)") + button.jbtn(@click="jog_fn(0, 0, 1, 0)") Z+ - td(style="height:100px", align="center", colspan="1") - button(@click="aux_jog_incr(+1)", - :disabled="!w.enabled", - style="display:grid;justify-content:center;align-items:center;padding:14px;") - | W+ - .fa.fa-arrow-up + // Row 2 + button.jbtn(@click="jog_fn(-1, 0, 0, 0)") X− + button.jbtn.ghost(@click="showMoveToZeroDialog('xy')") + span.lbl XY + span Origin + button.jbtn(@click="jog_fn(1, 0, 0, 0)") X+ + button.jbtn.ghost(@click="showMoveToZeroDialog('z')") + span.lbl Z + span Origin - td(style="height:100px", align="center", colspan="2") - button(@click="aux_home()", :disabled="!w.enabled", - style="height:100px;width:200px") - | Home - br - | W + // Row 3 + button.jbtn.dir(@click="jog_fn(-1, -1, 0, 0)", title="X- Y-") + .fa.fa-arrow-down.ico(style="transform: rotate(45deg)") + button.jbtn(@click="jog_fn(0, -1, 0, 0)") Y− + button.jbtn.dir(@click="jog_fn(1, -1, 0, 0)", title="X+ Y-") + .fa.fa-arrow-down.ico(style="transform: rotate(-45deg)") + button.jbtn(@click="jog_fn(0, 0, -1, 0)") Z− - tr(v-if="state['2an'] == 3") - td(style="height:100px", align="center", colspan="1") - button(@click="show_probe_dialog=true") - | Probe - br - | Rotary + // Row 4 — auxiliary axis (W or A) or probe shortcuts + template(v-if="w.enabled") + button.jbtn(@click="aux_jog_incr(-1)", :disabled="!w.enabled") + .fa.fa-arrow-down.ico + span.lbl W− + button.jbtn.ghost(@click="aux_home()", :disabled="!w.enabled") + span.lbl Home + span W + button.jbtn(@click="aux_jog_incr(+1)", :disabled="!w.enabled") + .fa.fa-arrow-up.ico + span.lbl W+ + button.jbtn(@click="show_probe_dialog=true", + :class="{'load-on': !state['pw']}") + .fa.fa-bullseye.ico + span.lbl Probe + template(v-else-if="state['2an'] == 3") + button.jbtn.dir(@click="jog_fn(0, 0, 0, -1)") + .fa.fa-rotate-left.ico + span.lbl A− + button.jbtn.ghost(@click="showMoveToZeroDialog('a')") + span.lbl A + span Origin + button.jbtn.dir(@click="jog_fn(0, 0, 0, 1)") + .fa.fa-rotate-right.ico + span.lbl A+ + button.jbtn(@click="show_probe_dialog=true", + :class="{'load-on': !state['pw']}") + .fa.fa-bullseye.ico + span.lbl Probe + template(v-else) + button.jbtn(@click="showProbeDialog('xyz')", + :class="{'load-on': !state['pw']}") + .fa.fa-bullseye.ico + span.lbl Probe XYZ + button.jbtn.ghost(@click="zero()", :disabled="!can_set_axis") + .fa.fa-map-marker.ico + span.lbl Zero all + button.jbtn(@click="showProbeDialog('z')", + :class="{'load-on': !state['pw']}") + .fa.fa-bullseye.ico + span.lbl Probe Z + button.jbtn.ghost(@click="home()", :disabled="!is_idle") + .fa.fa-home.ico + span.lbl Home all - td(style="height:100px", align="center", colspan="1") - button(@click="jog_fn(0,0,0,-1)", style="display: grid;justify-content: center;align-items: center;padding: 14px;") - | A- - .fa.fa-rotate-left - - td(style="height:100px", align="center", colspan="1") - button(@click="showMoveToZeroDialog('a')") - | A - br - | Origin + // ===== DRO + status strip ===== + .right-col - td(style="height:100px", align="center", colspan="1") - button(@click="jog_fn(0,0,0,1)", style="display: grid;justify-content: center;align-items: center;padding: 14px;") - | A+ - .fa.fa-rotate-right + .dro-card + .dro-head + div Axis + div Position + div Absolute + div Offset + div State + div Toolpath + div(style="text-align:right") Actions - tr(v-else) - td(style="height:100px", align="center", colspan="2") - button(:class="state['pw'] ? '' : 'load-on'", - style="height:100px;width:200px", - @click="showProbeDialog('xyz')") - | Probe XYZ - - td(style="height:100px", align="center", colspan="2") - button(:class="state['pw'] ? '' : 'load-on'", - style="height:100px;width:200px", - @click="showProbeDialog('z')") - | Probe Z - - td(style="vertical-align: top;") - table.axes - tr(:class="axes.klass") - th.name Axis - th.position Position - th.absolute Absolute - th.offset Offset - th.state State - th.tstate Toolpath - th.actions - button.pure-button(disabled, style="height:60px;width:60px;display:none;") - - button.pure-button(:disabled="!can_set_axis", - title="Zero all axis offsets.", @click="zero()",style="height:60px;width:60px") - .fa.fa-map-marker - - button.pure-button(title="Home all axes.", @click="home()", - :disabled="!is_idle",style="height:60px;width:60px") - .fa.fa-home - - each axis in 'xyzabc' - tr.axis(:class=`${axis}.klass`, v-if=`${axis}.enabled`, - :title=`${axis}.title`) - th.name= axis - td.position: unit-value(:value=`${axis}.pos`, precision=4) - td.absolute: unit-value(:value=`${axis}.abs`, precision=3) - td.offset: unit-value(:value=`${axis}.off`, precision=3) - td.state + // Per-axis rows — keep unit-value + bindings from axis-vars + each axis in 'xyzabc' + .dro-row(:class=`${axis}.klass + ' ' + ${axis}.tklass`, + v-if=`${axis}.enabled`, + :title=`${axis}.title`) + .dro-axis(:class=`'axis-' + '${axis}'`)= axis.toUpperCase() + .dro-pos: unit-value(:value=`${axis}.pos`, precision=4) + .dro-sec: unit-value(:value=`${axis}.abs`, precision=3) + .dro-sec: unit-value(:value=`${axis}.off`, precision=3) + .dro-state + span.chip(:class=`${axis}.tklass.indexOf('error') !== -1 ? 'chip-red' : (${axis}.homed ? 'chip-green' : 'chip-amber')`) .fa(:class=`'fa-' + ${axis}.icon`) - | {{#{axis}.state}} - td.tstate(:class=`${axis}.tklass`, :title=`${axis}.toolmsg`, @click=`showToolpathMessageDialog('${axis}')`) + |  {{#{axis}.state}} + .dro-toolpath + span.chip(:class=`${axis}.tklass.indexOf('error') !== -1 ? 'chip-red' : (${axis}.tklass.indexOf('warn') !== -1 ? 'chip-amber' : 'chip-green')`, + @click=`showToolpathMessageDialog('${axis}')`) .fa(:class=`'fa-' + ${axis}.ticon`) - | {{#{axis}.tstate}} - - th.actions - button.pure-button(:disabled="!can_set_axis", - title=`Set {{'${axis}' | upper}} axis position.`, - @click=`show_set_position('${axis}')`, style="height:60px;width:60px") - .fa.fa-cog - - button.pure-button(:disabled="!can_set_axis", - title=`Zero {{'${axis}' | upper}} axis offset.`, - @click=`zero('${axis}')`, style="height:60px;width:60px") - .fa.fa-map-marker - - button.pure-button(:disabled="!is_idle", @click=`home('${axis}')`, - title=`Home {{'${axis}' | upper}} axis.`, style="height:60px;width:60px") - .fa.fa-home - - // Auxiliary W axis (auxcnc ESP32 over /dev/ttyUSB0) - tr.axis(:class="w.klass", v-if="w.enabled", :title="w.title") - th.name w - td.position: unit-value(:value="w.pos", precision=4) - td.absolute: unit-value(:value="w.abs", precision=3) - td.offset — - td.state - .fa(:class="'fa-' + w.icon") - | {{w.state}} - td.tstate(:class="w.tklass", :title="w.toolmsg") - .fa(:class="'fa-' + w.ticon") - | {{w.tstate}} - - th.actions - // Invisible placeholders so the W home button lines up - // under the X/Y/Z home column. The W axis has no "set - // position" cog and no "zero offset" marker - home == 0. - button.pure-button(disabled, - style="height:60px;width:60px;visibility:hidden") + |  {{#{axis}.tstate}} + .actions-cell + button.icon-btn(:disabled="!can_set_axis", + :title=`'Set ${axis.toUpperCase()} axis position.'`, + @click=`show_set_position('${axis}')`) .fa.fa-cog - - button.pure-button(disabled, - style="height:60px;width:60px;visibility:hidden") + button.icon-btn(:disabled="!can_set_axis", + :title=`'Zero ${axis.toUpperCase()} axis offset.'`, + @click=`zero('${axis}')`) .fa.fa-map-marker - - button.pure-button(:disabled="!w.enabled", @click="aux_home()", - title="Home W axis.", style="height:60px;width:60px") + button.icon-btn(:disabled="!is_idle", + :title=`'Home ${axis.toUpperCase()} axis.'`, + @click=`home('${axis}')`) .fa.fa-home - - tr(style="vertical-align: top;") - td - table(width="100%") - tr - td(style="text-align:center") - table.info - tr - th State - td(:class="{attention: highlight_state}") {{mach_state}} - tr - th Message - td.message(:class="{attention: highlight_state}") - | {{message.replace(/^#/, '')}} + // W axis (auxiliary) — no offset, no set-zero / no set-position + .dro-row(:class="w.klass + ' ' + w.tklass", v-if="w.enabled", + :title="w.title") + .dro-axis.axis-w W + .dro-pos: unit-value(:value="w.pos", precision=4) + .dro-sec: unit-value(:value="w.abs", precision=3) + .dro-sec — + .dro-state + span.chip(:class="w.homed ? 'chip-green' : 'chip-amber'") + .fa(:class="'fa-' + w.icon") + |  {{w.state}} + .dro-toolpath + span.chip.chip-green + .fa(:class="'fa-' + w.ticon") + |  {{w.tstate}} + .actions-cell + button.icon-btn(disabled, style="visibility:hidden") + .fa.fa-cog + button.icon-btn(disabled, style="visibility:hidden") + .fa.fa-map-marker + button.icon-btn(:disabled="!w.enabled", + title="Home W axis.", @click="aux_home()") + .fa.fa-home - tr - th Display Units - td.units - select(v-model="display_units") - option(value="METRIC") METRIC - option(value="IMPERIAL") IMPERIAL + // ----- Status strip ----- + .status-strip + .stat-card + .stat-label State + .stat-val(:class="state_kpi_class") {{mach_state || '--'}} + .stat-sub(v-if="message") {{message.replace(/^#/, '')}} + .stat-sub(v-else) No alerts - tr(title="Active tool") - th Tool - td {{state.tool || 0}} + .stat-card + .stat-label Velocity / Feed + .stat-val + unit-value(:value="state.v", precision="2", unit="", iunit="", + scale="0.0254") + | ·  + unit-value(:value="state.feed", precision="0", unit="", iunit="") + .stat-sub {{metric ? 'm/min · mm/min' : 'IPM · IPM'}} - td - table.info - tr( - title="Current velocity in {{metric ? 'meters' : 'inches'}} per minute") - th Velocity - td - unit-value(:value="state.v", precision="2", unit="", iunit="", - scale="0.0254") - | {{metric ? ' m/min' : ' IPM'}} + .stat-card.stat-tappable(@click="overrides_open = !overrides_open", + :class="{open: overrides_open}", title="Tap to adjust feed/spindle override") + .stat-label Spindle + .stat-val + | {{(state.speed || 0) | fixed 0}} + span(v-if="state.s != null && !isNaN(state.s)") ({{state.s | fixed 0}}) + .stat-sub + | RPM (commanded / actual) + .fa.fa-sliders.tap-hint(title="Open override drawer") - tr(title="Programmed feed rate.") - th Feed - td - unit-value(:value="state.feed", precision="2", unit="", iunit="") - | {{metric ? ' mm/min' : ' IPM'}} + .stat-card + .stat-label Job + .stat-val + | {{0 <= state.line ? state.line : 0 | number}} + span(v-if="toolpath.lines") + | / {{toolpath.lines | number}} + .stat-sub(v-if="plan_time_remaining || toolpath.time") + | Line · {{plan_time_remaining ? (plan_time_remaining | time) : (toolpath.time | time)}} remaining + .stat-sub(v-else) Line · ETA -- - tr(title="Programed and actual speed.") - th Speed - td - | {{state.speed || 0 | fixed 0}} - span(v-if="!isNaN(state.s)")  ({{state.s | fixed 0}}) - = ' RPM' + // ----- Macro row (slice 0..7); full list lives in Settings → Macros ----- + .macro-row(v-if="state.macros && state.macros.length") + button.macro-btn(v-for="(index, macros) in state.macros.slice(0, 8)", + title="Click to run macro", + @click="run_macro(index)", + :disabled="!is_ready", + :style="{ borderLeftColor: macros.color || '#fde047' }") + span.mnum {{index + 1}} + .fa.fa-circle-play.micon + span.mname {{macros.name || ('Macro ' + (index + 1))}} - tr(title="Load switch states.") - th Loads - td - span(:class="state['1oa'] ? 'load-on' : ''") - | 1:{{state['1oa'] ? 'On' : 'Off'}} - |   - span(:class="state['2oa'] ? 'load-on' : ''") - | 2:{{state['2oa'] ? 'On' : 'Off'}} - - td - table.info - tr - th Remaining - td(title="Total run time (days:hours:mins:secs)"). - #[span(v-if="plan_time_remaining") {{plan_time_remaining | time}} of] - {{toolpath.time | time}} - - tr - th ETA - td.eta {{eta}} - - tr - th Line - td - | {{0 <= state.line ? state.line : 0 | number}} - span(v-if="toolpath.lines") - |  of {{toolpath.lines | number}} - - tr - th Progress - td.progress - label {{(progress || 0) | percent}} - .bar(:style="'width:' + (progress || 0) * 100 + '%'") - - .macros-div(class="present") - button.macros-button(title="Click to run Macros",v-for="(index,macros) in state.macros", - @click="run_macro(index)",:disabled="!is_ready",v-bind:style="{ backgroundColor: macros.color }") {{macros.name}} - - .tabs - - input#tab1(type="radio", name="tabs",checked="" @click="tab = 'auto'") - label(for="tab1", title="Run GCode programs",style="height:50px;width:100px") Auto - - input#tab2(type="radio", name="tabs", @click="tab = 'mdi'") - label(for="tab2", title="Manual GCode entry",style="height:50px;width:100px") MDI - - input#tab3(type="radio", name="tabs", @click="tab = 'messages'") - label(for="tab3",style="height:50px;width:100px") Messages - - input#tab4(type="radio", name="tabs", @click="tab = 'indicators'") - label(for="tab4",style="height:50px;width:100px") Indicators - - - - - section#content1.tab-content.pure-form - .toolbar.pure-control-group - button.pure-button(:class="{'attention': is_holding}", - title="{{is_running ? 'Pause' : 'Start'}} program.", - @click="start_pause", :disabled="!state.selected", - style="height:100px;width:100px;font-weight:normal") - img(v-if="is_running" src="images/pause_gcode.png" style="height: 55px;") - img(v-else src="images/play_gcode.png" style="height: 55px;") - - button.pure-button(title="Stop program.", @click="stop", style="height:100px;width:100px;font-weight:normal") - img(src="images/stop.png" style="height: 55px;") - - button.pure-button(title="Pause program at next optional stop (M1).", - @click="optional_pause", v-if="false", style="height:100px;width:100px;font-weight:normal") - .fa.fa-stop-circle-o - - message(:show.sync="uploading_files") - h3(slot="header") Files uploading - div(slot="body") - h3 Please wait... - p - p The files are currently being uploaded. - p Do not close the window. - div(slot="footer") - - button.pure-button(title="Execute one program step.", @click="step", - :disabled="(!is_ready && !is_holding) || !state.selected", - v-if="false", style="height:100px;width:100px;font-weight:normal") - .fa.fa-step-forward - - button.pure-button(title="Upload a new GCode folder.", @click="open_folder", - :disabled="!is_ready",style="height:100px;width:100px;font-weight:normal") - img(src="images/upload_folder.png" style="height: 65px;") - - form.gcode-folder-input.file-upload - input#folderInput(type="file", @change="upload_folder", :disabled="!is_ready", - webkitdirectory, directory) - - button.pure-button(title="Upload a new GCode program.", @click="open_file", - :disabled="!is_ready",style="height:100px;width:100px;font-weight:normal") - img(src="images/upload_gcode.png" style="height: 65px;") - - form.gcode-file-input.file-upload - input(type="file", @change="upload_file", :disabled="!is_ready", - accept=".nc,.ngc,.gcode,.gc", multiple) - - a(:disabled="!state.selected", download, - :href="'/api/file/' + state.selected", - title="Download the selected GCode program.") - button.pure-button(:disabled="!state.selected", style="height:100px;width:100px") - img(src="images/download_gcode.png" style="height: 65px;") - - button.pure-button(title="Delete current GCode program.", - @click="deleteGCode = true", - :disabled="!state.selected || !is_ready",style="height:100px;width:100px;font-weight:normal") - img(src="images/delete_gcode.png" style="height: 55px;") - - message.error-message(:show.sync="deleteGCode") - h3(slot="header") Select files to delete: - div(slot="body") - input.search-bar(type="text", v-model="search_query", placeholder="Search Files...") - .container - .folders - h3 Folders - div(v-for="(index, folder) in state.gcode_list", :key="index", @click="populateFiles(index)", - class="folder-item", :class="{ selected: index === selected_folder_index }") {{ folder.name }} - .files - h3 Files - label.file-item(v-for="item in gcode_filtered_files" :key="item") - input(type="checkbox" :value="item" v-model="selected_items_to_delete") - | {{ item }} - div(slot="footer") - button.pure-button(@click="cancel_delete",style="height:50px") Cancel - //- button.pure-button.button-error(@click="delete_all_except_macros") - //- .fa.fa-trash - //- |  All - button.pure-button.button-success(@click="delete_current",style="height:50px") - .fa.fa-trash - |  Selected - - .drop-down-container - message(:show.sync="create_folder") - h3(slot="header") Enter folder name: - div(slot="body") - input.input-name(type="text",minlength='1',maxlength='15',style ="margin-top:1rem;margin-bottom:2rem;", - id="folder-name" ,v-model="folder_name",@keypress="edited_folder_name") - - div(slot="footer") - button.pure-button(@click="cancel_new_folder") Cancel - button.pure-button.button-success(@click="create_new_folder",:disabled="!edited") - | Create - - message(:show.sync="confirmDelete") - h3(slot="header") Delete Folder? - div(slot="body") - p Are you sure to delete the folder? - - div(slot="footer") - button.pure-button(@click="confirmDelete=false") Cancel - button.pure-button.button-error(@click="delete_folder") Folder only - button.pure-button.button-success(@click="delete_folder_and_files") Folder and files - - button.pure-button(title="Create a new folder.", @click="create_folder=true", - :disabled="!is_ready",style="height:100%") - | Create Folder - - button.pure-button(title="Delete a folder.", @click="confirmDelete=true", - :disabled="!is_ready",style="height:100%;margin-left:5px") - | Delete Folder - - select(title="Select previously uploaded GCode folder.", - v-model="state.folder", @change="reset_gcode", :disabled="!is_ready", - style="max-width:100%;margin-left:5px") - option( selected='' value='default') Default folder - option(v-for="file in gcode_folders", :value="file") {{file}} - - select(title="Select previously uploaded GCode programs.", - v-model="state.selected", @change="load", :disabled="!is_ready", - style="max-width:300px;margin-left:5px") - option(v-for="file in gcode_files", :value="file") {{file}} - - button.pure-button(@click="toggle_sorting", :disabled="!is_ready", - style="height:75%") - | {{files_sortby}} - - .progress(v-if="toolpath_progress && toolpath_progress < 1", - title="Simulating GCode to check for errors, calculate ETA and " + - "generate 3D view. You can run GCode before the simulation " + - "finishes.") - div(:style="'width:' + (toolpath_progress || 0) * 100 + '%'") - label Simulating {{(toolpath_progress || 0) | percent}} - - path-viewer(:toolpath="toolpath", :state="state", :config="config") - gcode-viewer - - section#content2.tab-content - .mdi.pure-form(title="Manual GCode entry.") - button.pure-button(:disabled="!can_mdi", - :class="{'attention': is_holding}", - title="{{is_running ? 'Pause' : 'Start'}} command.", - @click="mdi_start_pause",style="height:100px;width:100px") - .fa(:class="is_running ? 'fa-pause' : 'fa-play'") - - button.pure-button(title="Stop command.", @click="stop",style="height:100px;width:100px") - .fa.fa-stop - - input(v-model="mdi", :disabled="!can_mdi", @keyup.enter="submit_mdi") - - div - em The machine is currently operating in #[strong {{mach_units}}] units. Use G20/G21 to switch units. - - .history(:class="{placeholder: !history}") - span(v-if="!history.length") MDI history displays here. - ul - li(v-for="item in history", @click="load_history($index)", - track-by="$index") - | {{item}} - - section#content3.tab-content - console - - section#content4.tab-content - indicators(:state="state", :template="template") - - .override(title="Feed rate override.") - label Feed - input(type="range", min="0", max="2", step="0.01", - v-model="feed_override", @change="override_feed") - span.percent {{feed_override | percent 0}} - - .override(title="Spindle speed override.") - label Speed - input(type="range", min="0", max="2", step="0.01", - v-model="speed_override", @change="override_speed") - span.percent {{speed_override | percent 0}} - - + // ----- Override drawer (anchored to bottom; toggled by Spindle KPI tile) ----- + .override-drawer(:class="{open: overrides_open}") + .od-head + .od-title + .fa.fa-sliders + |  Overrides + button.od-close(@click="overrides_open = false") ✕ + .od-body + .od-row + label Feed + input(type="range", min="0", max="2", step="0.01", + v-model="feed_override", @change="override_feed") + .od-val {{feed_override | percent 0}} + button.od-reset(@click="feed_override = 1; override_feed()") Reset 100% + .od-row + label Spindle + input(type="range", min="0", max="2", step="0.01", + v-model="speed_override", @change="override_speed") + .od-val {{speed_override | percent 0}} + button.od-reset(@click="speed_override = 1; override_speed()") Reset 100% diff --git a/src/pug/templates/program-view.pug b/src/pug/templates/program-view.pug new file mode 100644 index 0000000..bc4d4c0 --- /dev/null +++ b/src/pug/templates/program-view.pug @@ -0,0 +1,137 @@ +script#program-view-template(type="text/x-template") + .program-page + + // ----- Modal dialogs ----- + message(:show.sync="showGcodeMessage") + h3(slot="header") Processing New File + div(slot="body") + h3 Please wait.. + p Simulating GCode to check for errors, calculate ETA and generate 3D view. + div(slot="footer") + label Simulating {{(toolpath_progress || 0) | percent}} + + message(:show.sync="GCodeNotFound") + h3(slot="header") File not found + div(slot="body") + p It seems like the file you selected cannot be found. Try uploading again. + div(slot="footer") + button.pure-button.button-error(@click="GCodeNotFound=false") OK + + message(:show.sync="uploading_files") + h3(slot="header") Files uploading + div(slot="body") + h3 Please wait... + p + p The files are currently being uploaded. + p Do not close the window. + div(slot="footer") + + message.error-message(:show.sync="deleteGCode") + h3(slot="header") Select files to delete: + div(slot="body") + input.search-bar(type="text", v-model="search_query", placeholder="Search Files...") + .container + .folders + h3 Folders + div(v-for="(index, folder) in state.gcode_list", :key="index", + @click="populateFiles(index)", + class="folder-item", + :class="{ selected: index === selected_folder_index }") {{ folder.name }} + .files + h3 Files + label.file-item(v-for="item in gcode_filtered_files", :key="item") + input(type="checkbox", :value="item", v-model="selected_items_to_delete") + | {{ item }} + div(slot="footer") + button.pure-button(@click="cancel_delete", style="height:50px") Cancel + button.pure-button.button-success(@click="delete_current", style="height:50px") + .fa.fa-trash + |  Selected + + message(:show.sync="create_folder") + h3(slot="header") Enter folder name: + div(slot="body") + input.input-name(type="text", minlength="1", maxlength="15", + style="margin-top:1rem;margin-bottom:2rem;", + id="folder-name", v-model="folder_name", @keypress="edited_folder_name") + div(slot="footer") + button.pure-button(@click="cancel_new_folder") Cancel + button.pure-button.button-success(@click="create_new_folder", :disabled="!edited") Create + + message(:show.sync="confirmDelete") + h3(slot="header") Delete Folder? + div(slot="body") + p Are you sure to delete the folder? + div(slot="footer") + button.pure-button(@click="confirmDelete=false") Cancel + button.pure-button.button-error(@click="delete_folder") Folder only + button.pure-button.button-success(@click="delete_folder_and_files") Folder and files + + .program-card + + // Action bar (RUN / STOP / Upload / Download / Delete) + .action-bar + button.action-btn.run(:class="{'attention': is_holding}", + @click="start_pause", :disabled="!state.selected", + :title="is_running ? 'Pause program.' : 'Start program.'") + .fa.fa-play.ico(v-if="!is_running") + .fa.fa-pause.ico(v-else) + span {{is_running ? 'PAUSE' : 'RUN'}} + button.action-btn.stop(@click="stop", title="Stop program.") + .fa.fa-stop.ico + span STOP + button.action-btn(@click="open_folder", :disabled="!is_ready", + title="Upload a new GCode folder.") + .fa.fa-folder-arrow-up.ico + span UPLOAD FOLDER + form.gcode-folder-input.file-upload + input#folderInput(type="file", @change="upload_folder", + :disabled="!is_ready", webkitdirectory, directory) + button.action-btn(@click="open_file", :disabled="!is_ready", + title="Upload a new GCode program.") + .fa.fa-file-arrow-up.ico + span UPLOAD FILE + form.gcode-file-input.file-upload + input(type="file", @change="upload_file", :disabled="!is_ready", + accept=".nc,.ngc,.gcode,.gc", multiple) + a(:href="state.selected ? '/api/file/' + state.selected : '#'", + download, :class="{disabled: !state.selected}", + title="Download the selected GCode program.") + button.action-btn(:disabled="!state.selected") + .fa.fa-file-arrow-down.ico + span DOWNLOAD FILE + button.action-btn.danger(@click="deleteGCode = true", + :disabled="!state.selected || !is_ready", + title="Delete current GCode program.") + .fa.fa-trash.ico + span DELETE + + // File / folder selectors + .file-bar + button.file-btn(@click="create_folder=true", :disabled="!is_ready") + .fa.fa-folder-plus + |  Create Folder + button.file-btn(@click="confirmDelete=true", :disabled="!is_ready") + .fa.fa-folder-minus + |  Delete Folder + select.file-select(title="Select previously uploaded GCode folder.", + v-model="state.folder", @change="reset_gcode", :disabled="!is_ready") + option(selected, value="default") Default folder + option(v-for="file in gcode_folders", :value="file") {{file}} + select.file-select.primary(title="Select previously uploaded GCode programs.", + v-model="state.selected", @change="load", :disabled="!is_ready") + option(value="") (no file) + option(v-for="file in gcode_files", :value="file") {{file}} + button.file-btn(@click="toggle_sorting", :disabled="!is_ready") + .fa.fa-arrow-down-wide-short + |  {{files_sortby}} + + // Body: gcode listing on the left, 3D viewer on the right + .program-body + gcode-viewer + path-viewer(:toolpath="toolpath", :state="state", :config="config") + + .progress-bar(v-if="toolpath_progress && toolpath_progress < 1", + title="Simulating GCode to check for errors, calculate ETA and generate 3D view.") + div(:style="'width:' + (toolpath_progress || 0) * 100 + '%'") + label Simulating {{(toolpath_progress || 0) | percent}} diff --git a/src/pug/templates/settings-shell.pug b/src/pug/templates/settings-shell.pug new file mode 100644 index 0000000..3c605b1 --- /dev/null +++ b/src/pug/templates/settings-shell.pug @@ -0,0 +1,44 @@ +script#settings-shell-view-template(type="text/x-template") + .settings-shell + aside.settings-rail + // Use a single v-for over a data-driven items array so every + // rail item shares the same compiled :class binding template. + // This sidesteps a Vue 1 quirk where sibling-with-different- + // expression :class bindings sometimes fail to re-evaluate on + // hash navigation, leaving stale `.active` classes. + template(v-for="item in rail_items") + .set-section(v-if="item.section") {{item.section}} + a.set-item(v-if="!item.section", :class="{active: is_active(item)}", + :href="item.href") + .fa(:class="item.icon") + |  {{item.label}} + .set-rail-foot + button.sp-shutdown(@click="showShutdownDialog") + .fa.fa-power-off + |  Shutdown + button.sp-save(:disabled="!$root.modified", @click="$root.save()") + .fa.fa-save + |  Save{{$root.modified ? '*' : ''}} + + .settings-content + // Explicit v-if cascade so the inner template swaps reactively + // when sub changes (Vue 1's `` does not always + // re-evaluate dynamic strings inside a kept-alive parent). + settings-view-inner(v-if="sub === 'settings'", + :index="index", :config="config", :template="template", :state="state") + admin-general-view(v-if="sub === 'admin-general'", + :index="index", :config="config", :template="template", :state="state") + admin-network-view(v-if="sub === 'admin-network'", + :index="index", :config="config", :template="template", :state="state") + motor-view(v-if="sub === 'motor'", + :index="index", :config="config", :template="template", :state="state") + tool-view(v-if="sub === 'tool'", + :index="index", :config="config", :template="template", :state="state") + io-view(v-if="sub === 'io'", + :index="index", :config="config", :template="template", :state="state") + macros-view(v-if="sub === 'macros'", + :index="index", :config="config", :template="template", :state="state") + help-view(v-if="sub === 'help'", + :index="index", :config="config", :template="template", :state="state") + cheat-sheet-view(v-if="sub === 'cheat-sheet'", + :index="index", :config="config", :template="template", :state="state") diff --git a/src/stylus/style.styl b/src/stylus/style.styl index f64568a..007c7da 100644 --- a/src/stylus/style.styl +++ b/src/stylus/style.styl @@ -1,12 +1,44 @@ +// ===================================================================== +// V09 redesign tokens & chrome +// ===================================================================== +// +// The new shell wraps everything in `.app-shell { .app-head | .app-body }`. +// Inner views use the shared tokens below for jog/macro tiles, status +// chips and segmented controls. Anything legacy that's still needed by +// settings/admin/motor/tool/io templates remains lower in this file. + +$ink = #0f172a +$ink-soft = #334155 +$muted = #64748b +$muted-2 = #94a3b8 +$line = #e5e7eb +$line-soft = #f1f5f9 +$bg = #ffffff +$body-bg = #f1f5f9 + +$accent = #fde047 +$accent-ink = $ink +$accent-text = #0ea5e9 + +// Jog tile palette — V09 (flat soft slate, no shadow) +$jog-bg = #3f4b63 +$jog-hover = #4a5777 +$jog-dir = #5b6885 +$jog-dir-hov = #6a779a +$jog-ghost = #8c97ad +$jog-ghost-hov = #9ba6bb +$jog-ink = #fff +$jog-ghost-ink = $ink + body - overflow-y scroll + margin 0 + font-family 'Inter', system-ui, -apple-system, sans-serif + background $body-bg + color $ink [v-cloak] display none -.menu-link - z-index unset - tt color #000 background #eee @@ -32,168 +64,340 @@ tt background-color #0078e7 color #fff - -.header, .content - padding 0 - .clear clear left clear right -.header - height 140px - padding 0 +// ===================================================================== +// App shell +// ===================================================================== +.app-shell + display flex + flex-direction column + min-height 100vh + background $body-bg -.nav-header - padding-left 60px - display flex +.app-body + flex 1 + min-height 0 + display flex + flex-direction column + padding 18px - .brand + > * + flex 1 + min-height 0 + +.app-head + flex 0 0 96px + height 96px + display flex + align-items center + gap 18px + padding 0 24px + background $bg + border-bottom 1px solid $line + position relative + z-index 30 + + .brand-blk display flex - flex-direction row - align-self center - white-space nowrap + align-items center + gap 14px + + .brand-logo + width 42px + height 42px + border-radius 8px + background repeating-linear-gradient(135deg, #a7c7a3 0 6px, transparent 6px 14px) + + .brand-name + font-weight 900 + font-size 22px + letter-spacing -0.01em + + .head-spacer + flex 1 + +// Underline-ribbon tabs +.tabs-host + display inline-flex + gap 0 + margin-right auto + padding-left 18px + align-items stretch + height 96px + +.ktab + position relative + height 96px + padding 0 26px + display inline-flex + align-items center + gap 0.55rem + background transparent + border none + text-decoration none + color $ink-soft + font-size 1.05rem + font-weight 700 + cursor pointer + transition color .15s + + .fa + font-size 1.1rem + color $muted-2 + transition color .15s + + &:hover + color $ink + .fa + color $ink-soft + + &.active + color $ink + .fa + color $ink + + &.active::after + content "" + position absolute + left 14px + right 14px + bottom 0 + height 5px + background $accent + border-radius 5px 5px 0 0 + + .ktab-badge + background #fee2e2 + color #991b1b + font-size 0.7rem + padding 3px 8px + border-radius 9999px + font-weight 800 + line-height 1 + + &.active .ktab-badge + background $accent + color $accent-ink + +// System pill (collapses old chip-soup) +.sys-btn + display inline-flex + align-items center + gap 0.55rem + height 54px + padding 0 1.1rem + border-radius 14px + background $line-soft + border 1px solid $line + color $ink + font-size 0.9rem + font-weight 600 + cursor pointer + user-select none + + &:hover + background #e2e8f0 + + .pip + width 9px + height 9px + border-radius 9999px + background #22c55e + + .pip.amber + background #f59e0b + + .pip.red + background #dc2626 + + .fa-chevron-down + color $muted-2 + font-size 12px + + &.open + background #e2e8f0 + +.pi-temp-warning + align-self center + margin 0 4px + color #dc2626 + font-size 24px + +.state-badge + display inline-flex + align-items center + gap 0.6rem + height 54px + padding 0 1.1rem + border-radius 14px + background #dcfce7 + color #166534 + font-weight 800 + font-size 1rem + letter-spacing 0.04em + + .dot + width 10px + height 10px + border-radius 9999px + background currentColor + position relative + + .dot::after + content "" + position absolute + inset -3px + border-radius 9999px + border 2px solid currentColor + opacity 0.5 + animation pulse-dot 1.6s ease-out infinite + + &.warn + background #fef3c7 + color #92400e + + &.bad + background #fee2e2 + color #991b1b + + &.busy + background #dbeafe + color #1e40af + + &.unknown + background $line-soft + color $muted + + &.unknown .dot::after + display none + +@keyframes pulse-dot + 0% + transform scale(0.7) + opacity 0.6 + 100% + transform scale(2.2) + opacity 0 + +@media (prefers-reduced-motion: reduce) + .state-badge .dot::after + animation none + +// System popover +.sys-popover + position absolute + top 96px + right 240px + width 360px + background $bg + border 1px solid $line + border-radius 14px + box-shadow 0 18px 40px rgba(15, 23, 42, 0.18) + padding 12px + z-index 40 + display flex + flex-direction column + gap 10px + + .sp-row + display grid + grid-template-columns 32px 1fr auto + gap 12px + align-items center + + .sp-icon + width 32px + height 32px + border-radius 8px + background $line-soft + color $ink-soft + display inline-flex + align-items center + justify-content center img - width 300px - height 15% + width 18px + height 18px - .version - font-size 18pt - color #777 - display flex - flex-direction column - justify-content space-evenly - border-left #777 2px solid; - margin-left 15px - padding 0px 10px - font-weight bold + .fa.sp-warn + color #f59e0b - .upgrade-link - margin-left 20px - font-size 16pt - align-self center - color blue + .sp-label + font-size 0.7rem + text-transform uppercase + letter-spacing 0.1em + color $muted-2 + font-weight 800 - .upgrade-attention - color red - font-size 18pt - align-self center - margin-left 5px + .sp-val + font-size 0.95rem + color $ink + font-weight 600 - .pi-temp-warning - align-self center - font-size 30pt - font-family Audiowide - display inline - margin 0 30px - - .left - color #444 - .right - color #e5aa3d - - .easy-adapter - display flex - align-items center - gap 5px - padding 5px - margin 5px - - .round-dot - width 8px - height 8px - border-radius 50% - background-color #000000 - flex-shrink 0 - - .easy-adapter-text - margin 0 - padding 0 - - - .whitespace - flex-grow 1 - - .rotary-button - border 1px solid transparent - background transparent + .sp-act + height 36px + padding 0 12px + border-radius 8px + background $line-soft + border 1px solid $line + color $ink + font-size 0.8rem + font-weight 700 cursor pointer - width 100px - height 100px - border-radius 100px - margin 5px 0 5px 0 - display flex - justify-content center + text-decoration none + display inline-flex align-items center + gap 4px - &.active - border 1px solid #54ed54 - background #ccffcb - - &:focus - outline none - box-shadow none - - &:disabled - opacity 0.5 - background #e0e0e0 - border 1px solid black - cursor not-allowed - - .rotary-text - text-align center + .sp-act:hover + background #e2e8f0 .video + width 100% + height 180px + border-radius 8px + background #000 + overflow hidden position relative - width 174px - height 130px - border 2px solid transparent - border-radius 5px - - &:hover - border-color #aaa - - &.large - width 100% - margin 5px 0 - height inherit - - .crosshair - > * - border 1px dashed #ccc - position absolute - - .vertical - height 100% - width 0 - left 50% - margin-left -1px - - .horizontal - height 0 - width 100% - top 50% - margin-top -1px - - .box - width 16px - height 16px - top 50% - left 50% - margin-top -9px - margin-left -9px img width 100% height 100% + object-fit cover - .estop - align-self center - margin 0 30px + .sp-foot + display flex + gap 8px + margin-top 4px + padding-top 10px + border-top 1px solid $line-soft + + .sp-shutdown, .sp-save + flex 1 + height 40px + border-radius 10px + border 1px solid $line + background $line-soft + color $ink + font-weight 700 + cursor pointer + + .sp-shutdown + background #fef2f2 + color #991b1b + border-color #fecaca + + .sp-save:not([disabled]) + background $accent + color $ink + border-color $accent .error background red + color #fff .warn background orange @@ -268,467 +472,61 @@ span.unit position relative top 50% -#menu - .save - display block - margin 0.25em 0.6em - height 3.5em - width 8em +// Form rules used by the settings/admin/motor/tool/io templates that +// still rely on Pure form classes. +.app-body .pure-control-group + label.units + width 6em + text-align left + textarea + width 24em + height 12em - .pure-menu-heading - background inherit - padding 0 + > select, > input:not([type=checkbox]) + min-width 300px - .pure-menu-link - padding 0.6em - color #fff - - .pure-menu-item .pure-menu-link - padding-left 1.5em - - -#main - margin-left 0.5em - - .content - h2 - text-transform capitalize - - .pure-control-group - label.units - width 6em - text-align left - - textarea - width 24em - height 12em - - > select, > input:not([type=checkbox]) - min-width 300px - - > tt - min-width 15.25em - padding 0.7em 1em - border-radius 3px - display inline-block - + > tt + min-width 15.25em + padding 0.7em 1em + border-radius 3px + display inline-block @keyframes blink 50% fill #ff9d00 -.estop - width 130px - transition 250ms +// Octagonal STOP wrapper around the existing SVG. The SVG +// rules below (`.button`, `.ring`, etc.) keep working unchanged. +.app-head .estop + width 88px + height 88px + background #dc2626 + clip-path polygon(30% 0, 70% 0, 100% 30%, 100% 70%, 70% 100%, 30% 100%, 0 70%, 0 30%) + display flex + align-items center + justify-content center + border 3px solid #fff + box-shadow 0 0 0 3px #b91c1c, 0 8px 20px rgba(220, 38, 38, 0.35) + cursor pointer + transition transform 0.06s - &.active .ring - animation blink 2s step-start 0s infinite - - &:hover .button circle - fill #b72424 !important + &:active + transform scale(0.96) svg + width 56px + height 56px cursor pointer .button:hover filter brightness(120%) -.control-view - max-width 95% +.app-head .estop.active .ring + animation blink 2s step-start 0s infinite - .drop-down-container - height 50px - - .container - display flex - width 100% - height 25rem - margin-bottom 30px - - .folders - width 30% - border-right 1px solid #ccc - padding 10px - overflow-y auto - - .files - width 70% - padding 10px - display grid - overflow-y auto - - .search-bar - margin-bottom 10px - width 50% - - .file-item - padding 3px - - .folder-item - padding 10px - - .folder-item.selected - font-weight bold - background-color #add1ad - border-radius 5px - - table - border-collapse collapse - - // Make sure buttons don't turn into circles - button - -webkit-appearance none - border-radius 0px - border-width 1px - border-color darkgrey - - // The jogging buttons, etc. - .control-buttons button - font-size 150% - width 100px - height 100px - - .jog-units - font-size initial - margin-left 5px - - &:first-child - margin 0.5em 0 - - td, th - border none - - .radio-toolbar - input - opacity 0 - position fixed - width 0 - label - display inline-block - background-color #ddd - padding 10px 20px - font-family sans-serif Arial - font-size 16px - border 2px solid #444 - border-radius 4px - - .axes - width 100% - - .axis-x .name - color #f00 - - .axis-y .name - color #0f0 - - .axis-z .name - color #00f - - .axis-a .name - color #f80 - - .axis-b .name - color #0ff - - .axis-c .name - color #f0f - - td, th - padding 2px - white-space nowrap - border 1px solid #ddd - - th - text-align center - vertical-align bottom - - td - text-align right - font-family Courier - - .homed - background-color #ccffcc - color #000 - - .warn - background-color #ffffcc - - .error - background-color #ffcccc - - .axis - .name - text-transform capitalize - vertical-align middle - - .name, .position - font-size 24pt - line-height 24pt - - .position - width 99% - - td.state - text-align left - - .fa - margin-left 2px - margin-right 6px - - .absolute, .offset - min-width 6em - - td.tstate - text-align left - - .fa - margin-left 2px - margin-right 6px - - tr:nth-child(1) th.actions - text-align right - - .jog svg - text - user-select none - font-family Sans - font-weight bold - stroke transparent - fill #444 - - .button - cursor pointer - stroke #4c4c4c - - &:hover - stroke #e55 - - path - overflow visible - - .house - stroke #444 - fill #444 - - .ring - cursor pointer - overflow visible - - .button - stroke transparent - - &:hover - stroke #e55 - - text - font-size 8pt - text-anchor middle - - .info - empty-cells show - width 100% - display inline-block - - th, td - height 1.75em - padding 3px - text-align right - overflow hidden - text-overflow ellipsis - white-space nowrap - border 1px solid #ddd - - th - min-width 5.25em - width 5.25em - - td - min-width 8em - width 100% - - .units - padding 0 - - select - width 100% - height 1.9em - background-color transparent - border 0 - padding 3px - text-align right - - .eta - font-size 90% - - .progress - height 1.75em - - label - float right - - .bar - height 1.75em - background #f2ac45 - - .override - display none /* Hidden for now */ - margin 0.5em 0 - white-space nowrap - - label - font-weight bold - min-width 3.5em - display inline-block - - .percent - display inline-block - width 3em - - input - border-radius 0 - margin -0.4em 0.5em - - .override:nth-of-type(1) - clear left - float left - - .override:nth-of-type(2) - clear right - float right - - .toolbar - clear both - padding 0 0.125em - margin-bottom 50px - - > * - margin 0.25em 0.125em - - select - max-width 11em - min-width inherit !important - - .progress - display inline-block - background #fff - line-height 2em - border 1px solid #aaa - border-radius 3px - width 330px - vertical-align middle - text-align center - - div - height 2em - background #f2ac45 - - label - margin 0 0.125em - white-space nowrap - - .tabs - section - min-height 500px - overflow-x hidden - overflow-y auto - padding 0 - margin 0 - - .path-viewer - width 100% - - .path-viewer-content - height 500px - - .gcode, .history - font-family courier - clear both - overflow auto - width 100% - height 450px - white-space nowrap - - .clusterize-scroll - max-height 450px - - &.placeholder - color #aaa - - .history - padding 0.25em - - .gcode ul, .history ul - margin 0 - padding 0 - list-style none - - .gcode ul - li - line-height 15px - - li:nth-child(even) - background-color #fafafa - - li.highlight - background-color #eaeaea - - li > b - font-weight normal - display inline-block - padding 0 0.25em - color #e5aa3d - min-width 4em - - .history ul li - cursor pointer - - .mdi - clear both - white-space nowrap - padding 0.125em - display flex - - > * - margin 0.125em - - input - flex 2 - - .jog - text-align center - - > svg - margin 1em - - .jog-settings - margin-bottom 1em - - input - margin 0 0.5em - vertical-align middle - - .macros-div - display flex - flex-wrap wrap - justify-content flex-start - margin 10px - margin-left 50px - margin-right 50px - margin-bottom 30px - - .macros-button - height 60px - width 115px - font-weight normal - border-radius 10px - margin-left 1rem - margin-top 1rem - border 0 - overflow-wrap break-word - color #fff - box-shadow rgba(0, 0, 0, 0.3) 0px 0px 5px - text-shadow: rgba(0, 0, 0, 0.8) 0px 0px 3px +.app-head .estop:hover .button circle + fill #b72424 !important #macros width 104% @@ -990,66 +788,12 @@ tt.save background-color #f3f3f3 -.tabs +// Legacy `.tabs` selector retained only for #macros (Settings → Macros) +// which uses .tabs as a content container with a