AuxPreprocessor: stop mutating the macro source; use a tempfile

Macros and uploaded jobs now pass through gplan untouched on disk.
The (MSG,HOOK:...) substitution that lets the host react to ATC
M-codes mid-program now lands in a tempfile that gplan loads instead
of the operator-authored source.

Why we still rewrite at all: gplan (camotics planner) treats
M100/M102/M103 as no-ops by spec and doesn't expose a callback for
user-defined M-codes. Its only in-band channel back to Python during
a running program is the (MSG,...) message stream, so we substitute
hook messages for those M-codes purely as transport. That mechanism
is fine; what was broken was that we wrote the substitution back
over the macro source. So:

- The macro editor opened drop.nc and saw (MSG,HOOK:...) blobs
  instead of the M100/M102/M103 sequence.
- Re-running compounded any rewrite quirk (paren-comment handling,
  consecutive HOOK lines collapsing) on every load.
- Editing a macro accidentally re-rewrote its already-rewritten
  form.

Now:

- AuxPreprocessor.preprocess_to_tempfile() returns a path to a
  rewritten temp file; the source is never modified. The old
  preprocess_file() in-place wrapper is kept (deprecated) for
  the upload path, where mutating the saved upload is fine.
- Planner.load() goes through preprocess_to_tempfile and tracks
  the temp path on the Planner instance, deleting the previous
  tempfile on each new load() so /tmp doesn't fill up.
- Each rewritten (MSG,HOOK:...) line gets a tiny G4 P0.001
  dwell prefix so gplan doesn't collapse consecutive comment-
  only lines into a single block (which was eating all but the
  last hook in a sequence). The dwell appears only in the
  tempfile, never in the source.

Macros on the controller (drop.nc, grab.nc, release.nc, clamp.nc)
restored to the human-readable M100/M102/M103 form.
This commit is contained in:
2026-05-03 18:32:12 +02:00
parent d5ad717f78
commit 692be42f84
2 changed files with 106 additions and 24 deletions

View File

@@ -394,7 +394,22 @@ class AuxPreprocessor(object):
except ValueError: continue except ValueError: continue
event = _ATC_M_CODES.get(num) event = _ATC_M_CODES.get(num)
if event: if event:
fout.write('(MSG,HOOK:%s:)\n' % event) # gplan only delivers `(MSG,...)` to the
# message stream when it's attached to an
# executable block (so the host can release
# it on the matching cmd-id ack). A bare
# comment-only line gets collapsed and the
# message is silently dropped, which means
# back-to-back hook lines (like 4 ejects in
# a row) only deliver the last one.
#
# Pair each HOOK line with an essentially-
# zero dwell so it gets a planner block id
# of its own. G4 P0.001 = 1us dwell which
# is below any timer resolution and has no
# observable effect on the machine.
fout.write('G4 P0.001 (MSG,HOOK:%s:)\n'
% event)
code_stripped = _ATC_M_RE.sub('', code).strip() code_stripped = _ATC_M_RE.sub('', code).strip()
if code_stripped: if code_stripped:
# Mixed line: keep the residual executable # Mixed line: keep the residual executable
@@ -410,16 +425,26 @@ class AuxPreprocessor(object):
return rewrote_any return rewrote_any
def preprocess_file(src_path, log=None, coupling=None, **_unused): def preprocess_to_tempfile(src_path, log=None, coupling=None):
"""Convenience: rewrite src_path in place if it contains ATC """Run the preprocessor on `src_path` and return the path to a
M-codes or needs Z-A coupling injection. Returns True if the rewritten temp file (or None if no rewriting was needed). Caller
file was rewritten. owns the temp file and must os.unlink() it when done.
`coupling` is an optional dict (see AuxPreprocessor.__init__). The original source file is never modified - this is the
Extra keyword args are accepted for backwards compat (the old intentional design: the macro / job file the operator authored
w_first arg is no longer used).""" is what they see in the macro editor and the file viewer; the
rewriting happens only on the in-memory copy that gplan loads.
Why we rewrite at all: gplan (the camotics planner) treats the
user-defined M-codes M100/M102/M103 as no-ops. The only callback
channel it exposes during a running program is the (MSG,...)
message stream, so the only way for the host to react to those
M-codes mid-program is to substitute (MSG,HOOK:<event>:) lines
in their place. This rewriting is an implementation detail the
operator should never have to know about - hence the tempfile.
"""
if not AuxPreprocessor.file_uses_aux(src_path, coupling=coupling): if not AuxPreprocessor.file_uses_aux(src_path, coupling=coupling):
return False return None
pre = AuxPreprocessor(log=log, coupling=coupling) pre = AuxPreprocessor(log=log, coupling=coupling)
fd, tmp = tempfile.mkstemp(prefix='auxpre_', suffix='.nc', fd, tmp = tempfile.mkstemp(prefix='auxpre_', suffix='.nc',
dir=os.path.dirname(src_path) or None) dir=os.path.dirname(src_path) or None)
@@ -427,13 +452,36 @@ def preprocess_file(src_path, log=None, coupling=None, **_unused):
try: try:
rewrote = pre.process(src_path, tmp) rewrote = pre.process(src_path, tmp)
if rewrote: if rewrote:
shutil.move(tmp, src_path) return tmp
return True
os.unlink(tmp) os.unlink(tmp)
return False return None
except Exception: except Exception:
try: try:
os.unlink(tmp) os.unlink(tmp)
except OSError: except OSError:
pass pass
raise raise
def preprocess_file(src_path, log=None, coupling=None, **_unused):
"""DEPRECATED in-place version of the preprocessor. Kept for
callers that still rewrite their input on disk (chiefly the
upload path, where mutating the file is fine because there's no
operator-authored source to preserve).
Returns True if the file was rewritten, False otherwise.
For new callers prefer preprocess_to_tempfile() which never
touches the source."""
tmp = preprocess_to_tempfile(src_path, log=log, coupling=coupling)
if tmp is None:
return False
try:
shutil.move(tmp, src_path)
except Exception:
try:
os.unlink(tmp)
except OSError:
pass
raise
return True

