Compare commits
1 Commits
master
...
fe362e10ab
| Author | SHA1 | Date | |
|---|---|---|---|
| fe362e10ab |
77
AGENTS.md
77
AGENTS.md
@@ -1,77 +0,0 @@
|
||||
# Onefinity firmware — agent guidelines
|
||||
|
||||
## Branch model
|
||||
|
||||
This fork lives on **two long-lived branches**:
|
||||
|
||||
- **`master`** — public-facing fork. General-use upgrades on top of
|
||||
upstream OneFinity firmware: V09 UX redesign, Font Awesome 6, faster
|
||||
cold boot, macOS dev/deploy tooling, build & flash docs, SD-card
|
||||
backup, `/api/diag/timing`, kiosk/tablet polish, and assorted
|
||||
bug-fixes. **No A-axis, ATC, hooks, or auxcnc/ESP content.** Aim for
|
||||
changes that benefit any Onefinity owner.
|
||||
|
||||
- **`private-mods`** — bespoke shop branch. Stacks on top of `master`
|
||||
and adds everything specific to the auxcnc-ESP-driven A axis and
|
||||
the ATC: `Hooks` (ATC IPC), `AuxAxis` (ESP serial driver),
|
||||
`ExternalAxis` (virtual A through gplan), `AuxPreprocessor` (M100-M103),
|
||||
Z-A coupling interlock, the A-axis UI surface, and the
|
||||
`/api/aux/*` endpoints.
|
||||
|
||||
Upstream:
|
||||
- `upstream` → `https://github.com/OneFinityCNC/onefinity-firmware.git`
|
||||
- `origin` → Gitea (`https://gitea.home.muehe.org/muehe/onefinity-firmware.git`)
|
||||
|
||||
`origin/pre-split-backup` is a tag preserving the pre-split master
|
||||
tip. Keep it indefinitely until further notice.
|
||||
|
||||
## Where does a change go?
|
||||
|
||||
| Change | Branch |
|
||||
|---|---|
|
||||
| UI polish, theme, layout that any user benefits from | `master` |
|
||||
| Build / install / boot performance | `master` |
|
||||
| Diagnostics, logging, generic Python / Tornado fixes | `master` |
|
||||
| Anything that touches `AuxAxis`, `ExternalAxis`, `Hooks`, `AuxPreprocessor` | `private-mods` |
|
||||
| Anything mentioning the auxcnc ESP, `/dev/ttyUSB0`, the M100-M103 ATC pneumatics, or motor index 4 | `private-mods` |
|
||||
| Z-A coupling interlock, ATC tool change sequencing | `private-mods` |
|
||||
| A-axis UI (DRO row, jog tile, settings page, A-axis routes) | `private-mods` |
|
||||
| W → A renames or aux.json migrations | `private-mods` |
|
||||
|
||||
When in doubt: ask "would this be useful on a stock Onefinity with no
|
||||
ESP attached?" If yes → `master`. If no → `private-mods`.
|
||||
|
||||
## Workflow
|
||||
|
||||
```bash
|
||||
# Day-to-day shop / hardware work (default)
|
||||
git checkout private-mods
|
||||
# … do work, commit …
|
||||
git push origin private-mods
|
||||
|
||||
# Generic improvement to master
|
||||
git checkout master
|
||||
# … do work, commit …
|
||||
git push origin master
|
||||
|
||||
# After landing on master, replay private-mods on top
|
||||
git checkout private-mods
|
||||
git rebase master
|
||||
git push --force-with-lease origin private-mods
|
||||
```
|
||||
|
||||
If a change accidentally lands on `master` but is bespoke (touches
|
||||
the file table above), move it: `git reset --hard <prev>` on master,
|
||||
cherry-pick onto `private-mods`, force-push master.
|
||||
|
||||
## Deploy
|
||||
|
||||
- `./deploy.sh local` — UI bundle on `localhost:8770` (tmux session
|
||||
`onefin-local`). No controller backend; A-axis row stays hidden.
|
||||
- `./deploy.sh hardware` — rsync to the Pi over SSH, restart
|
||||
`bbctrl.service`. Use the `private-mods` branch on the shop Pi.
|
||||
- `./deploy.sh prod` — bundle a release tarball.
|
||||
|
||||
See `.pi/BUILD.md` for the full build / flash / cross-compile flow.
|
||||
|
||||
## Commit before ending a turn; push after significant changes.
|
||||
@@ -71,6 +71,8 @@ class Ctrl(object):
|
||||
self.jog = bbctrl.Jog(self)
|
||||
with Trace.span('ctrl.pwr'):
|
||||
self.pwr = bbctrl.Pwr(self)
|
||||
with Trace.span('ctrl.hooks'):
|
||||
self.hooks = bbctrl.Hooks(self)
|
||||
|
||||
with Trace.span('ctrl.mach.connect'):
|
||||
self.mach.connect()
|
||||
|
||||
454
src/py/bbctrl/Hooks.py
Normal file
454
src/py/bbctrl/Hooks.py
Normal file
@@ -0,0 +1,454 @@
|
||||
################################################################################
|
||||
#
|
||||
# Hooks - External event triggers during G-code execution
|
||||
#
|
||||
# Integrates with the controller's pause/unpause cycle to run external
|
||||
# actions (webhooks, scripts) at specific points during G-code execution.
|
||||
#
|
||||
# ## How tool-change hooks work (the important one):
|
||||
#
|
||||
# G-code: T5 M6
|
||||
#
|
||||
# 1. Planner replaces M6 with tool-change override G-code (configurable).
|
||||
# Default: "M0 M6 (MSG, Change tool)"
|
||||
#
|
||||
# 2. Planner emits: set(tool,5), pause(program), message("Change tool")
|
||||
# These are sent to the AVR as serial commands.
|
||||
#
|
||||
# 3. AVR finishes current move, enters HOLDING state.
|
||||
# Reports back: xx=HOLDING, pr="Program pause"
|
||||
#
|
||||
# 4. Pi: Mach._update() sees HOLDING, flushes CommandQueue.
|
||||
# CommandQueue executes callbacks: state.set('tool', 5) fires.
|
||||
#
|
||||
# 5. Hooks._on_state_change() sees tool changed.
|
||||
# Sets self._hook_busy = True, runs the hook in a thread.
|
||||
# While _hook_busy, Mach.unpause() is blocked via can_unpause().
|
||||
#
|
||||
# 6. Machine sits in HOLDING. UI shows "Change tool" message.
|
||||
# User cannot resume yet (unpause is gated).
|
||||
#
|
||||
# 7. Hook thread finishes (toolchanger done). Sets _hook_busy = False.
|
||||
# If auto_resume is set, calls unpause automatically.
|
||||
# Otherwise user clicks Continue in UI.
|
||||
#
|
||||
# 8. Mach.unpause() → planner.restart() → AVR UNPAUSE → motion resumes.
|
||||
#
|
||||
# ## Configuration (hooks.json):
|
||||
#
|
||||
# {
|
||||
# "tool-change": {
|
||||
# "type": "webhook",
|
||||
# "url": "http://toolchanger.local/api/change",
|
||||
# "method": "POST",
|
||||
# "timeout": 120,
|
||||
# "block_unpause": true,
|
||||
# "auto_resume": true
|
||||
# },
|
||||
# "program-start": {
|
||||
# "type": "script",
|
||||
# "command": "/usr/local/bin/dust-collector on",
|
||||
# "block_unpause": false
|
||||
# }
|
||||
# }
|
||||
#
|
||||
# block_unpause: if true, unpause is blocked until hook completes
|
||||
# auto_resume: if true AND block_unpause, auto-unpause after hook done
|
||||
#
|
||||
################################################################################
|
||||
|
||||
import os
|
||||
import json
|
||||
import subprocess
|
||||
import threading
|
||||
import traceback
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.error import URLError
|
||||
|
||||
|
||||
# Events that can be hooked
|
||||
HOOK_EVENTS = [
|
||||
'tool-change', # M6 - tool change requested
|
||||
'program-start', # Program begins running
|
||||
'program-end', # M2/M30 - program ends
|
||||
'pause', # M0/M1 - program pause
|
||||
'estop', # Emergency stop triggered
|
||||
'homing-start', # Homing cycle begins
|
||||
'homing-end', # Homing cycle completes
|
||||
'custom', # Triggered by (MSG,HOOK:name:data) comments
|
||||
]
|
||||
|
||||
|
||||
class Hooks:
|
||||
def __init__(self, ctrl):
|
||||
self.ctrl = ctrl
|
||||
self.log = ctrl.log.get('Hooks')
|
||||
self.hooks = {}
|
||||
|
||||
# Hook execution state
|
||||
self._hook_busy = False # True while a blocking hook runs
|
||||
self._hook_busy_event = None # Which event is blocking
|
||||
self._hook_error = None # Error from last hook, if any
|
||||
self._hook_thread = None
|
||||
|
||||
# In-process hook handlers registered by Python modules. Keyed by
|
||||
# event name (matches what the G-code emits as HOOK:<event>).
|
||||
# Take precedence over hooks.json entries with the same name.
|
||||
self._internal = {}
|
||||
|
||||
# Track state for edge detection — must be set before add_listener
|
||||
# because add_listener fires immediately with current state
|
||||
self._last_cycle = ctrl.state.get('cycle', 'idle')
|
||||
self._last_state = ctrl.state.get('xx', '')
|
||||
self._last_tool = ctrl.state.get('tool', 0)
|
||||
self._last_pause_reason = ctrl.state.get('pr', '')
|
||||
# Highest message id we've already inspected for HOOK: lines.
|
||||
self._last_msg_id = -1
|
||||
self._initialized = False
|
||||
|
||||
self._load_config()
|
||||
|
||||
# Listen for state changes
|
||||
ctrl.state.add_listener(self._on_state_change)
|
||||
self._initialized = True
|
||||
|
||||
# -- Config management --
|
||||
|
||||
def _get_config_path(self):
|
||||
return self.ctrl.get_path(filename='hooks.json')
|
||||
|
||||
def _load_config(self):
|
||||
path = self._get_config_path()
|
||||
if os.path.exists(path):
|
||||
try:
|
||||
with open(path) as f:
|
||||
self.hooks = json.load(f)
|
||||
self.log.info('Loaded %d hook(s) from %s' %
|
||||
(len(self.hooks), path))
|
||||
except Exception:
|
||||
self.log.error('Failed to load hooks.json: %s' %
|
||||
traceback.format_exc())
|
||||
else:
|
||||
self.log.info('No hooks.json found, hooks disabled')
|
||||
|
||||
def save_config(self, config):
|
||||
"""Save hook configuration (called from API)."""
|
||||
path = self._get_config_path()
|
||||
with open(path, 'w') as f:
|
||||
json.dump(config, f, indent=2)
|
||||
self.hooks = config
|
||||
self.log.info('Saved %d hook(s)' % len(config))
|
||||
|
||||
def get_config(self):
|
||||
return self.hooks
|
||||
|
||||
# -- Unpause gating (called from Mach) --
|
||||
|
||||
def can_unpause(self):
|
||||
"""Returns True if no blocking hook is running.
|
||||
Called by Mach.unpause() to gate resume."""
|
||||
if self._hook_busy:
|
||||
self.log.info('Unpause blocked: hook "%s" still running' %
|
||||
self._hook_busy_event)
|
||||
return False
|
||||
return True
|
||||
|
||||
def get_status(self):
|
||||
"""Return current hook execution status for the UI."""
|
||||
return {
|
||||
'busy': self._hook_busy,
|
||||
'event': self._hook_busy_event,
|
||||
'error': self._hook_error,
|
||||
}
|
||||
|
||||
# -- State change listener --
|
||||
|
||||
def _on_state_change(self, update):
|
||||
"""Called on every state update from the controller."""
|
||||
if not self._initialized:
|
||||
return
|
||||
state = self.ctrl.state
|
||||
|
||||
# Detect tool change (tool number changed while HOLDING)
|
||||
if 'tool' in update:
|
||||
new_tool = update['tool']
|
||||
if new_tool != self._last_tool:
|
||||
self._fire('tool-change', {
|
||||
'old_tool': self._last_tool,
|
||||
'new_tool': new_tool,
|
||||
})
|
||||
self._last_tool = new_tool
|
||||
|
||||
# Detect cycle changes
|
||||
if 'cycle' in update:
|
||||
new_cycle = update['cycle']
|
||||
if new_cycle != self._last_cycle:
|
||||
if new_cycle == 'running' and self._last_cycle == 'idle':
|
||||
self._fire('program-start', {})
|
||||
elif new_cycle == 'idle' and self._last_cycle == 'running':
|
||||
self._fire('program-end', {})
|
||||
elif new_cycle == 'homing':
|
||||
self._fire('homing-start', {})
|
||||
elif self._last_cycle == 'homing' and new_cycle == 'idle':
|
||||
self._fire('homing-end', {})
|
||||
self._last_cycle = new_cycle
|
||||
|
||||
# Detect AVR state changes
|
||||
if 'xc' in update or 'xx' in update:
|
||||
new_state = state.get('xx', '')
|
||||
if new_state != self._last_state:
|
||||
if new_state == 'ESTOPPED':
|
||||
# Cancel any running hook on estop. The hook thread
|
||||
# cannot be killed from Python, but we can ask the
|
||||
# AuxAxis to send ABORT to the ESP so its in-flight
|
||||
# motion stops. Also drain the external-axis
|
||||
# worker queue so resume after clear doesn't replay
|
||||
# stale moves.
|
||||
try:
|
||||
ext = getattr(self.ctrl, 'ext_axis', None)
|
||||
if ext is not None:
|
||||
ext.abort()
|
||||
except Exception:
|
||||
pass
|
||||
if self._hook_busy:
|
||||
self.log.warning('E-stop: cancelling hook "%s"' %
|
||||
self._hook_busy_event)
|
||||
try:
|
||||
aux = getattr(self.ctrl, 'aux', None)
|
||||
if aux is not None:
|
||||
aux.abort()
|
||||
except Exception:
|
||||
pass
|
||||
self._hook_busy = False
|
||||
self._hook_busy_event = None
|
||||
self._fire('estop', {})
|
||||
self._last_state = new_state
|
||||
|
||||
# Detect pause
|
||||
if 'pr' in update:
|
||||
pr = update['pr']
|
||||
if pr and pr != self._last_pause_reason:
|
||||
self._fire('pause', {'reason': pr})
|
||||
self._last_pause_reason = pr
|
||||
|
||||
# Detect custom hook messages emitted via (MSG,HOOK:event_name:data)
|
||||
# gcode comments. State stores them as a list under 'messages'
|
||||
# ([{'id': N, 'text': '...'}, ...]); fire only on new ids.
|
||||
if 'messages' in update:
|
||||
msgs = update['messages']
|
||||
if isinstance(msgs, list):
|
||||
for m in msgs:
|
||||
try:
|
||||
mid = m.get('id', -1)
|
||||
text = m.get('text', '')
|
||||
except AttributeError:
|
||||
continue
|
||||
if mid <= self._last_msg_id:
|
||||
continue
|
||||
self._last_msg_id = mid
|
||||
if isinstance(text, str) and text.startswith('HOOK:'):
|
||||
parts = text[5:].split(':', 1)
|
||||
event = parts[0]
|
||||
data = parts[1] if len(parts) > 1 else ''
|
||||
self._fire('custom', {
|
||||
'event': event,
|
||||
'data': data,
|
||||
}, custom_name=event)
|
||||
|
||||
# -- Hook execution --
|
||||
|
||||
def dispatch_hook_message(self, text):
|
||||
"""Direct entry point for HOOK:<event>:<data> messages emitted
|
||||
by the planner via (MSG,HOOK:...) comments. Bypasses the
|
||||
state.messages list (which the UI also reads), so callers can
|
||||
suppress popup display without losing the hook dispatch.
|
||||
|
||||
Returns True if the text matched a HOOK: line and was
|
||||
dispatched, False otherwise."""
|
||||
if not isinstance(text, str) or not text.startswith('HOOK:'):
|
||||
return False
|
||||
parts = text[5:].split(':', 1)
|
||||
event = parts[0]
|
||||
data = parts[1] if len(parts) > 1 else ''
|
||||
self._fire('custom', {'event': event, 'data': data},
|
||||
custom_name=event)
|
||||
return True
|
||||
|
||||
def register_internal(self, name, fn, block_unpause=True,
|
||||
auto_resume=True, timeout=120):
|
||||
"""Register an in-process handler for HOOK:<name> events.
|
||||
|
||||
fn(context) -> None. May raise. Runs synchronously in the hook
|
||||
thread; while it runs and block_unpause=True, Mach.unpause is
|
||||
gated."""
|
||||
self._internal[name] = {
|
||||
'type': 'internal',
|
||||
'fn': fn,
|
||||
'block_unpause': block_unpause,
|
||||
'auto_resume': auto_resume,
|
||||
'timeout': timeout,
|
||||
}
|
||||
self.log.info('Registered internal hook: %s' % name)
|
||||
|
||||
def _fire(self, event, context, custom_name=None):
|
||||
"""Fire a hook event."""
|
||||
# Internal handlers win over hooks.json entries.
|
||||
hook = None
|
||||
if custom_name:
|
||||
hook = self._internal.get(custom_name)
|
||||
if not hook:
|
||||
hook = self._internal.get(event)
|
||||
if not hook:
|
||||
hook = self.hooks.get(event)
|
||||
if custom_name and not hook:
|
||||
hook = self.hooks.get(custom_name)
|
||||
if not hook:
|
||||
return
|
||||
|
||||
self.log.info('Hook firing: %s %s' % (event, json.dumps(context)))
|
||||
|
||||
# Add standard context
|
||||
state = self.ctrl.state
|
||||
context.update({
|
||||
'event': event,
|
||||
'position': (state.get_position()
|
||||
if hasattr(state, 'get_position') else {}),
|
||||
'state': state.get('xx', ''),
|
||||
'cycle': state.get('cycle', 'idle'),
|
||||
})
|
||||
|
||||
block_unpause = hook.get('block_unpause', event == 'tool-change')
|
||||
auto_resume = hook.get('auto_resume', False)
|
||||
|
||||
if block_unpause:
|
||||
# Run in thread, block unpause until done
|
||||
self._hook_busy = True
|
||||
self._hook_busy_event = event
|
||||
self._hook_error = None
|
||||
|
||||
# Update UI state so frontend knows we're busy
|
||||
self.ctrl.state.set('hook_busy', True)
|
||||
self.ctrl.state.set('hook_event', event)
|
||||
|
||||
self._hook_thread = threading.Thread(
|
||||
target=self._run_hook_blocking,
|
||||
args=(hook, event, context, auto_resume),
|
||||
daemon=True
|
||||
)
|
||||
self._hook_thread.start()
|
||||
else:
|
||||
# Fire and forget (non-blocking)
|
||||
self._execute_hook(hook, context)
|
||||
|
||||
def _run_hook_blocking(self, hook, event, context, auto_resume):
|
||||
"""Runs in a background thread. Blocks unpause until complete."""
|
||||
try:
|
||||
self._execute_hook(hook, context)
|
||||
self.log.info('Hook "%s" completed successfully' % event)
|
||||
except Exception as e:
|
||||
self._hook_error = str(e)
|
||||
self.log.error('Hook "%s" failed: %s' % (event, e))
|
||||
finally:
|
||||
self._hook_busy = False
|
||||
self._hook_busy_event = None
|
||||
|
||||
# Schedule UI update on the ioloop thread
|
||||
self.ctrl.ioloop.call_later(0, self._hook_finished, auto_resume)
|
||||
|
||||
def _hook_finished(self, auto_resume):
|
||||
"""Called on the ioloop after a blocking hook completes."""
|
||||
self.ctrl.state.set('hook_busy', False)
|
||||
self.ctrl.state.set('hook_event', '')
|
||||
|
||||
if self._hook_error:
|
||||
self.ctrl.state.set('hook_error', self._hook_error)
|
||||
self.log.error('Hook error: %s' % self._hook_error)
|
||||
else:
|
||||
self.ctrl.state.set('hook_error', '')
|
||||
|
||||
if auto_resume and not self._hook_error:
|
||||
self.log.info('Hook done, auto-resuming')
|
||||
try:
|
||||
self.ctrl.mach.unpause()
|
||||
except Exception as e:
|
||||
self.log.error('Auto-resume failed: %s' % e)
|
||||
|
||||
def _execute_hook(self, hook, context):
|
||||
"""Execute a single hook (webhook, script, or internal). May block."""
|
||||
hook_type = hook.get('type', 'webhook')
|
||||
|
||||
if hook_type == 'webhook':
|
||||
self._fire_webhook(hook, context)
|
||||
elif hook_type == 'script':
|
||||
self._fire_script(hook, context)
|
||||
elif hook_type == 'internal':
|
||||
fn = hook.get('fn')
|
||||
if fn is None:
|
||||
raise Exception('Internal hook missing fn')
|
||||
fn(context)
|
||||
else:
|
||||
raise Exception('Unknown hook type: %s' % hook_type)
|
||||
|
||||
def _fire_webhook(self, hook, context):
|
||||
"""Fire a webhook HTTP request."""
|
||||
url = hook.get('url')
|
||||
if not url:
|
||||
raise Exception('Webhook missing url')
|
||||
|
||||
method = hook.get('method', 'POST').upper()
|
||||
timeout = hook.get('timeout', 30)
|
||||
headers = dict(hook.get('headers', {}))
|
||||
body = dict(hook.get('body', {}))
|
||||
|
||||
# Merge context into body
|
||||
body['_context'] = context
|
||||
|
||||
data = json.dumps(body).encode('utf-8')
|
||||
headers['Content-Type'] = 'application/json'
|
||||
|
||||
req = Request(url, data=data, headers=headers, method=method)
|
||||
self.log.info('Webhook %s %s' % (method, url))
|
||||
|
||||
resp = urlopen(req, timeout=timeout)
|
||||
self.log.info('Webhook response: %d' % resp.status)
|
||||
|
||||
if resp.status >= 400:
|
||||
raise Exception('Webhook returned %d' % resp.status)
|
||||
|
||||
def _fire_script(self, hook, context):
|
||||
"""Fire a local script/command. Blocks until complete."""
|
||||
command = hook.get('command')
|
||||
if not command:
|
||||
raise Exception('Script hook missing command')
|
||||
|
||||
timeout = hook.get('timeout', 120)
|
||||
|
||||
# Pass context as environment variables
|
||||
env = os.environ.copy()
|
||||
env['HOOK_EVENT'] = context.get('event', '')
|
||||
env['HOOK_STATE'] = context.get('state', '')
|
||||
env['HOOK_CYCLE'] = context.get('cycle', '')
|
||||
env['HOOK_DATA'] = json.dumps(context)
|
||||
|
||||
if 'old_tool' in context:
|
||||
env['HOOK_OLD_TOOL'] = str(context['old_tool'])
|
||||
if 'new_tool' in context:
|
||||
env['HOOK_NEW_TOOL'] = str(context['new_tool'])
|
||||
|
||||
self.log.info('Script: %s' % command)
|
||||
|
||||
result = subprocess.run(
|
||||
command, shell=True, env=env,
|
||||
timeout=timeout,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE
|
||||
)
|
||||
|
||||
stdout = result.stdout.decode('utf-8', errors='replace').strip()
|
||||
stderr = result.stderr.decode('utf-8', errors='replace').strip()
|
||||
|
||||
if stdout:
|
||||
self.log.info('Script stdout: %s' % stdout)
|
||||
|
||||
if result.returncode != 0:
|
||||
raise Exception('Script failed (%d): %s' %
|
||||
(result.returncode, stderr or 'non-zero exit'))
|
||||
@@ -196,12 +196,23 @@ class Planner():
|
||||
|
||||
|
||||
def _add_message(self, text):
|
||||
self.ctrl.state.add_message(text)
|
||||
|
||||
line = self.ctrl.state.get('line', 0)
|
||||
if 0 <= line: where = '%s:%d' % (self.where, line)
|
||||
else: where = self.where
|
||||
|
||||
# HOOK:<event>:<data> messages are an internal IPC channel
|
||||
# between the gcode preprocessor and Hooks; bypass the user
|
||||
# message list so they don't surface as popups, and dispatch
|
||||
# the hook directly. Routing through state.messages would
|
||||
# only deliver it after the 0.25s state-change debounce, by
|
||||
# which point we'd have to keep it visible to ensure Hooks
|
||||
# could see it.
|
||||
hooks = getattr(self.ctrl, 'hooks', None)
|
||||
if hooks is not None and hooks.dispatch_hook_message(text):
|
||||
self.log.info('HOOK msg: %s' % text, where = where)
|
||||
return
|
||||
|
||||
self.ctrl.state.add_message(text)
|
||||
self.log.message(text, where = where)
|
||||
|
||||
|
||||
@@ -259,6 +270,13 @@ class Planner():
|
||||
if type != 'set': self.log.info('Cmd:' + log_json(block))
|
||||
|
||||
if type == 'line':
|
||||
ext = self._external_axis_for_line(block)
|
||||
if ext is not None:
|
||||
# Side effect: enqueue the ESP move on the external-
|
||||
# axis worker. The AVR still receives the full target
|
||||
# (including A) so ex.position[A] tracks gplan; no
|
||||
# motor steps for A because no motor maps to it.
|
||||
self._dispatch_external_line(block, ext)
|
||||
self._enqueue_line_time(block)
|
||||
return Cmd.line(block['target'], block['exit-vel'],
|
||||
block['max-accel'], block['max-jerk'],
|
||||
@@ -289,8 +307,17 @@ class Planner():
|
||||
|
||||
if name[2:] == '_homed':
|
||||
motor = self.ctrl.state.find_motor(name[1])
|
||||
if motor is not None:
|
||||
# Synthetic external motor (index 4) doesn't exist
|
||||
# on the AVR; mirror the homed flag in State only.
|
||||
from bbctrl.ExternalAxis import EXTERNAL_MOTOR_INDEX
|
||||
if motor is not None and motor < EXTERNAL_MOTOR_INDEX:
|
||||
return Cmd.set_sync('%dh' % motor, value)
|
||||
if motor == EXTERNAL_MOTOR_INDEX:
|
||||
# Update synthetic motor flag and the<axis>_homed
|
||||
# projection consumed by the DRO.
|
||||
self.cmdq.enqueue(
|
||||
id, self.ctrl.state.set,
|
||||
'%dh' % EXTERNAL_MOTOR_INDEX, value)
|
||||
|
||||
return
|
||||
|
||||
@@ -339,6 +366,68 @@ class Planner():
|
||||
self.planner.set_logger(None)
|
||||
|
||||
|
||||
# ----------------------------------------------- external-axis routing
|
||||
#
|
||||
# When an axis is exposed to gplan via a synthetic motor (no AVR
|
||||
# channel), we need to fork its motion off to the ESP at line
|
||||
# encode time and let the rest of the line proceed to the AVR.
|
||||
# The split is done here rather than in gplan because gplan
|
||||
# treats all six axes uniformly and just emits target dicts; we
|
||||
# don't want to teach it about the ESP.
|
||||
|
||||
def _external_axis_for_line(self, block):
|
||||
"""Return the ExternalAxis instance for whichever axis in
|
||||
block['target'] is external, or None."""
|
||||
ext = getattr(self.ctrl, 'ext_axis', None)
|
||||
if ext is None or not ext.enabled:
|
||||
return None
|
||||
target = block.get('target') or {}
|
||||
if ext.axis_letter in target or ext.axis_letter.upper() in target:
|
||||
return ext
|
||||
return None
|
||||
|
||||
def _dispatch_external_line(self, block, ext):
|
||||
"""Side-effect: enqueue the ESP move on the external-axis
|
||||
worker thread (non-blocking). Returns the block (possibly
|
||||
unchanged) for the AVR.
|
||||
|
||||
We do NOT strip the external axis target from the AVR line.
|
||||
The AVR's exec_move_to_target updates ex.position[axis] for
|
||||
every axis in the target dict regardless of motor mapping,
|
||||
and reports it back via the `p` indexed var. Leaving A in
|
||||
the target keeps state.ap in sync with gplan's idea of A
|
||||
(otherwise the AVR's stale ex.position[A] would clobber
|
||||
ExternalAxis's state.ap=N update on the next status report).
|
||||
|
||||
The AVR doesn't step any motor for the external axis (no
|
||||
motor maps to it) - so leaving A in the target is
|
||||
physically a no-op for the steppers, while keeping the
|
||||
host-side state coherent.
|
||||
|
||||
We pass the full S-curve parameters to the ESP so its move
|
||||
duration matches the AVR's exactly. The ESP runs the same
|
||||
7-segment jerk-limited trajectory the AVR would have run
|
||||
if A had been a real motor."""
|
||||
target = block.get('target') or {}
|
||||
# Read the external target (case-insensitive) without modifying
|
||||
# the dict so the AVR still sees A.
|
||||
ext_mm = target.get(ext.axis_letter)
|
||||
if ext_mm is None:
|
||||
ext_mm = target.get(ext.axis_letter.upper())
|
||||
try:
|
||||
ext.enqueue_line(
|
||||
ext_mm,
|
||||
block.get('max-accel', 0.0),
|
||||
block.get('max-jerk', 0.0),
|
||||
block.get('entry-vel', 0.0),
|
||||
block.get('exit-vel', 0.0),
|
||||
block.get('times', [0]*7),
|
||||
)
|
||||
except Exception as e:
|
||||
self.log.error('External axis enqueue failed: %s' % e)
|
||||
raise
|
||||
return block
|
||||
|
||||
def reset(self, *args, **kwargs):
|
||||
stop = kwargs.get('stop', True)
|
||||
if stop:
|
||||
@@ -352,6 +441,16 @@ class Planner():
|
||||
self.cmdq.clear()
|
||||
self.reset_times()
|
||||
|
||||
# Drain the external-axis worker queue and force the next
|
||||
# move to re-sync position from the ESP (since State.reset
|
||||
# below will zero <axis>p which makes ext._pos_mm stale).
|
||||
ext = getattr(self.ctrl, 'ext_axis', None)
|
||||
if ext is not None:
|
||||
try: ext.abort()
|
||||
except Exception: pass
|
||||
try: ext._pos_mm = None
|
||||
except Exception: pass
|
||||
|
||||
resetState = kwargs.get('resetState', True)
|
||||
if resetState:
|
||||
self.ctrl.state.reset()
|
||||
@@ -369,6 +468,19 @@ class Planner():
|
||||
self.where = path
|
||||
path = self.ctrl.get_path('upload', path)
|
||||
self.log.info('GCode:' + path)
|
||||
# Rewrite ATC M-codes (M100..M103) before gplan sees them.
|
||||
# preprocess_file is a no-op when no rewriting is needed and
|
||||
# idempotent when run twice on the same file, so this is
|
||||
# safe on every load. W tokens are no longer rewritten - the
|
||||
# auxcnc stepper is now exposed as a virtual A axis and gcode
|
||||
# should use A directly.
|
||||
try:
|
||||
from bbctrl.AuxPreprocessor import preprocess_file
|
||||
if preprocess_file(path, log = self.log):
|
||||
self.log.info('Rewrote ATC M-codes in %s' % path)
|
||||
except Exception:
|
||||
self.log.exception('Aux preprocess at load failed; '
|
||||
'attempting to load file unchanged')
|
||||
self._sync_position()
|
||||
self.planner.load(path, self.get_config(False, True))
|
||||
self.reset_times()
|
||||
|
||||
@@ -766,6 +766,27 @@ class RotaryHandler(bbctrl.APIHandler):
|
||||
log.error('Unexpected error: {}'.format(e))
|
||||
|
||||
|
||||
class HooksGetHandler(bbctrl.APIHandler):
|
||||
def get(self):
|
||||
self.write_json(self.get_ctrl().hooks.get_config())
|
||||
|
||||
|
||||
class HooksSaveHandler(bbctrl.APIHandler):
|
||||
def put_ok(self):
|
||||
self.get_ctrl().hooks.save_config(self.json)
|
||||
|
||||
|
||||
class HooksStatusHandler(bbctrl.APIHandler):
|
||||
def get(self):
|
||||
self.write_json(self.get_ctrl().hooks.get_status())
|
||||
|
||||
|
||||
class HooksFireHandler(bbctrl.APIHandler):
|
||||
def put_ok(self, event):
|
||||
data = self.json if hasattr(self, 'json') and self.json else {}
|
||||
self.get_ctrl().hooks._fire(event, data)
|
||||
|
||||
|
||||
class RemoteDiagnosticsHandler(bbctrl.APIHandler):
|
||||
|
||||
def get(self):
|
||||
@@ -798,7 +819,6 @@ class RemoteDiagnosticsHandler(bbctrl.APIHandler):
|
||||
'message': e.reason or "Unknown"
|
||||
})
|
||||
|
||||
|
||||
class TimingHandler(bbctrl.APIHandler):
|
||||
"""Return the bbctrl process startup timeline as JSON.
|
||||
|
||||
@@ -992,6 +1012,10 @@ class Web(tornado.web.Application):
|
||||
(r'/api/time', TimeHandler),
|
||||
(r'/api/rotary', RotaryHandler),
|
||||
(r'/api/remote-diagnostics', RemoteDiagnosticsHandler),
|
||||
(r'/api/hooks', HooksGetHandler),
|
||||
(r'/api/hooks/save', HooksSaveHandler),
|
||||
(r'/api/hooks/status', HooksStatusHandler),
|
||||
(r'/api/hooks/fire/([\w-]+)', HooksFireHandler),
|
||||
(r'/(.*)', StaticFileHandler,
|
||||
{'path': bbctrl.get_resource('http/'),
|
||||
'default_filename': 'index.html'}),
|
||||
|
||||
@@ -66,6 +66,7 @@ from bbctrl.AVR import AVR
|
||||
from bbctrl.AVREmu import AVREmu
|
||||
from bbctrl.IOLoop import IOLoop
|
||||
from bbctrl.MonitorTemp import MonitorTemp
|
||||
from bbctrl.Hooks import Hooks
|
||||
import bbctrl.Cmd as Cmd
|
||||
import bbctrl.v4l2 as v4l2
|
||||
import bbctrl.Log as log
|
||||
|
||||
Reference in New Issue
Block a user