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.
This commit is contained in:
@@ -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:<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()
|
||||
@@ -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:<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."""
|
||||
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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user