View File

@@ -27,6 +27,7 @@
import json import json
import math import math
import os
import re import re
import time import time
from collections import deque from collections import deque
@@ -76,6 +77,10 @@ class Planner():
self.planner = None self.planner = None
self._position_dirty = False self._position_dirty = False
self.where = '' self.where = ''
# Tracks the rewritten temp file (if any) returned by the
# AuxPreprocessor for the currently-loaded program. We delete
# it on the next load() so it doesn't pile up under /tmp.
self._aux_tempfile = None
ctrl.state.add_listener(self._update) ctrl.state.add_listener(self._update)
@@ -507,28 +512,57 @@ class Planner():
def load(self, path): def load(self, path):
self.where = path self.where = path
path = self.ctrl.get_path('upload', path) src_path = self.ctrl.get_path('upload', path)
self.log.info('GCode:' + path) self.log.info('GCode:' + src_path)
# Rewrite ATC M-codes (M100..M103) before gplan sees them.
# preprocess_file is a no-op when no rewriting is needed and # Clean up any leftover temp file from a previous load.
# idempotent when run twice on the same file, so this is self._cleanup_aux_tempfile()
# safe on every load. W tokens are no longer rewritten - the
# auxcnc stepper is now exposed as a virtual A axis and gcode # Rewrite ATC M-codes (M100/M102/M103) and inject Z-A
# should use A directly. # coupling moves before gplan sees them. The rewriting goes
# to a temp file -- the operator's macro / job source is
# never modified. This matters because:
#
# 1. The macro editor reads back the source. If we
# rewrote in place, the operator would open `drop.nc`
# and see (MSG,HOOK:...) blobs instead of the M-code
# sequence they wrote.
# 2. Re-running a rewritten file would re-rewrite it; any
# bug in the regex (e.g. with paren comments) would
# compound on every load.
#
# Why we rewrite at all: gplan treats M100..M103 as no-ops
# by spec and exposes no callback for user M-codes. Its only
# in-band channel back to Python during a running program is
# the (MSG,...) message stream, so we substitute hook
# messages for the M-codes purely as transport.
load_path = src_path
try: try:
from bbctrl.AuxPreprocessor import preprocess_file from bbctrl.AuxPreprocessor import preprocess_to_tempfile
ext = getattr(self.ctrl, 'ext_axis', None) ext = getattr(self.ctrl, 'ext_axis', None)
coupling = (ext.coupling_for_preprocessor() coupling = (ext.coupling_for_preprocessor()
if ext is not None else None) if ext is not None else None)
if preprocess_file(path, log=self.log, coupling=coupling): tmp = preprocess_to_tempfile(
self.log.info('Rewrote (ATC / Z-A coupling) in %s' % path) src_path, log=self.log, coupling=coupling)
if tmp is not None:
self._aux_tempfile = tmp
load_path = tmp
self.log.info(
'Rewrote (ATC / Z-A coupling) for gplan: %s -> %s'
% (src_path, tmp))
except Exception: except Exception:
self.log.exception('Aux preprocess at load failed; ' self.log.exception('Aux preprocess at load failed; '
'attempting to load file unchanged') 'attempting to load file unchanged')
self._sync_position() self._sync_position()
self.planner.load(path, self.get_config(False, True)) self.planner.load(load_path, self.get_config(False, True))
self.reset_times() self.reset_times()
def _cleanup_aux_tempfile(self):
if self._aux_tempfile and os.path.exists(self._aux_tempfile):
try: os.unlink(self._aux_tempfile)
except OSError: pass
self._aux_tempfile = None
def stop(self): def stop(self):
try: try: